D01-F01-A02 / Complete engineering topic

Tick Bars: Build Candles from an Exact Trade Count

A production-minded guide to Tick Bars.

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

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

Practical promise. Build and audit bars that close after exactly N eligible trade records, including deterministic ordering, session resets, and partial-tail handling.

Tick Bars overview showing formed candles and trade counts

Tick bars replace the clock with an event counter. A complete 12-tick bar contains 12 accepted trade records whether those records arrived in one second or several minutes. This can make record count uniform across bars, but it does not make volume, liquidity, volatility, or information uniform.

The central engineering question is simple to state and easy to implement incorrectly:

Has the current session-local bar accumulated exactly the configured number of eligible trade records?

The word eligible carries most of the production risk. A correction counted as a fresh execution, a duplicate record, an undocumented odd-lot filter, or an unstable tie order changes every later boundary.

What counts as one tick?

In this package, one tick means one pre-cleaned execution record accepted by an upstream policy. It does not mean one share or one price change. A 10-share record and a 10,000-share record each add one to the bar count if both are eligible.

Before calling the constructor, the pipeline must have:

  • resolved cancels and corrections according to the source protocol;
  • removed duplicates while retaining an audit trail;
  • applied a documented sale-condition and odd-lot policy;
  • ordered equal timestamps using the source sequence rule;
  • assigned an explicit session key.

This separation is intentional. FINRA's reporting guidance treats execution time, quantity, and cancel/correction reporting as distinct concerns; a generic bar function cannot safely infer all of those rules (FINRA Trade Reporting FAQ). NYSE Daily TAQ likewise exposes timestamp and sequence information and documents historical timestamp-precision changes (NYSE Daily TAQ v4.3, sections 1.5.3–1.5.5).

The exact contract

The constructor receives one symbol and one currency in authoritative array order. Timestamps must be valid UTC strings and globally nondecreasing. Equal timestamps are allowed because the caller has already applied the source sequence rule; the constructor preserves their order and never sorts.

Two configuration fields control membership:

FieldRule
targetTicksRequired positive integer; every complete bar contains exactly this many records.
closePartialDefaults to true; emit non-empty session and stream tails when true, otherwise discard them.

Session keys are explicit. When a key changes, the current bar is emitted as session_end or discarded, then state resets before the first trade of the new session. A session may not disappear and later reappear in the same call.

The output includes OHLC, total quantity, exact reported notional, trade count, session, first/last trade IDs, and one of three closure reasons: threshold, session_end, or stream_end.

Mathematics: equality is the rule

For open bar B_j, count the admitted records:

Tj=iBj1.T_j=\sum_{i\in B_j}1.

Close when:

Tj=N,T_j=N,

where N is the positive integer targetTicks.

There is no threshold overshoot in this algorithm. Every accepted record increases the state by exactly one, so a valid counter reaches N exactly. The record that makes T_j=N closes the current bar; the following record opens the next one.

After membership is fixed:

O=p1,H=maxipi,L=minipi,C=pT,O=p_1,\quad H=\max_i p_i,\quad L=\min_i p_i,\quad C=p_T, V=ivi,D=ipivi.V=\sum_i v_i,\qquad D=\sum_i p_i v_i.

D is summed record by record. It is not reconstructed from a rounded average price.

Work the boundary by hand

Use targetTicks=3 and closePartial=true:

TradePriceSharesCount after admissionResult
E01100.00101Bar 0 remains open
E02101.00202Bar 0 remains open
E0399.00153Bar 0 closes
E04100.00251Bar 1 opens
E05102.00102Bar 1 remains open
E06101.00303Bar 1 closes
E07103.0051Partial bar emitted at stream end

Bar 0 is O/H/L/C = 100/101/99/99, volume is 45, and dollar value is:

100(10)+101(20)+99(15)=4,505.100(10)+101(20)+99(15)=4{,}505.

Bar 1 is 100/102/100/101, volume is 65, and dollar value is 6,550. E07 becomes a one-record stream_end bar with dollar value 515. With closePartial=false, E07 produces no output.

Seven trades mapped to two complete 3-tick bars and one partial tail

The diagram shows the key dependency: decide membership first, then calculate OHLCV.

Causal construction sequence

Rendering system map…

Only the current and earlier records affect a closure. Execution time validates ordering and labels bounds; arrival and availability times are separate dimensions that a live point-in-time system should preserve.

The static boundary visual isolates the three policies most likely to drift:

Exact count equality, session-tail choice, and reset behavior

Notice that a session boundary never carries an unfinished count into the next session.

Implementation walkthrough

The Python implementation and TypeScript implementation follow the same sequence:

  1. Validate targetTicks as a positive integer and closePartial as a boolean.
  2. Validate required fields, finite positive numbers, unique IDs, one instrument/currency, chronological time, and contiguous sessions.
  3. Preserve the supplied order; never sort tied timestamps.
  4. Flush or discard a session tail, then reset.
  5. Append one whole record and close only when count equals the target.
  6. Flush or discard the stream tail.

