D04-F02-A01 / Complete engineering topic

Traditional McClellan Oscillator: Compare Fast and Slow Raw Breadth

A production-minded guide to Traditional McClellan Oscillator: Compare Fast and Slow Raw Breadth.

D04 · MARKET BREADTH AND INTERNALS
D04-F02-A01Canonical / Tested / Open
D04 / D04-F02
Key concepts

The governed definitions this build depends on. Read them first if a term is unfamiliar.

Daily market breadth can reverse sharply.

One session may have 800 more advancing stocks than declining stocks. The next may have 600 more decliners than advancers. Looking at either value alone tells you what happened that day, but not how the recent breadth impulse compares with its slower background.

The Traditional McClellan Oscillator solves that problem with two memories of the same input:

  • a fast exponential moving average that moves 10% toward each new Net Advances value;
  • a slow exponential moving average that moves 5%;
  • the difference between them.

In compact form:

MOt=EMA10%(AtDt)EMA5%(AtDt)MO_t=EMA_{10\%}(A_t-D_t)-EMA_{5\%}(A_t-D_t)

When the fast breadth state is above the slow state, the oscillator is positive. When it is below, the oscillator is negative.

That sounds simple—and the recurrence is simple. The engineering difficulty lies in specifying the input universe, initialization, missing sessions, precision, revisions, and whether the implementation is traditional or ratio-adjusted.

What “traditional” means

Start with raw daily Net Advances:

xt=AtDtx_t=A_t-D_t

where AtA_t is advancing issues and DtD_t is declining issues in one declared market snapshot.

The original McClellan construction smooths this raw issue-count difference. McClellan Financial's first-party calculation note describes the input as the daily A-D difference, then applies 10% and 5% trends. Schwab's current study documentation likewise distinguishes a simple mode based on advances minus declines from a ratio-adjusted mode.

That distinction creates two separate catalog topics:

ConstructionInput before smoothingScale behavior
Traditional oscillatorADA-DRaw issue counts; population-sensitive
Ratio-adjusted oscillatorCommonly 1000(AD)/(A+D)1000(A-D)/(A+D)Proportional mover breadth

This article covers only the first.

If the EMA recurrence, span conversion, or initialization policy is unfamiliar, read the prerequisite tutorial EMA first. The oscillator is built from two independent EMA states.

The two EMA states

Let FtF_t be the fast 10% Trend:

Ft=Ft1+0.10(xtFt1)F_t=F_{t-1}+0.10(x_t-F_{t-1})

Equivalently:

Ft=0.10xt+0.90Ft1F_t=0.10x_t+0.90F_{t-1}

Let StS_t be the slow 5% Trend:

St=St1+0.05(xtSt1)S_t=S_{t-1}+0.05(x_t-S_{t-1})

or:

St=0.05xt+0.95St1S_t=0.05x_t+0.95S_{t-1}

The oscillator is:

MOt=FtStMO_t=F_t-S_t

The forecast-error form is the most intuitive. Each state looks at the distance between today's breadth and yesterday's smoothed state:

  • the fast EMA closes 10% of that distance;
  • the slow EMA closes 5%;
  • their gap reveals which state is leading.

One Net Advances input updating the fast and slow EMA states

With both prior states at zero and current Net Advances of +100:

Ft=0+0.10(1000)=10F_t=0+0.10(100-0)=10 St=0+0.05(1000)=5S_t=0+0.05(100-0)=5 MOt=105=+5MO_t=10-5=+5

The oscillator is not another average of Net Advances. It is the difference between two averages.

Why people call them 19- and 39-period EMAs

Technical-analysis software often labels the trends using the conventional EMA relationship:

α=2n+1\alpha=\frac{2}{n+1}

For the fast trend:

219+1=0.10\frac{2}{19+1}=0.10

For the slow trend:

239+1=0.05\frac{2}{39+1}=0.05

Therefore:

  • 10% Trend and 19-period EMA describe the same recurrence;
  • 5% Trend and 39-period EMA describe the same recurrence.

The labels do not mean that observations older than 19 or 39 sessions disappear. EMA influence decays geometrically but never becomes exactly zero. McClellan Financial explicitly warns that period language can be misleading for this reason.

Initialization is part of the algorithm

The recurrence needs prior states. Without an initialization rule, two implementations can use the same formula and still disagree early in the history.

This article declares a simple-average seed policy, consistent with the initialization note in StockCharts' maintained methodology.

Fast seed:

F19=119i=119xiF_{19}=\frac{1}{19}\sum_{i=1}^{19}x_i

