Library/Market Breadth and Internals/Advance/Decline Breadth

D04-F01-A05 / Complete engineering topic

Absolute Breadth Index

A production-minded guide to Absolute Breadth Index.

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

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

On 2026-03-25, Nasdaq Trader's annual file reported 3,254 advancing issues and 1,611 declining issues. On 2026-05-19, it reported 1,636 advances and 3,279 declines.

The signed spreads point in opposite directions:

3,2541,611=+1,6433{,}254-1{,}611=+1{,}643 1,6363,279=1,6431{,}636-3{,}279=-1{,}643

But the raw Absolute Breadth Index (ABI) is the same:

+1,643=1,643=1,643|+1{,}643|=|-1{,}643|=1{,}643

That pair makes ABI's value and cost obvious. It preserves the magnitude of an advance/decline imbalance and deliberately removes its direction.

Two official Nasdaq provider observations fold to the same author-derived raw ABI

The provider observations come from Nasdaq Trader's official Daily Market Files and 2026 CSV. Net Advances and ABI are author-derived arithmetic. The rows teach direction loss; they do not prove a causal market event or a trading signal.

Begin with the signed quantity

Let:

  • AtA_t be the number of advancing issues in session tt;
  • DtD_t be the number of declining issues;
  • NAt=AtDtNA_t=A_t-D_t be signed Net Advances.

The canonical raw ABI is:

ABIt=NAt=AtDtABI_t=|NA_t|=|A_t-D_t|

Schwab's thinkorswim documentation describes ABI as the absolute value of its Advances-minus-Declines spread, and TC2000 documents the same raw absolute-difference calculation before explaining its own transformed platform series. The unit of the raw calculation is issues, and each session stands alone.

Raw ABI is not:

  • a cumulative A/D line;
  • an A/D ratio;
  • a normalized percentage;
  • a moving average;
  • price-return variance or implied volatility;
  • share volume, dollar volume, or turnover;
  • evidence of prediction or profitability.

These boundaries matter because vendor products sometimes use the same label for transformed series. TC2000, for example, documents a five-day normalized construction in addition to raw absolute-difference examples. A chart label alone is not a complete algorithm specification.

What absolute value destroys

With mover count Rt=At+DtR_t=A_t+D_t:

0ABItRt0\le ABI_t\le R_t

Swapping advances and declines preserves magnitude:

AD=DA|A-D|=|D-A|

That symmetry is also non-invertibility. If ABI is 50, signed breadth could be +50 or -50. ABI alone cannot recover the original counts either. A trustworthy output therefore carries:

  • advances;
  • declines;
  • signed Net Advances;
  • raw ABI;
  • the source-state diagnostic;
  • universe, policy, clock, revision, and finality metadata.

Showing only “ABI = 50” hides the very direction that absolute value removed.

Three zeros that mean different things

Active balance, no movers, and an empty universe can all produce zero.

ConditionStateABIMeaning
A=D>0A=D>0Active balance0Moving issues cancel exactly
A=D=0,N>0A=D=0, N>0No movers0A population exists, but no issue moved
N=0N=0Empty universe0No population exists

Active balance, no movers, and empty universe share ABI zero but require different labels

A generic “flat breadth” label would be unclear. The numeric result is the same; the evidence state is not.

The arithmetic is easy; the population is hard

A production calculation needs more than advances and declines. Define:

  • UU: unchanged issues;
  • XX: issues excluded under a known policy;
  • MM: missing issues;
  • QQ: unclassified issues;
  • NN: point-in-time universe size.

The full partition must reconcile:

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

Missing and unclassified issues are not zeros. They are unresolved evidence. This package refuses to publish a raw ABI when M>0M>0, Q>0Q>0, or the partition fails—even if AD|A-D| can be calculated mechanically.

The snapshot identity also needs:

  • a stable listing identifier scheme;
  • venue and universe IDs plus a universe revision;
  • session and calendar IDs;
  • comparable-prior-close policy;
  • corporate-action policy.

These fields determine which issues belong in the population and how each one becomes advancing, declining, unchanged, or excluded. A current roster cannot safely reconstruct a historical population. Listings, delistings, symbol changes, missing prior closes, halts, suspensions, and distributions all need declared treatment.

Two clocks prevent future leakage

Every revision has:

  • effective_at: when the record applies;
  • available_at: when a researcher could know it.

