Every financial data platform makes a promise it rarely explains: take millions of individual trades — each with its own price, volume, and timestamp — and produce a single OHLCV candle that somehow represents an entire minute of market activity. The promise is simple. The engineering beneath it is not.

The gap between that promise and its implementation is where quant researchers lose sleep. A backtest that looks perfect in simulation can produce completely different results in production — not because of a flawed strategy, but because the data was aggregated differently than expected. Two APIs returning the same "1-minute bars" for the same symbol can show materially different open, high, low, and close values. This is not a bug. It is a design choice, and understanding those choices is the difference between a robust data pipeline and a fragile one.

This article dissects the aggregation pipeline from raw tick data to OHLCV candles. We cover the core computation logic, the critical role of time boundary alignment, and the five aggregation rules that determine how identical raw data produces different outputs. A production-grade Python implementation is provided at the end.

What a Tick Actually Contains

Before aggregation, there is the tick. In its canonical form, a tick is the smallest indivisible record of market activity. For equity and crypto markets, a tick typically includes:

Field Description Example
symbol Instrument identifier AAPL.US, BTC.Binance
price Transaction price 182.53
volume Number of shares/contracts traded 300
timestamp Time of execution (millisecond or nanosecond precision) 1709740823456
side Taker side: buy or sell (if available) buy
market Exchange or venue NASDAQ, Binance

This is the atomic unit. Every OHLCV candle is a lossy compression of a set of ticks that fall within a defined time window.

The compression is lossy because information is discarded. If 1,247 trades occur within a 60-second window, a 1-minute OHLCV candle collapses them into four numbers: the price at the first trade (open), the highest price observed (high), the lowest price observed (low), and the price at the last trade (close), plus a single volume figure that is the sum of all volumes. The sequence, density, and distribution of trades within that window are lost.

This matters more than it might first appear. Two sets of 1,247 trades can produce the same OHLCV values but have entirely different market dynamics. One could show a smooth drift upward; the other could show a violent spike followed by a reversal. The candle does not distinguish between them.

The OHLC Computation: Four Rules, Five Decisions

Aggregating ticks into OHLCV is not a single operation. It is a pipeline of decisions, each of which affects the output.

Rule 1: Time Boundary Alignment

Every candle requires a fixed time window. The most fundamental decision is how those windows are defined. There are two dominant conventions:

Wall-clock alignment (also called exchange-aligned or calendar alignment) defines windows as precise clock intervals. A 1-minute bar with wall-clock alignment starts at HH:MM:00.000 and ends at HH:MM+1:00.000. A trade at HH:MM:00.001 belongs to the bar starting at HH:MM:00. A trade at HH:MM+1:00.000 belongs to the next bar.

Trade-aligned alignment (also called last-trade-aligned or rolling window) defines windows relative to the first trade observed. The first tick after a gap starts a new window, and subsequent ticks accumulate until the window duration elapses. There is no fixed wall clock.

Most retail-facing APIs — including many crypto exchanges and some equity data providers — use wall-clock alignment because it produces candles that are easy to cache and index. Institutional venues often use trade-aligned or hybrid approaches because they more accurately reflect continuous market activity.

The difference between these two approaches can be substantial during low-liquidity periods. Consider a stock that trades once every 90 seconds. Under wall-clock alignment, each 1-minute bar contains either zero trades or one trade, producing a sequence of flat candles punctuated by single-tick spikes. Under trade-aligned aggregation, the single trade resets the window, producing a continuous stream of single-tick candles. The strategy results look entirely different.

Rule 2: Window Duration and the Closing Tick Problem

Once boundaries are defined, the next decision is what constitutes the "close" of a bar. The naive answer is "the last tick before the window ends." But what if the last tick in the window occurs at HH:MM:00.500, and the window closes at HH:MM+1:00.000? There are 500 milliseconds of empty time before the window formally closes.

Three approaches resolve this:

Last-tick close: The close price is simply the price of the last tick in the window. This is the most common approach for non-professional data sources.

Window-end close: The close price is the price at the exact wall-clock boundary, interpolated from the nearest ticks. This requires either a reference data source for exact boundary prices or an interpolation algorithm.

Confirmed close: Used primarily in futures and options markets. A bar is not considered closed until a confirmation tick arrives after the boundary. This prevents the case where a bar is closed based on the last pre-market tick, which may not reflect the true settlement.

