Library/Market Microstructure/Trade Classification

D11-F01-A02 / Complete engineering topic

Quote Test

A production-minded guide to Quote Test.

D11 · MARKET MICROSTRUCTURE
D11-F01-A02Canonical / Tested / Open
D11 / D11-F01

Market data can show that a transaction happened without giving a portable, research-ready answer to who initiated it. Was the trade price above, below, or indistinguishable from the point-in-time midpoint? Quote Test turns that ambiguity into an auditable calculation with explicit references, unknown states, and sensitivity profiles.

This tutorial is for builders who need signed flow for research, execution analytics, or data-quality work. You will implement the method in Python and TypeScript, reproduce a 60-record synthetic trace, inspect failure cases, and learn why a side label is not evidence of informed trading.

Calculation and evidence map

Start with the output contract

A useful classifier returns more than buy or sell. Each row needs a sign, a human-readable reason, the exact price or quote used as reference, and cumulative signed volume. The summary needs buy, sell, and unknown volume; coverage; imbalance; exclusions; and the active profile. If the system cannot show why a label exists, downstream users cannot audit it.

The canonical definition is: A trade above the matched midpoint is signed buy, below it is signed sell, and at the midpoint remains unknown.

The formula is s_i = +1 if P_i > M_i + eps; -1 if P_i < M_i - eps; otherwise 0, where M_i=(B_i+A_i)/2..

Decision boundary

Point-in-time data before arithmetic

Records are sorted by event time and deterministic sequence. Availability time prevents using a quote that was not knowable when the trade record arrived. Status and condition identify records that must not update state. Instrument, venue, source, session, units, and correction lineage belong in a production envelope.

The fixture is deliberately synthetic. No row is a market observation. This protects licensing boundaries and lets equality, missing state, crossed quotes, breaks, and long zero runs be designed exactly.

Work the canonical sequence

Under the synchronized profile, the inside-spread scenario exposes the method's main branches. The reference implementation creates a 60-row trace and stores the exact output beside the input.

Worked sequence

Read the trace in order. First confirm the record was eligible. Then inspect the causal reference. Next read the sign and reason code. Finally check cumulative signed volume. A visually plausible final imbalance is not enough; every transition must follow the frozen rule.

Diagnostic deep dive: midpoint distance is only as good as quote alignment

The quote test asks whether a transaction occurred above or below the prevailing midpoint. At a 99.99 bid and 100.01 ask, the midpoint is 100.00. A 100.01 trade is buy-classified, a 99.99 trade is sell-classified, and a 100.00 trade is unknown. This is an aggressor-side inference, not the resting order's side and not proof that a participant was informed.

The arithmetic is easy; the alignment is hard. A quote must be effective by the configured event-time cutoff and knowable no later than the trade record's availability time. Matching the closest quote in either direction leaks future data. Using a crossed quote creates a midpoint from an invalid market state. The package therefore matches only valid bid-less-than-or-equal-to-ask records that pass both clocks.

Tolerance and outside-spread prints

A midpoint tolerance creates a deliberate abstention band. That is useful when price precision, rounding, or venue-specific increments make exact comparisons fragile. It reduces coverage and may improve semantic honesty; it does not automatically improve empirical accuracy.

A trade outside the matched spread is not silently discarded. It still receives a midpoint-side label, while a diagnostic counts the anomaly. Such a print may reflect latency, fragmentation, a special condition, or bad data. Classification and data-quality judgment remain separate outputs.

Compare profiles before interpreting the result

Three profiles are pre-registered: synchronized, lag-250ms, and half-tick-band. They vary a genuine method choice—zero-tick behavior, quote lag, tolerance, sigma, or Student-t tails. The playground displays all three results together so coverage changes cannot be hidden.

Scenario and profile matrix

A sensitivity reversal is an analytical finding. It says the record's label depends on an assumption. It does not identify which assumption matches a particular live feed. That requires source-specific documentation and ground truth.