The fast EMA then updates recursively from observation 20 onward.

Slow seed:

S39=139i=139xiS_{39}=\frac{1}{39}\sum_{i=1}^{39}x_i

The first oscillator value is available at observation 39:

MO39=F39S39MO_{39}=F_{39}-S_{39}

Before then:

  • observations 1–18 have no fast or slow EMA;
  • observations 19–38 have a fast EMA but no slow EMA;
  • the oscillator remains null.

Another vendor may seed both EMAs with the first input or may load hidden prior history. That can be valid, but it is a different initialization convention. Compare series only after checking their seed policy and history.

A reproducible calculation flow

Rendering system map…

Rejected or missing snapshots do not advance either EMA. An accepted zero-breadth observation does. In the reference implementation, missing, provisional, cancelled, conflicting, or series-break evidence makes the whole requested result non-resolved, so a partial path cannot masquerade as a valid oscillator history.

The worked example

The synthetic fixture begins with 39 balanced observations:

A=100,D=100,x=0A=100,\quad D=100,\quad x=0

Both simple-average seeds are therefore zero at observation 39. The next five inputs expose the recursive behavior:

ObservationNet AdvancesFast EMASlow EMAOscillator
3900.0000000.0000000.000000
40+10010.0000005.000000+5.000000
41+10019.0000009.750000+9.250000
42−1007.1000004.262500+2.837500
4306.3900004.049375+2.340625
44−100−4.249000−1.153094−3.095906

Fast EMA, slow EMA, and oscillator path for the worked fixture

Observation 42 is the crucial point. The current breadth input is negative, but the oscillator remains positive.

Fast update:

F42=19+0.10(10019)=7.1F_{42} = 19+0.10(-100-19) = 7.1

Slow update:

S42=9.75+0.05(1009.75)=4.2625S_{42} = 9.75+0.05(-100-9.75) = 4.2625

Oscillator:

MO42=7.14.2625=2.8375MO_{42}=7.1-4.2625=2.8375

The negative session pushed both states down. It did not push the fast state below the slow state yet.

At observation 44, another −100 input produces:

F44=4.249F_{44}=-4.249 S44=1.15309375S_{44}=-1.15309375 MO44=3.09590625MO_{44}=-3.09590625

Now the fast state is below the slow state, so the oscillator is negative.

A real historical reconstruction—and its evidence boundary

Nasdaq Trader publishes a year-to-date file containing official Nasdaq Advances, Declines, and Unchanged counts. To make the state behavior concrete, we applied this article's declared SMA-19/SMA-39 seed to the first 39 rows in the 2026 file, from 2026-01-02 through 2026-02-27, and then continued the same recurrence.

This is a teaching reconstruction, not an oscillator value published by Nasdaq. The issue counts in the first three numeric columns are provider observations; Net Advances, both EMAs, and the oscillator are author-derived.

SessionNasdaq advancesNasdaq declinesNasdaq unchangedDerived Net AdvancesDerived fast EMADerived slow EMADerived oscillator
2026-02-271,6523,206208−1,554−8.105022.8974−31.0025
2026-03-022,4432,431228+12−6.094522.3526−28.4471
2026-03-031,3593,542190−2,183−223.7851−87.9151−135.8700
2026-03-043,3681,509224+1,859−15.50669.4307−24.9373
2026-03-051,4303,454195−2,024−216.3559−92.2408−124.1151
2026-03-061,4613,384238−1,923−387.0203−183.7788−203.2415

The 2026-03-04 row is the useful event. Advances exceeded declines by 1,859, but the derived oscillator remained negative because the fast state was still below the slow state after the preceding history. This demonstrates the algorithm's memory; it does not show that the oscillator predicted, caused, or profited from any market move.

Evidence boundary: the Nasdaq Trader file page, 2026 CSV, and official field definitions support the displayed provider counts. The free file does not, by itself, establish our strict roster, exclusions, classification completeness, stable-ID policy, availability timestamps, or revision chain. We therefore do not label this reconstruction a resolved production result. The file was accessed on 2026-07-23 and may later be revised.

Oscillator sign is not current-session sign

This relationship is exact:

MOt>0    Ft>StMO_t>0\iff F_t>S_t

It is not:

MOt>0    xt>0MO_t>0\iff x_t>0

A positive oscillator says the faster breadth estimate is above the slower estimate. It does not guarantee that:

  • today's Net Advances is positive;
  • the oscillator rose today;
  • a price index rose;
  • a future return will be positive.

Similarly, a negative oscillator means the fast breadth state is below the slow state. It is a relative state comparison, not a direct trading instruction.