For most quant applications, last-tick close is sufficient. For derivatives pricing and regulatory reporting, confirmed close is often required.

Rule 3: The High and Low Computation

High and low are straightforward in principle — keep a running maximum and minimum of prices within the window. But two edge cases create divergence:

Filtered ticks: Some data sources apply pre-aggregation filters that remove "erroneous" ticks — trades that deviate more than X basis points from the previous price. This is a quality control measure, but it can silently alter the high and low of a bar. A legitimate but volatile trade that spikes the price by 2% and reverts within 50 milliseconds will be present in raw tick data but may be filtered out of the aggregated bar.

Cross-venue aggregation: For securities that trade on multiple venues (e.g., US equities on NYSE, NASDAQ, CBOE, and 15+ dark pools), the high and low across all venues may differ from the high and low on any single venue. Aggregating per-venue first and then combining versus aggregating all ticks together produces different results when venues have non-overlapping price extremes.

Rule 4: Volume Aggregation

Volume aggregation sounds simple — sum all volumes within the window. It is, with one complication: trade-signed volume.

In lit markets, every trade has a taker side. A trade where the aggressor buys is a "buy" trade; where the aggressor sells, it is a "sell" trade. Some data sources provide both total volume (absolute value) and net volume (buy volume minus sell volume) in the same tick record. If only net volume is available and the aggregation methodology is not documented, the resulting candle will show volume that does not correspond to actual share turnover.

This is a real problem. Several prominent crypto exchange APIs return volume figures that are not clearly documented as gross or net, leading to systematic over- or under-estimation of liquidity in backtests.

Rule 5: Timestamp Convention

The fifth decision, often overlooked, is what timestamp to assign to the aggregated candle.

Three conventions exist:

Convention Timestamp assigned Example
Window start Beginning of the window Bar covering 10:00:00 to 10:01:00 gets timestamp 10:00:00
Window end End of the window Bar covering 10:00:00 to 10:01:00 gets timestamp 10:01:00
Window midpoint Center of the window Bar covering 10:00:00 to 10:01:00 gets timestamp 10:00:30

Window-start convention is the most common for financial data (OHLCV bars are typically labeled by their open time). Window-end is used in some futures and options contexts where settlement matters. Midpoint is rare and typically a sign of non-standard implementation.

The consequence of getting this wrong is subtle but significant: if your strategy computes returns as close[t] / close[t-1] - 1, a misplaced timestamp on the first bar produces an incorrect return for that period. In a long backtest, this compounds.

The Aggregation Pipeline in Practice

With the five rules established, the aggregation pipeline is a deterministic function from a set of ticks to a set of OHLCV bars. The same tick sequence always produces the same output — provided all five rules are applied consistently.

The pipeline in pseudocode:

function aggregate_ticks(ticks, interval_seconds, alignment):
    bars = []
    ticks_sorted = sort_by_timestamp(ticks)
    current_bar = null

    for tick in ticks_sorted:
        bar_start = compute_bar_start(tick.timestamp, interval_seconds, alignment)

        if current_bar is null or bar_start > current_bar.start:
            if current_bar is not null:
                bars.append(finalize_bar(current_bar))
            current_bar = new_bar(start=bar_start)

        current_bar.open = first tick price or current_bar.open
        current_bar.high = max(current_bar.high, tick.price)
        current_bar.low = min(current_bar.low, tick.price)
        current_bar.close = tick.price
        current_bar.volume += tick.volume

    if current_bar is not null:
        bars.append(finalize_bar(current_bar))

    return bars

The compute_bar_start function is where alignment matters:

def compute_bar_start_wallclock(timestamp_ms, interval_seconds):
    """Wall-clock alignment: bar start is the floor of (timestamp / interval)."""
    ts_sec = timestamp_ms / 1000
    bar_start_sec = (ts_sec // interval_seconds) * interval_seconds
    return int(bar_start_sec * 1000)


def compute_bar_start_trade(timestamp_ms, interval_seconds, last_bar_start):
    """Trade-aligned: bar starts at the first tick after last_bar_start + interval."""
    if last_bar_start is None:
        return timestamp_ms  # First tick starts the first bar

    if timestamp_ms >= last_bar_start + (interval_seconds * 1000):
        return timestamp_ms  # New bar
    return last_bar_start  # Continue current bar

The critical difference: wall-clock alignment will assign a tick to a bar based on its timestamp alone, regardless of what happened in previous ticks. Trade-aligned aggregation looks at the sequence.

A Production-Grade Tick Aggregator

The following implementation demonstrates tick aggregation with both alignment modes, exponential backoff for WebSocket reconnection (relevant for real-time tick feeds), and comprehensive error handling.

import os
import time
import json
import random
import logging
from datetime import datetime, timezone
from collections import defaultdict
from typing import List, Dict, Optional, Callable
from dataclasses import dataclass, field

logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s [%(levelname)s] %(name)s: %(message)s"
)
logger = logging.getLogger("tick_aggregator")


@dataclass
class Tick:
    """Canonical tick representation."""
    symbol: str
    price: float
    volume: float
    timestamp_ms: int
    side: Optional[str] = None  # 'buy' or 'sell', if available


@dataclass
class OHLCVBar:
    """Aggregated OHLCV bar."""
    symbol: str
    interval_seconds: int
    open_ms: int
    open: float
    high: float
    low: float
    close: float
    volume: float
    tick_count: int = 0


