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.
@@ -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()