D08-F02-A03 / Complete engineering topic

Triple Top

A production-minded guide to Triple Top.

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

A completed chart makes triple top look obvious. A live detector has a harder job: decide which pivots are actually knowable, measure whether the tests qualify, and refuse to confirm until a later close crosses the neckline. This tutorial builds that causal decision from data contract to code.

What you will build

The output is an auditable bearish event with a state, reason, structural pivots, candidate-availability index, geometry metrics, projected neckline, and confirmation index. It is useful for chart annotation, research labeling, and feature engineering. It is not a prediction or trade recommendation.

You should already understand Causal Pivot Detection: a pivot drawn at index ii is only knowable after the right-hand window closes. Here pivot_right=2, so a pivot at 14 first exists for the algorithm at 16.

Which claims come from evidence?

CFA Institute supplies the conventional reversal-pattern vocabulary. Lo, Mamaysky, and Wang motivate systematic recognition because unaided visual charting is subjective. TradingView's repainting documentation explains why later-bar pivot confirmation must remain visible. Those sources do not prescribe our 2.5% tolerance, five-bar separation, or 15-bar expiry; those are implementation choices for this teaching contract.

The selected variant

The detector consumes finalized, strictly positive close values and seeks high → low → high → low → high. It uses strict two-left/two-right pivots, contiguous alternating pivot windows, percentage similarity, minimum retracement, a prior-trend check, and a neckline break after candidate availability. OHLC pivots, volume confirmation, volatility scaling, retrospective best fit, candidate ranking, and execution are excluded variants.

Anatomy before algebra

The shaded region in this synthetic illustration is the allowed test-level band. Amber markers show geometric pivot positions and their later availability; the green marker is the first qualifying neckline close.

Triple Top synthetic causal anatomy

Open the full-size anatomy SVG.

Formula and symbols

For comparable tests pjp_j with mean pˉ\bar p,

s=maxjpjminjpjpˉ.s=\frac{\max_j p_j-\min_j p_j}{\bar p}.

Canonical similarity requires s0.025s\le0.025. Top retracement is (pˉv)/pˉ(\bar p-v)/\bar p; bottom retracement is (vpˉ)/pˉ(v-\bar p)/\bar p; canonical minimum is 0.05. Head variants also require 0.05 middle-pivot dominance.

Two neckline anchors project

Nt=n1+n2n1i2i1(ti1).N_t=n_1+\frac{n_2-n_1}{i_2-i_1}(t-i_1).

A double pattern uses its one opposing pivot as a horizontal neckline. A close exactly on the line is not a break.

Independently checked canonical arithmetic

These expected values are author-derived from the labeled synthetic pivot inputs; they are stored separately from both implementations in examples/checkpoints.json.

  • Structural pivot prices: [120.0, 110.0, 119.5, 111.0, 120.5]
  • Comparable test prices: [120.0, 119.5, 120.5]; mean test level: 120.0000
  • Normalized spread: (max − min) / mean = 0.008333 = 0.833%, below the 2.5% tolerance.
  • Opposing retracements: [8.333, 7.5]%; minimum 7.500%, above the 5% rule.
  • Prior directional move: 0.091667 = 9.167%, above the 3% minimum.
  • Candidate availability: final pivot index 42 plus two right bars = index 44.
  • Neckline at confirmation index 50: 112.071429.
  • Confirmation inequality: close 111.6000 < 112.0714.

Displayed percentages are rounded to three decimal places; detector comparisons use unrounded binary floating-point values.

State flow

Rendering system map…

Takeaway: valid geometry creates a candidate; only a later finalized close can create the confirmed event.

Reference implementation walkthrough

The implementations perform four explicit layers:

  1. validate observations and parameters;
  2. emit strict pivots only when their right-hand confirmation window exists;
  3. evaluate spacing, width, prior trend, similarity, retracement, and any head dominance;
  4. project the neckline from candidate availability through the inclusive deadline.
Python
result = detect_reversal(close, "triple_top")
if result["state"] == "candidate":
    print(result["event"]["metrics"])
elif result["state"] == "confirmed":
    print(result["event"]["confirmation_index"], result["event"]["neckline"])
else:
    print(result["reason"])

The TypeScript API returns the same field and reason-code semantics. Neither API mutates the caller's array.