Why the oscillator returns toward zero

Suppose Net Advances settles at a constant cc.

Both EMAs eventually approach that same value:

FtcF_t\rightarrow c StcS_t\rightarrow c

Therefore:

MOt=FtSt0MO_t=F_t-S_t\rightarrow0

The oscillator measures the separation between tracking speeds. A permanently positive level of Net Advances does not imply a permanently positive oscillator of the same size. Once both trends catch up, their difference shrinks.

This is why it is better to think of the oscillator as breadth momentum relative to a slower breadth baseline, not as a direct replacement for Net Advances.

Traditional scale depends on the universe

The entire transform is linear.

If all inputs are multiplied by cc:

xt=cxtx'_t=cx_t

then, under the same linear seed policy:

Ft=cFt,St=cStF'_t=cF_t,\qquad S'_t=cS_t

and:

MOt=cMOtMO'_t=cMO_t

If a market has ten times as many issues with the same proportional breadth split, the traditional oscillator response can be ten times as large.

That has three consequences:

  1. raw levels may drift when the eligible population changes;
  2. levels from differently sized markets are not directly comparable;
  3. universal raw-count thresholds are methodologically weak.

Ratio adjustment exists to address this scale problem, but it changes the input and introduces a zero-denominator policy. That is the next topic.

The data contract matters more than the subtraction

Point-in-time universe

Advances and declines must come from the universe eligible on that session. Reconstructing historical breadth using today's constituents creates survivorship bias.

Comparable prior close

The provider must declare how it treats corporate actions, new listings, missing prior closes, stale prices, and halts.

Count partition

For advances AA, declines DD, unchanged UU, exclusions XX, and universe size NN:

A+D+U+X+Q=NA+D+U+X+Q=N

Here (Q) is the explicitly unclassified count. Broken partitions, unclassified issues, incomplete rosters, or incomplete classification are rejected before EMA state changes.

Stable identity

Venue, universe methodology, session scope, and comparison basis must remain stable. Splicing two universes into one EMA history creates a state with no coherent meaning.

Strict ordering

Snapshots must be unique and strictly increasing by session. Sorting internally can hide upstream errors and make checkpoint replay ambiguous.

Missing is not zero

This is one of the most important implementation rules.

A real balanced or no-mover session may produce:

xt=0x_t=0

Processing that zero updates the states:

Ft=0.90Ft1F_t=0.90F_{t-1} St=0.95St1S_t=0.95S_{t-1}

The fast state decays more quickly, so the oscillator changes.

A missing session has no observation. The canonical policy does not update either state.

Inserting an invented zero for missing data changes every later value. Calendar completeness should be validated upstream; it should not be repaired silently inside the oscillator.

An empty universe is rejected. It is not treated as a valid zero-breadth market.

Revisions change the dependent suffix

An EMA has recursive state. If a historical Net Advances value changes, every later fast state, slow state, and oscillator value changes.

Exact recomputation starts from:

  • the beginning of the series; or
  • a verified checkpoint immediately before the corrected observation.

A checkpoint needs more than two numbers. It should record:

  • fast and slow states at full precision;
  • observation count;
  • last processed session;
  • alphas and seed policy;
  • series identity;
  • revision and provisionality provenance.

Because EMA memory never becomes exactly zero, there is no exact finite horizon after which a correction can be ignored.

Precision and rounding

Do not round each EMA update for display.

The exact fixture uses rational arithmetic. For example:

S42=34180=4.2625S_{42}=\frac{341}{80}=4.2625

and:

MO44=9906932000=3.09590625MO_{44}=-\frac{99069}{32000}=-3.09590625

A normal floating-point implementation is adequate for many dashboards, but it should:

  • keep full internal precision;
  • round only serialized display fields;
  • use an explicit comparison tolerance in tests;
  • preserve identical operation ordering across languages when parity matters.

Implementation blueprint

Plain text
retain revisions whose available_at is no later than the cutoff
resolve one contiguous, unambiguous revision head per expected session
if any required session is missing, provisional, cancelled, conflicting,
unclassified, empty, or belongs to another series:
    return a non-resolved result with no ordinary points

count = 0
warmup = []
fast = null
slow = null

for resolved snapshot in explicit session-sequence order:

    x = advances - declines
    count += 1
    keep x while count <= 39

    if count < 19:
        emit warming_fast
        continue

    if count == 19:
        fast = mean(first 19 inputs)
    else:
        fast = fast + 0.10 * (x - fast)

    if count < 39:
        emit warming_slow with fast
        continue

    if count == 39:
        slow = mean(first 39 inputs)
    else:
        slow = slow + 0.05 * (x - slow)

    oscillator = fast - slow
    emit ready point with state and provenance

