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/__init__.py +88 -0
- sahiapi/py.typed +0 -0
- sahiapi/sahi/__init__.py +0 -0
- sahiapi/sahi/account.py +28 -0
- sahiapi/sahi/auth.py +163 -0
- sahiapi/sahi/base.py +32 -0
- sahiapi/sahi/client.py +306 -0
- sahiapi/sahi/constants.py +115 -0
- sahiapi/sahi/env.py +39 -0
- sahiapi/sahi/exceptions.py +412 -0
- sahiapi/sahi/feed.py +668 -0
- sahiapi/sahi/funds.py +134 -0
- sahiapi/sahi/http.py +301 -0
- sahiapi/sahi/instruments.py +315 -0
- sahiapi/sahi/marketdata.py +268 -0
- sahiapi/sahi/orders.py +225 -0
- sahiapi/sahi/portfolio.py +49 -0
- sahiapi/sahi/proto/__init__.py +0 -0
- sahiapi/sahi/proto/codec.py +126 -0
- sahiapi/sahi/proto/marketdata.proto +138 -0
- sahiapi/sahi/proto/marketdata_pb2.py +55 -0
- sahiapi/sahi/ws_client.py +355 -0
- sahiapi-0.1.0.dist-info/METADATA +97 -0
- sahiapi-0.1.0.dist-info/RECORD +26 -0
- sahiapi-0.1.0.dist-info/WHEEL +4 -0
- sahiapi-0.1.0.dist-info/licenses/LICENSE +7 -0
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)
|