D01-F01-A05 / Complete engineering topic

Tick-Imbalance Bars: Make Every Boundary Explainable

A production-minded guide to Tick-Imbalance Bars.

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

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

Practical promise. You will build a causal Tick-Imbalance Bar, calculate its first boundary by hand, and know which conventions must travel with the result.

A labeled Tick-Imbalance Bar overview separating price context from the signed-tick decision state

A candle can look convincing while hiding a membership problem. With Tick-Imbalance Bars, the decisive object is not the candle shape. It is a running sum of tick-rule signs and the threshold that was frozen before the bar opened.

This article implements the named method from Section 2.3.2.1 of Marcos López de Prado's Advances in Financial Machine Learning as one disclosed, session-local EMA variant. The distinction matters: the source provides the stopping idea, while seeds, exponential-moving-average coefficients, floors, session resets, and tail handling remain implementation choices.

The one question the algorithm answers

For each eligible trade, assign a causal sign:

  • +1 after a price increase;
  • −1 after a price decrease;
  • the previous sign after an unchanged price.

Add those signs inside the open bar. Close at the first trade for which the absolute sum reaches the expected-magnitude threshold known at bar opening.

That is a sampling decision. It is not evidence that a buyer or seller was “informed,” and it does not predict the next return. An adaptive clock changes where observations fall; downstream usefulness needs separate testing.

Source method and teaching variant

The book defines the tick rule, cumulative imbalance, an expected imbalance based on expected bar length and expected sign, and the first-crossing rule. It carries the prior sign across bar boundaries and suggests exponential weighting for expectations.

This package makes the operational gaps explicit:

ConventionPackage decision
First signConfigured as −1 or +1; +1 in the stored fixture
Flat tradeCarries the last sign
Bar boundaryPrice and sign state carry within a session
Session boundaryPrice, sign, expectations, and open-bar state reset
Expected lengthOne-step EMA of threshold-closed bar lengths
Expected signOne-step EMA of each threshold-closed bar's mean sign
Open-bar thresholdFrozen until closure
FloorPositive lower bound added by this package
MultiplierApplied to expected magnitude before the floor
Partial tailEmitted or dropped; never updates expectations

Calling these “the default” would be misinformation. They are reproducible choices for this package.

Data comes before the formula

Tick signs depend on exact transaction order. The NYSE Daily TAQ specification documents a trade file ordered by symbol, time, and message sequence and includes sale condition, correction indicator, sequence number, trade ID, volume, and price. Its time field is SIP publication time, which is one reason a system must name the time semantic it uses.

FINRA's trade-reporting FAQ separately documents execution-time meaning and correction/cancellation workflows. A production pipeline must resolve those records before bar construction.

This package accepts one symbol and currency per call. It rejects duplicate IDs, missing or non-finite values, decreasing time, ambiguous equal timestamps, mixed instruments, and a session that reappears after another session starts. When timestamps tie, both records need a strictly increasing integer source sequence. The implementation never guesses a sort order.

The mathematics, with every state named

For price pip_i, the tick sign bib_i is