Implementation walkthrough

The Python entry point is:

Python
from trade_classification import run_topic

result = run_topic("quote", input_data, config)

TypeScript uses the same input and output semantics:

TypeScript
import { runTopic } from "./trade-classification";

const result = runTopic("quote", inputData, config);

Both implementations reject non-finite inputs, preserve nulls and reason codes, and round stored floating results to ten decimal places. Shared fixtures compare all scenario/profile cubes with 1e-7 tolerance.

Test the boundary, not just the attractive example

The eight scenarios include causal initialization, equality, missing references, invalid or cancelled records, session or latency changes, and noisy comparisons. Independent arithmetic checks one defining boundary for the method. Those checks establish executable truth, not empirical predictive power.

The most dangerous failure is silent hindsight: matching a future quote, estimating a scale from future bars, or leaving a later-broken trade in recursive state. The next most dangerous is semantic drift: treating an aggressor-side inference as trader identity, private information, or a trading signal.

Failure boundary

Guided lab

The self-contained lab begins in a fully rendered canonical state. Choose a scenario and profile, move one record at a time, use teaching anchors to jump to important states, and compare profile summaries. The chart, formula trace, diagnostics, guidance, and audit table remain synchronized.

Reduced-motion users retain every state transition. Play becomes one deterministic step instead of a timer. The standalone lab has no wallet, provider call, credential, or external network dependency.

Historical evidence decision

A named market example is not useful for this package. Without licensed raw records, corrections, feed-specific clocks, conditions, and a truth label, a famous symbol would add narrative confidence without reproducibility. The correct next step for empirical research is a licensed, venue-bounded evaluation—not an invented tape.

What the result can and cannot support

It can support a reproducible signed-flow feature, classifier comparison, data-quality diagnosis, and instructional trace. It cannot certify trader motive, informed trading, future price direction, execution quality, or profitability.

Reject these statements:

  • Any trade inside the spread is unknown.
  • The nearest timestamp is always the prevailing quote.
  • A future quote may be backfilled for coverage.
  • A crossed quote still supplies a usable midpoint.
  • Above midpoint means the resting order was a buy.

The durable habit is to attach the convention to the number: method, profile, source, clocks, filters, coverage, and unknown policy.

Where to go next

Continue to Lee-Ready Trade Signing. Within Trade Classification, each tutorial adds a different observable: transaction-price state, quote position, a hybrid branch, or aggregated price response.

Sources

Source facts are bounded to their named markets and methods. All examples and calculations here are synthetic or author-derived.

Quote Test Calculation Flow

Purpose: show where causal normalization ends and classification begins.

Rendering system map…

Takeaway: the classification formula is only one stage; record eligibility and causal alignment determine what it is allowed to see.

ReferencesPrimary sources and evidence notes

Expand the source trail, evidence role, and limitations behind the engineering choices.

LEE_READY — Inferring Trade Direction from Intraday Data

  • Organization or authors: Charles M. C. Lee and Mark J. Ready
  • Source type: Original peer-reviewed paper
  • Publication or effective date: 1991-06
  • Version: Journal of Finance 46(2)
  • URL or DOI: https://doi.org/10.1111/j.1540-6261.1991.tb02683.x
  • Accessed: 2026-07-29
  • Jurisdiction: NYSE and AMEX empirical setting
  • Supports: Tick-test states, quote matching, midpoint classification, and the historical five-second TAQ correction.
  • Limitations: The five-second adjustment was an empirical repair for 1988 data and is not a universal modern-market lag.
  • Evidence role: Sourced fact only; all fixtures and displayed outputs are synthetic or author-derived.
  • Redistribution decision: No licensed market observations are redistributed.

