D04-F02-A03 / Complete engineering topic

Traditional McClellan Summation Index

A production-minded guide to Traditional McClellan Summation Index.

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

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

What cumulative path is created when each traditional raw-count oscillator value becomes the next index increment? Traditional McClellan Summation Index answers that question only when the input unit, warm-up, seed, and evidence cutoff remain explicit.

The numerical path below is synthetic teaching data. The methodology statements are sourced; every displayed input, EMA, oscillator, and index value is author-derived.

The decision before the formula

Choose this index when the governed raw issue-count history and its cumulative path are the teaching object. Accumulation adds path dependence: the same current oscillator can sit at different index levels after different histories.

The six-topic McClellan family has three input choices—raw issue counts, ratio-adjusted issue breadth, and raw allocated volume—and two output choices—an oscillator or a cumulative Summation Index. Sharing the 10%/5% engine does not make those choices interchangeable.

QuestionThis topic’s answer
What is observed?Advancing issues minus declining issues
What memory is retained?Dual EMA state plus cumulative state
What happens when source scale doubles?Doubling the raw issue-count scale doubles every oscillator increment; with a +1000 seed, the index displacement from +1000 doubles.
What is withheld?Source evidence is provisional, so no cumulative suffix is allowed.

From evidence to input

The package first resolves one ready revision for every required session at the requested knowledge cutoff. Missing, cancelled, provisional, duplicated, or identity-incompatible evidence is not converted into a plausible zero.

Only after that resolution does the topic transform the record:

xt=AtDtx_t=A_t-D_t

Then the shared tracking engine updates:

Ft=Ft1+0.10(ztFt1),St=St1+0.05(ztSt1)F_t=F_{t-1}+0.10(z_t-F_{t-1}),\qquad S_t=S_{t-1}+0.05(z_t-S_{t-1}) SIt=SIt1+MOtSI_t=SI_{t-1}+MO_t

The 10% and 5% tracking rates correspond to the conventional 19- and 39-period labels because

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

The labels do not turn exponential memory into finite windows. Old observations decay; they do not disappear exactly.

Rendering system map…

The important boundary is left to right: source resolution precedes recursion, and accumulation—when present—follows a ready oscillator.

Warm-up is observable state

The package uses the simple average of observations 1–19 as the first fast state and the simple average of observations 1–39 as the first slow state. Before observation 39, the oscillator is null because both states do not yet coexist.

The declared pre-output seed is 1000. It translates the level; each daily increment still equals the oscillator.

The canonical cross-language fixture uses a balanced warm-up because it makes exact parity checks easy. The guided lab uses a separate nonzero path to teach the state transition instead of opening on a blank chart.

A nonzero 60-session teaching path

The lab moves through a broad advance, cooling, reacceleration, reversal, and stress/recovery regime.

ObservationRegimeInputFastSlowOscillatorIndex
39reversal80.00003.647822.5641-18.9163981.0837
40stress and recovery120.000015.283027.4359-12.1529968.9308
45stress and recovery-80.000034.544136.9243-2.3802975.8610
60stress and recovery20.000012.405019.1039-6.6989836.5249

At observation 39, the fast and slow states are already different. That makes the first ready oscillator a genuine state comparison rather than a zero placeholder. By observation 45, the current input and the two memory states reveal how the algorithm reacts to a regime change.

Nonzero worked state

Notice the sequence, not merely the last number: the input changes first, the fast state reacts more, the slow state follows, and the output records their separation.

The scale comparison that distinguishes this topic

The lab can double the source scale without changing the direction pattern.

Source scale at observation 45InputOscillatorIndex
-80.0000-2.3802975.8610
-160.0000-4.7605951.7220

Doubling the raw issue-count scale doubles every oscillator increment; with a +1000 seed, the index displacement from +1000 doubles. This is the family-level reason to name the input unit before interpreting magnitude.

Family decision map

The highlighted cell is this topic. Move horizontally to change the input unit; move vertically to add or remove cumulative memory.

Data contract: a number is not enough