bi={+1,pi>pi1,1,pi<pi1,bi1,pi=pi1.b_i= \begin{cases} +1,&p_i>p_{i-1},\\ -1,&p_i<p_{i-1},\\ b_{i-1},&p_i=p_{i-1}. \end{cases}

The first trade of a session uses the configured seed s0s_0. Within a session, the previous price and sign continue across complete bars.

For TT trades in the open bar,

θT=i=1Tbi.\theta_T=\sum_{i=1}^{T}b_i.

Before bar kk opens, freeze

hk=max(hmin,λE^k[T]E^k[b]).h_k=\max\left(h_{\min},\lambda\widehat E_k[T]\left|\widehat E_k[b]\right|\right).

Here E^k[T]\widehat E_k[T] is expected bar length, E^k[b]\widehat E_k[b] is expected mean tick sign, hminh_{\min} is the package floor, and λ\lambda is the package multiplier. The first-crossing boundary is

Tk=min{T1:θThk}.T_k^*=\min\{T\ge1:|\theta_T|\ge h_k\}.

Equality closes. Since θT\theta_T changes in integer steps, a threshold of 2.5 is crossed at 3. The crossing trade stays whole.

After a complete bar—and only then—the package updates:

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

Updating either expectation while the current bar is open would move the goalpost and define a different algorithm.

Work one boundary by hand

Start with expected length 8, expected mean sign 0.5, floor 3, multiplier 1, and session seed +1. The frozen threshold is

max(3,8×0.5)=4.\max(3,8\times0.5)=4.

Six trades with their tick signs and cumulative imbalance reaching the frozen threshold at trade six

The prices are 100.00, 100.00, 100.10, 100.05, 100.10, and 100.15. Their signs are +1, +1, +1, −1, +1, +1. The cumulative imbalance is 1, 2, 3, 2, 3, 4. Trade six closes by equality.

With αT=0.25\alpha_T=0.25, expected length becomes

0.75(8)+0.25(6)=7.5.0.75(8)+0.25(6)=7.5.

With αb=0.5\alpha_b=0.5, expected mean sign becomes

0.5(0.5)+0.5(4/6)=0.5833330.5(0.5)+0.5(4/6)=0.583333\ldots

The next threshold is therefore 4.375. That value is frozen before the next trade arrives.

Three boundaries that change output

Frozen threshold, fractional overshoot, and session reset shown as three separate rules

First, the threshold does not drift inside an open bar. Second, an integer statistic can exceed a fractional boundary, but the crossing trade is not split. Third, a session reset returns to disclosed seeds. Carrying adaptive state overnight would be a different variant, not a harmless optimization.

The same discipline applies to tails. A threshold close is complete and updates expectations. A session_end or stream_end close is partial and does not. With closePartial=false, the tail is deliberately omitted.

Walk the state, do not trust the final candle

Open the interactive Tick-Imbalance Bar lab. Its canonical scenario starts with a useful preview, not an empty chart. Use Back and Step to move across the six-trade boundary. Then compare:

  • a flat-tick scenario, where the carried sign is visible;
  • a session-reset scenario, where the same opening price receives the configured seed again;
  • multiplier changes, which recompute every boundary from observation one.

The price path is labeled context only. The signed-tick path, frozen threshold, sign decision, and audit rows are the actual closing evidence.

Implementation and parity

The Python and TypeScript implementations use the same validation and state order:

  1. validate configuration and the full stream;
  2. reset explicit session state;
  3. freeze a bar threshold;
  4. classify one trade using only present and prior state;
  5. update OHLCV and imbalance;
  6. emit on abs(imbalance) >= threshold;
  7. update expectations from a complete bar;
  8. emit or drop an incomplete tail.

The shared fixture has 240 synthetic trades across two sessions and produces 12 stored bars. Focused tests cover first-sign seeding, flat carry, cross-bar carry, threshold freezing, equality, fractional overshoot, session reset, tails, ties, invalid numbers, duplicates, and configuration domains.

Passing those tests proves definition-level consistency. It does not prove statistical superiority, information discovery, or profitability.

Why there is no “Company X on Date Y” episode yet

A real historical explanation could make the value concrete—but only with evidence that actually observes the algorithm's inputs. Tick-Imbalance Bars require the exact transaction tape, event-time and sequence semantics, correction state, sale-condition policy, session calendar, and full parameter configuration.

End-of-day OHLCV from FMP or another provider cannot reconstruct transaction membership. Using it to claim that a historical episode “caused” particular Tick-Imbalance Bars would be false precision.

The package therefore marks a real order-flow episode as medium priority, evidence-gated:

  1. obtain a licensed, reproducible tick tape;
  2. archive the source and entitlement note privately;
  3. record cleaning, corrections, ordering, and configuration;
  4. reproduce bars in both languages;
  5. publish only data and derived evidence allowed by the license;
  6. label observed facts, package choices, derivations, and interpretation separately.

Until those conditions are met, deterministic synthetic data is the honest way to teach construction.

Practical checklist

  • Name the methodology source and the exact variant.
  • Store the first-sign, flat-trade, session, and tail conventions.
  • Preserve stable sequence for timestamp ties.
  • Resolve corrections and sale conditions upstream.
  • Freeze expected state for the duration of an open bar.
  • Keep the crossing trade whole.
  • Recompute from the beginning after any parameter change.
  • Treat adaptation as sampling state, not a forecast.
  • Validate real episodes with transaction data, not EOD proxies.

Summary and next topic

You can now explain every TIB boundary with four objects: the signed trades, cumulative imbalance, frozen threshold, and package state. Continue with Volume-Imbalance Bars to see how weighting the same signs by share quantity changes the statistic and the data-quality risks.

Primary references

Full source roles, versions, and limitations are in REFERENCES.md. The canonical contract is the topic README.

Causal construction flow

Purpose: separate preprocessed evidence, open-bar state, post-close learning, and session reset.

Rendering system map…

Takeaway: an open bar cannot learn from itself. Expected state changes only after a complete threshold closure, while a new session returns to disclosed seeds.

References3 primary sources and evidence notes

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

R01 — Advances in Financial Machine Learning, Section 2.3.2.1

  • Organization or authors: Marcos López de Prado
  • Source type: Original published methodology for this named bar construction
  • Publication or effective date: February 2018
  • Version: First edition; Section 2.3.2.1, “Tick Imbalance Bars”
  • URL or DOI: https://uat.store.wiley.com/en-us/advances-in-financial-machine-learning-p-9781119482109
  • ISBN: 978-1-119-48210-9 (e-book); 978-1-119-48208-6 (hardcover)
  • Accessed: 2026-07-22
  • Jurisdiction: Market-agnostic methodology
  • Supports: Tick-rule signs; cumulative tick imbalance; expected-length and expected-sign threshold; first-crossing definition; use of exponential weighting on prior state.
  • Limitations: The cited section does not make this package's session seed/reset, one-step EMA coefficients, threshold floor, multiplier, or partial-tail rule universal. The publisher page confirms the edition; readers need the book section for the complete derivation.

R02 — NYSE Daily TAQ Client Specification

  • Organization or authors: New York Stock Exchange, an Intercontinental Exchange company
  • Source type: Official market-data product specification
  • Publication or effective date: 2026
  • 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. consolidated equity trades
  • Supports: File ordering by symbol, time, and message sequence; time as SIP publication time; sale conditions; trade volume and price; correction indicator; sequence number; trade ID.
  • Limitations: TAQ access and redistribution are governed by NYSE terms. Its published time is not automatically the execution-time field for every use case.

R03 — Trade Reporting Frequently Asked Questions

  • Organization or authors: Financial Industry Regulatory Authority
  • Source type: Official 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: FINRA-reportable U.S. transactions
  • Supports: Execution-time meaning and granularity; price and quantity reporting; correction, cancellation, reversal, and as-of workflows.
  • Limitations: Reporting rules vary by facility and are not a Tick-Imbalance Bar methodology.

Evidence classification

Claim typeEvidence
Named TIB method and defining stopping ideaR01
Market-data fields and stable source orderingR02
Why correction and execution-time policies must be upstream and explicitR03
Seeds, EMA coefficients, floor, multiplier, reset, and tailsPackage choices, tested in this repository
Worked arithmeticPackage derivation, stored in examples/worked-example.json
Predictive or profitable behaviorNot claimed and not evidenced

Data and licensing note

The distributed dataset is deterministic synthetic data under the repository's project license. No FMP response, NYSE transaction tape, proprietary venue data, or investor record is redistributed. A real historical case remains out of scope until transaction-level evidence and its publication rights are verified.

bars.ts
/** Causal Tick-Imbalance Bars under this package's disclosed EMA convention. */
export type Trade = {
  tradeId: string;
  timestamp: string;
  session: string;
  symbol: string;
  price: number;
  volume: number;
  currency: string;
  sequence?: number;
};

export type Config = {
  closePartial: boolean;
  initialTickSign: -1 | 1;
  initialExpectedTicks: number;
  initialExpectedTickImbalance: number;
  alphaTicks: number;
  alphaTickImbalance: number;
  thresholdFloor: number;
  thresholdMultiplier: number;
};

export type Bar = {
  barIndex: number; session: string; startTime: string; endTime: string;
  lastTradeTime: 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";
  imbalance: number; threshold: number;
};

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): number {
  if (typeof value !== "number" || !Number.isFinite(value)) {
    throw new Error(`${name} must be a finite number`);
  }
  return value;
}

