D08-F01-A03 / Complete engineering topic

Support/Resistance Clustering

A production-minded guide to Support/Resistance Clustering.

D08 · GEOMETRIC CHART PATTERNS
D08-F01-A03Canonical / Tested / Open
D08 / D08-F01

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.

Knowledge-time map for Support/Resistance Clustering

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

FieldTypeMeaningRequired policy
kindhigh or lowresistance or support candidatecluster kinds separately
event_indexinteger at least 0pivot geometry timeunique within kind
confirmation_indexintegerfirst knowledge timeat least event_index and no later than caller cutoff
pricefinite positive numberconfirmed pivot pricesingle instrument and adjustment basis
eps_bpsfinite positive numberlog-ratio neighborhood radiusconverted with log1p
min_touchesintegercore-neighborhood count including selfat 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.

VariantDefinitionBest useLimitation
This packagesame-kind 1-D DBSCAN in log priceunknown number of horizontal levelsone epsilon is not a cluster-span cap
Price-bin groupingfixed or rounded price bucketssimple reportingbucket edges create discontinuities
K-meansforces a chosen number of centroidsknown cluster countforces noise into a cluster
Time-decayed densityweights or removes old pivotschanging regimesadds a decay/window convention

The complete mathematical contract

δ=log(1+ϵbps10000),d(i,j)=logpilogpj\delta=\log\left(1+\frac{\epsilon_{bps}}{10000}\right),\qquad d(i,j)=|\log p_i-\log p_j| Nδ(i)={j:kindj=kindi, d(i,j)δ}N_\delta(i)=\{j:\operatorname{kind}_j=\operatorname{kind}_i,\ d(i,j)\le\delta\} i is core    Nδ(i)m;p^level=median{pj:jC}i\text{ is core}\iff |N_\delta(i)|\ge m;\qquad \widehat{p}_{level}=\operatorname{median}\{p_j:j\in C\}
SymbolMeaningUnit
p_iconfirmed pivot pricepositive price units
epsilon_bpsratio-distance settingbasis points
deltaexact log-distance radiuslog-price
mminimum touches including selfcount
Cdensity-connected same-kind clusterset 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 objectValueStateResult
100.00100.00, 100.30, 99.90corethree neighbors including self
100.30100.00, 100.30, 99.90corewithin exact 50 bps log radius
99.90100.00, 100.30, 99.90corejoins the same density component
105.00105.00 onlynoisefewer than three neighbors
cluster99.90 to 100.30level 100.00median 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

Rendering system map…

The diagram has one deliberate shape: validate, calculate only from available evidence, publish diagnostics, and preserve the rejection branch.

The algorithm proceeds as follows:

  1. Consume only pivots whose confirmation index is no later than the current knowledge index.
  2. Separate high pivots from low pivots before clustering.
  3. Transform positive prices with the natural logarithm.
  4. Expand inclusive epsilon neighborhoods with at least min_touches members including the point itself.
  5. 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

Parameter decision boundary for Support/Resistance Clustering

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

ExperimentQuestion to answer
Clean alternating swingsA clean control for confirmation latency and alternating structure.
Plateau and equality boundariesEqual extrema expose the selected earliest-plateau and strict-update policies.
Noisy micro-swingsNoise reveals sensitivity to span, reversal, density, and residual thresholds.
Repeated level retestsRepeated highs and lows create inspectable support and resistance density.
Trend with one gross outlierOne extreme observation distinguishes ordinary fitting from consensus fitting.
Level and slope regime shiftA structural shift exposes stale clusters, provisional pivots, and window dependence.
DBSCAN density chainNeighbor-to-neighbor reachability can create a level wider than one epsilon radius.
Sparse pivots and explicit noiseIsolated 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:

  1. Predict the next state before pressing Step.
  2. Compare the event index with the knowledge index.
  3. Move one parameter across its boundary and explain the changed branch.
  4. Switch to the failure scenario and identify why “no result” is correct.

Where implementations fail

Failure and rejection atlas for Support/Resistance Clustering

  • 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

Family handoff for Support/Resistance Clustering

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

The canonical README, reference record, and claim ledger contain the complete definition and evidence classifications.

Support/Resistance Clustering calculation flow

Purpose: preserve validation, causal availability, decision branches, and diagnostics in the order used by the implementation.

Rendering system map…

Takeaway: a rejected or unavailable result is part of the output contract; it must not be filled with retrospective geometry.

ReferencesPrimary sources and evidence notes

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.
level_clusters.ts
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};
}
Full-height labsupport resistance clustering labOpen full screen