Measuring and predicting adverse selection on passive fills, built on order book data reconstructed from Kraken.
This repo currently contains the data layer: Kraken WebSocket v2
collectors for L2 (book), L3 (level3, order by order), and trades. It
maintains local books validated against Kraken's CRC32 checksums and
archives the raw messages for replay.
python3 -m venv .venv && source .venv/bin/activate
pip install -e ".[dev]"
cp .env.example .env # then paste the Kraken key + secret into .env
pytest # offline unit tests (checksums, signing, book logic)Scripts can be run directly (python scripts/<name>.py). They add the repo
root to sys.path, so they don't depend on the editable install.
Only the L3 feed needs a key. L2 and trades are public. Create the key at Kraken → Settings → API with only these permissions:
- WebSocket interface: required (the L3 feed authenticates with a token from
GetWebSocketsToken) - Query Funds: optional (
test_api_key.pyuses it as a signature check)
Leave every trading, deposit, and withdrawal permission off. The project only reads data.
.env holds the key and is gitignored. Everything else in the repo is safe to
commit and share. Each person who runs the L3 collector needs their own .env.
Don't share keys over git, Slack, or notebooks. Use a password manager, or
better, have each person generate their own read-only key. Kraken nonces must
increase per key, so two people using the same key at the same moment can hit
EAPI:Invalid nonce.
| script | key? | what it does |
|---|---|---|
scripts/test_api_key.py |
yes | Checks the key end to end: REST signature, WS token, L3 subscribe, and the L3 snapshot checksum |
scripts/stream_l2_book.py |
no | Live L2 book + trades; prints top of book and checks every checksum |
scripts/stream_l3_orders.py |
yes | Live L3 add/modify/delete events and a periodic book summary (--events for every event) |
scripts/record.py |
L3 only | Collector: writes raw L2 + trades (+ L3) to data/raw/, rotating files hourly |
scripts/validate_recording.py |
no | Replays recorded files offline; reports checksum failure rate, message counts, gaps/uptime |
Typical first run:
python scripts/stream_l2_book.py --symbol BTC/USD --seconds 30 --trades # no key needed
python scripts/test_api_key.py # once .env is filled in
python scripts/stream_l3_orders.py --symbol BTC/USD --seconds 30 --events
python scripts/record.py --symbol BTC/USD --symbol ETH/USD --seconds 600
python scripts/validate_recording.py data/raw/public/*/*.jsonl.gz
python scripts/validate_recording.py data/raw/l3/*/*.jsonl.gzSymbols use Kraken v2 names (BTC/USD, not XBT/USD).
toxicflow/kraken/book.py:L2Book(price → qty) andL3Book(price → individual orders). L3 orders within a level stay in Kraken's order, because the L3 checksum hashes them in that order.- Checksums: after every message, the top 10 levels are CRC32-hashed and
compared with Kraken's value. Prices are parsed as
Decimaland formatted with the pair'sprice_precision/qty_precision. Kraken sends JSON numbers, which drop trailing zeros, so this precision is needed. On a mismatch the symbol is unsubscribed and resubscribed to get a fresh snapshot. toxicflow/kraken/ws.py:BookStreamhandles connect, subscribe, resync on mismatch, reconnect with backoff, and a stale-connection watchdog (30 s without messages; Kraken heartbeats about once a second). L3 gets a new auth token on every reconnect.toxicflow/kraken/recorder.py: one gzipped JSONL file per stream per UTC hour atdata/raw/<stream>/<date>/<stream>_<date>T<HH>.jsonl.gz. The first line is metadata (depth, precisions). After that, each line is{"recv_ns": <local receive time>, "msg": <Kraken's exact JSON>}. Every file begins with a fresh snapshot, so each hour can be replayed on its own.
Answers "what did the book look like at time t?" for the fill simulator, markouts and ML features.
import glob
from toxicflow.bookquery import BookQuery, build_tables
# Until the event log / valid_intervals tables exist, build equivalents from raw files.
tables = build_tables(glob.glob("data/raw/l3/*/*.jsonl.gz"),
glob.glob("data/raw/public/*/*.jsonl.gz"), symbol="BTC/USD")
bq = BookQuery.load(tables.events, tables.trades, tables.valid_intervals,
pair=tables.pair, depth=tables.depth,
clock="exchange", cache_dir="data/cache/bookquery")
t = "2026-10-16T21:08:00Z" # or int ns, datetime, pd.Timestamp
bq.best_at(t) # Best(bid_price, bid_qty, ask_price, ask_qty) as Decimal
bq.mid_at(t), bq.spread_at(t) # float; also take arrays of times (vectorized)
bq.depth_at(t, levels=10) # {"bids": [(price, size), ...], "asks": [...]}
bq.queue_at("bid", "81537.4", t) # [(order_id, size), ...], front of queue first
bq.queue_ahead("bid", "81537.4", t) # size a new order joining now would wait behind
bq.queue_ahead("bid", "81537.4", t, order_id="OTPONL-XSSW7-WQVKCF")
bq.mid_after(fill_times, ("100ms", "1s", "10s")) # DataFrame for markouts
bq.is_valid(t)
for step in bq.iter_book(t0, t1): # sequential scan for the fill simulator
step.ts, step.book, step.events, step.resetscripts/markouts_example.py computes markouts for a list of fills (recorded
trades by default). scripts/bench_bookquery.py reproduces the timings below.
- As-of: a query at
treflects every event withts <= t, in original message order (seq), and nothing after. All events at one timestamp apply together. No lookahead, so features are safe for walk-forward evaluation. - Clock: times are int ns since the epoch (UTC).
clock="exchange"(default) uses Kraken's per-messagetimestamp.clock="recv"uses local receive time. Horizons are in the same clock. Trade timestamps are in the same exchange clock as L3 but truncated to microseconds, so a fill's own L3 events land a few hundred ns after the trade's timestamp.mid_at(trade_ts)is therefore the pre-trade mid. - Validity: outside a valid interval, queries return
None/NaN, or raiseInvalidTimeErrorwithstrict=True. Every interval starts from a fresh snapshot, so no book is carried across a gap.mid_after(t, h)isNaNift + hleaves the interval containingt. The adapter ends an interval at a checksum mismatch (1 ns before the bad message), a silence longer thangap_s, a recorder restart, a new snapshot, or the end of data. - Queue priority (each level is a FIFO queue, so position decides who fills first): adds join the back of the queue. A size decrease (partial fill or amend down) keeps the order's place. A size increase sends it to the back, since exchanges won't let you jump the queue by enlarging an order. Price changes arrive as delete + add. Only a delete removes an order. These rules reproduce Kraken's checksum on every recorded message.
- Depth caveat: the feed shows only the top
depthlevels per side (100 in our recordings). Levels pushed past that are dropped, and anything beyond the deepest visible level is unknown, not empty.queue_atandqueue_aheadthere emitOutsideDepthWarning. Someaddevents are "refills": old orders scrolling into view from below level 100, not new orders. Their owntimestampfield is older than the message. - Precision: prices and sizes are integer ticks/lots internally, and
book results are
Decimal.mid/spread/arraybest_atare float64, converted only throughunits.ticks_to_float.
| table | required columns | optional |
|---|---|---|
| events | ts_<clock> or ts (int ns), seq (message order), order_id, side (bid/ask), event (snapshot/add/modify/delete), price_ticks+qty_lots or price+qty (remaining size after the event) |
msg_id (depth truncation per message), symbol, any extra columns (e.g. classification) pass through in bq.events |
| trades | ts_<clock> or ts, price, qty, side (aggressor) |
anything |
| valid_intervals | start_<clock>/end_<clock> or start/end (inclusive ns) |
symbol |
Each interval's events must begin with the book's snapshot, because replay
starts from an empty book at start. If the event log drops snapshot
orders or seq, the library can't rebuild the book.
Measured on the 2.5-minute BTC/USD sample (14k events, 9.8k messages),
Apple Silicon, Python 3.12, checkpoint_every=500:
| operation | time |
|---|---|
build_tables (raw replay + checksum per message) |
340 ms |
BookQuery.load (build / from disk cache) |
53 ms / 15 ms |
10k random mid_at, one array call |
0.4 ms (0.04 µs each) |
10k random mid_at, scalar loop |
180 ms (18 µs each) |
10k mid_after × 3 horizons |
1.8 ms |
1k random book_at |
500 ms (0.5 ms each) |
iter_book over all 8k timestamps |
51 ms |
Pass arrays to the top-of-book functions: a single call is ~400× faster
than looping. book_at costs ~4 µs × events replayed since the last
checkpoint, so smaller checkpoint_every trades memory for speed. For
walking forward in time, iter_book is far cheaper than repeated book_at.
CERTIFICATE_VERIFY_FAILED: the code already usescertifi's CA bundle. If you still see this from other tools, runInstall Certificates.commandfrom your python.org Python folder.ModuleNotFoundError: toxicflowwithpip install -eon macOS: if the repo is in an iCloud-synced folder (Desktop/Documents), iCloud marks.venvfiles hidden and Python 3.13+ skips hidden.pthfiles. The scripts work around this. Moving the repo out of iCloud is better.ESession:Invalid sessionon the L3 subscribe means the token was rejected. Runtest_api_key.py.