When to Use
Use this whenever a quantitative system consumes trade prints from more than one venue
and downstream code must not branch per venue. Exchanges disagree on field names (p
vs price vs last_price), timestamp units (millisecond ints, ISO-8601 strings, naive
datetime objects), symbol conventions (BTCUSDT vs BTC-USD), and — most
dangerously — on what the trade's side field means.
The normalization boundary is where those disagreements get resolved once. Everything
past it sees UnifiedTick and nothing else.
Two properties of this implementation are load-bearing, and both are the opposite of the obvious approach:
UnifiedTick.sideis always the aggressor (taker) side, derived per venue rather than copied. See Common Pitfalls.- Nothing is defaulted. Every unreadable field raises
NormalizationError. A normalizer that substitutesprice=0.0,side=BUY, ortimestamp=now()converts a loud feed failure into a silent data corruption that no downstream consumer can detect.
When NOT to Use
- For quotes, book updates, or bar data.
UnifiedTickmodels a trade print: one price, one size, one aggressor. It has no bid/ask, no level, no sequence number. Useorder-book-depth-processing-l2-l3for book state. - As a sequencing, dedup, or gap-detection layer. This maps one payload to one tick
and holds no cross-message state. Reconnects will re-deliver ticks and this module
will happily normalize each copy. Gap detection belongs in
sequence-number-gap-detection-for-feeds; cross-venue disagreement on the same instrument belongs inmulti-source-price-reconciliation-tie-breaking. - As a clock-correction layer.
exchange_timestampis whatever the venue stamped; it is not adjusted for the venue's clock offset relative to yours. Do not difference timestamps across venues to compare event ordering without first correcting skew — seeclock-skew-correction-for-tick-timestamps. - On Coinbase data that a vendor has already converted to taker convention.
parse_coinbaseinvertssidebecause Coinbase publishes the maker side. If your aggregator already flipped it, the two inversions cancel and every Coinbase side is wrong. Verify which convention your source uses before wiring it up. - As an entitlement or licensing boundary. Normalizing a vendor's data does not
make it redistributable — see
market-data-entitlement-and-licensing-per-venue.
Prerequisites
- A decoded venue payload as a
Mapping(this module does not do transport, framing, or JSON decoding). - A registered symbol mapping per (venue, ticker) pair. Required by default:
strict_symbols=Truerejects unmapped tickers rather than inventing a symbol. - The arrival timestamp captured at socket read, if you queue payloads before parsing.
- A decision on the timezone of naive timestamps (
naive_timestamp_tz). Defaults to UTC;pykiteconnectconsumers wantNone(host local).
Workflow
-
Instantiate the registry with its safety policies chosen deliberately.
strict_symbols,naive_timestamp_tz, andallow_non_positive_priceeach default to the conservative setting. Relax one only with a stated reason — the defaults are what stop a malformed feed from looking healthy. -
Register every (venue, ticker) → canonical symbol pair before the feed starts.
register_symbol_mapping("binance", "BTCUSDT", "BTC/USD"). If a symbol is missing at runtime, the tick raises rather than passing through: an unmapped ticker that flows downstream becomes a second, phantom instrument holding half the volume, and that stays invisible until a position report disagrees with the broker. -
Capture the receipt timestamp where the bytes arrive, not where you parse.
normalize(venue, payload, receipt_timestamp=arrival). If you omit it, the tick is stamped at parse time; when a queue sits between the socket and the parser, every latency figure computed from that tick silently includes the queue depth. -
Dispatch through
normalize(); add venues withregister_parser(). The three built-in parsers are ordinary registered callables, not a hard-coded branch. A new venue is a function of(payload, receipt_timestamp) -> UnifiedTick, not a fork of this module. Declaretimestamp_unitif the venue's epoch scale is known — leave itAUTOto have it inferred from magnitude bands. -
Resolve the aggressor side per venue, never by copying the venue's field. Binance's
m("is the buyer the market maker?") must be inverted; Coinbase'ssideis the maker side and must also be inverted; Zerodha carries no aggressor information at all and yieldsUNKNOWN. Confirm the convention in the venue's own documentation before adding a parser. -
Handle
UNKNOWNexplicitly at the consumer. It is a real, expected value, not a parse failure. Order-flow imbalance must exclude those ticks from the signed sum rather than treat them as either side — every Zerodha tick isUNKNOWN, so silently coercing them toBUYwould manufacture a permanent buying bias. -
Catch
NormalizationErrorat the venue boundary and dead-letter the payload. Do not retry a rejected payload: rejection means the message was unreadable, and re-parsing identical bytes yields an identical rejection. Alert on the rate — a sudden rise in rejections is usually a venue schema change, which is the failure this design is built to make visible.
Full step-by-step procedure with venue-specific detail: see
references/workflows.md. Venue field mapping table for this skill: seereferences/standards.md. Printable pre-flight checklist: seeassets/checklist.md.
Common Pitfalls
-
Copying the venue's
sidefield straight through. This is the defect most likely to survive testing, because each venue looks self-consistent in isolation. Binance documentsmas "Is the buyer the market maker?", som=truemeans the aggressor was the seller. Coinbase documentssideon both the Exchangematcheschannel and the Advanced Trademarket_tradeschannel as the maker order side. Derive Binance's side frommwhile passing Coinbase's through unchanged and the two venues end up on opposite conventions — cross-venue order-flow imbalance is then sign-flipped for one of them, with no error anywhere. -
Reading Zerodha's cumulative volume as a trade size.
pykiteconnect's WebSocket ticker emitslast_traded_quantity(trade size) andvolume_traded(cumulative session volume); the REST quote endpoint names the same two fieldslast_quantityandvolume. Falling back from the trade size to the volume field does not degrade gracefully — it reports the day's running total as a single print, inflating every volume-weighted statistic by orders of magnitude. -
Guessing a timestamp's unit from its magnitude. The common
if ts > 1e11: ts /= 1000rule breaks on the same Binance streams served withtimeUnit=MICROSECOND, yielding timestamps around the year 55839 with no error raised. Infer against disjoint per-unit bands (they sit three decades apart) or declare the unit per venue; reject anything matching no band. -
Treating naive timestamps as UTC by default.
pykiteconnectbuildslast_trade_timewithdatetime.fromtimestamp(epoch), which is naive in the recording host's local zone. Calling.timestamp()on it round-trips correctly only on that host; assuming UTC on a UTC-clocked server shifts every Indian tick by 5h30m. Make the assumed zone an explicit setting. -
Substituting
time.time()when a timestamp will not parse. This is the worst available default. It makesreceipt_timestamp - exchange_timestampcollapse to roughly zero, so a stale or corrupt feed reports perfect latency and every staleness and clock-skew monitor downstream goes blind at exactly the moment it was needed. -
Letting an unmapped symbol pass through verbatim.
BTCUSDTandBTC-USDthen coexist as two unrelated instruments, splitting the consolidated view of one tradable. -
Leaking raw venue fields past the boundary. If any strategy code reads
s,p, orlast_price, the normalization layer is not actually a boundary and per-venue branching has merely moved downstream.
Verification
- Normalize Binance, Coinbase, and Zerodha payloads and confirm every output is a
UnifiedTickwith identical field names and types (test_all_venues_share_one_schema). - Confirm the cross-venue side convention: a resting bid being hit is
SELLon both venues — Binancem=trueand Coinbase makerside="buy"must produce the sameNormalizedSide(test_economically_identical_trades_agree_across_venues). - Confirm seconds, milliseconds, microseconds and nanosecond epochs all resolve to the same instant, and that an out-of-band value raises.
- Confirm a missing price, a
nanprice, a zero quantity, a missing timestamp, and an unmapped symbol each raiseNormalizationErrorrather than producing a tick. - Confirm a naive Kite
datetimeis interpreted in the configured zone, not silently replaced with the current time. - Run
python -m unittest discover -s skills/multi-exchange-feed-normalization/scriptsand confirm all tests pass.