fxsocket 0.1__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.
- fxsocket/__init__.py +134 -0
- fxsocket/_http.py +68 -0
- fxsocket/_version.py +1 -0
- fxsocket/client.py +254 -0
- fxsocket/config.py +12 -0
- fxsocket/enums.py +122 -0
- fxsocket/errors.py +163 -0
- fxsocket/management.py +118 -0
- fxsocket/models.py +391 -0
- fxsocket/py.typed +0 -0
- fxsocket/terminal/__init__.py +45 -0
- fxsocket/terminal/client.py +757 -0
- fxsocket/terminal/stream.py +594 -0
- fxsocket-0.1.dist-info/METADATA +256 -0
- fxsocket-0.1.dist-info/RECORD +17 -0
- fxsocket-0.1.dist-info/WHEEL +4 -0
- fxsocket-0.1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,594 @@
|
|
|
1
|
+
"""Per-account WebSocket streaming for the terminal API (MT4 + MT5).
|
|
2
|
+
|
|
3
|
+
The wire protocol is identical on both platforms. A connection authenticates
|
|
4
|
+
with ``?api_key=`` (WebSocket upgrades can't carry custom headers), then the
|
|
5
|
+
client subscribes to topics:
|
|
6
|
+
|
|
7
|
+
================ ============== =====================================
|
|
8
|
+
topic needs yields
|
|
9
|
+
================ ============== =====================================
|
|
10
|
+
``prices`` symbol :class:`Tick`
|
|
11
|
+
``bars`` symbol+timeframe :class:`Bar`
|
|
12
|
+
``account`` – :class:`AccountUpdate`
|
|
13
|
+
``positions`` – :class:`PositionsUpdate`
|
|
14
|
+
``trades`` – :class:`TradeUpdate`
|
|
15
|
+
``terminal`` – :class:`TerminalUpdate`
|
|
16
|
+
================ ============== =====================================
|
|
17
|
+
|
|
18
|
+
Plus control events: :class:`StreamWarning` (slow-client drop count),
|
|
19
|
+
:class:`Subscribed` / :class:`Unsubscribed` / :class:`StreamErrorEvent` /
|
|
20
|
+
:class:`Subscriptions`.
|
|
21
|
+
|
|
22
|
+
:class:`AsyncStream` is the primary, async interface; :class:`Stream` is a
|
|
23
|
+
thread-backed synchronous wrapper over it.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import asyncio
|
|
29
|
+
import json
|
|
30
|
+
import queue
|
|
31
|
+
import ssl as _ssl
|
|
32
|
+
import threading
|
|
33
|
+
from collections.abc import AsyncIterator, Iterator
|
|
34
|
+
from dataclasses import dataclass, field
|
|
35
|
+
from typing import Any
|
|
36
|
+
from urllib.parse import parse_qsl, urlencode, urlparse, urlunparse
|
|
37
|
+
|
|
38
|
+
from websockets.asyncio.client import connect as _ws_connect
|
|
39
|
+
from websockets.exceptions import ConnectionClosed
|
|
40
|
+
|
|
41
|
+
from ..enums import MT5_ONLY_TIMEFRAMES, Platform, Timeframe
|
|
42
|
+
from ..errors import StreamError, UnsupportedOnPlatformError, ValidationError
|
|
43
|
+
from ..models import (
|
|
44
|
+
AccountSummary,
|
|
45
|
+
Candle,
|
|
46
|
+
OpenedOrder,
|
|
47
|
+
Quote,
|
|
48
|
+
TerminalStatusData,
|
|
49
|
+
TradeEventData,
|
|
50
|
+
)
|
|
51
|
+
from .client import coerce_timeframe
|
|
52
|
+
|
|
53
|
+
_TOPICS = frozenset(
|
|
54
|
+
{"account", "positions", "trades", "terminal", "prices", "bars"}
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
# --------------------------------------------------------------------------- #
|
|
58
|
+
# Events (the tagged union yielded by a stream)
|
|
59
|
+
# --------------------------------------------------------------------------- #
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass(frozen=True)
|
|
63
|
+
class Tick:
|
|
64
|
+
symbol: str
|
|
65
|
+
data: Quote
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass(frozen=True)
|
|
69
|
+
class Bar:
|
|
70
|
+
symbol: str
|
|
71
|
+
timeframe: str
|
|
72
|
+
data: Candle
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclass(frozen=True)
|
|
76
|
+
class AccountUpdate:
|
|
77
|
+
data: AccountSummary
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@dataclass(frozen=True)
|
|
81
|
+
class PositionsUpdate:
|
|
82
|
+
data: list[OpenedOrder]
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@dataclass(frozen=True)
|
|
86
|
+
class TradeUpdate:
|
|
87
|
+
data: TradeEventData
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@dataclass(frozen=True)
|
|
91
|
+
class TerminalUpdate:
|
|
92
|
+
data: TerminalStatusData
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@dataclass(frozen=True)
|
|
96
|
+
class StreamWarning:
|
|
97
|
+
"""The server dropped ``dropped`` messages because this client lagged."""
|
|
98
|
+
|
|
99
|
+
dropped: int
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@dataclass(frozen=True)
|
|
103
|
+
class Subscribed:
|
|
104
|
+
topic: str
|
|
105
|
+
message: str
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@dataclass(frozen=True)
|
|
109
|
+
class Unsubscribed:
|
|
110
|
+
topic: str
|
|
111
|
+
message: str
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@dataclass(frozen=True)
|
|
115
|
+
class StreamErrorEvent:
|
|
116
|
+
"""A server-sent ``error`` frame (e.g. bad timeframe). Not an exception."""
|
|
117
|
+
|
|
118
|
+
topic: str
|
|
119
|
+
message: str
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@dataclass(frozen=True)
|
|
123
|
+
class Subscriptions:
|
|
124
|
+
data: list[dict[str, Any]]
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@dataclass(frozen=True)
|
|
128
|
+
class UnknownEvent:
|
|
129
|
+
type: str
|
|
130
|
+
raw: dict[str, Any] = field(default_factory=dict)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
StreamEvent = (
|
|
134
|
+
Tick
|
|
135
|
+
| Bar
|
|
136
|
+
| AccountUpdate
|
|
137
|
+
| PositionsUpdate
|
|
138
|
+
| TradeUpdate
|
|
139
|
+
| TerminalUpdate
|
|
140
|
+
| StreamWarning
|
|
141
|
+
| Subscribed
|
|
142
|
+
| Unsubscribed
|
|
143
|
+
| StreamErrorEvent
|
|
144
|
+
| Subscriptions
|
|
145
|
+
| UnknownEvent
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def parse_event(msg: dict[str, Any]) -> StreamEvent:
|
|
150
|
+
"""Map a decoded server frame to a typed event."""
|
|
151
|
+
t = msg.get("type")
|
|
152
|
+
if t == "tick":
|
|
153
|
+
return Tick(
|
|
154
|
+
symbol=msg.get("symbol", ""), data=Quote.model_validate(msg["data"])
|
|
155
|
+
)
|
|
156
|
+
if t == "bar":
|
|
157
|
+
return Bar(
|
|
158
|
+
symbol=msg.get("symbol", ""),
|
|
159
|
+
timeframe=msg.get("timeframe", ""),
|
|
160
|
+
data=Candle.model_validate(msg["data"]),
|
|
161
|
+
)
|
|
162
|
+
if t == "account":
|
|
163
|
+
return AccountUpdate(data=AccountSummary.model_validate(msg["data"]))
|
|
164
|
+
if t == "positions":
|
|
165
|
+
return PositionsUpdate(
|
|
166
|
+
data=[OpenedOrder.model_validate(r) for r in msg.get("data", [])]
|
|
167
|
+
)
|
|
168
|
+
if t == "trade":
|
|
169
|
+
return TradeUpdate(data=TradeEventData.model_validate(msg["data"]))
|
|
170
|
+
if t == "terminal":
|
|
171
|
+
return TerminalUpdate(data=TerminalStatusData.model_validate(msg["data"]))
|
|
172
|
+
if t == "warning":
|
|
173
|
+
return StreamWarning(dropped=int(msg.get("dropped", 0)))
|
|
174
|
+
if t == "subscribed":
|
|
175
|
+
return Subscribed(topic=msg.get("topic", ""), message=msg.get("message", ""))
|
|
176
|
+
if t == "unsubscribed":
|
|
177
|
+
return Unsubscribed(topic=msg.get("topic", ""), message=msg.get("message", ""))
|
|
178
|
+
if t == "error":
|
|
179
|
+
return StreamErrorEvent(
|
|
180
|
+
topic=msg.get("topic", ""), message=msg.get("message", "")
|
|
181
|
+
)
|
|
182
|
+
if t == "subscriptions":
|
|
183
|
+
return Subscriptions(data=list(msg.get("data", [])))
|
|
184
|
+
return UnknownEvent(type=str(t or ""), raw=msg)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
# --------------------------------------------------------------------------- #
|
|
188
|
+
# Helpers
|
|
189
|
+
# --------------------------------------------------------------------------- #
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _with_api_key(ws_url: str, api_key: str) -> str:
|
|
193
|
+
parts = urlparse(ws_url)
|
|
194
|
+
params = dict(parse_qsl(parts.query))
|
|
195
|
+
params["api_key"] = api_key
|
|
196
|
+
return urlunparse(parts._replace(query=urlencode(params)))
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _ssl_context(uri: str, verify: bool) -> _ssl.SSLContext | None:
|
|
200
|
+
if not uri.startswith("wss://"):
|
|
201
|
+
return None # ws:// — no TLS (websockets requires ssl=None here)
|
|
202
|
+
# For wss:// the websockets client rejects ssl=None, so always pass an
|
|
203
|
+
# explicit context — a verifying default, or one that skips verification
|
|
204
|
+
# for a private droplet's self-signed certificate.
|
|
205
|
+
ctx = _ssl.create_default_context()
|
|
206
|
+
if not verify:
|
|
207
|
+
ctx.check_hostname = False
|
|
208
|
+
ctx.verify_mode = _ssl.CERT_NONE
|
|
209
|
+
return ctx
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _sub_payload(
|
|
213
|
+
topic: str, symbol: str | None, timeframe: str | None
|
|
214
|
+
) -> dict[str, Any]:
|
|
215
|
+
payload: dict[str, Any] = {"action": "subscribe", "topic": topic}
|
|
216
|
+
if symbol is not None:
|
|
217
|
+
payload["symbol"] = symbol
|
|
218
|
+
if timeframe is not None:
|
|
219
|
+
payload["timeframe"] = timeframe
|
|
220
|
+
return payload
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _validate_sub(
|
|
224
|
+
topic: str,
|
|
225
|
+
symbol: str | None,
|
|
226
|
+
timeframe: Timeframe | str | None,
|
|
227
|
+
platform: Platform,
|
|
228
|
+
) -> str | None:
|
|
229
|
+
"""Validate a subscription; returns the canonical timeframe label (or None)."""
|
|
230
|
+
if topic not in _TOPICS:
|
|
231
|
+
raise ValidationError(
|
|
232
|
+
f"unknown topic {topic!r}; one of {sorted(_TOPICS)}"
|
|
233
|
+
)
|
|
234
|
+
if topic in ("prices", "bars") and not symbol:
|
|
235
|
+
raise ValidationError(f"topic {topic!r} requires a symbol")
|
|
236
|
+
if topic == "bars":
|
|
237
|
+
if not timeframe:
|
|
238
|
+
raise ValidationError("topic 'bars' requires a timeframe")
|
|
239
|
+
tf = coerce_timeframe(timeframe)
|
|
240
|
+
if platform is Platform.MT4 and tf in MT5_ONLY_TIMEFRAMES:
|
|
241
|
+
raise UnsupportedOnPlatformError(
|
|
242
|
+
f"{tf.value} is an MT5-only timeframe; not available on MT4."
|
|
243
|
+
)
|
|
244
|
+
return tf.value
|
|
245
|
+
return None
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
# --------------------------------------------------------------------------- #
|
|
249
|
+
# Async stream
|
|
250
|
+
# --------------------------------------------------------------------------- #
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
class AsyncStream:
|
|
254
|
+
"""An async WebSocket stream bound to one account's terminal.
|
|
255
|
+
|
|
256
|
+
Use as an async context manager and iterate it::
|
|
257
|
+
|
|
258
|
+
async with client.stream(account) as s:
|
|
259
|
+
await s.subscribe_prices("EURUSD")
|
|
260
|
+
async for event in s:
|
|
261
|
+
match event:
|
|
262
|
+
case Tick():
|
|
263
|
+
...
|
|
264
|
+
|
|
265
|
+
With ``auto_reconnect`` (default), a dropped connection is transparently
|
|
266
|
+
re-established and all active subscriptions are replayed.
|
|
267
|
+
"""
|
|
268
|
+
|
|
269
|
+
def __init__(
|
|
270
|
+
self,
|
|
271
|
+
*,
|
|
272
|
+
ws_url: str,
|
|
273
|
+
api_key: str,
|
|
274
|
+
platform: Platform | str,
|
|
275
|
+
verify: bool = True,
|
|
276
|
+
auto_reconnect: bool = True,
|
|
277
|
+
open_timeout: float = 10.0,
|
|
278
|
+
ping_interval: float = 20.0,
|
|
279
|
+
max_reconnect_attempts: int = 5,
|
|
280
|
+
) -> None:
|
|
281
|
+
self.platform = Platform(platform)
|
|
282
|
+
self._uri = _with_api_key(ws_url, api_key)
|
|
283
|
+
self._ssl = _ssl_context(self._uri, verify)
|
|
284
|
+
self._auto_reconnect = auto_reconnect
|
|
285
|
+
self._open_timeout = open_timeout
|
|
286
|
+
self._ping_interval = ping_interval
|
|
287
|
+
self._max_attempts = max_reconnect_attempts
|
|
288
|
+
self._conn: Any = None
|
|
289
|
+
self._closing = False
|
|
290
|
+
#: serializes connection swaps (reconnect) against sends (subscribe)
|
|
291
|
+
self._lock = asyncio.Lock()
|
|
292
|
+
#: active subscriptions as (topic, symbol, timeframe), replayed on reconnect
|
|
293
|
+
self._subs: set[tuple[str, str | None, str | None]] = set()
|
|
294
|
+
|
|
295
|
+
async def _open(self) -> None:
|
|
296
|
+
self._conn = await _ws_connect(
|
|
297
|
+
self._uri,
|
|
298
|
+
ssl=self._ssl,
|
|
299
|
+
open_timeout=self._open_timeout,
|
|
300
|
+
ping_interval=self._ping_interval,
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
async def connect(self) -> None:
|
|
304
|
+
try:
|
|
305
|
+
await self._open()
|
|
306
|
+
except OSError as exc: # DNS/refused/TLS at connect time
|
|
307
|
+
raise StreamError(f"failed to connect to terminal stream: {exc}") from exc
|
|
308
|
+
|
|
309
|
+
async def _send(self, payload: dict[str, Any]) -> None:
|
|
310
|
+
async with self._lock:
|
|
311
|
+
if self._conn is None:
|
|
312
|
+
raise StreamError("stream is not connected")
|
|
313
|
+
await self._conn.send(json.dumps(payload))
|
|
314
|
+
|
|
315
|
+
async def subscribe(
|
|
316
|
+
self,
|
|
317
|
+
topic: str,
|
|
318
|
+
*,
|
|
319
|
+
symbol: str | None = None,
|
|
320
|
+
timeframe: Timeframe | str | None = None,
|
|
321
|
+
) -> None:
|
|
322
|
+
tf = _validate_sub(topic, symbol, timeframe, self.platform)
|
|
323
|
+
self._subs.add((topic, symbol, tf))
|
|
324
|
+
await self._send(_sub_payload(topic, symbol, tf))
|
|
325
|
+
|
|
326
|
+
async def unsubscribe(
|
|
327
|
+
self,
|
|
328
|
+
topic: str,
|
|
329
|
+
*,
|
|
330
|
+
symbol: str | None = None,
|
|
331
|
+
timeframe: Timeframe | str | None = None,
|
|
332
|
+
) -> None:
|
|
333
|
+
tf = _validate_sub(topic, symbol, timeframe, self.platform)
|
|
334
|
+
self._subs.discard((topic, symbol, tf))
|
|
335
|
+
payload = _sub_payload(topic, symbol, tf)
|
|
336
|
+
payload["action"] = "unsubscribe"
|
|
337
|
+
await self._send(payload)
|
|
338
|
+
|
|
339
|
+
# convenience wrappers ------------------------------------------------- #
|
|
340
|
+
|
|
341
|
+
async def subscribe_prices(self, symbol: str) -> None:
|
|
342
|
+
await self.subscribe("prices", symbol=symbol)
|
|
343
|
+
|
|
344
|
+
async def subscribe_bars(self, symbol: str, timeframe: Timeframe | str) -> None:
|
|
345
|
+
await self.subscribe("bars", symbol=symbol, timeframe=timeframe)
|
|
346
|
+
|
|
347
|
+
async def subscribe_account(self) -> None:
|
|
348
|
+
await self.subscribe("account")
|
|
349
|
+
|
|
350
|
+
async def subscribe_positions(self) -> None:
|
|
351
|
+
await self.subscribe("positions")
|
|
352
|
+
|
|
353
|
+
async def subscribe_trades(self) -> None:
|
|
354
|
+
await self.subscribe("trades")
|
|
355
|
+
|
|
356
|
+
async def subscribe_terminal(self) -> None:
|
|
357
|
+
await self.subscribe("terminal")
|
|
358
|
+
|
|
359
|
+
async def unsubscribe_prices(self, symbol: str) -> None:
|
|
360
|
+
await self.unsubscribe("prices", symbol=symbol)
|
|
361
|
+
|
|
362
|
+
async def unsubscribe_bars(self, symbol: str, timeframe: Timeframe | str) -> None:
|
|
363
|
+
await self.unsubscribe("bars", symbol=symbol, timeframe=timeframe)
|
|
364
|
+
|
|
365
|
+
async def unsubscribe_account(self) -> None:
|
|
366
|
+
await self.unsubscribe("account")
|
|
367
|
+
|
|
368
|
+
async def unsubscribe_positions(self) -> None:
|
|
369
|
+
await self.unsubscribe("positions")
|
|
370
|
+
|
|
371
|
+
async def unsubscribe_trades(self) -> None:
|
|
372
|
+
await self.unsubscribe("trades")
|
|
373
|
+
|
|
374
|
+
async def unsubscribe_terminal(self) -> None:
|
|
375
|
+
await self.unsubscribe("terminal")
|
|
376
|
+
|
|
377
|
+
async def list_subscriptions(self) -> None:
|
|
378
|
+
"""Ask the server to echo current subscriptions (a ``Subscriptions``)."""
|
|
379
|
+
await self._send({"action": "list"})
|
|
380
|
+
|
|
381
|
+
# iteration ------------------------------------------------------------ #
|
|
382
|
+
|
|
383
|
+
async def __aiter__(self) -> AsyncIterator[StreamEvent]:
|
|
384
|
+
while True:
|
|
385
|
+
conn = self._conn
|
|
386
|
+
if self._closing or conn is None:
|
|
387
|
+
return
|
|
388
|
+
try:
|
|
389
|
+
raw = await conn.recv()
|
|
390
|
+
# ConnectionClosed is the clean case; OSError covers transport
|
|
391
|
+
# errors (TLS/socket); both mean "the connection is gone".
|
|
392
|
+
except (ConnectionClosed, OSError) as exc:
|
|
393
|
+
if self._closing or not self._auto_reconnect:
|
|
394
|
+
return
|
|
395
|
+
if not await self._reconnect():
|
|
396
|
+
raise StreamError(
|
|
397
|
+
"terminal stream dropped and could not be re-established"
|
|
398
|
+
) from exc
|
|
399
|
+
continue
|
|
400
|
+
try:
|
|
401
|
+
msg = json.loads(raw)
|
|
402
|
+
except (ValueError, TypeError):
|
|
403
|
+
continue
|
|
404
|
+
if isinstance(msg, dict):
|
|
405
|
+
yield parse_event(msg)
|
|
406
|
+
|
|
407
|
+
async def _reconnect(self) -> bool:
|
|
408
|
+
"""Reconnect and replay subscriptions. Returns False if it gave up.
|
|
409
|
+
|
|
410
|
+
A failure mid-replay (the fresh socket drops again) is treated as a
|
|
411
|
+
failed attempt and retried — it must never escape and kill iteration.
|
|
412
|
+
Duplicate replays after a partial failure are harmless: the server
|
|
413
|
+
answers ``already subscribed``.
|
|
414
|
+
"""
|
|
415
|
+
delay = 0.5
|
|
416
|
+
for attempt in range(self._max_attempts):
|
|
417
|
+
if attempt:
|
|
418
|
+
await asyncio.sleep(delay)
|
|
419
|
+
delay = min(delay * 2, 30.0)
|
|
420
|
+
if self._closing:
|
|
421
|
+
return False
|
|
422
|
+
try:
|
|
423
|
+
async with self._lock:
|
|
424
|
+
await self._open()
|
|
425
|
+
for topic, symbol, tf in list(self._subs):
|
|
426
|
+
payload = _sub_payload(topic, symbol, tf)
|
|
427
|
+
await self._conn.send(json.dumps(payload))
|
|
428
|
+
return True
|
|
429
|
+
except (StreamError, ConnectionClosed, OSError):
|
|
430
|
+
continue
|
|
431
|
+
return False
|
|
432
|
+
|
|
433
|
+
async def aclose(self) -> None:
|
|
434
|
+
self._closing = True
|
|
435
|
+
if self._conn is not None:
|
|
436
|
+
await self._conn.close()
|
|
437
|
+
self._conn = None
|
|
438
|
+
|
|
439
|
+
async def __aenter__(self) -> AsyncStream:
|
|
440
|
+
await self.connect()
|
|
441
|
+
return self
|
|
442
|
+
|
|
443
|
+
async def __aexit__(self, *exc: object) -> None:
|
|
444
|
+
await self.aclose()
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
# --------------------------------------------------------------------------- #
|
|
448
|
+
# Sync wrapper
|
|
449
|
+
# --------------------------------------------------------------------------- #
|
|
450
|
+
|
|
451
|
+
_CLOSED = object()
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
class Stream:
|
|
455
|
+
"""Synchronous wrapper over :class:`AsyncStream`.
|
|
456
|
+
|
|
457
|
+
Runs the async stream on a dedicated event loop in a background thread and
|
|
458
|
+
exposes a blocking iterator::
|
|
459
|
+
|
|
460
|
+
with client.stream(account) as s:
|
|
461
|
+
s.subscribe_prices("EURUSD")
|
|
462
|
+
for event in s:
|
|
463
|
+
...
|
|
464
|
+
"""
|
|
465
|
+
|
|
466
|
+
def __init__(self, factory: Any) -> None:
|
|
467
|
+
# ``factory`` builds the (unconnected) AsyncStream inside the loop thread.
|
|
468
|
+
self._loop = asyncio.new_event_loop()
|
|
469
|
+
self._thread = threading.Thread(target=self._loop.run_forever, daemon=True)
|
|
470
|
+
self._thread.start()
|
|
471
|
+
self._astream: AsyncStream = factory()
|
|
472
|
+
self._queue: queue.Queue[Any] = queue.Queue()
|
|
473
|
+
self._consumer: Any = None
|
|
474
|
+
self._closed = False
|
|
475
|
+
|
|
476
|
+
def _run(self, coro: Any, *, timeout: float | None = None) -> Any:
|
|
477
|
+
try:
|
|
478
|
+
return asyncio.run_coroutine_threadsafe(coro, self._loop).result(
|
|
479
|
+
timeout=timeout
|
|
480
|
+
)
|
|
481
|
+
except TimeoutError as exc:
|
|
482
|
+
raise StreamError("terminal stream operation timed out") from exc
|
|
483
|
+
|
|
484
|
+
async def _consume(self) -> None:
|
|
485
|
+
try:
|
|
486
|
+
async for event in self._astream:
|
|
487
|
+
self._queue.put(event)
|
|
488
|
+
finally:
|
|
489
|
+
self._queue.put(_CLOSED)
|
|
490
|
+
|
|
491
|
+
# lifecycle ------------------------------------------------------------ #
|
|
492
|
+
|
|
493
|
+
def connect(self) -> Stream:
|
|
494
|
+
self._run(self._astream.connect())
|
|
495
|
+
self._consumer = asyncio.run_coroutine_threadsafe(self._consume(), self._loop)
|
|
496
|
+
return self
|
|
497
|
+
|
|
498
|
+
def subscribe(
|
|
499
|
+
self,
|
|
500
|
+
topic: str,
|
|
501
|
+
*,
|
|
502
|
+
symbol: str | None = None,
|
|
503
|
+
timeframe: Timeframe | str | None = None,
|
|
504
|
+
) -> None:
|
|
505
|
+
self._run(self._astream.subscribe(topic, symbol=symbol, timeframe=timeframe))
|
|
506
|
+
|
|
507
|
+
def subscribe_prices(self, symbol: str) -> None:
|
|
508
|
+
self._run(self._astream.subscribe_prices(symbol))
|
|
509
|
+
|
|
510
|
+
def subscribe_bars(self, symbol: str, timeframe: Timeframe | str) -> None:
|
|
511
|
+
self._run(self._astream.subscribe_bars(symbol, timeframe))
|
|
512
|
+
|
|
513
|
+
def subscribe_account(self) -> None:
|
|
514
|
+
self._run(self._astream.subscribe_account())
|
|
515
|
+
|
|
516
|
+
def subscribe_positions(self) -> None:
|
|
517
|
+
self._run(self._astream.subscribe_positions())
|
|
518
|
+
|
|
519
|
+
def subscribe_trades(self) -> None:
|
|
520
|
+
self._run(self._astream.subscribe_trades())
|
|
521
|
+
|
|
522
|
+
def subscribe_terminal(self) -> None:
|
|
523
|
+
self._run(self._astream.subscribe_terminal())
|
|
524
|
+
|
|
525
|
+
def unsubscribe(
|
|
526
|
+
self,
|
|
527
|
+
topic: str,
|
|
528
|
+
*,
|
|
529
|
+
symbol: str | None = None,
|
|
530
|
+
timeframe: Timeframe | str | None = None,
|
|
531
|
+
) -> None:
|
|
532
|
+
self._run(self._astream.unsubscribe(topic, symbol=symbol, timeframe=timeframe))
|
|
533
|
+
|
|
534
|
+
def unsubscribe_prices(self, symbol: str) -> None:
|
|
535
|
+
self._run(self._astream.unsubscribe_prices(symbol))
|
|
536
|
+
|
|
537
|
+
def unsubscribe_bars(self, symbol: str, timeframe: Timeframe | str) -> None:
|
|
538
|
+
self._run(self._astream.unsubscribe_bars(symbol, timeframe))
|
|
539
|
+
|
|
540
|
+
def unsubscribe_account(self) -> None:
|
|
541
|
+
self._run(self._astream.unsubscribe_account())
|
|
542
|
+
|
|
543
|
+
def unsubscribe_positions(self) -> None:
|
|
544
|
+
self._run(self._astream.unsubscribe_positions())
|
|
545
|
+
|
|
546
|
+
def unsubscribe_trades(self) -> None:
|
|
547
|
+
self._run(self._astream.unsubscribe_trades())
|
|
548
|
+
|
|
549
|
+
def unsubscribe_terminal(self) -> None:
|
|
550
|
+
self._run(self._astream.unsubscribe_terminal())
|
|
551
|
+
|
|
552
|
+
def list_subscriptions(self) -> None:
|
|
553
|
+
self._run(self._astream.list_subscriptions())
|
|
554
|
+
|
|
555
|
+
def __iter__(self) -> Iterator[StreamEvent]:
|
|
556
|
+
if self._consumer is None:
|
|
557
|
+
raise RuntimeError(
|
|
558
|
+
"Stream is not connected — use `with client.stream(account) as s:` "
|
|
559
|
+
"(or call .connect()) before iterating."
|
|
560
|
+
)
|
|
561
|
+
while True:
|
|
562
|
+
event = self._queue.get()
|
|
563
|
+
if event is _CLOSED:
|
|
564
|
+
return
|
|
565
|
+
yield event
|
|
566
|
+
|
|
567
|
+
def close(self) -> None:
|
|
568
|
+
if self._closed:
|
|
569
|
+
return
|
|
570
|
+
self._closed = True
|
|
571
|
+
try:
|
|
572
|
+
# aclose() flags the stream closing, so the consumer's async-for
|
|
573
|
+
# ends instead of reconnecting; then wait for it to drain.
|
|
574
|
+
try:
|
|
575
|
+
self._run(self._astream.aclose(), timeout=5.0)
|
|
576
|
+
except Exception:
|
|
577
|
+
pass
|
|
578
|
+
if self._consumer is not None:
|
|
579
|
+
try:
|
|
580
|
+
self._consumer.result(timeout=5.0)
|
|
581
|
+
except Exception:
|
|
582
|
+
pass
|
|
583
|
+
finally:
|
|
584
|
+
# Guarantee a waiting iterator wakes even if the consumer's own
|
|
585
|
+
# sentinel never landed (hung task, killed loop).
|
|
586
|
+
self._queue.put(_CLOSED)
|
|
587
|
+
self._loop.call_soon_threadsafe(self._loop.stop)
|
|
588
|
+
self._thread.join(timeout=2.0)
|
|
589
|
+
|
|
590
|
+
def __enter__(self) -> Stream:
|
|
591
|
+
return self.connect()
|
|
592
|
+
|
|
593
|
+
def __exit__(self, *exc: object) -> None:
|
|
594
|
+
self.close()
|