Library/Price Action and Candlesticks/Two-Candle Patterns/Piercing Line

D06-F03-A05 / Complete engineering topic

Piercing Line

A production-minded guide to Piercing Line.

Piercing LineD06 / D06-F03

Detect Piercing Line causally and expose every direction, scale, endpoint, midpoint, gap, tolerance, and context check.

The decision this tutorial makes visible

Visual descriptions of Piercing Line often hide whether shadows or real bodies are compared, whether equality counts, and whether the preceding trend was known before the pair. This package makes those choices executable.

The precise question is: When do two consecutive candles qualify as Piercing Line under explicit scale, endpoint, tolerance, and prior-context rules?

A practitioner can see exactly which geometry or context rule failed. A builder can reproduce the same decision from frozen OHLC, scales, tick policy, and prior-only state in Python, TypeScript, fixtures, visuals, and the guided lab.

Intuition before notation

The midpoint penetration is strict; stopping exactly at halfway is a documented rejection.

The package teaches one auditable convention and names nearby variants. It does not imply that platforms sharing the same label use identical thresholds.

Scope and nearby methods

Two consecutive comparable closed candles plus prior body/range scales and a downtrend context frozen before the first candle. After a long bearish candle, a bullish candle opens at least one tick below the prior low and closes strictly above the first body midpoint but below its open.

VariantDefinitionBest useMain limitation
Package pair contractAfter a long bearish candle, a bullish candle opens at least one tick below the prior low and closes strictly above the first body midpoint but below its open.Auditable causal detectionNot vendor parity
Body-only textbook variantCompare open-close intervalsContinuous marketsMay omit gap or shadow detail
Full-range variantCompare high-low intervalsStricter screeningMaterially different matches

What is sourced, selected, synthetic, and derived

RoleMaterial claimEvidenceBoundary
Sourced factCandlesticks encode OHLC as a real body and shadows, and established pattern names are interpreted under configurable conventions and market context.S1-S4Sources support terminology and convention/context roles, not this package's thresholds or a forecast.
Implementation choiceTwo consecutive comparable closed candles plus prior body/range scales and a downtrend context frozen before the first candle. After a long bearish candle, a bullish candle opens at least one tick below the prior low and closes strictly above the first body midpoint but below its open.Frozen package definitionOther books and software can use different averages, factors, equality, gaps, or trend filters.
Synthetic teaching inputEvery OHLC value, scale history, trend input, tick, tolerance, and scenario path in the package is repository-authored.datasets/canonical-input.json and scenario-results.jsonNo value is an observed security, exchange session, provider bar, or return.
Author-derived calculationThe structured matched output follows from the printed formula and synthetic fixture.expected-output.json, independent arithmetic, and parity testsDefinition fidelity is not empirical or predictive validation.

The sources support candle construction, established names, configurable relative thresholds, and the need for context. The numeric fixture, medians, thresholds, equality operators, and outputs are repository-authored synthetic choices and author-derived calculations.

Formula, symbols, and numerical policy

Plain text
After a long bearish candle, a bullish candle opens at least one tick below the prior low and closes strictly above the first body midpoint but below its open.
SymbolMeaningUnitPolicy
B1,B2First and second real bodiespriceDirection retained
lo_i,hi_iReal-body endpointspriceInclusive containment where declared
M1First-body midpointpriceStrict Piercing/Dark-Cloud crossing
tauExtreme-price tolerancepricetick × tolerance ticks
  • Calculate with unrounded finite OHLC differences in one declared price unit and adjustment basis.
  • Use an explicit positive tick size; threshold equality follows the operator printed in the formula.
  • Reference medians use at most the latest ten eligible prior closed bars and never include a candidate candle in its own baseline.
  • Round only for display; preserve null, warm-up, zero-body, wrong-context, and failed-check states instead of coercing them to a match.

Read the formula in the same order as the algorithm. Validate identity, ordering, units, and supported state first. Apply the selected equality and window rules second. Calculate with unrounded numeric values. Round only at the declared presentation boundary, and preserve null as a diagnostic rather than coercing it to zero.