At decision time TT, a record is usable only when both clocks are at or before TT. A correction published later cannot change an earlier point-in-time result.

Revisions must form one unique contiguous chain:

Plain text
revision 1
  -> revision 2 supersedes revision 1
  -> revision 3 supersedes revision 2

Two competing revision 2 records are ambiguous. A jump from revision 1 to revision 3 is ambiguous. A cancellation at the current head is incomplete until replacement evidence arrives.

Four outcomes, not one hopeful number

The reference implementations return one of four evaluation states:

StateWhy it occursPublished arithmetic
resolvedUnique causal lineage, reconciled partition, no missing or unclassified issuesNet Advances and raw ABI
incompleteMissing causal evidence, partition problem, unresolved issues, or cancellationNull
ambiguousConflicting identity or revision lineageNull
unsupportedRequested metric is not raw issue-count ABINull

A coherent provisional revision can be resolved, but it remains visibly provisional. A later final correction changes the affected session. Any separately defined smoother must then recompute windows containing that session.

Rendering system map…

The guided ABI lab lets you step through the historical direction-loss lesson, all three resolved zero states, missing evidence, a revision conflict, and a future correction that stays hidden until it becomes available.

A synthetic contract-complete mirror

Historical provider rows often lack fields required by a stricter production contract. A synthetic pair can isolate the algorithm while satisfying every field:

ScenarioADUXMQNNetABIEvaluation
Advances dominate60308200100+3030Resolved
Declines dominate30608200100-3030Resolved

The equal ABI is not evidence that these market states are equivalent. It proves only the mathematical symmetry.

Why the Nasdaq pair remains bounded evidence

Nasdaq's official field definitions say the annual file includes the Composite, Advances, Declines, and Unchanged and that Number of Issues is based on issues active for the date. Those statements support the labels used in the source file.

They do not supply everything this package requires for a resolved output. The free file does not expose a stable row-level listing roster; exclusions, missing, and unclassified counts; row publication timestamps; adjustment and comparison policy; or revision lineage.

Accordingly:

  • the exact CSV counts and Composite levels are provider observations;
  • Net Advances and raw ABI are author-derived;
  • the dates are not asserted to share equal universes;
  • the Composite levels are context, not an explanatory variable;
  • there is no claim of causation, prediction, threshold behavior, or profit.

This wording is not a disclaimer pasted onto a fact. It is the correct evidence classification.

Raw versus normalized ABI

Raw ABI grows with the scale of the population. If proportional imbalance is the intended question, a researcher may define:

nABIt=AtDtAt+DtnABI_t=\frac{|A_t-D_t|}{A_t+D_t}

That mover-normalized ratio is bounded between 0 and 1 when movers exist, and undefined when A+D=0A+D=0. It is a different metric with a different unit and edge-case policy. The canonical function returns unsupported when asked to calculate it under the raw-ABI contract.

Likewise, smoothing adds window, warm-up, missing-session, and revision policies. In general:

SMA(AD)SMA(AD)SMA(|A-D|) \ne |SMA(A-D)|

The first averages magnitudes. The second permits opposite signed spreads to cancel before absolute value. They should never share an unlabeled series.

Implementation and verification

The package includes matching Python and TypeScript implementations backed by one shared fixture.

Tests cover:

  • advance- and decline-dominant mirrors;
  • active balance, no movers, and empty universe;
  • missing, unclassified, and partition failures;
  • a future correction ignored at the earlier decision time;
  • the same correction applied after it becomes available;
  • duplicate, broken, and non-contiguous revision lineages;
  • conflicting snapshot identity;
  • cancellations and provisional revisions;
  • unsupported variants, unsafe counts, time-zone offsets, and non-mutation.

Passing these tests establishes implementation behavior. It does not establish market usefulness.

Practical display contract

When raw ABI is shown, display it beside:

  • Advances and Declines;
  • signed Net Advances or dominant side;
  • unchanged and excluded counts;
  • coverage;
  • universe and session identity;
  • provisional/final status;
  • evaluation state and reason codes.

That display keeps the magnitude useful without pretending it contains direction or certainty it never had.

Summary

Raw ABI is exactly:

ABIt=AtDtABI_t=|A_t-D_t|

