Library/Market Breadth and Internals/Advance/Decline Breadth

D04-F01-A02 / Complete engineering topic

Advance/Decline Ratio: Advancing Issues per Declining Issue

Calculate advancing issues per declining issue without hiding zero denominators, incomplete coverage, or correction timing.

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

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

A market index can move because a few heavily weighted securities moved. The Advance/Decline Ratio asks a different question: among the issues in one declared population, how many advanced for each issue that declined?

The answer is useful only when the denominator and the population are honest. A displayed value of 2 might represent 60 advances and 30 declines with complete coverage, or 6 advances and 3 declines while 90 issues are missing. Those observations share arithmetic, but not evidence quality.

This tutorial builds one exact variant: advancing issues divided by declining issues. It does not turn that ratio into a forecast or trading instruction.

The definition is narrow

For session (t):

ADRt=AtDt\operatorname{ADR}_t=\frac{A_t}{D_t}
  • (A_t): issues with a comparable current close above the comparable prior close;
  • (D_t): issues with a comparable current close below the comparable prior close.

Nasdaq's official glossary uses the same high-level definition: advancing stocks divided by declining stocks over a period. The glossary does not settle the production questions of universe membership, corporate-action adjustment, corrections, missing observations, or division by zero. Those belong in the implementation contract.

A ratio above 1 means advances outnumber declines. A ratio below 1 means declines outnumber advances. A ratio of exactly 1 means the two counts are equal.

Start with the issue universe

Before counting anything, declare:

  • the venue or composite;
  • the trading calendar and session;
  • the point-in-time universe and membership revision;
  • the allowed security types;
  • a stable listing identifier, not only a reusable ticker;
  • the current close and comparable prior-close basis;
  • the corporate-action adjustment policy;
  • the observation's effective time and the time it became available.

Every universe member must reconcile into one category:

CategoryTreatment
AdvanceEnters the numerator
DeclineEnters the denominator
UnchangedClassified, but enters neither side
New or no prior closeExcluded and reported
HaltedExcluded under the declared halt policy
SuspendedExcluded under the declared suspension policy
DelistedRequires declared terminal/delisting treatment
MissingRequired observation absent
UnclassifiedPresent but not assignable under the policy

The counts must sum exactly to the declared universe size. Missing count fields are errors; they are never interpreted as zeros.

Corporate actions matter because a raw prior close can be economically incomparable with today's close after a split or distribution. The ratio implementation receives already classified counts, so the data producer must preserve the issue-level evidence behind those counts.

Keep evidence state separate from ratio state

One status cannot explain both data quality and denominator behavior. This package returns two.

Evidence state:

  • resolved: final snapshot, full coverage, unique and complete correction chain;
  • incomplete: provisional snapshot or one or more excluded issues;
  • ambiguous: conflicting revisions, duplicate record IDs, a revision gap, or broken supersession;
  • unsupported: no matching record was available at the requested as-of time.

Ratio state:

  • finite: declines are positive, so (A/D) is finite;
  • no_declines: advances are positive and declines are zero;
  • no_movers: both advances and declines are zero in a non-empty universe;
  • empty_universe: the declared universe has no issues.

Only a resolved and finite observation receives a publishable ratio. An incomplete observation can show an observed_partition_ratio for diagnosis, but the main ratio remains null.

The resolved ratio and explicit denominator states

The picture makes the key separation visible: the denominator state answers whether division is defined; the evidence state answers whether the result is publishable.

Division by zero is not a formatting problem

Consider four boundary cases:

AdvancesDeclinesUniverse contextResult
050Positive denominatorFinite ratio 0
500Advances onlyno_declines, null ratio
00All unchanged or excludedno_movers, null ratio
00Empty universeempty_universe, null ratio and coverage

Returning infinity for (A>0,D=0) is a possible display policy, but it is not a finite value and it does not serialize safely in ordinary JSON. Adding one to each count, capping the output, or carrying the previous ratio changes the algorithm. This package does none of those things silently.

A resolved synthetic calculation

Suppose a final, fully classified 100-issue universe contains:

Plain text
60 advances
30 declines
10 unchanged
0 excluded

The partition reconciles to 100, and coverage is 100%. The ratio is:

60/30=260/30=2

The output is resolved, finite, and advances_dominant: two advancing issues for each declining issue.

Now change one unchanged issue into a new listing without a comparable prior close. The observed counts still yield 2, but coverage is 99%. The evidence state becomes incomplete; advance_decline_ratio is null, while observed_partition_ratio=2 remains available as a clearly labeled diagnostic.

Identical observed ratios can have different evidence coverage

