D01-F01-A06 / Complete engineering topic

Volume-Imbalance Bars: Build Candles from Signed Shares Without Look-Ahead

A production-minded guide to Volume-Imbalance Bars.

D01 · MARKET DATA ENGINEERING
D01-F01-A06Canonical / Tested / Open
D01 / D01-F01
Key concepts

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

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.

Volume-Imbalance Bars overview with an explicitly illustrative signed-volume overlay

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:

bt={+1,pt>pt1,1,pt<pt1,bt1,pt=pt1.b_t = \begin{cases} +1, & p_t>p_{t-1},\\ -1, & p_t<p_{t-1},\\ b_{t-1}, & p_t=p_{t-1}. \end{cases}

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:

xt=btvt,θT=t=1Txt.x_t=b_t v_t, \qquad \theta_T=\sum_{t=1}^{T}x_t.

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

hk=max(hmin,sE^k1[T]E^k1[x]).h_k=\max\left(h_{\min},s\,\widehat E_{k-1}[T]\left|\widehat E_{k-1}[x]\right|\right).
SymbolMeaningUnit
E[T]expected complete-bar lengthtrades/bar
E[x]expected signed volume per tradesigned shares/trade
h_minfinal absolute package threshold floorshares
sscenario scaledimensionless
h_kfrozen thresholdshares

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:

E^k[T]=(1αT)E^k1[T]+αTTk,\widehat E_k[T]=(1-\alpha_T)\widehat E_{k-1}[T]+\alpha_T T_k, E^k[x]=(1αx)E^k1[x]+αxθTkTk.\widehat E_k[x]=(1-\alpha_x)\widehat E_{k-1}[x]+\alpha_x\frac{\theta_{T_k}}{T_k}.

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.

TradePriceSharesSignSigned sharesCumulativeResult
W01100.0060+1+60+60Continue
W02100.0040+1+40+100Flat price carries +1
W0399.9970-1-70+30Continue
W0499.9890-1-90-60Continue
W0599.9850-1-50-110Flat price carries -1
W0699.9795-1-95-205Close; 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.

Exact worked-example membership, signs, thresholds, and secondary candle summary

The machine-readable trace lives in worked-example.json. Notice that OHLCV appears only after membership is known.

Keep the state transition causal

Rendering system map…

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:

  • signedVolume and thresholdShares;
  • expectedTicksBefore and expectedSignedVolumeBefore;
  • overshootShares, thresholdMet, and isComplete;
  • closeReason (threshold, session_end, or stream_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.

  1. Canonical adaptive stream: enough observations to see several closes and expectation changes.
  2. Equality and overshoot: exact equality is compared with an indivisible crossing trade.
  3. 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.

Exact boundary, overshoot, post-close update, and session reset

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

MethodAccumulationClose conditionMain sensitivity
Volume barsgross sharesfixed gross-share totalsize and trade splitting policy
Tick-imbalance barssigned trade countadaptive signed-count thresholdtick-sign path
Volume-imbalance barssigned sharesadaptive signed-share thresholdsign path, sizes, seeds, EMAs
Order-book imbalancequoted depthmetric-specific, not this rulebook 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.

Causal volume-imbalance construction flow

Purpose: show why a crossing trade is tested against state frozen before that trade arrived.

Rendering system map…

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 notes

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.

bars.ts
/** 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;
}
Full-height labplaygroundOpen full screen