Both implementations are O(n) in the number of trades. The readable version stores at most one open bar, so working memory is O(N). A streaming production version can retain scalar accumulators while preserving first/last lineage.

Explore the 240-trade lab

Open the Tick Bars interactive playground. Its deterministic synthetic tape contains 240 trades across two sessions—enough to reveal repeated boundaries, activity changes, and session behavior without redistributing licensed transaction data.

Use it in this order:

  1. Inspect the complete 12-trade canonical bar shown on load.
  2. Step backward and forward across its equality boundary.
  3. Change targetTicks and watch all membership recompute from trade one.
  4. Filter to either session and verify the reset.
  5. Run to the end with a target that does not divide 120 and inspect stream_end.

The NYSE specification states that historical Daily TAQ access is licensed and subject to product agreements, so a named historical trade tape is not included here without verified redistribution rights (NYSE Daily TAQ v4.3, preface and section 1). Synthetic data is the safer evidence choice for this mechanical lesson.

Tests that expose policy drift

The shared 240-trade fixture produces 20 complete bars at targetTicks=12: ten per session. Python and TypeScript must match the same stored expected output exactly.

Additional tests cover:

  • the seven-record worked example with partial tails emitted and dropped;
  • integer-only threshold validation;
  • NaN, infinity, nonpositive numbers, and reversed timestamps;
  • duplicates and mixed instruments;
  • stable input order for equal timestamps;
  • rejection of a session that reappears;
  • session-end partial closure and reset.

These checks establish definition-level correctness. They do not establish that 12 is a useful market parameter or that any downstream strategy is profitable.

Common failure modes

FailureWhy it changes resultsSafe response
Count a correction as a new tradeAdds a false tick and shifts every later boundaryResolve corrections before construction; version rebuilt bars.
Sort only by timestampEqual-time records may reorderPreserve the source sequence key.
Filter odd lots silentlyChanges count and durationDocument eligibility and apply it upstream.
Carry a partial bar overnightBlends independent sessionsFlush or drop, then reset.
Accept targetTicks=2.5Python/JavaScript coercion can disagreeRequire a positive integer.
Interpret equal count as equal liquidityQuantity and market conditions remain variableCompare volume, notional, spread, and duration separately.

Tick bars versus nearby clocks

MethodBar closes whenWhat is uniformWhat remains variable
Time barsClock interval endsScheduled durationTrade count and volume
Tick barsAccepted record count reaches NTrade countDuration, volume, and notional
Volume barsReported quantity reaches/crosses targetApproximate accumulated quantityTrade count and duration
Dollar barsReported notional reaches/crosses targetApproximate reported notionalTrade count and duration

Choose the clock whose invariant matches the downstream question. Greater complexity does not imply greater validity.

Summary and next topic

A reliable tick bar is defined as much by its data contract as by its counter. Specify eligibility, preserve source order, close at exact equality, reset by session, and make the partial-tail decision visible. Keep lineage in the output so every candle can be reconstructed.

Continue with Volume Bars, where a crossing trade can overshoot the target and forces a different boundary discussion.

Primary references and source roles

Source versions, jurisdictions, supported claims, and limitations are recorded in REFERENCES.md. The canonical factual and algorithmic contract is README.md.

Causal construction flow — Tick Bars

Purpose: show how one already-cleaned trade advances session-local count and why a later trade cannot change an earlier membership decision.

Rendering system map…

Takeaway: valid fixed-count state reaches the integer target exactly; the equality trade closes the current bar, and only the following trade can open the next one.

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 López de Prado
  • Source type: Author manuscript for a published book chapter
  • 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: Comparison of standard time sampling with activity- and information-driven bar families, including tick bars.
  • Limitations: The source motivates sampling families. This package's exact validation, session, tied-order, and partial-tail policies are explicit implementation choices, not universal defaults.

R02 — NYSE Daily TAQ Client Specifications

  • Organization or authors: New York Stock Exchange, an Intercontinental Exchange company
  • Source type: Official market-data product specification
  • Publication or effective date: 2026-03-03
  • Version: 4.3 (March 3, 2026)
  • URL or DOI: https://www.nyse.com/publicdocs/nyse/data/Daily_TAQ_Client_Spec_v4.3.pdf
  • Accessed: 2026-07-22
  • Jurisdiction: United States listed equities; CTA and UTP SIP-derived Daily TAQ product.
  • Supports: A Daily TAQ record represents one SIP event; trade data has price, volume, sale-condition, timestamp, and sequence fields; sequence conventions differ by tape; timestamp precision changed historically; historical access is licensed.
  • Limitations: Daily TAQ is an end-of-day licensed product and does not prescribe this package's eligibility filter, targetTicks, session partition, or partial-tail policy. The specification is cited for source semantics, not redistributed here.

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: United States FINRA-reportable trades
  • Supports: Execution-time, quantity, odd-lot, cancellation, and correction concerns are source/reporting-policy questions that must be resolved before generic bar construction.
  • Limitations: Rules vary by facility and security, and regulatory reporting guidance is not a tick-bar algorithm.