Build the algorithm

  1. Validate consecutive comparable candles, tick, tolerance, prior scales, and context.
  2. Calculate both anatomies and the first-body midpoint.
  3. Evaluate the Piercing Line contract: After a long bearish candle, a bullish candle opens at least one tick below the prior low and closes strictly above the first body midpoint but below its open.
  4. Apply prior context and return every check and diagnostic.

Production-minded operational checklist

  1. Require consecutive closed bars
  2. Freeze context before first candle
  3. Record tick and equality rules
  4. Inspect every failed check

Reject a plausible-looking label when the bar is provisional, reference state is insufficient, session/basis is mixed, or required prior context is unavailable.

Worked synthetic example

The canonical fixture is synthetic teaching data, not an observed control event, customer order, or broker execution. Its primary author-derived output, matched, is true. The complete input and output are in datasets/canonical-input.json and datasets/expected-output.json.

The synthetic pair is constructed to satisfy the selected Piercing Line contract with prior-body median 2, prior-range median 5, tick size 0.1, and context downtrend. No value is an observed market bar.

Counterfactual checkpoint

Exact decision boundary. Move the teaching driver through the printed threshold. The output changes because The detector compares unrounded geometry with an explicit scale-aware threshold.

The structured result retains state and diagnostics in addition to the primary number. That makes the calculation independently reviewable and prevents a partial, null, rejected, or venue-bounded outcome from being mistaken for an unqualified value.

Boundary and counterexample workbook

The playground computes every scenario at 61 deterministic parameter states. The table uses the declared focus step and states whether that focus reproduces the canonical fixture. The full state ledger and compressed transition segments are in datasets/scenario-results.json.

ScenarioReview focusPurposeStatePrimary outputDiagnosticDecision segments
Canonical threshold sweepStep 30 · canonical fixtureMove the defining synthetic geometry through the exact canonical boundary; state 31 reproduces the complete fixture.matchedpattern matchedmatched; failed: none2
Exact equality boundaryStep 30 · canonical fixtureHold the canonical synthetic fixture at its material equality rule.matchedpattern matchedmatched; failed: none1
Geometry rejectionStep 30 · comparison focusBreak a material body, shadow, direction, containment, midpoint, gap, or tolerance condition.not-matchedno pattern matchnot-matched; failed: second_bullish, gap_below_first_low, close_above_midpoint1
Opposite prior contextStep 30 · comparison focusKeep the geometry but reverse the causal prior trend to expose wrong-context behavior.wrong-contextno pattern matchwrong-context; failed: none1
Reference warm-upStep 30 · comparison focusUse only three prior bars so the scale-aware detector cannot initialize.warmupno pattern matchwarmup; failed: minimum_history1
Larger prior scaleStep 30 · comparison focusKeep current geometry but double prior body and range baselines.not-matchedno pattern matchnot-matched; failed: long_first1
Sideways-context controlStep 30 · comparison focusRetain valid geometry and scales while withholding reversal context.wrong-contextno pattern matchwrong-context; failed: none1

These rows are not backtest observations. They are controlled counterexamples that expose how one driver changes the state, output, or reason code while the rest of the contract stays fixed.

Visualize the boundary

Piercing Line annotated teaching map

Open this SVG at full size, or use the guided playground to compare the seven topic-specific canonical, boundary, policy, and failure scenarios.

The Mermaid flow answers where the selected calculation sits in the processing sequence. The SVG keeps the formula, output, decision boundary, and invariant visible together. The lab lets the reader step through the same structured states without changing the underlying definition.

Implementation walkthrough

The references validate OHLC first, derive body and shadows once, calculate causal scales or context, evaluate named Boolean checks in formula order, then expose match, state, diagnostics, and every failure reason.

The main implementation branches are:

  • history < 5 — Return warmup, because Scale is unavailable.
  • pair geometry fails — Return not-matched plus failures, because At least one declared condition fails.
  • geometry passes but context != downtrend — Return wrong-context, because Reversal name requires prior context.
  • geometry and context pass — Return matched, because Selected contract is satisfied.