Issue-count topics require a reconciled universe partition and stable classification method. Ratio-adjusted topics also require a positive advances-plus-declines denominator. Volume topics require nonnegative Up, Down, and Unchanged Volume that reconcile exactly to positive Total Volume, with stable venue, unit, and adjustment basis.

Every record carries session order, effective time, availability time, revision, evidence state, and series identity. A correction changes the dependent EMA suffix; a cumulative topic also changes every later index value.

Failure is a visible result

The failure scenario stops at observation 45 and reports why the next update is withheld: Source evidence is provisional, so no cumulative suffix is allowed. The chart preserves the last valid path but does not draw a fictional recursive suffix.

This distinction matters operationally:

  • a validated input of zero updates both EMA states;
  • a missing or provisional observation performs no update;
  • a broken partition is invalid, not neutral;
  • a future revision cannot change an earlier knowledge-cutoff result;
  • a series-identity change starts a new governed series.

Implementation without hidden assumptions

The reference code separates three responsibilities:

  1. resolve evidence at the causal cutoff;
  2. transform one validated record into the topic’s declared input unit;
  3. update unrounded fast, slow, oscillator, and optional cumulative state.

That separation makes a production review easier: a correct subtraction cannot conceal a broken source partition, and a correct recurrence cannot conceal future information.

Python and TypeScript share the same canonical fixture and reason-code semantics. The teaching dataset is intentionally separate, so richer pedagogy does not turn the implementation test fixture into presentation data.

Evidence boundary and historical decision

McClellan Financial’s first-party material supports the 10%/5% engine and the topic-specific transform or accumulation rule. The package’s SMA initialization, software statuses, causal revision schema, teaching seed, and synthetic scenarios are explicit implementation choices.

A named historical example is deferred for this topic. A reproducible case would require the exact point-in-time universe or volume allocation, source timestamps, corrections, adjustment basis, calibration lineage, and redistribution permission. A synthetic path is better evidence of the mechanism than an incompletely specified market number.

Methodology pages also contain interpretive language and historical thresholds. Those statements are not promoted here into universal trading rules, predictive claims, or evidence of profitability.

Explore the guided lab

Open the guided playground, then:

  1. inspect the nonzero observation-39 state;
  2. step through the stress/recovery regime;
  3. select the expanded-source comparison;
  4. watch the scale diagnostic explain what changed and what stayed invariant;
  5. select invalid evidence and step to observation 45;
  6. reset and confirm the same deterministic state.

Summary

Traditional McClellan Summation Index is not defined only by fast minus slow. It is defined by its input unit, its two initialization states, its optional cumulative seed, and its evidence boundary. Doubling the raw issue-count scale doubles every oscillator increment; with a +1000 seed, the index displacement from +1000 doubles.

Next: Ratio-Adjusted Summation Index (RASI).

Primary references

See the annotated source record and the canonical technical contract.

Traditional McClellan Summation Index calculation flow

Purpose: show that evidence, input units, recursive memory, and optional cumulative state are separate decisions.

Rendering system map…

Takeaway: Doubling the raw issue-count scale doubles every oscillator increment; with a +1000 seed, the index displacement from +1000 doubles.

ReferencesPrimary sources and evidence notes

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

Only the formulas and historical naming are sourced facts. The fixture is synthetic; its arithmetic, seed choice, validation schema, error states, and software interfaces are explicit author choices.

MCO-01 — Calculating the McClellan Oscillator

  • Organization or authors: McClellan Financial Publications; Tom McClellan
  • Source type: Maintained first-party methodology note
  • Publication or effective date: Not stated unless shown on the source page
  • Version: Web or PDF edition accessed 2026-07-25
  • URL: Calculating the McClellan Oscillator
  • Accessed: 2026-07-25
  • Jurisdiction: United States market-breadth methodology
  • Supports: 10% and 5% EMA recurrences, raw A-D input, oscillator subtraction, and the 19/39 label equivalence.
  • Limitations: It does not prescribe this package's SMA seed, causal cutoff, revision schema, or software error states.

