fix(ws): orderbook overhaul — fresh snapshots + O(1) apply_delta#136
Conversation
OrderbookManager previously kept the public Orderbook model as its mutable backing store and handed the same instance back to every caller. apply_delta and apply_snapshot mutated book.yes / book.no in place, so consumers that retained a reference to a yielded book silently saw future updates leak in -- breaking the snapshot semantics that Pydantic users (and the iterator wrapper in client.py) expect. Separate internal mutable state (_BookState) from the emitted model. Each apply_*/get call now materializes a fresh Orderbook with copied level lists. OrderbookLevel itself is treated as immutable (we replace rather than mutate), so the levels can be shared by reference without risk. Add a regression test that pins both the identity invariant (each call returns a distinct Orderbook) and the value invariant (a previously returned book's state survives later deltas). Closes #85 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The previous list-of-levels storage required a linear scan to locate a price (O(n)) and a re-sort after every new-level insert (O(n log n)). On shallow books that's fine, but deep books -- which multivariate markets surface at thousands of levels per side -- turn every WS delta into an O(n) operation on the hot path. Switch _BookState's internal storage to dict[Decimal, Decimal] keyed on price. apply_delta becomes O(1) for both the lookup and the update (get / set / del). The sorted list[OrderbookLevel] that consumers see is materialized lazily in _BookState.to_orderbook -- still O(n log n), but only when a snapshot is actually emitted (i.e. exactly once per delta, no extra work). Add a regression test that swaps in a counting dict and asserts the per-delta access count stays bounded as the book grows from 10 to 500 levels (proves O(1), not O(n)). Invariant-based, not wall-clock, so it is stable on slow CI. Closes #87 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Code Review — PR #136OverviewTwo well-scoped fixes to What Works Well
Issues and Suggestions1. Counting dict test misses
|
| Case | Covered? |
|---|---|
| Snapshot immutability (identity) | ✅ new test |
| Snapshot immutability (value) | ✅ new test |
get() immutability |
✅ new test |
| Delta O(1) access count | ✅ new test |
| Existing tests (snapshot, delta, remove, etc.) | ✅ unchanged |
Full suite: 1622 passed, 48 skipped — no regressions. Good.
Summary
This is a clean, well-reasoned fix. The architectural split (_BookState vs Orderbook) is right-sized and doesn't over-engineer. The two regression tests are solid. The four points above are minor — items 1 and 3 are the most worth addressing before merge for long-term maintainability. Items 2 and 4 are optional polish.
Verdict: Approve with minor suggestions. The correctness and performance improvements are sound; the test suite adequately pins the new invariants.
Summary
Wave 3 — two paired orderbook fixes. Same files, two logical commits, single PR.
#85 — Stop mutating Pydantic models in place
OrderbookManagerpreviously built its emittedOrderbookby mutating the Pydantic model in place. Consumers holding a reference saw leaked mutations on the next delta. Separated mutable internal state (new private_BookStatedataclass) from the emitted model: eachget()/apply_*()returns a freshOrderbooksnapshot. Consumers can hold references safely.#87 —
apply_deltafrom O(n) → O(1)Internal level storage switched from
list[OrderbookLevel]todict[Decimal, Decimal]. Update/insert/delete is dict-time. The sorted-level-list contract on the emittedOrderbookis materialized lazily into_orderbook()(the unavoidable O(n log n) work moves from per-delta to per-snapshot-emit).Perf characterization
Before: O(n) lookup + O(n log n) on new-level insert. Reported ~1.91 µs mid-book / ~3.06 µs end-of-book at n=99.
After: O(1) on update; O(n log n) only on snapshot emit. Per-delta wire-format access is bounded as the book grows (verified n=10 vs n=500 via counting-dict probe).
Public API
No change.
OrderbookManager.{apply_snapshot,apply_delta,get,remove,clear}signatures unchanged.Orderbook/OrderbookLevelmodels unchanged. Observable behavior change (intentional per #85): consecutiveget()calls return distinct instances.Wave 3 boundaries
No touches to
kalshi/ws/dispatch.pyorkalshi/ws/client.py/channels.py(sibling branches own those).Closes #85
Closes #87
Test plan
tests/ws/test_orderbook.py(14 → 16). Pin the snapshot-immutability invariant (OrderbookManager mutates Pydantic models in place; consumers see leaked mutations #85) + counter-based perf check (Orderbookapply_deltais O(n) per delta; use price-indexed dict #87).uv run ruff check .cleanuv run mypy kalshi/clean (strict)