Use the lab as an audit instrument

Open the guided playground. Start at the informative first-pivot prefix, use Back to inspect earlier context, and step to the candidate. The metric cards show spread, minimum retracement, prior move, and any head dominance. The audit table separates pivot index from “known at” index.

Compare five scenarios:

  • no neckline break expires rather than confirming a silhouette;
  • mismatched tests fail the normalized spread or shoulder rule;
  • shallow opposing swings fail retracement;
  • a flat lead-in fails the reversal premise;
  • the canonical series confirms only after the boundary crossing.

Switching presets is sensitivity analysis, not optimization evidence.

Boundaries worth testing

A tied neighbor is not a strict pivot. Equality at the 2.5% maximum tolerance passes; equality at the 5% minimum retracement passes. Equality on the neckline does not confirm. A break visible before the final pivot becomes knowable is not backdated. The first bar after an unbroken deadline rejects the candidate.

Python and TypeScript tests cover those conventions, malformed values, nonmutation, prefix causality, independent checkpoints, and the four rejection fixtures.

Closest-pattern routing

The extra resistance test increases the geometry length and creates a sloped neckline from two trough anchors; it does not by itself prove a stronger trading edge. More generally, five comparable tests route to a triple structure; a dominant middle test with comparable outer shoulders routes to a head-and-shoulders structure. The detector distinguishes them with equations, not a memorized silhouette.

Historical and empirical boundary

A named market case is deferred until point-in-time bars, identity, session, adjustment, correction, parameter, access-time, licensing, and redistribution evidence are available. The New York Fed head-and-shoulders study demonstrates that an objective rule can be researched, but its FX design and performance result are not evidence for this detector.

Passing implementation and geometry tests answers “did we implement the declared rule?” It does not answer “does this retain value after costs, bias controls, and out-of-sample testing?”

Practical limitations

Close-only geometry ignores intrabar path, volume, gaps, spread, fees, liquidity, corrections, and venue rules. Overlapping candidates need a separate ranking policy. Thresholds may behave differently across volatility regimes and bar intervals. Treat the event as a research feature, not a standalone decision.

Asset map

AssetPlacementPurposePath
Anatomy SVGGeometry sectionPivots, tolerance band, neckline, knowledge delayvisuals/static/triple-top-anatomy.svg
State diagramState-flow sectionCandidate versus confirmation branchingvisuals/mermaid/triple-top-states.md
Guided labAudit sectionCausal stepping, metrics, scenarios, presetsvisuals/animated/triple-top-lab.html
CheckpointsWorked arithmeticIndependent expected valuesexamples/checkpoints.json

Summary and next tutorial

Triple Top is not “three or five points that look right.” It is a timed contract: confirmed pivots, measured geometry, explicit exclusions, and a later neckline event. Continue to Triple Bottom and compare which sign, test-level, and dominance rules change while the causal discipline stays fixed.

Full source roles and limitations are recorded in REFERENCES.md.

Triple Top causal state flow

Purpose: separate pivot availability, geometry qualification, neckline confirmation, and explicit rejection.

Rendering system map…

Takeaway: a recognizable shape is still only a candidate until a finalized close crosses the neckline within the frozen confirmation window.

ReferencesPrimary sources and evidence notes

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

This ledger separates sourced claims from implementation choices. No source is used to imply that the package's thresholds are universal or that the pattern is profitable.

Code
## CFA-REVERSALS — Members’ Guide to 2021 Refresher Readings

- Owner/author: CFA Institute
- Source type: Professional-body curriculum summary
- Publication date: 2021
- Version: Public PDF accessed 2026-07-26
- Jurisdiction/scope: General investment education
- URL: https://www.cfainstitute.org/sites/default/files/-/media/documents/book/curriculum-update/2021-member-guide-refresher-readings.PDF
- Accessed: 2026-07-27
- Supports: Classifies head and shoulders, inverse head and shoulders, double tops/bottoms, and triple tops/bottoms as common reversal chart patterns.
- Limitations: Does not prescribe this package’s pivot window, numeric tolerances, causal state machine, or performance claim.

## LMW-2000 — Foundations of Technical Analysis: Computational Algorithms, Statistical Inference, and Empirical Implementation