Its simplicity is a feature only when three responsibilities stay visible:

  1. retain the direction and original counts that absolute value removes;
  2. distinguish equal arithmetic from equal evidence states;
  3. publish a number only from a complete causal revision lineage.

Use Net Advances when direction is the question. Use a separately named normalized or smoothed measure when scale or persistence is the question. Use actual return-volatility estimators when price variability is the question.

For the full contract, evidence note, tests, and visual assets, see the package README and references.

Absolute Breadth Index evidence and calculation flow

This flow separates request support, point-in-time evidence, revision identity, and count completeness before arithmetic.

Rendering system map…

Takeaway: an arithmetically available number is not a publishable result unless its causal evidence is resolved.

ReferencesPrimary sources and evidence notes

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

Web sources were checked on 2026-07-23. The annual CSV was retrieved at 2026-07-22T22:01:20Z.

ABI-01 — Schwab thinkorswim AdvanceDecline study

  • Organization: Charles Schwab / thinkorswim
  • Source type: Official platform documentation
  • URL: AdvanceDecline study
  • Supports: The ABI plot as the absolute value of the Advance/Decline Spread, where the spread is advances minus declines.
  • Limitations: Platform documentation does not expose every source-universe, classification, and revision rule.

ABI-02 — TC2000 T2101 help

  • Organization: TC2000 Software Company
  • Source type: Official product documentation
  • URL: T2101 Absolute Breadth Index
  • Supports: Raw absolute-difference examples and the existence of a distinct vendor-specific five-day normalized construction.
  • Limitations: The platform transformation is not the canonical raw daily issue-count ABI used in this package.

ABI-03 — Nasdaq Trader Daily Market Files

  • Organization: Nasdaq Trader
  • Source type: Official venue data landing page
  • URL: Daily Market Files
  • Supports: The annual downloadable file as a source of daily Nasdaq market statistics.
  • Limitations: The landing page does not establish a row-level stable universe, adjustment policy, availability time, or revision lineage.

ABI-04 — Nasdaq Daily Market Summary definitions

  • Organization: Nasdaq Trader
  • Source type: Official field definitions
  • URL: Daily Market Summary definitions
  • Supports: The year-to-date file includes the Nasdaq Composite, Advances, Declines, and Unchanged; “Number of Issues” is based on active issues for the date.
  • Limitations: Does not define the full exclusion, missing, and unclassified partition required by this package.

ABI-05 — Nasdaq Trader 2026 annual CSV

  • Organization: Nasdaq Trader
  • Source type: Official downloadable annual data file
  • URL: 2026 CSV
  • Retrieved: 2026-07-22T22:01:20Z
  • Raw response SHA-256: 849742f9d4bf93288c52b54a832f7c5ab94dbfb291c6844febc551bec009cdb0
  • Supports: Provider observations for 2026-03-25 and 2026-05-19 used in the bounded direction-loss example.
  • Limitations: A year-to-date file can be revised. Exact Net Advances and raw ABI in the article are author-derived arithmetic, not provider fields. No causal market interpretation is asserted.

Evidence reconciliation

Sourced facts:

  • Maintained platform references define raw ABI as AD|A-D|.
  • Nasdaq's official file reported the two cited rows at the recorded retrieval time.

Author-derived arithmetic:

  • 2026-03-25 Net Advances +1,643, raw ABI 1,643.
  • 2026-05-19 Net Advances -1,643, raw ABI 1,643.

Explicit package choices:

  • the canonical variant is a raw, non-cumulative integer issue count;
  • signed spread and original counts remain in resolved output;
  • only a complete causal lineage can publish arithmetic;
  • resolved, incomplete, ambiguous, and unsupported are implementation states, not vendor labels.

Claims intentionally not made:

  • the two Nasdaq rows do not prove equal universes;
  • ABI is not described as price volatility, volume, or a trading signal;
  • no relationship is inferred between breadth counts and Composite levels;
  • no threshold, forecast, causation, or profitability claim is made.
absoluteBreadthIndex.ts
/** Point-in-time evaluation of the raw Absolute Breadth Index. */

export type EvaluationState = "resolved" | "incomplete" | "ambiguous" | "unsupported";
export type BreadthState =
  | "advances_imbalanced"
  | "declines_imbalanced"
  | "active_balance"
  | "no_movers"
  | "empty_universe";

