The governed definitions this build depends on. Read them first if a term is unfamiliar.
- PrimaryTraditional McClellan OscillatorA breadth oscillator equal to the 10% Trend minus the 5% Trend of daily advancing issues minus declining issues.
- PrerequisiteExponential Moving AverageAn Exponential Moving Average recursively gives recent observations greater influence than progressively older observations.
- PrerequisiteMarket BreadthMarket breadth describes how widely a market movement is shared across its eligible securities.
- PrerequisiteNet AdvancesNet Advances is the number of advancing issues minus the number of declining issues for one validated session.
- Important10% TrendThe faster McClellan exponential trend that assigns 10 percent weight to the current breadth input.
- Important5% TrendThe slower McClellan exponential trend that assigns 5 percent weight to the current breadth input.
- ImportantCausal Knowledge CutoffThe time boundary that prevents information unavailable at that moment from influencing a calculation.
- ImportantEMA Period-Label EquivalenceEMA period-label equivalence maps a conventional period label to its smoothing constant.
- ImportantNo-Imputation Missing-Session PolicyA policy that blocks or pauses calculation instead of converting a missing required session into a zero observation.
- ImportantOscillatorAn indicator constructed to fluctuate around a reference level or within a range as its inputs change.
- ImportantProcessing CheckpointA persisted valid state and provenance bundle from which deterministic processing can safely resume.
- ImportantReady PointThe first eligible observation at which every state required for an indicator output has been initialized.
- ImportantRecursive StateA stored calculation value that a recurrence carries from one accepted observation into the next update.
- ImportantRevision LineageAn ordered chain connecting each correction to the exact earlier record it supersedes.
- ImportantSMA-Seeded EMA InitializationAn EMA initialization policy that uses the arithmetic mean of the first eligible observations as the initial recursive state.
- ImportantSmoothing ConstantA smoothing constant controls how far an exponential state moves toward each new observation.
- ImportantSuffix RecalculationRecomputing a corrected observation and every later output whose recursive state may depend on it.
- ImportantWarm-Up PeriodThe initial span in which an indicator is accumulating enough eligible history to satisfy its declared initialization policy.
- MentionedRatio-Adjusted BreadthRatio-adjusted breadth scales advances minus declines by advances plus declines.
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:
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:
where is advancing issues and 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:
| Construction | Input before smoothing | Scale behavior |
|---|---|---|
| Traditional oscillator | Raw issue counts; population-sensitive | |
| Ratio-adjusted oscillator | Commonly | 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 be the fast 10% Trend:
Equivalently:
Let be the slow 5% Trend:
or:
The oscillator is:
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.
With both prior states at zero and current Net Advances of +100:
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:
For the fast trend:
For the slow trend:
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:
The fast EMA then updates recursively from observation 20 onward.
Slow seed:
The first oscillator value is available at observation 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
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:
Both simple-average seeds are therefore zero at observation 39. The next five inputs expose the recursive behavior:
| Observation | Net Advances | Fast EMA | Slow EMA | Oscillator |
|---|---|---|---|---|
| 39 | 0 | 0.000000 | 0.000000 | 0.000000 |
| 40 | +100 | 10.000000 | 5.000000 | +5.000000 |
| 41 | +100 | 19.000000 | 9.750000 | +9.250000 |
| 42 | −100 | 7.100000 | 4.262500 | +2.837500 |
| 43 | 0 | 6.390000 | 4.049375 | +2.340625 |
| 44 | −100 | −4.249000 | −1.153094 | −3.095906 |
Observation 42 is the crucial point. The current breadth input is negative, but the oscillator remains positive.
Fast update:
Slow update:
Oscillator:
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:
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.
| Session | Nasdaq advances | Nasdaq declines | Nasdaq unchanged | Derived Net Advances | Derived fast EMA | Derived slow EMA | Derived oscillator |
|---|---|---|---|---|---|---|---|
| 2026-02-27 | 1,652 | 3,206 | 208 | −1,554 | −8.1050 | 22.8974 | −31.0025 |
| 2026-03-02 | 2,443 | 2,431 | 228 | +12 | −6.0945 | 22.3526 | −28.4471 |
| 2026-03-03 | 1,359 | 3,542 | 190 | −2,183 | −223.7851 | −87.9151 | −135.8700 |
| 2026-03-04 | 3,368 | 1,509 | 224 | +1,859 | −15.5066 | 9.4307 | −24.9373 |
| 2026-03-05 | 1,430 | 3,454 | 195 | −2,024 | −216.3559 | −92.2408 | −124.1151 |
| 2026-03-06 | 1,461 | 3,384 | 238 | −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:
It is not:
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 .
Both EMAs eventually approach that same value:
Therefore:
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 :
then, under the same linear seed policy:
and:
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:
- raw levels may drift when the eligible population changes;
- levels from differently sized markets are not directly comparable;
- 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 , declines , unchanged , exclusions , and universe size :
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:
Processing that zero updates the states:
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:
and:
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
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 time per observation after warm-up and 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
- ;
- ;
- 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.
Rendered from the canonical Mermaid sources linked by this article.
Traditional McClellan Oscillator calculation flow
This flow separates causal revision resolution, evidence gating, EMA warm-up, and the first ready oscillator output.
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 notesExpand the source trail, evidence role, and limitations behind the engineering choices.
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 ; 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; 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.csvretrieved 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.
Full dependency-light reference implementations in both supported languages.
/** 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);
}
The embedded lab now expands to its full document height, keeping the article as the only scroll surface.