NYSE_TAQ — Daily TAQ Client Specification

  • Organization or authors: NYSE / ICE Data Services
  • Source type: Official market-data specification
  • Publication or effective date: 2017
  • Version: 3.0b
  • URL or DOI: https://www.nyse.com/publicdocs/nyse/data/Daily_TAQ_Client_Spec_v3.0b.pdf
  • Accessed: 2026-07-29
  • Jurisdiction: U.S. consolidated equity data
  • Supports: Separate trades, quotes, NBBO, and administrative records and the need to preserve data-source semantics.
  • Limitations: TAQ is licensed data; this package redistributes no TAQ observations.
  • Evidence role: Sourced fact only; all fixtures and displayed outputs are synthetic or author-derived.
  • Redistribution decision: No licensed market observations are redistributed.

NASDAQ_ITCH — Nasdaq TotalView-ITCH 5.0 Specification

  • Organization or authors: Nasdaq
  • Source type: Official exchange technical specification
  • Publication or effective date: 2026
  • Version: ITCH 5.0
  • URL or DOI: https://www.nasdaqtrader.com/content/technicalsupport/specifications/dataproducts/NQTVITCHspecification.pdf
  • Accessed: 2026-07-29
  • Jurisdiction: Nasdaq market data
  • Supports: Nanosecond event timestamps, executions, non-display trade messages, crosses, and broken-trade processing.
  • Limitations: ITCH message semantics are venue-specific; the published non-display buy/sell field is not a reliable universal aggressor label.
  • Evidence role: Sourced fact only; all fixtures and displayed outputs are synthetic or author-derived.
  • Redistribution decision: No licensed market observations are redistributed.

FAST_MARKETS — Liquidity Measurement Problems in Fast, Competitive Markets

  • Organization or authors: Craig W. Holden and Stacey Jacobsen
  • Source type: Original peer-reviewed paper
  • Publication or effective date: 2014-08
  • Version: Journal of Finance 69(4)
  • URL or DOI: https://doi.org/10.1111/jofi.12127
  • Accessed: 2026-07-29
  • Jurisdiction: U.S. equity TAQ measurement
  • Supports: Modern trade/quote alignment and sequencing can materially affect microstructure measurement.
  • Limitations: The paper evaluates liquidity measurement procedures; this package does not reproduce its empirical dataset.
  • Evidence role: Sourced fact only; all fixtures and displayed outputs are synthetic or author-derived.
  • Redistribution decision: No licensed market observations are redistributed.
trade-classification.ts
export type NumericRecord = Record<string, any>;

function finite(value: unknown, name: string, minimum?: number): number {
  if (typeof value !== "number" || !Number.isFinite(value)) throw new Error(`${name} must be a finite number`);
  if (minimum !== undefined && value < minimum) throw new Error(`${name} must be >= ${minimum}`);
  return value;
}

function roundValue(value: any): any {
  if (typeof value === "number") return Number(value.toFixed(10));
  if (Array.isArray(value)) return value.map(roundValue);
  if (value && typeof value === "object") return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, roundValue(item)]));
  return value;
}

function validTrade(row: NumericRecord): boolean {
  return (row.status ?? "valid") === "valid" && (row.condition ?? "regular") === "regular";
}

function sortedRows(rows: NumericRecord[]): NumericRecord[] {
  return [...rows].sort((left, right) => (left.event_time_ms ?? 0) - (right.event_time_ms ?? 0) || (left.sequence ?? 0) - (right.sequence ?? 0));
}

