D01-F01-A07 / Complete engineering topic

Tick-Run Bars: Count the Dominant Side Without Confusing a Run With a Streak

A production-minded guide to Tick-Run Bars.

D01 · MARKET DATA ENGINEERING
D01-F01-A07Canonical / 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 calculate and implement a causal Tick-Run Bar, audit every close, and explain why “run” here is not the longest consecutive same-sign streak.

Tick-Run Bars hero showing positive and negative cumulative counts against a frozen threshold

Time bars ask whether a fixed interval ended. Tick bars ask whether enough trades arrived. Tick-Run Bars ask a different question: Has either tick-rule side accumulated an unexpectedly large count relative to prior complete bars?

That wording contains a common trap. In this method, a run is the larger cumulative positive or negative count inside the open bar. It is not necessarily a contiguous streak. If the signs are +1, -1, +1, -1, +1, the positive count is three, even though the longest positive streak is one.

This distinction is not cosmetic. Implement the consecutive-streak interpretation and you construct different bar boundaries.

What the method can and cannot tell you

Tick-Run Bars provide a state-dependent sampling clock. They can make dominant signed-count accumulation visible and can create a reproducible alternative to fixed time or activity thresholds.

They do not prove that a trade was buyer- or seller-initiated. The package derives signs from price changes, so +1 and -1 are tick-rule labels. They also do not demonstrate informed trading, forecast returns, or establish a profitable strategy. Passing construction tests proves that the code follows the declared rule—not that a downstream economic claim is true.

First assign the signs

For session-local trade prices ptp_t, the package assigns

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}

The first trade in each session uses the configured seed, +1 in the canonical fixture. A flat trade carries the previous sign rather than contributing zero.

Two boundaries behave differently:

  • Across a bar boundary, previous price and sign carry forward. The underlying trade stream did not restart merely because an output bar closed.
  • Across a session boundary, price, sign, expectations, and open-bar state reset to the disclosed seeds.

The tick test is an inference. Even where the output fields use familiar names such as buyTicks and sellTicks, those labels do not certify aggressor identity. The original Lee–Ready study is useful both for the rule and for the limitations of inferred direction.

Count both sides, then take the larger

After TT trades in the open bar, define

NT+=t=1T1[bt=+1],NT=t=1T1[bt=1].N_T^+ = \sum_{t=1}^{T}\mathbf{1}[b_t=+1], \qquad N_T^- = \sum_{t=1}^{T}\mathbf{1}[b_t=-1].

At the start of a bar, freeze the expected length E^0[T]\widehat E_0[T] and expected positive-sign probability p^0+\widehat p_0^+. This package computes

h=max ⁣(hmin,sE^0[T]max(p^0+,1p^0+)).h = \max\!\left( h_{\min}, s\,\widehat E_0[T]\max(\widehat p_0^+,1-\widehat p_0^+) \right).

The bar closes on the first observation for which

max(NT+,NT)h.\max(N_T^+,N_T^-) \ge h.

Every symbol has a role:

SymbolMeaningUnit
NT+N_T^+Positive tick-rule count in the open barticks
NTN_T^-Negative tick-rule count in the open barticks
E^0[T]\widehat E_0[T]Expected complete-bar length, frozen at bar startticks
p^0+\widehat p_0^+Expected positive-sign fraction, frozen at bar startprobability
ssPackage multiplierdimensionless
hminh_{\min}Final threshold floorticks

The floor is applied after scaling the adaptive component. Even if s<1s<1, the resulting threshold cannot fall below hminh_{\min}.

Run, imbalance, and streak are different algorithms

Suppose the signs are +1, -1, +1, -1, +1.

StateValue
Positive cumulative count3
Negative cumulative count2
Tick imbalance32=13-2=1
Longest positive consecutive streak1

A run threshold of three closes at trade five. An imbalance threshold of three does not. A consecutive-streak threshold of three does not. Calling all three “runs” would hide a material methodology change.

Comparison of cumulative run count, net imbalance, and consecutive streak state

The figure’s takeaway is simple: Tick-Run Bar membership uses the two cumulative side counts and their maximum.

A complete eight-trade example

Start with expected length 4, expected positive probability 0.625, a 2-tick floor, multiplier 1, and EMA coefficients 0.5. The first threshold is

h1=max(2,4×0.625)=2.5 ticks.h_1=\max(2,4\times0.625)=2.5\text{ ticks}.
TradePriceRuleSignN+N^+NN^-Result
W01100.00session seed+110open
W02100.00flat carries +1+120open
W0399.90downtick-121open
W04100.00uptick+131close at h1=2.5h_1=2.5

Equality closes, and the crossing trade belongs wholly to the closing bar. Here the dominant count is 3, so overshoot is 3 - 2.5 = 0.5 ticks.

Only now do expectations update. The observed length is four and the observed positive fraction is 3/43/4. Expected length remains 4; expected positive probability becomes

(10.5)×0.625+0.5×0.75=0.6875.(1-0.5)\times0.625+0.5\times0.75=0.6875.