MCO-03 — The McClellan Oscillator and Summation Index

  • Organization or authors: McClellan Financial Publications
  • Source type: Maintained first-party overview
  • Publication or effective date: Not stated unless shown on the source page
  • Version: Web or PDF edition accessed 2026-07-25
  • URL: The McClellan Oscillator and Summation Index
  • Accessed: 2026-07-25
  • Jurisdiction: United States market-breadth methodology
  • Supports: The Summation Index as cumulative oscillator values and the historical +1000 convention for the classic issue-count series.
  • Limitations: A published absolute level depends on calibration and source history; a fresh local seed does not reproduce a vendor series.

NASD-01 — A-D definition

  • Organization or authors: Nasdaq, Inc.
  • Source type: Official exchange glossary
  • Publication or effective date: Not stated unless shown on the source page
  • Version: Web or PDF edition accessed 2026-07-25
  • URL: A-D definition
  • Accessed: 2026-07-25
  • Jurisdiction: United States market-breadth methodology
  • Supports: Advancing issues minus declining issues as a signed market-breadth quantity.
  • Limitations: It does not define this package's universe, volume allocation, revisions, or EMA initialization.

Evidence reconciliation

  • Sourced fact: First-party methodology defines the Summation Index as the running total of McClellan Oscillator values and documents the historical +1000 neutral convention.
  • Implementation choice: SMA seeds at observations 19 and 39, display-only rounding, atomic withholding for unresolved evidence, and the documented accumulator seed.
  • Synthetic teaching input: the 60-session fixture under datasets/.
  • Author-derived calculation: every transformed input, EMA, oscillator, and cumulative value in the worked example.
  • Historical-example decision: deferred. A named market series would require a reproducible universe, final breadth or volume allocation, revision history, calibration history, access timestamp, and redistribution permission.
  • Empirical claim intentionally excluded: passing calculation tests does not establish forecasting or trading value.
traditionalMcclellanSummationIndex.ts
/** Reference implementation for Traditional McClellan Summation Index. */
export type RecordRow = Record<string, unknown>;
export const MODE = "raw_issue_sum";
export const INITIAL_INDEX: number | null = 1000.0;
const FAST_ALPHA = 0.10;
const SLOW_ALPHA = 0.05;
const IDENTITY_FIELDS = ["venue_id", "universe_id", "methodology_id", "calendar_id", "volume_unit", "volume_adjustment_basis"] as const;

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

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

export function transformRecord(record: RecordRow): number {
  if (MODE.startsWith("ratio_issue")) {
    const advances = numberValue(record.advances, "advances");
    const declines = numberValue(record.declines, "declines");
    if (advances < 0 || declines < 0) throw new Error("issue counts must be non-negative");
    const denominator = advances + declines;
    if (denominator === 0) throw new Error("ratio-adjusted breadth is undefined when advances + declines is zero");
    return 1000 * (advances - declines) / denominator;
  }
  if (MODE.startsWith("raw_issue")) {
    const advances = numberValue(record.advances, "advances");
    const declines = numberValue(record.declines, "declines");
    if (advances < 0 || declines < 0) throw new Error("issue counts must be non-negative");
    return advances - declines;
  }
  const up = numberValue(record.up_volume, "up_volume");
  const down = numberValue(record.down_volume, "down_volume");
  const unchanged = numberValue(record.unchanged_volume, "unchanged_volume");
  const total = numberValue(record.total_volume, "total_volume");
  if (Math.min(up, down, unchanged, total) < 0) throw new Error("volume fields must be non-negative");
  if (total <= 0 || up + down + unchanged !== total) throw new Error("volume partition must be positive and reconcile exactly");
  return up - down;
}