The lesson is simple: a ratio without raw counts and coverage can conceal its weakest assumption.

Corrections require two clocks

effective_at says when a snapshot applies. available_at says when a researcher or live system could have known it.

For an as-of query, the algorithm:

  1. matches the exact venue, calendar, session, universe, listing scheme, security-type policy, comparison basis, and corporate-action policy;
  2. ignores records whose available_at is later than the decision time;
  3. requires unique record IDs and contiguous revisions starting at 1;
  4. verifies that every correction supersedes the immediately prior record;
  5. chooses the highest eligible revision.

If two eligible records claim the same revision, or the chain has a gap, the algorithm returns ambiguous rather than guessing. Future corrections do not leak into an earlier result.

Rendering system map…

A real exchange observation, with a strict boundary

Nasdaq Trader's official 2026 daily market-statistics CSV reports:

DateAdvancesDeclinesUnchangedAuthor-derived (A/D)
2026-03-051,4303,4541950.4140127389
2026-06-263,1071,9121921.625

The counts are source observations. The ratios are transparent divisions of those reported columns. This does not make them resolved package results.

The CSV does not provide the full point-in-time roster, stable listing IDs, excluded buckets, exact prior-close adjustment rules, publication timestamps, or a correction chain. For that reason, the two values are labeled observed-partition diagnostics, not authoritative complete-universe ratios.

To produce a resolved historical case, retain the security master, session calendar, issue-level current and prior comparable closes, corporate-action records, exclusion reasons, publication timestamps, and revision lineage. Official NYSE materials describe licensed trade/quote, closing-price, reference-data, and corporate-action products that could supply parts of that evidence. Product descriptions alone do not prove a historical ratio.

What this ratio is not

Do not substitute a nearby measure just because its name sounds similar:

  • net advances are (A-D), not (A/D);
  • percentage advancing uses a universe or classified-count denominator;
  • normalized breadth is ((A-D)/(A+D));
  • volume breadth weights by traded volume;
  • capitalization breadth weights by market value;
  • an index return aggregates price performance under index weights.

The Advance/Decline Ratio is an equal-count participation descriptor. A small and a large company each contribute one issue when both are eligible.

Implementation and validation

The Python and TypeScript implementations use one synthetic fixture set. Their tests cover:

  • ratios above, below, and equal to one;
  • zero advances, zero declines, all unchanged, and an empty universe;
  • each exclusion category and incomplete coverage;
  • provisional snapshots;
  • missing, negative, fractional, and unsafe counts;
  • conflicting, missing, duplicate, and broken revisions;
  • policy mismatch and future-evidence isolation;
  • separation from net advances and percentage advancing.

Passing these tests proves that the code follows the declared contract. It does not prove that the metric predicts returns or supports a profitable strategy.

Use the interactive lab to choose a scenario, step through evidence checks, and compare denominator and coverage states. Reset always returns to the same complete canonical preview.

Practical checklist

Before publishing a ratio, ask:

  1. Is the venue, session, calendar, and universe named?
  2. Are listing identities stable through ticker changes?
  3. Is the prior close comparable after corporate actions?
  4. Do all category counts reconcile to the universe?
  5. Are unchanged and every exclusion bucket visible?
  6. Is the correction available as of the evaluation time?
  7. Is the revision chain unique and complete?
  8. Is the evidence resolved and the ratio state finite?

If any answer is no, show the diagnostic state instead of a confident-looking number.

Summary

The formula is advances divided by declines. The trustworthy result is larger than the formula: it includes stable universe identity, comparable closes, explicit exclusions, two clocks, correction lineage, coverage, and separate evidence and denominator states.

The next related topic is the Cumulative Advance/Decline Line, which accumulates net advances rather than this ratio.

Evidence and ratio state flow

Rendering system map…

Takeaway: evidence quality and denominator behavior are separate state dimensions. A finite observed partition is publishable only when evidence is resolved.

Correction-aware snapshot lifecycle

Rendering system map…

Takeaway: effective_at describes the market state; available_at controls what was knowable for an as-of evaluation.

References7 primary sources and evidence notes

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

R01 - Advance/Decline Ratio glossary

  • Organization: Nasdaq, Inc.
  • Source type: Official exchange glossary
  • Publication date: Undated live page
  • URL: https://www.nasdaq.com/glossary/b/advance-decline-ratio
  • Accessed: 2026-07-23
  • Supports: The named ratio is advancing stocks divided by declining stocks over a period.
  • Limitations: It does not define a production universe, corrections, coverage, or zero-denominator policy.

