Building a Market Data Pipeline That Survives the Day
Websocket ingestion, tick-to-candle aggregation, reconnect handling and the gap-detection layer that stops a strategy trading on data it never received.
A strategy is only as good as the prices it reads. Most retail systems get this backwards: enormous care goes into the signal logic, and the data feeding it is a websocket callback that appends to a list. That works until the socket drops during a volatile minute, at which point the strategy carries on computing indicators over stale numbers and has no idea anything is wrong.
The shape of a market data pipeline
System architecture
Four responsibilities, deliberately kept apart
Separating connection, buffering, aggregation and strategy is what allows aggregation to be tested without a live socket and a session to be replayed offline.
The broker websocket delivers ticks on a network thread and its callback only enqueues onto a bounded queue before returning. A consumer reads the queue and feeds both a tick aggregator that builds bars and flags gaps, and a raw tick store kept for replay and forensics. The connection layer also publishes a feed health state. The strategy loop consumes bars and reads that health state to gate new entries.
The health state being published rather than merely logged is what lets the strategy refuse to trade. A log line cannot be read by the code that needs it.
The useful separation is between the part that talks to the broker, the part that turns a tick stream into bars, and the part that runs strategy logic. Keeping those distinct is what allows you to test aggregation without a live socket and to replay a session offline.
Each boundary in that chain is also a place to record health. The connection layer knows about reconnects, the aggregation layer knows about gaps, and the strategy layer knows whether the bar it just received is trustworthy. Collapsing these into one module is what makes "why did it trade there?" unanswerable.
From tick to candle
Process flow
Aggregation driven by the boundary-crossing tick
A bar is emitted when the first tick of the next interval arrives, not when a wall-clock timer fires. That choice removes a race that is hard to reproduce.
A tick arrives with price, quantity and timestamp. Its interval bucket is computed as the timestamp minus the timestamp modulo the interval. If the bucket matches the open bar, high, low, close and volume are updated. If the bucket is later, the open bar is emitted exactly once. When more than one interval has elapsed the emitted bar is flagged as having a gap. The bar reaches the strategy carrying its tick count and gap flag.
The gap flag is what separates a market with no trades from a feed that was not being read. Without it those two states are indistinguishable downstream.
Aggregation looks trivial and contains most of the subtle bugs in a retail data stack. The specification worth writing down before coding: a bar covers a half-open interval, it is emitted only once, and it is emitted when the first tick of the next interval arrives rather than when a wall-clock timer fires.
data/aggregator.pypython
from dataclasses import dataclass, field@dataclassclass Bar: start: int # epoch seconds, interval start open: float high: float low: float close: float volume: int = 0 tick_count: int = 0 had_gap: bool = Falseclass CandleAggregator: """Emits a bar when the first tick of the next interval arrives.""" def __init__(self, interval_seconds: int): self.interval = interval_seconds self._current: Bar | None = None def _bucket(self, ts: int) -> int: return ts - (ts % self.interval) def on_tick(self, ts: int, price: float, qty: int = 0) -> Bar | None: bucket = self._bucket(ts) current = self._current if current is None: self._current = Bar(bucket, price, price, price, price, qty, 1) return None if bucket == current.start: current.high = max(current.high, price) current.low = min(current.low, price) current.close = price current.volume += qty current.tick_count += 1 return None if bucket < current.start: # Late tick for an already-closed interval. Do not silently fold # it in; that rewrites history a strategy may have acted on. raise OutOfOrderTick(ts, current.start) completed = current # More than one interval elapsed: the feed had no ticks in between. completed.had_gap = bucket > current.start + self.interval self._current = Bar(bucket, price, price, price, price, qty, 1) return completedclass OutOfOrderTick(Exception): pass
Interval-boundary aggregation with provenance. The tick_count and had_gap fields are what let a strategy decide whether to trust the bar.
Two decisions in that code are deliberate and worth defending. Raising on an out-of-order tick rather than absorbing it means a clock or sequencing problem surfaces immediately instead of quietly corrupting bars. And flagging had_gap when more than one interval elapses distinguishes "no trades happened" from "we were not listening" — which is exactly the distinction a strategy needs and almost never has.
What happens when the socket drops
Timeline
The reconnect sequence, and when trading resumes
Entries stop at the second step and do not resume until reconciliation succeeds. Exits stay available throughout.
A watchdog detects silence rather than waiting for a socket close event. The feed is immediately marked degraded and new entries are blocked. Reconnection uses exponential backoff with jitter. Subscriptions are re-established explicitly because they rarely survive a reconnect. Missing data is backfilled and reconciled against existing bars rather than appended. Only after reconciliation succeeds is the feed marked healthy and entries permitted again.
Elapsed times are illustrative. What matters is the ordering: healthy status is the last step, never an optimistic assumption made at reconnect.
Assume the connection will drop several times a session — on broker maintenance, on a network blip, on your own process being slow to read. The question is not whether you handle it but what state the system is in while it is happening.
The critical rule: a disconnect must propagate to the strategy as a state change, not merely be logged. A strategy that does not know the feed is down will keep evaluating conditions against its last known price, and the outcome depends entirely on luck.
The sequence worth implementing
Detect the drop quickly — a heartbeat or an expected-tick-interval watchdog, not just the socket close event, which can be slow or absent.
Mark the feed degraded and publish that state. New entries should be blocked immediately.
Reconnect with exponential backoff and jitter. A tight reconnect loop from many clients is how a broker ends up rate-limiting you.
Re-subscribe explicitly. Do not assume subscriptions survived the reconnect; many implementations drop them.
Backfill the gap from the REST historical endpoint, and reconcile it against your existing bars rather than appending blindly.
Only after reconciliation succeeds, mark the feed healthy and allow entries again.
The reconciliation step is the one most often skipped. Broker historical bars and your locally aggregated bars will not always agree, because they may use different tick filtering or different interval conventions. Discovering that mismatch during a quiet reconnect is much better than discovering it from a strategy that acted on a spliced series.
Detecting staleness before the strategy acts
A price with no timestamp attached is not usable for a trading decision. The cheapest high-value control in the whole pipeline is a freshness assertion immediately before any order is generated.
data/freshness.pypython
import time# Maximum acceptable silence, per instrument class.MAX_SILENCE_SECONDS = { "index_future": 5, "index_option_atm": 10, "index_option_wing": 60, "stock_future": 15,}def is_fresh(last_tick_ts: float, instrument_class: str, now: float | None = None) -> bool: now = time.time() if now is None else now limit = MAX_SILENCE_SECONDS.get(instrument_class, 30) return (now - last_tick_ts) <= limitdef assert_tradeable(book, instrument, instrument_class: str) -> None: if not book.feed_healthy: raise FeedDegraded(instrument) if not is_fresh(book.last_tick_ts(instrument), instrument_class): raise StaleQuote(instrument, book.last_tick_ts(instrument))class FeedDegraded(Exception): passclass StaleQuote(Exception): pass
Thresholds have to be per instrument. An illiquid option strike is legitimately quiet in a way an index future is not.
Per-instrument thresholds matter because a single global number cannot be right. Five seconds of silence on an index future means something is broken; sixty seconds on a far out-of-the-money strike is an ordinary Tuesday. A single threshold either produces constant false alarms on the wings or fails to notice a genuinely dead feed on the liquid legs.
Where the pipeline usually breaks
Decision funnel
What has to survive for a tick to reach a decision
Each stage discards some data legitimately. The failure mode is discarding it silently, so the strategy cannot tell a thin stage from a broken one.
Every tick the exchange feed publishes must first be received while the socket is up, since disconnects and re-subscribe gaps lose some. It must then be read before a bounded queue overflows, because slow consumers drop the oldest entries. It must be in order and within session, as out-of-order and stale timestamps are rejected. It is then folded into a bar carrying a tick count and gap flag, and finally must pass a per-instrument staleness check before a strategy may act on it.
Each narrowing is acceptable if it is counted. A pipeline that cannot report how much it dropped at each stage cannot tell you whether today was normal.
Across retail data stacks the same handful of failures recur, and they are almost never in the interesting part of the code.
Failure
How it presents
Control that catches it
Work done in the socket callback
Unexplained disconnects under load
Queue between ingestion and processing
Subscriptions lost on reconnect
One instrument silently stops updating
Per-instrument freshness watchdog
Timezone or epoch-unit mismatch
Bars offset by hours, or by 1000×
Assert bar timestamps fall inside session hours
Missing gap flag
Indicators computed across a hole in the data
had_gap propagated to the strategy
Unreconciled backfill
A duplicated or double-counted bar
Compare overlap before splicing
No freshness check pre-order
Orders priced off a frozen quote
Freshness assertion in the order path
Recurring failure modes and the control that catches each
A pipeline you can trust on a bad day
The socket callback only enqueues
No indicator maths, no database writes, no order placement on the network thread.
Feed health is a published state, not a log line
The strategy can read it and must check it before entering.
Entries blocked while degraded; exits and cancels still permitted
Verify both halves separately — this is where a single guard gets it wrong.
Bars carry tick count and a gap flag
A strategy can then refuse to act on a bar built from two ticks.
Backfill is reconciled against local bars before use
Compare the overlapping region and alert on mismatch rather than trusting either side.
Session replay from stored ticks reproduces the same bars
If replay does not reproduce live output, one of them is wrong and you cannot yet tell which.
None of this is exotic engineering. It is a queue, a state flag, a timestamp check and a reconciliation step. What it buys is the ability to say, after an unexpected trade, whether the data was sound at that moment — and that answer is the difference between fixing a strategy and guessing at it.
Frequently asked questions
Why should a websocket callback only put ticks on a queue?
Broker clients deliver ticks on a network thread, so doing real work there — computing indicators, writing to a database, placing orders — blocks the socket. When the client stops reading fast enough the broker eventually disconnects it. Enqueue and return, then process on a consumer thread. This alone resolves a large share of otherwise mysterious disconnections.
Should candles be closed by a timer or by the next tick?
By the first tick of the next interval. A wall-clock timer races with tick processing, so a tick can land in the wrong bar when the timer fires first, producing bars that differ subtly from the broker's own only under load. Use a timer solely as a fallback to close a bar in an instrument that has genuinely stopped trading, and mark such bars distinctly.
How should a strategy behave when the market data feed drops?
The disconnect should propagate as a published state change, not just a log entry. New entries are blocked immediately, while exits and cancellations continue to work on last known state, since a degraded feed is exactly when flattening may be necessary. Entries resume only after reconnect, explicit re-subscription and successful reconciliation of backfilled data.
How can a system tell a quiet market from a dead feed?
By storing timestamps and gap information alongside prices, then applying per-instrument silence thresholds. Five seconds without a tick on an index future indicates a problem, whereas sixty seconds on a far out-of-the-money strike is normal. Bars should also carry a tick count and a gap flag so a strategy can refuse to act on a bar built from almost no data.
Why does backfilled data need reconciliation?
Broker historical bars and locally aggregated bars can disagree because of different tick filtering or interval conventions. Appending backfill blindly can duplicate or double-count a bar. Comparing the overlapping region and alerting on mismatch surfaces the discrepancy during a quiet reconnect rather than through a strategy acting on a spliced series.
Is it worth storing raw ticks as well as candles?
Yes. Raw ticks with arrival timestamps allow any interval to be rebuilt, a bug to be reproduced exactly, and the reasonableness of a fill to be assessed after the fact. Candles alone discard the information most needed when something unexpected has happened, and tick storage is inexpensive relative to one unexplainable trading day.
The engineering team builds QuaTick’s order routing, market-data pipeline and broker integrations. These pieces cover the parts of automation that only show up in production.