D01-F01-A01 / Complete engineering topic

Time Bars

A production-minded guide to Time Bars.

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

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

Turn an irregular, finalized trade tape into clock-aligned OHLCV bars without confusing the first trade with the interval start, crossing a session, or hiding empty time.

Time Bars overview showing trades grouped by clock boundaries

A candle is easy to draw and surprisingly easy to define badly. “One-minute bar” does not answer which timestamp field drives membership, where the minute grid starts, what happens exactly at 09:31:00, how auctions and halts are treated, or whether a late correction may rewrite history.

Time Bars solve one narrow problem: assign each eligible trade to a fixed session-relative interval, then aggregate the members. They do not clean the feed, discover sessions, or predict price.

The interval comes before OHLCV

For trade time t, session anchor a_s, and interval length Δ, define

k(t)=tasΔ,Is,k=[as+kΔ, as+(k+1)Δ).k(t)=\left\lfloor\frac{t-a_s}{\Delta}\right\rfloor, \qquad I_{s,k}=[a_s+k\Delta,\ a_s+(k+1)\Delta).

The left bracket includes the start. The right parenthesis excludes the end. A trade exactly at the right boundary belongs to the next interval.

Only after membership is fixed do we calculate

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

D is the sum of each unrounded trade price times quantity. It is not close price times total volume.

A four-trade example that exposes three edge cases

Use a synthetic anchor of 14:30:00.000Z and a 60-second interval.

TradeTimePriceVolumeInterval
W114:30:00.000100100
W214:30:59.99910150
W314:31:00.0009981
W414:33:15.00010223

W1 and W2 form interval 0. Its OHLC is 100/101/100/101, volume is 15, and dollar value is 1,505. W3 begins interval 1 because it lies exactly on interval 0's excluded end. Interval 2 contains no trade, so this package omits it. W4 lands in interval 3 and reports one omitted interval before it.

Exact Time Bar boundary, empty interval, and session-reset semantics

The final bar is emitted from a finite input only when closePartial=true. Its stream_end label describes why buffered records were returned; it is not evidence that a live interval was complete.

Do not label the first trade as the interval start

Suppose interval 1 is [14:31:00, 14:32:00) but its first trade arrives at 14:31:01. These are two different facts:

  • startTime = 14:31:00.000Z is the clock boundary;
  • firstTradeTime = 14:31:01.000Z is the first contributing event.

Collapsing them makes bars appear irregular and breaks joins to a canonical clock grid. The repaired output contract preserves both, along with intervalIndex, lastTradeTime, trade IDs, closure reason, and skipped-empty count.

The source timestamp must be named precisely

“Timestamp” is not a portable definition. A source may expose execution time, matching-engine publication time, SIP dissemination time, vendor receipt time, or a later corrected value. The current NYSE Daily TAQ specification, for example, describes its exchange Timestamp 1 as the matching-engine publication timestamp and documents nanosecond field precision and historical timestamp changes (NYSE Daily TAQ Client Specification v4.3, §§1.5.4–1.5.5).

This package's field is explicitly the selected execution-event time in UTC, at millisecond precision. A production adapter must prove that mapping. If it uses publication time instead, that is another valid dataset—but it is not silently the same dataset.

Exact timestamp ties need a source sequence. Otherwise “open” and “close” can depend on whichever file row happened to appear first.

Sessions are calendar data, not UTC dates

The reference takes an explicit UTC anchor for every session key. It never infers a session from midnight, and it never assumes that one fixed UTC clock works through daylight-saving changes or early closes.

NYSE currently publishes its Tape A core session as 9:30 a.m.–4:00 p.m. ET and identifies core open and closing auctions separately (NYSE Trading Information). NYSE's auction material also distinguishes open, close, and trading-halt auctions and notes that a listed security may open after 9:30 a.m. (NYSE Auctions). Those facts illustrate why a versioned venue calendar and phase policy belong upstream; they are not a universal market schedule.

Before building production bars, decide:

  • whether auction prints join the continuous session or use separate phase keys;
  • whether early, core, and late sessions share a grid;
  • what calendar version produced each UTC anchor;
  • whether a halt leaves empty wall-clock buckets or changes the clock.

This package keeps the wall clock running through a halt. With emptyBarPolicy=omit, halted intervals produce no fabricated OHLC. The first eligible resumption trade stays in its actual interval and records the skipped count.

Late reports and corrections need a version policy

