Building your own K-line aggregator fills the gap between REST and WebSocket: the REST candle endpoint hands you finished bars, the live stream hands you millisecond ticks, but the step in between — folding ticks into time-bucketed bars that match your historical schema — is yours to implement. TIQEX exposes REST under https://api.tiqex.io/api/v1/market/* and streaming at wss://api.tiqex.io/api/v1/market/stream, both authenticated with X-API-Key and both wrapped in the same succ / data / msg / code envelope. This tutorial builds a runnable aggregator, then reconciles it against REST candles.
Scope note: what each OHLCV field means belongs to the OHLCV glossary page; this article only covers how to construct them. The live recorder pattern (resume and gap detection) solves persistence and replay. This article solves time bucketing and schema reconciliation. They compose, but neither replaces the other.
When you actually need one
Not every project needs custom bar construction. Three situations make REST candles insufficient.
Backfill windows exceed the single-request cap. The candle endpoint defaults to limit 100 and caps at 1000. Backfilling one trading day of 1-minute bars means 1,440 rows, so you must paginate and stitch. Every request counts against your daily quota and per-minute limit — 30 RPM on the free tier — so aggressive backfill burns quota fast.
Live indicators need an open bar. Historical endpoints return settled windows. A moving average, VWAP, or threshold alert computed only after the window closes loses the entire window's reaction time. An aggregator updates the current bar on every tick.
Cross-market parameters are not uniform. Equity candles take interval values of 1T / 5T / 30T / 1H / 2H / 4H / 1D / 1W / 1M, where T means minutes, while crypto candles follow Binance-style 1m / 1h. Bar fields differ too. An aggregator lets you expose one internal schema to everything downstream.
The bucketing clock
The stream frame carries tick.at as an RFC3339 string; normalise it to UTC first. Buckets must be left-closed, right-open: [start, start + interval). A tick landing exactly on a boundary belongs to the new window, not the old one. Implement the flooring with integer division on the Unix epoch rather than local-time arithmetic, which produces duplicate or missing windows across DST transitions.
Empty windows are the other common mistake. If a window receives no ticks, the correct output is no bar at all — not a bar priced at zero. Emitting zero corrupts every downstream average and percentage change. Cross-market comparisons make this worse, because an empty bar usually signals a holiday rather than a halt.
Only three frame types exist: subscribed, tick, and error (carrying msg and code). Handle the error frame explicitly. Otherwise a single UPSTREAM_UNAVAILABLE is swallowed silently and looks like a stream that simply went quiet.
A minimal aggregator
The implementation below uses only the standard library and runs as-is, driven by synthetic ticks, so it consumes no real quota.
from datetime import datetime, timezone, timedelta
INTERVAL = timedelta(minutes=1)
EPOCH = datetime(1970, 1, 1, tzinfo=timezone.utc)
def bucket_start(ts: datetime) -> datetime:
"""Floor a UTC timestamp to the interval boundary (left-closed)."""
return EPOCH + (ts - EPOCH) // INTERVAL * INTERVAL
def parse_at(raw: str) -> datetime:
"""Normalise an RFC3339 tick timestamp to aware UTC."""
return datetime.fromisoformat(raw.replace("Z", "+00:00"))
class Aggregator:
def __init__(self, symbol: str):
self.symbol = symbol
self.cur = None
self.bars = []
def add(self, tick: dict) -> None:
ts, price = parse_at(tick["at"]), float(tick["last"])
start = bucket_start(ts)
if self.cur is None or start > self.cur["openTime"]:
if self.cur:
self.bars.append(self.cur)
self.cur = {"openTime": start, "open": price, "high": price,
"low": price, "close": price, "volume": 0.0}
self.cur["high"] = max(self.cur["high"], price)
self.cur["low"] = min(self.cur["low"], price)
self.cur["close"] = price
self.cur["volume"] += float(tick.get("volume") or 0)
def flush(self) -> list:
if self.cur:
self.bars.append(self.cur)
self.cur = None
return self.bars
if __name__ == "__main__":
ticks = [
{"at": "2026-09-26T02:00:05Z", "last": 100.0, "volume": 2},
{"at": "2026-09-26T02:00:41Z", "last": 103.5, "volume": 1},
{"at": "2026-09-26T02:02:03Z", "last": 101.0, "volume": 4},
]
agg = Aggregator("BTCUSDT")
for t in ticks:
agg.add(t)
for b in agg.flush():
print(b["openTime"].isoformat(), b["open"], b["high"],
b["low"], b["close"], b["volume"])
It prints two bars, 02:00 and 02:02. The 02:01 minute had no ticks, so no bar appears — the empty-window rule in practice. Padding that window with zero would invent a bar closing at zero.
Late ticks, out-of-order arrival, and the grace period
On a real network, arrival order does not match tick.at order. Multiplexing, reconnect replay, and upstream batching all deliver data for an already-sealed bar. The minimal implementation tests start > self.cur["openTime"], which is fragile: a late tick lands in the current window and corrupts both high and low.
The production pattern is a grace period. Keep the last N windows open for backfill and drop anything older while incrementing a counter. N usually lands between 2 and 5 intervals, driven by your tolerance rather than taste. Expose at least three counters: late_ticks, dropped_ticks, and gap_windows. These three numbers are the only evidence that a stream which looks healthy has quietly lost data.
On cost: the stream handshake counts against daily quota and RPM, while ticks pushed afterwards are not metered per message. Long-lived connections therefore spend far less quota than REST polling. Tier details live on the pricing page.
Reconciling against REST candles
A working aggregator is not a correct one. Before shipping, reconcile it against REST candles to confirm your schema matches the official one. The first obstacle is field mapping, because bar shapes differ by market:
| Semantic | Equity candle bar | Crypto candle bar |
|---|---|---|
| Open time | openTime(Ms) | openTime / openTimeMs |
| OHLC | open / high / low / close | open / high / low / close |
| Volume | volume | volume / quoteVolume |
| Trade count | trades | trades |
| Market-specific | vwap / session | takerBuyVolume / takerBuyQuoteVolume |
The reconciler indexes local bars by open time, compares field by field, and classifies differences into three buckets. Classification matters more than a bare mismatch count, because each bucket points at a different root cause.
from datetime import datetime, timedelta, timezone
INTERVAL = timedelta(minutes=1)
FIELDS = ("open", "high", "low", "close")
def reconcile(local_bars: list, rest_bars: list, tol: float = 1e-6) -> dict:
"""Diff locally aggregated bars against REST kline bars.
missing : REST has the window, we do not (grace period or gap).
extra : we have the window, REST does not (timezone or symbol bug).
mismatch : both have it, a field value differs beyond tolerance.
"""
index = {b["openTime"]: b for b in rest_bars}
report = {"missing": [], "extra": [], "mismatch": []}
for b in local_bars:
key = b["openTime"]
r = index.pop(key, None)
if r is None:
report["extra"].append(key)
continue
for f in FIELDS:
if abs(float(b[f]) - float(r[f])) > tol:
report["mismatch"].append((key, f, b[f], r[f]))
report["missing"] = sorted(index)
return report
if __name__ == "__main__":
t0 = datetime(2026, 9, 26, 2, 0, tzinfo=timezone.utc)
local = [{"openTime": t0, "open": 100.0, "high": 103.5,
"low": 100.0, "close": 103.5}]
rest = [{"openTime": t0, "open": 100.0, "high": 103.5,
"low": 100.0, "close": 103.5},
{"openTime": t0 + INTERVAL, "open": 103.5, "high": 104.0,
"low": 103.0, "close": 103.8}]
print(reconcile(local, rest))
The output reports 02:01 under missing, with extra and mismatch empty. That is the empty-window case: your aggregator correctly produced nothing, while REST history contains the bar. Treat it as an expected difference and whitelist it rather than "fixing" the aggregator.
The mapping from bucket to root cause is stable. missing usually means grace-period drops or an interrupted stream. extra usually means timezone normalisation or symbol mapping is wrong. mismatch usually means adjustment mode or volume-unit divergence. Never compare volume across markets directly — units differ and the number is not comparable. Reconciliation requests also consume daily quota and RPM, so run it once per day offline rather than on every commit.
FAQ
Why does my bar's close differ slightly from REST?
Three candidates: the window has not closed and REST returned an in-progress bar; the grace period dropped the final tick; or the comparison has no tolerance. Use a 1e-6 absolute tolerance and skip unclosed windows.
How many symbols can one WebSocket connection carry?
It scales by plan: free 1 connection / 10 symbols, standard 3 / 25, pro 10 / 50, enterprise 50 / 200. Past the cap the server returns a WS_CONN_LIMIT error frame; shard your subscriptions or fall back to REST snapshots instead of reconnecting in a loop.
Should the aggregator persist every bar?
It depends. A live dashboard only needs a rolling window in memory. Replay, review, and model training need durable storage, and that is where the recorder pattern's resume and gap detection belongs. The two are complementary.
Do pushed ticks count against quota?
No. The billing rule is that handshakes are metered and pushes are not counted per message: the stream handshake counts toward daily quota and RPM, while subsequent ticks are not metered individually. That is why long-lived connections beat polling in production.
The aggregator's value is not the code but the fact that it puts live and historical data on one schema: the former drives indicators, the latter validates them. Wire the reconciler into CI so every schema change is caught by a difference class, then tune the grace period against a stated tolerance instead of a guessed number.
Starting costs nothing. The free tier includes 300 calls per day, delayed crypto, US stocks, and metals, no credit card, and WebSocket streaming — enough to run every line above. Upgrade only when you need all-market realtime and higher quota. Confirm symbols on the crypto market page, follow the WebSocket live quotes guide for connection details, and treat the equity candle API reference as the source of truth for parameters.
This article is a technical implementation note. It is not investment advice and does not forecast any instrument's price. Endpoint parameters, fields, and quotas are cited from TIQEX official documentation and the pricing page, verified 2026-09-26.