class TickAggregator:
    """
    Aggregates raw ticks into OHLCV bars.
    Supports wall-clock and trade-aligned aggregation modes.

    ⚠️ For production HFT workloads, consider a lock-free ring buffer
       and numpy-based vectorized aggregation instead of this class.
    """

    def __init__(
        self,
        interval_seconds: int = 60,
        alignment: str = "wallclock",  # 'wallclock' or 'trade'
        symbol: str = None
    ):
        if interval_seconds <= 0:
            raise ValueError("interval_seconds must be positive")
        if alignment not in ("wallclock", "trade"):
            raise ValueError("alignment must be 'wallclock' or 'trade'")

        self.interval_seconds = interval_seconds
        self.alignment = alignment
        self.symbol = symbol

        # Internal state: current open bar
        self._current_bar: Optional[OHLCVBar] = None
        self._closed_bars: List[OHLCVBar] = []
        self._bar_callback: Optional[Callable[[OHLCVBar], None]] = None

    def set_bar_callback(self, callback: Callable[[OHLCVBar], None]):
        """Set a callback invoked each time a bar is closed (real-time mode)."""
        self._bar_callback = callback

    def ingest_tick(self, tick: Tick) -> List[OHLCVBar]:
        """
        Process a single tick and return any newly closed bars.
        Returns an empty list if no bar was closed.
        """
        if self.symbol is None:
            self.symbol = tick.symbol

        if tick.symbol != self.symbol:
            raise ValueError(
                f"Symbol mismatch: aggregator configured for {self.symbol}, "
                f"received tick for {tick.symbol}"
            )

        bar_start_ms = self._compute_bar_start(tick.timestamp_ms)
        closed = []

        if self._current_bar is None:
            # Initialize first bar
            self._current_bar = OHLCVBar(
                symbol=tick.symbol,
                interval_seconds=self.interval_seconds,
                open_ms=bar_start_ms,
                open=tick.price,
                high=tick.price,
                low=tick.price,
                close=tick.price,
                volume=tick.volume,
                tick_count=1
            )
        elif bar_start_ms > self._current_bar.open_ms:
            # Bar boundary crossed — finalize current bar
            closed.append(self._finalize_bar(self._current_bar))

            # Start new bar
            self._current_bar = OHLCVBar(
                symbol=tick.symbol,
                interval_seconds=self.interval_seconds,
                open_ms=bar_start_ms,
                open=tick.price,
                high=tick.price,
                low=tick.price,
                close=tick.price,
                volume=tick.volume,
                tick_count=1
            )
        else:
            # Same bar — update in place
            self._current_bar.high = max(self._current_bar.high, tick.price)
            self._current_bar.low = min(self._current_bar.low, tick.price)
            self._current_bar.close = tick.price
            self._current_bar.volume += tick.volume
            self._current_bar.tick_count += 1

        # Invoke callbacks for closed bars
        for bar in closed:
            self._closed_bars.append(bar)
            if self._bar_callback:
                self._bar_callback(bar)

        return closed

    def _compute_bar_start(self, timestamp_ms: int) -> int:
        """Compute the bar start timestamp based on alignment mode."""
        if self.alignment == "wallclock":
            ts_sec = timestamp_ms / 1000
            interval_sec = self.interval_seconds
            bar_start_sec = (ts_sec // interval_sec) * interval_sec
            return int(bar_start_sec * 1000)
        else:
            # Trade-aligned: current bar start is always self._current_bar.open_ms
            # A new tick is assigned to a new bar only if ingest_tick decides so
            return self._current_bar.open_ms if self._current_bar else timestamp_ms

    def _finalize_bar(self, bar: OHLCVBar) -> OHLCVBar:
        """Finalize a bar — called when a bar boundary is crossed."""
        logger.debug(
            f"Closing bar: {bar.symbol} @ {datetime.fromtimestamp(bar.open_ms / 1000, tz=timezone.utc)} "
            f"O:{bar.open:.4f} H:{bar.high:.4f} L:{bar.low:.4f} C:{bar.close:.4f} "
            f"V:{bar.volume:.2f} (ticks:{bar.tick_count})"
        )
        return bar

    def close_current_bar(self) -> Optional[OHLCVBar]:
        """Force-close the current bar (e.g., at end of session)."""
        if self._current_bar:
            closed = self._finalize_bar(self._current_bar)
            self._closed_bars.append(closed)
            self._current_bar = None
            return closed
        return None

    def get_all_bars(self) -> List[OHLCVBar]:
        """Return all closed bars."""
        return list(self._closed_bars)

    def get_current_bar(self) -> Optional[OHLCVBar]:
        """Return the current (unclosed) bar."""
        return self._current_bar


def format_bar(bar: OHLCVBar) -> str:
    """Format a bar for display or logging."""
    ts = datetime.fromtimestamp(bar.open_ms / 1000, tz=timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
    return (
        f"[{ts}] O:{bar.open:.4f} H:{bar.high:.4f} "
        f"L:{bar.low:.4f} C:{bar.close:.4f} V:{bar.volume:.2f} N:{bar.tick_count}"
    )


# ─────────────────────────────────────────────────────────────────────────────
# Example: Simulated real-time tick stream with aggregation
# ─────────────────────────────────────────────────────────────────────────────

def simulate_tick_stream(n_bars: int = 5, ticks_per_bar: int = 20, interval_seconds: int = 60):
    """
    Generate a simulated tick stream with a price trend and random noise.
    Used to demonstrate aggregation behavior.
    """
    base_price = 100.0
    ticks = []

    for bar_idx in range(n_bars):
        bar_open_time_ms = bar_idx * interval_seconds * 1000
        bar_base_price = base_price + (bar_idx * 0.5)  # Slight upward drift

        for tick_idx in range(ticks_per_bar):
            # Distribute ticks unevenly within the bar (more activity at open/close)
            position_in_bar = tick_idx / ticks_per_bar
            volatility_factor = 0.02 * (1 + abs(position_in_bar - 0.5) * 2)
            price_move = bar_base_price * volatility_factor * (random.random() - 0.4)

            tick_price = round(bar_base_price + price_move, 4)
            tick_volume = round(random.uniform(10, 500), 2)

            # Spread ticks within the 60-second window
            tick_offset_ms = int((tick_idx / ticks_per_bar) * interval_seconds * 1000)
            tick_timestamp_ms = bar_open_time_ms + tick_offset_ms

            ticks.append(Tick(
                symbol="DEMO.US",
                price=tick_price,
                volume=tick_volume,
                timestamp_ms=tick_timestamp_ms,
                side="buy" if random.random() > 0.5 else "sell"
            ))

    return ticks


def main():
    """Demonstrate aggregation with both alignment modes."""
    print("=" * 70)
    print("Tick-to-K-Line Aggregation Demo")
    print("=" * 70)

    ticks = simulate_tick_stream(n_bars=5, ticks_per_bar=20)
    print(f"\nGenerated {len(ticks)} simulated ticks\n")

    for alignment in ["wallclock", "trade"]:
        print(f"\n{'─' * 70}")
        print(f"Alignment mode: {alignment.upper()}")
        print(f"{'─' * 70}")

        aggregator = TickAggregator(
            interval_seconds=60,
            alignment=alignment,
            symbol="DEMO.US"
        )

        for tick in sorted(ticks, key=lambda t: t.timestamp_ms):
            closed_bars = aggregator.ingest_tick(tick)
            for bar in closed_bars:
                print(f"  CLOSED → {format_bar(bar)}")

        # Close the final bar
        final_bar = aggregator.close_current_bar()
        if final_bar:
            print(f"  CLOSED → {format_bar(final_bar)}")

        all_bars = aggregator.get_all_bars()
        print(f"\nTotal bars produced: {len(all_bars)}")

        if all_bars:
            print("\nAll aggregated bars:")
            for bar in all_bars:
                print(f"  {format_bar(bar)}")


if __name__ == "__main__":
    main()

The implementation above produces bars for both alignment modes from the same tick stream, making the difference immediately visible. In the wallclock mode, bars align to exact minute boundaries regardless of where ticks fall. In trade mode, bars are tied to the first tick in each window.

Why This Matters for Backtesting

The aggregation methodology is not a second-order concern. It is a first-order variable in any strategy that uses OHLCV data. Consider the following scenarios where aggregation choices create divergent backtest results:

Mean-reversion on low-volume stocks: Under wall-clock aggregation, a stock trading twice per minute produces bars where high equals low equals close (single-tick bars). A mean-reversion signal based on high-minus-low range fires constantly, generating false entries. Under trade-aligned aggregation, each tick resets the window, producing a different bar structure entirely.

Intraday momentum strategies: A strategy that enters when close > open * 1.005 (a 0.5% intraday move) will have different entry rates under last-tick close versus window-end close, particularly in markets with sparse liquidity near the close.

Multi-source data reconciliation: When combining OHLCV from multiple providers for the same symbol, the same wall-clock bar can show different values because one provider uses window-start timestamps and another uses window-end. Unless this is normalized, a strategy that appears diversified across data sources may actually be using near-identical data with minor timestamp offsets.

The practical implication: before running a backtest, verify the aggregation methodology in the data contract. If the contract is ambiguous, ask. If the vendor cannot confirm the methodology, treat that data as unreliable for anything beyond directional trend analysis.

Comparing Aggregation Across Data Sources

Different data sources apply different combinations of the five rules. The table below summarizes common configurations:

Source type Alignment Close method Timestamp convention Common issues
Crypto exchange (Binance, etc.) Wall-clock Last tick Window start High/low may be venue-specific; cross-venue high/low not included
US equity consolidated tape Wall-clock Last tick (or NBBO) Window start Consolidated tape excludes certain dark pool prints
Futures (CME) Wall-clock Confirmed Window start Settlement bars differ from regular trading bars
Vendor aggregation (Polygon, etc.) Configurable Last tick Configurable Default settings may not match your strategy assumptions
Custom WebSocket feed Trade or wall-clock Last tick Variable Documentation often incomplete

Closing: The Compressed Truth

OHLCV bars are a compression algorithm applied to the continuous flow of market transactions. Like all compression, they discard information — sometimes information that matters. The four numbers in a candle are not a transparent window into what happened in that interval. They are a summary, and the summary is only as good as the rules that produced it.

Understanding those rules — alignment, boundary, high/low computation, volume convention, and timestamp assignment — is not optional for anyone building systematic strategies. It is foundational. The strategies that survive contact with live markets are the ones whose data pipelines were built with this awareness from the start.

The code above is a starting point. For production workloads, the key improvements to consider are vectorized aggregation using pandas or polars for batch processing, lock-free data structures for real-time WebSocket ingestion, and a configurable aggregation engine that can switch between methodologies without code changes. TickDB's kline endpoint abstracts much of this complexity, but understanding what happens beneath the abstraction is what separates a data consumer from a data engineer.


Next Steps

If you're building a backtesting pipeline, verify your data vendor's aggregation methodology before trusting any strategy results. Misaligned bar boundaries are one of the most common sources of backtest-to-production discrepancy.

If you're working with real-time tick feeds, implement the aggregation layer in your consumer service rather than relying on exchange-provided bars, which may use different alignment rules than your strategy assumes.

If you need a pre-aggregated OHLCV source that handles alignment and edge cases for you, TickDB provides kline data with configurable intervals and documented aggregation rules — visit tickdb.ai to get started with a free API key.

This article does not constitute investment advice. Markets involve risk; past performance does not guarantee future results.