Rule 613's timestamp and clock-synchronization requirements demonstrate that event identity and time provenance are foundational to market audit data (SEC Rule 613 overview). They do not select a bar timestamp or resolve a correction for us.

The reference constructor consumes a finalized batch. Corrections, cancels, and sale-condition filtering must already be resolved. A live pipeline must instead choose a policy such as:

  1. wait for an event-time watermark;
  2. publish provisional, versioned bars that can be retracted and replaced;
  3. quarantine observations later than a declared tolerance.

Inserting a late trade into historical bars without recording the revision creates look-ahead for anyone replaying the earlier view. Ignoring it creates a different error. The safe response is explicit versioning, not an undocumented compromise.

The causal construction loop

Rendering system map…

Takeaway: the clock selects membership; the trade updates OHLCV only after that decision.

Timestamped trades mapping into clock intervals before OHLCV aggregation

Both reference implementations make one linear pass. They reject non-finite values, malformed UTC timestamps, duplicate IDs, decreasing event time, ambiguous ties, mixed symbols or currencies, missing anchors, and reappearing sessions.

Use the playground to inspect membership

Open the Time Bars playground. Its 240 observations are deterministic and synthetic. The initial state shows the complete canonical result, so the chart is informative before interaction. Then:

  1. choose both sessions or one session;
  2. change the interval length;
  3. reset, step, or play from the first trade;
  4. compare exact interval bounds with first and last trade times;
  5. inspect closure reasons and skipped empty intervals.

Changing the interval recomputes membership from observation one. It is an educational comparison, not parameter optimization.

Tests that matter

The shared 240-trade fixture produces 18 nonempty bars at 60 seconds. Python and TypeScript must reproduce the same stored objects. Additional tests target the failure modes a visually plausible chart can hide:

  • a trade at 59.999 stays in the current interval and one at 60.000 moves;
  • an interval start is not replaced by its first trade time;
  • empty intervals are omitted but counted;
  • NaN and infinity are rejected;
  • equal timestamps require increasing source sequence;
  • no output crosses a session key;
  • dropping the finite tail is deliberate and testable.

Passing these tests proves conformance to this definition. It says nothing about whether a 60-second parameter is economically useful.

Why the package remains synthetic

A historical session could be valuable only if it includes licensed trade-level timestamps, conditions, corrections, a versioned venue calendar, and a reproducible field mapping. An end-of-day provider bar is already aggregated, so it cannot establish which trades formed it. Tick-data redistribution rights also vary by source.

For this revision, synthetic data are the stronger educational evidence: every record can be published, every boundary can be checked, and no vendor observation is presented as a universal market fact. A future historical case should supplement—not replace—this fixture after independent evidence and licensing review.

Practical checklist

  • Name the selected timestamp and retain receive or availability time separately.
  • Generate anchors from a versioned venue calendar.
  • State auction, halt, sale-condition, correction, empty-bar, and tail policies.
  • Preserve interval boundaries and actual trade-time lineage as separate fields.
  • Require a source sequence for exact time ties.
  • Use watermarks or revisions for live data.
  • Treat OHLCV as lossy and construction correctness as distinct from trading usefulness.

Summary and next topic

A trustworthy Time Bar is a governed interval-membership decision, not merely a candle shape. You can now calculate its boundaries, implement its output contract, and identify the upstream policies that change the result.

Continue with Tick Bars, where elapsed time stops controlling membership and eligible trade count becomes the stopping rule.

Causal construction flow — Time Bars

Purpose: show that session and interval membership are decided before a trade changes OHLCV.

Rendering system map…

Takeaway: the clock selects membership; accumulation happens afterward, and no interval crosses a session key.

References4 primary sources and evidence notes

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

R01 — 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: United States listed equities; CTA and UTP historical products
  • Supports: Timestamp field format and precision; historical changes in timestamp coverage; the meaning of exchange Timestamp 1; trade-field and condition provenance.
  • Limitations: NYSE product definitions do not prescribe this package's timestamp selection, interval grid, or eligibility policy. Access and redistribution remain subject to source terms.

R02 — Trading Information

  • Organization or authors: New York Stock Exchange
  • Source type: Official venue schedule and trading-calendar page
  • Publication or effective date: Living page
  • Version: Page accessed 2026-07-22
  • URL or DOI: https://www.nyse.com/trade/trading-information
  • Accessed: 2026-07-22
  • Jurisdiction: NYSE Group venues and products
  • Supports: Venue-specific early, core, and late trading sessions; currently published NYSE Tape A core hours; separately identified opening and closing auction times; calendar links.
  • Limitations: Hours differ by venue, tape, product, date, holiday, and rule version. The package's UTC anchors are synthetic and are not derived from this page.