The second threshold is 4 × 0.6875 = 2.75. W05 has the same price as W04. Because price and sign carry across a bar boundary, W05 receives +1. W06 is a downtick, W07 is flat and carries -1, and W08 is another downtick. The second bar ends with (N+,N)=(1,3)(N^+,N^-)=(1,3) and overshoot 0.25.

Eight-trade worked example with frozen thresholds, updates, and cross-bar flat carry

The machine-readable arithmetic is in worked-example.json.

The causal processing order

Rendering system map…

Freezing matters. If the current bar updated its own expected length or side probability before closure, later observations could move the decision boundary they are being compared with. The package instead uses only state available from earlier complete bars.

Session-end and stream-end partials are different from complete bars. They may be emitted for conservation and audit, but they do not update expectations. Set closePartial=false only when intentionally dropping valid tails.

Build for auditability

The output includes standard OHLCV and lineage plus the actual decision state:

  • positive and negative counts;
  • dominant count and side;
  • frozen threshold;
  • fractional overshoot;
  • expected length and positive probability frozen at bar start;
  • thresholdMet, isComplete, and the close reason;
  • first and last contributing trade IDs.

These fields answer “why did this bar close?” without reconstructing hidden state. They also help catch accidental implementations of imbalance or streak logic.

The Python and TypeScript implementations share a 240-trade, two-session oracle. It produces 14 bars with exact cross-language equality and conserves all 240 trades when partials are enabled. Tests also cover alternating signs, equality, flat carry across a bar, session reset, a multiplier below one, partial policy, invalid configuration, ordering ties, duplicate IDs, mixed currencies, nonfinite values, and reappearing sessions.

Passing those tests answers a narrow but essential question: the implementations match this specification. It does not answer whether this clock improves any trading or forecasting system.

Explore the state instead of trusting the final candle

Open the Tick-Run Bars guided lab. Its initial state already contains several observations. You can choose:

  • Canonical two-close for the eight-trade arithmetic;
  • Alternating is still a run to contrast cumulative count with consecutive streak;
  • Flat carry across a bar to inspect state continuity;
  • Session reset to see all seeds restart.

Back and Step move exactly one trade. Play uses the same transition. Pause stops it. Reset deterministically restores the selected scenario. The threshold multiplier recomputes the entire prefix, while the final floor remains visible. Reduced-motion preference turns Play into a single step.

Production risks to address upstream

Bar construction cannot repair a poorly defined tape. Before aggregation, document:

  • correction, cancellation, and duplicate handling;
  • sale-condition eligibility;
  • event-time versus publication- or receive-time semantics;
  • equal-timestamp sequence ordering;
  • session/calendar boundaries;
  • symbol and currency identity;
  • algorithm and parameter versions.

The current NYSE Daily TAQ specification v4.3, dated 2026-03-03, demonstrates why sequence, price, share volume, sale condition, correction indicator, and trade identity are first-class data fields. FINRA reporting guidance demonstrates that execution-time, price, quantity, corrections, and reporting context have specific meanings. Neither source turns this package’s teaching configuration into a market standard.

Why there is no named historical company example

A historical Tick-Run Bar needs transaction-level membership evidence. Daily OHLCV or an EOD provider aggregate cannot prove each tick sign, the adaptive state before each bar, or which exact trade crossed a frozen threshold. The required tape may also have contractual redistribution limits.

Therefore this article does not attach a company and date to synthetic arithmetic. A future case should be added only with a sequence-aware, publishable transaction tape and documented correction, eligibility, timestamp, and session rules. Financial Modeling Prep aggregate data are not used because they cannot support that claim.

Summary

Tick-Run Bars close when the larger cumulative positive or negative tick-rule count reaches a frozen, adaptive threshold. Remember the decisive distinctions:

  1. a run count is not a consecutive streak;
  2. a run count is not net imbalance;
  3. flats carry the prior sign;
  4. price/sign continue across bars but reset across sessions;
  5. only complete bars update expectations;
  6. equality closes and the crossing trade stays whole;
  7. mechanics do not establish prediction or profitability.

Next, use OHLC Consistency Validator to check the invariants of any generated bar stream.

Primary references

The methodology, tick-test caveat, current feed specification, reporting guidance, and evidence limitations are documented in REFERENCES.md. The canonical README remains the factual source for code, tests, and derivatives.

Causal construction flow — Tick-Run Bars

Purpose: separate session reset, sign assignment, dominant-count accumulation, and the post-close expectation update.

Rendering system map…

Takeaway: the open bar never changes its own threshold. Session and stream partials do not update the expectation state.

References4 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.2

  • 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.2, “Tick Runs 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: Positive/negative tick counts and the expected-length times dominant-side-probability stopping idea; adaptive expectation estimates based on earlier state.
  • Limitations: The publisher page confirms the edition; the book section is required for the derivation. It does not make this package’s session seed/reset, one-step EMA coefficients, final floor, multiplier, or partial-tail rules universal.

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://onlinelibrary.wiley.com/doi/abs/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 Tick-Run Bars. A tick-rule 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: Ordering by symbol, time, and message sequence; trades-file fields including SIP publication time, exchange, symbol, sale condition, share volume, price, correction indicator, sequence number, and trade ID; contractual access requirements.
  • Limitations: The specification describes a licensed data product, not the bar algorithm. It neither supplies a universal eligibility filter nor grants 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 meaning and precision, price, quantity, corrections, odd lots, and reporting context must be handled before aggregation.
  • Limitations: Facility-specific reporting rules are not a universal trade-eligibility policy and do not define Tick-Run Bars.

