A canonical, well-specified, cross-language (Python + TypeScript) reference implementation of Net Advances (
advances − declines) — the foundational market-breadth primitive — built around point-in-time revision selection (no look-ahead) and a hard rule: never publish a number the evidence does not support. Ships an as-of replay surface that makes revision risk visible.
📖 Full article (canonical): Net Advances — The Fintech Builder
This repository is the runnable, production-oriented companion to that article. The article teaches the concept; this repo is the code you install and build on.
🧭 Browse all algorithms: Awesome FinTech Algorithms — the full index of the library. 🗂️ This algorithm's domain: Market Breadth and Internals › Advance-Decline Breadth
| Catalog topic | D04-F01-A01 |
| Domain | D04 — Market Breadth and Internals |
| Family | D04-F01 — Advance-Decline Breadth |
| Difficulty | 2 / 5 |
| Languages | Python, TypeScript |
| Feeds | A/D Ratio · Cumulative A/D Line · McClellan Oscillator |
- What is Net Advances?
- The two things that actually go wrong
- Status: the refusal to guess
- Why this implementation
- Install
- Quickstart
- Replay: making revision risk visible
- Request & result shapes
- Worked example (exact)
- API reference
- Edge cases & limitations
- Testing
- Related algorithms
- License
net_advances = advances − declines
It's the count of issues that closed up minus the count that closed down — the primitive underneath the A/D Ratio, the Cumulative A/D Line, and the McClellan Oscillator. Breadth answers "how many stocks are participating?", which a cap-weighted index can hide entirely.
Subtracting two counts is trivial. Producing a number you can defend six months later is not — and that's what this package is actually about.
Breadth inputs get corrected. A vendor publishes a provisional close at 21:05, then a correction at 22:00 that flips one issue from advance to decline. If your backtest reads today's database, it silently uses the corrected value for a decision made at 21:30 — and your results are fiction.
So a request here carries the whole revision chain, and the calculator selects
the latest revision that was both effective and available at
calculation_as_of. The supersession chain is validated first (no sequence gaps,
no duplicate ids, links intact). A correction that lands later cannot change
an earlier query — that's a test, not a promise.
Every issue is partitioned:
advances + declines + unchanged + excluded + unclassified == universe_size
That invariant is enforced. If even one issue can't be classified, the result
does not quietly report the sum of the rest — it returns
status: "incomplete" and leaves net_advances as null.
status |
meaning | net_advances |
|---|---|---|
ready |
counts reconcile to the universe | the number |
no_movers |
complete universe, nothing advanced or declined | 0 (active zero) |
empty_universe |
the declared universe is empty | 0 (active zero) |
incomplete |
an issue lacks evidence to classify | null |
ambiguous |
broken revision chain / duplicate listing ids | null |
unsupported |
nothing was causally available at the as-of time | null |
The distinction that matters: no_movers and empty_universe are active
zeros — genuinely "nothing moved". incomplete is not zero, because there a
zero would be a lie. Most implementations collapse all four into 0 and lose the
difference.
- Point-in-time by construction — revision selection, chain validation, and
an
is_provisionalflag on every result. - Explicit statuses over silent zeros, with
diagnosticsexplaining why. - Full audit trail — the selected
revision_id/sequence/effective_at/available_at, the per-stateexclusion_counts,coverage_ratio, and the echoed request identity (venue, universe, comparison basis). - Replay helpers (this repo's addition) that turn the revision history into a timeline you can chart.
- Cross-language parity — both suites assert all three named fixture
query_expectations, field by field.
Python
pip install fintech-net-advancesTypeScript / JavaScript (Node ≥ 20)
npm install fintech-net-advancesPython
from fintech_net_advances import calculate_net_advances
result = calculate_net_advances(request) # request carries the revision chain
if result["status"] == "ready":
publish(result["net_advances"], provisional=result["is_provisional"])
else:
flag(result["status"], result["diagnostics"])TypeScript
import { calculateNetAdvances } from "fintech-net-advances";
const result = calculateNetAdvances(request);
if (result.status === "ready") publish(result.net_advances);The calculator answers one as-of question. The operationally interesting question is how the answer moved as corrections arrived — and whether a chart's move was a market event or a data event.
from fintech_net_advances import revision_timeline, replay_at_revision_boundaries
revision_timeline(request) # when each revision became usable, and why
replay_at_revision_boundaries(request) # the answer at each of those instantsRunning the bundled example prints exactly that (identical in both languages):
revision timeline:
SYNTH-R1: usable from 2026-01-05T21:05:00Z (provisional)
SYNTH-R2: usable from 2026-01-05T22:00:00Z (final)
replay at revision boundaries:
as of 2026-01-05T21:05:00Z: SYNTH-R1 -> net=2 (ready, provisional)
as of 2026-01-05T22:00:00Z: SYNTH-R2 -> net=0 (ready, final)
before first publication: status=unsupported net=None
Same session, same date — breadth "moved" from +2 to 0 because a correction
landed, not because the market did. usable_from is max(effective_at, available_at): a revision is usable only once it is both in force and published.
Request: session_date, session_id, session_timezone, venue_id,
universe_id, comparison_basis, corporate_action_policy, price_tolerance,
calculation_as_of, and revisions[].
Revision: revision_id, revision_sequence, supersedes_revision_id,
effective_at, available_at, is_final, members[].
Member: listing_id, security_id, ticker, state, current_price,
prior_comparable_price. Valid states: eligible, excluded_halted,
excluded_suspended, excluded_delisted, excluded_new_no_prior_close,
missing_price, unclassified.
Result: status, direction, net_advances, the counts
(advances/declines/unchanged/excluded/unclassified), universe_size,
mover_count, classified_count, coverage_ratio, exclusion_counts,
diagnostics, the full selected-revision audit trail, and is_provisional.
Note the calculator does not adjust prices.
prior_comparable_pricemust already be split/distribution-adjusted upstream;corporate_action_policyrecords whose convention was used.
A 12-issue synthetic universe, price_tolerance = 0.01, two revisions:
| as of | selected | status | A | D | U | excl | net | provisional |
|---|---|---|---|---|---|---|---|---|
21:04:59Z |
— | unsupported | — | — | — | — | null |
— |
21:30:00Z |
SYNTH-R1 |
ready | 5 | 3 | 2 | 2 | +2 (advances dominant) | yes |
22:30:00Z |
SYNTH-R2 |
ready | 4 | 4 | 2 | 2 | 0 (balanced) | no |
Both partitions reconcile to 12 with coverage_ratio = 10/12. The correction
moves one issue from advance to decline — and must not affect the 21:30 query.
Every field of all three rows is asserted by both language test suites.
| Purpose | Python | TypeScript |
|---|---|---|
| Point-in-time calculation | calculate_net_advances(request) |
calculateNetAdvances(request) |
| Revision usability timeline | revision_timeline(request) |
revisionTimeline(request) |
| Replay at chosen times | replay_as_of(request, times) |
replayAsOf(request, times) |
| Replay at revision boundaries | replay_at_revision_boundaries(request) |
replayAtRevisionBoundaries(request) |
| Errors | BreadthValidationError |
BreadthValidationError |
Structural problems (bad dates, malformed timestamps, unknown member states) raise; evidence problems return a non-ready status. That split is deliberate: a caller bug is not the same thing as a data gap.
available_atmay not precedeeffective_at— that combination is a structural error, not a data gap.- Ties are
balanced, anddirectionisnullwhenevernet_advancesis. - Tolerance is inclusive: a move of exactly
price_tolerancecounts as unchanged, not a mover. - Coverage is not quality. A high
coverage_ratiowith a stale universe is still wrong;universe_idandcomparison_basistravel with the result so the reader can check. - No price adjustment happens here — see the note above.
- Not a signal. Breadth describes participation, not future direction.
Python (39 tests)
cd python && pip install -e ".[dev]" && pytestTypeScript (28 tests, zero runtime dependencies)
cd typescript && npm install && npm test && npm run buildD04-F01-A02— Advance/Decline Ratio ·A03— Cumulative A/D Line ·A04— Normalized A/D Line ·A05— Absolute Breadth IndexD04-F02-A01— Traditional McClellan Oscillator (EMAs of Net Advances)D07-F01-A02— EMA (the smoothing the McClellan family uses)
Full index: Awesome FinTech Algorithms.
MIT © The Fintech Builder. Part of the 100 FinTech Algorithms library.