function summarize(method: string, trace: NumericRecord[]): NumericRecord {
  const valid = trace.filter((row) => !["excluded-condition", "broken-or-cancelled"].includes(row.reason));
  const buys = valid.filter((row) => row.sign === 1);
  const sells = valid.filter((row) => row.sign === -1);
  const unknown = valid.filter((row) => row.sign === 0);
  const sum = (rows: NumericRecord[]) => rows.reduce((total, row) => total + row.volume, 0);
  const buyVolume = sum(buys);
  const sellVolume = sum(sells);
  const unknownVolume = sum(unknown);
  const totalVolume = buyVolume + sellVolume + unknownVolume;
  let cumulative = 0;
  for (const row of trace) {
    cumulative += row.sign * row.volume;
    row.cumulative_signed_volume = roundValue(cumulative);
  }
  return roundValue({
    state: valid.length ? "ok" : "no-eligible-records",
    method,
    trace,
    total_valid: valid.length,
    classified_count: buys.length + sells.length,
    unknown_count: unknown.length,
    buy_count: buys.length,
    sell_count: sells.length,
    buy_volume: buyVolume,
    sell_volume: sellVolume,
    unknown_volume: unknownVolume,
    signed_volume: buyVolume - sellVolume,
    total_volume: totalVolume,
    coverage: valid.length ? (buys.length + sells.length) / valid.length : 0,
    imbalance_fraction: totalVolume ? (buyVolume - sellVolume) / totalVolume : 0,
    classification_counts: {buy: buys.length, sell: sells.length, unknown: unknown.length},
  });
}

export function tickTest(data: NumericRecord, config: NumericRecord): NumericRecord {
  if (!Array.isArray(data.trades)) throw new Error("trades must be a list");
  const zeroMode = config.zero_tick_mode ?? "carry";
  if (!["carry", "abstain"].includes(zeroMode)) throw new Error("zero_tick_mode must be carry or abstain");
  const resetOnSession = config.reset_on_session ?? true;
  let previousPrice: number | null = null;
  let lastNonzero = 0;
  let previousSession: string | null = null;
  const trace: NumericRecord[] = [];
  for (const row of sortedRows(data.trades)) {
    const volume = finite(row.size ?? 0, "size", 0);
    const price = finite(row.price, "price", 0);
    const session = String(row.session ?? "session");
    if (resetOnSession && previousSession !== null && session !== previousSession) {
      previousPrice = null;
      lastNonzero = 0;
    }
    previousSession = session;
    if (!validTrade(row)) {
      const reason = row.status !== "valid" ? "broken-or-cancelled" : "excluded-condition";
      trace.push({id: row.id, sequence: row.sequence, event_time_ms: row.event_time_ms, price, volume, sign: 0, side: "excluded", reason, reference_price: previousPrice});
      continue;
    }
    let sign = 0;
    let reason = "no-prior-trade";
    if (previousPrice !== null) {
      if (price > previousPrice) {
        sign = 1; reason = "uptick"; lastNonzero = 1;
      } else if (price < previousPrice) {
        sign = -1; reason = "downtick"; lastNonzero = -1;
      } else if (zeroMode === "carry" && lastNonzero) {
        sign = lastNonzero; reason = lastNonzero > 0 ? "zero-uptick" : "zero-downtick";
      } else {
        reason = "zero-tick-unknown";
      }
    }
    const reference = previousPrice;
    previousPrice = price;
    trace.push({id: row.id, sequence: row.sequence, event_time_ms: row.event_time_ms, price, volume, sign, side: sign > 0 ? "buy" : sign < 0 ? "sell" : "unknown", reason, reference_price: reference});
  }
  return summarize("tick-test", trace);
}

function matchedQuote(trade: NumericRecord, quotes: NumericRecord[], quoteLagMs: number): NumericRecord | null {
  const cutoff = finite(trade.event_time_ms, "trade event_time_ms", 0) - quoteLagMs;
  const knowledge = finite(trade.available_time_ms ?? trade.event_time_ms, "trade available_time_ms", 0);
  const eligible = quotes.filter((quote) => {
    if ((quote.status ?? "valid") !== "valid") return false;
    const eventTime = finite(quote.event_time_ms, "quote event_time_ms", 0);
    const availableTime = finite(quote.available_time_ms ?? eventTime, "quote available_time_ms", 0);
    const bid = finite(quote.bid, "bid", 0);
    const ask = finite(quote.ask, "ask", 0);
    return bid <= ask && eventTime <= cutoff && availableTime <= knowledge;
  });
  if (!eligible.length) return null;
  return eligible.reduce((best, row) =>
    row.event_time_ms > best.event_time_ms || (row.event_time_ms === best.event_time_ms && row.sequence > best.sequence) ? row : best
  );
}