Neither reference silently fetches data, mutates caller-owned inputs outside the declared engine behavior, guesses hidden state, or substitutes a provider default. Shared JSON fixtures make value, null, state, and reason-code drift visible across languages.

Testing and validation

Definition tests compare every canonical field, reject malformed state, and exercise the material boundary. Family validation recomputes every playground state from the reference function. Independent arithmetic is recorded beside the fixture rather than inferred only from implementation output.

The audit must preserve these invariants:

  • Both candle anatomies and endpoints
  • First-body midpoint, gap/tolerance arithmetic, and reference scales
  • Required context and every Boolean check

Passing these checks proves that both implementations match the selected detector contract on the fixture and scenarios. It does not validate a market forecast.

Failure modes and misuse

  • A geometry match is not confirmation, a directional forecast, an order, or investment advice.
  • Thresholds and lookbacks are explicit teaching choices, not universal market standards or calibrated parameters.
  • Feed construction, sessions, corporate actions, contract rolls, ticks, missing bars, provisional candles, and later corrections can change a result.
  • Passing definition and parity tests does not establish historical association, cost-adjusted performance, robustness, or profitability.

Debugging order

When a result looks surprising, inspect the state in this order:

  1. Validate OHLC ordering, closure, interval, session, price basis, and tick size.
  2. Recalculate body, shadows, body endpoints, and the prior median scales.
  3. Confirm prior trend was frozen before the pattern began and inspect equality/tolerance rules.
  4. Compare every named Boolean check before interpreting the final match label.

Evidence and historical boundary

Historical decision: deferred. Historical examples are deferred until licensed point-in-time consecutive OHLC bars, exact session boundaries, tick size, adjustment/roll rules, prior context, parameter vintage, revisions, and an independently designed outcome study are available.

The primary sources are Nison candlestick reference, CMT context note, TA-Lib candle settings, TA-Lib Piercing Line. They support the source roles listed in the research ledger, not a redistributable historical observation, a universal candlestick definition, a licensed historical market event, a predictive edge, a trading recommendation, or a profitability claim.

Summary and next topic

You can now calculate Piercing Line under an explicit definition and carry its structured evidence into Dark Cloud Cover. The learning flow is: Bearish Harami → Piercing Line → Dark Cloud Cover. Carry the result forward only with its scope, clock, state, and evidence label.

Piercing Line calculation flow

This flow identifies the selected calculation stages and the structured output.

Rendering system map…

Takeaway: Piercing Line is a precise two-bar relationship plus prior downtrend context, not a visual resemblance or forecast.

ReferencesPrimary sources and evidence notes

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

S1 — Japanese Candlestick Charting Techniques, Second Edition

  • Organization or authors: Steve Nison
  • Source type: Authoritative practitioner book
  • Publication or effective date: 2001-11-01
  • Version: Second edition; ISBN 9780735201811
  • URL or DOI: https://www.penguinrandomhouse.com/books/350650/japanese-candlestick-charting-techniques-by-steve-nison/
  • Accessed: 2026-08-01
  • Jurisdiction: General technical-analysis literature
  • Supports: The established candlestick vocabulary and the importance of reading formations in market context.
  • Limitations: A charting reference is not a machine contract, universal threshold specification, or empirical performance proof.

S2 — Technical Insights — Candles in Market Context

  • Organization or authors: CMT Association
  • Source type: Official professional association education
  • Publication or effective date: 2020-11
  • Version: Technical Insights November 2020
  • URL or DOI: https://cmtassociation.org/technical_insights/technical-insights-november-2020/
  • Accessed: 2026-08-01
  • Jurisdiction: Professional technical-analysis education
  • Supports: A reversal label such as bearish engulfing must be interpreted after the relevant prior trend rather than from candle geometry alone.
  • Limitations: Does not define the package's causal efficiency filter or thresholds.