R02 - Daily Market Files landing page

  • Organization: Nasdaq Trader
  • Source type: Official exchange data page
  • Publication date: Continuously maintained
  • URL: https://www.nasdaqtrader.com/Trader.aspx?id=DailyMarketFiles
  • Accessed: 2026-07-23
  • Supports: Nasdaq publishes annual downloadable files containing daily market statistics.
  • Limitations: The page and files do not supply this package's stable roster, exclusion partition, exact publication timestamp, or revision chain.

R03 - 2026 Daily Market Statistics CSV

  • Organization: Nasdaq Trader
  • Source type: Official exchange-hosted CSV
  • Publication date: 2026 year-to-date file
  • URL: https://www.nasdaqtrader.com/dynamic/dailyfiles/daily2026.csv
  • Accessed: 2026-07-23
  • Supports: Bounded observations used in the article: 2026-03-05 reports 1,430 advances, 3,454 declines, and 195 unchanged; 2026-06-26 reports 3,107 advances, 1,912 declines, and 192 unchanged.
  • Limitations: The rows do not establish the complete point-in-time issue roster, excluded categories, stable listing identities, comparison-price adjustments, availability time, or correction lineage. Derived ratios are therefore observed-partition diagnostics, not resolved package outputs. Do not redistribute the source file without checking Nasdaq's terms.

R04 - Daily TAQ catalog

  • Organization: New York Stock Exchange
  • Source type: Official exchange product catalog
  • Publication date: Live catalog
  • URL: https://www.nyse.com/data-products/catalog/daily-taq
  • Accessed: 2026-07-23
  • Supports: A licensed reconstruction source can include previous-day trades and quotes, master data, and consolidated U.S. equity coverage; historical availability is described from 1993.
  • Limitations: Product access and redistribution are licensed. The page does not publish the historical ratio used here.

R05 - TAQ NYSE Closing Prices Client Specification

  • Organization: NYSE Technologies
  • Source type: Official technical specification
  • Version: 1.1, 2012-10-15
  • URL: https://www.nyse.com/publicdocs/nyse/data/TAQ_NYSE_Closing_Prices_Client_Spec.pdf
  • Accessed: 2026-07-23
  • Supports: Closing-price files contain listed-issue identifiers and close-related fields and have documented daily delivery timing.
  • Limitations: Older specification; not a current universal breadth methodology and not evidence for any numeric A/D row.

R06 - NYSE Reference Data

  • Organization: New York Stock Exchange
  • Source type: Official exchange product page
  • Publication date: Live page
  • URL: https://www.nyse.com/market-data/reference
  • Accessed: 2026-07-23
  • Supports: The NYSE Group Security Master is a daily exchange record for maintaining listed and traded security reference information.
  • Limitations: Product description, not a free historical roster or an A/D calculation.

R07 - NYSE Corporate Actions

  • Organization: New York Stock Exchange
  • Source type: Official exchange product page
  • Publication date: Live page
  • URL: https://www.nyse.com/market-data/corporate-actions
  • Accessed: 2026-07-23
  • Supports: Corporate-action and listing-event data are separate inputs needed to make prior-close comparisons and membership treatment reproducible.
  • Limitations: It does not prescribe this package's classification policy.

Evidence boundary

ClaimClassificationSource
Ratio variant is advances divided by declinesSourced definitionR01
The two 2026 count triplets appear in an official Nasdaq-hosted CSVSourced observationsR02-R03
1,430 / 3,454 = 0.4140127389 and 3,107 / 1,912 = 1.625Derived arithmeticIndependently reproducible from R03
Those derived values are resolved historical ratiosNot claimedRequired audit fields are absent from R03
Explicit states and null publishable ratio for incomplete evidenceImplementation choiceThis package
The ratio predicts returns or produces a tradeNot claimedRequires separate empirical research
advanceDeclineRatio.ts
/** Correction-aware reference implementation for advancing issues / declining issues. */

export type EvidenceState = "resolved" | "incomplete" | "ambiguous" | "unsupported";
export type RatioState = "finite" | "no_declines" | "no_movers" | "empty_universe";
export type Direction = "advances_dominant" | "declines_dominant" | "balanced" | "advances_only" | "no_movers" | "no_universe";

export interface BreadthSnapshot {
  series_id: string; record_id: string; session_date: string;
  effective_at: string; available_at: string; venue_id: string;
  calendar_id: string; session_id: string; universe_id: string;
  universe_revision: string; listing_id_scheme: string;
  security_type_policy: string; comparison_basis: string;
  corporate_action_policy: string; revision: number; is_final: boolean;
  supersedes_record_id: string | null;
  advances: number; declines: number; unchanged: number;
  new_or_no_prior_close: number; halted: number; suspended: number;
  delisted: number; missing: number; unclassified: number; universe_size: number;
}

