Summary
In the WS recv hot path, _process_frame calls self._orderbook_mgr.apply_snapshot(snapshot) / apply_delta(delta) purely for side effect — the return value is not captured. But both methods unconditionally execute state.to_orderbook(), which:
- runs
sorted(self.yes.items()) and sorted(self.no.items()) → O(n log n) per side
- allocates
2 * N OrderbookLevel Pydantic models per call
On a 500-level book, every single delta frame burns an O(n log n) sort and ~1000 Pydantic model allocations whose result is immediately discarded. At 1–10k orderbook_delta msg/s this is by far the dominant per-frame cost and creates significant GC pressure in long-running streamers. The user-facing OrderbookManager.get(ticker) already materializes on demand, so the work done inside the recv loop is wasted.
Location
kalshi/ws/client.py:285-292 — recv loop calls and discards return value
kalshi/ws/orderbook.py:76-123 — apply_snapshot / apply_delta always return materialized Orderbook
Evidence
# kalshi/ws/client.py:285-292 (recv hot path)
if msg_type == "orderbook_snapshot" and self._orderbook_mgr:
snapshot = OrderbookSnapshotMessage.model_validate(data)
self._orderbook_mgr.apply_snapshot(snapshot) # return value discarded
pre_validated = snapshot
elif msg_type == "orderbook_delta" and self._orderbook_mgr:
delta = OrderbookDeltaMessage.model_validate(data)
self._orderbook_mgr.apply_delta(delta) # return value discarded
pre_validated = delta
# kalshi/ws/orderbook.py:117-123 (inside apply_delta)
elif delta > 0:
levels[price] = delta
return state.to_orderbook() # ← O(n log n) sort + 2n OrderbookLevel allocations
Recommended fix
Split state mutation from snapshot materialization. Two options, either fine:
Option A — internal in-place variants (cleanest):
def _apply_snapshot_inplace(self, msg: OrderbookSnapshotMessage) -> None: ...
def _apply_delta_inplace(self, msg: OrderbookDeltaMessage) -> None: ...
def apply_snapshot(self, msg) -> Orderbook:
self._apply_snapshot_inplace(msg)
return self.get(msg.msg.market_ticker)
def apply_delta(self, msg) -> Orderbook | None:
self._apply_delta_inplace(msg)
state = self._books.get(msg.msg.market_ticker)
return state.to_orderbook() if state else None
Have the recv loop call _apply_*_inplace directly.
Option B — opt-out kwarg:
def apply_delta(self, msg, *, materialize: bool = True) -> Orderbook | None:
...
if not materialize:
return None
return state.to_orderbook()
Either way the recv path stops paying for the unused snapshot.
Add a perf benchmark (scripts/bench_ws_recv.py) that feeds 10k synthetic delta frames at varying book depths and reports frames/sec + GC counts — see separate "bench scripts missing" issue. Without a baseline this regression is invisible to CI.
Severity & category
high / performance, ws
Summary
In the WS recv hot path,
_process_framecallsself._orderbook_mgr.apply_snapshot(snapshot)/apply_delta(delta)purely for side effect — the return value is not captured. But both methods unconditionally executestate.to_orderbook(), which:sorted(self.yes.items())andsorted(self.no.items())→ O(n log n) per side2 * N OrderbookLevelPydantic models per callOn a 500-level book, every single delta frame burns an O(n log n) sort and ~1000 Pydantic model allocations whose result is immediately discarded. At 1–10k orderbook_delta msg/s this is by far the dominant per-frame cost and creates significant GC pressure in long-running streamers. The user-facing
OrderbookManager.get(ticker)already materializes on demand, so the work done inside the recv loop is wasted.Location
kalshi/ws/client.py:285-292— recv loop calls and discards return valuekalshi/ws/orderbook.py:76-123—apply_snapshot/apply_deltaalways return materializedOrderbookEvidence
Recommended fix
Split state mutation from snapshot materialization. Two options, either fine:
Option A — internal in-place variants (cleanest):
Have the recv loop call
_apply_*_inplacedirectly.Option B — opt-out kwarg:
Either way the recv path stops paying for the unused snapshot.
Add a perf benchmark (
scripts/bench_ws_recv.py) that feeds 10k synthetic delta frames at varying book depths and reports frames/sec + GC counts — see separate "bench scripts missing" issue. Without a baseline this regression is invisible to CI.Severity & category
high / performance, ws