S3 — Candlestick Settings

  • Organization or authors: TA-Lib project
  • Source type: Official maintained technical documentation
  • Publication or effective date: 2026
  • Version: Core API documentation accessed 2026-08-01
  • URL or DOI: https://ta-lib.org/api/candle-settings/
  • Accessed: 2026-08-01
  • Jurisdiction: Cross-platform software convention
  • Supports: Maintained candlestick software evaluates body, shadow, and near/far characteristics against configurable prior averages and factors.
  • Limitations: This package uses prior medians and transparent factors and does not claim TA-Lib parity.

S4 — Piercing Line recognizer

  • Organization or authors: TA-Lib project
  • Source type: Official maintained function catalog
  • Publication or effective date: 2026
  • Version: Function catalog accessed 2026-08-01
  • URL or DOI: https://ta-lib.org/functions/cdlpiercing
  • Accessed: 2026-08-01
  • Jurisdiction: Cross-platform software convention
  • Supports: A maintained software convention for the Piercing Line geometry and its named neighboring patterns.
  • Limitations: The repository contract is intentionally explicit and scale-aware but is not a claim of bit-for-bit TA-Lib behavior.

Evidence boundary

Sources establish the chart geometry, names, configurable-convention context, and contextual interpretation named in their records. They do not certify repository thresholds, synthetic bars, matches, or future returns.

candlestick-detection.ts
/** Transparent, repository-selected D06 candle and pattern contracts. */

type AnyMap = Record<string, any>;

const F02_TITLES: Record<string, string> = {
  "D06-F02-A01": "Doji", "D06-F02-A02": "Dragonfly Doji", "D06-F02-A03": "Gravestone Doji",
  "D06-F02-A04": "Marubozu", "D06-F02-A05": "Spinning Top", "D06-F02-A06": "Hammer",
  "D06-F02-A07": "Hanging Man", "D06-F02-A08": "Inverted Hammer", "D06-F02-A09": "Shooting Star",
};
const F03_TITLES: Record<string, string> = {
  "D06-F03-A01": "Bullish Engulfing", "D06-F03-A02": "Bearish Engulfing", "D06-F03-A03": "Bullish Harami",
  "D06-F03-A04": "Bearish Harami", "D06-F03-A05": "Piercing Line", "D06-F03-A06": "Dark Cloud Cover",
  "D06-F03-A07": "Tweezer Top", "D06-F03-A08": "Tweezer Bottom",
};

function finite(value: unknown, name: string): number {
  if (typeof value !== "number" || !Number.isFinite(value)) throw new TypeError(`${name} must be a finite number`);
  return value;
}
function positive(value: unknown, name: string): number { const n = finite(value, name); if (n <= 0) throw new RangeError(`${name} must be positive`); return n; }
function nonnegative(value: unknown, name: string): number { const n = finite(value, name); if (n < 0) throw new RangeError(`${name} must be non-negative`); return n; }
function candle(value: unknown, name: string): AnyMap {
  if (!value || typeof value !== "object" || Array.isArray(value)) throw new TypeError(`${name} must be an object`);
  const source = value as AnyMap;
  const result = {open: finite(source.open, `${name}.open`), high: finite(source.high, `${name}.high`), low: finite(source.low, `${name}.low`), close: finite(source.close, `${name}.close`)};
  if (result.high < Math.max(result.open, result.close, result.low)) throw new RangeError(`${name}.high is below another OHLC value`);
  if (result.low > Math.min(result.open, result.close, result.high)) throw new RangeError(`${name}.low is above another OHLC value`);
  return result;
}
function anatomy(c: AnyMap): AnyMap {
  const bodyHigh = Math.max(c.open, c.close), bodyLow = Math.min(c.open, c.close);
  return {body_high: bodyHigh, body_low: bodyLow, body: bodyHigh-bodyLow, upper_shadow: c.high-bodyHigh, lower_shadow: bodyLow-c.low, range: c.high-c.low, direction: c.close>c.open?"bullish":c.close<c.open?"bearish":"neutral"};
}
function numberList(value: unknown, name: string): number[] {
  if (!Array.isArray(value)) throw new TypeError(`${name} must be an array`);
  return value.map((item,index)=>nonnegative(item, `${name}[${index}]`));
}
function median(values: number[]): number { const ordered=[...values].sort((a,b)=>a-b), n=ordered.length; return n%2?ordered[(n-1)/2]:(ordered[n/2-1]+ordered[n/2])/2; }
function referenceScales(data: AnyMap): [number|null,number|null,number] {
  const bodies=numberList(data.prior_bodies,"prior_bodies"), ranges=numberList(data.prior_ranges,"prior_ranges");
  if(bodies.length!==ranges.length) throw new RangeError("prior_bodies and prior_ranges must have equal length");
  if(bodies.some((body,index)=>body>ranges[index])) throw new RangeError("a prior body cannot exceed its range");
  if(bodies.length<5) return [null,null,bodies.length];
  return [median(bodies.slice(-10)),median(ranges.slice(-10)),Math.min(10,bodies.length)];
}
function context(data: AnyMap): string { if(!["uptrend","downtrend","sideways"].includes(data.trend_context)) throw new RangeError("trend_context must be uptrend, downtrend, or sideways"); return data.trend_context; }