export function calculateValues(values: readonly number[]) {
  const data = values.map((value) => numberValue(value, "input value"));
  let fast: number | null = null;
  let slow: number | null = null;
  let indexState = INITIAL_INDEX;
  return data.map((value, offset) => {
    const position = offset + 1;
    if (position === 19) fast = data.slice(0, 19).reduce((a, b) => a + b, 0) / 19;
    else if (position > 19) fast = fast! + FAST_ALPHA * (value - fast!);
    if (position === 39) slow = data.slice(0, 39).reduce((a, b) => a + b, 0) / 39;
    else if (position > 39) slow = slow! + SLOW_ALPHA * (value - slow!);
    const oscillator = slow === null ? null : fast! - slow;
    let indexValue: number | null = null;
    if (oscillator !== null && INITIAL_INDEX !== null) {
      indexState = indexState! + oscillator;
      indexValue = indexState;
    }
    return {
      observation: position, input_value: value, fast_ema: fast, slow_ema: slow,
      oscillator, index_value: indexValue,
      status: position < 19 ? "warming_fast" : position < 39 ? "warming_slow" : "ready",
    };
  });
}

export function calculate(records: readonly RecordRow[], cutoff: string) {
  const cutoffTime = timestamp(cutoff, "cutoff");
  const retained: RecordRow[] = [];
  let ignoredFuture = 0;
  for (const record of records) {
    if (timestamp(record.available_at, "available_at") > cutoffTime) ignoredFuture += 1;
    else retained.push(record);
  }
  if (!retained.length) return {status: "incomplete", reason_codes: ["no_evidence_at_cutoff"], points: [], ignored_future_records: ignoredFuture};

  const groups = new Map<number, RecordRow[]>();
  for (const record of retained) {
    const sequence = record.session_sequence;
    const revision = record.revision;
    if (!Number.isInteger(sequence) || (sequence as number) <= 0) throw new Error("session_sequence must be a positive integer");
    if (!Number.isInteger(revision) || (revision as number) < 0) throw new Error("revision must be a non-negative integer");
    const bucket = groups.get(sequence as number) ?? [];
    bucket.push(record);
    groups.set(sequence as number, bucket);
  }
  const sequences = [...groups.keys()].sort((a, b) => a - b);
  for (let i = 1; i < sequences.length; i += 1) {
    if (sequences[i] !== sequences[i - 1] + 1) return {status: "incomplete", reason_codes: ["missing_session_sequence"], points: [], ignored_future_records: ignoredFuture};
  }
  const resolved: RecordRow[] = [];
  let identity: string | null = null;
  for (const sequence of sequences) {
    const revisions = groups.get(sequence)!;
    const numbers = revisions.map((row) => row.revision as number);
    if (new Set(numbers).size !== numbers.length) return {status: "ambiguous", reason_codes: ["duplicate_revision"], points: [], ignored_future_records: ignoredFuture};
    const sorted = [...numbers].sort((a, b) => a - b);
    if (sorted.some((value, index) => value !== index)) return {status: "ambiguous", reason_codes: ["broken_revision_chain"], points: [], ignored_future_records: ignoredFuture};
    const head = revisions.reduce((a, b) => (a.revision as number) > (b.revision as number) ? a : b);
    if (head.event_type === "cancel") return {status: "incomplete", reason_codes: ["cancelled_session"], points: [], ignored_future_records: ignoredFuture};
    if (head.source_evidence_state !== "ready") return {status: "incomplete", reason_codes: ["source_not_ready"], points: [], ignored_future_records: ignoredFuture};
    const currentIdentity = IDENTITY_FIELDS.map((field) => head[field]);
    if (currentIdentity.some((value) => typeof value !== "string" || !value)) throw new Error("series identity fields must be non-empty strings");
    const encoded = JSON.stringify(currentIdentity);
    if (identity === null) identity = encoded;
    else if (identity !== encoded) return {status: "unsupported", reason_codes: ["series_identity_break"], points: [], ignored_future_records: ignoredFuture};
    transformRecord(head);
    resolved.push(head);
  }
  const points = calculateValues(resolved.map(transformRecord)).map((point, index) => ({...point, session_date: resolved[index].session_date}));
  return {
    status: "resolved", reason_codes: [], points,
    latest_oscillator: points.at(-1)?.oscillator ?? null,
    latest_index_value: points.at(-1)?.index_value ?? null,
    ignored_future_records: ignoredFuture, seed_policy: "sma_19_39", initial_index: INITIAL_INDEX,
  };
}
Full-height labplaygroundOpen full screen