Every financial transaction begins as a single event — a buyer and a seller agreed on a price at a specific millisecond. This event is called a tick. It contains the raw truth of the market: who traded, what instrument, at what price, and how much volume changed hands.
But that raw truth is unwieldy. A single day of NVIDIA (NVDA) trading generates millions of individual ticks. Analyzing that data point-by-point is computationally expensive and cognitively overwhelming. The solution is aggregation: compressing millions of discrete events into a smaller set of standardized summary bars — the OHLC (Open-High-Low-Close) candles that every chart displays.
The process sounds simple. It is not.
The way you define the boundaries of a bar, the rule you use to determine the open, and the logic that resolves conflicts between ticks all introduce subtle biases that compound into measurable strategy differences. Two traders using the same raw tick feed can produce different backtest results — not because of a coding error, but because their aggregation rules diverge at the boundaries.
This article dissects the tick-to-candlestick compression pipeline: what happens at each stage, where the assumptions live, and how to implement production-grade aggregation that aligns with your analytical intent.
The Anatomy of a Tick
Before aggregation makes sense, the primitive unit must be clear. A tick is not just a price. It is a tuple with several fields that interact in non-obvious ways.
The canonical tick structure includes:
| Field | Description | Significance for aggregation |
|---|---|---|
timestamp |
When the trade occurred (UTC, milliseconds) | Determines which bar a tick belongs to |
price |
Execution price of the trade | Source for all OHLC components |
volume |
Number of shares/contracts traded | Determines which price "wins" at close |
side |
Buyer-initiated (buy) or seller-initiated (sell) | Affects order flow metrics, not OHLC directly |
conditions |
Exchange-specific modifiers (odd lot, trade-through exempt, etc.) | May require filtering before aggregation |
The timestamp is the most consequential field for aggregation. Markets do not agree on time. The New York Stock Exchange operates in Eastern Time. Crypto markets operate in UTC. A trade at 9:30:00.000 AM ET and a trade at 14:30:00.000 UTC occurred at the same moment — but a naive timestamp comparison treats them as 5 hours apart.
Before aggregation begins, all timestamps must be normalized to a single timezone and precision standard. TickDB normalizes timestamps to UTC milliseconds in all API responses. This eliminates the most common source of bar misalignment in multi-venue backtesting.
The Four Aggregation Rules That Matter
The phrase "compress ticks into K-lines" hides a decision tree with four distinct branches. Each rule produces different OHLC values for the same tick sequence.
Rule 1: Time-Based Aggregation (The Standard)
The most common rule. Bars are defined by fixed time intervals anchored to a wall-clock boundary.
Bar 1: 09:30:00.000 → 09:31:00.000
Bar 2: 09:31:00.000 → 09:32:00.000
Bar 3: 09:32:00.000 → 09:33:00.000
The open of each bar is the price of the first tick within the interval. The close is the price of the last tick. The high is the maximum price observed; the low is the minimum.
The boundary problem: A tick occurring at exactly 09:31:00.000 belongs to Bar 2 in the standard definition. But a tick at 09:30:59.999 belongs to Bar 1. Depending on your precision, a tick that visually "belongs" to one bar may be placed in another. This creates bar boundary drift over time.
The NYSE alignment: NYSE-listed stocks begin trading at 9:30 AM ET. But the official opening auction occurs at that time, producing a single "opening trade" with special conditions. A time-based aggregation rule that treats the auction print as the open of the first 1-minute bar is correct. One that treats it as a regular tick may misalign all subsequent bars.
Rule 2: Volume-Based Aggregation (Dollar Bar)
Instead of fixed time intervals, bars are defined by fixed volume or dollar notional. When the cumulative volume within a bar reaches a threshold, the bar closes and a new one begins.
Bar 1: Starts at 09:30:00. Closes when cumulative volume = $1,000,000
Bar 2: Starts when Bar 1 closes. Closes at next $1,000,000
The advantage: Bars contain roughly equal information content. High-activity periods produce more bars; low-activity periods produce fewer. This is particularly valuable for mean-reversion strategies that are sensitive to sampling density.
The complication: Bar boundaries are unpredictable. A strategy that relies on "enter at the open of the next bar" cannot know when that bar will open until it happens. This creates execution ambiguity.
TickDB note: The trades endpoint provides tick-level data. Volume-based aggregation requires client-side accumulation of the volume field across a subscribed feed. This is feasible with WebSocket streaming but requires local state management.
Rule 3: Tick-Based Aggregation (Tick Bar)
Each bar contains exactly N ticks. When N ticks have been received, the bar closes.
Bar 1: Ticks 1–100
Bar 2: Ticks 101–200
Bar 3: Ticks 201–300
The advantage: Similar to volume bars — bars represent equal information units rather than equal time. Tick bars are particularly common in crypto, where trade frequency is high and consistent.
The complication: Tick frequency is not stationary. During a flash crash, tick frequency surges. Tick bars compress naturally during high-volatility events, but the bar composition is dominated by noise trades rather than price-forming events. This can mask the very signal you are trying to capture.
Rule 4: Tick-Imbalance Aggregation (Information-Driven)
A more sophisticated rule from market microstructure theory. Bars are defined by the accumulated signed volume (order flow imbalance):
Bar 1: Starts when imbalance exceeds threshold θ
Closes when imbalance reverses past −θ
The theory: Price discovery occurs when there is sustained order flow imbalance. Tick-imbalance bars capture periods of genuine information arrival rather than arbitrary time or volume slices.
The complication: The threshold θ is a hyperparameter. Too low → too many bars (noise). Too high → too few bars (missed signals). Calibration requires domain knowledge and out-of-sample validation.
Where the Open Price Comes From
The open price of a bar is defined as the price of the first tick within the interval. This sounds trivial. It is not.
The Look-Ahead Bias Problem
If your data feed delivers ticks with latency (even 10 milliseconds), the "first tick" you receive for a new bar may not be the true first tick. You are receiving ticks in a different order than they occurred.
A conservative solution: wait for a synchronization signal before declaring a bar open. In practice, most systems use the first tick received as the open — accepting that look-ahead bias exists in historical data but is negligible for real-time use.
The Opening Auction
US equities conduct an opening auction from 9:30 AM ET (continuous trading opening). The opening auction can span several seconds and produces a single auction trade that represents the clearing price for all orders submitted before the open.
Critical rule: The open price of the first 1-minute bar should be the opening auction price — not the first regular-way trade. If your aggregation logic treats the auction print as just another tick, you will misplace the open by up to 30 seconds.
The Last-Trade vs. Settlement Price
For some instruments (futures, crypto), there is a distinction between the last trade price and the settlement price. The settlement price is calculated by the exchange at the end of a session and is used for marking positions. The last trade price is the actual last execution.
Aggregation rule for futures: Use the settlement price as the close of the final bar of the session. Use the last trade price for all other bars. Mixing these creates artificial gaps at session boundaries.
The Close Price: Why It Matters More Than You Think
The close of a bar is not merely a data point. In many strategies, it is the price at which positions are entered and exited. The close is disproportionately important in bar-based strategies.
Last-Tick vs. VWAP Close
Two legitimate definitions of close:
| Definition | Calculation | Use case |
|---|---|---|
| Last-tick close | Price of the final tick in the interval | Momentum strategies, breakout systems |
| VWAP close | Volume-weighted average price of the interval | Execution benchmarks, mean-reversion |
A momentum strategy that enters on bar close and exits on the next bar close is sensitive to which close definition you use. A last-tick close may jump erratically at low-volume bars (a single large trade determines the close). A VWAP close smooths this but obscures the true marginal price.
TickDB's kline endpoint returns last-tick close. If you require VWAP, you must calculate it client-side from the trades feed.
After-Hours Bars
US equity continuous trading runs from 9:30 AM to 4:00 PM ET. After-hours trading (4:00 PM to 8:00 PM ET) produces ticks that your aggregation logic must handle.
Standard approach: Aggregate after-hours ticks into their own bars, separate from regular-session bars. Do not mix sessions. A bar that spans 3:59 PM to 4:01 PM crosses the session boundary and conflates two distinct liquidity regimes.
TickDB approach: The /v1/market/kline endpoint returns bars segmented by session when period is set to a session-based interval (e.g., "1d" returns daily bars aligned to US equity sessions). For intraday analysis, aggregate from the trades endpoint filtered by timestamp within session hours.
Production-Grade Tick Aggregation in Python
The following implementation handles the most common aggregation scenario: time-based 1-minute bars from a WebSocket tick feed, with proper timestamp normalization, boundary alignment, and reconnection logic.
import os
import json
import time
import random
import asyncio
import requests
from datetime import datetime, timezone
from collections import defaultdict
from dataclasses import dataclass, field
@dataclass
class Candle:
"""Represents a single OHLC bar."""
timestamp: int # Open time in UTC milliseconds
open: float = 0.0
high: float = float('-inf')
low: float = float('inf')
close: float = 0.0
volume: int = 0
tick_count: int = 0
def to_dict(self):
return {
"timestamp": self.timestamp,
"open": self.open,
"high": self.high if self.high != float('-inf') else 0.0,
"low": self.low if self.low != float('inf') else 0.0,
"close": self.close,
"volume": self.volume,
"tick_count": self.tick_count,
}
class TickAggregator:
"""
Aggregates tick data into OHLC bars.
Supports time-based bar formation with configurable interval
and proper handling of bar boundary alignment.
"""
def __init__(self, interval_seconds: int = 60, timezone_offset: int = -5):
"""
Args:
interval_seconds: Bar interval in seconds (60 = 1-minute bars)
timezone_offset: Hours offset from UTC for session alignment
-5 for US Eastern (standard time)
-4 for US Eastern (daylight saving time)
"""
self.interval_ms = interval_seconds * 1000
self.timezone_offset = timezone_offset
self.current_bar: dict[str, Candle] = {}
self.bar_buffer: list[dict] = []
def _get_bar_timestamp(self, tick_ts_ms: int) -> int:
"""
Align tick timestamp to the nearest bar boundary.
This is the critical alignment function. It truncates the
millisecond timestamp to the bar interval boundary.
"""
return (tick_ts_ms // self.interval_ms) * self.interval_ms
def ingest_tick(self, symbol: str, tick: dict) -> list[dict] | None:
"""
Process a single tick and update the current bar.
Returns completed bars if any were closed by this tick.
"""
tick_ts_ms = tick["timestamp"]
tick_price = float(tick["price"])
tick_volume = int(tick["volume"])
# Align to bar boundary
bar_ts = self._get_bar_timestamp(tick_ts_ms)
# Initialize new bar if this tick opens a new interval
if symbol not in self.current_bar or self.current_bar[symbol].timestamp != bar_ts:
# Close the previous bar and emit it
completed_bars = []
if symbol in self.current_bar:
closed_bar = self.current_bar[symbol]
if closed_bar.tick_count > 0:
completed_bars.append(closed_bar.to_dict())
self.current_bar[symbol] = Candle(timestamp=bar_ts)
# Update bar fields
bar = self.current_bar[symbol]
if bar.tick_count == 0:
bar.open = tick_price
bar.high = max(bar.high, tick_price)
bar.low = min(bar.low, tick_price)
bar.close = tick_price
bar.volume += tick_volume
bar.tick_count += 1
return completed_bars if completed_bars else None
# ─────────────────────────────────────────────────────────────────────────────
# WebSocket consumer with production-grade reliability
# ─────────────────────────────────────────────────────────────────────────────
class TickDBWebSocketConsumer:
"""
Connects to TickDB WebSocket and streams tick data through an aggregator.
Implements:
- Exponential backoff with jitter on reconnect
- Rate-limit handling (3001 errors)
- Heartbeat ping/pong keepalive
- Environment-variable-based authentication
"""
RECONNECT_BASE_DELAY = 1.0 # seconds
RECONNECT_MAX_DELAY = 32.0 # seconds
MAX_RETRY_ATTEMPTS = 10
PING_INTERVAL = 20.0 # seconds
def __init__(self, api_key: str, symbols: list[str], aggregator: TickAggregator):
self.api_key = api_key
self.symbols = symbols
self.aggregator = aggregator
self.ws = None
self.retry_count = 0
self.last_ping = 0
self._running = False
def _get_websocket_url(self) -> str:
"""
Build authenticated WebSocket URL.
Note: TickDB WebSocket authentication uses URL parameter ?api_key=,
NOT header-based authentication.
"""
host = "wss://api.tickdb.ai/ws"
symbol_list = ",".join(self.symbols)
return f"{host}?api_key={self.api_key}&symbols={symbol_list}&channel=trades"
async def connect(self):
"""Establish WebSocket connection with retry logic."""
import websockets
self._running = True
retry_delay = self.RECONNECT_BASE_DELAY
while self._running and self.retry_count < self.MAX_RETRY_ATTEMPTS:
try:
url = self._get_websocket_url()
async with websockets.connect(url) as ws:
self.ws = ws
self.retry_count = 0 # Reset on successful connection
print(f"[TickDB] Connected to WebSocket stream for {self.symbols}")
await self._receive_loop()
except websockets.exceptions.ConnectionClosed as e:
self._handle_disconnect(e, "Connection closed")
retry_delay = self._compute_backoff(retry_delay)
except Exception as e:
self._handle_disconnect(e, "Unexpected error")
retry_delay = self._compute_backoff(retry_delay)
if self.retry_count >= self.MAX_RETRY_ATTEMPTS:
print(f"[TickDB] Max retry attempts reached. Giving up.")
raise RuntimeError("TickDB WebSocket connection failed after maximum retries.")
def _compute_backoff(self, current_delay: float) -> float:
"""Exponential backoff with full jitter (RFC 8293)."""
cap = min(current_delay * 2, self.RECONNECT_MAX_DELAY)
# Full jitter: random value in [0, cap]
jitter = random.uniform(0, cap)
sleep_time = jitter
print(f"[TickDB] Reconnecting in {sleep_time:.2f}s (attempt {self.retry_count + 1})")
time.sleep(sleep_time)
self.retry_count += 1
return cap
def _handle_disconnect(self, error: Exception, context: str):
"""Log disconnect and prepare for reconnect."""
print(f"[TickDB] {context}: {error}")
self.ws = None
async def _receive_loop(self):
"""Main message loop with heartbeat management."""
import websockets
import asyncio
while self._running:
try:
message = await asyncio.wait_for(
self.ws.recv(),
timeout=self.PING_INTERVAL + 5
)
data = json.loads(message)
# Handle pong response
if data.get("type") == "pong":
continue
# Process tick data
if "data" in data:
completed_bars = []
for tick in data["data"]:
result = self.aggregator.ingest_tick(tick["symbol"], tick)
if result:
completed_bars.extend(result)
if completed_bars:
for bar in completed_bars:
print(f"[Agg] Closed bar: {bar['timestamp']} | "
f"O:{bar['open']:.2f} H:{bar['high']:.2f} "
f"L:{bar['low']:.2f} C:{bar['close']:.2f} "
f"V:{bar['volume']}")
# Send heartbeat ping
current_time = time.time()
if current_time - self.last_ping > self.PING_INTERVAL:
await self.ws.send(json.dumps({"cmd": "ping"}))
self.last_ping = current_time
except asyncio.TimeoutError:
# Heartbeat timeout — connection may be dead
print("[TickDB] Heartbeat timeout. Reconnecting...")
raise websockets.exceptions.ConnectionClosed(1006, "Heartbeat timeout")
async def disconnect(self):
"""Graceful shutdown."""
self._running = False
if self.ws:
await self.ws.close()
# ─────────────────────────────────────────────────────────────────────────────
# REST fallback: fetch historical klines directly from TickDB
# ─────────────────────────────────────────────────────────────────────────────
def fetch_historical_klines(
symbol: str,
interval: str = "1m",
limit: int = 500,
api_key: str | None = None
) -> list[dict]:
"""
Fetch pre-aggregated OHLCV bars from TickDB REST API.
This is the preferred approach for historical backtesting.
TickDB provides 10+ years of cleaned, session-aligned US equity OHLCV data.
Args:
symbol: Exchange symbol, e.g. "NVDA.US"
interval: Bar interval ("1m", "5m", "1h", "1d")
limit: Maximum number of bars to return (max 1000)
api_key: TickDB API key. Falls back to TICKDB_API_KEY env var.
Returns:
List of OHLCV dictionaries.
"""
api_key = api_key or os.environ.get("TICKDB_API_KEY")
if not api_key:
raise ValueError(
"API key not provided and TICKDB_API_KEY env var is not set. "
"Get your free key at https://tickdb.ai"
)
url = "https://api.tickdb.ai/v1/market/kline"
headers = {"X-API-Key": api_key}
params = {
"symbol": symbol,
"interval": interval,
"limit": min(limit, 1000),
}
response = requests.get(
url,
headers=headers,
params=params,
timeout=(3.05, 10) # (connect timeout, read timeout)
)
if response.status_code != 200:
raise RuntimeError(f"HTTP {response.status_code}: {response.text}")
result = response.json()
if result.get("code") != 0:
raise RuntimeError(f"TickDB error {result.get('code')}: {result.get('message')}")
return result.get("data", [])
# ─────────────────────────────────────────────────────────────────────────────
# Example usage
# ─────────────────────────────────────────────────────────────────────────────
async def main():
# Fetch pre-aggregated historical data for backtesting
print("=== Fetching historical 1-minute bars for NVDA ===")
bars = fetch_historical_klines("NVDA.US", interval="1m", limit=100)
for bar in bars[-5:]:
ts = datetime.fromtimestamp(bar["timestamp"] / 1000, tz=timezone.utc)
print(f"{ts.strftime('%Y-%m-%d %H:%M:%S')} | "
f"O:{bar['open']:.2f} H:{bar['high']:.2f} "
f"L:{bar['low']:.2f} C:{bar['close']:.2f} V:{bar['volume']}")
# Initialize real-time aggregator
api_key = os.environ.get("TICKDB_API_KEY")
if api_key:
aggregator = TickAggregator(interval_seconds=60)
consumer = TickDBWebSocketConsumer(
api_key=api_key,
symbols=["NVDA.US"],
aggregator=aggregator
)
await consumer.connect()
else:
print("\nNote: Set TICKDB_API_KEY to enable live WebSocket streaming.")
print("Sign up free at https://tickdb.ai")
if __name__ == "__main__":
asyncio.run(main())
⚠️ Engineering notes:
- The
TickAggregator._get_bar_timestamp()function uses integer truncation to align to bar boundaries. This is deterministic and consistent with exchange time conventions. If your strategy is sensitive to bar boundary alignment, validate this function against the exchange's official bar definitions. - The WebSocket consumer uses
asyncioandwebsockets. For synchronous environments, replace withthreadingandwebsocket-client. - For high-frequency tick aggregation (>100 ticks/second), move the aggregation logic to a separate thread or use
asyncio.to_thread()to avoid blocking the receive loop.
Derived Metrics from Aggregated Bars
Once bars are constructed, you can compute derived indicators. Three are most relevant to microstructure analysis:
| Metric | Formula | What it signals |
|---|---|---|
| Buy/Sell Pressure Ratio | High − Close vs. Close − Low | Whether buying or selling dominated the bar |
| Range Ratio | (High − Low) / Close | Volatility within the bar |
| Close Position | (Close − Low) / (High − Low) | Where the bar closed relative to its range |
The close position metric is particularly useful for mean-reversion signals. A bar that closes near its low (close position near 0) followed by a bar that opens below it creates a potential long entry — the previous bar's low acts as a dynamic support level.
def compute_close_position(bar: dict) -> float:
"""Position of close within the bar range. 0 = low, 1 = high."""
if bar["high"] == bar["low"]:
return 0.5 # Flat bar — undefined, default to midpoint
return (bar["close"] - bar["low"]) / (bar["high"] - bar["low"])
def compute_buy_sell_pressure(bar: dict) -> dict:
"""
Decompose bar range into buyer-dominated and seller-dominated portions.
Buyer pressure = High − Close (the "headroom" buyers could not push through)
Seller pressure = Close − Low (the "drop" sellers could not reverse)
"""
buyer_pressure = bar["high"] - bar["close"]
seller_pressure = bar["close"] - bar["low"]
total_range = bar["high"] - bar["low"]
if total_range == 0:
return {"buyer_ratio": 0.5, "seller_ratio": 0.5}
return {
"buyer_ratio": buyer_pressure / total_range,
"seller_ratio": seller_pressure / total_range,
"imbalance": (buyer_pressure - seller_pressure) / total_range,
}
Comparing Aggregation Approaches: Which Should You Use?
The choice of aggregation rule is not arbitrary. It reflects a hypothesis about what constitutes a meaningful market event.
| Scenario | Recommended aggregation | Rationale |
|---|---|---|
| Breakout momentum strategy | Time-based (1m, 5m, 1h) | Predictable bar boundaries for entry timing |
| Mean-reversion on equity | Time-based (1m, 5m) | Consistent sampling for statistical tests |
| High-frequency crypto strategy | Tick-based or volume-based | Captures true event density in 24/7 markets |
| Volume-profile analysis | Volume-based | Equal information per bar; reveals volume nodes |
| Order flow imbalance detection | Tick-imbalance | Captures information-driven price discovery events |
| Futures calendar spread | Time-based (session-aligned) | Session boundaries define the trading day |
For most equity strategies, time-based aggregation with 1-minute or 5-minute bars is the correct starting point. It is predictable, benchmarkable, and aligns with how market participants reference price data.
TickDB's OHLCV Data: What Is Pre-Aggregated for You
The code above shows how to build aggregation from scratch. In practice, TickDB provides pre-aggregated OHLCV data that handles the edge cases automatically.
TickDB's /v1/market/kline endpoint returns:
- Open, High, Low, Close — calculated from the last-trade convention
- Volume — cumulative traded volume within the session-aligned bar
- Timestamp — aligned to exchange session boundaries (US equity: 9:30 AM ET session start)
- Historical depth: 10+ years for US equities, with data cleaned and aligned across venues
What is not included in pre-aggregated klines:
- VWAP (must be calculated client-side from
tradesfeed) - Tick-imbalance bars (requires custom aggregation logic)
- Opening auction prints separately from regular-session bars (auction prints are included in the first session bar)
For backtesting, pre-aggregated klines are the correct starting point. For live trading signals that require custom aggregation rules, the trades WebSocket stream provides the raw tick feed needed to implement volume bars, tick bars, or imbalance bars.
Closing
The path from tick to candlestick is paved with assumptions. Every aggregation rule encodes a theory about what constitutes a meaningful market event — and every theory introduces its own bias.
Time-based bars assume that market events are evenly distributed across clock time. Volume bars assume that information content scales with activity. Tick-imbalance bars assume that sustained directional order flow is the fundamental unit of price discovery.
None of these assumptions is universally correct. The right aggregation rule is the one that matches your strategy's theory of how prices move.
If your strategy profits from short-term dislocations — the gap between what the market is doing right now and what it should be doing — then you need the raw tick feed, not the pre-aggregated bar. The trades WebSocket stream from TickDB gives you the granularity to implement your own aggregation logic with full control over boundary conditions and close definitions.
If your strategy operates on higher timeframes and needs clean, session-aligned data across years of history, then the /v1/market/kline endpoint delivers the data in the format your backtesting engine expects — no aggregation logic required.
Choose the data granularity that matches your signal. The compression pipeline is not a neutral transform. It is a design decision that shapes what your strategy can and cannot see.
Next Steps
If you need pre-aggregated OHLCV data for strategy backtesting, the /v1/market/kline endpoint provides 10+ years of session-aligned bars for US equities and other markets. Sign up at tickdb.ai for a free API key — no credit card required.
If you need raw tick data for custom aggregation logic, the trades WebSocket channel streams every individual trade in real time. This is the correct feed for implementing volume bars, tick-imbalance bars, or venue-specific auction handling.
If you are building a real-time signal system, the code in this article provides a production-ready aggregation scaffold with heartbeat, reconnection, and rate-limit handling. The TickAggregator class can be extended to implement volume-based or tick-imbalance-based bar formation with minimal changes.
If you use AI coding assistants, search for the tickdb-market-data SKILL on ClawHub to integrate TickDB data access directly into your development workflow.
This article does not constitute investment advice. Market data aggregation methods do not guarantee strategy performance. Backtest results are based on historical data and do not reflect future conditions. Past performance does not guarantee future results.