The algorithm is O(1)O(1) time per observation after warm-up and O(1)O(1) persistent state. The initial 39-value buffer can be discarded after both seeds exist, provided the checkpoint is durable.

The package provides equivalent Python and TypeScript implementations. Each exposes a clearly named pure transform for mathematical testing and a strict production entry point for cutoff, revisions, identity, partition, and evidence state. Both consume the same shared fixture, which compresses the 39-zero warm-up and records the exact observations 39 through 44.

Use the interactive McClellan playground to pause on observation 42, switch to a persistent-positive path, recompute a corrected suffix, rescale the raw input, or compare resolved output with missing, provisional, and conflicting evidence states.

What to test

A reliable implementation should cover:

Formula and seed tests

  • 2/(19+1)=0.102/(19+1)=0.10;
  • 2/(39+1)=0.052/(39+1)=0.05;
  • fast seed equals SMA-19;
  • slow seed equals SMA-39;
  • oscillator is null before observation 39.

Recurrence tests

  • the exact worked values;
  • positive oscillator with negative current input at observation 42;
  • negative crossing at observation 44;
  • convergence toward zero under constant input;
  • linear scaling of output when all raw inputs scale.

Contract tests

  • invalid count partitions;
  • negative, fractional, or unsafe counts;
  • duplicate or unordered sessions;
  • changing venue or universe identity;
  • empty universe rejection;
  • no-mover zero update;
  • missing observation without imputation.
  • future revision ignored before semantic validation at an earlier cutoff;
  • incomplete, ambiguous, or unsupported requests emit no ordinary points;
  • contiguous revision chains and correction-driven suffix recomputation.

State tests

  • correction replay from a prior checkpoint;
  • provisional-input propagation;
  • full-precision state persistence;
  • deterministic restart.

Calculation tests establish definition fidelity. They do not establish forecast quality.

Interpretation without universal thresholds

Analysts often inspect:

  • zero-line crossings;
  • changes in direction;
  • unusually large positive or negative values;
  • breadth thrusts;
  • divergences against a price index.

These are analysis patterns, not universal rules.

StockCharts itself notes that oscillator divergences are common and many do not produce reversals. Traditional raw-count magnitude also changes with universe size, making fixed thresholds particularly sensitive to venue and era.

A defensible empirical study must declare:

  • the exact universe and data vendor;
  • traditional versus ratio-adjusted input;
  • EMA seed and warm-up;
  • threshold or divergence-detection rules;
  • revision handling;
  • sample period and out-of-sample split;
  • costs and execution assumptions for any trading claim.

This article supplies none of those rules as investment advice.

Traditional or ratio-adjusted?

Use the Traditional McClellan Oscillator when:

  • reproducing an unadjusted historical series;
  • working within one stable universe;
  • raw issue-count breadth is the intended unit;
  • comparing directly with Traditional Net Advances inputs.

Use the Ratio-Adjusted McClellan Oscillator when:

  • issue population changes materially;
  • cross-era or cross-market scale matters;
  • the desired input is proportional mover breadth.

The EMA engine is the same. The input contract is not.

Where raw issue breadth fits in the family

The traditional oscillator is the family’s raw issue-count, non-cumulative foundation. Its magnitude changes when the same participation percentage occurs in a larger or smaller universe. That is why the next tutorial introduces ratio adjustment. Accumulation is a separate choice: the Traditional McClellan Summation Index adds every ready oscillator to a declared state, while volume siblings replace equal-vote issue counts with allocated share volume.

The choice is therefore about the unit and memory contract—not which historical line appears more attractive.

Traditional McClellan Oscillator calculation flow

This flow separates causal revision resolution, evidence gating, EMA warm-up, and the first ready oscillator output.

Rendering system map…

Takeaway: the canonical series emits no oscillator before observation 39. A missing, provisional, cancelled, or conflicting required session withholds the ordinary path instead of advancing either EMA with an invented zero.

ReferencesPrimary sources and evidence notes

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

Access date for methodology sources: 2026-07-20. Nasdaq historical evidence was accessed on 2026-07-23. The package redistributes only synthetic fixture data; the article reports a small, attributed set of provider observations and author-derived arithmetic.

  • Accessed: 2026-07-23