Evidence classification

Claim typeEvidence
Named Tick-Run Bar stopping ideaR01
Tick-test rule and inference caveatR02
Market-data fields, ordering, and access constraintsR03
Why reporting/correction policy is upstreamR04
Seeds, EMA coefficients, final floor, multiplier, resets, whole-trade assignment, and tailsPackage choices, tested here
Worked arithmeticPackage derivation in examples/worked-example.json
Predictive or profitable behaviorNot claimed and not evidenced

Data, licensing, and historical-example decision

All distributed observations are deterministic synthetic records under the repository’s project license. No Financial Modeling Prep response, NYSE transaction tape, proprietary venue record, issuer event, or investor record is redistributed.

A named company/date case is intentionally omitted. Daily OHLCV or EOD provider data cannot establish transaction-by-transaction tick signs, the evolving expected state, or the exact observation that crossed a frozen threshold. A defensible historical example needs a sequence-aware tape with publication rights plus documented correction, cancellation, sale-condition, timestamp, and session rules. Until that evidence exists, attaching a historical label would create false precision.

FMP is not used for this topic because its aggregate endpoints cannot prove bar membership.

bars.ts
/** Causal Tick-Run 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;
  initialBuyProbability: number; alphaTicks: number; alphaBuyProbability: number;
  thresholdFloorTicks: 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";
  buyTicks: number; sellTicks: number; dominantCount: number;
  dominantSide: "buy" | "sell" | "tie"; thresholdTicks: number;
  overshootTicks: number; thresholdMet: boolean; isComplete: boolean;
  frozenExpectedTicks: number; frozenBuyProbability: 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 probability = finite(config.initialBuyProbability, "initialBuyProbability");
  if (probability < 0 || probability > 1) throw new Error("initialBuyProbability must be in [0, 1]");
  for (const [name, value] of [
    ["alphaTicks", config.alphaTicks], ["alphaBuyProbability", config.alphaBuyProbability],
  ] as const) {
    const alpha = finite(value, name);
    if (alpha <= 0 || alpha > 1) throw new Error(`${name} must be in (0, 1]`);
  }
  positive(config.thresholdFloorTicks, "thresholdFloorTicks");
  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;
    positive(trade.price, "price"); positive(trade.volume, "volume");
    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 expectedBuyProbability = config.initialBuyProbability;
  let buyTicks = 0; let sellTicks = 0; let threshold = 0;
  let frozenExpectedTicks = 0; let frozenBuyProbability = 0;

  const beginBar = (): void => {
    current = []; buyTicks = 0; sellTicks = 0;
    frozenExpectedTicks = expectedTicks; frozenBuyProbability = expectedBuyProbability;
    threshold = Math.max(
      config.thresholdFloorTicks,
      config.thresholdMultiplier * frozenExpectedTicks
        * Math.max(frozenBuyProbability, 1 - frozenBuyProbability),
    );
  };
  const resetSessionState = (): void => {
    previousPrice = null; tickSign = config.initialTickSign;
    expectedTicks = config.initialExpectedTicks;
    expectedBuyProbability = config.initialBuyProbability;
  };
  const emit = (reason: Bar["closeReason"]): void => {
    if (!current.length) return;
    const prices = current.map(trade => trade.price);
    const volumes = current.map(trade => trade.volume);
    const dominantCount = Math.max(buyTicks, sellTicks);
    const dominantSide = buyTicks > sellTicks ? "buy" : sellTicks > buyTicks ? "sell" : "tie";
    const isComplete = reason === "threshold";
    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,
      buyTicks, sellTicks, dominantCount, dominantSide,
      thresholdTicks: rounded(threshold),
      overshootTicks: isComplete ? rounded(Math.max(0, dominantCount - threshold)) : 0,
      thresholdMet: isComplete, isComplete,
      frozenExpectedTicks: rounded(frozenExpectedTicks),
      frozenBuyProbability: rounded(frozenBuyProbability),
    });
    if (isComplete) {
      const observedTicks = current.length;
      const observedBuyProbability = buyTicks / observedTicks;
      expectedTicks = (1 - config.alphaTicks) * expectedTicks + config.alphaTicks * observedTicks;
      expectedBuyProbability = (1 - config.alphaBuyProbability) * expectedBuyProbability
        + config.alphaBuyProbability * observedBuyProbability;
    }
    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);
    buyTicks += Number(tickSign === 1); sellTicks += Number(tickSign === -1);
    if (Math.max(buyTicks, sellTicks) >= threshold) emit("threshold");
  }
  if (current.length && config.closePartial) emit("stream_end");
  return result;
}
Full-height labplaygroundOpen full screen