Library/Market Breadth and Internals/Advance/Decline Breadth

D04-F01-A04 / Complete engineering topic

Normalized Advance/Decline Line: Cumulative (A-D)/(A+D) Breadth

A production-minded guide to Normalized Advance/Decline Line: Cumulative (A-D)/(A+D) Breadth.

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

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

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

pt=AtDtAt+Dtp_t=\frac{A_t-D_t}{A_t+D_t}

and then accumulates

Nt=Nt1+kpt.N_t=N_{t-1}+k p_t.

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:

703070+30=0.4\frac{70-30}{70+30}=0.4

and

700300700+300=0.4.\frac{700-300}{700+300}=0.4.

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.

Two synthetic populations have different raw net advances but the same mover-normalized balance.

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:

Nt=s+i=1tkAiDiAi+Di.N_t=s+\sum_{i=1}^{t}k\frac{A_i-D_i}{A_i+D_i}.

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:

ConditionDaily resultStrict 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:

Nt=s+i=1tkpi.N_t=s+\sum_{i=1}^{t}k p_i.

Patching only the displayed point at (j) leaves every later level wrong.

Rendering system map…

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.AD(A+D)(100(A-D)/(A+D))Line
1603090+33.333333.3333
2355590-22.222211.1111
34040800.000011.1111
4702090+55.555666.6667
5256590-44.444422.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.

A 20-session synthetic cumulative path remains hidden when a publication-gate scenario is unresolved.

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.

DateProvider AProvider DProvider UAuthor-derived ratioAuthor-derived path, seed 0 and scale 100
2026-03-022,4432,431228+0.0024620.2462
2026-03-031,3593,542190-0.445419-44.2957
2026-03-043,3681,509224+0.381177-6.1780
2026-03-051,4303,454195-0.414414-47.6195
2026-03-061,4613,384238-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.

Causal normalized-line publication flow

The cutoff and evidence gate run before cumulative arithmetic.

Rendering system map…

Takeaway: future evidence is not interpreted, and unresolved evidence cannot produce a plausible terminal value.

References5 primary sources and evidence notes

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, and Unchanged counts 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

ClaimSource or basisClassification
The selected daily term is ((A-D)/(A+D))R4, R5Sourced convention
A constant multiplier changes display scaleR4 plus algebraSourced convention and deduction
The 2026 CSV contains the five published count rowsR2Provider observation
Annual files and included breadth fieldsR1, R3Official source description
Ratios and zero-seed scale-100 path in the real tableDirect arithmeticAuthor-derived
Real table is not strict resolved outputGap analysis against R1–R3Author assessment
Zero movers is incomplete rather than a flat stepUndefined (0/0) plus strict publication policyPackage choice
Revision states, cutoff isolation, suffix replay, and series breaksReproducibility designPackage choice
Synthetic 20-session examplePackage fixturePedagogical data

No source is used to claim prediction, causality between breadth and index returns, or investment performance.

normalizedAdLine.ts
/** 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);
}
Full-height labnormalized ad line playgroundOpen full screen