R03 — Auctions

  • Organization or authors: New York Stock Exchange
  • Source type: Official venue auction overview
  • Publication or effective date: Living page
  • Version: Page accessed 2026-07-22
  • URL or DOI: https://www.nyse.com/trade/auctions
  • Accessed: 2026-07-22
  • Jurisdiction: NYSE Group equity venues
  • Supports: Distinct opening, closing, and trading-halt auctions; venue-specific timelines; the fact that the NYSE DMM opening may occur after 9:30 a.m.
  • Limitations: The overview directs readers to venue Rule 7.35 for governing details. It does not determine whether a downstream bar dataset should include an auction print in a continuous-session bar.

R04 — Rule 613: Consolidated Audit Trail

  • Organization or authors: U.S. Securities and Exchange Commission
  • Source type: Official regulator overview of adopted rule
  • Publication or effective date: 2012 rule; page updated as a living overview
  • Version: Page accessed 2026-07-22
  • URL or DOI: https://www.sec.gov/about/divisions-offices/division-trading-markets/rule-613-consolidated-audit-trail
  • Accessed: 2026-07-22
  • Jurisdiction: United States National Market System securities
  • Supports: Reportable event lineage, timestamp granularity, and synchronized business-clock requirements as evidence that event-time provenance is operationally material.
  • Limitations: CAT requirements do not define this package's bar timestamp field, correction policy, session calendar, or aggregation convention.

Evidence and data decision

All distributed observations are deterministic synthetic records under the repository's project license. They imitate field shapes and irregular activity, not a venue, security, historical session, or investor. No FMP or other provider data are embedded.

A named historical example was considered and deferred. End-of-day provider bars cannot verify trade-to-interval membership because they are already aggregated. A defensible later case requires licensed trade-level records, source-field documentation, sale-condition and correction treatment, a versioned exchange calendar, and an archived calculation. Correct construction does not establish predictive or economic value.

bars.ts
/** Reference implementation of session-anchored, left-closed Time Bars. */
export type Trade = {
  tradeId: string;
  timestamp: string;
  session: string;
  symbol: string;
  price: number;
  volume: number;
  currency: string;
  sequence?: number;
};

export type Config = {
  intervalSeconds: number;
  sessionStarts: Record<string, string>;
  closePartial?: boolean;
  emptyBarPolicy?: "omit";
};

export type Bar = {
  barIndex: number;
  session: string;
  intervalIndex: number;
  startTime: string;
  endTime: string;
  firstTradeTime: string;
  lastTradeTime: string;
  open: number;
  high: number;
  low: number;
  close: number;
  volume: number;
  dollarValue: number;
  tickCount: number;
  firstTradeId: string;
  lastTradeId: string;
  emptyIntervalsBefore: number;
  closeReason: "interval" | "session_end" | "stream_end";
};

const UTC_MILLISECOND = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;

function milliseconds(value: unknown): number {
  if (typeof value !== "string" || !UTC_MILLISECOND.test(value)) {
    throw new Error("timestamp must use YYYY-MM-DDTHH:mm:ss.sssZ");
  }
  const parsed = Date.parse(value);
  if (!Number.isFinite(parsed)) throw new Error("timestamp must be a valid UTC instant");
  return parsed;
}

function positive(value: unknown, name: string): number {
  if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
    throw new Error(`${name} must be a finite positive number`);
  }
  return value;
}

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