function positive(value: unknown, name: string): number {
  const parsed = finite(value, name);
  if (parsed <= 0) throw new Error(`${name} must be positive`);
  return parsed;
}

function rounded(value: number): number { return Number(value.toFixed(8)); }

function validate(trades: Trade[], config: Config): void {
  if (!Array.isArray(trades)) throw new Error("trades must be an array");
  if (!config || typeof config !== "object") throw new Error("config must be an object");
  if (typeof config.closePartial !== "boolean") throw new Error("closePartial must be boolean");
  if (config.initialTickSign !== -1 && config.initialTickSign !== 1) {
    throw new Error("initialTickSign must be -1 or +1");
  }
  positive(config.initialExpectedTicks, "initialExpectedTicks");
  const seed = finite(config.initialExpectedTickImbalance, "initialExpectedTickImbalance");
  if (seed < -1 || seed > 1) throw new Error("initialExpectedTickImbalance must be in [-1, 1]");
  for (const [name, value] of [
    ["alphaTicks", config.alphaTicks], ["alphaTickImbalance", config.alphaTickImbalance],
  ] as const) {
    const alpha = finite(value, name);
    if (alpha <= 0 || alpha > 1) throw new Error(`${name} must be in (0, 1]`);
  }
  positive(config.thresholdFloor, "thresholdFloor");
  positive(config.thresholdMultiplier, "thresholdMultiplier");

  const ids = new Set<string>();
  const closedSessions = new Set<string>();
  let activeSession: string | null = null;
  let priorTime: number | null = null;
  let priorSequence: number | null = null;
  let symbol: string | null = null;
  let currency: string | null = null;
  for (const trade of trades) {
    if (!trade || typeof trade !== "object") throw new Error("each trade must be an object");
    for (const name of ["tradeId", "timestamp", "session", "symbol", "currency"] as const) {
      if (typeof trade[name] !== "string" || !trade[name].trim()) {
        throw new Error(`${name} must be a non-empty string`);
      }
    }
    if (ids.has(trade.tradeId)) throw new Error("tradeId must be unique");
    ids.add(trade.tradeId);
    const currentTime = timestamp(trade.timestamp);
    if (priorTime !== null && currentTime < priorTime) throw new Error("trades must be chronological");
    if (priorTime !== null && currentTime === priorTime) {
      if (!Number.isInteger(trade.sequence) || priorSequence === null || trade.sequence! <= priorSequence) {
        throw new Error("equal timestamps require strictly increasing integer sequence values");
      }
    }
    priorTime = currentTime;
    priorSequence = Number.isInteger(trade.sequence) ? trade.sequence! : null;
    if (positive(trade.price, "price") <= 0 || positive(trade.volume, "volume") <= 0) {
      throw new Error("price and volume must be positive");
    }
    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 (activeSession === null) activeSession = trade.session;
    else if (trade.session !== activeSession) {
      closedSessions.add(activeSession);
      if (closedSessions.has(trade.session)) {
        throw new Error("a session may not reappear after another session begins");
      }
      activeSession = trade.session;
    }
  }
}

