Library/Market Breadth and Internals/Advance/Decline Breadth

D04-F01-A03 / Complete engineering topic

Cumulative Advance/Decline Line: Recompute History Without Looking Ahead

Build a seeded, calendar-complete, revision-aware cumulative breadth line that refuses to publish incomplete evidence.

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

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

A cumulative Advance/Decline Line looks like one addition per session. In production, the harder question is whether every addition belongs to the same series and was actually knowable at the requested time.

This tutorial builds a line that can answer that question. It declares its seed and calendar, keeps revisions in an append-only lineage, recomputes the dependent suffix after a correction, and publishes no main value when the evidence is incomplete, ambiguous, or incompatible.

Start with the recurrence

For session position t:

NAt=AtDtNA_t=A_t-D_t L0=s,Lt=Lt1+NAtL_0=s, \qquad L_t=L_{t-1}+NA_t

A_t and D_t are advancing and declining issue counts. s is an explicit seed. Fidelity describes the familiar A/D line as a cumulative total of advancing stocks less declining stocks. That definition does not choose your roster, calendar, seed, revision policy, or publication clock; those are system contracts.

Why state changes the engineering problem

Daily Net Advances is local. A cumulative point contains every earlier increment. If session k changes by delta:

Δ=NAkNAk\Delta=NA'_k-NA_k

then every point from k onward changes by the same delta:

Lt={Lt,t<kLt+Δ,tkL'_t=\begin{cases}L_t,&t<k\\L_t+\Delta,&t\ge k\end{cases}

A synthetic correction changes the corrected point and every later point.

What to notice: the revised branch does not reconnect at the next date. Every dependent point is rebuilt.

Declare one continuity segment

The implementation accepts a continuity contract with:

  • series_id and continuity_id;
  • venue, universe, calendar, and session identities;
  • the prior-price comparison basis;
  • a signed safe-integer seed and seed lineage;
  • the seed's effective and available timestamps;
  • an ordered list of expected session dates and contiguous positions.

If the universe method, calendar, session, comparison basis, or continuity identity changes, begin a new segment with an explicit seed. Do not splice the new record into the old line.

The seed sets the vertical origin:

Two differently seeded lines retain identical daily changes.

What to notice: shifting the seed moves every level equally. It does not repair a gap or make incompatible series comparable.

Reconcile daily evidence before accumulating it

Every upsert supplies:

At+Dt+Ut+Xt+Qt=NtA_t+D_t+U_t+X_t+Q_t=N_t

U_t is unchanged, X_t is excluded by a declared rule, Q_t is unclassified, and N_t is universe size. A source can claim source_evidence_state="ready" only when the partition reconciles and Q_t=0.

The daily evidence state is one of:

  • ready: eligible to enter a resolved cumulative line;
  • incomplete: required evidence is missing;
  • ambiguous: more than one interpretation remains;
  • unsupported: the daily result cannot be represented by this contract.

is_final is separate. A structurally ready but provisional observation still makes the cumulative line incomplete because every later point depends on it.

Keep two clocks

Each revision records:

  • effective_at: when the information applies in market or business time;
  • available_at: when this system received or accepted it.

At a knowledge_cutoff, only events with available_at <= knowledge_cutoff exist for the calculation. The implementation parses availability first and ignores future events before inspecting their identity, source state, or counts. A malformed future correction therefore cannot change an earlier answer.

This is the difference between reconstructing what was known and rebuilding history with hindsight.

Require one revision lineage

Visible events stay in supplied ingest order; the function never sorts them. Within each session:

  1. revision one has no parent;
  2. later revisions use the next contiguous number;
  3. each revision supersedes the exact previously selected event;
  4. a duplicate identity, skipped number, branch, or competing parent is ambiguous;
  5. the latest visible cancellation makes the expected session incomplete.
Rendering system map…

Missing is not zero

The contract owns the expected session list. If a middle session is missing or cancelled, the algorithm stops the diagnostic prefix at that boundary. It does not insert zero and does not connect later observations across the gap.

An observed zero means advances equaled declines. A missing session means the increment is unknown. Drawing both as a flat step is misinformation.

Publish only resolved evidence

StatusInterpretationMain pointsFinal value
resolvedEvery expected session has one final, ready, reconciled revision.PresentPresent
incompleteMissing/cancelled session, provisional source, unavailable seed, or incomplete daily evidence.EmptyNull
ambiguousRevision lineage or daily evidence cannot be selected uniquely.EmptyNull
unsupportedMetric, partition, calendar, daily state, or continuity identity is incompatible.EmptyNull

An incomplete result may include causal_prefix_diagnostic. The name is deliberate: it is troubleshooting evidence, not a partially publishable line.

Synthetic worked example

With seed zero:

SessionAdvancesDeclinesNetCumulative
2026-01-056030+3030
2026-01-063555-2010
2026-01-074040010
2026-01-086525+4050
2026-01-093060-3020

A synthetic second revision for January 6 changes Net Advances from -20 to -10. Before its availability, the original resolved line remains 30, 10, 10, 50, 20. After its availability, the rebuilt line is 30, 20, 20, 60, 30. The correction moves the entire suffix by +10.

Open the guided cumulative-line lab to step through resolved, corrected, missing-gap, provisional, series-break, and ambiguous-lineage scenarios. Step advances exactly one evidence transition. Reset restores the same initial state.

A bounded real observation, not a resolved package result

Nasdaq Trader provides yearly Daily Market Files and lists Nasdaq Advances, Declines, and Unchanged among the fields. The official 2026 CSV was independently checked on 2026-07-23.

DateAdvances observedDeclines observedUnchanged observedNet derivedZero-seed cumulative derived
2026-03-022,4432,431228+1212
2026-03-031,3593,542190-2,183-2,171
2026-03-043,3681,509224+1,859-312
2026-03-051,4303,454195-2,024-2,336
2026-03-061,4613,384238-1,923-4,259

The three count columns are provider observations. Net and cumulative columns are arithmetic derived here. The free file does not provide this package's stable point-in-time roster, excluded and unclassified partition, per-row availability timestamp, or revision chain. Therefore the table is not a resolved output, does not prove equal coverage across dates, and carries no causal, predictive, or trading claim.

Implementation and testing

The Python and TypeScript implementations share one synthetic fixture. Both reject safe-integer overflow and malformed timestamps, preserve input order, ignore future-only anomalies, recompute corrections, and suppress the main line for all non-resolved states.

The tests cover:

  • canonical and translated-seed sequences;
  • earlier and later correction cutoffs;
  • missing, cancelled, and provisional sources;
  • incomplete, ambiguous, and unsupported daily evidence;
  • hidden unclassified issues;
  • branches, duplicate IDs, and noncontiguous ingest positions;
  • every stable identity break;
  • calendar, partition, timestamp, overflow, and immutability failures;
  • future-only series breaks, duplicate IDs, bad partitions, bad counts, and bad source states.

What this line does not prove

A rising segment means the included sessions added positive Net Advances overall; a falling segment means negative values accumulated. It does not identify why, predict the next return, or validate a strategy. Divergence detection requires separate peak rules, alignment, confirmation, and empirical testing. Fidelity itself cautions that A/D signals may not confirm trends or forecast reversals.

Correct recurrence and clean data contracts prove implementation fidelity. They do not prove profitability, causation, or investment suitability.

Summary

A trustworthy cumulative A/D line needs five disciplines:

  1. one explicit seed and continuity identity;
  2. one ordered calendar with no invented observations;
  3. complete daily issue classification;
  4. causal revision selection using availability time;
  5. full suffix recomputation and no publication outside resolved.

The next topic, Normalized Advance/Decline Line, addresses scaling conventions. It does not relax any of these evidence requirements.

Revision lineage and suffix recomputation

This diagram shows how a causally visible correction becomes a new resolved line.

Rendering system map…

Takeaway: a correction is never a local chart edit, and it cannot be applied before its availability time.

Causal recurrence and publication flow

This diagram separates causal filtering from publication.

Rendering system map…

Takeaway: future, missing, provisional, ambiguous, or incompatible evidence never becomes a publishable cumulative value.

References4 primary sources and evidence notes

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

R1 — Nasdaq Trader Daily Market Files

  • Organization: Nasdaq Trader
  • Source type: Official exchange market-statistics download page
  • Publication or effective date: Live year-to-date service
  • Version: 2026 CSV checked 2026-07-23
  • URL: Daily Market Files
  • Data URL: 2026 CSV
  • Accessed: 2026-07-23
  • Jurisdiction: United States
  • Supports: The existence and scope of downloadable yearly daily market-statistics files; the five bounded Advances, Declines, and Unchanged observations used in this package.
  • Limitations: Does not supply this package's stable roster, excluded partition, per-row availability timestamp, or correction lineage. Redistribution terms were not inferred; only a small attributed observation is recorded.

R2 — Nasdaq Trader Daily Market Summary Definitions

  • Organization: Nasdaq Trader
  • Source type: Official exchange field definitions
  • Publication or effective date: Live documentation
  • Version: Reviewed 2026-07-23
  • URL: Daily Market Summary Definitions
  • Accessed: 2026-07-23
  • Jurisdiction: United States
  • Supports: The year-to-date file includes Nasdaq Advances, Declines, and Unchanged; issue counts are based on issues active for the provided date.
  • Limitations: Does not define a cumulative recurrence, roster identity, exclusions, publication clock, or revisions.

R3 — Fidelity advance/decline education

  • Organization: Fidelity Investments
  • Source type: Maintained institutional education article
  • Publication date: 2026-04-20
  • Version: Live page reviewed 2026-07-23
  • URL: Advance/decline indicator
  • Accessed: 2026-07-23
  • Jurisdiction: United States
  • Supports: The familiar A/D line as the cumulative total of advancing stocks less declining stocks; caution that chart signals may not confirm or forecast reversals.
  • Limitations: Educational interpretation, not an exchange methodology. Its current market commentary is not reused as evidence for this algorithm.

R4 — Nasdaq A-D glossary

  • Organization: Nasdaq, Inc.
  • Source type: Official exchange glossary
  • Publication date: Not stated
  • Version: Live page reviewed 2026-07-23
  • URL: A-D definition
  • Accessed: 2026-07-23
  • Jurisdiction: United States
  • Supports: Advances less declines relative to prior closing prices and the market-breadth context.
  • Limitations: Does not specify the cumulative seed, universe, calendar, corrections, or software behavior.

Evidence boundary

The causal event model, two-clock semantics, safe-integer limits, revision-chain rules, explicit result states, and refusal to publish incomplete evidence are package design decisions. Correction examples are synthetic; no historical Nasdaq correction chain is claimed.

cumulativeAdvanceDeclineLine.ts
/** Causal, revision-aware Cumulative Advance/Decline Line. */

export type ResultStatus = "resolved" | "incomplete" | "ambiguous" | "unsupported";
export type Action = "upsert" | "cancel";

export interface ExpectedSession { session_sequence: number; session_date: string }
export interface SeriesContract {
  metric: "cumulative_issue_count_advance_decline_line" | string;
  series_id: string; continuity_id: string; venue_id: string; universe_id: string;
  calendar_id: string; session_type: string; comparison_basis: string;
  seed: number; seed_lineage_id: string; seed_effective_at: string;
  seed_available_at: string; expected_sessions: ExpectedSession[];
}
export interface BreadthEvent {
  event_id: string; ingest_sequence: number; revision_number: number;
  supersedes_event_id: string | null; action: Action; session_sequence: number;
  session_date: string; effective_at: string; available_at: string;
  series_id: string; continuity_id: string; venue_id: string; universe_id: string;
  calendar_id: string; session_type: string; comparison_basis: string;
  universe_snapshot_id: string; is_final: boolean;
  source_evidence_state: "ready" | "incomplete" | "ambiguous" | "unsupported";
  advances?: number; declines?: number; unchanged?: number; excluded?: number; unclassified?: number;
  universe_size?: number;
}
export interface BreadthLinePoint {
  session_sequence: number; session_date: string; event_id: string;
  revision_number: number; effective_at: string; available_at: string;
  advances: number; declines: number; unchanged: number; excluded: number;
  unclassified: number;
  universe_size: number; net_advances: number; cumulative_line: number;
  source_is_provisional: boolean; line_is_provisional: boolean;
  universe_snapshot_id: string;
  source_evidence_state: "ready";
}
export interface BreadthLineResult {
  status: ResultStatus; reasons: string[]; knowledge_cutoff: string; seed: number;
  points: BreadthLinePoint[]; missing_sessions: string[]; cancelled_sessions: string[];
  final_value: number | null; causal_prefix_diagnostic: BreadthLinePoint[];
  contains_provisional: boolean; ignored_future_event_count: number;
  recompute_from_session: string | null; blocked_from_session: string | null;
  metric: "cumulative_issue_count_advance_decline_line";
}

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

const MAX_SAFE = Number.MAX_SAFE_INTEGER;
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
const IDENTITY_FIELDS = ["series_id", "continuity_id", "venue_id", "universe_id", "calendar_id", "session_type", "comparison_basis"] as const;
const COUNT_FIELDS = ["advances", "declines", "unchanged", "excluded", "unclassified", "universe_size"] as const;

function text(value: unknown, field: string): string {
  if (typeof value !== "string" || value.trim() === "") throw new BreadthLineValidationError(`${field} must be a non-empty string.`);
  return value;
}
function timestamp(value: unknown, field: string): number {
  if (typeof value !== "string" || !/(Z|[+-]\d{2}:\d{2})$/.test(value) || Number.isNaN(Date.parse(value))) throw new BreadthLineValidationError(`${field} must be an RFC 3339 timestamp with offset.`);
  return Date.parse(value);
}
function date(value: unknown, field: string): string {
  if (typeof value !== "string" || !DATE_RE.test(value) || new Date(`${value}T00:00:00Z`).toISOString().slice(0, 10) !== value) throw new BreadthLineValidationError(`${field} must be a real YYYY-MM-DD date.`);
  return value;
}
function integer(value: unknown, field: string, nonnegative = false): number {
  if (!Number.isSafeInteger(value) || (nonnegative && (value as number) < 0)) throw new BreadthLineValidationError(`${field} must be a ${nonnegative ? "non-negative " : ""}safe integer.`);
  return value as number;
}
function base(status: ResultStatus, reasons: string[], cutoff: string, seed: number, ignored = 0): BreadthLineResult {
  return {status, reasons, knowledge_cutoff: cutoff, seed, points: [], final_value: null, causal_prefix_diagnostic: [], missing_sessions: [], cancelled_sessions: [], contains_provisional: false, ignored_future_event_count: ignored, recompute_from_session: null, blocked_from_session: null, metric: "cumulative_issue_count_advance_decline_line"};
}

export function calculateCumulativeAdvanceDeclineLine(contract: SeriesContract, events: BreadthEvent[], knowledgeCutoff: string): BreadthLineResult {
  if (contract === null || typeof contract !== "object") throw new BreadthLineValidationError("contract must be an object.");
  if (!Array.isArray(events)) throw new BreadthLineValidationError("events must be an ordered array.");
  const cutoff = timestamp(knowledgeCutoff, "knowledge_cutoff");
  const seed = integer(contract.seed, "seed");
  text(contract.seed_lineage_id, "seed_lineage_id");
  const seedEffective = timestamp(contract.seed_effective_at, "seed_effective_at");
  const seedAvailable = timestamp(contract.seed_available_at, "seed_available_at");
  if (seedAvailable < seedEffective) throw new BreadthLineValidationError("seed_available_at cannot precede seed_effective_at.");
  for (const field of IDENTITY_FIELDS) text(contract[field], field);
  if (contract.metric !== "cumulative_issue_count_advance_decline_line") return base("unsupported", ["unsupported_metric"], knowledgeCutoff, seed);
  if (!Array.isArray(contract.expected_sessions)) throw new BreadthLineValidationError("expected_sessions must be an ordered array.");
  const expected: ExpectedSession[] = [];
  let previousDate: string | null = null;
  for (let i = 0; i < contract.expected_sessions.length; i++) {
    const raw = contract.expected_sessions[i];
    const sequence = integer(raw.session_sequence, "session_sequence", true);
    const sessionDate = date(raw.session_date, "session_date");
    if (sequence !== i + 1 || (previousDate !== null && sessionDate <= previousDate)) return base("unsupported", ["expected_session_sequence_not_contiguous"], knowledgeCutoff, seed);
    expected.push({session_sequence: sequence, session_date: sessionDate}); previousDate = sessionDate;
  }
  const expectedByDate = new Map(expected.map(x => [x.session_date, x.session_sequence]));
  if (cutoff < seedAvailable) { const result = base("incomplete", ["seed_not_yet_available"], knowledgeCutoff, seed); result.blocked_from_session = expected[0]?.session_date ?? null; return result; }

  const validated: Array<BreadthEvent & {_available: number}> = [];
  let previousAvailable: number | null = null;
  const seen = new Set<string>(); let duplicate = false;
  const unsupported = new Set<string>();
  let ignored = 0;
  for (let i = 0; i < events.length; i++) {
    const raw = events[i]; if (raw === null || typeof raw !== "object") throw new BreadthLineValidationError("each event must be an object.");
    const event = structuredClone(raw) as BreadthEvent & {_available: number};
    const available = timestamp(event.available_at, "available_at");
    if (available > cutoff) { ignored++; continue; }
    const id = text(event.event_id, "event_id"); if (seen.has(id)) duplicate = true; seen.add(id);
    if (integer(event.ingest_sequence, "ingest_sequence", true) !== validated.length + 1) unsupported.add("event_sequence_not_contiguous");
    const effective = timestamp(event.effective_at, "effective_at");
    if (available < effective) throw new BreadthLineValidationError("available_at cannot precede effective_at.");
    if (previousAvailable !== null && available < previousAvailable) unsupported.add("events_not_in_ingest_order"); previousAvailable = available; event._available = available;
    event.session_date = date(event.session_date, "session_date"); event.session_sequence = integer(event.session_sequence, "session_sequence", true); event.revision_number = integer(event.revision_number, "revision_number", true);
    if (event.revision_number < 1) throw new BreadthLineValidationError("revision_number must be at least 1.");
    if (event.action !== "upsert" && event.action !== "cancel") throw new BreadthLineValidationError("action must be upsert or cancel.");
    if (event.supersedes_event_id !== null) text(event.supersedes_event_id, "supersedes_event_id");
    for (const field of IDENTITY_FIELDS) { text(event[field], field); if (event[field] !== contract[field]) unsupported.add("series_break_required"); }
    if (expectedByDate.get(event.session_date) !== event.session_sequence) unsupported.add("event_outside_expected_calendar");
    text(event.universe_snapshot_id, "universe_snapshot_id"); if (typeof event.is_final !== "boolean") throw new BreadthLineValidationError("is_final must be a boolean.");
    if (!["ready", "incomplete", "ambiguous", "unsupported"].includes(event.source_evidence_state)) throw new BreadthLineValidationError("source_evidence_state must be ready, incomplete, ambiguous, or unsupported.");
    if (event.action === "upsert") {
      const counts = Object.fromEntries(COUNT_FIELDS.map(f => [f, integer(event[f], f, true)])) as Record<typeof COUNT_FIELDS[number], number>;
      if (counts.advances + counts.declines + counts.unchanged + counts.excluded + counts.unclassified !== counts.universe_size) unsupported.add("unreconciled_issue_partition");
      if (event.source_evidence_state === "ready" && counts.unclassified !== 0) unsupported.add("ready_source_contains_unclassified_issues");
      Object.assign(event, counts);
    }
    validated.push(event);
  }
  if (unsupported.size) return base("unsupported", [...unsupported].sort(), knowledgeCutoff, seed);
  const visible = validated;
  if (duplicate) return base("ambiguous", ["duplicate_event_id"], knowledgeCutoff, seed, ignored);
  const grouped = new Map(expected.map(x => [x.session_date, [] as typeof visible]));
  for (const event of visible) grouped.get(event.session_date)!.push(event);
  const selected = new Map<string, typeof visible[number]>(); const corrected: string[] = [];
  for (const session of expected) {
    const chain = grouped.get(session.session_date)!; if (!chain.length) continue;
    for (let i = 0; i < chain.length; i++) {
      const requiredParent = i === 0 ? null : chain[i - 1].event_id;
      if (chain[i].revision_number !== i + 1 || chain[i].supersedes_event_id !== requiredParent) return base("ambiguous", [`non_unique_revision_lineage:${session.session_date}`], knowledgeCutoff, seed, ignored);
    }
    selected.set(session.session_date, chain.at(-1)!); if (chain.length > 1) corrected.push(session.session_date);
  }
  let running = seed; let provisionalSeen = false; let blocked: string | null = null;
  let sourceBlockStatus: ResultStatus | null = null;
  const points: BreadthLinePoint[] = []; const missing: string[] = []; const cancelled: string[] = []; const reasons: string[] = [];
  for (const session of expected) {
    const event = selected.get(session.session_date);
    if (!event) { missing.push(session.session_date); blocked ??= session.session_date; continue; }
    if (event.action === "cancel") { cancelled.push(session.session_date); blocked ??= session.session_date; continue; }
    const sourceState = event.source_evidence_state;
    if (sourceState !== "ready") {
      blocked ??= session.session_date; reasons.push(`source_evidence_${sourceState}:${session.session_date}`);
      if (sourceState === "unsupported") sourceBlockStatus = "unsupported";
      else if (sourceState === "ambiguous" && sourceBlockStatus !== "unsupported") sourceBlockStatus = "ambiguous";
      else if (sourceBlockStatus === null) sourceBlockStatus = "incomplete";
      continue;
    }
    if (blocked !== null) continue;
    const advances = event.advances!; const declines = event.declines!; const net = advances - declines; running = integer(running + net, "cumulative_line");
    const sourceProvisional = !event.is_final; provisionalSeen ||= sourceProvisional;
    points.push({session_sequence: session.session_sequence, session_date: session.session_date, event_id: event.event_id, revision_number: event.revision_number, effective_at: event.effective_at, available_at: event.available_at, advances, declines, unchanged: event.unchanged!, excluded: event.excluded!, unclassified: event.unclassified!, universe_size: event.universe_size!, net_advances: net, cumulative_line: running, source_is_provisional: sourceProvisional, line_is_provisional: provisionalSeen, universe_snapshot_id: event.universe_snapshot_id, source_evidence_state: "ready"});
  }
  if (missing.length) reasons.push("missing_expected_session"); if (cancelled.length) reasons.push("cancelled_session"); if (provisionalSeen) reasons.push("provisional_source_in_suffix");
  const status: ResultStatus = sourceBlockStatus ?? (reasons.length ? "incomplete" : "resolved");
  const publishable = status === "resolved";
  return {status, reasons, knowledge_cutoff: knowledgeCutoff, seed, points: publishable ? points : [], final_value: publishable ? (points.at(-1)?.cumulative_line ?? seed) : null, causal_prefix_diagnostic: publishable ? [] : points, missing_sessions: missing, cancelled_sessions: cancelled, contains_provisional: provisionalSeen, ignored_future_event_count: ignored, recompute_from_session: corrected.length ? [...corrected].sort()[0] : null, blocked_from_session: blocked, metric: "cumulative_issue_count_advance_decline_line"};
}
Full-height labcumulative ad lineOpen full screen