function patternResult(args: AnyMap): AnyMap {
  let state:string, matched:boolean;
  if(args.bodyScale===null||args.rangeScale===null){state="warmup";matched=false;}
  else if(args.matchedGeometry&&args.requiredContext!==null&&args.actualContext!==args.requiredContext){state="wrong-context";matched=false;}
  else {matched=args.matchedGeometry;state=matched?"matched":"not-matched";}
  return {topic_id:args.topicId,pattern:args.title,matched,state,direction:args.a.direction,trend_context:args.actualContext,required_context:args.requiredContext,body:args.a.body,upper_shadow:args.a.upper_shadow,lower_shadow:args.a.lower_shadow,range:args.a.range,body_scale:args.bodyScale,range_scale:args.rangeScale,history_count:args.historyCount,geometry_score:args.geometryScore,checks:args.checks,failed_checks:Object.keys(args.checks).filter(key=>!args.checks[key])};
}

function shadowToBody(data: AnyMap): AnyMap {
  const c=candle(data.candle,"candle"), tick=positive(data.tick_size,"tick_size"), a=anatomy(c), body=a.body, effective=Math.max(body,tick);
  const upperRaw=body===0?null:a.upper_shadow/body, lowerRaw=body===0?null:a.lower_shadow/body, upperEffective=a.upper_shadow/effective, lowerEffective=a.lower_shadow/effective;
  const dominant=upperEffective>lowerEffective?"upper":lowerEffective>upperEffective?"lower":"balanced";
  return {state:body===0?"zero-body":"calculated",body,upper_shadow:a.upper_shadow,lower_shadow:a.lower_shadow,range:a.range,tick_size:tick,effective_body:effective,upper_to_body:upperRaw,lower_to_body:lowerRaw,upper_to_effective_body:upperEffective,lower_to_effective_body:lowerEffective,upper_to_range:a.range===0?null:a.upper_shadow/a.range,lower_to_range:a.range===0?null:a.lower_shadow/a.range,dominant_shadow:dominant,dominant_shadow_ratio:Math.max(upperEffective,lowerEffective)};
}

function gapClassification(data: AnyMap): AnyMap {
  const previous=candle(data.previous,"previous"), current=candle(data.current,"current"), tick=positive(data.tick_size,"tick_size"), minimumTicks=nonnegative(data.minimum_gap_ticks,"minimum_gap_ticks"), threshold=tick*minimumTicks;
  const pa=anatomy(previous), ca=anatomy(current);
  const candidates:AnyMap={up:{open:current.open-previous.close,body:ca.body_low-pa.body_high,"full-range":current.low-previous.high},down:{open:previous.close-current.open,body:pa.body_low-ca.body_high,"full-range":previous.low-current.high}};
  const qualifies=(distance:number)=>distance>0&&distance>=threshold;
  let direction="none", gapType="overlap", distance=0;
  outer: for(const candidateDirection of ["up","down"]) for(const candidateType of ["full-range","body","open"]){const d=candidates[candidateDirection][candidateType];if(qualifies(d)){direction=candidateDirection;gapType=candidateType;distance=d;break outer;}}
  return {state:direction!=="none"?"gap":"overlap",gap_direction:direction,gap_type:gapType,gap_distance:distance,threshold_distance:threshold,open_gap_up:qualifies(candidates.up.open),open_gap_down:qualifies(candidates.down.open),body_gap_up:qualifies(candidates.up.body),body_gap_down:qualifies(candidates.down.body),full_range_gap_up:qualifies(candidates.up["full-range"]),full_range_gap_down:qualifies(candidates.down["full-range"]),signed_open_gap:current.open-previous.close,previous_close:previous.close,current_open:current.open};
}

