Coverage for adrs/oms/price_feed.py: 100%
60 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-08-29 05:07 +0800
« prev ^ index » next coverage.py v7.15.2, created at 2026-08-29 05:07 +0800
1import logging
2import time
3from dataclasses import dataclass
4from decimal import Decimal
5from typing import Callable
7from cybotrade import Symbol
9logger = logging.getLogger(__name__)
11# How long since *any* frame on the connection before every quote is withheld.
12#
13# This is the liveness signal, not a per-quote freshness limit. @bookTicker
14# pushes on every top-of-book change, so silence on a healthy socket means the
15# book has not moved and the last quote is still the current one. The heartbeat
16# subscription (BTCUSDT) was measured at 58-212 frames/sec with a 515ms worst
17# gap over 60s, so 2s is ~4x the observed worst case: tight enough to catch a
18# wedged socket inside one placement tick, loose enough to survive a scheduling
19# hiccup.
20DEFAULT_HEARTBEAT_MAX_AGE_SEC = 2.0
22# Backstop for the single failure liveness cannot see: one symbol's subscription
23# dying while the connection keeps delivering others. Deliberately generous — a
24# tight value would reintroduce the REST-per-tick cost on quiet symbols that this
25# feed exists to remove. The trade is up to this long quoting from a dead
26# subscription, versus REST on every tick for every quiet symbol.
27DEFAULT_QUOTE_MAX_AGE_SEC = 60.0
30@dataclass(frozen=True, slots=True)
31class Quote:
32 bid: Decimal
33 ask: Decimal
34 received_at: float # time.monotonic() at parse time
36 @property
37 def mid(self) -> Decimal:
38 # Must match cybotrade's ExchangeClient.get_current_price exactly, or the
39 # feed and REST paths would quote differently for the same book.
40 return (self.bid + self.ask) / Decimal("2.0")
43class PriceFeed:
44 """
45 Top-of-book cache with a staleness policy. Pure: it makes no exchange calls,
46 knows nothing about websockets, and decides nothing about what to do on a
47 miss — callers fall back to REST.
49 `heartbeat_max_age_sec=None` disables the connection-liveness gate and leaves
50 per-symbol age as the only guard. **Do not set it without first measuring that
51 the stream re-pushes an unchanged book for an IDLE symbol.** An earlier version
52 of this docstring asserted Bybit's orderbook.1 re-pushes on a 3s timer; that was
53 never verified and it contradicts the adapter actually shipped, whose rationale
54 is the opposite — see BybitPublicWS, which subscribes a busy heartbeat symbol
55 precisely because it treats the stream as change-driven. What was measured is
56 only that BTCUSDT delivers 21-61 frames/sec with a 590ms worst gap, which a
57 change-driven stream on a busy book explains just as well as a timer. Acting on
58 the old claim would have switched off the liveness gate on the strength of an
59 unmeasured assumption.
60 """
62 def __init__(
63 self,
64 heartbeat_max_age_sec: float | None = DEFAULT_HEARTBEAT_MAX_AGE_SEC,
65 quote_max_age_sec: float = DEFAULT_QUOTE_MAX_AGE_SEC,
66 clock: Callable[[], float] = time.monotonic,
67 ):
68 self._heartbeat_max_age_sec = heartbeat_max_age_sec
69 self._quote_max_age_sec = quote_max_age_sec
70 self._clock = clock
71 self._quotes: dict[Symbol, Quote] = {}
72 self._last_message_at: float | None = None
73 self._served = 0
74 self._fallback_no_liveness = 0
75 self._fallback_unseen = 0
76 self._fallback_backstop = 0
77 self._rejected = 0
79 def note_message(self) -> None:
80 """
81 Record that the connection delivered something.
83 Called for every frame, including ones that do not yield a quote: a
84 malformed frame still proves the socket is alive.
85 """
86 self._last_message_at = self._clock()
88 def apply(self, symbol: Symbol, bid: Decimal, ask: Decimal) -> bool:
89 """
90 Store a quote. False if the book is crossed or zero, in which case this
91 symbol's cached quote is dropped too.
93 Dropping it is the point. A symbol that starts pushing an unusable book
94 — delisting, settlement, one side of the book emptied — otherwise has
95 every update rejected while liveness stays fresh off other symbols, so
96 get() would keep serving the last good quote for the full backstop
97 window. That is the only path in this feed that can knowingly serve a
98 wrong price; falling back to REST reads the real book instead. Liveness
99 still counts: the frame arrived, so the socket is provably delivering.
100 """
101 self.note_message()
102 if bid <= 0 or ask <= 0 or bid >= ask:
103 self._rejected += 1
104 self._quotes.pop(symbol, None)
105 logger.warning(
106 f"[PRICE_FEED] Rejected implausible book for {symbol} and dropped "
107 f"its cached quote: bid={bid} ask={ask}"
108 )
109 return False
110 self._quotes[symbol] = Quote(bid=bid, ask=ask, received_at=self._clock())
111 return True
113 def get(self, symbol: Symbol) -> Quote | None:
114 """The current quote, or None when the caller must fall back to REST."""
115 now = self._clock()
116 if self._heartbeat_max_age_sec is not None:
117 if (
118 self._last_message_at is None
119 or now - self._last_message_at > self._heartbeat_max_age_sec
120 ):
121 self._fallback_no_liveness += 1
122 return None
123 quote = self._quotes.get(symbol)
124 if quote is None:
125 self._fallback_unseen += 1
126 return None
127 if now - quote.received_at > self._quote_max_age_sec:
128 self._fallback_backstop += 1
129 return None
130 self._served += 1
131 return quote
133 def clear(self) -> None:
134 """
135 Drop every quote and reset liveness. Called on reconnect: after a gap in
136 delivery no cached quote can be trusted, so each symbol takes one REST
137 read before rejoining the feed.
138 """
139 self._quotes.clear()
140 self._last_message_at = None
142 def invalidate(self, symbol: Symbol) -> None:
143 """Drop one symbol. Unused on Binance; Bybit needs it on a sequence gap."""
144 self._quotes.pop(symbol, None)
146 def stats(self) -> dict[str, int | float | None]:
147 now = self._clock()
148 return {
149 "served": self._served,
150 "fallback_no_liveness": self._fallback_no_liveness,
151 "fallback_unseen": self._fallback_unseen,
152 "fallback_backstop": self._fallback_backstop,
153 "rejected": self._rejected,
154 "tracked_symbols": len(self._quotes),
155 "liveness_age_sec": (
156 None if self._last_message_at is None else now - self._last_message_at
157 ),
158 }