function validate(trades: unknown, config: Config): asserts trades is Trade[] {
  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");
  positive(config.intervalSeconds, "intervalSeconds");
  if (!config.sessionStarts || typeof config.sessionStarts !== "object" || !Object.keys(config.sessionStarts).length) {
    throw new Error("sessionStarts must be a non-empty mapping");
  }
  for (const [session, anchor] of Object.entries(config.sessionStarts)) {
    if (!session || typeof anchor !== "string") throw new Error("sessionStarts contains an invalid entry");
    milliseconds(anchor);
  }
  if ((config.emptyBarPolicy ?? "omit") !== "omit") {
    throw new Error("this reference implementation supports emptyBarPolicy='omit' only");
  }
  if (config.closePartial !== undefined && typeof config.closePartial !== "boolean") {
    throw new Error("closePartial must be boolean");
  }

  const required = ["tradeId", "timestamp", "session", "symbol", "price", "volume", "currency"] as const;
  const ids = new Set<string>();
  const completedSessions = new Set<string>();
  let activeSession: string | null = null;
  let previousTime = -Infinity;
  let previousSequence: number | undefined;
  let symbol: string | undefined;
  let currency: string | undefined;

  for (const raw of trades) {
    if (!raw || typeof raw !== "object") throw new Error("trade must be an object");
    const trade = raw as Record<string, unknown>;
    if (required.some((field) => !(field in trade))) throw new Error("trade is missing a required field");
    for (const field of ["tradeId", "timestamp", "session", "symbol", "currency"] as const) {
      if (typeof trade[field] !== "string" || !trade[field]) throw new Error(`${field} must be a non-empty string`);
    }
    const now = milliseconds(trade.timestamp);
    if (now < previousTime) throw new Error("trades must be globally ordered by execution event time");
    if (now === previousTime) {
      if (!Number.isInteger(trade.sequence) || previousSequence === undefined || Number(trade.sequence) <= previousSequence) {
        throw new Error("equal timestamps require a strictly increasing integer sequence");
      }
    } else if (trade.sequence !== undefined && !Number.isInteger(trade.sequence)) {
      throw new Error("sequence must be an integer when supplied");
    }
    previousTime = now;
    previousSequence = Number.isInteger(trade.sequence) ? Number(trade.sequence) : undefined;

    const id = trade.tradeId as string;
    if (ids.has(id)) throw new Error("tradeId must be unique after corrections and cancels are resolved");
    ids.add(id);
    positive(trade.price, "price");
    positive(trade.volume, "volume");

    const nextSession = trade.session as string;
    if (activeSession !== nextSession) {
      if (activeSession !== null) completedSessions.add(activeSession);
      if (completedSessions.has(nextSession)) throw new Error("a session cannot reappear after a later session begins");
      activeSession = nextSession;
    }
    if (!(activeSession in config.sessionStarts)) throw new Error("sessionStarts must contain every session");
    if (now < milliseconds(config.sessionStarts[activeSession])) throw new Error("trade precedes its declared session anchor");

    symbol ??= trade.symbol as string;
    currency ??= trade.currency as string;
    if (trade.symbol !== symbol || trade.currency !== currency) {
      throw new Error("one constructBars call must contain one symbol and one currency");
    }
  }
}

export function constructBars(trades: Trade[], config: Config): Bar[] {
  validate(trades, config);
  if (!trades.length) return [];

  const bars: Bar[] = [];
  let current: Trade[] = [];
  let session: string | null = null;
  let bucket: number | null = null;
  let previousEmittedSession: string | null = null;
  let previousEmittedBucket: number | null = null;

  const emit = (reason: Bar["closeReason"]): void => {
    if (!current.length || session === null || bucket === null) return;
    const intervalMs = config.intervalSeconds * 1000;
    const startMs = milliseconds(config.sessionStarts[session]) + bucket * intervalMs;
    const prices = current.map((trade) => trade.price);
    const volumes = current.map((trade) => trade.volume);
    const emptyIntervalsBefore = previousEmittedSession === session && previousEmittedBucket !== null
      ? Math.max(0, bucket - previousEmittedBucket - 1)
      : bucket;
    bars.push({
      barIndex: bars.length,
      session,
      intervalIndex: bucket,
      startTime: new Date(startMs).toISOString(),
      endTime: new Date(startMs + intervalMs).toISOString(),
      firstTradeTime: current[0].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,
      emptyIntervalsBefore,
      closeReason: reason,
    });
    previousEmittedSession = session;
    previousEmittedBucket = bucket;
    current = [];
  };

  for (const trade of trades) {
    if (session !== null && trade.session !== session) {
      if (current.length && config.closePartial !== false) emit("session_end");
      else current = [];
      session = trade.session;
      bucket = null;
    } else if (session === null) {
      session = trade.session;
    }
    const offset = milliseconds(trade.timestamp) - milliseconds(config.sessionStarts[session]);
    const nextBucket = Math.floor(offset / (config.intervalSeconds * 1000));
    if (bucket !== null && nextBucket !== bucket) emit("interval");
    bucket = nextBucket;
    current.push(trade);
  }
  if (current.length && config.closePartial !== false) emit("stream_end");
  return bars;
}
Full-height labplaygroundOpen full screen