function trendContext(data: AnyMap): AnyMap {
  const closes=numberList(data.prior_closes,"prior_closes"), ranges=numberList(data.prior_ranges,"prior_ranges"), lookback=data.lookback;
  if(!Number.isInteger(lookback)||lookback<3) throw new RangeError("lookback must be an integer of at least 3");
  const efficiencyFloor=nonnegative(data.minimum_efficiency,"minimum_efficiency"), moveFloor=nonnegative(data.minimum_move_scale,"minimum_move_scale");
  if(efficiencyFloor>1) throw new RangeError("minimum_efficiency cannot exceed 1");
  if(closes.length!==ranges.length) throw new RangeError("prior_closes and prior_ranges must have equal length");
  if(closes.length<lookback) return {state:"warmup",trend_context:"warmup",history_count:closes.length,net_move:null,path_move:null,efficiency:null,range_scale:null,normalized_move:null};
  const sample=closes.slice(-lookback), rangeSample=ranges.slice(-lookback), net=sample.at(-1)!-sample[0];
  let path=0;for(let i=1;i<sample.length;i++)path+=Math.abs(sample[i]-sample[i-1]);
  const efficiency=path===0?0:Math.abs(net)/path, scale=median(rangeSample);
  if(scale===0)return {state:"zero-scale",trend_context:"sideways",history_count:lookback,net_move:net,path_move:path,efficiency,range_scale:scale,normalized_move:null};
  const normalized=net/scale, trend=efficiency>=efficiencyFloor&&normalized>=moveFloor?"uptrend":efficiency>=efficiencyFloor&&normalized<=-moveFloor?"downtrend":"sideways";
  return {state:"ready",trend_context:trend,history_count:lookback,net_move:net,path_move:path,efficiency,range_scale:scale,normalized_move:normalized};
}

