Which confirmed pivot prices form repeated level neighborhoods rather than isolated touches? That is the practical question behind this tutorial. Density clustering turns repeated confirmed pivot prices into inspectable level neighborhoods without forcing every pivot into a level or preselecting the number of levels.
The goal is not to make a persuasive chart. It is to build an object whose definition, clocks, parameters, invalid states, and diagnostics survive code review and point-in-time testing.
The useful output before the implementation
Use levels as descriptive zones, chart annotations, feature inputs, or audit groups. Do not call one touch a level, force all pivots into a cluster, or describe a historical density as a guaranteed barrier.
The selected package variant is Same-kind one-dimensional DBSCAN in log-price space, median cluster level, and confirmation-time availability. A different price field, threshold unit, equality rule, seed, window, or repainting policy defines a different algorithm. Record the convention with every result.
The visual separates where geometry occurred from when the required evidence arrived. That distinction is the family’s central protection against hindsight.
Inputs, ownership, and the rejection boundary
| Field | Type | Meaning | Required policy |
|---|---|---|---|
| kind | high or low | resistance or support candidate | cluster kinds separately |
| event_index | integer at least 0 | pivot geometry time | unique within kind |
| confirmation_index | integer | first knowledge time | at least event_index and no later than caller cutoff |
| price | finite positive number | confirmed pivot price | single instrument and adjustment basis |
| eps_bps | finite positive number | log-ratio neighborhood radius | converted with log1p |
| min_touches | integer | core-neighborhood count including self | at least 2 |
Synthetic fixtures are clearly labeled and use one instrument basis. Production data must additionally carry source ownership, session, time zone, adjustment basis, and finality. Missing, malformed, mixed-basis, future-dated, or revised inputs are not silently repaired.
Build intuition before notation
A level is a neighborhood of repeated confirmed pivots, not a single pixel-perfect price. Density clustering allows noise instead of forcing every pivot into a level. Epsilon limits one neighborhood step, not the total lower-to-upper cluster width. Core points can connect through a chain of overlapping neighborhoods. A border point may join a cluster without having enough neighbors to start one.
The nearby methods below can all be reasonable, but their outputs are not interchangeable.
| Variant | Definition | Best use | Limitation |
|---|---|---|---|
| This package | same-kind 1-D DBSCAN in log price | unknown number of horizontal levels | one epsilon is not a cluster-span cap |
| Price-bin grouping | fixed or rounded price buckets | simple reporting | bucket edges create discontinuities |
| K-means | forces a chosen number of centroids | known cluster count | forces noise into a cluster |
| Time-decayed density | weights or removes old pivots | changing regimes | adds a decay/window convention |
The complete mathematical contract
| Symbol | Meaning | Unit |
|---|---|---|
| p_i | confirmed pivot price | positive price units |
| epsilon_bps | ratio-distance setting | basis points |
| delta | exact log-distance radius | log-price |
| m | minimum touches including self | count |
| C | density-connected same-kind cluster | set of pivots |
Plain-language interpretation: every output is the consequence of a declared comparison. Strict and inclusive boundaries, threshold transforms, clock cutoffs, and null results are part of the method, not cleanup details.
Reproduce the canonical trace
These are author-derived calculations over synthetic input:
| Index or object | Value | State | Result |
|---|---|---|---|
| 100.00 | 100.00, 100.30, 99.90 | core | three neighbors including self |
| 100.30 | 100.00, 100.30, 99.90 | core | within exact 50 bps log radius |
| 99.90 | 100.00, 100.30, 99.90 | core | joins the same density component |
| 105.00 | 105.00 only | noise | fewer than three neighbors |
| cluster | 99.90 to 100.30 | level 100.00 | median member price; bounds are observed, not predictive |
Suppose confirmed resistance pivots are 100.00, 100.30, 99.90, and 105.00. With 50 bps and three touches, the first three lie within a connected log-price neighborhood and form a cluster with median level 100.00. The 105.00 pivot is noise. Epsilon is a neighborhood rule: chained members can make a cluster's total lower-to-upper span wider than epsilon.
Work through the trace without calling the implementation. Independent arithmetic is the only way to catch a program that is consistently wrong in both languages.
From data to a publishable object
The diagram has one deliberate shape: validate, calculate only from available evidence, publish diagnostics, and preserve the rejection branch.
The algorithm proceeds as follows:
- Consume only pivots whose confirmation index is no later than the current knowledge index.
- Separate high pivots from low pivots before clustering.
- Transform positive prices with the natural logarithm.
- Expand inclusive epsilon neighborhoods with at least min_touches members including the point itself.
- Publish median level, bounds, touches, confirmation range, members, and noise.
The pseudocode and implementations preserve this ordering so a reconciliation log can identify the first divergent step.
What must remain true
- High and low pivots never share one cluster.
- Every core-neighborhood comparison includes equality at epsilon.
- Minimum touches includes the point itself.
- Noise remains explicit and can later become border or core when new pivots arrive.
- Cluster bounds are observed member extrema, not confidence or prediction intervals.
These properties are more useful than a screenshot test. They describe behavior across canonical, boundary, failure, and revised inputs.
See the decision boundary move
Start with sparse noise, lower min touches, then widen epsilon. Inspect whether a new cluster appears because a point became core or because two neighborhoods became density connected. A changed answer after a parameter change is not automatically a bug; an undocumented parameter change is.
Eight experiments instead of one perfect chart
| Experiment | Question to answer |
|---|---|
| Clean alternating swings | A clean control for confirmation latency and alternating structure. |
| Plateau and equality boundaries | Equal extrema expose the selected earliest-plateau and strict-update policies. |
| Noisy micro-swings | Noise reveals sensitivity to span, reversal, density, and residual thresholds. |
| Repeated level retests | Repeated highs and lows create inspectable support and resistance density. |
| Trend with one gross outlier | One extreme observation distinguishes ordinary fitting from consensus fitting. |
| Level and slope regime shift | A structural shift exposes stale clusters, provisional pivots, and window dependence. |
| DBSCAN density chain | Neighbor-to-neighbor reachability can create a level wider than one epsilon radius. |
| Sparse pivots and explicit noise | Isolated confirmed pivots remain noise instead of being forced into a level. |
Each scenario contains 300 observations and eight checkpoints. The lab starts at an informative state, hides future observations, exposes the current formula trace, and lets you change the parameters without deleting the full fixture.
Open the Support/Resistance Clustering guided lab.
Use the lab in four passes:
- Predict the next state before pressing Step.
- Compare the event index with the knowledge index.
- Move one parameter across its boundary and explain the changed branch.
- Switch to the failure scenario and identify why “no result” is correct.
Where implementations fail
- A point exactly epsilon away is a neighbor.
- Border points can join a cluster without being core points.
- Density reachability can chain across a wider total range than epsilon.
- Cluster labels and levels may change as newly confirmed pivots connect neighborhoods.
- Corporate-action basis changes can fabricate levels unless every input uses one consistent basis.
The principal misuse is Calling every isolated pivot a level or presenting a dense historical neighborhood as a guaranteed future barrier. Detection correctness is separate from historical association, and historical association is separate from value after costs, bias controls, and out-of-sample testing.
Python, TypeScript, and reconciliation
The Python and TypeScript references consume the same declared semantics.
Tests cover the canonical trace, exact boundaries, invalid states, temporal
ordering, deterministic choices, and the enriched fixture shape. Numeric
results use 1e-9; kinds, indexes, memberships, nulls, and rejection behavior
are exact.
When production implementations disagree, compare source identity and adjustment basis first, parameters second, and the earliest intermediate state third. Comparing only final chart lines hides the actual defect.
How the family fits together
Causal pivots provide honest extrema. ZigZag provides alternating swing compression. Support/resistance clustering provides horizontal density. Robust trendline fitting provides sloped consensus. Each stage answers a different question and must preserve upstream confirmation time.
The next tutorial is Robust Trendline Fitting.
Evidence and historical boundary
The historical-example decision is not useful. The synthetic paths isolate the mechanism more cleanly than a selected security chart. A named case would require licensed point-in-time OHLC, verified identity, session and adjustment basis, frozen parameters, finality, and a separate empirical design.
This tutorial establishes implementation agreement and intended detection. It does not establish prediction, causation, profitability, or investment suitability.
Primary sources
- Original DBSCAN paper — density reachability and explicit noise.
- scikit-learn DBSCAN 1.9.0 — inclusive eps and min_samples including self.
- TradingView repainting — upstream pivot knowledge-time boundary.
The canonical README, reference record, and claim ledger contain the complete definition and evidence classifications.
Rendered from the canonical Mermaid sources linked by this article.
Support/Resistance Clustering calculation flow
Purpose: preserve validation, causal availability, decision branches, and diagnostics in the order used by the implementation.
Takeaway: a rejected or unavailable result is part of the output contract; it must not be filled with retrospective geometry.
ReferencesPrimary sources and evidence notesExpand the source trail, evidence role, and limitations behind the engineering choices.
Expand the source trail, evidence role, and limitations behind the engineering choices.
DBSCAN_PAPER — A Density-Based Algorithm for Discovering Clusters in Large Spatial Databases with Noise
- Organization or authors: Martin Ester, Hans-Peter Kriegel, Jorg Sander, Xiaowei Xu
- Source type: Original KDD-96 paper
- Publication or effective date: 1996
- Version: KDD-96 original paper
- URL: https://file.biolab.si/papers/1996-DBSCAN-KDD.pdf
- Accessed: 2026-07-27
- Jurisdiction: technical; not jurisdiction-specific
- Supports: Density reachability, neighborhood radius, minimum density, arbitrary-shaped clusters, and explicit noise.
- Limitations: The paper is not about financial levels; applying DBSCAN to confirmed pivots is a package design.
- Evidence role: sourced algorithm or maintained-library behavior; the financial application remains a package choice.
- Publication boundary: no provider observation, historical return, causal outcome, or profitability claim.
SKLEARN_DBSCAN — sklearn.cluster.DBSCAN
- Organization or authors: scikit-learn project
- Source type: Maintained official library documentation
- Publication or effective date: Maintained documentation
- Version: scikit-learn 1.9.0
- URL: https://scikit-learn.org/stable/modules/generated/sklearn.cluster.DBSCAN.html
- Accessed: 2026-07-27
- Jurisdiction: technical; not jurisdiction-specific
- Supports: Inclusive epsilon neighborhoods, min_samples including the point itself, noise labels, and parameter behavior.
- Limitations: The package implements deterministic one-dimensional DBSCAN directly for Python/TypeScript parity.
- Evidence role: sourced algorithm or maintained-library behavior; the financial application remains a package choice.
- Publication boundary: no provider observation, historical return, causal outcome, or profitability claim.
TV_REPAINT — Pine Script Concepts: Repainting
- Organization or authors: TradingView
- Source type: Maintained official platform documentation
- Publication or effective date: Maintained documentation
- Version: Pine Script v6 concepts page current on 2026-07-27
- URL: https://www.tradingview.com/pine-script-docs/concepts/repainting/
- Accessed: 2026-07-27
- Jurisdiction: technical; not jurisdiction-specific
- Supports: A pivot plotted back on its event bar is only discovered after right-side bars have elapsed and can mislead if knowledge time is hidden.
- Limitations: The page explains platform display behavior, not the financial usefulness of pivots.
- Evidence role: sourced algorithm or maintained-library behavior; the financial application remains a package choice.
- Publication boundary: no provider observation, historical return, causal outcome, or profitability claim.
Full dependency-light reference implementations in both supported languages.
export type Bar = { timestamp: string; high: number; low: number; close: number };
export type Pivot = { kind: "high" | "low"; event_index: number; confirmation_index: number; price: number; separation?: number; latency?: number };
function finite(value: number): boolean { return Number.isFinite(value); }
function validateBars(bars: Bar[]): void {
if (!Array.isArray(bars) || bars.length === 0) throw new Error("bars must be a non-empty array");
let previous = "";
bars.forEach((bar, index) => {
if (![bar.high, bar.low, bar.close].every(finite)) throw new Error(`bar ${index} contains a non-finite price`);
if (bar.high < bar.low || bar.close < bar.low || bar.close > bar.high) throw new Error(`bar ${index} violates OHLC ordering`);
if (!bar.timestamp || (previous && bar.timestamp <= previous)) throw new Error("timestamps must be strictly increasing");
previous = bar.timestamp;
});
}
function validatePivots(pivots: Pivot[]): void {
if (!Array.isArray(pivots)) throw new Error("pivots must be an array");
const identities = new Set<string>();
pivots.forEach((pivot, index) => {
if (pivot.kind !== "high" && pivot.kind !== "low") throw new Error(`pivot ${index} kind must be high or low`);
if (!Number.isInteger(pivot.event_index) || pivot.event_index < 0) throw new Error(`pivot ${index} event_index must be an integer >= 0`);
if (!Number.isInteger(pivot.confirmation_index) || pivot.confirmation_index < pivot.event_index) throw new Error(`pivot ${index} confirmation_index must be >= event_index`);
if (!finite(pivot.price) || pivot.price <= 0) throw new Error(`pivot ${index} price must be finite and > 0`);
const identity = `${pivot.kind}:${pivot.event_index}`;
if (identities.has(identity)) throw new Error("same-kind pivot event indexes must be unique");
identities.add(identity);
});
}
export function detectCausalPivots(bars: Bar[], leftSpan = 3, rightSpan = 3, minSeparation = 0): Pivot[] {
validateBars(bars);
if (!Number.isInteger(leftSpan) || leftSpan < 1 || !Number.isInteger(rightSpan) || rightSpan < 1) throw new Error("spans must be integers >= 1");
if (!finite(minSeparation) || minSeparation < 0) throw new Error("minSeparation must be finite and >= 0");
const events: Pivot[] = [];
for (let i = leftSpan; i < bars.length - rightSpan; i += 1) {
const left = bars.slice(i - leftSpan, i), right = bars.slice(i + 1, i + rightSpan + 1);
const leftHigh = Math.max(...left.map(b => b.high)), rightHigh = Math.max(...right.map(b => b.high));
const leftLow = Math.min(...left.map(b => b.low)), rightLow = Math.min(...right.map(b => b.low));
const highMargin = bars[i].high - Math.max(leftHigh, rightHigh);
const lowMargin = Math.min(leftLow, rightLow) - bars[i].low;
if (bars[i].high > leftHigh && bars[i].high >= rightHigh && highMargin + 1e-12 >= minSeparation)
events.push({kind:"high", event_index:i, confirmation_index:i+rightSpan, price:bars[i].high, separation:highMargin, latency:rightSpan});
if (bars[i].low < leftLow && bars[i].low <= rightLow && lowMargin + 1e-12 >= minSeparation)
events.push({kind:"low", event_index:i, confirmation_index:i+rightSpan, price:bars[i].low, separation:lowMargin, latency:rightSpan});
}
return events.sort((a,b) => a.confirmation_index-b.confirmation_index || a.event_index-b.event_index || a.kind.localeCompare(b.kind));
}
export function clusterPivotLevels(pivots: Pivot[], epsBps=50, minTouches=3) {
validatePivots(pivots);
if (!(epsBps > 0) || !Number.isInteger(minTouches) || minTouches < 2) throw new Error("invalid clustering parameters");
const eps=Math.log1p(epsBps/10000), clusters:any[]=[], noise:Pivot[]=[];
for (const kind of ["low","high"] as const) {
const points=pivots.filter(p=>p.kind===kind).sort((a,b)=>a.confirmation_index-b.confirmation_index||a.event_index-b.event_index);
const logs=points.map(p=>Math.log(p.price)), labels=Array<number|null>(points.length).fill(null);
const neighbors=(i:number)=>points.map((_,j)=>j).filter(j=>Math.abs(logs[j]-logs[i])<=eps+1e-15);
let cid=0;
for(let i=0;i<points.length;i++){ if(labels[i]!==null)continue; const near=neighbors(i); if(near.length<minTouches){labels[i]=-1;continue;} labels[i]=cid; const queue=[...near];
for(let q=0;q<queue.length;q++){const j=queue[q]; if(labels[j]===-1)labels[j]=cid; if(labels[j]!==null)continue; labels[j]=cid; const more=neighbors(j); if(more.length>=minTouches)for(const m of more)if(!queue.includes(m))queue.push(m);} cid++;
}
for(let c=0;c<cid;c++){const members=points.filter((_,i)=>labels[i]===c), prices=members.map(p=>p.price).sort((a,b)=>a-b), mid=prices.length%2?prices[(prices.length-1)/2]:(prices[prices.length/2-1]+prices[prices.length/2])/2;
clusters.push({kind,cluster_id:`${kind}-${c}`,level:mid,lower:prices[0],upper:prices.at(-1),touch_count:members.length,first_confirmation_index:Math.min(...members.map(p=>p.confirmation_index)),last_confirmation_index:Math.max(...members.map(p=>p.confirmation_index)),member_event_indexes:members.map(p=>p.event_index)});
} noise.push(...points.filter((_,i)=>labels[i]===-1));
} return {clusters,noise};
}
The embedded lab now expands to its full document height, keeping the article as the only scroll surface.