The governed definitions this build depends on. Read them first if a term is unfamiliar.
- PrimaryBarA bar is an aggregate summary of eligible market events grouped by a declared sampling rule.
- PrimaryTrading VolumeTrading volume is the aggregate eligible quantity executed over a stated instrument, source, and interval.
- PrerequisiteTradeA trade is an executed transaction in which a quantity of a financial instrument changes hands at agreed terms.
- ImportantMeasurement UnitA measurement unit states the quantity scale represented by a numeric value, such as currency, shares, contracts, seconds, or percent.
- ImportantOutlierAn outlier is an observation that is unusually distant or inconsistent under a specified comparison model.
- ImportantVolume Adjustment BasisA volume adjustment basis declares how reported volume values are represented and adjusted within a series.
Practical promise. By the end, you can calculate the exact trade that closes a volume-imbalance bar, audit its frozen threshold, and reproduce the same membership in Python and TypeScript.
Ordinary volume bars close after a fixed amount of gross shares. Volume-imbalance bars ask a different question: has directional net share quantity become large relative to an expectation learned from earlier complete bars?
That sounds compact, but small conventions change every later candle. What signs a flat-price trade? What initializes the first trade? Does equality close? Is the threshold updated during a bar? What happens to a crossing trade's overshoot? This tutorial makes those decisions visible and testable.
The method is a sampling rule, not a forecast. A close says that the configured boundary was reached. It does not prove informed trading, future direction, or profitability.
The object being accumulated
Start with one cleaned, eligible, chronological trade stream. Each record has an event time, source-resolved order, symbol, price, quantity, currency, session, and unique post-correction identifier.
For trade t, infer a causal tick sign:
At session start, this package seeds the first sign at +1. A flat price carries the previous sign. This is a price-based inference, not observed aggressor identity.
Multiply the sign by eligible executed shares:
x_t and theta_T are signed shares. Gross volume sum(v_t) remains a different output. Do not call contracts, lots, tokens, or notional "shares" without a documented conversion.
Freeze the decision boundary before the bar begins
At the start of bar k, this package freezes
| Symbol | Meaning | Unit |
|---|---|---|
E[T] | expected complete-bar length | trades/bar |
E[x] | expected signed volume per trade | signed shares/trade |
h_min | final absolute package threshold floor | shares |
s | scenario scale | dimensionless |
h_k | frozen threshold | shares |
The scale multiplies only the adaptive expected-imbalance component; the outer maximum is the final operation. Therefore h_k cannot fall below h_min, including when s < 1. The bar closes on the first T for which |theta_T| >= h_k. Equality closes. The whole crossing trade stays in the closing bar, and the overshoot is reported rather than split or carried forward.
Only after a threshold-complete bar closes are the two expectations updated:
Session-end and stream-end partials do not train either EMA. At a session change, this package resets the sign and both expectations to their configured seeds.
That signed-volume update is a documented package variant: one EMA observation is the completed bar's mean signed shares per trade. Some implementations instead estimate expected b_t v_t from a tick-level EMA or rolling tick history. The estimators can form different bars, so a production dataset must version the estimator explicitly.
Work the boundary by hand
Use an expected length of 4 trades, expected signed volume of 50 shares/trade, a 120-share floor, scale 1, and EMA coefficients 0.25. The first frozen threshold is max(120, 4 x 50) = 200 shares.
| Trade | Price | Shares | Sign | Signed shares | Cumulative | Result |
|---|---|---|---|---|---|---|
| W01 | 100.00 | 60 | +1 | +60 | +60 | Continue |
| W02 | 100.00 | 40 | +1 | +40 | +100 | Flat price carries +1 |
| W03 | 99.99 | 70 | -1 | -70 | +30 | Continue |
| W04 | 99.98 | 90 | -1 | -90 | -60 | Continue |
| W05 | 99.98 | 50 | -1 | -50 | -110 | Flat price carries -1 |
| W06 | 99.97 | 95 | -1 | -95 | -205 | Close; overshoot 5 |
W01 and W02 share a timestamp. Their source-resolved array order remains authoritative. After W06, expected ticks become 4.5 and expected signed volume becomes 28.95833333 shares/trade. The next frozen threshold is 130.3125 shares. W07 adds +80; W08 reaches +135 and closes with 4.6875 shares of overshoot.
The machine-readable trace lives in worked-example.json. Notice that OHLCV appears only after membership is known.
Keep the state transition causal
This order prevents the crossing trade from changing the threshold used to judge itself. It also prevents a partial tail from quietly influencing later parameters.
What the output must reveal
A trustworthy bar contains more than OHLCV. The reference implementations expose:
signedVolumeandthresholdShares;expectedTicksBeforeandexpectedSignedVolumeBefore;overshootShares,thresholdMet, andisComplete;closeReason(threshold,session_end, orstream_end);- first and last trade IDs for lineage.
The 240-trade synthetic oracle produces 18 bars and conserves every trade with closePartial=true. Both languages match that stored output exactly.
Explore three situations in the lab
Open the interactive playground. It starts with a useful preview and supports Back, Step, Play/Pause, and deterministic Reset.
- Canonical adaptive stream: enough observations to see several closes and expectation changes.
- Equality and overshoot: exact equality is compared with an indivisible crossing trade.
- Invalid correction/order input: the lab rejects an ambiguous tape before state changes.
The chart's signed-share trace is a derived secondary overlay, explicitly labeled as such. It is not order-book depth or a real aggressor feed.
Production rules that cannot be guessed later
- Resolve corrections, cancels, duplicates, and sale-condition eligibility before aggregation.
- Preserve source sequence when timestamps tie; event time alone may not establish order.
- Do not mix symbols, currencies, or quantity units in one call.
- Reject non-finite and nonpositive numerical fields.
- Make session-reset and partial-tail policies versioned data-product metadata.
- Recompute affected bars when corrected history changes; do not append a correction as new volume.
How it differs from neighboring concepts
| Method | Accumulation | Close condition | Main sensitivity |
|---|---|---|---|
| Volume bars | gross shares | fixed gross-share total | size and trade splitting policy |
| Tick-imbalance bars | signed trade count | adaptive signed-count threshold | tick-sign path |
| Volume-imbalance bars | signed shares | adaptive signed-share threshold | sign path, sizes, seeds, EMAs |
| Order-book imbalance | quoted depth | metric-specific, not this rule | book level and update semantics |
Volume-imbalance bars are also not VPIN or a footprint-chart imbalance. Similar words do not imply interchangeable calculations.
Evidence boundary
The algorithm definition is supported by the original information-driven-bars treatment. The tick-test convention is supported by Lee and Ready. The official NYSE Daily TAQ Client Specification v4.3 (2026-03-03) confirms that the trades file is sorted by symbol, time, and message sequence number and includes time, symbol, sale condition, share volume, share price, correction indicator, sequence number, and trade ID. NYSE and FINRA material support the need for explicit transaction fields, sequencing, reporting context, and correction handling.
The public example remains synthetic. A daily data-provider aggregate cannot establish the tick signs, adaptive state, or exact crossing trade. A named company/session example would require a redistributable transaction tape plus source-specific correction and eligibility rules. Without that evidence, a historical label would be false precision.
Summary
You can now audit the complete decision chain: cleaned ordered trades, tick signs, signed shares, a frozen threshold, the first equality/crossing trade, overshoot, post-close expectation updates, and the next threshold. Passing the tests proves implementation fidelity—not market usefulness.
Continue with Tick-Run Bars to compare a dominant-side run rule with signed-net accumulation.
Primary references and applicability notes are maintained in REFERENCES.md. The canonical specification is the package README.md.
Rendered from the canonical Mermaid sources linked by this article.
Causal volume-imbalance construction flow
Purpose: show why a crossing trade is tested against state frozen before that trade arrived.
Takeaway: adaptive estimates change only after a complete bar; partial bars never train them, and session changes restore the declared seeds.
References4 primary sources and evidence notesExpand the source trail, evidence role, and limitations behind the engineering choices.
Expand the source trail, evidence role, and limitations behind the engineering choices.
R01 — Advances in Financial Machine Learning, Chapter 1 manuscript
- Organization or authors: Marcos Lopez de Prado
- Source type: Original book-chapter manuscript
- Publication or effective date: 2018-01-18
- Version: First-edition chapter manuscript
- URL or DOI: https://ssrn.com/abstract=3104847
- Accessed: 2026-07-22
- Jurisdiction: Method is market-agnostic; examples concern financial market data.
- Supports: Information-driven bars; volume imbalance
b_t v_t; the expected-bar-length times expected-imbalance stopping idea; expectation estimates based on prior observations/bars. - Limitations: The manuscript does not make this package's seed, floor, scale, session-reset, whole-trade, or partial-tail choices universal. Those are labeled package conventions.
R02 — Inferring Trade Direction from Intraday Data
- Organization or authors: Charles M. C. Lee and Mark J. Ready
- Source type: Original peer-reviewed research article, Journal of Finance 46(2), 733-746
- Publication or effective date: 1991-06
- Version: Version of record
- URL or DOI: https://doi.org/10.1111/j.1540-6261.1991.tb02683.x
- Accessed: 2026-07-22
- Jurisdiction: Empirical discussion uses NYSE and AMEX transaction/quote data.
- Supports: Tick-test classification by price changes, including carrying the last nonzero direction through zero ticks; limitations of inferred trade direction.
- Limitations: The paper does not define volume-imbalance bars. A tick sign is an inference, not observed aggressor identity.
R03 — NYSE Daily TAQ Client Specification
- Organization or authors: New York Stock Exchange / Intercontinental Exchange
- Source type: Official market-data product specification
- Publication or effective date: 2026-03-03
- Version: 4.3
- URL or DOI: https://www.nyse.com/publicdocs/nyse/data/Daily_TAQ_Client_Spec_v4.3.pdf
- Accessed: 2026-07-22
- Jurisdiction: U.S. listed equities in CTA and UTP SIP data
- Supports: The trades file is sorted by symbol, time, and message sequence number; its schema includes SIP publication time, exchange, symbol, sale condition, share volume, share price, correction indicator, sequence number, trade ID, source, and reporting-facility information; source-time precision history and contractual access requirements.
- Limitations: The specification describes a licensed product, not the bar algorithm. It does not grant redistribution rights for a real transaction tape.
R04 — Trade Reporting Frequently Asked Questions
- Organization or authors: Financial Industry Regulatory Authority (FINRA)
- Source type: Official regulatory reporting guidance
- Publication or effective date: Living guidance
- Version: Page accessed 2026-07-22
- URL or DOI: https://www.finra.org/filing-reporting/market-transparency-reporting/trade-reporting-faq
- Accessed: 2026-07-22
- Jurisdiction: U.S. FINRA-reportable transactions
- Supports: Why execution time, quantity, price precision, odd lots, corrections, and reporting context must be handled before aggregation.
- Limitations: Facility-specific reporting rules are not a universal eligibility filter and do not define volume-imbalance bars.
Evidence and historical-example decision
All distributed observations are deterministic synthetic records under the repository's project license. They model mechanics, not a real venue, issuer, investor, or market episode.
A named historical session is not included. A daily OHLCV series or provider aggregate cannot establish tick signs, the adaptive expectation state, or the exact transaction that crossed the frozen threshold. A defensible historical example would require a redistributable, sequence-aware transaction tape plus documented correction and sale-condition rules. Until that evidence is available, attaching a company/date label would create false precision.
No Financial Modeling Prep data are needed for this topic: its normal endpoints provide aggregates, not the transaction-level membership evidence required for the algorithm.
Full dependency-light reference implementations in both supported languages.
/** Causal volume-imbalance bars under this package's explicit convention. */
export type Trade = {
tradeId: string; timestamp: string; session: string; symbol: string;
price: number; volume: number; currency: string;
};
export type Config = {
closePartial?: boolean;
initialTickSign?: -1 | 1;
initialExpectedTicks?: number;
initialExpectedSignedVolume?: number;
alphaTicks?: number;
alphaSignedVolume?: number;
thresholdFloorShares?: number;
thresholdScale?: number;
};
export type Bar = {
barIndex: number; session: string; startTime: string; endTime: string;
open: number; high: number; low: number; close: number; volume: number;
dollarValue: number; tickCount: number; firstTradeId: string; lastTradeId: string;
closeReason: "threshold" | "session_end" | "stream_end"; isComplete: boolean;
signedVolume: number; thresholdShares: number; expectedTicksBefore: number;
expectedSignedVolumeBefore: number; overshootShares: number; thresholdMet: boolean;
};
function timestamp(value: unknown): number {
if (typeof value !== "string" || !value.endsWith("Z"))
throw new Error("timestamp must be an ISO-8601 UTC string ending in Z");
const parsed = Date.parse(value);
if (!Number.isFinite(parsed)) throw new Error("timestamp must be valid ISO-8601");
return parsed;
}
function finite(value: unknown, name: string, positive = false): number {
if (typeof value !== "number" || !Number.isFinite(value))
throw new Error(`${name} must be a finite number`);
if (positive && value <= 0) throw new Error(`${name} must be positive`);
return value;
}
function rounded(value: number): number { return Number(value.toFixed(8)); }
function normalized(config: Config) {
if (config === null || typeof config !== "object") throw new Error("config must be an object");
const closePartial = config.closePartial ?? true;
if (typeof closePartial !== "boolean") throw new Error("closePartial must be boolean");
const initialSign = config.initialTickSign ?? 1;
if (initialSign !== -1 && initialSign !== 1) throw new Error("initialTickSign must be -1 or 1");
const alphaTicks = finite(config.alphaTicks ?? .2, "alphaTicks", true);
const alphaSigned = finite(config.alphaSignedVolume ?? .2, "alphaSignedVolume", true);
if (alphaTicks > 1 || alphaSigned > 1) throw new Error("EMA coefficients must be in (0, 1]");
return {
closePartial, initialSign,
initialExpectedTicks: finite(config.initialExpectedTicks ?? 16, "initialExpectedTicks", true),
initialExpectedSigned: finite(config.initialExpectedSignedVolume ?? 35, "initialExpectedSignedVolume"),
alphaTicks, alphaSigned,
floor: finite(config.thresholdFloorShares ?? 300, "thresholdFloorShares", true),
scale: finite(config.thresholdScale ?? 1, "thresholdScale", true),
};
}
function validateTrades(trades: Trade[]): void {
if (!Array.isArray(trades)) throw new Error("trades must be an array");
const ids = new Set<string>(), closedSessions = new Set<string>();
let previous = -Infinity, currentSession: string | null = null;
let symbol: string | null = null, currency: string | null = null;
for (const trade of trades) {
if (!trade || typeof trade !== "object") throw new Error("trade is missing a required field");
for (const key of ["tradeId", "timestamp", "session", "symbol", "currency"] as const)
if (typeof trade[key] !== "string" || !trade[key]) throw new Error(`${key} must be a non-empty string`);
const now = timestamp(trade.timestamp);
if (now < previous) throw new Error("trades must be globally chronological");
previous = now;
if (ids.has(trade.tradeId)) throw new Error("tradeId must be unique after corrections are resolved");
ids.add(trade.tradeId);
finite(trade.price, "price", true); finite(trade.volume, "volume", true);
if (symbol === null) { symbol = trade.symbol; currency = trade.currency; }
else if (trade.symbol !== symbol || trade.currency !== currency)
throw new Error("one symbol and one currency are allowed per call");
if (currentSession === null) currentSession = trade.session;
else if (trade.session !== currentSession) {
closedSessions.add(currentSession);
if (closedSessions.has(trade.session)) throw new Error("a session may not reappear after a later session begins");
currentSession = trade.session;
}
}
}
export function constructBars(trades: Trade[], config: Config): Bar[] {
const cfg = normalized(config); validateTrades(trades);
if (!trades.length) return [];
const result: Bar[] = [];
let current: Trade[] = [], session: string | null = null, previousPrice: number | null = null;
let tickSign = cfg.initialSign, expectedTicks = cfg.initialExpectedTicks;
let expectedSigned = cfg.initialExpectedSigned, signedVolume = 0;
let frozenThreshold = 0, frozenExpectedTicks = 0, frozenExpectedSigned = 0;
const beginBar = () => {
current = []; signedVolume = 0;
frozenExpectedTicks = expectedTicks; frozenExpectedSigned = expectedSigned;
frozenThreshold = Math.max(cfg.floor, cfg.scale * frozenExpectedTicks * Math.abs(frozenExpectedSigned));
};
const emit = (reason: Bar["closeReason"]) => {
if (!current.length) return;
const prices = current.map(t => t.price), volumes = current.map(t => t.volume);
const thresholdMet = Math.abs(signedVolume) >= frozenThreshold;
result.push({
barIndex: result.length, session: current[0].session,
startTime: current[0].timestamp, endTime: current.at(-1)!.timestamp,
open: rounded(prices[0]), high: rounded(Math.max(...prices)), low: rounded(Math.min(...prices)),
close: rounded(prices.at(-1)!), volume: rounded(volumes.reduce((a,b) => a+b, 0)),
dollarValue: rounded(current.reduce((a,t) => a+t.price*t.volume, 0)), tickCount: current.length,
firstTradeId: current[0].tradeId, lastTradeId: current.at(-1)!.tradeId,
closeReason: reason, isComplete: reason === "threshold", signedVolume: rounded(signedVolume),
thresholdShares: rounded(frozenThreshold), expectedTicksBefore: rounded(frozenExpectedTicks),
expectedSignedVolumeBefore: rounded(frozenExpectedSigned),
overshootShares: rounded(Math.max(Math.abs(signedVolume)-frozenThreshold, 0)), thresholdMet,
});
if (reason === "threshold") {
const observedTicks = current.length, observedSigned = signedVolume / observedTicks;
expectedTicks = (1-cfg.alphaTicks)*expectedTicks + cfg.alphaTicks*observedTicks;
expectedSigned = (1-cfg.alphaSigned)*expectedSigned + cfg.alphaSigned*observedSigned;
}
beginBar();
};
beginBar();
for (const trade of trades) {
if (session !== null && trade.session !== session) {
if (current.length && cfg.closePartial) emit("session_end"); else beginBar();
expectedTicks = cfg.initialExpectedTicks; expectedSigned = cfg.initialExpectedSigned;
previousPrice = null; tickSign = cfg.initialSign; beginBar();
}
session = trade.session;
if (previousPrice !== null)
tickSign = trade.price > previousPrice ? 1 : trade.price < previousPrice ? -1 : tickSign;
previousPrice = trade.price; current.push(trade); signedVolume += tickSign*trade.volume;
if (Math.abs(signedVolume) >= frozenThreshold) emit("threshold");
}
if (current.length && cfg.closePartial) emit("stream_end");
return result;
}
The embedded lab now expands to its full document height, keeping the article as the only scroll surface.