export interface BreadthQuery {
  session_date: string; decision_as_of: string; venue_id: string;
  universe_id: string; series_id: string;
  calendar_id: string; session_id: string; universe_revision: string;
  listing_id_scheme: string; security_type_policy: string;
  comparison_basis: string; corporate_action_policy: string;
}

export interface BreadthResult {
  evidence_state: EvidenceState; ratio_state: RatioState | null;
  advance_decline_ratio: number | null; direction: Direction | null;
  observed_partition_ratio: number | null; observed_partition_direction: Direction | null;
  selected_record_id: string | null; selected_revision: number | null;
  advances: number | null; declines: number | null; unchanged: number | null;
  excluded_count: number | null; universe_size: number | null;
  mover_count: number | null; classified_count: number | null;
  coverage_ratio: number | null; is_provisional: boolean | null;
  metric: "advancing_issues_divided_by_declining_issues"; reasons: string[];
}

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

const TEXT_FIELDS = ["series_id", "record_id", "venue_id", "calendar_id", "session_id", "universe_id", "universe_revision", "listing_id_scheme", "security_type_policy", "comparison_basis", "corporate_action_policy"] as const;
const COUNT_FIELDS = ["advances", "declines", "unchanged", "new_or_no_prior_close", "halted", "suspended", "delisted", "missing", "unclassified", "universe_size"] as const;
const EXCLUSION_FIELDS = ["new_or_no_prior_close", "halted", "suspended", "delisted", "missing", "unclassified"] as const;
const QUERY_POLICY_FIELDS = ["calendar_id", "session_id", "universe_revision", "listing_id_scheme", "security_type_policy", "comparison_basis", "corporate_action_policy"] as const;
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
const RFC3339_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/;

function validDate(value: unknown, field: string): asserts value is string {
  if (typeof value !== "string" || !DATE_RE.test(value)) throw new BreadthValidationError(`${field} must be a valid YYYY-MM-DD date.`);
  const [y, m, d] = value.split("-").map(Number); const parsed = new Date(Date.UTC(y, m - 1, d));
  if (parsed.getUTCFullYear() !== y || parsed.getUTCMonth() !== m - 1 || parsed.getUTCDate() !== d) throw new BreadthValidationError(`${field} must be a valid YYYY-MM-DD date.`);
}

function validTimestamp(value: unknown, field: string): number {
  if (typeof value !== "string" || !RFC3339_RE.test(value) || Number.isNaN(Date.parse(value))) throw new BreadthValidationError(`${field} must be an RFC 3339 timestamp.`);
  return Date.parse(value);
}

function validCount(value: unknown, field: string): number {
  if (!Number.isSafeInteger(value) || (value as number) < 0) throw new BreadthValidationError(`${field} must be a non-negative cross-language safe integer.`);
  return value as number;
}

function validateSnapshot(snapshot: BreadthSnapshot): BreadthSnapshot {
  if (snapshot === null || typeof snapshot !== "object") throw new BreadthValidationError("snapshot must be an object.");
  validDate(snapshot.session_date, "session_date");
  const effective = validTimestamp(snapshot.effective_at, "effective_at");
  const available = validTimestamp(snapshot.available_at, "available_at");
  if (available < effective) throw new BreadthValidationError("available_at cannot precede effective_at.");
  for (const field of TEXT_FIELDS) if (typeof snapshot[field] !== "string" || snapshot[field].trim() === "") throw new BreadthValidationError(`${field} must be a non-empty string.`);
  if (snapshot.supersedes_record_id !== null && (typeof snapshot.supersedes_record_id !== "string" || snapshot.supersedes_record_id.trim() === "")) throw new BreadthValidationError("supersedes_record_id must be null or a non-empty string.");
  if (validCount(snapshot.revision, "revision") < 1) throw new BreadthValidationError("revision must be at least 1.");
  if (typeof snapshot.is_final !== "boolean") throw new BreadthValidationError("is_final must be a boolean.");
  const counts = Object.fromEntries(COUNT_FIELDS.map((f) => [f, validCount(snapshot[f], f)])) as Record<(typeof COUNT_FIELDS)[number], number>;
  const partition = COUNT_FIELDS.filter((f) => f !== "universe_size").reduce((sum, f) => sum + counts[f], 0);
  if (partition !== counts.universe_size) throw new BreadthValidationError("all classification buckets must sum exactly to universe_size.");
  return {...snapshot, ...counts};
}

