Library/Geometric Chart Patterns/Indicator Divergence Detection/Market-Wide Divergence Scanner and Ranking

D08-F05-A08 / Complete engineering topic

Market-Wide Divergence Scanner and Ranking

A production-minded guide to Market-Wide Divergence Scanner and Ranking.

Market-Wide Divergence Scanner and RankingD08 / D08-F05

The attractive part of divergence is the picture: two price extrema slope one way while a momentum line slopes another. The difficult part is everything the picture hides. Which extrema were eligible? Was the indicator extremum on the same bar? When did each pivot become confirmable? Did the chart draw a marker into the past? What happens when one indicator disagrees, or price invalidates the candidate before confirmation?

This tutorial answers those engineering questions for Market-Wide Divergence Scanner and Ranking. You will build a detector that can rank an as-of universe with deterministic eligibility, freshness, liquidity, and data-quality rules. The output is deliberately modest: an auditable geometry or workflow state. It is not a prediction or recommendation.

The decision made visible

Which currently eligible divergence records deserve review first under a frozen cross-sectional ranking contract? The selected contract starts from confirmed pivot events, not a retrospective line drawn after the whole chart is visible. Price and indicator pivots retain both event index and confirmation index. Alignment becomes knowable only at the later of those confirmations.

The aligned price and indicator geometry

The image uses synthetic data. Notice the solid extrema at their event locations and the knowledge-time label beside the later confirmation. That distinction is the foundation for every later score and state.

Intuition before notation

Imagine two price lows. If the second price low is materially lower but the aligned RSI low is materially higher, the selected contract calls the geometry regular bullish divergence. Mirror that construction at highs for regular bearish divergence. Hidden divergence reverses which series makes the higher or lower extremum and is kept in a separate family.

The word “materially” needs numbers. We use a fractional price threshold, a standardized indicator threshold, an inclusive maximum alignment lag, and a bounded pivot-pair separation. These are selected teaching defaults, not laws of markets.

Formula and equality policy

R=0.55C+0.15 state+0.10 freshness+0.10 liquidity+0.10 data_quality

Price delta is (P2-P1)/abs(P1). Indicator delta is calculated after I*=polarity×raw/scale. A positive standardized change always means the adapted indicator increased. Equality at the declared detection thresholds passes within a numerical tolerance of 1e-12. Lifecycle confirmation and invalidation use strict crossings so one bar cannot satisfy both at equality.

Work the actual decision, not just the picture

A scanner has two separate contracts: point-in-time admission and deterministic ranking. A record unavailable at the as-of index or excluded by policy must never receive a rank, regardless of how attractive its components look.

Decision stepCanonical calculationObserved resultInterpretation
AdmissionSYNTH-A eligible and v=47 ≤ a=71admitavailability checked before scoring
StateconfirmedSstate=100candidate would contribute 60
SYNTH-A score0.55×86 + 0.15×100 + 0.10×92 + 0.10×80 + 0.10×9889.30rank 1
Stable ordersort score descending, then symbol ascendingA, B, C, Ddeterministic under exact ties

The arithmetic is author-derived from labeled synthetic inputs. It checks definition fidelity only. The detector uses unrounded values and preserves the exact event and knowledge clocks shown in the fixture.

Misconception clinic

A rank is not an order list. It tells an analyst which eligible diagnostic record to review first under frozen weights; it says nothing about expected return, position size, or execution priority.

The boundary worth testing is equally concrete: Admission precedes ranking. available_index equal to as_of_index is eligible; a future record is not. The strict policy excludes candidates, while the permissive teaching policy admits the deliberately low-quality comparison row. Exact score ties use symbol order.

Compare before you combine

AlternativeContractBest useDo not confuse with
Admission policyeligible, available, supported statecontrols who entersmust be point-in-time
Ranking formulaweighted componentsorders admitted rowsweights are selected
Stable tie-breaksymbol ascendingreproducible outputnot an economic preference

Operational review checklist

  • Freeze the as-of universe
  • Check record availability
  • Apply eligibility and state policy
  • Reject nonfinite/out-of-range components
  • Print every component
  • Sort with a stable tie-break
  • Keep rank separate from orders and outcomes

Evidence boundary. The admission rules, state points, component weights, and tie-break are package-selected. S10 is a misuse boundary for cross-sectional/configuration selection; it does not validate the ranking or any return association.

Build the causal pipeline