export function quoteTest(data: NumericRecord, config: NumericRecord): NumericRecord {
  if (!Array.isArray(data.trades) || !Array.isArray(data.quotes)) throw new Error("trades and quotes must be lists");
  const lag = finite(config.quote_lag_ms ?? 0, "quote_lag_ms", 0);
  const tolerance = finite(config.midpoint_tolerance ?? 0, "midpoint_tolerance", 0);
  const quotes = sortedRows(data.quotes);
  const trace: NumericRecord[] = [];
  for (const trade of sortedRows(data.trades)) {
    const volume = finite(trade.size ?? 0, "size", 0);
    const price = finite(trade.price, "price", 0);
    if (!validTrade(trade)) {
      const reason = trade.status !== "valid" ? "broken-or-cancelled" : "excluded-condition";
      trace.push({id: trade.id, sequence: trade.sequence, event_time_ms: trade.event_time_ms, price, volume, sign: 0, side: "excluded", reason, midpoint: null, bid: null, ask: null, quote_id: null});
      continue;
    }
    const quote = matchedQuote(trade, quotes, lag);
    let sign = 0, reason = "no-causal-quote";
    let bid: number | null = null, ask: number | null = null, midpoint: number | null = null;
    if (quote) {
      bid = finite(quote.bid, "bid", 0);
      ask = finite(quote.ask, "ask", 0);
      midpoint = (bid + ask) / 2;
      if (price > midpoint + tolerance) { sign = 1; reason = "above-midpoint"; }
      else if (price < midpoint - tolerance) { sign = -1; reason = "below-midpoint"; }
      else reason = "midpoint-or-tolerance-band";
    }
    trace.push({id: trade.id, sequence: trade.sequence, event_time_ms: trade.event_time_ms, price, volume, sign, side: sign > 0 ? "buy" : sign < 0 ? "sell" : "unknown", reason, midpoint, bid, ask, quote_id: quote?.id ?? null});
  }
  const output = summarize("quote-test", trace);
  output.quote_lag_ms = lag;
  output.midpoint_tolerance = tolerance;
  output.outside_spread_count = trace.filter((row) => row.bid !== null && (row.price < row.bid || row.price > row.ask)).length;
  return roundValue(output);
}

