Library/Market Breadth and Internals/Advance/Decline Breadth

D04-F01-A01 / Complete engineering topic

Net Advances: Count Participation Without Hiding Data Gaps

Build a revision-aware issue-count breadth snapshot that reconciles stable listings and refuses unsupported results.

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

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

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:

Net Advances=advancing issuesdeclining issues\text{Net Advances}=\text{advancing issues}-\text{declining issues}

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 ii, let Pi,tP_{i,t} be the current comparison price and Pi,t1P^*_{i,t-1} the comparable prior price. Let ε\varepsilon be a declared price-unit tolerance:

ci={advance,Pi,tPi,t1>εdecline,Pi,tPi,t1<εunchanged,Pi,tPi,t1εc_i= \begin{cases} \text{advance}, & P_{i,t}-P^*_{i,t-1}>\varepsilon\\ \text{decline}, & P_{i,t}-P^*_{i,t-1}< -\varepsilon\\ \text{unchanged}, & |P_{i,t}-P^*_{i,t-1}|\leq\varepsilon \end{cases}

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:

  • AA be advances;
  • DD be declines;
  • UU be unchanged;
  • XX be explicit exclusions;
  • QQ be unclassified;
  • NN be the selected point-in-time universe.

Every unique listing must land in exactly one bucket:

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

Only when Q=0Q=0 and identity is unique may the calculator publish:

NA=ADNA=A-D

It also returns mover count A+DA+D, classified count A+D+UA+D+U, and classification coverage:

Coverage={(A+D+U)/N,N>0null,N=0Coverage= \begin{cases} (A+D+U)/N,&N>0\\ \text{null},&N=0 \end{cases}

Coverage is an audit diagnostic. It is not a probability that the result is correct.

Rendering system map…

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:

effective_atcalculation_as_ofeffective\_at\leq calculation\_as\_of available_atcalculation_as_ofavailable\_at\leq calculation\_as\_of

The algorithm selects the highest unique eligible revision sequence and checks its supersession link. A later correction cannot alter an earlier as-of query.

A late correction changes one member and the signed balance

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:

BucketCount
Advances5
Declines3
Unchanged2
Excluded2
Unclassified0

The partition and metric are:

5+3+2+2+0=125+3+2+2+0=12 NA=53=+2NA=5-3=+2

At 22:00 UTC, final revision R2 becomes available. It corrects one listing from advance to decline. A 22:30 query produces:

4+4+2+2+0=124+4+2+2+0=12 NA=44=0NA=4-4=0

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:

SessionComposite closeDaily return derived from adjacent official closesAdvancesDeclinesUnchangedDerived ADA-D
2026-03-0522,748.99-0.25645%1,4303,454195-2,024
2026-06-2625,297.62-0.24047%3,1071,912192+1,195

Both Composite returns were close to -0.25%, yet the published breadth balances had opposite signs.

Two similarly negative Composite sessions have opposite published breadth balances

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

StatusMeaningNet Advances
readyUnique, complete, reconciled snapshotInteger
no_moversComplete non-empty universe, no advances or declines0
empty_universeNo members in the declared universe0
incompleteAt least one member cannot be classifiednull
ambiguousRevision or listing identity is not uniquenull
unsupportedNo revision was available by the query timenull

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:

  1. validate session, policy, tolerance, and timestamps;
  2. select a causally eligible revision;
  3. verify the revision chain and stable listing identity;
  4. classify eligible prices and retain exclusion reasons;
  5. reconcile the universe;
  6. suppress incomplete or ambiguous results;
  7. compute the signed difference and diagnostics.

The core arithmetic remains deliberately small:

Python
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

Full source roles and limitations are recorded in REFERENCES.md.

Net Advances evidence and calculation flow

Purpose: show why evidence state is resolved before arithmetic.

Rendering system map…

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.

Rendering system map…

Takeaway: a correction changes only queries at or after its available_at time.

References6 primary sources and evidence notes

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 ADA-D 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 ready fixtures 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

StatementClassification
Net Advances equals advancing issues minus declining issuesSourced definition, R1
Nasdaq year file fields and quoted session rowsProvider observation, R2-R3
Percentage returns and signed differences in the exampleDerived arithmetic
Similar weighted returns can coexist with opposite issue balancesDirect comparison of R3 rows
Stable IDs, five-part partition, statuses, and causal revision selectionExplicit implementation design
The example predicts a later return or explains why the market movedNot claimed
netAdvances.ts
/** 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,
  };
}
Full-height labplaygroundOpen full screen