MCO-01 — Calculating the McClellan Oscillator

  • Organization or authors: McClellan Financial Publications; explanation by Tom McClellan
  • Source type: Maintained first-party calculation note
  • Publication or effective date: Not stated
  • Version: Web edition accessed 2026-07-20
  • URL: Calculating the McClellan Oscillator
  • Jurisdiction: United States market breadth
  • Supports: Raw daily A-D input; 10% and 5% EMA trends; recurrence formulas; equivalence between 10% and a 19-period EMA through 2/(n+1)2/(n+1); attribution to Sherman and Marian McClellan.
  • Limitations: Does not prescribe one universal software initialization, missing-session, revision, or output-schema policy.

MCO-02 — The McClellan Oscillator and Summation Index

  • Organization or authors: McClellan Financial Publications
  • Source type: Maintained first-party methodology overview
  • Publication or effective date: Not stated
  • Version: Web edition accessed 2026-07-20
  • URL: McClellan Oscillator and Summation Index overview
  • Jurisdiction: United States market breadth
  • Supports: Advances minus declines as daily breadth; recent-weighted EMA intuition; 10% and 5% trends; oscillator as their numerical difference; Summation Index as a later cumulative derivative.
  • Limitations: Interpretive language is historical methodology, not current out-of-sample performance evidence.

MCO-03 — 2004 MTA Lifetime Achievement Award booklet

  • Organization or authors: Sherman and Marian McClellan / McClellan Financial Publications
  • Source type: Historical first-party technical booklet
  • Publication or effective date: 2004
  • Version: PDF accessed 2026-07-20
  • URL: Development of the McClellan Oscillator
  • Jurisdiction: United States market breadth
  • Supports: Historical development; smoothing-constant terminology; 2/(n+1)2/(n+1) conversion; distinction between original raw breadth and later ratio adjustment.
  • Limitations: Historical exposition; some examples and thresholds reflect their original market universe and era.

MCO-04 — StockCharts ChartSchool methodology

  • Organization or authors: StockCharts.com
  • Source type: Maintained technical methodology
  • Publication or effective date: Last updated approximately six months before access
  • Version: Web edition accessed 2026-07-20
  • URL: McClellan Oscillator
  • Jurisdiction: General market analysis
  • Supports: Oscillator as 19-period EMA minus 39-period EMA; first EMA calculation as a simple average; ratio-adjusted versus unadjusted distinction; unadjusted series based on raw Net Advances.
  • Limitations: The main displayed formula is ratio-adjusted; platform symbols and thresholds are vendor-specific.

MCO-05 — Schwab thinkorswim McClellanOscillator

  • Organization or authors: Charles Schwab / thinkorswim
  • Source type: Maintained official platform documentation
  • Publication or effective date: Continuously maintained
  • Version: Web edition accessed 2026-07-20
  • URL: McClellanOscillator study
  • Jurisdiction: United States platform documentation
  • Supports: Separate simple and ratio-adjusted breadth modes; simple mode as advances minus declines; final oscillator as fast EMA minus slow EMA.
  • Limitations: Configurable platform lengths and chart levels are not universal defaults or predictive proof.

MCO-06 — Nasdaq A-D definition

  • Organization or authors: Nasdaq, Inc.
  • Source type: Official exchange glossary
  • Publication or effective date: Not stated
  • Version: Live web page accessed 2026-07-20
  • URL: A-D definition
  • Jurisdiction: United States
  • Supports: Issues above previous closes less issues below previous closes as the signed breadth input.
  • Limitations: Does not specify this package's universe, exclusions, revisions, or EMA policy.

MCO-07 — Nasdaq Trader Daily Market Files

  • Organization or authors: Nasdaq, Inc.
  • Source type: Official exchange year-to-date statistics download page and CSV
  • Publication or effective date: 2026 year-to-date file; mutable during the year
  • Version: daily2026.csv retrieved 2026-07-23
  • URL: Daily Market Files and 2026 CSV
  • Jurisdiction: United States, Nasdaq-listed issues
  • Supports: The provider-observed session dates and Nasdaq Advances, Declines, and Unchanged counts used in the bounded 2026 teaching reconstruction.
  • Limitations: The downloadable file is not a vendor-published Traditional McClellan Oscillator. It does not expose this package's full roster, exclusion, classification, availability-time, stable-ID, or revision-chain contract. It may be revised after access.

MCO-08 — Daily Market Summary Data Fields and Definitions

  • Organization or authors: Nasdaq, Inc.
  • Source type: Official exchange data dictionary
  • Publication or effective date: Maintained web documentation
  • Version: Web edition accessed 2026-07-23
  • URL: Daily Market Summary Definitions
  • Jurisdiction: United States, Nasdaq-listed issues
  • Supports: The year-to-date file includes Nasdaq Advances, Declines, and Unchanged fields; Nasdaq also states that Number of Issues is based on issues active for the date.
  • Limitations: Does not define the package's EMA seed, causal cutoff, revision resolution, or strict production-readiness decision.