First validate finalized bars and pivot clocks. Then adapt each indicator without dropping raw values. For each price pivot, search unused same-kind indicator pivots inside the inclusive lag. Choose the smallest absolute event-time difference; if two candidates tie, choose the earlier indicator event. The alignment knowledge time is the maximum of both confirmation indexes.

Next form consecutive aligned same-kind pairs and apply the regular/hidden geometry. Only after geometry exists may downstream modules score it, wait for confirmation, combine indicators, or rank a universe. This order prevents a high score or ranking formula from manufacturing a missing divergence.

Plain text
confirmed pivots → declared adapters → causal alignment
→ divergence classification → score/state/confluence → as-of ranking

Worked synthetic example

The canonical fixture contains 72 repository-authored hourly bars. Confirmed price lows occur at event indexes 18 and 42. RSI low events occur at 19 and 41, so both align inside the three-bar lag. Their knowledge time is the later confirmation carried by each pair. The complete expected result begins:

JSON
{"topic_id": "D08-F05-A08", "state": "ranked", "ranking": [{"symbol": "SYNTH-A", "state": "confirmed", "divergence_type": "regular-bullish", "rank_score": 89.3, "components": {"confluence_score": 86.0, "freshness": 92.0, "liquidity": 80.0, "data_quality": 98.0}, "as_of_index": 71, "interpretation": "deterministic triage priority; not an order or return forecast", "rank": 1}, {"symbol": "SYNTH-B", "state": "candidate", "divergence_type": "regular-bearish", "rank_score": 86.95, "components": {"confluence_score": 91.0, "freshness": 98.0, "liquidity": 72.0, "data_quality": 94.0}, "as_of_index": 71, "interpretation": "deterministic triage priority; not an order or return forecast", "rank": 2}, {"symbol": "SYNTH-C", "state": "confirmed", "divergence_type": "hidden-bullish", "rank_score": 81.3, "components": {"confluence_score": 74.0, "freshness": 70.0, "liquidity": 96.0, "data_quality": 90.0},

Use the complete expected-output file for machine comparison. The compact excerpt is not a substitute for the structured audit.

Seven experiments, three presets

The guided lab retains all 72 observations and recomputes 1,512 states: seven scenarios multiplied by three parameter presets. Start with the canonical fixture, step through the second pivot confirmation, and observe when the output changes. Then move to the exact equality case, reject an alignment outside the lag, compare hidden geometry, create mixed indicator evidence, invalidate a candidate, and remove the second pivot entirely.

Open the self-contained playground.

For the scanner topic, the strict preset admits confirmed records only, while the permissive preset also admits the deliberately low-quality comparison row. The purpose is not to select the most signals; it is to expose how declared policy changes the output.

Implementation walkthrough

The Python and TypeScript implementations share fixtures but not calculation code. Both validate types, finite values, clocks, scales, weights, and state ranges. Both return structured warm-up or empty states rather than replacing missing evidence with zero. Both preserve raw and normalized indicator values so an adapter can be audited later.

Scoring uses named components: price geometry, indicator geometry, prominence, alignment, and separation. Confluence groups events only when divergence type and price-pivot pair match. Scanner ranking admits only records available at the as-of index and applies a stable symbol tie-break. None of these scores is calibrated probability.

Testing what matters

Canonical output is recursively compared across languages. Independent arithmetic checks do not call either implementation. Boundary tests cover equality, no alignment, insufficient history, invalid scale, lifecycle precedence, deterministic reset, and scanner ordering. Visual checks compare the displayed state with the same scenario-results fixture.

Definition tests answer whether code implements the selected rules. They do not answer whether the signal predicts returns. That would require a point-in-time dataset, parameter freeze, transaction costs, survivorship controls, multiple-testing correction, and genuinely out-of-sample evaluation.

Failure atlas

The most dangerous failure is temporal: a pivot appears on an old event bar but was not knowable until later. Other failures include mismatching a price low to an indicator high, comparing raw RSI points with raw MACD units, counting three highly correlated indicators as three independent confirmations, confirming with an intrabar value that later disappears, and ranking stale candidates above fresher confirmed records.

Strong trends also matter. Divergence can persist while price continues in the same direction. TradingView explicitly warns that divergence should not be used alone, and Fidelity notes MACD whipsaw in ranges. Treat detection as a structured observation requiring separate validation and risk policy.

Historical example: deferred

A named chart would be more vivid but less honest without the exact point-in-time data and implementation vintage. A future historical case must publish the security, venue, timeframe, session, adjustment basis, finalized bars, indicator source and parameters, pivot convention, confirmation clocks, revisions, and redistribution rights. Until then, synthetic fixtures provide stronger reproducibility.

What you can now hand downstream

You can hand off a structured Market-Wide Divergence Scanner and Ranking result with scope, formula version, thresholds, raw and adapted inputs, pivot identities, event and knowledge times, state, reason, and evidence boundary intact. The next tutorial is Level Confluence and Zone Scoring.

Primary references

| S1 | TradingView — RSI divergence indicator | Regular divergence terminology and explicit warning against standalone use. | | S2 | TradingView — MACD indicator | Regular versus hidden interpretation and MACD component definitions. | | S3 | TradingView Pine Script — Repainting | Future-leak, back-plotting, confirmed-bar, and revision risks. | | S4 | TradingView Pine Script — Visuals FAQ | Pivot confirmation delay and explicit state-machine guidance. | | S5 | Fidelity — Relative Strength Index | RSI formula and price/RSI divergence interpretation. | | S6 | Fidelity — MACD | MACD calculation, range behavior, whipsaw limitation, and divergence context. | | S7 | TA-Lib — Function index | Official indicator inventory, documented inputs/outputs, and implementation links. | | S8 | TA-Lib — RSI | RSI lookback, initial unstable period, input, and output contract. | | S9 | SciPy — find_peaks | Nearby retrospective peak, distance, prominence, width, plateau, and NaN conventions. | | S10 | Bailey et al. — Effects of Backtest Overfitting | Multiple-configuration selection raises overfitting risk; used only as a misuse boundary. |

Market-Wide Divergence Scanner and Ranking calculation flow

Rendering system map…

Takeaway: preserve clocks and geometry before aggregation.

ReferencesPrimary sources and evidence notes

Expand the source trail, evidence role, and limitations behind the engineering choices.

The source ledger records what each source can support. Synthetic fixtures and author-derived arithmetic are not provider observations.

S1 — TradingView — RSI divergence indicator

  • Organization or authors: TradingView
  • Source type: official documentation, institutional education, or original research record
  • Publication or effective date: current page or recorded paper edition
  • Version: page retrieved on the access date
  • URL or DOI: https://www.tradingview.com/support/solutions/43000589127-rsi-divergence-indicator/
  • Accessed: 2026-08-03
  • Jurisdiction: educational and technical; not a trading rule
  • Supports: Regular divergence terminology and explicit warning against standalone use.
  • Limitations: Does not establish this package's thresholds, scoring weights, predictive value, or profitability. Those are explicitly selected implementation conventions.

S2 — TradingView — MACD indicator

  • Organization or authors: TradingView
  • Source type: official documentation, institutional education, or original research record
  • Publication or effective date: current page or recorded paper edition
  • Version: page retrieved on the access date
  • URL or DOI: https://www.tradingview.com/support/solutions/43000502344-moving-average-convergence-divergence-macd-indicator/
  • Accessed: 2026-08-03
  • Jurisdiction: educational and technical; not a trading rule
  • Supports: Regular versus hidden interpretation and MACD component definitions.
  • Limitations: Does not establish this package's thresholds, scoring weights, predictive value, or profitability. Those are explicitly selected implementation conventions.

S3 — TradingView Pine Script — Repainting

  • Organization or authors: TradingView Pine Script
  • Source type: official documentation, institutional education, or original research record
  • Publication or effective date: current page or recorded paper edition
  • Version: page retrieved on the access date
  • URL or DOI: https://www.tradingview.com/pine-script-docs/concepts/repainting/
  • Accessed: 2026-08-03
  • Jurisdiction: educational and technical; not a trading rule
  • Supports: Future-leak, back-plotting, confirmed-bar, and revision risks.
  • Limitations: Does not establish this package's thresholds, scoring weights, predictive value, or profitability. Those are explicitly selected implementation conventions.

S4 — TradingView Pine Script — Visuals FAQ

  • Organization or authors: TradingView Pine Script
  • Source type: official documentation, institutional education, or original research record
  • Publication or effective date: current page or recorded paper edition
  • Version: page retrieved on the access date
  • URL or DOI: https://www.tradingview.com/pine-script-docs/faq/visuals/
  • Accessed: 2026-08-03
  • Jurisdiction: educational and technical; not a trading rule
  • Supports: Pivot confirmation delay and explicit state-machine guidance.
  • Limitations: Does not establish this package's thresholds, scoring weights, predictive value, or profitability. Those are explicitly selected implementation conventions.

S5 — Fidelity — Relative Strength Index

  • Organization or authors: Fidelity
  • Source type: official documentation, institutional education, or original research record
  • Publication or effective date: current page or recorded paper edition
  • Version: page retrieved on the access date
  • URL or DOI: https://www.fidelity.com/learning-center/trading-investing/technical-analysis/technical-indicator-guide/RSI
  • Accessed: 2026-08-03
  • Jurisdiction: educational and technical; not a trading rule
  • Supports: RSI formula and price/RSI divergence interpretation.
  • Limitations: Does not establish this package's thresholds, scoring weights, predictive value, or profitability. Those are explicitly selected implementation conventions.

S6 — Fidelity — MACD

  • Organization or authors: Fidelity
  • Source type: official documentation, institutional education, or original research record
  • Publication or effective date: current page or recorded paper edition
  • Version: page retrieved on the access date
  • URL or DOI: https://www.fidelity.com/learning-center/trading-investing/technical-analysis/technical-indicator-guide/macd
  • Accessed: 2026-08-03
  • Jurisdiction: educational and technical; not a trading rule
  • Supports: MACD calculation, range behavior, whipsaw limitation, and divergence context.
  • Limitations: Does not establish this package's thresholds, scoring weights, predictive value, or profitability. Those are explicitly selected implementation conventions.

S7 — TA-Lib — Function index

  • Organization or authors: TA-Lib
  • Source type: official documentation, institutional education, or original research record
  • Publication or effective date: current page or recorded paper edition
  • Version: page retrieved on the access date
  • URL or DOI: https://ta-lib.org/functions/
  • Accessed: 2026-08-03
  • Jurisdiction: educational and technical; not a trading rule
  • Supports: Official indicator inventory, documented inputs/outputs, and implementation links.
  • Limitations: Does not establish this package's thresholds, scoring weights, predictive value, or profitability. Those are explicitly selected implementation conventions.

S8 — TA-Lib — RSI

  • Organization or authors: TA-Lib
  • Source type: official documentation, institutional education, or original research record
  • Publication or effective date: current page or recorded paper edition
  • Version: page retrieved on the access date
  • URL or DOI: https://ta-lib.org/functions/rsi.html
  • Accessed: 2026-08-03
  • Jurisdiction: educational and technical; not a trading rule
  • Supports: RSI lookback, initial unstable period, input, and output contract.
  • Limitations: Does not establish this package's thresholds, scoring weights, predictive value, or profitability. Those are explicitly selected implementation conventions.

S9 — SciPy — find_peaks

  • Organization or authors: SciPy
  • Source type: official documentation, institutional education, or original research record
  • Publication or effective date: current page or recorded paper edition
  • Version: page retrieved on the access date
  • URL or DOI: https://docs.scipy.org/doc/scipy/reference/generated/scipy.signal.find_peaks.html
  • Accessed: 2026-08-03
  • Jurisdiction: educational and technical; not a trading rule
  • Supports: Nearby retrospective peak, distance, prominence, width, plateau, and NaN conventions.
  • Limitations: Does not establish this package's thresholds, scoring weights, predictive value, or profitability. Those are explicitly selected implementation conventions.

S10 — Bailey et al. — Effects of Backtest Overfitting

  • Organization or authors: Bailey et al.
  • Source type: official documentation, institutional education, or original research record
  • Publication or effective date: current page or recorded paper edition
  • Version: page retrieved on the access date
  • URL or DOI: https://papers.ssrn.com/sol3/papers.cfm?abstract_id=2308659
  • Accessed: 2026-08-03
  • Jurisdiction: educational and technical; not a trading rule
  • Supports: Multiple-configuration selection raises overfitting risk; used only as a misuse boundary.
  • Limitations: Does not establish this package's thresholds, scoring weights, predictive value, or profitability. Those are explicitly selected implementation conventions.

Historical-example decision

Deferred. A named market case would require licensed point-in-time OHLC and indicator inputs, verified symbol and venue, exact interval/session and adjustment basis, parameter vintage, event and knowledge times, revision state, and redistribution permission. The package therefore uses deterministic synthetic data and makes no historical-performance claim.

Topic-specific applicability map

  • Central evidence: The admission rules, state points, component weights, and tie-break are package-selected. S10 is a misuse boundary for cross-sectional/configuration selection; it does not validate the ranking or any return association.
  • Selected contract: Admit point-in-time eligible records first; then rank by 0.55C+0.15state+0.10freshness+0.10liquidity+0.10data quality. This rule is repository-authored and may have valid alternatives.
  • Synthetic/derived boundary: every printed price, indicator value, state, score, group, and symbol comes from repository-authored fixtures and deterministic arithmetic.
  • Historical decision: deferred until identity, point-in-time basis, exact implementation vintage, revision history, and redistribution rights are reproducible.
  • Unsupported inference: the cited material does not establish prediction, causation, profitability, calibration, or optimality for this package.
divergence.ts
/** Independent TypeScript reference for D08-F05. Geometry is diagnostic only. */

const EPS = 1e-12;
type Row = Record<string, any>;

function finite(value: unknown): value is number { return typeof value === "number" && Number.isFinite(value); }
function requireRule(condition: unknown, message: string): asserts condition { if (!condition) throw new Error(message); }

export function adaptIndicator(indicator: Row): Row {
  const name = String(indicator.name ?? "").trim();
  const family = String(indicator.family ?? "").trim();
  const polarity = indicator.polarity ?? 1;
  const scale = indicator.scale;
  const pivots = indicator.pivots;
  requireRule(name, "indicator name is required"); requireRule(family, "indicator family is required");
  requireRule(polarity === -1 || polarity === 1, "indicator polarity must be -1 or 1");
  requireRule(finite(scale) && scale > 0, "indicator scale must be finite and > 0");
  requireRule(Array.isArray(pivots), "indicator pivots must be a list");
  const adapted = pivots.map((pivot: Row, index: number) => {
    requireRule(pivot && typeof pivot === "object", `indicator pivot ${index} must be an object`);
    requireRule(pivot.kind === "high" || pivot.kind === "low", `indicator pivot ${index} kind is invalid`);
    for (const field of ["event_index", "confirmation_index"]) requireRule(Number.isInteger(pivot[field]), `indicator pivot ${index} ${field} is invalid`);
    requireRule(pivot.confirmation_index >= pivot.event_index, "indicator confirmation precedes event");
    requireRule(finite(pivot.value), `indicator pivot ${index} value is invalid`);
    const prominence = pivot.prominence ?? 0.5;
    requireRule(finite(prominence) && prominence >= 0 && prominence <= 1, "indicator prominence must be in [0,1]");
    return {kind:pivot.kind,event_index:pivot.event_index,confirmation_index:pivot.confirmation_index,raw_value:pivot.value,normalized_value:polarity*pivot.value/scale,prominence};
  });
  return {name,family,polarity,scale,pivots:adapted};
}

function validatePricePivots(pivots: Row[]): void {
  requireRule(Array.isArray(pivots), "price_pivots must be a list"); const identities = new Set<string>();
  pivots.forEach((pivot,index) => {
    requireRule(pivot && typeof pivot === "object", `price pivot ${index} must be an object`);
    requireRule(pivot.kind === "high" || pivot.kind === "low", `price pivot ${index} kind is invalid`);
    for (const field of ["event_index","confirmation_index"]) requireRule(Number.isInteger(pivot[field]), `price pivot ${index} ${field} is invalid`);
    requireRule(pivot.confirmation_index >= pivot.event_index, "price confirmation precedes event");
    requireRule(finite(pivot.price) && pivot.price > 0, "price pivot price must be finite and > 0");
    const prominence = pivot.prominence ?? 0.5; requireRule(finite(prominence) && prominence >= 0 && prominence <= 1, "price prominence must be in [0,1]");
    const key=`${pivot.kind}:${pivot.event_index}`; requireRule(!identities.has(key), "same-kind price pivot event indexes must be unique"); identities.add(key);
  });
}

export function alignPivots(pricePivots: Row[], indicator: Row, maxLag=3): Row[] {
  validatePricePivots(pricePivots); requireRule(Number.isInteger(maxLag) && maxLag >= 0, "max_lag must be an integer >= 0");
  const adapted=adaptIndicator(indicator); const used=new Set<number>(); const aligned:Row[]=[];
  const ordered=[...pricePivots].sort((a,b)=>a.event_index-b.event_index || String(a.kind).localeCompare(String(b.kind)));
  for(const price of ordered){
    const candidates=adapted.pivots.map((item:Row,position:number)=>({lag:Math.abs(item.event_index-price.event_index),event:item.event_index,position,item}))
      .filter((x:Row)=>!used.has(x.position)&&x.item.kind===price.kind&&x.lag<=maxLag)
      .sort((a:Row,b:Row)=>a.lag-b.lag||a.event-b.event);
    if(!candidates.length) continue; const chosen=candidates[0]; used.add(chosen.position); const item=chosen.item;
    aligned.push({kind:price.kind,price_event_index:price.event_index,price_confirmation_index:price.confirmation_index,price:Number(price.price),price_prominence:Number(price.prominence??0.5),indicator_event_index:item.event_index,indicator_confirmation_index:item.confirmation_index,indicator_value:item.normalized_value,indicator_raw_value:item.raw_value,indicator_prominence:item.prominence,lag:chosen.lag,knowledge_index:Math.max(price.confirmation_index,item.confirmation_index),indicator:adapted.name,indicator_family:adapted.family});
  }
  return aligned;
}

function classifyPair(first:Row,second:Row,minPrice:number,minIndicator:number):string|null{
  const pd=(second.price-first.price)/Math.abs(first.price); const id=second.indicator_value-first.indicator_value;
  if(first.kind==="low"){
    if(pd<=-minPrice+EPS&&id>=minIndicator-EPS)return "regular-bullish";
    if(pd>=minPrice-EPS&&id<=-minIndicator+EPS)return "hidden-bullish";
  }else{
    if(pd>=minPrice-EPS&&id<=-minIndicator+EPS)return "regular-bearish";
    if(pd<=-minPrice+EPS&&id>=minIndicator-EPS)return "hidden-bearish";
  }
  return null;
}

export function detectDivergences(pricePivots:Row[],indicators:Row[],options:Row={}):Row[]{
  requireRule(Array.isArray(indicators)&&indicators.length>0,"indicators must be a non-empty list");
  const maxLag=options.max_lag??3,minSeparation=options.min_separation??5,maxSeparation=options.max_separation??40,minPrice=options.min_price_fraction??0.005,minIndicator=options.min_indicator_delta??0.05;
  requireRule(Number.isInteger(minSeparation)&&minSeparation>=1,"min_separation must be an integer >= 1"); requireRule(Number.isInteger(maxSeparation)&&maxSeparation>=minSeparation,"max_separation must be >= min_separation");
  requireRule(finite(minPrice)&&minPrice>=0,"min_price_fraction must be finite and >= 0"); requireRule(finite(minIndicator)&&minIndicator>=0,"min_indicator_delta must be finite and >= 0");
  const events:Row[]=[];
  for(const indicator of indicators){ const aligned=alignPivots(pricePivots,indicator,maxLag);
    for(const kind of ["low","high"]){const same=aligned.filter(x=>x.kind===kind); for(let j=0;j+1<same.length;j++){const first=same[j],second=same[j+1],span=second.price_event_index-first.price_event_index;if(span<minSeparation||span>maxSeparation)continue;const type=classifyPair(first,second,minPrice,minIndicator);if(!type)continue;const pd=(second.price-first.price)/Math.abs(first.price),id=second.indicator_value-first.indicator_value;
      events.push({type,direction:kind==="low"?"bullish":"bearish",kind,indicator:first.indicator,indicator_family:first.indicator_family,price_pair:[first.price_event_index,second.price_event_index],indicator_pair:[first.indicator_event_index,second.indicator_event_index],price_values:[first.price,second.price],indicator_values:[first.indicator_value,second.indicator_value],price_delta_fraction:pd,indicator_delta:id,separation:span,max_alignment_lag:Math.max(first.lag,second.lag),prominence:(first.price_prominence+second.price_prominence+first.indicator_prominence+second.indicator_prominence)/4,candidate_knowledge_index:Math.max(first.knowledge_index,second.knowledge_index),alignment:[first,second]});
    }}
  }
  return events.sort((a,b)=>a.candidate_knowledge_index-b.candidate_knowledge_index||String(a.type).localeCompare(String(b.type))||String(a.indicator).localeCompare(String(b.indicator)));
}

export function scoreDivergence(event:Row,maxLag=3):Row{
  requireRule(event&&typeof event==="object","event must be an object");
  const price=Math.min(Math.abs(event.price_delta_fraction)/0.04,1),indicator=Math.min(Math.abs(event.indicator_delta)/0.15,1),prominence=Math.min(Math.max(event.prominence,0),1),alignment=maxLag===0&&event.max_alignment_lag===0?1:Math.max(0,1-event.max_alignment_lag/Math.max(maxLag,1)),separation=Math.max(0,1-Math.abs(event.separation-24)/24);
  const score=Number((100*(.28*price+.28*indicator+.18*prominence+.14*alignment+.12*separation)).toFixed(6));
  const quality=score>=85?"exceptional":score>=70?"strong":score>=50?"moderate":"weak";
  const round=(x:number)=>Number(x.toFixed(6));
  return {score,quality,components:{price_geometry:round(price),indicator_geometry:round(indicator),prominence:round(prominence),alignment:round(alignment),separation:round(separation)},interpretation:"definition-strength score; not probability or expected return"};
}

export function advanceState(event:Row,bars:Row[],confirmationHorizon=12,invalidationBuffer=.005):Row{
  requireRule(Array.isArray(bars)&&bars.length>0,"bars must be a non-empty list"); requireRule(Number.isInteger(confirmationHorizon)&&confirmationHorizon>=1,"confirmation_horizon must be an integer >= 1"); requireRule(finite(invalidationBuffer)&&invalidationBuffer>=0&&invalidationBuffer<1,"invalidation_buffer must be in [0,1)");
  bars.forEach((bar,index)=>requireRule(bar&&finite(bar.close)&&finite(bar.high)&&finite(bar.low),`bar ${index} is invalid`));
  const [first,second]=event.price_pair,candidate=event.candidate_knowledge_index,direction=event.direction;
  const slice=bars.slice(first,second+1); const confirmation=direction==="bullish"?Math.max(...slice.map(x=>x.high)):Math.min(...slice.map(x=>x.low)); const invalidation=event.price_values[1]*(direction==="bullish"?1-invalidationBuffer:1+invalidationBuffer); const deadline=candidate+confirmationHorizon; const trace:Row[]=[]; let state="candidate",finalIndex:number|null=null;
  for(let index=candidate;index<Math.min(bars.length,deadline+1);index++){const bar=bars[index];if(direction==="bullish"&&bar.low<invalidation-EPS){state="invalidated";finalIndex=index}else if(direction==="bearish"&&bar.high>invalidation+EPS){state="invalidated";finalIndex=index}else if(direction==="bullish"&&bar.close>confirmation+EPS){state="confirmed";finalIndex=index}else if(direction==="bearish"&&bar.close<confirmation-EPS){state="confirmed";finalIndex=index}trace.push({index,state,close:bar.close});if(finalIndex!==null)break;}
  if(finalIndex===null&&bars.length-1>=deadline){state="expired";finalIndex=deadline;if(trace.length)trace[trace.length-1].state="expired"}
  return {state,candidate_index:candidate,final_index:finalIndex,confirmation_level:Number(confirmation.toFixed(6)),invalidation_level:Number(invalidation.toFixed(6)),deadline,trace};
}

export function combineConfluence(events:Row[],weights:Record<string,number>,minimumIndicators=2):Row[]{
  requireRule(Array.isArray(events),"events must be a list");requireRule(weights&&Object.keys(weights).length>0,"weights must be a non-empty object");requireRule(Number.isInteger(minimumIndicators)&&minimumIndicators>=1,"minimum_indicators must be >= 1");const total=Object.values(weights).reduce((a,b)=>a+b,0);requireRule(total>0&&Object.values(weights).every(x=>finite(x)&&x>=0),"weights must be finite, non-negative, and sum > 0");
  const groups=new Map<string,Row[]>();for(const event of events){const key=`${event.type}|${event.price_pair.join(",")}`;groups.set(key,[...(groups.get(key)??[]),event]);}const out:Row[]=[];
  for(const group of groups.values()){const unique=new Map(group.map(x=>[x.indicator,x]));if(unique.size<minimumIndicators)continue;const selected=[...unique.values()],selectedWeight=selected.reduce((sum,x)=>sum+(weights[x.indicator]??0),0);if(selectedWeight<=0)continue;const quality=selected.reduce((sum,x)=>sum+(weights[x.indicator]??0)*scoreDivergence(x).score,0)/selectedWeight,coverage=selectedWeight/total,families=new Set(selected.map(x=>x.indicator_family)).size/selected.length,score=Number((.45*quality+35*coverage+20*families).toFixed(6));out.push({type:selected[0].type,direction:selected[0].direction,price_pair:selected[0].price_pair,indicators:[...unique.keys()].sort(),indicator_count:unique.size,weighted_quality:Number(quality.toFixed(6)),weight_coverage:Number(coverage.toFixed(6)),family_coverage:Number(families.toFixed(6)),confluence_score:score,knowledge_index:Math.max(...selected.map(x=>x.candidate_knowledge_index)),interpretation:"agreement score; not independent evidence or probability"});}
  return out.sort((a,b)=>b.confluence_score-a.confluence_score||String(a.type).localeCompare(String(b.type))||String(a.price_pair).localeCompare(String(b.price_pair)));
}

export function rankUniverse(records:Row[],asOfIndex:number):Row[]{
  requireRule(Array.isArray(records),"records must be a list");requireRule(Number.isInteger(asOfIndex)&&asOfIndex>=0,"as_of_index must be an integer >= 0");const ranked:Row[]=[];
  for(const record of records){if((record.available_index??0)>asOfIndex||record.eligible===false)continue;for(const field of ["confluence_score","freshness","liquidity","data_quality"])requireRule(finite(record[field])&&record[field]>=0&&record[field]<=100,`${field} must be in [0,100]`);requireRule(record.state==="candidate"||record.state==="confirmed","scanner state must be candidate or confirmed");const state=record.state==="confirmed"?100:70;const rank=Number((.55*record.confluence_score+.15*state+.1*record.freshness+.1*record.liquidity+.1*record.data_quality).toFixed(6));ranked.push({symbol:String(record.symbol),state:record.state,divergence_type:record.divergence_type,rank_score:rank,components:{confluence_score:record.confluence_score,freshness:record.freshness,liquidity:record.liquidity,data_quality:record.data_quality},as_of_index:asOfIndex,interpretation:"deterministic triage priority; not an order or return forecast"});}
  ranked.sort((a,b)=>b.rank_score-a.rank_score||a.symbol.localeCompare(b.symbol));ranked.forEach((x,i)=>x.rank=i+1);return ranked;
}

export function runTopic(payload:Row,topicId?:string):Row{
  const selected=topicId??String(payload.topic_id??""),p=payload.parameters??{},price=payload.price_pivots??[],indicators=payload.indicators??[],maxLag=p.max_lag??3;
  if(selected.endsWith("A01")){if(!indicators.length)return{topic_id:selected,state:"warmup",alignments:[],reason:"no-indicator-pivots"};const alignments=alignPivots(price,indicators[0],maxLag);return{topic_id:selected,state:alignments.length>=2?"ready":"warmup",alignments,alignment_count:alignments.length,reason:alignments.length>=2?"aligned":"fewer-than-two-alignments"};}
  if(selected.endsWith("A04")){const adapters=indicators.map(adaptIndicator);return{topic_id:selected,state:adapters.length?"ready":"warmup",adapters,adapter_count:adapters.length};}
  const events=indicators.length?detectDivergences(price,indicators,p):[];
  if(selected.endsWith("A02")){const filtered=events.filter(x=>x.type.startsWith("regular-"));return{topic_id:selected,state:filtered.length?"detected":"not-detected",events:filtered,event_count:filtered.length};}
  if(selected.endsWith("A03")){const filtered=events.filter(x=>x.type.startsWith("hidden-"));return{topic_id:selected,state:filtered.length?"detected":"not-detected",events:filtered,event_count:filtered.length};}
  if(selected.endsWith("A05")){if(!events.length)return{topic_id:selected,state:"not-detected",score:null,reason:"no-divergence-event"};return{topic_id:selected,state:"scored",event:events[0],score:scoreDivergence(events[0],maxLag)};}
  if(selected.endsWith("A06")){if(!events.length)return{topic_id:selected,state:"searching",event:null,lifecycle:null};const event=events[0],lifecycle=advanceState(event,payload.bars??[],p.confirmation_horizon??12,p.invalidation_buffer??.005);return{topic_id:selected,state:lifecycle.state,event,lifecycle};}
  if(selected.endsWith("A07")){const groups=combineConfluence(events,payload.weights??{},p.minimum_indicators??2);return{topic_id:selected,state:groups.length?"confluent":"insufficient-confluence",groups,group_count:groups.length};}
  if(selected.endsWith("A08")){const ranking=rankUniverse(payload.universe??[],payload.as_of_index??((payload.bars??[]).length-1));return{topic_id:selected,state:ranking.length?"ranked":"empty",ranking,eligible_count:ranking.length};}
  throw new Error(`unsupported topic id: ${selected}`);
}
Full-height labplaygroundOpen full screen