export interface RevisionRecord {
  record_id: string;
  revision: number;
  supersedes_record_id: string | null;
  kind: "observation" | "cancellation";
  effective_at: string;
  available_at: string;
  is_final: boolean;
  session_date: string;
  venue_id: string;
  universe_id: string;
  universe_revision: string;
  session_id: string;
  calendar_id: string;
  comparison_basis: string;
  corporate_action_policy: string;
  listing_identifier_scheme: string;
  advances: number;
  declines: number;
  unchanged: number;
  excluded: number;
  missing: number;
  unclassified: number;
  universe_size: number;
}

export interface ABIRequest {
  decision_time: string;
  metric_variant: string;
  records: RevisionRecord[];
}

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

const countFields = [
  "advances", "declines", "unchanged", "excluded",
  "missing", "unclassified", "universe_size",
] as const;
const identityFields = [
  "session_date", "venue_id", "universe_id", "universe_revision",
  "session_id", "calendar_id", "comparison_basis",
  "corporate_action_policy", "listing_identifier_scheme",
] as const;

function timestamp(value: unknown, field: string): number {
  if (typeof value !== "string" || value.trim() === "") {
    throw new ABIValidationError(`${field} must be a non-empty RFC 3339 timestamp.`);
  }
  const parsed = Date.parse(value);
  if (!Number.isFinite(parsed) || !/(?:Z|[+-]\d\d:\d\d)$/.test(value)) {
    throw new ABIValidationError(`${field} must be an RFC 3339 timestamp with an offset.`);
  }
  return parsed;
}

function emptyResult(
  state: Exclude<EvaluationState, "resolved">,
  reasonCodes: string[],
  decisionTime: string,
  visibleCount = 0,
  futureCount = 0,
) {
  return {
    metric: "raw_absolute_breadth_index" as const,
    evaluation_state: state,
    publishable: false,
    reason_codes: reasonCodes,
    decision_time: decisionTime,
    records_causally_available: visibleCount,
    future_records_ignored: futureCount,
    record_id: null,
    revision: null,
    is_provisional: null,
    net_advances: null,
    absolute_breadth_index: null,
    mover_count: null,
    classified_count: null,
    coverage_ratio: null,
    breadth_state: null,
  };
}

function validateRecord(record: RevisionRecord, index: number) {
  if (record === null || typeof record !== "object") {
    throw new ABIValidationError(`records[${index}] must be an object.`);
  }
  for (const field of ["record_id", ...identityFields] as const) {
    if (typeof record[field] !== "string" || record[field].trim() === "") {
      throw new ABIValidationError(`records[${index}].${field} must be non-empty.`);
    }
  }
  if (!Number.isSafeInteger(record.revision) || record.revision < 1) {
    throw new ABIValidationError(`records[${index}].revision must be a positive integer.`);
  }
  if (
    record.supersedes_record_id !== null
    && (typeof record.supersedes_record_id !== "string" || record.supersedes_record_id.trim() === "")
  ) {
    throw new ABIValidationError(
      `records[${index}].supersedes_record_id must be null or non-empty.`,
    );
  }
  if (record.kind !== "observation" && record.kind !== "cancellation") {
    throw new ABIValidationError(`records[${index}].kind must be observation or cancellation.`);
  }
  if (typeof record.is_final !== "boolean") {
    throw new ABIValidationError(`records[${index}].is_final must be boolean.`);
  }
  const effectiveAt = timestamp(record.effective_at, `records[${index}].effective_at`);
  const availableAt = timestamp(record.available_at, `records[${index}].available_at`);
  if (record.kind === "observation") {
    for (const field of countFields) {
      if (!Number.isSafeInteger(record[field]) || record[field] < 0) {
        throw new ABIValidationError(
          `records[${index}].${field} must be a non-negative safe integer.`,
        );
      }
    }
  }
  return {...record, _effectiveAt: effectiveAt, _availableAt: availableAt};
}