Evidence and design reconciliation

Sourced facts:

  • The traditional input is daily advances minus declines.
  • The fast and slow smoothing constants are 10% and 5%.
  • These constants correspond to the conventional 19- and 39-period EMA labels.
  • The oscillator is fast EMA minus slow EMA.
  • Traditional and ratio-adjusted inputs are materially different.
  • Nasdaq's year-to-date file provides attributed breadth-count observations; every Net Advances and oscillator value in the historical reconstruction is author-derived.

Explicit package choices:

  • The first 19- and 39-period EMA values use simple-average seeds, following StockCharts' documented initialization convention.
  • The first oscillator output is emitted at observation 39.
  • Pre-warm-up values are null rather than backfilled.
  • A real validated no-mover session updates the traditional EMA with Net Advances 0; a missing session does not update state.
  • Empty-universe snapshots are invalid for this series.
  • Corrections require suffix recomputation from a prior valid checkpoint.

Claims intentionally not made:

  • No universal overbought, oversold, thrust, divergence, or trading threshold is asserted.
  • A zero crossing is not presented as a guaranteed buy or sell instruction.
  • Calculation correctness is not treated as evidence of profitability.
mcclellanOscillator.ts
/** Causal, SMA-seeded Traditional McClellan Oscillator. */

export type EvidenceState = "resolved" | "incomplete" | "ambiguous" | "unsupported";
export type McClellanStatus = "warming_fast" | "warming_slow" | "ready";
export type EventType = "upsert" | "cancel";

export interface BreadthRecord {
  event_id: string;
  revision: number;
  supersedes_revision: number | null;
  event_type: EventType;
  session_sequence: number;
  session_date: string;
  effective_at: string;
  available_at: string;
  venue_id: string;
  universe_id: string;
  calendar_id: string;
  session: string;
  comparison_basis: string;
  corporate_action_policy: string;
  stable_id_scheme: string;
  methodology_id: string;
  input_policy: string;
  seed_policy: string;
  fast_alpha: number;
  slow_alpha: number;
  source_evidence_state?: string;
  roster_complete?: boolean;
  classification_complete?: boolean;
  advances?: number;
  declines?: number;
  unchanged?: number;
  excluded?: number;
  unclassified?: number;
  universe_size?: number;
}

export interface CorePoint {
  observation: number;
  net_advances: number;
  fast_ema: number | null;
  slow_ema: number | null;
  oscillator: number | null;
  status: McClellanStatus;
}

export interface McClellanPoint extends CorePoint {
  session_sequence: number;
  session_date: string;
  event_id: string;
  revision: number;
  source_status: "ready" | "balanced" | "no_movers";
  metric: "traditional_mcclellan_oscillator";
}

export interface CalculationOptions {
  cutoff: string;
  expectedStartSequence: number;
  expectedEndSequence: number;
}

export interface CalculationResult {
  state: EvidenceState;
  reason_codes: string[];
  points: McClellanPoint[];
  latest_oscillator: number | null;
  ignored_future_revisions: number;
  fast_alpha: 0.1;
  slow_alpha: 0.05;
  seed_policy: "simple_average";
  cutoff: string;
}

export class McClellanValidationError extends Error {
  constructor(message: string) {
    super(message);
    this.name = "McClellanValidationError";
  }
}

const countFields = ["advances", "declines", "unchanged", "excluded", "unclassified"] as const;
const identityFields = [
  "venue_id", "universe_id", "calendar_id", "session", "comparison_basis",
  "corporate_action_policy", "stable_id_scheme", "methodology_id",
] as const;

function finite(value: unknown, name: string): number {
  if (typeof value !== "number" || !Number.isFinite(value)) {
    throw new McClellanValidationError(`${name} must be a finite number.`);
  }
  return value;
}

function timestamp(value: unknown, name: string): number {
  if (typeof value !== "string" || !value.endsWith("Z")) {
    throw new McClellanValidationError(`${name} must be an RFC 3339 UTC timestamp.`);
  }
  const parsed = Date.parse(value);
  if (Number.isNaN(parsed)) {
    throw new McClellanValidationError(`${name} must be an RFC 3339 UTC timestamp.`);
  }
  return parsed;
}