function singlePattern(topicId:string,data:AnyMap):AnyMap{
  const c=candle(data.current,"current"),tick=positive(data.tick_size,"tick_size"),actualContext=context(data),[bodyScale,rangeScale,count]=referenceScales(data),a=anatomy(c);
  if(bodyScale===null||rangeScale===null)return patternResult({topicId,title:F02_TITLES[topicId],a,matchedGeometry:false,requiredContext:null,actualContext,bodyScale:null,rangeScale:null,historyCount:count,geometryScore:0,checks:{minimum_history:false}});
  const effectiveBody=Math.max(a.body,tick),dojiLimit=.10*rangeScale,shortShadowLimit=.05*rangeScale;let checks:Record<string,boolean>,requiredContext:string|null=null,score:number;
  switch(topicId){
    case"D06-F02-A01":checks={near_zero_body:a.body<=dojiLimit};score=1-a.body/Math.max(dojiLimit,tick);break;
    case"D06-F02-A02":checks={doji_body:a.body<=dojiLimit,short_upper_shadow:a.upper_shadow<=shortShadowLimit,long_lower_shadow:a.lower_shadow>=.60*rangeScale};score=a.lower_shadow/Math.max(rangeScale,tick);break;
    case"D06-F02-A03":checks={doji_body:a.body<=dojiLimit,long_upper_shadow:a.upper_shadow>=.60*rangeScale,short_lower_shadow:a.lower_shadow<=shortShadowLimit};score=a.upper_shadow/Math.max(rangeScale,tick);break;
    case"D06-F02-A04":checks={long_body:a.body>=1.20*bodyScale,short_upper_shadow:a.upper_shadow<=shortShadowLimit,short_lower_shadow:a.lower_shadow<=shortShadowLimit};score=a.body/Math.max(bodyScale,tick);break;
    case"D06-F02-A05":checks={short_body:a.body<=.60*bodyScale+1e-12,material_upper_shadow:a.upper_shadow+1e-12>=Math.max(a.body,.20*rangeScale),material_lower_shadow:a.lower_shadow+1e-12>=Math.max(a.body,.20*rangeScale)};score=Math.min(a.upper_shadow,a.lower_shadow)/effectiveBody;break;
    case"D06-F02-A06":case"D06-F02-A07":requiredContext=topicId.endsWith("A06")?"downtrend":"uptrend";checks={compact_body:a.body<=.75*bodyScale,long_lower_shadow:a.lower_shadow>=2*effectiveBody,short_upper_shadow:a.upper_shadow<=.25*effectiveBody,body_near_high:a.upper_shadow<=.25*Math.max(a.range,tick)};score=a.lower_shadow/effectiveBody;break;
    case"D06-F02-A08":case"D06-F02-A09":requiredContext=topicId.endsWith("A08")?"downtrend":"uptrend";checks={compact_body:a.body<=.75*bodyScale,long_upper_shadow:a.upper_shadow>=2*effectiveBody,short_lower_shadow:a.lower_shadow<=.25*effectiveBody,body_near_low:a.lower_shadow<=.25*Math.max(a.range,tick)};score=a.upper_shadow/effectiveBody;break;
    default:throw new RangeError(`unsupported single-candle topic ${topicId}`);
  }
  return patternResult({topicId,title:F02_TITLES[topicId],a,matchedGeometry:Object.values(checks).every(Boolean),requiredContext,actualContext,bodyScale,rangeScale,historyCount:count,geometryScore:score,checks});
}