export function leeReady(data: NumericRecord, config: NumericRecord): NumericRecord {
  if (!Array.isArray(data.trades) || !Array.isArray(data.quotes)) throw new Error("trades and quotes must be lists");
  const lag = finite(config.quote_lag_ms ?? 0, "quote_lag_ms", 0);
  const tolerance = finite(config.midpoint_tolerance ?? 0, "midpoint_tolerance", 0);
  const zeroMode = config.zero_tick_mode ?? "carry";
  const quotes = sortedRows(data.quotes);
  let previousPrice: number | null = null;
  let lastNonzero = 0;
  const trace: NumericRecord[] = [];
  for (const trade of sortedRows(data.trades)) {
    const volume = finite(trade.size ?? 0, "size", 0);
    const price = finite(trade.price, "price", 0);
    if (!validTrade(trade)) {
      const reason = trade.status !== "valid" ? "broken-or-cancelled" : "excluded-condition";
      trace.push({id: trade.id, sequence: trade.sequence, event_time_ms: trade.event_time_ms, price, volume, sign: 0, side: "excluded", reason, rule: "excluded", midpoint: null, quote_id: null});
      continue;
    }
    let tickSign = 0, tickReason = "no-prior-trade";
    if (previousPrice !== null) {
      if (price > previousPrice) { tickSign = 1; tickReason = "uptick"; lastNonzero = 1; }
      else if (price < previousPrice) { tickSign = -1; tickReason = "downtick"; lastNonzero = -1; }
      else if (zeroMode === "carry" && lastNonzero) { tickSign = lastNonzero; tickReason = lastNonzero > 0 ? "zero-uptick" : "zero-downtick"; }
      else tickReason = "zero-tick-unknown";
    }
    previousPrice = price;
    const quote = matchedQuote(trade, quotes, lag);
    let sign = 0, reason = "no-causal-quote", rule = "quote-required";
    let midpoint: number | null = null;
    if (quote) {
      const bid = finite(quote.bid, "bid", 0);
      const ask = finite(quote.ask, "ask", 0);
      midpoint = (bid + ask) / 2;
      if (price > midpoint + tolerance) { sign = 1; reason = "above-midpoint"; rule = "quote"; }
      else if (price < midpoint - tolerance) { sign = -1; reason = "below-midpoint"; rule = "quote"; }
      else { sign = tickSign; reason = `midpoint-fallback:${tickReason}`; rule = "tick-fallback"; }
    }
    trace.push({id: trade.id, sequence: trade.sequence, event_time_ms: trade.event_time_ms, price, volume, sign, side: sign > 0 ? "buy" : sign < 0 ? "sell" : "unknown", reason, rule, midpoint, quote_id: quote?.id ?? null});
  }
  const output = summarize("lee-ready", trace);
  output.quote_rule_count = trace.filter((row) => row.rule === "quote").length;
  output.tick_fallback_count = trace.filter((row) => row.rule === "tick-fallback").length;
  output.missing_quote_count = trace.filter((row) => row.rule === "quote-required").length;
  output.quote_lag_ms = lag;
  return roundValue(output);
}

function logGamma(z: number): number {
  const coefficients = [0.9999999999998099, 676.5203681218851, -1259.1392167224028, 771.3234287776531, -176.6150291621406, 12.507343278686905, -0.13857109526572012, 9.984369578019572e-6, 1.5056327351493116e-7];
  if (z < 0.5) return Math.log(Math.PI) - Math.log(Math.sin(Math.PI * z)) - logGamma(1 - z);
  z -= 1;
  let x = coefficients[0];
  for (let i = 1; i < coefficients.length; i += 1) x += coefficients[i] / (z + i);
  const t = z + 7.5;
  return 0.5 * Math.log(2 * Math.PI) + (z + 0.5) * Math.log(t) - t + Math.log(x);
}

function betaFraction(a: number, b: number, x: number): number {
  const qab = a + b, qap = a + 1, qam = a - 1;
  let c = 1;
  let d = 1 - qab * x / qap;
  if (Math.abs(d) < 3e-14) d = 3e-14;
  d = 1 / d;
  let result = d;
  for (let m = 1; m <= 200; m += 1) {
    const m2 = 2 * m;
    let aa = m * (b - m) * x / ((qam + m2) * (a + m2));
    d = 1 + aa * d; if (Math.abs(d) < 3e-14) d = 3e-14;
    c = 1 + aa / c; if (Math.abs(c) < 3e-14) c = 3e-14;
    d = 1 / d; result *= d * c;
    aa = -(a + m) * (qab + m) * x / ((a + m2) * (qap + m2));
    d = 1 + aa * d; if (Math.abs(d) < 3e-14) d = 3e-14;
    c = 1 + aa / c; if (Math.abs(c) < 3e-14) c = 3e-14;
    d = 1 / d;
    const delta = d * c;
    result *= delta;
    if (Math.abs(delta - 1) < 3e-14) break;
  }
  return result;
}

function regularizedBeta(x: number, a: number, b: number): number {
  if (x <= 0) return 0;
  if (x >= 1) return 1;
  const front = Math.exp(logGamma(a + b) - logGamma(a) - logGamma(b) + a * Math.log(x) + b * Math.log1p(-x));
  if (x < (a + 1) / (a + b + 2)) return front * betaFraction(a, b, x) / a;
  return 1 - front * betaFraction(b, a, 1 - x) / b;
}