function nonempty(record: BreadthRecord, field: keyof BreadthRecord): string {
  const value = record[field];
  if (typeof value !== "string" || value.trim() === "") {
    throw new McClellanValidationError(`${String(field)} must be a non-empty string.`);
  }
  return value;
}

function result(
  state: EvidenceState,
  reasons: string[],
  future: number,
  cutoff: string,
  points: McClellanPoint[] = [],
): CalculationResult {
  const emitted = state === "resolved" ? points : [];
  return {
    state,
    reason_codes: [...new Set(reasons)].sort(),
    points: emitted,
    latest_oscillator: emitted.at(-1)?.oscillator ?? null,
    ignored_future_revisions: future,
    fast_alpha: 0.1,
    slow_alpha: 0.05,
    seed_policy: "simple_average",
    cutoff,
  };
}

export function calculateMcClellanValues(netAdvances: readonly number[]): CorePoint[] {
  if (!Array.isArray(netAdvances)) {
    throw new McClellanValidationError("netAdvances must be an array.");
  }
  const values = netAdvances.map((value, index) => finite(value, `netAdvances[${index}]`));
  let fast: number | null = null;
  let slow: number | null = null;
  let fastSeedSum = 0;
  let slowSeedSum = 0;
  return values.map((value, index): CorePoint => {
    const observation = index + 1;
    if (observation <= 19) fastSeedSum += value;
    if (observation <= 39) slowSeedSum += value;
    if (observation === 19) fast = fastSeedSum / 19;
    else if (observation > 19) fast = (fast as number) + 0.1 * (value - (fast as number));
    if (observation === 39) slow = slowSeedSum / 39;
    else if (observation > 39) slow = (slow as number) + 0.05 * (value - (slow as number));
    const status: McClellanStatus = observation < 19
      ? "warming_fast" : observation < 39 ? "warming_slow" : "ready";
    return {
      observation,
      net_advances: value,
      fast_ema: fast,
      slow_ema: slow,
      oscillator: fast === null || slow === null ? null : fast - slow,
      status,
    };
  });
}