- Owner/author: Andrew W. Lo, Harry Mamaysky, and Jiang Wang
- Source type: Original academic working paper and Journal of Finance article
- Publication date: 2000
- Version: NBER Working Paper 7613; DOI 10.3386/w7613
- Jurisdiction/scope: Empirical U.S. equity study
- URL: https://www.nber.org/papers/w7613
- Accessed: 2026-07-27
- Supports: Documents the subjectivity problem in visual charting and a systematic computational approach based on extrema of a smoothed price path, including double tops/bottoms and head-and-shoulders structures.
- Limitations: Its smoothing and empirical design are not reproduced here; it does not validate this package’s thresholds or profitability.

## NYFED-HS — Head and Shoulders: Not Just a Flaky Pattern

- Owner/author: C. L. Osler and P. H. Kevin Chang, Federal Reserve Bank of New York
- Source type: Original central-bank staff report
- Publication date: August 1995
- Version: Staff Report No. 4
- Jurisdiction/scope: Historical foreign-exchange research
- URL: https://www.newyorkfed.org/research/staff_reports/sr4.html
- Accessed: 2026-07-27
- Supports: Shows that head-and-shoulders can be translated into an objective computer-implemented real-time rule and evaluated against a stated empirical design.
- Limitations: Its FX sample, rule, and performance results are not transferred to this detector or to current markets.

## TV-REPAINT — Repainting

- Owner/author: TradingView Pine Script documentation
- Source type: Official maintained technical documentation
- Publication date: Current documentation
- Version: Version 6 documentation accessed 2026-07-26
- Jurisdiction/scope: Platform behavior
- URL: https://www.tradingview.com/pine-script-docs/concepts/repainting/
- Accessed: 2026-07-27
- Supports: Explains that pivots require later observations, plotting them back on the pivot date can mislead, and confirmed-bar logic trades timeliness for reproducibility.
- Limitations: Pine behavior is evidence for timing risk, not a universal detector definition.

## SCIPY-PEAKS — scipy.signal.find_peaks

- Owner/author: SciPy
- Source type: Official maintained library documentation
- Publication date: Current documentation
- Version: SciPy 1.17 documentation accessed 2026-07-26
- Jurisdiction/scope: Technical library
- URL: https://docs.scipy.org/doc/scipy/reference/generated/scipy.signal.find_peaks.html
- Accessed: 2026-07-27
- Supports: Documents general peak properties such as distance, prominence, width, and plateau handling that must be explicit in automated peak selection.
- Limitations: The package does not depend on SciPy and instead freezes a small strict confirmed-pivot rule for cross-language parity.

Claim-to-source map

Package claimEvidenceStatus
The named structure is conventionally classified as a reversal patternCFA-REVERSALSSupported as terminology/classification
Visual chart recognition is subjective and can be translated into computational rulesLMW-2000Supported
Head-and-shoulders rules can be evaluated as objective real-time algorithmsNYFED-HSSupported for its historical FX design only
A pivot becomes knowable only after later bars; back-plotting can misleadTV-REPAINTSupported as implementation-timing risk
Automated peaks need explicit selection propertiesSCIPY-PEAKSSupported generally
The numeric windows and tolerances in this package are authoritative market constantsNoneExplicitly unsupported; implementation choices
The detector produces excess returnsNoneNot claimed
reversal.ts
export type Pivot = {
  kind: "high" | "low";
  index: number;
  confirmation_index: number;
  price: number;
};

export type Detection = {
  pattern: string;
  state: "searching" | "candidate" | "confirmed" | "rejected";
  reason: string;
  event: Record<string, unknown> | null;
  events: Record<string, unknown>[];
  pivots: Pivot[];
  parameters: Record<string, number>;
};

const PATTERNS: Record<string, { types: Array<"high" | "low">; side: "top" | "bottom" }> = {
  double_top: { types: ["high", "low", "high"], side: "top" },
  double_bottom: { types: ["low", "high", "low"], side: "bottom" },
  triple_top: { types: ["high", "low", "high", "low", "high"], side: "top" },
  triple_bottom: { types: ["low", "high", "low", "high", "low"], side: "bottom" },
  head_and_shoulders: { types: ["high", "low", "high", "low", "high"], side: "top" },
  inverse_head_and_shoulders: { types: ["low", "high", "low", "high", "low"], side: "bottom" },
};