export function calculateAdvanceDeclineRatio(snapshot: BreadthSnapshot): BreadthResult {
  const item = validateSnapshot(snapshot); const a = item.advances; const d = item.declines;
  const excluded = EXCLUSION_FIELDS.reduce((sum, f) => sum + item[f], 0);
  const movers = a + d; const classified = movers + item.unchanged;
  const coverage = item.universe_size === 0 ? null : classified / item.universe_size;
  let ratioState: RatioState; let ratio: number | null; let direction: Direction;
  if (item.universe_size === 0) { ratioState = "empty_universe"; ratio = null; direction = "no_universe"; }
  else if (movers === 0) { ratioState = "no_movers"; ratio = null; direction = "no_movers"; }
  else if (d === 0) { ratioState = "no_declines"; ratio = null; direction = "advances_only"; }
  else { ratioState = "finite"; ratio = a / d; direction = ratio > 1 ? "advances_dominant" : ratio < 1 ? "declines_dominant" : "balanced"; }
  const reasons: string[] = []; if (!item.is_final) reasons.push("snapshot_is_provisional"); if (excluded > 0) reasons.push("coverage_is_incomplete");
  const evidenceState: EvidenceState = reasons.length ? "incomplete" : "resolved";
  return {evidence_state: evidenceState, ratio_state: ratioState, advance_decline_ratio: evidenceState === "resolved" ? ratio : null,
    direction: evidenceState === "resolved" ? direction : null, observed_partition_ratio: ratio, observed_partition_direction: direction,
    selected_record_id: item.record_id, selected_revision: item.revision, advances: a, declines: d, unchanged: item.unchanged,
    excluded_count: excluded, universe_size: item.universe_size, mover_count: movers, classified_count: classified,
    coverage_ratio: coverage, is_provisional: !item.is_final, metric: "advancing_issues_divided_by_declining_issues", reasons};
}

function emptyResult(state: EvidenceState, reason: string): BreadthResult {
  return {evidence_state: state, ratio_state: null, advance_decline_ratio: null, direction: null, observed_partition_ratio: null, observed_partition_direction: null, selected_record_id: null, selected_revision: null,
    advances: null, declines: null, unchanged: null, excluded_count: null, universe_size: null, mover_count: null, classified_count: null,
    coverage_ratio: null, is_provisional: null, metric: "advancing_issues_divided_by_declining_issues", reasons: [reason]};
}

export function evaluateAdvanceDeclineRatioAsOf(snapshots: BreadthSnapshot[], query: BreadthQuery): BreadthResult {
  if (query === null || typeof query !== "object") throw new BreadthValidationError("query must be an object.");
  validDate(query.session_date, "query.session_date"); const decision = validTimestamp(query.decision_as_of, "query.decision_as_of");
  for (const field of ["venue_id", "universe_id", "series_id", ...QUERY_POLICY_FIELDS] as const) if (typeof query[field] !== "string" || query[field].trim() === "") throw new BreadthValidationError(`query.${field} must be a non-empty string.`);
  const eligible = snapshots.filter((raw) => {
    if (raw === null || typeof raw !== "object") throw new BreadthValidationError("each snapshot must be an object.");
    if (raw.session_date !== query.session_date || raw.venue_id !== query.venue_id || raw.universe_id !== query.universe_id || raw.series_id !== query.series_id || QUERY_POLICY_FIELDS.some((field) => raw[field] !== query[field])) return false;
    return validTimestamp(raw.available_at, "available_at") <= decision;
  }).map(validateSnapshot);
  if (!eligible.length) return emptyResult("unsupported", "no_snapshot_available_as_of_query");
  if (new Set(eligible.map((item) => item.record_id)).size !== eligible.length) return emptyResult("ambiguous", "duplicate_record_id");
  const highest = Math.max(...eligible.map((item) => item.revision));
  const byRevision = new Map<number, BreadthSnapshot[]>(); for (const item of eligible) byRevision.set(item.revision, [...(byRevision.get(item.revision) ?? []), item]);
  for (let revision = 1; revision <= highest; revision += 1) if (!byRevision.has(revision)) return emptyResult("ambiguous", "non_contiguous_revision_chain");
  if ([...byRevision.values()].some((items) => items.length !== 1)) return emptyResult("ambiguous", "conflicting_highest_revision");
  for (let revision = 1; revision <= highest; revision += 1) { const current = byRevision.get(revision)![0]; const expected = revision === 1 ? null : byRevision.get(revision - 1)![0].record_id; if (current.supersedes_record_id !== expected) return emptyResult("ambiguous", "broken_supersession_chain"); }
  return calculateAdvanceDeclineRatio(byRevision.get(highest)![0]);
}
Full-height labadvance decline ratioOpen full screen