The governed definitions this build depends on. Read them first if a term is unfamiliar.
- PrimaryCumulative StateA persisted running value updated by adding each newly accepted increment to the previous accumulated value.
- PrimaryRatio-Adjusted BreadthRatio-adjusted breadth scales advances minus declines by advances plus declines.
- PrerequisiteMarket BreadthMarket breadth describes how widely a market movement is shared across its eligible securities.
- PrerequisiteMoving IssuesMoving Issues is the sum of advancing and declining issue counts for a session.
- PrerequisiteNet AdvancesNet Advances is the number of advancing issues minus the number of declining issues for one validated session.
- ImportantBreadth Scale FactorThe breadth scale factor multiplies a normalized issue-balance ratio into a declared reporting scale.
- ImportantCausal Knowledge CutoffThe time boundary that prevents information unavailable at that moment from influencing a calculation.
- ImportantCumulative Starting LevelThe declared initial constant from which a cumulative recurrence begins before accepted increments are added.
- ImportantNo-Imputation Missing-Session PolicyA policy that blocks or pauses calculation instead of converting a missing required session into a zero observation.
- ImportantPoint-in-Time Issue UniverseA point-in-time issue universe is the eligible security set established using information valid for the session being calculated.
- ImportantRevision LineageAn ordered chain connecting each correction to the exact earlier record it supersedes.
- ImportantSuffix RecalculationRecomputing a corrected observation and every later output whose recursive state may depend on it.
- ImportantZero-Mover SessionA zero-mover session has no advancing or declining issues, so A plus D equals zero.
Daily mover-normalized balance, accumulated with an explicit and immutable seed and display scale.
Suppose one session has 70 advancing issues and 30 declining issues. Another has 700 advances and 300 declines. Their Net Advances values are +40 and +400, but both have the same 70/30 split among movers. The second session is larger in absolute issue count; it is not stronger in proportional participation.
The Normalized Advance/Decline Line in this article answers that proportional question. It first calculates
and then accumulates
Here (A_t) and (D_t) are advancing and declining issue counts, (k) is one constant display scale, and the initial value is an explicit seed. This package uses seed 0 and scale 100.
That name needs the formula beside it. “Normalized A/D Line” is not a universal definition, and nearby indicators use materially different denominators or state updates.
What normalization changes
The raw cumulative A/D Line adds (A_t-D_t). Its step is an absolute count. The normalized line divides that count by the mover population (A_t+D_t) before accumulating it.
For the two synthetic sessions:
and
Their raw steps differ tenfold, while their mover-normalized steps match. This is scale invariance when both counts are multiplied by the same positive constant. It is a mathematical comparison, not a claim that two real markets with those counts would be economically interchangeable.
Synthetic comparison. The image is an inline SVG, not a historical market chart.
The selected formula is only one variant
This article selects the unsmoothed cumulative mover-normalized line:
It is not:
- the raw cumulative A/D Line, which sums (A-D);
- the A/D ratio, which divides (A) by (D);
- ((A-D)/(A+D+U)), which includes unchanged issues;
- ((A-D)/N), which divides by a full universe;
- the Ratio-Adjusted McClellan Oscillator, which smooths a normalized input with exponential moving averages instead of directly accumulating it.
Those differences are not cosmetic. For 60 advances, 30 declines, 10 unchanged, and a universe of 120, the mover-only result is (30/90=33.33%), the unchanged-inclusive result is (30/100=30%), and the full-universe result is (30/120=25%).
Persist the denominator policy with the series. A chart that changes the denominator mid-history is a splice of incompatible methods.
Four zero cases that must remain distinct
The formula is bounded because the magnitude of (A-D) cannot exceed (A+D). That gives clear endpoints and one important hole:
| Condition | Daily result | Strict publication state |
|---|---|---|
| (D=0,A>0) | (+1) | resolved if the evidence is complete |
| (A=0,D>0) | (-1) | resolved if the evidence is complete |
| (A=D>0) | (0) | resolved active balance |
| (A+D=0) | undefined (0/0) | incomplete; no cumulative path |
The first row is especially useful when comparing this topic with the A/D ratio. For (A/D), (D=0) is a zero-denominator problem. For this topic, (A+D=A>0), so the mover-normalized balance is exactly +1.
The strict implementation does not turn a zero-mover observation into a flat
line. A flat segment looks like the valid (A=D>0) case and would erase the
uncertainty. It returns incomplete: zero_movers and suppresses all public
points. An empty universe, a missing session, and an unclassified population
are also separate states.
Before arithmetic: prove the evidence is usable
Daily breadth counts are the output of a classification system. To reproduce a line, a builder needs more than three integers.
The package requires:
- a point-in-time universe and stable listing/security identifier scheme;
- a declared venue, session, and trading calendar;
- a comparison basis, such as comparable official close to prior close;
- a corporate-action policy for listings, delistings, splits, mergers, spin-offs, and symbol changes;
- mutually exclusive advances, declines, unchanged, excluded, and unclassified counts whose sum equals the universe size;
- an effective time and an earliest available time;
- a versioned revision or cancellation chain;
- an explicit evidence cutoff.
An aggregate count cannot itself prove that the roster was historically correct. The upstream process must make that assertion and retain its lineage. Normalization fixes a scale problem; it does not fix survivorship bias.
Two clocks prevent future leakage
effective_at says when an observation applies economically. available_at
says when that version could first have been known to the calculation. The line
is evaluated at a UTC cutoff.
The implementation parses available_at first. If it is after the cutoff, the
record is ignored immediately. No later field in that future record is allowed
to invalidate the earlier result. Tests cover a future row with a broken
revision number, incompatible policy, invalid count, conflicting identity, and
malformed effective time; the visible history remains unchanged.
This matters because a historical correction may be economically effective for March but not published until April. An analysis run with a March cutoff cannot use the April correction.
Revision resolution is part of the algorithm
Each session has one event ID and a contiguous chain of revisions. At a given cutoff:
- one complete chain with one head can be resolved;
- two records claiming the same revision are ambiguous;
- a missing revision in the chain is ambiguous;
- a cancellation makes the expected session incomplete;
- two event IDs claiming one calendar sequence are ambiguous.
When a visible correction changes session (j), recompute (j) and every point after it. The entire suffix depends on the changed prefix:
Patching only the displayed point at (j) leaves every later level wrong.
The publication gate comes before the line, not after it.
A 20-session synthetic path
The shared fixture contains 20 synthetic sessions, enough to show repeated advancing, declining, and balanced transitions. The first five sessions are:
| Seq. | A | D | (A+D) | (100(A-D)/(A+D)) | Line |
|---|---|---|---|---|---|
| 1 | 60 | 30 | 90 | +33.3333 | 33.3333 |
| 2 | 35 | 55 | 90 | -22.2222 | 11.1111 |
| 3 | 40 | 40 | 80 | 0.0000 | 11.1111 |
| 4 | 70 | 20 | 90 | +55.5556 | 66.6667 |
| 5 | 25 | 65 | 90 | -44.4444 | 22.2222 |
Sequence 3 is a valid flat contribution: 80 issues moved and the sides were equal. The full unrounded recurrence ends at a display value of 77.7778.
Synthetic path. The cumulative chart is publishable only for the resolved scenario.
Open the interactive guided lab to step through the path, inspect the denominator, seed, and scale, and compare zero movers, low coverage, revision conflict, future evidence, cancellation, and series-break scenarios.
A bounded official Nasdaq observation
Nasdaq Trader’s official Daily Market Files page describes downloadable annual
files of daily Nasdaq market statistics. Its field-definition page lists
Nasdaq Advances, Declines, and Unchanged among the year-to-date fields. On
2026-07-23, the official daily2026.csv returned the following counts.
| Date | Provider A | Provider D | Provider U | Author-derived ratio | Author-derived path, seed 0 and scale 100 |
|---|---|---|---|---|---|
| 2026-03-02 | 2,443 | 2,431 | 228 | +0.002462 | 0.2462 |
| 2026-03-03 | 1,359 | 3,542 | 190 | -0.445419 | -44.2957 |
| 2026-03-04 | 3,368 | 1,509 | 224 | +0.381177 | -6.1780 |
| 2026-03-05 | 1,430 | 3,454 | 195 | -0.414414 | -47.6195 |
| 2026-03-06 | 1,461 | 3,384 | 238 | -0.396904 | -87.3099 |
The A/D/U counts are provider observations. The last two columns are my arithmetic from those counts. This is not a strict resolved line. The free CSV does not expose stable roster IDs, an exclusion/unclassified partition, per-row publication time, or a revision chain. It cannot prove constant or equal universes, and this article does not infer causality between breadth and an index move.
The limitation is part of the lesson: a useful real observation can explain the formula without pretending to meet a stronger production contract.
Seed, scale, series breaks, and rounding
The seed determines the vertical origin. The scale determines the size of every daily contribution. Changing seed from 0 to 1,000 translates every point by 1,000. Changing scale from 100 to 1,000 multiplies every step by ten. Neither change should occur inside one series.
The same rule applies to venue, universe, calendar, session, comparison basis, corporate-action policy, stable-ID scheme, denominator, and methodology ID. A change starts a new segment. Do not splice incompatible histories and draw a continuous line across the boundary.
Accumulate using full runtime precision. Round only for display. Rounding each
daily ratio before adding it creates path-dependent drift. Counts are required
to be safe integers, and a configured absolute-line guard returns
unsupported: line_overflow before a non-finite or excessive value can be
published.
How to interpret the output safely
A rising line means positive mover-normalized contributions accumulated under the selected contract. A falling line means negative contributions accumulated. The absolute level depends on seed and scale.
That description is not a forecast. A divergence from an index can be defined and tested, but the calculation alone does not prove predictive power, causality, economic significance, or profitability after costs. Keep raw Net Advances, mover count, unchanged count, coverage, and data state beside the line. Each answers a question that the normalized value cannot answer alone.
Builder checklist
- Write
(A-D)/(A+D)in the title or subtitle. - Persist seed, scale, and denominator.
- Separate active balance from zero movers.
- Require an explicit expected calendar sequence.
- Keep effective and available clocks.
- Resolve revisions before replay.
- Suppress cumulative points for non-resolved evidence.
- Recompute the full dependent suffix after a correction.
- Start a new series at methodology breaks.
- Round only the display.
- Label real observations and author-derived arithmetic separately.
- Make no prediction or trading-performance claim from a plausible chart.
The next topic, Absolute Breadth Index, removes direction and asks how large the advance–decline imbalance is. For code, fixtures, test evidence, and source roles, use the package README and references.
Rendered from the canonical Mermaid sources linked by this article.
Causal normalized-line publication flow
The cutoff and evidence gate run before cumulative arithmetic.
Takeaway: future evidence is not interpreted, and unresolved evidence cannot produce a plausible terminal value.
References5 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 unless noted. The synthetic fixture is public package data. The Nasdaq counts are observations from an official public file; derived ratios and cumulative values are author calculations.
R1 — Nasdaq Trader Daily Market Files
- Organization: Nasdaq Trader
- Source type: Official exchange-operator data landing page
- URL: Daily Market Files
- Supports: Annual downloadable files containing daily Nasdaq market statistics.
- Limitations: The landing page does not provide point-in-time roster lineage, file-publication timestamps, or a revision protocol for each historical row.
R2 — Nasdaq Trader 2026 daily CSV
- Organization: Nasdaq Trader
- Source type: Official exchange-operator CSV
- URL: daily2026.csv
- Retrieved: 2026-07-23
- Supports: Provider-observed
Advances,Declines, andUnchangedcounts for 2026-03-02 through 2026-03-06 used in the bounded real observation. - Limitations: The public CSV does not expose stable security identifiers, exclusion/unclassified counts, per-row publication times, or revision lineage. It therefore does not satisfy the package’s strict resolved-input contract.
R3 — Nasdaq Trader field definitions
- Organization: Nasdaq Trader
- Source type: Official exchange-operator field-definition page
- URL: Daily Market Summary Definitions
- Supports: The year-to-date file includes Nasdaq Advances, Declines, and Unchanged; the page describes the summary as concerning Nasdaq issues and says active issue counts are date-specific.
- Limitations: It does not fully define comparison-price adjustments, exclusions, corrections, or the exact roster used by each breadth count.
R4 — McClellan breadth normalization
- Organization or authors: McClellan Financial Publications
- Source type: Original-practitioner technical monograph
- Publication context: Material compiled for a 2004 Market Technicians Association award submission
- URL: Understanding Breadth Statistics
- Supports: Dividing (A-D) by (A+D) reduces the population-scale effect; multiplying by a constant such as 1,000 is a display convention.
- Limitations: Practitioner methodology, not an exchange data standard. This package does not adopt its timing, signal, or performance claims.
R5 — StockCharts A/D Percent
- Organization: StockCharts.com
- Source type: Maintained product methodology
- URL: Advance-Decline Percent
- Supports: A bounded percentage based on net advances relative to advancing plus declining issues and a cumulative A/D Percent construction.
- Limitations: Vendor terminology is not universal and does not replace this package’s causal evidence contract.
Evidence map
| Claim | Source or basis | Classification |
|---|---|---|
| The selected daily term is ((A-D)/(A+D)) | R4, R5 | Sourced convention |
| A constant multiplier changes display scale | R4 plus algebra | Sourced convention and deduction |
| The 2026 CSV contains the five published count rows | R2 | Provider observation |
| Annual files and included breadth fields | R1, R3 | Official source description |
| Ratios and zero-seed scale-100 path in the real table | Direct arithmetic | Author-derived |
| Real table is not strict resolved output | Gap analysis against R1–R3 | Author assessment |
| Zero movers is incomplete rather than a flat step | Undefined (0/0) plus strict publication policy | Package choice |
| Revision states, cutoff isolation, suffix replay, and series breaks | Reproducibility design | Package choice |
| Synthetic 20-session example | Package fixture | Pedagogical data |
No source is used to claim prediction, causality between breadth and index returns, or investment performance.
Full dependency-light reference implementations in both supported languages.
/** Causal mover-normalized cumulative Advance/Decline Line. */
export type EvidenceState = "resolved" | "incomplete" | "ambiguous" | "unsupported";
export type EventType = "upsert" | "cancel";
export interface BreadthRecord {
event_id: string;
revision: number;
supersedes_revision: number | null;
event_type: EventType;
session_sequence: number;
session_date: string;
effective_at: string;
available_at: string;
venue_id: string;
universe_id: string;
calendar_id: string;
session: string;
comparison_basis: string;
corporate_action_policy: string;
stable_id_scheme: string;
methodology_id: string;
denominator_policy: string;
seed: number;
scale: number;
roster_complete?: boolean;
classification_complete?: boolean;
advances?: number;
declines?: number;
unchanged?: number;
excluded?: number;
unclassified?: number;
universe_size?: number;
}
export interface CalculationOptions {
cutoff: string;
expectedStartSequence: number;
expectedEndSequence: number;
initialValue?: number;
scale?: number;
minimumCoverage?: number;
maxAbsLine?: number;
}
export interface NormalizedADLinePoint {
session_sequence: number;
session_date: string;
effective_at: string;
available_at: string;
event_id: string;
revision: number;
net_advances: number;
mover_count: number;
normalized_breadth: number;
scaled_contribution: number;
normalized_line: number;
coverage_ratio: number;
daily_direction: "advancing" | "declining" | "balanced";
metric: "mover_normalized_advance_decline_line";
}
export interface CalculationResult {
state: EvidenceState;
reason_codes: string[];
points: NormalizedADLinePoint[];
ignored_future_revisions: number;
denominator_policy: "movers";
initial_value: number;
scale: number;
minimum_coverage: number;
cutoff: string;
}
export class NormalizedADLineValidationError extends Error {
constructor(message: string) {
super(message);
this.name = "NormalizedADLineValidationError";
}
}
const MAX_SAFE = Number.MAX_SAFE_INTEGER;
const countFields = ["advances", "declines", "unchanged", "excluded", "unclassified"] as const;
const identityFields = [
"venue_id", "universe_id", "calendar_id", "session", "comparison_basis",
"corporate_action_policy", "stable_id_scheme", "methodology_id",
] as const;
function finite(value: unknown, name: string): number {
if (typeof value !== "number" || !Number.isFinite(value)) {
throw new NormalizedADLineValidationError(`${name} must be a finite number.`);
}
return value;
}
function timestamp(value: unknown, name: string): number {
if (typeof value !== "string" || !value.endsWith("Z")) {
throw new NormalizedADLineValidationError(`${name} must be an RFC 3339 UTC timestamp.`);
}
const parsed = Date.parse(value);
if (Number.isNaN(parsed)) {
throw new NormalizedADLineValidationError(`${name} must be an RFC 3339 UTC timestamp.`);
}
return parsed;
}
function nonempty(record: BreadthRecord, field: keyof BreadthRecord): string {
const value = record[field];
if (typeof value !== "string" || value.trim() === "") {
throw new NormalizedADLineValidationError(`${String(field)} must be a non-empty string.`);
}
return value;
}
function result(
state: EvidenceState,
reasons: string[],
future: number,
seed: number,
scale: number,
minimumCoverage: number,
cutoff: string,
points: NormalizedADLinePoint[] = [],
): CalculationResult {
return {
state,
reason_codes: [...new Set(reasons)].sort(),
points: state === "resolved" ? points : [],
ignored_future_revisions: future,
denominator_policy: "movers",
initial_value: seed,
scale,
minimum_coverage: minimumCoverage,
cutoff,
};
}
export function calculateNormalizedAdLine(
records: readonly BreadthRecord[],
options: CalculationOptions,
): CalculationResult {
if (!Array.isArray(records)) {
throw new NormalizedADLineValidationError("records must be an array.");
}
const seed = finite(options.initialValue ?? 0, "initialValue");
const scale = finite(options.scale ?? 100, "scale");
const minimumCoverage = finite(options.minimumCoverage ?? 0.95, "minimumCoverage");
const maxAbsLine = finite(options.maxAbsLine ?? MAX_SAFE, "maxAbsLine");
const cutoffTime = timestamp(options.cutoff, "cutoff");
if (scale <= 0) throw new NormalizedADLineValidationError("scale must be positive.");
if (minimumCoverage < 0 || minimumCoverage > 1) {
throw new NormalizedADLineValidationError("minimumCoverage must be in [0, 1].");
}
if (maxAbsLine <= 0) throw new NormalizedADLineValidationError("maxAbsLine must be positive.");
if (!Number.isInteger(options.expectedStartSequence)
|| !Number.isInteger(options.expectedEndSequence)
|| options.expectedStartSequence <= 0
|| options.expectedEndSequence < options.expectedStartSequence) {
throw new NormalizedADLineValidationError("expected sequence bounds are invalid.");
}
const availableRecords: BreadthRecord[] = [];
let future = 0;
let previousAvailable = -Infinity;
for (const record of records) {
const available = timestamp(record.available_at, "available_at");
if (available > cutoffTime) {
future += 1;
continue;
}
const effective = timestamp(record.effective_at, "effective_at");
if (available < effective) {
throw new NormalizedADLineValidationError("available_at cannot precede effective_at.");
}
if (available < previousAvailable) {
throw new NormalizedADLineValidationError(
"records must be ordered by non-decreasing available_at; input is never silently sorted.",
);
}
previousAvailable = available;
nonempty(record, "event_id");
nonempty(record, "session_date");
for (const field of identityFields) nonempty(record, field);
if (record.event_type !== "upsert" && record.event_type !== "cancel") {
throw new NormalizedADLineValidationError("event_type must be upsert or cancel.");
}
if (!Number.isInteger(record.revision) || record.revision < 1
|| !Number.isInteger(record.session_sequence) || record.session_sequence < 1) {
throw new NormalizedADLineValidationError("revision and session_sequence must be positive integers.");
}
const expectedSupersedes = record.revision === 1 ? null : record.revision - 1;
if (record.supersedes_revision !== expectedSupersedes) {
throw new NormalizedADLineValidationError(
"supersedes_revision must be null for revision 1 and revision-1 thereafter.",
);
}
if (record.denominator_policy !== "movers") {
return result("unsupported", ["denominator_policy_drift"], future, seed, scale, minimumCoverage, options.cutoff);
}
if (record.seed !== seed || record.scale !== scale) {
return result("unsupported", ["seed_or_scale_drift"], future, seed, scale, minimumCoverage, options.cutoff);
}
availableRecords.push({...record});
}
const grouped = new Map<string, BreadthRecord[]>();
for (const record of availableRecords) {
grouped.set(record.event_id, [...(grouped.get(record.event_id) ?? []), record]);
}
const heads: BreadthRecord[] = [];
const ambiguous: string[] = [];
for (const versions of grouped.values()) {
const byRevision = new Map<number, BreadthRecord[]>();
for (const version of versions) {
byRevision.set(version.revision, [...(byRevision.get(version.revision) ?? []), version]);
}
const revisions = [...byRevision.keys()].sort((a, b) => a - b);
if (revisions.some((revision, index) => revision !== index + 1)) {
ambiguous.push("revision_gap");
continue;
}
if ([...byRevision.values()].some((items) => items.length !== 1)) {
ambiguous.push("revision_conflict");
continue;
}
const head = byRevision.get(revisions.at(-1)!)![0];
if (versions.some((version) => version.session_sequence !== head.session_sequence
|| version.session_date !== head.session_date)) {
ambiguous.push("revision_identity_conflict");
continue;
}
heads.push(head);
}
if (ambiguous.length) return result("ambiguous", ambiguous, future, seed, scale, minimumCoverage, options.cutoff);
const bySequence = new Map<number, BreadthRecord[]>();
for (const head of heads) {
bySequence.set(head.session_sequence, [...(bySequence.get(head.session_sequence) ?? []), head]);
}
const expected = Array.from(
{length: options.expectedEndSequence - options.expectedStartSequence + 1},
(_, index) => options.expectedStartSequence + index,
);
if ([...bySequence.values()].some((items) => items.length !== 1)) {
return result("ambiguous", ["multiple_events_for_session"], future, seed, scale, minimumCoverage, options.cutoff);
}
if (expected.some((sequence) => !bySequence.has(sequence))) {
return result("incomplete", ["missing_session"], future, seed, scale, minimumCoverage, options.cutoff);
}
if ([...bySequence.keys()].some((sequence) => !expected.includes(sequence))) {
return result("unsupported", ["unexpected_session_sequence"], future, seed, scale, minimumCoverage, options.cutoff);
}
const ordered = expected.map((sequence) => bySequence.get(sequence)![0]);
let identity: string | null = null;
let previousDate = "";
const blocking: string[] = [];
for (const record of ordered) {
const currentIdentity = JSON.stringify(identityFields.map((field) => record[field]));
if (identity === null) identity = currentIdentity;
else if (identity !== currentIdentity) {
return result("unsupported", ["series_break"], future, seed, scale, minimumCoverage, options.cutoff);
}
if (!/^\d{4}-\d{2}-\d{2}$/.test(record.session_date)
|| Number.isNaN(Date.parse(`${record.session_date}T00:00:00Z`))) {
throw new NormalizedADLineValidationError("session_date must be a valid YYYY-MM-DD date.");
}
if (previousDate && record.session_date <= previousDate) {
return result("unsupported", ["calendar_sequence_date_conflict"], future, seed, scale, minimumCoverage, options.cutoff);
}
previousDate = record.session_date;
if (record.event_type === "cancel") {
blocking.push("cancelled_session");
continue;
}
if (record.roster_complete !== true) blocking.push("roster_incomplete");
if (record.classification_complete !== true) blocking.push("classification_incomplete");
const values = {} as Record<(typeof countFields)[number] | "universe_size", number>;
for (const field of [...countFields, "universe_size"] as const) {
const value = record[field];
if (!Number.isSafeInteger(value) || (value as number) < 0) {
throw new NormalizedADLineValidationError(`${field} must be a non-negative safe integer for upserts.`);
}
values[field] = value as number;
}
const partitionTotal = countFields.reduce((sum, field) => sum + values[field], 0);
if (!Number.isSafeInteger(partitionTotal)) {
throw new NormalizedADLineValidationError("the count partition total must be a safe integer.");
}
if (partitionTotal !== values.universe_size) {
blocking.push("count_partition_incomplete");
continue;
}
if (values.universe_size === 0) {
blocking.push("empty_universe");
continue;
}
if (values.unclassified > 0) blocking.push("unclassified_issues");
const coverage = (values.advances + values.declines + values.unchanged) / values.universe_size;
if (coverage < minimumCoverage) blocking.push("low_coverage");
if (values.advances + values.declines === 0) blocking.push("zero_movers");
}
if (blocking.length) return result("incomplete", blocking, future, seed, scale, minimumCoverage, options.cutoff);
let line = seed;
const points: NormalizedADLinePoint[] = [];
for (const record of ordered) {
const advances = record.advances!;
const declines = record.declines!;
const movers = advances + declines;
const net = advances - declines;
const ratio = net / movers;
const contribution = scale * ratio;
const candidate = line + contribution;
if (!Number.isFinite(candidate) || Math.abs(candidate) > maxAbsLine) {
return result("unsupported", ["line_overflow"], future, seed, scale, minimumCoverage, options.cutoff);
}
line = candidate;
points.push({
session_sequence: record.session_sequence,
session_date: record.session_date,
effective_at: record.effective_at,
available_at: record.available_at,
event_id: record.event_id,
revision: record.revision,
net_advances: net,
mover_count: movers,
normalized_breadth: ratio,
scaled_contribution: contribution,
normalized_line: line,
coverage_ratio: (advances + declines + record.unchanged!) / record.universe_size!,
daily_direction: net > 0 ? "advancing" : net < 0 ? "declining" : "balanced",
metric: "mover_normalized_advance_decline_line",
});
}
return result("resolved", [], future, seed, scale, minimumCoverage, options.cutoff, points);
}
The embedded lab now expands to its full document height, keeping the article as the only scroll surface.