export function calculateMcClellan(
  records: readonly BreadthRecord[],
  options: CalculationOptions,
): CalculationResult {
  if (!Array.isArray(records)) throw new McClellanValidationError("records must be an array.");
  const cutoffTime = timestamp(options.cutoff, "cutoff");
  if (!Number.isInteger(options.expectedStartSequence)
    || !Number.isInteger(options.expectedEndSequence)
    || options.expectedStartSequence <= 0
    || options.expectedEndSequence < options.expectedStartSequence) {
    throw new McClellanValidationError("expected sequence bounds are invalid.");
  }

  const availableRecords: BreadthRecord[] = [];
  let future = 0;
  let previousAvailable = -Infinity;
  for (const source of records) {
    const available = timestamp(source.available_at, "available_at");
    if (available > cutoffTime) {
      future += 1;
      continue;
    }
    const record = {...source};
    const effective = timestamp(record.effective_at, "effective_at");
    if (available < effective) throw new McClellanValidationError("available_at cannot precede effective_at.");
    if (available < previousAvailable) {
      throw new McClellanValidationError(
        "records must be ordered by non-decreasing available_at; input is never silently sorted.",
      );
    }
    previousAvailable = available;
    nonempty(record, "event_id");
    nonempty(record, "session_date");
    for (const field of identityFields) nonempty(record, field);
    if (record.event_type !== "upsert" && record.event_type !== "cancel") {
      throw new McClellanValidationError("event_type must be upsert or cancel.");
    }
    if (!Number.isInteger(record.revision) || record.revision < 1
      || !Number.isInteger(record.session_sequence) || record.session_sequence < 1) {
      throw new McClellanValidationError("revision and session_sequence must be positive integers.");
    }
    const expectedSupersedes = record.revision === 1 ? null : record.revision - 1;
    if (record.supersedes_revision !== expectedSupersedes) {
      throw new McClellanValidationError(
        "supersedes_revision must be null for revision 1 and revision-1 thereafter.",
      );
    }
    if (record.input_policy !== "raw_net_advances"
      || record.seed_policy !== "simple_average"
      || record.fast_alpha !== 0.1 || record.slow_alpha !== 0.05) {
      return result("unsupported", ["algorithm_policy_drift"], future, options.cutoff);
    }
    availableRecords.push(record);
  }

  const grouped = new Map<string, BreadthRecord[]>();
  for (const record of availableRecords) {
    grouped.set(record.event_id, [...(grouped.get(record.event_id) ?? []), record]);
  }
  const heads: BreadthRecord[] = [];
  const ambiguous: string[] = [];
  for (const versions of grouped.values()) {
    const byRevision = new Map<number, BreadthRecord[]>();
    for (const version of versions) {
      byRevision.set(version.revision, [...(byRevision.get(version.revision) ?? []), version]);
    }
    const revisions = [...byRevision.keys()].sort((a, b) => a - b);
    if (revisions.some((revision, index) => revision !== index + 1)) {
      ambiguous.push("revision_gap");
      continue;
    }
    if ([...byRevision.values()].some((items) => items.length !== 1)) {
      ambiguous.push("revision_conflict");
      continue;
    }
    const head = byRevision.get(revisions.at(-1)!)![0];
    if (versions.some((version) => version.session_sequence !== head.session_sequence
      || version.session_date !== head.session_date)) {
      ambiguous.push("revision_identity_conflict");
      continue;
    }
    heads.push(head);
  }
  if (ambiguous.length) return result("ambiguous", ambiguous, future, options.cutoff);

  const bySequence = new Map<number, BreadthRecord[]>();
  for (const head of heads) {
    bySequence.set(head.session_sequence, [...(bySequence.get(head.session_sequence) ?? []), head]);
  }
  const expected = Array.from(
    {length: options.expectedEndSequence - options.expectedStartSequence + 1},
    (_, index) => options.expectedStartSequence + index,
  );
  if ([...bySequence.values()].some((items) => items.length !== 1)) {
    return result("ambiguous", ["multiple_events_for_session"], future, options.cutoff);
  }
  if (expected.some((sequence) => !bySequence.has(sequence))) {
    return result("incomplete", ["missing_session"], future, options.cutoff);
  }
  if ([...bySequence.keys()].some((sequence) => !expected.includes(sequence))) {
    return result("unsupported", ["unexpected_session_sequence"], future, options.cutoff);
  }

  const ordered = expected.map((sequence) => bySequence.get(sequence)![0]);
  let identity: string | null = null;
  let previousDate = "";
  const blocking: string[] = [];
  for (const record of ordered) {
    const currentIdentity = JSON.stringify(identityFields.map((field) => record[field]));
    if (identity === null) identity = currentIdentity;
    else if (identity !== currentIdentity) {
      return result("unsupported", ["series_break"], future, options.cutoff);
    }
    if (!/^\d{4}-\d{2}-\d{2}$/.test(record.session_date)
      || Number.isNaN(Date.parse(`${record.session_date}T00:00:00Z`))) {
      throw new McClellanValidationError("session_date must be a valid YYYY-MM-DD date.");
    }
    if (previousDate && record.session_date <= previousDate) {
      return result("unsupported", ["calendar_sequence_date_conflict"], future, options.cutoff);
    }
    previousDate = record.session_date;
    if (record.event_type === "cancel") {
      blocking.push("cancelled_session");
      continue;
    }
    if (record.source_evidence_state !== "ready") blocking.push("source_evidence_not_ready");
    if (record.roster_complete !== true) blocking.push("roster_incomplete");
    if (record.classification_complete !== true) blocking.push("classification_incomplete");
    const values = {} as Record<(typeof countFields)[number] | "universe_size", number>;
    for (const field of [...countFields, "universe_size"] as const) {
      const value = record[field];
      if (!Number.isSafeInteger(value) || (value as number) < 0) {
        throw new McClellanValidationError(`${field} must be a non-negative safe integer for upserts.`);
      }
      values[field] = value as number;
    }
    const partition = countFields.reduce((sum, field) => sum + values[field], 0);
    if (!Number.isSafeInteger(partition)) {
      throw new McClellanValidationError("the count partition total must be a safe integer.");
    }
    if (partition !== values.universe_size) blocking.push("count_partition_incomplete");
    if (values.universe_size === 0) blocking.push("empty_universe");
    if (values.unclassified > 0) blocking.push("unclassified_issues");
  }
  if (blocking.length) return result("incomplete", blocking, future, options.cutoff);

  const core = calculateMcClellanValues(ordered.map((record) => record.advances! - record.declines!));
  const points = core.map((point, index): McClellanPoint => {
    const record = ordered[index];
    const movers = record.advances! + record.declines!;
    return {
      ...point,
      session_sequence: record.session_sequence,
      session_date: record.session_date,
      event_id: record.event_id,
      revision: record.revision,
      source_status: movers === 0 ? "no_movers" : point.net_advances === 0 ? "balanced" : "ready",
      metric: "traditional_mcclellan_oscillator",
    };
  });
  return result("resolved", [], future, options.cutoff, points);
}
Full-height labmcclellan playgroundOpen full screen