export function studentTCdf(value: number, degreesOfFreedom: number): number {
  finite(value, "standardized price change");
  finite(degreesOfFreedom, "degrees_of_freedom", 1e-9);
  if (value === 0) return 0.5;
  const x = degreesOfFreedom / (degreesOfFreedom + value * value);
  const tail = 0.5 * regularizedBeta(x, degreesOfFreedom / 2, 0.5);
  return value > 0 ? 1 - tail : tail;
}

export function bulkVolumeClassification(data: NumericRecord, config: NumericRecord): NumericRecord {
  if (!Array.isArray(data.bars)) throw new Error("bars must be a list");
  if (data.starting_price === null || data.starting_price === undefined) throw new Error("starting_price is required");
  let previousPrice = finite(data.starting_price, "starting_price", 0);
  const sigma = finite(config.sigma, "sigma", 1e-12);
  const df = finite(config.degrees_of_freedom, "degrees_of_freedom", 1e-9);
  const trace: NumericRecord[] = [];
  for (const bar of sortedRows(data.bars)) {
    const price = finite(bar.close, "close", 0);
    const volume = finite(bar.volume ?? 0, "volume", 0);
    if ((bar.status ?? "valid") !== "valid") {
      trace.push({id: bar.id, sequence: bar.sequence, event_time_ms: bar.event_time_ms, price, volume, sign: 0, side: "excluded", reason: "broken-or-cancelled", price_change: null, z_score: null, buy_fraction: null, buy_volume: 0, sell_volume: 0});
      continue;
    }
    const priceChange = price - previousPrice;
    const zScore = priceChange / sigma;
    const buyFraction = studentTCdf(zScore, df);
    const buyVolume = volume * buyFraction;
    const sellVolume = volume - buyVolume;
    const sign = buyFraction > 0.5 ? 1 : buyFraction < 0.5 ? -1 : 0;
    trace.push({id: bar.id, sequence: bar.sequence, event_time_ms: bar.event_time_ms, price, volume, sign, side: sign > 0 ? "buy-leaning" : sign < 0 ? "sell-leaning" : "balanced", reason: "student-t-volume-split", price_change: priceChange, z_score: zScore, buy_fraction: buyFraction, buy_volume: buyVolume, sell_volume: sellVolume});
    previousPrice = price;
  }
  const valid = trace.filter((row) => row.reason !== "broken-or-cancelled");
  const buyVolume = valid.reduce((total, row) => total + row.buy_volume, 0);
  const sellVolume = valid.reduce((total, row) => total + row.sell_volume, 0);
  const total = buyVolume + sellVolume;
  let cumulative = 0;
  for (const row of trace) {
    cumulative += row.buy_volume - row.sell_volume;
    row.cumulative_signed_volume = roundValue(cumulative);
  }
  return roundValue({
    state: valid.length ? "ok" : "no-eligible-records",
    method: "bulk-volume-classification",
    trace,
    total_valid: valid.length,
    classified_count: valid.length,
    unknown_count: 0,
    buy_count: valid.filter((row) => row.sign > 0).length,
    sell_count: valid.filter((row) => row.sign < 0).length,
    buy_volume: buyVolume,
    sell_volume: sellVolume,
    unknown_volume: 0,
    signed_volume: buyVolume - sellVolume,
    total_volume: total,
    coverage: valid.length ? 1 : 0,
    imbalance_fraction: total ? (buyVolume - sellVolume) / total : 0,
    sigma,
    degrees_of_freedom: df,
  });
}

export function runTopic(kind: string, data: NumericRecord, config: NumericRecord): NumericRecord {
  if (kind === "tick") return tickTest(data, config);
  if (kind === "quote") return quoteTest(data, config);
  if (kind === "lee_ready") return leeReady(data, config);
  if (kind === "bvc") return bulkVolumeClassification(data, config);
  throw new Error(`unsupported topic kind: ${kind}`);
}
Full-height labquote test labOpen full screen