Market data can show that a transaction happened without giving a portable, research-ready answer to who initiated it. How much of each bar's volume should be attributed to buying versus selling pressure? Bulk Volume Classification 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.
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: Apply a Student-t cumulative distribution to each standardized bar-to-bar price change, then split bar volume by the resulting buy fraction.
The formula is V^B_tau = V_tau F_t((P_tau-P_{tau-1})/sigma; nu), and V^S_tau = V_tau - V^B_tau..
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 transparent-t1 profile, the alternating-pressure scenario exposes the method's main branches. The reference implementation creates a 60-row trace and stores the exact output beside the input.
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: fractional volume, not binary trade labels
Bulk Volume Classification works on bars. For each bar, standardize the change from the previous bar's closing price by a pre-estimated price-change scale. Feed that z-score into a Student-t cumulative distribution. The result is the estimated buy fraction; one minus it is the sell fraction. A zero change maps to 50/50, and buy plus sell volume always equals total eligible volume.
The canonical profile uses one degree of freedom, a Cauchy distribution, because its CDF has a transparent independent check: F(z)=0.5+atan(z)/pi. The research-sensitivity profile uses 0.25 degrees of freedom, matching the heavy-tail calibration reported by Easley, Lopez de Prado, and O'Hara. Degrees of freedom and sigma are conventions, not facts about every market.
Sigma must be point-in-time
Sigma cannot be estimated using the future bars being classified. A production system needs a trailing, frozen, or separately trained price-change distribution whose observation and availability clocks precede the bar. The synthetic fixture supplies sigma directly so the tutorial isolates classification.
BVC does not claim that every unit of fractional buy volume came from a buyer-initiated trade. It is a bulk pressure estimate derived from price response. It can be useful when individual ordering is unreliable or compressed data are all that exist, but it must not be compared with a tick rule as though both output the same object.
Bar construction is a separate contract
The classifier begins after bars exist. Time bars, trade-count bars, and volume bars collect different event populations and therefore create different closing-price changes and volume denominators. A production result must state the bar rule, session boundary, partial-bar policy, correction policy, and whether the close is the final eligible transaction or another price measure. Changing the bar rule changes the observable presented to BVC; it is not a harmless rendering choice.
Cancelled bars in the teaching fixture are excluded without updating the previous eligible close. In a live event stream, a late trade correction may change an already-formed bar and every following standardized change. Correct handling requires replay or versioned derived bars. The classifier cannot repair an invalid bar-building pipeline, and cross-language agreement on the same bad bars would only reproduce the error consistently.
Compare profiles before interpreting the result
Three profiles are pre-registered: transparent-t1, paper-tail, and high-noise. 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.
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:
from trade_classification import run_topic
result = run_topic("bvc", input_data, config)
TypeScript uses the same input and output semantics:
import { runTopic } from "./trade-classification";
const result = runTopic("bvc", 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.
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:
- BVC assigns every underlying trade a side.
- A positive bar proves all volume was buyer initiated.
- Sigma may be estimated using the same future sample.
- The paper's degrees of freedom are universal.
- A fractional volume estimate is ground-truth aggressor volume.
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 Quoted Spread. Within Trade Classification, each tutorial adds a different observable: transaction-price state, quote position, a hybrid branch, or aggregated price response.
Sources
- Discerning Information from Trade Data — Bulk Volume Classification, Student-t CDF volume splitting, interval aggregation, and comparison with tick rules.
- Nasdaq TotalView-ITCH 5.0 Specification — Nanosecond event timestamps, executions, non-display trade messages, crosses, and broken-trade processing.
- Daily TAQ Client Specification — Separate trades, quotes, NBBO, and administrative records and the need to preserve data-source semantics.
Source facts are bounded to their named markets and methods. All examples and calculations here are synthetic or author-derived.
Rendered from the canonical Mermaid sources linked by this article.
Bulk Volume Classification Calculation Flow
Purpose: show where causal normalization ends and classification begins.
Takeaway: the classification formula is only one stage; record eligibility and causal alignment determine what it is allowed to see.
ReferencesPrimary 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.
BVC — Discerning Information from Trade Data
- Organization or authors: David Easley, Marcos Lopez de Prado, and Maureen O'Hara
- Source type: Original research paper
- Publication or effective date: 2016-05
- Version: Journal of Financial Economics 120(2)
- URL or DOI: https://doi.org/10.1016/j.jfineco.2016.01.018
- Accessed: 2026-07-29
- Jurisdiction: Empirical futures setting with general classification method
- Supports: Bulk Volume Classification, Student-t CDF volume splitting, interval aggregation, and comparison with tick rules.
- Limitations: BVC estimates bulk buy/sell fractions; it does not recover the true side of every trade or prove informed trading.
- 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.
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.
Full dependency-light reference implementations in both supported languages.
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}`);
}
The embedded lab now expands to its full document height, keeping the article as the only scroll surface.