R04 — MIDAS: Market Information Data Analytics System

  • Organization or authors: U.S. Securities and Exchange Commission
  • Source type: Official regulator market-data overview
  • Publication or effective date: 2024-06-14
  • Version: Last reviewed 2024-06-28
  • URL or DOI: https://www.sec.gov/securities-topics/market-structure-analytics/midas-market-information-data-analytics-system
  • Accessed: 2026-07-22
  • Jurisdiction: United States securities markets
  • Supports: High-resolution order, quote, and execution data is operationally large and requires specialized processing.
  • Limitations: MIDAS coverage does not prescribe trade eligibility, tick-bar thresholds, or predictive usefulness.

Evidence and data decision

The seven-record worked example and 240-record teaching tape are deterministic synthetic data distributed under the repository's project license. They model field shapes, equality, activity changes, session boundaries, and partial tails; they do not represent a real venue, security, investor, or historical episode.

A named historical tape was considered but not used. The official NYSE specification makes clear that Daily TAQ access is licensed and subject to product agreements. No transaction-level dataset with verified public redistribution rights was established during this review. Synthetic data is therefore the appropriate evidence for reproducible mechanics. This decision should be revisited only when both source authenticity and redistribution rights can be documented.

bars.ts
/**
 * Readable reference implementation for fixed-count tick bars.
 *
 * Input order is authoritative. Corrections/cancels, duplicate removal,
 * eligibility filtering, and equal-timestamp source sequencing happen upstream.
 */
export type Trade = {
  tradeId: string;
  timestamp: string;
  session: string;
  symbol: string;
  price: number;
  volume: number;
  currency: string;
};

export type TickBarConfig = {
  targetTicks: number;
  closePartial?: boolean;
};

export type TickBar = {
  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";
};

const REQUIRED_FIELDS: (keyof Trade)[] = [
  "tradeId", "timestamp", "session", "symbol", "price", "volume", "currency",
];

function timestampMs(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 positiveNumber(value: unknown, field: string): number {
  if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
    throw new Error(`${field} must be a finite positive number`);
  }
  return value;
}

function validate(trades: Trade[], config: TickBarConfig): Required<TickBarConfig> {
  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 (!Number.isInteger(config.targetTicks) || config.targetTicks <= 0) {
    throw new Error("targetTicks must be a positive integer");
  }
  const closePartial = config.closePartial ?? true;
  if (typeof closePartial !== "boolean") throw new Error("closePartial must be boolean");

  const ids = new Set<string>();
  const closedSessions = new Set<string>();
  let previousTime = -Infinity;
  let currentSession: string | null = null;
  let symbol: string | null = null;
  let currency: string | null = null;

  for (const trade of trades) {
    if (!trade || typeof trade !== "object" || REQUIRED_FIELDS.some(field => !(field in trade))) {
      throw new Error("trade is missing a required field");
    }
    for (const field of ["tradeId", "session", "symbol", "currency"] as const) {
      if (typeof trade[field] !== "string" || !trade[field].trim()) {
        throw new Error(`${field} must be a non-empty string`);
      }
    }

    const now = timestampMs(trade.timestamp);
    if (now < previousTime) throw new Error("trades must be globally chronological");
    previousTime = now;

    if (ids.has(trade.tradeId)) {
      throw new Error("tradeId must be unique after corrections and cancels");
    }
    ids.add(trade.tradeId);
    positiveNumber(trade.price, "price");
    positiveNumber(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 required per call");
    }

    if (currentSession === null) {
      currentSession = trade.session;
    } else if (trade.session !== currentSession) {
      closedSessions.add(currentSession);
      currentSession = trade.session;
      if (closedSessions.has(currentSession)) {
        throw new Error("each session must occupy one contiguous input block");
      }
    }
  }

  return { targetTicks: config.targetTicks, closePartial };
}

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

export function constructBars(trades: Trade[], config: TickBarConfig): TickBar[] {
  const { targetTicks, closePartial } = validate(trades, config);
  const result: TickBar[] = [];
  let current: Trade[] = [];
  let session: string | null = null;

  const emit = (reason: TickBar["closeReason"]): void => {
    if (current.length === 0) 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((total, value) => total + value, 0)),
      dollarValue: rounded(current.reduce(
        (total, trade) => total + trade.price * trade.volume, 0,
      )),
      tickCount: current.length,
      firstTradeId: current[0].tradeId,
      lastTradeId: current.at(-1)!.tradeId,
      closeReason: reason,
    });
    current = [];
  };

  for (const trade of trades) {
    if (session !== null && trade.session !== session) {
      if (closePartial) emit("session_end");
      else current = [];
    }
    session = trade.session;
    current.push(trade);

    // Each accepted record increments the count by one, so equality is exact.
    if (current.length === targetTicks) emit("threshold");
  }

  if (current.length > 0 && closePartial) emit("stream_end");
  return result;
}
Full-height labplaygroundOpen full screen