function twoPattern(topicId:string,data:AnyMap):AnyMap{
  const first=candle(data.first,"first"),second=candle(data.second,"second"),tick=positive(data.tick_size,"tick_size"),tolerance=tick*nonnegative(data.price_tolerance_ticks,"price_tolerance_ticks"),actualContext=context(data),[bodyScale,rangeScale,count]=referenceScales(data),a=anatomy(first),b=anatomy(second);
  if(bodyScale===null||rangeScale===null){const result=patternResult({topicId,title:F03_TITLES[topicId],a:{body:b.body,upper_shadow:b.upper_shadow,lower_shadow:b.lower_shadow,range:b.range,direction:b.direction},matchedGeometry:false,requiredContext:null,actualContext,bodyScale:null,rangeScale:null,historyCount:count,geometryScore:0,checks:{minimum_history:false}});Object.assign(result,{first_direction:a.direction,second_direction:b.direction,first_body:a.body,second_body:b.body,price_tolerance:tolerance});return result;}
  const longFirst=a.body>=1.20*bodyScale,shortSecond=b.body<=.60*bodyScale,midpoint=(first.open+first.close)/2;let requiredContext:string,checks:Record<string,boolean>,score:number;
  switch(topicId){
    case"D06-F03-A01":requiredContext="downtrend";checks={first_bearish:a.direction==="bearish",second_bullish:b.direction==="bullish",lower_end_engulfed:b.body_low<=a.body_low,upper_end_engulfed:b.body_high>=a.body_high,strictly_larger_body:b.body>a.body};score=b.body/Math.max(a.body,tick);break;
    case"D06-F03-A02":requiredContext="uptrend";checks={first_bullish:a.direction==="bullish",second_bearish:b.direction==="bearish",lower_end_engulfed:b.body_low<=a.body_low,upper_end_engulfed:b.body_high>=a.body_high,strictly_larger_body:b.body>a.body};score=b.body/Math.max(a.body,tick);break;
    case"D06-F03-A03":requiredContext="downtrend";checks={first_bearish:a.direction==="bearish",long_first:longFirst,second_bullish:b.direction==="bullish",short_second:shortSecond,contained_low:b.body_low>=a.body_low,contained_high:b.body_high<=a.body_high,strict_containment:b.body_low>a.body_low||b.body_high<a.body_high};score=1-b.body/Math.max(a.body,tick);break;
    case"D06-F03-A04":requiredContext="uptrend";checks={first_bullish:a.direction==="bullish",long_first:longFirst,second_bearish:b.direction==="bearish",short_second:shortSecond,contained_low:b.body_low>=a.body_low,contained_high:b.body_high<=a.body_high,strict_containment:b.body_low>a.body_low||b.body_high<a.body_high};score=1-b.body/Math.max(a.body,tick);break;
    case"D06-F03-A05":requiredContext="downtrend";checks={first_bearish:a.direction==="bearish",long_first:longFirst,second_bullish:b.direction==="bullish",gap_below_first_low:second.open<=first.low-tick,close_above_midpoint:second.close>midpoint,close_below_first_open:second.close<first.open};score=(second.close-midpoint)/Math.max(a.body,tick);break;
    case"D06-F03-A06":requiredContext="uptrend";checks={first_bullish:a.direction==="bullish",long_first:longFirst,second_bearish:b.direction==="bearish",gap_above_first_high:second.open>=first.high+tick,close_below_midpoint:second.close<midpoint,close_above_first_open:second.close>first.open};score=(midpoint-second.close)/Math.max(a.body,tick);break;
    case"D06-F03-A07":requiredContext="uptrend";checks={first_bullish:a.direction==="bullish",second_bearish:b.direction==="bearish",highs_within_tolerance:Math.abs(first.high-second.high)<=tolerance,first_upper_rejection:a.upper_shadow>=.50*Math.max(a.body,tick),second_upper_rejection:b.upper_shadow>=.50*Math.max(b.body,tick)};score=Math.max(0,1-Math.abs(first.high-second.high)/Math.max(tolerance,tick));break;
    case"D06-F03-A08":requiredContext="downtrend";checks={first_bearish:a.direction==="bearish",second_bullish:b.direction==="bullish",lows_within_tolerance:Math.abs(first.low-second.low)<=tolerance,first_lower_rejection:a.lower_shadow>=.50*Math.max(a.body,tick),second_lower_rejection:b.lower_shadow>=.50*Math.max(b.body,tick)};score=Math.max(0,1-Math.abs(first.low-second.low)/Math.max(tolerance,tick));break;
    default:throw new RangeError(`unsupported two-candle topic ${topicId}`);
  }
  const result=patternResult({topicId,title:F03_TITLES[topicId],a:{body:b.body,upper_shadow:b.upper_shadow,lower_shadow:b.lower_shadow,range:b.range,direction:b.direction},matchedGeometry:Object.values(checks).every(Boolean),requiredContext,actualContext,bodyScale,rangeScale,historyCount:count,geometryScore:score,checks});
  Object.assign(result,{first_direction:a.direction,second_direction:b.direction,first_body:a.body,second_body:b.body,first_body_low:a.body_low,first_body_high:a.body_high,second_body_low:b.body_low,second_body_high:b.body_high,first_high:first.high,second_high:second.high,first_low:first.low,second_low:second.low,midpoint,price_tolerance:tolerance});return result;
}

export function calculate(topicId:string,inputs:AnyMap):AnyMap{
  if(!inputs||typeof inputs!=="object"||Array.isArray(inputs))throw new TypeError("inputs must be an object");
  if(topicId==="D06-F01-A03")return shadowToBody(inputs);
  if(topicId==="D06-F01-A04")return gapClassification(inputs);
  if(topicId==="D06-F01-A05")return trendContext(inputs);
  if(topicId in F02_TITLES)return singlePattern(topicId,inputs);
  if(topicId in F03_TITLES)return twoPattern(topicId,inputs);
  throw new RangeError(`unsupported topic id ${topicId}`);
}
Full-height labplaygroundOpen full screen