sahiapi 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
sahiapi/sahi/feed.py ADDED
@@ -0,0 +1,668 @@
1
+ """``SahiFeed``: the two live WebSocket streams.
2
+
3
+ Sahi has two streams, and :class:`SahiFeed` owns one connection for each:
4
+
5
+ * the **tick stream** (``WSS /v1/stream/marketdata``), binary protobuf, which you
6
+ subscribe to per instrument at a mode (LTP, quote, or depth); and
7
+ * the **order-updates stream** (``WSS /v1/stream/orders``), JSON, which pushes
8
+ your account's order events with no subscription.
9
+
10
+ Both share a token and the connection rules in :mod:`~sahiapi.sahi.ws_client`.
11
+ Ticks update a local cache as they arrive, so :meth:`get_ltp`, :meth:`get_quote`
12
+ and :meth:`get_depth` return the latest value even before you start consuming.
13
+ Once the tick stream has closed fatally (an expired token above all) those
14
+ reads raise that error instead of serving a frozen price, so a caller that only
15
+ polls the cache learns of it too.
16
+ Your callbacks run on the thread that calls :meth:`consume`, one at a time, never
17
+ on a socket thread, so a slow callback cannot stall the connection or race
18
+ another callback.
19
+
20
+ What waits for :meth:`consume` is bounded. Every tick is a full snapshot for
21
+ its instrument at its mode, so only the newest tick per instrument and mode is
22
+ held for the callback: a callback that keeps up sees every tick, a slow one
23
+ sees fewer, fresher ticks, and memory stays proportional to the subscriptions
24
+ whether or not :meth:`consume` is ever called. Order updates are events, so
25
+ they wait in order up to a cap; past it the oldest is dropped and the
26
+ ``on_connect`` hook is queued once, so the Stream + Snapshot reconcile below
27
+ fills the gap exactly as it does after a reconnect.
28
+
29
+ Subscriptions are connection-local server-side and are not replayed after a drop,
30
+ so the feed keeps its own subscription table and re-sends it on every reconnect.
31
+ The table holds only ids the server has acknowledged: a subscribe request waits
32
+ in a pending batch until its ``ack`` arrives, and a batch the server rejects
33
+ (an unknown id, the per-connection cap) is dropped whole and reported through
34
+ the callback as an ``error`` event naming its mode and ids. The server refuses
35
+ such a request without applying any of it, so recording ids on send would let
36
+ one bad id silence every id replayed with it after a reconnect. Pending batches
37
+ are replayed one frame each, after the acknowledged table, so a bad batch can
38
+ fail only itself.
39
+ The order stream replays nothing either, so it offers an ``on_connect`` hook for
40
+ the API reference's Stream + Snapshot bootstrap: on every (re)connect, fetch the
41
+ order book and reconcile by ``last_modified_at``. A fatal close — an expired
42
+ token above all — stops both streams and is raised from :meth:`consume`, and
43
+ so does an exception escaping one of your callbacks: :meth:`consume` never
44
+ returns or raises with a stream still running behind it.
45
+
46
+ Thread-safety: the subscription table and tick cache are read and written from
47
+ both caller threads (subscribe/unsubscribe/get_*) and the socket thread (tick
48
+ arrival, reconnect replay), so a single lock guards them. It is never held across
49
+ a network send, so a blocked socket cannot stall a caller. The server answers
50
+ subscribe requests in the order it receives them, with no request id, so a second
51
+ lock serializes subscribe frames, held from queuing a batch through writing it:
52
+ the wire order is then always the pending order, whichever threads subscribe or
53
+ replay, and a write stuck on a dead link delays only the next subscribe.
54
+ """
55
+
56
+ from __future__ import annotations
57
+
58
+ import json
59
+ import logging
60
+ import queue
61
+ import threading
62
+ from collections import deque
63
+ from dataclasses import dataclass, field
64
+ from typing import Any, Callable, Final
65
+
66
+ from . import constants, env
67
+ from .client import SahiAPI
68
+ from .exceptions import (
69
+ SahiFeedConnectionException,
70
+ SahiFeedException,
71
+ SahiFeedNotSubscribedException,
72
+ )
73
+ from .proto import codec
74
+ from .ws_client import WebSocketConnection
75
+
76
+ __all__ = ["SahiFeed"]
77
+
78
+ logger = logging.getLogger(__name__)
79
+
80
+ DataCallback = Callable[[dict[str, Any], dict[str, Any]], None]
81
+ ConnectCallback = Callable[[], None]
82
+
83
+ _MARKETDATA_PATH = "/v1/stream/marketdata"
84
+ _ORDERS_PATH = "/v1/stream/orders"
85
+
86
+ # Order updates waiting for consume() beyond this many drop the oldest and queue
87
+ # a resync (the on_connect hook), as the reference prescribes for a 4409.
88
+ _ORDER_QUEUE_LIMIT: Final = 1000
89
+
90
+
91
+ @dataclass
92
+ class _Batch:
93
+ """One subscribe request awaiting the server's ack or error."""
94
+
95
+ mode: str
96
+ ids: list[int]
97
+ # True for a batch the replay built from already-acknowledged ids: on error
98
+ # those ids leave the table, since the server no longer holds them.
99
+ held: bool = field(default=False)
100
+
101
+
102
+ @dataclass(frozen=True)
103
+ class _TickReady:
104
+ """Dispatch marker: the newest tick for this (bucket, instrument) awaits delivery."""
105
+
106
+ key: tuple[str, int]
107
+
108
+
109
+ class _OrdersReady:
110
+ """Dispatch marker: the order queue has at least one item to deliver."""
111
+
112
+
113
+ _ORDERS_READY = _OrdersReady()
114
+ _ORDER_CONNECT: Final = ("connect",)
115
+
116
+
117
+ # Tick payload type -> the cache bucket it updates.
118
+ _TICK_CACHE_BUCKET = {
119
+ "ltp_tick": "ltp",
120
+ "quote_tick": "quote",
121
+ "depth_tick": "depth",
122
+ }
123
+
124
+
125
+ class SahiFeed:
126
+ """Live market-data and order-update streams for a :class:`SahiAPI`.
127
+
128
+ Args:
129
+ api: A constructed :class:`SahiAPI`. The feed borrows its token and host;
130
+ it never calls back into the REST client.
131
+
132
+ Rotating the API's token with :meth:`SahiAPI.set_access_token` does not affect
133
+ a running feed. A feed connection carries the token it was started with until
134
+ it is stopped and started again.
135
+ """
136
+
137
+ def __init__(self, api: SahiAPI) -> None:
138
+ self._access_token = api.access_token
139
+ ws_base = env.websocket_base_url(env.API_BASE_URL)
140
+ self._marketdata_url = ws_base + _MARKETDATA_PATH
141
+ self._orders_url = ws_base + _ORDERS_PATH
142
+
143
+ self._marketdata: WebSocketConnection | None = None
144
+ self._orders: WebSocketConnection | None = None
145
+ self._marketdata_callback: DataCallback | None = None
146
+ self._orders_callback: DataCallback | None = None
147
+ self._orders_connect_callback: ConnectCallback | None = None
148
+
149
+ # Guards _subscriptions, _cache and _pending, which the caller threads
150
+ # and the socket thread both touch. Held only for the dict operation,
151
+ # never over a send, so it cannot couple caller latency to the network.
152
+ self._lock = threading.Lock()
153
+ # Serializes subscribe frames: held from queuing a batch through writing
154
+ # it, by subscribe calls and the reconnect replay alike, so the wire
155
+ # order matches _pending (the server answers in wire order, and that is
156
+ # how acks are matched). Taken before _lock, never inside it.
157
+ self._subscribe_lock = threading.Lock()
158
+ # The connection epoch whose reconnect replay has run; guarded by
159
+ # _subscribe_lock. A subscribe call sends directly only on that epoch;
160
+ # on a newer one (the socket is up but its replay is still to come) it
161
+ # leaves the batch for the replay, which sends the held table first.
162
+ self._replayed_epoch: int | None = None
163
+ # instrument_id -> mode for ids the server has acknowledged, replayed
164
+ # after a reconnect.
165
+ self._subscriptions: dict[int, str] = {}
166
+ # Subscribe requests sent (or queued) and not yet answered, in send order.
167
+ # The server answers each with one ack or one error, in that order.
168
+ self._pending: deque[_Batch] = deque()
169
+ # bucket ("ltp"/"quote"/"depth") -> instrument_id -> latest decoded tick.
170
+ self._cache: dict[str, dict[int, dict[str, Any]]] = {"ltp": {}, "quote": {}, "depth": {}}
171
+
172
+ # Hand-off to consume(): markers and one-off events (acks, errors, the
173
+ # fatal error, the stop sentinel). The payloads live in the two bounded
174
+ # structures below, guarded by _lock.
175
+ self._dispatch: queue.Queue[Any] = queue.Queue()
176
+ # (bucket, instrument_id) -> the newest (tick, meta) not yet delivered.
177
+ self._latest_tick: dict[tuple[str, int], tuple[dict[str, Any], dict[str, Any]]] = {}
178
+ # Order frames and connect markers, in arrival order, at most
179
+ # _ORDER_QUEUE_LIMIT long.
180
+ self._order_queue: deque[tuple[Any, ...]] = deque()
181
+ self._order_connects_queued = 0
182
+
183
+ # -- market-data subscriptions -------------------------------------------
184
+
185
+ def subscribe_ltp(
186
+ self, instrument_ids: list[int], on_data_received: DataCallback | None = None
187
+ ) -> None:
188
+ """Subscribe these instruments for last-traded-price ticks.
189
+
190
+ Opens the tick connection on first use. ``on_data_received`` sets the one
191
+ market-data callback shared by all tick modes (LTP/quote/depth); passing
192
+ it here replaces any previously registered callback, and omitting it keeps
193
+ the current one. Ticks reach the callback from :meth:`consume`, never on
194
+ the socket thread.
195
+
196
+ The call does not wait for the server. The ids count as subscribed at
197
+ once (:meth:`get_ltp` returns ``None`` until a tick lands), and enter the
198
+ replayed table when the server's ``ack`` arrives. The server refuses a
199
+ request whole if any id is unknown or the per-connection cap would be
200
+ passed; that batch is then dropped, none of its ids are subscribed, and
201
+ the callback receives an ``error`` event whose ``meta`` carries the
202
+ ``mode`` and ``instrument_ids`` of the refused batch.
203
+ """
204
+ self._subscribe(constants.MODE_LTP, instrument_ids, on_data_received)
205
+
206
+ def subscribe_quote(
207
+ self, instrument_ids: list[int], on_data_received: DataCallback | None = None
208
+ ) -> None:
209
+ """Subscribe these instruments for full-quote ticks.
210
+
211
+ See :meth:`subscribe_ltp` for the shared-callback and connection behaviour
212
+ of ``on_data_received``.
213
+ """
214
+ self._subscribe(constants.MODE_QUOTE, instrument_ids, on_data_received)
215
+
216
+ def subscribe_depth(
217
+ self, instrument_ids: list[int], on_data_received: DataCallback | None = None
218
+ ) -> None:
219
+ """Subscribe these instruments for depth ticks (quote plus market depth).
220
+
221
+ See :meth:`subscribe_ltp` for the shared-callback and connection behaviour
222
+ of ``on_data_received``.
223
+ """
224
+ self._subscribe(constants.MODE_DEPTH, instrument_ids, on_data_received)
225
+
226
+ def change_mode(self, instrument_ids: list[int], mode: str) -> None:
227
+ """Change the tick mode for these instruments, subscribing any that are new.
228
+
229
+ The new mode is recorded in the subscription table, so it survives a
230
+ reconnect. Ids not previously subscribed are added at ``mode`` rather than
231
+ rejected.
232
+
233
+ This sends a ``subscribe`` frame, not the wire's ``mode_change``: the
234
+ server's subscribe is idempotent and re-points ids it already holds,
235
+ while its mode_change rejects the whole request with ``NOT_SUBSCRIBED``
236
+ if any id is new, which would leave the table and the server disagreeing.
237
+ """
238
+ self._subscribe(mode, instrument_ids, None)
239
+
240
+ def unsubscribe(self, instrument_ids: list[int]) -> None:
241
+ """Stop ticks for these instruments and drop their cached values.
242
+
243
+ Afterwards :meth:`get_ltp` / :meth:`get_quote` / :meth:`get_depth` raise
244
+ :class:`SahiFeedNotSubscribedException` for them until re-subscribed.
245
+ """
246
+ dropped = set(instrument_ids)
247
+ # Under the subscribe lock, so the frame is ordered with every subscribe
248
+ # frame: it cannot slip in between a reconnect replay's frames and reach
249
+ # the server ahead of the one re-subscribing these very ids, nor cross
250
+ # a concurrent subscribe of the same id.
251
+ with self._subscribe_lock:
252
+ with self._lock:
253
+ for instrument_id in dropped:
254
+ self._subscriptions.pop(instrument_id, None)
255
+ for bucket_name, bucket in self._cache.items():
256
+ bucket.pop(instrument_id, None)
257
+ self._latest_tick.pop((bucket_name, instrument_id), None)
258
+ # An id still awaiting its ack must not come back when that ack lands.
259
+ for batch in self._pending:
260
+ batch.ids = [i for i in batch.ids if i not in dropped]
261
+ if self._marketdata is not None:
262
+ self._send_marketdata(codec.encode_unsubscribe(instrument_ids))
263
+
264
+ def _subscribe(
265
+ self, mode: str, instrument_ids: list[int], on_data_received: DataCallback | None
266
+ ) -> None:
267
+ if on_data_received is not None:
268
+ self._marketdata_callback = on_data_received
269
+ ids = list(dict.fromkeys(instrument_ids))
270
+ if not ids:
271
+ return
272
+ codec.encode_subscribe(mode, ids) # validates the mode before any state changes
273
+ batch = _Batch(mode, ids)
274
+ self._ensure_marketdata()
275
+ with self._subscribe_lock:
276
+ with self._lock:
277
+ self._pending.append(batch)
278
+ self._send_batch_locked(batch)
279
+
280
+ # -- cache reads ----------------------------------------------------------
281
+
282
+ def get_ltp(self, instrument_id: int) -> dict[str, Any] | None:
283
+ """Return the latest LTP tick for an instrument, or ``None`` if none has arrived.
284
+
285
+ Raises:
286
+ SahiFeedException: The error that closed the tick stream fatally, if
287
+ it has (for example :class:`SahiFeedAuthExpiredException` on an
288
+ expired token), so a cache-only caller does not read a frozen price.
289
+ SahiFeedNotSubscribedException: If the instrument is not subscribed.
290
+ """
291
+ return self._cached("ltp", instrument_id)
292
+
293
+ def get_quote(self, instrument_id: int) -> dict[str, Any] | None:
294
+ """Return the latest quote tick for an instrument, or ``None`` if none yet.
295
+
296
+ Raises:
297
+ SahiFeedException: The error that closed the tick stream fatally, if
298
+ it has (for example :class:`SahiFeedAuthExpiredException` on an
299
+ expired token), so a cache-only caller does not read a frozen price.
300
+ SahiFeedNotSubscribedException: If the instrument is not subscribed.
301
+ """
302
+ return self._cached("quote", instrument_id)
303
+
304
+ def get_depth(self, instrument_id: int) -> dict[str, Any] | None:
305
+ """Return the latest depth tick for an instrument, or ``None`` if none yet.
306
+
307
+ Raises:
308
+ SahiFeedException: The error that closed the tick stream fatally, if
309
+ it has (for example :class:`SahiFeedAuthExpiredException` on an
310
+ expired token), so a cache-only caller does not read a frozen price.
311
+ SahiFeedNotSubscribedException: If the instrument is not subscribed.
312
+ """
313
+ return self._cached("depth", instrument_id)
314
+
315
+ def _cached(self, bucket: str, instrument_id: int) -> dict[str, Any] | None:
316
+ conn = self._marketdata
317
+ if conn is not None:
318
+ fatal = conn.fatal_error
319
+ if fatal is not None:
320
+ raise fatal
321
+ with self._lock:
322
+ if not self._is_subscribed(instrument_id):
323
+ raise SahiFeedNotSubscribedException(
324
+ f"instrument {instrument_id} is not subscribed"
325
+ )
326
+ return self._cache[bucket].get(instrument_id)
327
+
328
+ def _is_subscribed(self, instrument_id: int) -> bool:
329
+ """Acknowledged or awaiting its ack. Call with the lock held."""
330
+ if instrument_id in self._subscriptions:
331
+ return True
332
+ return any(instrument_id in batch.ids for batch in self._pending)
333
+
334
+ # -- order updates --------------------------------------------------------
335
+
336
+ def subscribe_order_updates(
337
+ self, on_data_received: DataCallback, *, on_connect: ConnectCallback | None = None
338
+ ) -> None:
339
+ """Start the order-updates stream.
340
+
341
+ There is no per-instrument subscription: the server pushes every order
342
+ event for the account as soon as the socket is open. Each frame is an
343
+ order object shaped like an element of ``GET /v1/orders``; an update may
344
+ arrive more than once, and nothing is replayed for time the socket was
345
+ down. The API reference's remedy is Stream + Snapshot, which
346
+ ``on_connect`` exists for:
347
+
348
+ 1. ``on_connect`` fires after every (re)connect, on the :meth:`consume`
349
+ thread, before any frame from that connection.
350
+ 2. In it, fetch ``api.get_order_list()``.
351
+ 3. Keep frames that arrive meanwhile; reconcile per ``order_id`` by the
352
+ higher ``last_modified_at``. Duplicates fall out of that step.
353
+
354
+ Args:
355
+ on_data_received: Receives ``(order, meta)`` per frame, where ``meta``
356
+ is ``{"stream": "orders", "type": "order"}``.
357
+ on_connect: Optional; called with no arguments after each connect,
358
+ including reconnects after 4408 and 4409, and once more when
359
+ frames had to be dropped because :meth:`consume` fell more than
360
+ a thousand frames behind: the same snapshot repairs both gaps.
361
+ """
362
+ self._orders_callback = on_data_received
363
+ self._orders_connect_callback = on_connect
364
+ if self._orders is None:
365
+ self._orders = WebSocketConnection(
366
+ self._orders_url,
367
+ access_token=self._access_token,
368
+ binary=False,
369
+ on_message=self._on_orders_message,
370
+ on_open=self._on_orders_open,
371
+ on_fatal=self._on_fatal,
372
+ name="sahi-orders",
373
+ )
374
+ self._orders.start()
375
+
376
+ # -- run loop -------------------------------------------------------------
377
+
378
+ def consume(self) -> None:
379
+ """Dispatch callbacks on the calling thread until :meth:`stop`.
380
+
381
+ Blocks. Each queued message runs its callback here, one at a time, so your
382
+ callbacks never run concurrently or on a socket thread. Returns when
383
+ :meth:`stop` is called.
384
+
385
+ Ticks are delivered newest-first per instrument and mode: if several
386
+ arrived for one instrument while an earlier callback ran, only the
387
+ latest is delivered, since each is a full snapshot. Order updates are
388
+ delivered in arrival order.
389
+
390
+ However it ends, both streams are stopped first: on a fatal stream
391
+ close, on an exception from one of your callbacks, or on an interrupt.
392
+ A stream is never left reconnecting in the background with nobody
393
+ draining it, so the caller need not add a ``finally: stop()``. An
394
+ exception from a callback is re-raised as-is, not swallowed: a bug in
395
+ the callback should surface, and a callback that wants to survive a
396
+ transient failure (a database hiccup, say) catches it in its own body,
397
+ where it knows what to retry.
398
+
399
+ Raises:
400
+ SahiFeedException: If a stream closes fatally — most importantly
401
+ :class:`SahiFeedAuthExpiredException` when the token expires (4401).
402
+ Exception: Whatever one of your callbacks raised.
403
+ """
404
+ try:
405
+ while True:
406
+ item = self._dispatch.get()
407
+ if item is None: # stop sentinel
408
+ return
409
+ if isinstance(item, SahiFeedException):
410
+ raise item
411
+ if isinstance(item, _TickReady):
412
+ self._deliver_tick(item.key)
413
+ elif item is _ORDERS_READY:
414
+ self._deliver_order()
415
+ else:
416
+ callback, data, meta = item
417
+ callback(data, meta)
418
+ finally:
419
+ self.stop()
420
+
421
+ def _deliver_tick(self, key: tuple[str, int]) -> None:
422
+ with self._lock:
423
+ entry = self._latest_tick.pop(key, None)
424
+ callback = self._marketdata_callback
425
+ if entry is None or callback is None:
426
+ return # unsubscribed meanwhile, or the callback was cleared
427
+ data, meta = entry
428
+ callback(data, meta)
429
+
430
+ def _deliver_order(self) -> None:
431
+ with self._lock:
432
+ if not self._order_queue:
433
+ return
434
+ item = self._order_queue.popleft()
435
+ if item is _ORDER_CONNECT:
436
+ self._order_connects_queued -= 1
437
+ if self._order_queue:
438
+ # One item per marker keeps ticks and orders interleaved fairly.
439
+ self._dispatch.put(_ORDERS_READY)
440
+ if item is _ORDER_CONNECT:
441
+ connect = self._orders_connect_callback
442
+ if connect is not None:
443
+ connect()
444
+ return
445
+ callback = self._orders_callback
446
+ if callback is not None:
447
+ callback(item[0], item[1])
448
+
449
+ def _queue_order_item(self, item: tuple[Any, ...]) -> None:
450
+ """Append to the order queue, dropping the oldest and queueing a resync past the cap."""
451
+ with self._lock:
452
+ was_empty = not self._order_queue
453
+ frames_waiting = len(self._order_queue) - self._order_connects_queued
454
+ if item is not _ORDER_CONNECT and frames_waiting >= _ORDER_QUEUE_LIMIT:
455
+ self._drop_oldest_order_frame_locked()
456
+ self._order_queue.append(item)
457
+ if item is _ORDER_CONNECT:
458
+ self._order_connects_queued += 1
459
+ if was_empty:
460
+ self._dispatch.put(_ORDERS_READY)
461
+
462
+ def _drop_oldest_order_frame_locked(self) -> None:
463
+ """Drop the oldest frame (never a connect marker) and make sure a resync is queued."""
464
+ markers_in_front = 0
465
+ while self._order_queue and self._order_queue[0] is _ORDER_CONNECT:
466
+ self._order_queue.popleft()
467
+ markers_in_front += 1
468
+ if self._order_queue:
469
+ self._order_queue.popleft()
470
+ for _ in range(markers_in_front):
471
+ self._order_queue.appendleft(_ORDER_CONNECT)
472
+ if self._order_connects_queued == 0:
473
+ # Recover the way a 4409 does, but locally: the snapshot the hook
474
+ # fetches carries the final state of every dropped frame. Queued
475
+ # once per overflow episode, at the front, so recovery runs first.
476
+ self._order_queue.appendleft(_ORDER_CONNECT)
477
+ self._order_connects_queued += 1
478
+ logger.warning(
479
+ "order updates: consume() is more than %d frames behind; dropping the "
480
+ "oldest and queueing on_connect to resync",
481
+ _ORDER_QUEUE_LIMIT,
482
+ )
483
+
484
+ def stop(self) -> None:
485
+ """Close both streams and unblock :meth:`consume`."""
486
+ if self._marketdata is not None:
487
+ self._marketdata.stop()
488
+ if self._orders is not None:
489
+ self._orders.stop()
490
+ self._dispatch.put(None)
491
+
492
+ # -- internals ------------------------------------------------------------
493
+
494
+ def _ensure_marketdata(self) -> None:
495
+ if self._marketdata is None:
496
+ self._marketdata = WebSocketConnection(
497
+ self._marketdata_url,
498
+ access_token=self._access_token,
499
+ binary=True,
500
+ on_message=self._on_marketdata_message,
501
+ on_open=self._replay_subscriptions,
502
+ on_fatal=self._on_fatal,
503
+ name="sahi-marketdata",
504
+ )
505
+ self._marketdata.start()
506
+
507
+ def _send_marketdata(self, frame: bytes) -> None:
508
+ # Best-effort immediate send for frames that need no reply tracking
509
+ # (unsubscribe). If the socket is still connecting the frame is safely
510
+ # dropped: a reconnect wipes the server's table anyway.
511
+ if self._marketdata is None:
512
+ return
513
+ try:
514
+ self._marketdata.send(frame)
515
+ except SahiFeedConnectionException:
516
+ pass
517
+
518
+ def _send_batch_locked(self, batch: _Batch) -> None:
519
+ """Send a just-queued batch now, if this is its turn. Call with
520
+ ``_subscribe_lock`` held.
521
+
522
+ The batch goes out only on the connection epoch whose replay has run:
523
+ on a socket that is not up yet, or is up but still to be replayed, it
524
+ stays queued and the replay sends it after the held table, in pending
525
+ order. The subscribe lock is held through the write, so no other
526
+ subscribe or replay can put a frame on the wire between this batch
527
+ being queued and sent: the server answers in wire order, and a frame
528
+ out of pending order would pair its ack with the wrong batch.
529
+ """
530
+ conn = self._marketdata
531
+ if conn is None:
532
+ return
533
+ epoch = conn.epoch
534
+ if epoch is None or epoch != self._replayed_epoch:
535
+ return # the replay for this socket carries it
536
+ with self._lock:
537
+ frame = codec.encode_subscribe(batch.mode, list(batch.ids))
538
+ try:
539
+ conn.send(frame, epoch=epoch)
540
+ except SahiFeedConnectionException:
541
+ pass # dropped meanwhile: the next replay sends it
542
+
543
+ def _replay_subscriptions(self) -> None:
544
+ """Re-send the subscription state after a (re)connect.
545
+
546
+ Runs on the socket thread. Acknowledged ids go first, one frame per mode;
547
+ then every batch still awaiting an answer, one frame each, so a batch
548
+ the server rejects takes nothing else down with it. The order is also
549
+ the order the server will answer in, which is how acks are matched, so
550
+ the frames are written under the subscribe lock: a subscribe call
551
+ arriving meanwhile queues behind them and is sent, in pending order,
552
+ once the replay has run. Until then :meth:`_send_batch_locked` leaves
553
+ the batch for this replay to carry. The table and cache lock is held
554
+ only to snapshot the state, so the writes block no reader.
555
+ """
556
+ conn = self._marketdata
557
+ if conn is None:
558
+ return
559
+ with self._subscribe_lock:
560
+ with self._lock:
561
+ epoch = conn.epoch
562
+ if epoch is None:
563
+ return
564
+ by_mode: dict[str, list[int]] = {}
565
+ for instrument_id, mode in self._subscriptions.items():
566
+ by_mode.setdefault(mode, []).append(instrument_id)
567
+ held = [_Batch(mode, ids, held=True) for mode, ids in by_mode.items()]
568
+ # Batches the old socket answered were popped; the rest start
569
+ # over on this socket.
570
+ self._pending = deque(held + [b for b in self._pending if b.ids and not b.held])
571
+ frames = [codec.encode_subscribe(b.mode, list(b.ids)) for b in self._pending]
572
+ self._replayed_epoch = epoch
573
+ for frame in frames:
574
+ conn.send(frame, epoch=epoch)
575
+
576
+ def _apply_ack(self, data: dict[str, Any]) -> None:
577
+ if data.get("action") != "SUBSCRIBE":
578
+ return # unsubscribe acks need no bookkeeping; nothing else is sent
579
+ with self._lock:
580
+ if not self._pending:
581
+ return
582
+ batch = self._pending.popleft()
583
+ acked = set(data.get("instrument_ids") or [])
584
+ if not set(batch.ids) <= acked:
585
+ logger.warning(
586
+ "marketdata ack for %s did not cover the batch %s; applying anyway",
587
+ sorted(acked),
588
+ batch.ids,
589
+ )
590
+ for instrument_id in batch.ids:
591
+ self._subscriptions[instrument_id] = batch.mode
592
+
593
+ def _apply_error(self, data: dict[str, Any]) -> _Batch | None:
594
+ """Drop the batch the server refused. Returns it, or None if none was pending."""
595
+ with self._lock:
596
+ if not self._pending:
597
+ return None
598
+ batch = self._pending.popleft()
599
+ if batch.held:
600
+ # A replayed, previously acknowledged batch the server now refuses:
601
+ # it no longer holds these ids, so neither does the table.
602
+ for instrument_id in batch.ids:
603
+ self._subscriptions.pop(instrument_id, None)
604
+ for bucket in self._cache.values():
605
+ bucket.pop(instrument_id, None)
606
+ return batch
607
+
608
+ def _on_marketdata_message(self, frame: Any) -> None:
609
+ raw = frame if isinstance(frame, bytes) else bytes(frame, "latin-1")
610
+ msg_type, data = codec.decode_server_message(raw)
611
+ meta: dict[str, Any] = {"stream": "marketdata", "type": msg_type}
612
+ bucket = _TICK_CACHE_BUCKET.get(msg_type)
613
+ if bucket is not None:
614
+ instrument_id = data.get("instrument_id")
615
+ if isinstance(instrument_id, int):
616
+ meta["instrument_id"] = instrument_id
617
+ # Only cache while subscribed or awaiting the ack (the server sends
618
+ # a warm-start tick before the ack): a tick already in flight when
619
+ # the caller unsubscribed must not repopulate the dropped entry.
620
+ with self._lock:
621
+ if self._is_subscribed(instrument_id):
622
+ self._cache[bucket][instrument_id] = data
623
+ if self._marketdata_callback is not None:
624
+ # Coalesce: only the newest tick per key waits, and
625
+ # the key is queued once until it is delivered.
626
+ key = (bucket, instrument_id)
627
+ waiting = key in self._latest_tick
628
+ self._latest_tick[key] = (data, meta)
629
+ if not waiting:
630
+ self._dispatch.put(_TickReady(key))
631
+ return # delivered through the coalesced path above, if at all
632
+ if msg_type == "ack":
633
+ self._apply_ack(data)
634
+ elif msg_type == "error":
635
+ batch = self._apply_error(data)
636
+ if batch is not None:
637
+ meta["mode"] = batch.mode
638
+ meta["instrument_ids"] = list(batch.ids)
639
+ logger.warning(
640
+ "marketdata subscribe refused: %s %s (mode %s, ids %s)",
641
+ data.get("code"),
642
+ data.get("message"),
643
+ meta.get("mode"),
644
+ meta.get("instrument_ids"),
645
+ )
646
+ if self._marketdata_callback is not None:
647
+ self._dispatch.put((self._marketdata_callback, data, meta))
648
+
649
+ def _on_orders_open(self) -> None:
650
+ # Hand the connect event to consume() so the user's snapshot fetch runs on
651
+ # their thread, ordered before any frame from this connection.
652
+ if self._orders_connect_callback is not None:
653
+ self._queue_order_item(_ORDER_CONNECT)
654
+
655
+ def _on_orders_message(self, frame: Any) -> None:
656
+ text = frame if isinstance(frame, str) else frame.decode()
657
+ try:
658
+ data = json.loads(text)
659
+ except ValueError:
660
+ return # ignore a non-JSON frame rather than crash the stream
661
+ if self._orders_callback is not None:
662
+ self._queue_order_item((data, {"stream": "orders", "type": "order"}))
663
+
664
+ def _on_fatal(self, error: SahiFeedException) -> None:
665
+ # Runs on the failing stream's socket thread. Only hand the error to
666
+ # consume(); consume() stops both streams — doing it here would join this
667
+ # very thread and deadlock.
668
+ self._dispatch.put(error)