The governed definitions this build depends on. Read them first if a term is unfamiliar.
- PrimaryNet AdvancesNet Advances is the number of advancing issues minus the number of declining issues for one validated session.
- PrerequisiteAdvancing IssueAn advancing issue is an eligible security whose comparable price is above its prior comparable close.
- PrerequisiteDeclining IssueA declining issue is an eligible security whose comparable price is below its prior comparable close.
- PrerequisiteMarket BreadthMarket breadth describes how widely a market movement is shared across its eligible securities.
- PrerequisitePoint-in-Time Issue UniverseA point-in-time issue universe is the eligible security set established using information valid for the session being calculated.
- ImportantCausal Knowledge CutoffThe time boundary that prevents information unavailable at that moment from influencing a calculation.
- ImportantRevision LineageAn ordered chain connecting each correction to the exact earlier record it supersedes.
- ImportantUnchanged IssueAn unchanged issue is an eligible security whose comparable price did not move under the declared comparison policy.
- ImportantUniverse PartitionA universe partition assigns every eligible issue to exactly one declared classification bucket.
A market index tells you what happened to a weighted portfolio. It does not tell you how many listed issues participated. Net Advances supplies that second view:
The formula is one subtraction. The real work is deciding which issues existed in the universe, whether their prices are comparable, and what evidence was available at the time. This tutorial builds those controls into the result.
By the end, you can calculate the metric from stable listing records, replay a late correction without look-ahead, and explain why an incomplete snapshot must return no number.
What this number does and does not say
For one declared venue or feed, session, point-in-time universe, comparison policy, and revision, Net Advances asks:
How many more issues advanced than declined?
Each eligible issue has weight one. The result is not share volume, market capitalization, an index return, trend strength, a forecast, or a buy/sell signal.
Nasdaq describes A-D as issues above previous closing prices minus issues below them. That establishes the broad definition. It does not create a universal answer for which securities are eligible, how corporate actions change the comparable prior close, or when a correction becomes knowable. Those choices must travel with the data.
Start with stable identity
A ticker is a display label, not a durable key. Tickers can change or be reused. Two different listings can even share similar labels across venues.
This package classifies one listing_id at a time and also carries security_id. A duplicate listing record makes the snapshot ambiguous; the calculator does not guess which copy wins.
The point-in-time universe must include the awkward records as well as the easy ones:
- unchanged issues;
- new listings with no comparable prior close;
- halted or suspended issues;
- securities delisted during the period;
- missing required comparison prices;
- unresolved records whose policy is not yet known.
Silently dropping these names changes the universe and can change the result.
Declare the comparison rule
For eligible listing , let be the current comparison price and the comparable prior price. Let be a declared price-unit tolerance:
The calculator does not adjust a previous close for a split, distribution, or other corporate action. It classifies the two prices supplied by the source. The request must state the upstream corporate-action policy. If the comparison cannot be verified, mark the member unclassified instead of manufacturing a direction.
Reconcile the full universe
Let:
- be advances;
- be declines;
- be unchanged;
- be explicit exclusions;
- be unclassified;
- be the selected point-in-time universe.
Every unique listing must land in exactly one bucket:
Only when and identity is unique may the calculator publish:
It also returns mover count , classified count , and classification coverage:
Coverage is an audit diagnostic. It is not a probability that the result is correct.
The important branch happens before the subtraction.
Use two clocks, not one
effective_at says when a snapshot economically applies. available_at says when a consumer could first use it. A historical calculation must satisfy both:
The algorithm selects the highest unique eligible revision sequence and checks its supersession link. A later correction cannot alter an earlier as-of query.
Work a complete synthetic correction
The lab fixture has 12 stable listings and a tolerance of 0.01 price units.
At 21:30 UTC, only provisional revision R1 is available:
| Bucket | Count |
|---|---|
| Advances | 5 |
| Declines | 3 |
| Unchanged | 2 |
| Excluded | 2 |
| Unclassified | 0 |
The partition and metric are:
At 22:00 UTC, final revision R2 becomes available. It corrects one listing from advance to decline. A 22:30 query produces:
This is an active balance: eight issues moved. It is not the same as all unchanged, an empty universe, or missing evidence.
Open the guided revision lab and use Step to move through context, revision selection, member classification, reconciliation, and interpretation. Switch to the missing-price and duplicate-ID scenarios to see why the metric becomes null.
A real historical contrast, with a strict evidence boundary
Nasdaq Trader provides an official 2026 daily CSV with Nasdaq Composite levels plus published advances, declines, and unchanged counts. Two rows show why index direction and issue participation are different objects:
| Session | Composite close | Daily return derived from adjacent official closes | Advances | Declines | Unchanged | Derived |
|---|---|---|---|---|---|---|
| 2026-03-05 | 22,748.99 | -0.25645% | 1,430 | 3,454 | 195 | -2,024 |
| 2026-06-26 | 25,297.62 | -0.24047% | 3,107 | 1,912 | 192 | +1,195 |
Both Composite returns were close to -0.25%, yet the published breadth balances had opposite signs.
This is a bounded provider observation, not a strict ready result from our implementation. The free year file does not provide the stable listing roster, excluded and unclassified counts, publication time, or correction chain required by the package. We do not claim equal universes, a causal relation between the index move and breadth, or any predictive implication.
Return states that preserve meaning
| Status | Meaning | Net Advances |
|---|---|---|
ready | Unique, complete, reconciled snapshot | Integer |
no_movers | Complete non-empty universe, no advances or declines | 0 |
empty_universe | No members in the declared universe | 0 |
incomplete | At least one member cannot be classified | null |
ambiguous | Revision or listing identity is not unique | null |
unsupported | No revision was available by the query time | null |
Returning null is not a failure to calculate. It is a successful refusal to turn missing evidence into a false fact.
Implement the same contract in Python and TypeScript
Both implementations follow the same order:
- validate session, policy, tolerance, and timestamps;
- select a causally eligible revision;
- verify the revision chain and stable listing identity;
- classify eligible prices and retain exclusion reasons;
- reconcile the universe;
- suppress incomplete or ambiguous results;
- compute the signed difference and diagnostics.
The core arithmetic remains deliberately small:
classified = advances + declines + unchanged
movers = advances + declines
net_advances = advances - declines
See the Python source, TypeScript source, and shared fixture.
Test meaning, causality, and parity
The suite tests more than subtraction:
- before-publication queries are unsupported;
- future corrections do not change or leak into earlier output;
- final correction selection changes +2 to active zero;
- empty and all-unchanged universes remain distinct;
- every operational exclusion is counted;
- missing prices and explicit unclassified members suppress the metric;
- duplicate listing IDs and broken revision chains are ambiguous;
- ticker collision does not merge stable listings;
- tolerance boundaries match in both languages;
- invalid clocks, prices, identifiers, states, and sequences fail structurally;
- neither implementation mutates the input.
Common mistakes
- Using today's roster for an old session: creates survivorship bias.
- Comparing raw closes across a split: manufactures direction.
- Treating a halt as unchanged: substitutes a policy decision for evidence.
- Dropping a new listing: hides universe coverage.
- Selecting by revision sequence without availability time: introduces look-ahead.
- Merging by ticker: corrupts identity.
- Interpreting positive breadth as bullish prediction: goes beyond the metric.
What to carry forward
Net Advances is trustworthy only when the population, prices, identity, clocks, and revisions are trustworthy. Preserve the underlying counts and diagnostics, and let incomplete evidence remain visibly incomplete.
The next family topic, Advance/Decline Ratio, uses the same mover counts but introduces a denominator and its own zero-decline edge case.
Primary sources
- Nasdaq A-D glossary
- Nasdaq Daily Market Summary definitions
- Nasdaq Daily Market Files
- Official 2026 daily CSV
- FINRA breadth correction notice
Full source roles and limitations are recorded in REFERENCES.md.
Rendered from the canonical Mermaid sources linked by this article.
Net Advances evidence and calculation flow
Purpose: show why evidence state is resolved before arithmetic.
Takeaway: a signed number is published only after the selected universe is unique, complete, and reconciled.
Revision lifecycle with two clocks
Purpose: distinguish the session-effective clock from causal availability.
Takeaway: a correction changes only queries at or after its available_at time.
References6 primary 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.
Accessed 2026-07-23. The package redistributes only synthetic CC0 fixtures.
R1 - Nasdaq A-D glossary
- Organization: Nasdaq, Inc.
- Type: Official exchange glossary
- URL: A-D Definition
- Supports: The broad definition of advances minus declines relative to previous closing prices.
- Limitations: It does not define a complete operational universe, adjustment policy, or revision contract.
R2 - Nasdaq Daily Market Summary definitions
- Organization: Nasdaq Trader
- Type: Official market-statistics documentation
- URL: Daily Market Summary Definitions
- Supports: The daily summary covers Nasdaq issues across trading venues; the year file includes Composite, advances, declines, and unchanged; number of issues is based on issues active for the date.
- Limitations: The page does not establish this package's stable member roster, exclusions, availability timestamps, or correction chain.
R3 - Nasdaq Daily Market Files and 2026 CSV
- Organization: Nasdaq Trader
- Type: Official downloadable market statistics
- URLs: Daily Market Files, 2026 CSV
- Publication date: Updated during 2026
- Supports: The bounded 2026-03-05 and 2026-06-26 provider observations quoted in the README and article.
- Derived arithmetic: Daily Composite percentage changes and were calculated from the published rows.
- Limitations: No provider publication timestamp, member-level identity, excluded/unclassified partition, or correction lineage. Rows are not ingested as strict
readyfixtures and the CSV is not redistributed.
R4 - Consolidated Tape System output specification
- Organization: Consolidated Tape Association / NYSE
- Type: Official interface specification
- URL: CTS Output Multicast Interface Specification
- Supports: Operational dissemination includes up, down, and unchanged issue summaries and prior-day corrections.
- Limitations: Historical specification; not a universal current methodology and not used for the numeric historical observation.
R5 - FINRA TRACE End of Day Market Breadth correction
- Organization: Financial Industry Regulatory Authority
- Type: Official correction notice
- Publication date: 2019-11-18 for 2019-11-13 data
- URL: TRACE End of Day Market Breadth Error
- Supports: Breadth counts can be restated, so revision ID, availability time, and finality are operationally material.
- Limitations: The notice concerns 144A bond breadth, not the Nasdaq equity observation or a definition of this algorithm.
R6 - NYSE Daily TAQ catalog and reference data
- Organization: New York Stock Exchange
- Types: Official licensed-data catalog and reference pages
- URLs: Daily TAQ, Reference data, Corporate actions
- Supports: A defensible reconstruction needs licensed historical trades or closes, exchange reference identity, and listing/suspension/delisting events.
- Limitations: These pages describe evidence sources; they do not supply a free numeric session case for this package.
Evidence boundary
| Statement | Classification |
|---|---|
| Net Advances equals advancing issues minus declining issues | Sourced definition, R1 |
| Nasdaq year file fields and quoted session rows | Provider observation, R2-R3 |
| Percentage returns and signed differences in the example | Derived arithmetic |
| Similar weighted returns can coexist with opposite issue balances | Direct comparison of R3 rows |
| Stable IDs, five-part partition, statuses, and causal revision selection | Explicit implementation design |
| The example predicts a later return or explains why the market moved | Not claimed |
Full dependency-light reference implementations in both supported languages.
/** Causal, revision-aware reference implementation of Net Advances. */
export type BreadthStatus =
| "ready"
| "no_movers"
| "empty_universe"
| "incomplete"
| "ambiguous"
| "unsupported";
export type BreadthDirection =
| "advances_dominant"
| "declines_dominant"
| "balanced";
export type MemberState =
| "eligible"
| "excluded_halted"
| "excluded_suspended"
| "excluded_delisted"
| "excluded_new_no_prior_close"
| "missing_price"
| "unclassified";
export interface IssueObservation {
listing_id: string;
security_id: string;
ticker: string | null;
state: MemberState;
current_price: number | null;
prior_comparable_price: number | null;
}
export interface BreadthRevision {
revision_id: string;
revision_sequence: number;
supersedes_revision_id: string | null;
effective_at: string;
available_at: string;
is_final: boolean;
members: IssueObservation[];
}
export interface BreadthRequest {
session_date: string;
session_id: string;
session_timezone: string;
venue_id: string;
universe_id: string;
comparison_basis: string;
corporate_action_policy: string;
price_tolerance: number;
calculation_as_of: string;
revisions: BreadthRevision[];
}
export interface BreadthResult {
status: BreadthStatus;
direction: BreadthDirection | null;
metric: "advances_minus_declines";
session_date: string;
session_id: string;
venue_id: string;
universe_id: string;
calculation_as_of: string;
selected_revision_id: string | null;
selected_revision_sequence: number | null;
selected_effective_at: string | null;
selected_available_at: string | null;
is_provisional: boolean | null;
advances: number | null;
declines: number | null;
unchanged: number | null;
excluded: number | null;
unclassified: number | null;
universe_size: number | null;
mover_count: number | null;
classified_count: number | null;
coverage_ratio: number | null;
net_advances: number | null;
exclusion_counts: Record<string, number>;
diagnostics: string[];
}
export class BreadthValidationError extends Error {
constructor(message: string) {
super(message);
this.name = "BreadthValidationError";
}
}
const STATES = new Set<MemberState>([
"eligible",
"excluded_halted",
"excluded_suspended",
"excluded_delisted",
"excluded_new_no_prior_close",
"missing_price",
"unclassified",
]);
const EXCLUSIONS = [...STATES].filter(
(state) => state !== "eligible" && state !== "missing_price" && state !== "unclassified",
);
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 text(value: unknown, field: string): string {
if (typeof value !== "string" || value.trim() === "") {
throw new BreadthValidationError(`${field} must be a non-empty string.`);
}
return value;
}
function timestamp(value: unknown, field: string): [string, number] {
if (
typeof value !== "string" ||
!RFC3339_RE.test(value) ||
Number.isNaN(Date.parse(value))
) {
throw new BreadthValidationError(
`${field} must be a real RFC 3339 timestamp with an offset.`,
);
}
return [value, Date.parse(value)];
}
function sessionDate(value: unknown): string {
if (typeof value !== "string" || !DATE_RE.test(value)) {
throw new BreadthValidationError("session_date must be a real YYYY-MM-DD date.");
}
const [year, month, day] = value.split("-").map(Number);
const parsed = new Date(Date.UTC(year, month - 1, day));
if (
parsed.getUTCFullYear() !== year ||
parsed.getUTCMonth() !== month - 1 ||
parsed.getUTCDate() !== day
) {
throw new BreadthValidationError("session_date must be a real YYYY-MM-DD date.");
}
return value;
}
function safeInteger(value: unknown, field: string, positive = false): number {
if (
!Number.isSafeInteger(value) ||
(value as number) < (positive ? 1 : 0)
) {
throw new BreadthValidationError(
`${field} must be a ${positive ? "positive" : "non-negative"} safe integer.`,
);
}
return value as number;
}
function finitePrice(
value: unknown,
field: string,
strictlyPositive = false,
): number {
if (
typeof value !== "number" ||
!Number.isFinite(value) ||
value < 0 ||
(strictlyPositive && value <= 0)
) {
throw new BreadthValidationError(
`${field} must be a finite ${strictlyPositive ? "positive" : "non-negative"} number.`,
);
}
return value;
}
function emptyResult(
request: BreadthRequest,
status: BreadthStatus,
diagnostics: string[],
): BreadthResult {
return {
status,
direction: null,
metric: "advances_minus_declines",
session_date: request.session_date,
session_id: request.session_id,
venue_id: request.venue_id,
universe_id: request.universe_id,
calculation_as_of: request.calculation_as_of,
selected_revision_id: null,
selected_revision_sequence: null,
selected_effective_at: null,
selected_available_at: null,
is_provisional: null,
advances: null,
declines: null,
unchanged: null,
excluded: null,
unclassified: null,
universe_size: null,
mover_count: null,
classified_count: null,
coverage_ratio: null,
net_advances: null,
exclusion_counts: {},
diagnostics,
};
}
export function calculateNetAdvances(request: BreadthRequest): BreadthResult {
if (request === null || typeof request !== "object") {
throw new BreadthValidationError("request must be an object.");
}
sessionDate(request.session_date);
for (const field of [
"session_id",
"session_timezone",
"venue_id",
"universe_id",
"comparison_basis",
"corporate_action_policy",
] as const) {
text(request[field], field);
}
const [, calculationTime] = timestamp(
request.calculation_as_of,
"calculation_as_of",
);
const tolerance = finitePrice(request.price_tolerance, "price_tolerance");
if (!Array.isArray(request.revisions)) {
throw new BreadthValidationError("revisions must be an array.");
}
const candidates = request.revisions
.map((revision, index) => {
if (revision === null || typeof revision !== "object") {
throw new BreadthValidationError(`revisions[${index}] must be a record.`);
}
text(revision.revision_id, `revisions[${index}].revision_id`);
safeInteger(
revision.revision_sequence,
`revisions[${index}].revision_sequence`,
true,
);
const [, effectiveTime] = timestamp(
revision.effective_at,
`revisions[${index}].effective_at`,
);
const [, availableTime] = timestamp(
revision.available_at,
`revisions[${index}].available_at`,
);
if (availableTime < effectiveTime) {
throw new BreadthValidationError("available_at cannot precede effective_at.");
}
if (typeof revision.is_final !== "boolean") {
throw new BreadthValidationError(`revisions[${index}].is_final must be boolean.`);
}
if (revision.supersedes_revision_id !== null) {
text(
revision.supersedes_revision_id,
`revisions[${index}].supersedes_revision_id`,
);
}
if (!Array.isArray(revision.members)) {
throw new BreadthValidationError(`revisions[${index}].members must be an array.`);
}
return {...revision, effectiveTime, availableTime};
})
.filter(
(revision) =>
revision.effectiveTime <= calculationTime &&
revision.availableTime <= calculationTime,
);
if (candidates.length === 0) {
return emptyResult(request, "unsupported", [
"No revision was both effective and available at calculation_as_of.",
]);
}
if (new Set(candidates.map((r) => r.revision_id)).size !== candidates.length) {
return emptyResult(request, "ambiguous", [
"Causally available revision IDs are not unique.",
]);
}
const highestSequence = Math.max(...candidates.map((r) => r.revision_sequence));
const chain = [] as typeof candidates;
for (let sequence = 1; sequence <= highestSequence; sequence += 1) {
const matches = candidates.filter((r) => r.revision_sequence === sequence);
if (matches.length !== 1) {
return emptyResult(request, "ambiguous", [
"The causal revision chain has a gap or duplicate sequence.",
]);
}
chain.push(matches[0]);
}
if (
chain[0].supersedes_revision_id !== null ||
chain.slice(1).some((revision, index) => revision.supersedes_revision_id !== chain[index].revision_id)
) {
return emptyResult(request, "ambiguous", ["The causal supersession links are broken."]);
}
const selected = chain.at(-1)!;
const listingIds = new Set<string>();
const duplicateIds = new Set<string>();
const parsed: Array<[MemberState, number | null, number | null]> = [];
const exclusionCounts = Object.fromEntries(
EXCLUSIONS.map((state) => [state, 0]),
) as Record<string, number>;
selected.members.forEach((member, index) => {
if (member === null || typeof member !== "object") {
throw new BreadthValidationError(`members[${index}] must be a record.`);
}
const listingId = text(member.listing_id, `members[${index}].listing_id`);
text(member.security_id, `members[${index}].security_id`);
if (
member.ticker !== null &&
(typeof member.ticker !== "string" || member.ticker.trim() === "")
) {
throw new BreadthValidationError(`members[${index}].ticker must be null or non-empty.`);
}
if (!STATES.has(member.state)) {
throw new BreadthValidationError(`members[${index}].state is unsupported.`);
}
if (listingIds.has(listingId)) duplicateIds.add(listingId);
listingIds.add(listingId);
if (member.state === "eligible") {
if (member.current_price === null || member.prior_comparable_price === null) {
parsed.push(["unclassified", null, null]);
} else {
parsed.push([
member.state,
finitePrice(member.current_price, `members[${index}].current_price`),
finitePrice(
member.prior_comparable_price,
`members[${index}].prior_comparable_price`,
true,
),
]);
}
} else {
if (member.current_price !== null) {
finitePrice(member.current_price, `members[${index}].current_price`);
}
if (member.prior_comparable_price !== null) {
finitePrice(
member.prior_comparable_price,
`members[${index}].prior_comparable_price`,
true,
);
}
parsed.push([member.state, null, null]);
}
});
if (duplicateIds.size > 0) {
return {
...emptyResult(request, "ambiguous", [
"Duplicate listing_id values prevent a unique universe partition.",
]),
selected_revision_id: selected.revision_id,
selected_revision_sequence: selected.revision_sequence,
selected_effective_at: selected.effective_at,
selected_available_at: selected.available_at,
is_provisional: !selected.is_final,
};
}
let advances = 0;
let declines = 0;
let unchanged = 0;
let excluded = 0;
let unclassified = 0;
for (const [state, current, prior] of parsed) {
if (state === "eligible") {
const delta = (current as number) - (prior as number);
if (Math.abs(delta) <= tolerance + 1e-12) unchanged += 1;
else if (delta > 0) advances += 1;
else declines += 1;
} else if (state === "unclassified" || state === "missing_price") {
unclassified += 1;
} else {
excluded += 1;
exclusionCounts[state] += 1;
}
}
const universeSize = selected.members.length;
const classifiedCount = advances + declines + unchanged;
const moverCount = advances + declines;
const coverageRatio = universeSize ? classifiedCount / universeSize : null;
if (classifiedCount + excluded + unclassified !== universeSize) {
throw new Error("Internal partition invariant failed.");
}
let status: BreadthStatus;
let direction: BreadthDirection | null = null;
let netAdvances: number | null;
const diagnostics: string[] = [];
if (unclassified > 0) {
status = "incomplete";
netAdvances = null;
diagnostics.push(
"At least one member lacks enough causally available evidence for classification.",
);
} else if (universeSize === 0) {
status = "empty_universe";
netAdvances = 0;
diagnostics.push("The declared point-in-time universe is empty.");
} else if (moverCount === 0) {
status = "no_movers";
netAdvances = 0;
diagnostics.push("The complete universe has no advancing or declining issues.");
} else {
status = "ready";
netAdvances = advances - declines;
direction =
netAdvances > 0
? "advances_dominant"
: netAdvances < 0
? "declines_dominant"
: "balanced";
diagnostics.push("Counts reconcile to the selected point-in-time universe.");
}
return {
status,
direction,
metric: "advances_minus_declines",
session_date: request.session_date,
session_id: request.session_id,
venue_id: request.venue_id,
universe_id: request.universe_id,
calculation_as_of: request.calculation_as_of,
selected_revision_id: selected.revision_id,
selected_revision_sequence: selected.revision_sequence,
selected_effective_at: selected.effective_at,
selected_available_at: selected.available_at,
is_provisional: !selected.is_final,
advances,
declines,
unchanged,
excluded,
unclassified,
universe_size: universeSize,
mover_count: moverCount,
classified_count: classifiedCount,
coverage_ratio: coverageRatio,
net_advances: netAdvances,
exclusion_counts: exclusionCounts,
diagnostics,
};
}
The embedded lab now expands to its full document height, keeping the article as the only scroll surface.