Normalized event schema (v1)
Status: implemented for book_diff, trade, and top_of_book
(astra-normalize). funding and liquidation remain reserved table names
only. The spec below is the format the normalizer targets and the replay engine
builds against — one decided format instead of three invented ones.
Why this document comes before code
Every downstream claim depends on this schema: deterministic replay needs byte-identical normalized output, the audit trail needs provenance, and research needs queryable columns. Designing it now forces the decisions while they are still cheap. Changing it later means migrating stored data.
Sources
The schema is derived from what the recorder already captures, not imagined:
CaptureRecord:seq, instrument (venue + market type + symbol), channel,ts_socket, optionalts_exchange, rawpayload,CaptureFlags- Binance
depthUpdate:U/uupdate ids,b/alevels, event timeE - Bybit
orderbook:uversion,b/alevels,typesnapshot/delta - Bybit
publicTrade,allLiquidation; Coinbasematches,ticker GapMarker: synthetic records marking holes with reasons
Anything the current capture cannot produce is marked as such below rather than specified wishfully.
Tables
One table per channel family. A single unified table would force nullable columns for everything and make every query pay for every channel's shape.
book_diff
One row per venue book-update event.
| Column | Type | Meaning |
|---|---|---|
venue |
string | binance, bybit (more when connected) |
market_type |
string | spot, perp_usdt |
symbol |
string | canonical BASE/QUOTE (BTC/USDT, BTC/USD) |
ts_exchange |
int64 ns, nullable | venue event time when the payload carries one (E on Binance depth) |
ts_socket |
int64 ns | local read time, always present |
ts_ready |
int64 ns | when the normalized row was produced — not captured yet |
first_update_id |
uint64, nullable | Binance U; Bybit u |
last_update_id |
uint64, nullable | Binance u; Bybit u |
bids |
list of (price, qty) fixed decimal | levels touched by this event; null on synthetic gap rows and unparseable frames (same skip rule) |
asks |
list of (price, qty) fixed decimal | levels touched by this event; null on synthetic gap rows and unparseable frames (same skip rule) |
capture_id |
string | source capture |
seq |
uint64 | position in the source capture |
flags |
uint32 | capture quality bits |
synthetic |
bool | true for gap markers (which carry no levels) |
gap_reason |
string, nullable | marker reason (venue_close, read_error: …, update_id_gap: …) |
gap_attempts |
uint32, nullable | reconnect attempts (0 for sequence gaps) |
gap_started |
int64 ns, nullable | last good frame before the hole |
gap_ended |
int64 ns, nullable | first frame after the hole |
trade
One row per venue trade print. Aggregated messages (Bybit publicTrade
bundles) expand to one row per trade, distinguished by print_index —
decided because a bundle is a transport artifact, not a market event.
An empty bundle (zero prints) yields zero rows: no events, nothing to
placeholder — unlike an unparseable frame, which keeps a null row so seq
accounting survives.
| Column | Type | Meaning |
|---|---|---|
venue, market_type, symbol |
strings | as above |
ts_exchange |
int64 ns, nullable | trade time when carried and parseable (T millis; Coinbase RFC3339, nulled when unparseable) |
ts_socket |
int64 ns | local read time |
ts_ready |
int64 ns | not captured yet |
trade_id |
string, nullable | venue trade id as sent (numbers stringified, strings preserved) |
price, quantity |
fixed decimal, nullable | null on synthetic gap rows and on unparseable frames (null rows replay as counted skips, never as events — see replay_normalized) |
side |
string, nullable | canonical Buy/Sell; derived from the maker flag on Binance, passed through on Bybit, case-normalized on Coinbase |
print_index |
uint32 | 0-based position within the source message; always 0 except expanded bundles |
capture_id, seq, flags, synthetic |
as above | |
gap_reason, gap_attempts, gap_started, gap_ended |
nullable | as in book_diff; gap markers arrive per channel |
top_of_book
One row per top-of-book update (Binance bookTicker, Coinbase ticker).
| Column | Type | Meaning |
|---|---|---|
venue, market_type, symbol |
strings | as above |
ts_exchange, ts_socket, ts_ready |
int64 ns | ts_ready not captured yet |
best_bid, best_bid_qty |
fixed decimal, nullable | null on synthetic gap rows and unparseable frames (same skip rule) |
best_ask, best_ask_qty |
fixed decimal, nullable | null on synthetic gap rows and unparseable frames (same skip rule) |
capture_id, seq, flags, synthetic |
as above | |
gap_reason, gap_attempts, gap_started, gap_ended |
nullable | as in book_diff; gap markers arrive per channel |
funding and liquidation
Specified only as reserved table names. No parser exists, and Binance
funding/liquidation are not live-verified (geo-blocked). Columns will be
defined from the first real payloads, not from documentation.
Fixed rules
- Prices and quantities are fixed-point decimals (scale 10^8, matching
Fixed). No floats in any table, for the same reason as in code. - Timestamps are int64 nanoseconds since epoch. The three-clock system
(
exchange,socket,ready) is what makes latency measurable later. - Raw payloads are not stored in normalized tables. The raw chunks remain the source of truth; normalization must be re-derivable from them or it is not a normalization.
- Synthetic gap markers become rows with
synthetic = trueand null market fields, so a replay over normalized data sees the same holes as a replay over raw data.
Partitioning and files
dataset/
└── venue=binance/
└── market_type=spot/
└── symbol=BTC_USDT/
└── channel=book_diff/
└── date=2026-09-30/
├── part-00000.parquet
└── ...
Hive-style partitions (venue, market_type, symbol, channel, date).
Symbols use _ instead of / because / is a path separator. One writer per
partition per day; files are immutable once closed, mirroring chunk semantics.
Since streaming, each touched chunk appends one row group per partition file —
so the same records under different chunkings share file layout and logical
rows but package row groups differently (read-back equality, proven by test).
Rerunning into the same output directory does not overwrite: the writer
numbers the next part-NNNNN.parquet after existing files, so a rerun
duplicates every row. Idempotency comes from fresh output directories, not
from the writer — the cross-machine procedure already mandates them.
Validation rules
Every normalized batch must satisfy these before it is accepted:
timestamps within [capture start, capture end + tolerance] [deferred: tolerance undecided]
prices > 0 where present [enforced]
quantities >= 0 where present [enforced]
update ids continuous within the batch unless a synthetic gap row intervenes [not enforced: continuity is the capture layer's job, checked there]
no duplicate (capture_id, seq, print_index) triples [enforced]
schema version recorded in file metadata, equal to the writer's version [enforced]
A batch that fails validation is rejected whole, never partially accepted.
Open questions (undecided, deliberately)
- The tolerance on timestamp validation (clock skew between venue and local).
- Whether
ts_readybelongs in the table or in sidecar metadata. - Compression codec and row-group sizing (measure on real data first).
Decided since v1 (was open, now closed)
- Deterministic normalization: byte-identical files for the same capture twice (CI-enforced), read-back equality across chunkings, no wall-clock reads, fixed-point only. Designed with the replay engine, as required.
What this document is not
Not a migration plan, not a query engine, not a promise of Parquet files by any date. It is the format that future code must target, so that when the normalizer is built, its output is already specified.