export function evaluateAbsoluteBreadthIndex(request: ABIRequest) {
  if (request === null || typeof request !== "object") {
    throw new ABIValidationError("request must be an object.");
  }
  const decisionTime = timestamp(request.decision_time, "decision_time");
  if (request.metric_variant !== "raw_issue_count") {
    return emptyResult(
      "unsupported", ["metric_variant_not_raw_issue_count"], request.decision_time,
    );
  }
  if (!Array.isArray(request.records)) {
    throw new ABIValidationError("records must be an array.");
  }
  if (request.records.length === 0) {
    return emptyResult("incomplete", ["no_revision_records"], request.decision_time);
  }
  const records = request.records.map(validateRecord);
  const visible = records.filter(
    (record) => record._availableAt <= decisionTime && record._effectiveAt <= decisionTime,
  );
  const futureCount = records.length - visible.length;
  if (visible.length === 0) {
    return emptyResult(
      "incomplete", ["no_causally_available_record"], request.decision_time, 0, futureCount,
    );
  }
  const ids = visible.map((record) => record.record_id);
  if (new Set(ids).size !== ids.length) {
    return emptyResult(
      "ambiguous", ["duplicate_record_id"], request.decision_time,
      visible.length, futureCount,
    );
  }
  const identity = identityFields.map((field) => visible[0][field]).join("\u001f");
  if (visible.slice(1).some(
    (record) => identityFields.map((field) => record[field]).join("\u001f") !== identity,
  )) {
    return emptyResult(
      "ambiguous", ["conflicting_snapshot_identity"], request.decision_time,
      visible.length, futureCount,
    );
  }
  const ordered = [...visible].sort((left, right) => left.revision - right.revision);
  const revisions = ordered.map((record) => record.revision);
  if (new Set(revisions).size !== revisions.length) {
    return emptyResult(
      "ambiguous", ["duplicate_revision"], request.decision_time,
      visible.length, futureCount,
    );
  }
  if (revisions.some((revision, index) => revision !== index + 1)) {
    return emptyResult(
      "ambiguous", ["non_contiguous_revision_lineage"], request.decision_time,
      visible.length, futureCount,
    );
  }
  if (
    ordered[0].supersedes_record_id !== null
    || ordered.slice(1).some(
      (record, index) => record.supersedes_record_id !== ordered[index].record_id,
    )
  ) {
    return emptyResult(
      "ambiguous", ["broken_revision_lineage"], request.decision_time,
      visible.length, futureCount,
    );
  }
  const head = ordered.at(-1)!;
  if (head.kind === "cancellation") {
    return emptyResult(
      "incomplete", ["current_revision_cancelled"], request.decision_time,
      visible.length, futureCount,
    );
  }
  const partition = countFields.slice(0, -1)
    .reduce((sum, field) => sum + head[field], 0);
  if (partition !== head.universe_size) {
    return emptyResult(
      "incomplete", ["partition_mismatch"], request.decision_time,
      visible.length, futureCount,
    );
  }
  const unresolved: string[] = [];
  if (head.missing > 0) unresolved.push("missing_issues_present");
  if (head.unclassified > 0) unresolved.push("unclassified_issues_present");
  if (unresolved.length > 0) {
    return emptyResult(
      "incomplete", unresolved, request.decision_time, visible.length, futureCount,
    );
  }
  const netAdvances = head.advances - head.declines;
  const absoluteBreadthIndex = Math.abs(netAdvances);
  const moverCount = head.advances + head.declines;
  const classifiedCount = moverCount + head.unchanged;
  let breadthState: BreadthState;
  if (head.universe_size === 0) breadthState = "empty_universe";
  else if (moverCount === 0) breadthState = "no_movers";
  else if (netAdvances === 0) breadthState = "active_balance";
  else if (netAdvances > 0) breadthState = "advances_imbalanced";
  else breadthState = "declines_imbalanced";

  return {
    ...Object.fromEntries(identityFields.map((field) => [field, head[field]])),
    ...Object.fromEntries(countFields.map((field) => [field, head[field]])),
    metric: "raw_absolute_breadth_index" as const,
    evaluation_state: "resolved" as const,
    publishable: true,
    reason_codes: [] as string[],
    decision_time: request.decision_time,
    records_causally_available: visible.length,
    future_records_ignored: futureCount,
    record_id: head.record_id,
    revision: head.revision,
    is_provisional: !head.is_final,
    net_advances: netAdvances,
    absolute_breadth_index: absoluteBreadthIndex,
    mover_count: moverCount,
    classified_count: classifiedCount,
    coverage_ratio: head.universe_size ? classifiedCount / head.universe_size : null,
    breadth_state: breadthState,
  };
}

export const calculateAbsoluteBreadthIndex = evaluateAbsoluteBreadthIndex;
Full-height lababi playgroundOpen full screen