const DEFAULTS: Record<string, number> = {
  pivot_left: 2, pivot_right: 2, min_swing_bars: 5, max_pattern_bars: 40,
  max_confirmation_bars: 15, trend_lookback: 6, level_tolerance: 0.025,
  shoulder_tolerance: 0.025, min_retracement: 0.05, head_margin: 0.05,
  min_prior_trend: 0.03, break_buffer: 0,
};

function validated(close: number[], pattern: string, overrides: Record<string, number>) {
  if (!(pattern in PATTERNS)) throw new Error(`unsupported pattern: ${pattern}`);
  if (!Array.isArray(close) || close.length === 0) throw new Error("close must be a non-empty array");
  const values = close.map((value) => {
    if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
      throw new Error("close values must be finite and strictly positive");
    }
    return value;
  });
  for (const key of Object.keys(overrides)) {
    if (!(key in DEFAULTS)) throw new Error(`unknown parameter: ${key}`);
  }
  const params = { ...DEFAULTS, ...overrides };
  for (const key of ["pivot_left", "pivot_right", "min_swing_bars", "max_pattern_bars", "max_confirmation_bars", "trend_lookback"]) {
    if (!Number.isInteger(params[key]) || params[key] < 1) throw new Error(`${key} must be a positive integer`);
  }
  for (const key of ["level_tolerance", "shoulder_tolerance", "min_retracement", "head_margin", "min_prior_trend", "break_buffer"]) {
    if (!Number.isFinite(params[key]) || params[key] < 0) throw new Error(`${key} must be finite and nonnegative`);
  }
  if (params.level_tolerance >= 1 || params.shoulder_tolerance >= 1) throw new Error("similarity tolerances must be below 1");
  return { values, params };
}

export function confirmedPivots(close: number[], left = 2, right = 2): Pivot[] {
  const { values, params } = validated(close, "double_top", { pivot_left: left, pivot_right: right });
  const pivots: Pivot[] = [];
  for (let index = params.pivot_left; index < values.length - params.pivot_right; index += 1) {
    const center = values[index];
    const neighbors = values.slice(index - params.pivot_left, index).concat(values.slice(index + 1, index + params.pivot_right + 1));
    const kind = neighbors.every((value) => center > value) ? "high" : neighbors.every((value) => center < value) ? "low" : null;
    if (kind) pivots.push({ kind, index, confirmation_index: index + params.pivot_right, price: center });
  }
  return pivots;
}

function spread(values: number[]) {
  const mean = values.reduce((a, b) => a + b, 0) / values.length;
  return (Math.max(...values) - Math.min(...values)) / mean;
}

function line(a: Pivot, b: Pivot, index: number) {
  if (a.index === b.index) return a.price;
  return a.price + ((b.price - a.price) / (b.index - a.index)) * (index - a.index);
}

function metrics(pattern: string, pivots: Pivot[]) {
  const prices = pivots.map((pivot) => pivot.price);
  let tests: number[], retracements: number[], headDominance: number | null = null;
  if (prices.length === 3) {
    tests = [prices[0], prices[2]];
    const mean = (tests[0] + tests[1]) / 2;
    retracements = [PATTERNS[pattern].side === "top" ? (mean - prices[1]) / mean : (prices[1] - mean) / mean];
  } else {
    tests = pattern.startsWith("triple_") ? [prices[0], prices[2], prices[4]] : [prices[0], prices[4]];
    const mean = tests.reduce((a, b) => a + b, 0) / tests.length;
    retracements = PATTERNS[pattern].side === "top"
      ? [(mean - prices[1]) / mean, (mean - prices[3]) / mean]
      : [(prices[1] - mean) / mean, (prices[3] - mean) / mean];
    if (pattern.includes("head_and_shoulders")) {
      headDominance = PATTERNS[pattern].side === "top" ? (prices[2] - mean) / mean : (mean - prices[2]) / mean;
    }
  }
  const mean = tests.reduce((a, b) => a + b, 0) / tests.length;
  return {
    test_mean: mean,
    level_spread: spread(tests),
    retracement_min: Math.min(...retracements),
    head_dominance: headDominance,
    formation_bars: pivots.at(-1)!.index - pivots[0].index,
    min_separation: Math.min(...pivots.slice(1).map((pivot, index) => pivot.index - pivots[index].index)),
  };
}