export function constructBars(trades: Trade[], config: Config): Bar[] {
  validate(trades, config);
  if (trades.length === 0) return [];
  const result: Bar[] = [];
  let current: Trade[] = [];
  let activeSession: string | null = null;
  let previousPrice: number | null = null;
  let tickSign = config.initialTickSign;
  let expectedTicks = config.initialExpectedTicks;
  let expectedImbalance = config.initialExpectedTickImbalance;
  let imbalance = 0;
  let threshold = 0;

  const beginBar = (): void => {
    current = []; imbalance = 0;
    threshold = Math.max(
      config.thresholdFloor,
      config.thresholdMultiplier * expectedTicks * Math.abs(expectedImbalance),
    );
  };
  const resetSessionState = (): void => {
    previousPrice = null;
    tickSign = config.initialTickSign;
    expectedTicks = config.initialExpectedTicks;
    expectedImbalance = config.initialExpectedTickImbalance;
  };
  const emit = (reason: Bar["closeReason"]): void => {
    if (!current.length) return;
    const prices = current.map(trade => trade.price);
    const volumes = current.map(trade => trade.volume);
    result.push({
      barIndex: result.length, session: current[0].session,
      startTime: current[0].timestamp, endTime: current.at(-1)!.timestamp,
      lastTradeTime: 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((sum, value) => sum + value, 0)),
      dollarValue: rounded(current.reduce((sum, trade) => sum + trade.price * trade.volume, 0)),
      tickCount: current.length, firstTradeId: current[0].tradeId,
      lastTradeId: current.at(-1)!.tradeId, closeReason: reason,
      imbalance: rounded(imbalance), threshold: rounded(threshold),
    });
    if (reason === "threshold") {
      const observedTicks = current.length;
      const observedImbalance = imbalance / observedTicks;
      expectedTicks = (1 - config.alphaTicks) * expectedTicks + config.alphaTicks * observedTicks;
      expectedImbalance = (1 - config.alphaTickImbalance) * expectedImbalance
        + config.alphaTickImbalance * observedImbalance;
    }
    beginBar();
  };

  beginBar();
  for (const trade of trades) {
    if (activeSession !== null && trade.session !== activeSession) {
      if (current.length && config.closePartial) emit("session_end");
      else beginBar();
      resetSessionState();
      beginBar();
    }
    activeSession = trade.session;
    if (previousPrice !== null) {
      if (trade.price > previousPrice) tickSign = 1;
      else if (trade.price < previousPrice) tickSign = -1;
    }
    previousPrice = trade.price;
    current.push(trade);
    imbalance += tickSign;
    if (Math.abs(imbalance) >= threshold) emit("threshold");
  }
  if (current.length && config.closePartial) emit("stream_end");
  return result;
}
Full-height labplaygroundOpen full screen