function shape(pattern: string, pivots: Pivot[], params: Record<string, number>) {
  const prices = pivots.map((pivot) => pivot.price);
  if (pattern === "double_top" || pattern === "double_bottom") {
    const outer = [prices[0], prices[2]];
    if (spread(outer) > params.level_tolerance) return { valid: false, reason: "outer-level-mismatch", anchors: [pivots[1]] };
    const mean = (outer[0] + outer[1]) / 2;
    const retracement = pattern === "double_top" ? (mean - prices[1]) / mean : (prices[1] - mean) / mean;
    if (retracement < params.min_retracement) return { valid: false, reason: "retracement-too-shallow", anchors: [pivots[1]] };
    return { valid: true, reason: "geometry-valid", anchors: [pivots[1]] };
  }
  if (pattern === "triple_top" || pattern === "triple_bottom") {
    const tests = [prices[0], prices[2], prices[4]];
    const mean = tests.reduce((a, b) => a + b, 0) / 3;
    if (spread(tests) > params.level_tolerance) return { valid: false, reason: "outer-level-mismatch", anchors: [pivots[1], pivots[3]] };
    const retracements = pattern === "triple_top"
      ? [(mean - prices[1]) / mean, (mean - prices[3]) / mean]
      : [(prices[1] - mean) / mean, (prices[3] - mean) / mean];
    if (Math.min(...retracements) < params.min_retracement) return { valid: false, reason: "retracement-too-shallow", anchors: [pivots[1], pivots[3]] };
    return { valid: true, reason: "geometry-valid", anchors: [pivots[1], pivots[3]] };
  }
  const shoulders = [prices[0], prices[4]];
  const mean = (shoulders[0] + shoulders[1]) / 2;
  if (spread(shoulders) > params.shoulder_tolerance) return { valid: false, reason: "shoulder-mismatch", anchors: [pivots[1], pivots[3]] };
  const dominant = pattern === "head_and_shoulders" ? (prices[2] - mean) / mean : (mean - prices[2]) / mean;
  if (dominant < params.head_margin) return { valid: false, reason: "head-not-dominant", anchors: [pivots[1], pivots[3]] };
  const retracements = pattern === "head_and_shoulders"
    ? [(mean - prices[1]) / mean, (mean - prices[3]) / mean]
    : [(prices[1] - mean) / mean, (prices[3] - mean) / mean];
  if (Math.min(...retracements) < params.min_retracement) return { valid: false, reason: "retracement-too-shallow", anchors: [pivots[1], pivots[3]] };
  return { valid: true, reason: "geometry-valid", anchors: [pivots[1], pivots[3]] };
}

function evaluation(close: number[], pattern: string, pivots: Pivot[], params: Record<string, number>): Record<string, unknown> {
  const spec = PATTERNS[pattern];
  const first = pivots[0], last = pivots[pivots.length - 1];
  const base = {
    pattern, pivot_indices: pivots.map((p) => p.index), pivot_prices: pivots.map((p) => p.price),
    pivot_types: pivots.map((p) => p.kind), candidate_available_index: Math.max(...pivots.map((p) => p.confirmation_index)),
    confirmation_index: null, neckline: null, metrics: metrics(pattern, pivots),
  };
  if (pivots.slice(1).some((p, i) => p.index - pivots[i].index < params.min_swing_bars)) return { ...base, state: "rejected", reason: "pivots-too-close" };
  if (last.index - first.index > params.max_pattern_bars) return { ...base, state: "rejected", reason: "formation-too-wide" };
  if (first.index < params.trend_lookback) return { ...base, state: "rejected", reason: "insufficient-prior-context" };
  const prior = close[first.index - params.trend_lookback];
  const priorMove = spec.side === "top" ? (first.price - prior) / first.price : (prior - first.price) / first.price;
  const baseWithTrend = { ...base, prior_move: priorMove };
  if (priorMove < params.min_prior_trend) return { ...baseWithTrend, state: "rejected", reason: "prior-trend-too-weak" };
  const geometry = shape(pattern, pivots, params);
  if (!geometry.valid) return { ...baseWithTrend, state: "rejected", reason: geometry.reason };
  const start = baseWithTrend.candidate_available_index;
  const deadline = last.index + params.max_confirmation_bars;
  for (let index = start; index <= Math.min(close.length - 1, deadline); index += 1) {
    const neckline = geometry.anchors.length === 1 ? geometry.anchors[0].price : line(geometry.anchors[0], geometry.anchors[1], index);
    const broken = spec.side === "top" ? close[index] < neckline * (1 - params.break_buffer) : close[index] > neckline * (1 + params.break_buffer);
    if (broken) return { ...baseWithTrend, state: "confirmed", reason: "neckline-break-confirmed", confirmation_index: index, neckline, direction: spec.side === "top" ? "bearish" : "bullish" };
  }
  const neckline = geometry.anchors.length === 1 ? geometry.anchors[0].price : line(geometry.anchors[0], geometry.anchors[1], close.length - 1);
  if (close.length - 1 > deadline) return { ...baseWithTrend, state: "rejected", reason: "confirmation-window-expired", neckline };
  return { ...baseWithTrend, state: "candidate", reason: "geometry-valid-awaiting-neckline-break", neckline };
}

export function detectReversal(close: number[], pattern: string, overrides: Record<string, number> = {}): Detection {
  const { values, params } = validated(close, pattern, overrides);
  const pivots = confirmedPivots(values, params.pivot_left, params.pivot_right);
  const expected = PATTERNS[pattern].types;
  const evaluations: Record<string, unknown>[] = [];
  for (let start = 0; start <= pivots.length - expected.length; start += 1) {
    const window = pivots.slice(start, start + expected.length);
    if (window.every((pivot, index) => pivot.kind === expected[index])) evaluations.push(evaluation(values, pattern, window, params));
  }
  const confirmed = evaluations.filter((item) => item.state === "confirmed");
  const candidates = evaluations.filter((item) => item.state === "candidate");
  const rejected = evaluations.filter((item) => item.state === "rejected");
  const selected = confirmed.at(-1) ?? candidates.at(-1) ?? rejected.at(-1) ?? null;
  return {
    pattern,
    state: (selected?.state as Detection["state"]) ?? "searching",
    reason: (selected?.reason as string) ?? "required confirmed pivot sequence not yet available",
    event: selected,
    events: confirmed,
    pivots,
    parameters: params,
  };
}

export function traceReversal(close: number[], pattern: string, overrides: Record<string, number> = {}) {
  validated(close, pattern, overrides);
  return close.map((value, index) => {
    const result = detectReversal(close.slice(0, index + 1), pattern, overrides);
    const event = result.event;
    return {
      index, close: value, state: result.state, reason: result.reason, pivot_count: result.pivots.length,
      pivot_indices: (event?.pivot_indices as number[]) ?? [], pivot_prices: (event?.pivot_prices as number[]) ?? [],
      candidate_available_index: (event?.candidate_available_index as number | null) ?? null,
      neckline: (event?.neckline as number | null) ?? null,
      confirmation_index: (event?.confirmation_index as number | null) ?? null,
      metrics: (event?.metrics as Record<string, number | null>) ?? null,
      prior_move: (event?.prior_move as number | null) ?? null,
      known_pivots: result.pivots.map((pivot) => ({ ...pivot })),
      confirmed: result.state === "confirmed",
    };
  });
}

export const double_top = (close: number[], params: Record<string, number> = {}) => detectReversal(close, "double_top", params);
export const double_bottom = (close: number[], params: Record<string, number> = {}) => detectReversal(close, "double_bottom", params);
export const triple_top = (close: number[], params: Record<string, number> = {}) => detectReversal(close, "triple_top", params);
export const triple_bottom = (close: number[], params: Record<string, number> = {}) => detectReversal(close, "triple_bottom", params);
export const head_and_shoulders = (close: number[], params: Record<string, number> = {}) => detectReversal(close, "head_and_shoulders", params);
export const inverse_head_and_shoulders = (close: number[], params: Record<string, number> = {}) => detectReversal(close, "inverse_head_and_shoulders", params);
Full-height labtriple top labOpen full screen