dnse-sdk-openapi 0.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.
Files changed (50) hide show
  1. broker-api/get_list_care_by.py +22 -0
  2. dnse/__init__.py +4 -0
  3. dnse/client.py +408 -0
  4. dnse/common.py +103 -0
  5. dnse_sdk_openapi-0.0.1.dist-info/METADATA +120 -0
  6. dnse_sdk_openapi-0.0.1.dist-info/RECORD +50 -0
  7. dnse_sdk_openapi-0.0.1.dist-info/WHEEL +5 -0
  8. dnse_sdk_openapi-0.0.1.dist-info/top_level.txt +5 -0
  9. marketdata-api/get_instruments.py +22 -0
  10. marketdata-api/get_latest_trade.py +22 -0
  11. marketdata-api/get_ohlc.py +31 -0
  12. marketdata-api/get_security_definition.py +22 -0
  13. marketdata-api/get_trades.py +22 -0
  14. marketdata-api/get_working_dates.py +22 -0
  15. trading-api/cancel_order.py +29 -0
  16. trading-api/close_position.py +27 -0
  17. trading-api/create_trading_token.py +26 -0
  18. trading-api/get_accounts.py +22 -0
  19. trading-api/get_balances.py +22 -0
  20. trading-api/get_close_price.py +22 -0
  21. trading-api/get_execution_detail.py +28 -0
  22. trading-api/get_loan_packages.py +27 -0
  23. trading-api/get_order_detail.py +28 -0
  24. trading-api/get_order_history.py +30 -0
  25. trading-api/get_orders.py +27 -0
  26. trading-api/get_position_by_id.py +26 -0
  27. trading-api/get_positions.py +26 -0
  28. trading-api/get_ppse.py +29 -0
  29. trading-api/post_order.py +38 -0
  30. trading-api/put_order.py +35 -0
  31. trading-api/send_email_otp.py +22 -0
  32. websocket-marketdata/expected_price.py +53 -0
  33. websocket-marketdata/foreign_investor.py +51 -0
  34. websocket-marketdata/market_index.py +52 -0
  35. websocket-marketdata/ohlc.py +55 -0
  36. websocket-marketdata/ohlc_closed.py +55 -0
  37. websocket-marketdata/order.py +51 -0
  38. websocket-marketdata/quote.py +50 -0
  39. websocket-marketdata/sec_def.py +52 -0
  40. websocket-marketdata/trade.py +52 -0
  41. websocket-marketdata/trade_extra.py +51 -0
  42. websocket-marketdata/trading_websocket/__init__.py +33 -0
  43. websocket-marketdata/trading_websocket/_version.py +3 -0
  44. websocket-marketdata/trading_websocket/auth.py +59 -0
  45. websocket-marketdata/trading_websocket/client.py +790 -0
  46. websocket-marketdata/trading_websocket/connection.py +151 -0
  47. websocket-marketdata/trading_websocket/encoding.py +78 -0
  48. websocket-marketdata/trading_websocket/exceptions.py +38 -0
  49. websocket-marketdata/trading_websocket/models.py +525 -0
  50. websocket-marketdata/trading_websocket/py.typed +0 -0
@@ -0,0 +1,790 @@
1
+ """
2
+ TradingClient - High-level async WebSocket client for real-time trading data.
3
+
4
+ This module provides the main TradingClient class that handles:
5
+ - WebSocket connection management with automatic reconnection
6
+ - HMAC authentication
7
+ - Channel subscriptions (market data and private channels)
8
+ - Event-driven message handling
9
+ - Heartbeat monitoring
10
+ - Graceful shutdown
11
+ """
12
+
13
+ from typing import Optional, Callable, List, Dict, Any
14
+ import asyncio
15
+ import logging
16
+ import time
17
+ from .connection import WebSocketConnection
18
+ from .auth import AuthManager
19
+ from .encoding import MessageEncoder, MessageDecoder
20
+ from .models import Trade, Quote, Ohlc, Order, Position, AccountUpdate, ExpectedPrice, SecurityDefinition, TradeExtra, \
21
+ MarketIndex, ForeignInvestor
22
+ from .exceptions import (
23
+ AuthenticationError,
24
+ ConnectionError,
25
+ SubscriptionError,
26
+ ConnectionClosed,
27
+ )
28
+
29
+ logger = logging.getLogger(__name__)
30
+ logger.setLevel(logging.INFO)
31
+ if not logger.handlers:
32
+ handler = logging.StreamHandler()
33
+ handler.setLevel(logging.INFO)
34
+ formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
35
+ handler.setFormatter(formatter)
36
+ logger.addHandler(handler)
37
+
38
+ DEFAULT_BOARDS = ["G1", "G3", "G4", "G7", "T1", "T2", "T3", "T4", "T6"]
39
+
40
+ _MSG_TYPE_MAP = {
41
+ "t": ("trade", Trade, None),
42
+ "te": ("trade_extra", TradeExtra, None),
43
+ "e": ("expected_price", ExpectedPrice, None),
44
+ "sd": ("security_definition", SecurityDefinition, None),
45
+ "q": ("quote", Quote, None),
46
+ "b": ("ohlc", Ohlc, None),
47
+ "bc": ("ohlc_closed", Ohlc, None),
48
+ "do": ("order_event", Order, "order"),
49
+ "eo": ("order_event", Order, "order"),
50
+ "p": ("position", Position, None),
51
+ "mi": ("market_index", MarketIndex, None),
52
+ "a": ("account", AccountUpdate, None),
53
+ "f": ("foreign", ForeignInvestor, None),
54
+ }
55
+
56
+
57
+ class TradingClient:
58
+ """
59
+ Async WebSocket client for real-time trading data.
60
+
61
+ Features:
62
+ - Automatic reconnection with re-authentication and re-subscription
63
+ - HMAC-SHA256 authentication
64
+ - Support for JSON and MessagePack encoding
65
+ - Event-driven architecture with callback handlers
66
+ - Heartbeat monitoring with pong tracking
67
+ - Context manager support (async with)
68
+ - Async iterator support (async for)
69
+ """
70
+
71
+ def __init__(
72
+ self,
73
+ api_key: str,
74
+ api_secret: str,
75
+ base_url: str = "wss://ws-openapi.dnse.com.vn",
76
+ encoding: str = "json", # or "json"
77
+ auto_reconnect: bool = True,
78
+ max_retries: int = 10,
79
+ heartbeat_interval: float = 25.0,
80
+ timeout: float = 60.0,
81
+ ):
82
+ """
83
+ Initialize trading client.
84
+
85
+ Args:
86
+ api_key: API key for authentication
87
+ api_secret: API secret for HMAC signature
88
+ base_url: WebSocket gateway URL
89
+ encoding: Message encoding ("json" or "msgpack")
90
+ auto_reconnect: Enable automatic reconnection
91
+ max_retries: Maximum reconnection attempts
92
+ heartbeat_interval: Seconds between heartbeat pings
93
+ timeout: Connection timeout in seconds
94
+ """
95
+ self.api_key = api_key
96
+ self.api_secret = api_secret
97
+ self.base_url = base_url
98
+ self.encoding = encoding
99
+ self.auto_reconnect = auto_reconnect
100
+ self.max_retries = max_retries
101
+ self.heartbeat_interval = heartbeat_interval
102
+ self.timeout = timeout
103
+
104
+ # Internal state
105
+ self._connection: Optional[WebSocketConnection] = None
106
+ self._auth_manager = AuthManager(api_key, api_secret)
107
+ self._encoder = MessageEncoder(encoding)
108
+ self._decoder = MessageDecoder(encoding)
109
+ self._event_handlers: Dict[str, List[Callable]] = {}
110
+ self._subscriptions: Dict[str, Dict[str, Any]] = {}
111
+ self._is_authenticated = False
112
+ self._session_id: Optional[str] = None
113
+ # Per-event queues: only created when needed (async iterator usage)
114
+ self._queues: Dict[str, asyncio.Queue] = {}
115
+ # Internal dispatch queues: 1 queue per worker, symbol hashed to worker
116
+ self._dispatch_queues: List[asyncio.Queue] = []
117
+ self._is_running = False
118
+ self._last_pong_time: float = 0.0
119
+ self._heartbeat_task: Optional[asyncio.Task] = None
120
+ self._message_handler_task: Optional[asyncio.Task] = None
121
+ self._dispatch_worker_tasks: List[asyncio.Task] = []
122
+ self._num_workers: int = 6 # 1 worker per symbol slot
123
+
124
+ async def connect(self) -> None:
125
+ """
126
+ Establish WebSocket connection and authenticate.
127
+
128
+ Raises:
129
+ ConnectionError: Failed to connect
130
+ AuthenticationError: Authentication failed
131
+ asyncio.TimeoutError: Connection timeout
132
+ """
133
+ url = f"{self.base_url}/v1/stream?encoding={self.encoding}"
134
+
135
+ logger.info(f"Connecting to {url}")
136
+
137
+ self._connection = WebSocketConnection(
138
+ url=url,
139
+ timeout=self.timeout,
140
+ heartbeat_interval=self.heartbeat_interval,
141
+ auto_reconnect=self.auto_reconnect,
142
+ max_retries=self.max_retries,
143
+ )
144
+
145
+ await self._connection.connect()
146
+
147
+ # Wait for welcome message
148
+ welcome = await asyncio.wait_for(
149
+ self._connection.receive(), timeout=self.timeout
150
+ )
151
+
152
+ welcome_data = self._decoder.decode(welcome)
153
+ self._session_id = welcome_data.get("session_id") or welcome_data.get("sid")
154
+
155
+ logger.info(f"Connected! Session ID: {self._session_id}")
156
+
157
+ # Authenticate
158
+ await self._authenticate()
159
+
160
+ # Start background tasks
161
+ self._is_running = True
162
+ self._last_pong_time = time.time()
163
+
164
+ # Init 1 queue per worker slot
165
+ self._dispatch_queues = [asyncio.Queue() for _ in range(self._num_workers)]
166
+
167
+ # Start dispatch workers (1 per slot, same symbol → same worker → ordered)
168
+ self._dispatch_worker_tasks = [
169
+ asyncio.create_task(self._dispatch_worker(i))
170
+ for i in range(self._num_workers)
171
+ ]
172
+
173
+ # Start message handler (receive + decode only)
174
+ self._message_handler_task = asyncio.create_task(self._message_handler())
175
+
176
+ # Start heartbeat
177
+ if self.heartbeat_interval > 0:
178
+ self._heartbeat_task = asyncio.create_task(self._heartbeat_loop())
179
+
180
+ async def _authenticate(self) -> None:
181
+ """
182
+ Perform HMAC authentication.
183
+
184
+ Raises:
185
+ AuthenticationError: Authentication failed
186
+ asyncio.TimeoutError: Authentication timeout
187
+ """
188
+ auth_msg = self._auth_manager.create_auth_message()
189
+ encoded = self._encoder.encode(auth_msg)
190
+
191
+ await self._connection.send(encoded)
192
+
193
+ # Wait for auth response
194
+ response = await asyncio.wait_for(
195
+ self._connection.receive(), timeout=self.timeout
196
+ )
197
+
198
+ data = self._decoder.decode(response)
199
+ action = data.get("action") or data.get("a")
200
+
201
+ if action == "auth_success":
202
+ self._is_authenticated = True
203
+ logger.info("Authentication successful")
204
+ elif action in ("auth_error", "error"):
205
+ error_msg = data.get("message") or data.get("msg", "Unknown error")
206
+ raise AuthenticationError(f"Authentication failed: {error_msg}")
207
+ else:
208
+ raise AuthenticationError(f"Unexpected response: {action}")
209
+
210
+ async def subscribe_trades(
211
+ self, symbols: List[str], on_trade: Optional[Callable[[Trade], None]] = None, encoding="json", board_id=None
212
+ ) -> None:
213
+ boards = [board_id] if board_id is not None else DEFAULT_BOARDS
214
+
215
+ for board in boards:
216
+ channel = f"tick.{board}.json"
217
+ if encoding == "msgpack":
218
+ channel = f"tick.{board}.msgpack"
219
+ await self._subscribe_channel(channel, symbols)
220
+
221
+ if on_trade:
222
+ _handler = self._make_filtered_handler(board_id, "boardId", on_trade) if board_id is not None else on_trade
223
+ self.on("trade", _handler)
224
+
225
+ async def subscribe_trade_extra(
226
+ self, symbols: List[str], on_trade_extra: Optional[Callable[[TradeExtra], None]] = None, encoding="json",
227
+ board_id=None
228
+ ) -> None:
229
+ boards = [board_id] if board_id is not None else DEFAULT_BOARDS
230
+
231
+ for board in boards:
232
+ channel = f"tick_extra.{board}.json"
233
+ if encoding == "msgpack":
234
+ channel = f"tick_extra.{board}.msgpack"
235
+ await self._subscribe_channel(channel, symbols)
236
+
237
+ if on_trade_extra:
238
+ _handler = self._make_filtered_handler(board_id, "boardId", on_trade_extra) if board_id is not None else on_trade_extra
239
+ self.on("trade_extra", _handler)
240
+
241
+ async def subscribe_expected_price(
242
+ self, symbols: List[str], on_expected_price: Optional[Callable[[ExpectedPrice], None]] = None,
243
+ encoding="json", board_id=None
244
+ ) -> None:
245
+ boards = [board_id] if board_id is not None else DEFAULT_BOARDS
246
+
247
+ for board in boards:
248
+ channel = f"expected_price.{board}.json"
249
+ if encoding == "msgpack":
250
+ channel = f"expected_price.{board}.msgpack"
251
+ await self._subscribe_channel(channel, symbols)
252
+
253
+ if on_expected_price:
254
+ _handler = self._make_filtered_handler(board_id, "boardId", on_expected_price) if board_id is not None else on_expected_price
255
+ self.on("expected_price", _handler)
256
+
257
+ async def subscribe_order_event(
258
+ self, market_type="STOCK",
259
+ on_order_event: Optional[Callable[[Order], None]] = None,
260
+ encoding="json"
261
+ ) -> None:
262
+ channel = f"order.{market_type}.{encoding}"
263
+ await self._subscribe_channel(channel, [])
264
+
265
+ if on_order_event:
266
+ self.on("order_event", on_order_event)
267
+
268
+ async def subscribe_sec_def(
269
+ self, symbols: List[str], on_sec_def: Optional[Callable[[SecurityDefinition], None]] = None,
270
+ encoding="json", board_id=None
271
+ ) -> None:
272
+ boards = [board_id] if board_id is not None else DEFAULT_BOARDS
273
+
274
+ for board in boards:
275
+ channel = f"security_definition.{board}.json"
276
+ if encoding == "msgpack":
277
+ channel = f"security_definition.{board}.msgpack"
278
+ await self._subscribe_channel(channel, symbols)
279
+
280
+ if on_sec_def:
281
+ _handler = self._make_filtered_handler(board_id, "boardId", on_sec_def) if board_id is not None else on_sec_def
282
+ self.on("security_definition", _handler)
283
+
284
+ async def subscribe_market_index(
285
+ self, market_index: str, on_market_index: Optional[Callable[[MarketIndex], None]] = None, encoding="json"
286
+ ) -> None:
287
+ channel = f"market_index.{market_index}.json"
288
+ if encoding == "msgpack":
289
+ channel = f"market_index.{market_index}.msgpack"
290
+ await self._subscribe_channel(channel, [])
291
+
292
+ if on_market_index:
293
+ self.on("market_index", on_market_index)
294
+
295
+ async def subscribe_quotes(
296
+ self, symbols: List[str], on_quote: Optional[Callable[[Quote], None]] = None, encoding="json", board_id=None
297
+ ) -> None:
298
+ boards = [board_id] if board_id is not None else ["G1", "G2", "G3", "G4", "G5", "G6", "G7"]
299
+
300
+ for board in boards:
301
+ channel = f"top_price.{board}.json"
302
+ if encoding == "msgpack":
303
+ channel = f"top_price.{board}.msgpack"
304
+ await self._subscribe_channel(channel, symbols)
305
+
306
+ if on_quote:
307
+ _handler = self._make_filtered_handler(board_id, "boardId", on_quote) if board_id is not None else on_quote
308
+ self.on("quote", _handler)
309
+
310
+ async def subscribe_foreign_trading(
311
+ self, symbols: List[str], board_id: str = "*",
312
+ on_trade: Optional[Callable[[ForeignInvestor], None]] = None,
313
+ encoding="json"
314
+ ) -> None:
315
+ ext = "msgpack" if encoding == "msgpack" else "json"
316
+ channel = f"foreign.{board_id}.{ext}"
317
+ await self._subscribe_channel(channel, symbols)
318
+
319
+ if on_trade:
320
+ self.on("foreign", on_trade)
321
+
322
+ async def subscribe_ohlc(
323
+ self,
324
+ symbols: List[str],
325
+ resolution: Optional[str] = None,
326
+ on_ohlc: Optional[Callable[[Ohlc], None]] = None, encoding="json"
327
+ ) -> None:
328
+ # If resolution is None, subscribe to all resolutions
329
+ if resolution is None:
330
+ all_resolutions = ["1", "3", "5", "15", "30", "1H", "1D", "1W"]
331
+ for res in all_resolutions:
332
+ channel = "ohlc." + res + ".json"
333
+ if encoding == "msgpack":
334
+ channel = "ohlc." + res + ".msgpack"
335
+ await self._subscribe_channel(channel, symbols)
336
+ else:
337
+ channel = "ohlc." + resolution + ".json"
338
+ if encoding == "msgpack":
339
+ channel = "ohlc." + resolution + ".msgpack"
340
+ await self._subscribe_channel(channel, symbols)
341
+
342
+ if on_ohlc:
343
+ self.on("ohlc", on_ohlc)
344
+
345
+ async def subscribe_ohlc_closed(
346
+ self,
347
+ symbols: List[str],
348
+ resolution: Optional[str] = None,
349
+ on_ohlc: Optional[Callable[[Ohlc], None]] = None, encoding="json"
350
+ ) -> None:
351
+ # If resolution is None, subscribe to all resolutions
352
+ if resolution is None:
353
+ all_resolutions = ["1", "3", "5", "15", "30", "1H", "1D", "1W"]
354
+ for res in all_resolutions:
355
+ channel = "ohlc_closed." + res + ".json"
356
+ if encoding == "msgpack":
357
+ channel = "ohlc_closed." + res + ".msgpack"
358
+ await self._subscribe_channel(channel, symbols)
359
+ else:
360
+ channel = "ohlc_closed." + resolution + ".json"
361
+ if encoding == "msgpack":
362
+ channel = "ohlc_closed." + resolution + ".msgpack"
363
+ await self._subscribe_channel(channel, symbols)
364
+
365
+ if on_ohlc:
366
+ self.on("ohlc_closed", on_ohlc)
367
+
368
+ async def subscribe_orders(
369
+ self, on_order: Optional[Callable[[Order], None]] = None
370
+ ) -> None:
371
+ await self._subscribe_channel("orders", [])
372
+
373
+ if on_order:
374
+ self.on("order", on_order)
375
+
376
+ async def subscribe_positions(
377
+ self, on_position: Optional[Callable[[Position], None]] = None
378
+ ) -> None:
379
+
380
+ await self._subscribe_channel("positions", [])
381
+
382
+ if on_position:
383
+ self.on("position", on_position)
384
+
385
+ async def subscribe_account(
386
+ self, on_account: Optional[Callable[[AccountUpdate], None]] = None
387
+ ) -> None:
388
+ await self._subscribe_channel("account", [])
389
+
390
+ if on_account:
391
+ self.on("account", on_account)
392
+
393
+ async def _subscribe_channel(
394
+ self, channel: str, symbols: List[str], **kwargs
395
+ ) -> None:
396
+ if not self._is_authenticated:
397
+ raise SubscriptionError("Must authenticate before subscribing")
398
+
399
+ subscribe_msg = {
400
+ "action": "subscribe",
401
+ "channels": [{"name": channel, "symbols": symbols, **kwargs}],
402
+ }
403
+
404
+ encoded = self._encoder.encode(subscribe_msg)
405
+ await self._connection.send(encoded)
406
+
407
+ # Store subscription for reconnection
408
+ self._subscriptions[channel] = {"symbols": symbols, "kwargs": kwargs}
409
+
410
+ logger.info(f"Subscribed to {channel}: {symbols}")
411
+
412
+ async def unsubscribe(self, channel: str, symbols: List[str]) -> None:
413
+ unsubscribe_msg = {
414
+ "action": "unsubscribe",
415
+ "channels": [{"name": channel, "symbols": symbols}],
416
+ }
417
+
418
+ encoded = self._encoder.encode(unsubscribe_msg)
419
+ await self._connection.send(encoded)
420
+
421
+ # Update local state
422
+ if channel in self._subscriptions:
423
+ stored_symbols = self._subscriptions[channel].get("symbols", [])
424
+ for symbol in symbols:
425
+ if symbol in stored_symbols:
426
+ stored_symbols.remove(symbol)
427
+
428
+ # Remove channel if no symbols left
429
+ if not stored_symbols:
430
+ del self._subscriptions[channel]
431
+
432
+ logger.info(f"Unsubscribed from {channel}: {symbols}")
433
+
434
+ def _make_filtered_handler(self, board_id: Optional[str], attr: str, handler: Callable) -> Callable:
435
+ """Wrap handler to only fire when obj.{attr} == board_id."""
436
+ def _filtered(obj, _b=board_id, _a=attr, _cb=handler):
437
+ if getattr(obj, _a, None) == _b:
438
+ _cb(obj)
439
+ return _filtered
440
+
441
+ def on(self, event: str, handler: Callable) -> None:
442
+ if event not in self._event_handlers:
443
+ self._event_handlers[event] = []
444
+ self._event_handlers[event].append(handler)
445
+
446
+ async def _message_handler(self) -> None:
447
+ reconnect_attempt = 0
448
+ max_reconnect_delay = 60
449
+
450
+ while self._is_running:
451
+ try:
452
+ async for message in self._connection:
453
+ data = self._decoder.decode(message)
454
+ data["_receivedAt"] = time.time()
455
+
456
+ # Hash symbol → worker index để đảm bảo cùng 1 mã
457
+ symbol = data.get("Symbol") or data.get("symbol") or ""
458
+ worker_idx = hash(symbol) % self._num_workers
459
+ await self._dispatch_queues[worker_idx].put(data)
460
+
461
+ reconnect_attempt = 0
462
+ except ConnectionClosed as e:
463
+ logger.warning(f"Connection closed: {e}")
464
+
465
+ # Handle reconnection if enabled and error is recoverable
466
+ if self.auto_reconnect and e.recoverable:
467
+ reconnect_attempt += 1
468
+ logger.info(f"Attempting to reconnect (attempt {reconnect_attempt}/{self.max_retries})...")
469
+
470
+ # Emit reconnecting event
471
+ self._emit("reconnecting", {
472
+ "attempt": reconnect_attempt,
473
+ "max_retries": self.max_retries,
474
+ "delay": 0,
475
+ "error": str(e),
476
+ })
477
+
478
+ try:
479
+ await self._handle_reconnection()
480
+ reconnect_attempt = 0
481
+ except Exception as reconnect_error:
482
+ logger.error(f"Reconnection failed: {reconnect_error}")
483
+ self._emit("error", reconnect_error)
484
+ break
485
+ else:
486
+ self._emit("error", e)
487
+ break
488
+ except Exception as e:
489
+ logger.error(f"Message handler error: {e}")
490
+ self._emit("error", e)
491
+
492
+ if not self.auto_reconnect:
493
+ break
494
+
495
+ if self._is_connection_error(e):
496
+ reconnect_attempt += 1
497
+
498
+ if reconnect_attempt > self.max_retries:
499
+ logger.error(f"Max reconnection attempts ({self.max_retries}) exceeded")
500
+ self._emit("max_reconnect_exceeded", reconnect_attempt)
501
+ break
502
+
503
+ delay = min(2 ** (reconnect_attempt - 1), max_reconnect_delay)
504
+ logger.info(f"Connection error detected. Reconnecting in {delay}s (attempt {reconnect_attempt}/{self.max_retries})...")
505
+
506
+ self._emit("reconnecting", {
507
+ "attempt": reconnect_attempt,
508
+ "max_retries": self.max_retries,
509
+ "delay": delay,
510
+ "error": str(e),
511
+ })
512
+
513
+ await asyncio.sleep(delay)
514
+
515
+ try:
516
+ await self._handle_reconnection()
517
+ logger.info("Reconnection successful after connection error")
518
+ reconnect_attempt = 0
519
+ except Exception as reconnect_error:
520
+ logger.error(f"Reconnection failed: {reconnect_error}")
521
+ continue
522
+ else:
523
+ logger.error(f"Non-recoverable error: {e}")
524
+ break
525
+
526
+ async def _dispatch_worker(self, worker_idx: int) -> None:
527
+ """
528
+ Each worker owns 1 queue. Symbol is hashed to worker_idx,
529
+ so same symbol always processed by same worker → message order guaranteed.
530
+ """
531
+ q = self._dispatch_queues[worker_idx]
532
+ while self._is_running:
533
+ try:
534
+ data = await asyncio.wait_for(q.get(), timeout=1.0)
535
+ await self._dispatch_message(data)
536
+ q.task_done()
537
+ except asyncio.TimeoutError:
538
+ continue
539
+ except Exception as e:
540
+ logger.error(f"Dispatch worker[{worker_idx}] error: {e}")
541
+
542
+ def _is_connection_error(self, error: Exception) -> bool:
543
+ """
544
+ Check if an exception is related to connection issues.
545
+
546
+ Args:
547
+ error: The exception to check
548
+
549
+ Returns:
550
+ True if the error is connection-related and potentially recoverable
551
+ """
552
+ import websockets.exceptions
553
+
554
+ # Connection-related exception types
555
+ connection_error_types = (
556
+ ConnectionError, # Our custom ConnectionError
557
+ OSError, # Network-level errors (includes socket errors)
558
+ ConnectionResetError,
559
+ ConnectionRefusedError,
560
+ ConnectionAbortedError,
561
+ BrokenPipeError,
562
+ TimeoutError,
563
+ asyncio.TimeoutError,
564
+ websockets.exceptions.WebSocketException,
565
+ websockets.exceptions.ConnectionClosed,
566
+ websockets.exceptions.ConnectionClosedError,
567
+ websockets.exceptions.ConnectionClosedOK,
568
+ )
569
+
570
+ if isinstance(error, connection_error_types):
571
+ return True
572
+
573
+ # Check error message for common connection-related patterns
574
+ error_msg = str(error).lower()
575
+ connection_keywords = [
576
+ 'connection',
577
+ 'network',
578
+ 'socket',
579
+ 'timeout',
580
+ 'reset',
581
+ 'refused',
582
+ 'closed',
583
+ 'broken pipe',
584
+ 'eof',
585
+ 'disconnect',
586
+ ]
587
+
588
+ return any(keyword in error_msg for keyword in connection_keywords)
589
+
590
+ async def _dispatch_message(self, data: Dict[str, Any]) -> None:
591
+ action = data.get("action") or data.get("a")
592
+ msg_type = data.get("T")
593
+
594
+ if action == "subscribed":
595
+ logger.debug(f"Subscription confirmed: {data}")
596
+ elif action == "ping":
597
+ logger.info("Received ping from server, sending pong")
598
+ await self._connection.send(self._encoder.encode({"action": "pong"}))
599
+ elif action == "pong":
600
+ self._last_pong_time = time.time()
601
+ elif action == "error":
602
+ error_msg = data.get("message") or data.get("msg")
603
+ logger.error(f"Server error: {error_msg}")
604
+ self._emit("error", Exception(error_msg))
605
+ elif msg_type in _MSG_TYPE_MAP:
606
+ event, model_cls, field = _MSG_TYPE_MAP[msg_type]
607
+ if field is not None and field != "":
608
+ obj = model_cls.from_dict(data[field])
609
+ obj.receivedAt = data["_receivedAt"]
610
+ else:
611
+ obj = model_cls.from_dict(data)
612
+ self._emit(event, obj)
613
+ # Only push to queue if no callback registered (using async iterator)
614
+ if event not in self._event_handlers:
615
+ if event in self._queues:
616
+ await self._queues[event].put(obj)
617
+ if "*" in self._queues:
618
+ await self._queues["*"].put(obj)
619
+
620
+ def _emit(self, event: str, data: Any) -> None:
621
+ if event not in self._event_handlers:
622
+ return
623
+ for handler in self._event_handlers[event]:
624
+ try:
625
+ if asyncio.iscoroutinefunction(handler):
626
+ asyncio.ensure_future(handler(data))
627
+ else:
628
+ handler(data)
629
+ except Exception as e:
630
+ logger.error(f"Handler error for {event}: {e}")
631
+
632
+ def queue(self, event: str) -> asyncio.Queue:
633
+ """
634
+ Get (or create) a dedicated queue for a specific event type.
635
+ Use this when consuming messages via async iterator instead of callbacks.
636
+
637
+ Example:
638
+ q = client.queue("quote")
639
+ while True:
640
+ quote = await q.get()
641
+ """
642
+ if event not in self._queues:
643
+ self._queues[event] = asyncio.Queue()
644
+ return self._queues[event]
645
+
646
+ async def _heartbeat_loop(self) -> None:
647
+ while self._is_running and self._connection and self._connection.is_connected:
648
+ try:
649
+ ping_msg = self._encoder.encode({"action": "ping"})
650
+ await self._connection.send(ping_msg)
651
+ logger.debug("Sent heartbeat ping")
652
+ await asyncio.sleep(self.heartbeat_interval)
653
+ except Exception as e:
654
+ logger.error(f"Heartbeat error: {e}")
655
+ break
656
+
657
+ async def _handle_reconnection(self) -> None:
658
+ """
659
+ Handle reconnection after connection drop.
660
+
661
+ Performs:
662
+ 1. Re-establish connection
663
+ 2. Re-authenticate
664
+ 3. Re-subscribe to all previous channels
665
+ 4. Emit reconnected event
666
+ """
667
+ logger.info("Starting reconnection process...")
668
+
669
+ # Store subscriptions before reconnect
670
+ previous_subscriptions = self._subscriptions.copy()
671
+
672
+ # Reset authentication state
673
+ self._is_authenticated = False
674
+
675
+ # Reconnect
676
+ await self._connection.connect()
677
+
678
+ # Wait for welcome message
679
+ welcome = await asyncio.wait_for(
680
+ self._connection.receive(), timeout=self.timeout
681
+ )
682
+
683
+ welcome_data = self._decoder.decode(welcome)
684
+ self._session_id = welcome_data.get("session_id") or welcome_data.get("sid")
685
+
686
+ logger.info(f"Reconnected! New Session ID: {self._session_id}")
687
+
688
+ # Re-authenticate
689
+ await self._authenticate()
690
+
691
+ # Re-subscribe to all previous channels
692
+ for channel, sub_data in previous_subscriptions.items():
693
+ symbols = sub_data.get("symbols", [])
694
+ kwargs = sub_data.get("kwargs", {})
695
+ await self._subscribe_channel(channel, symbols, **kwargs)
696
+ logger.info(f"Re-subscribed to {channel}: {symbols}")
697
+
698
+ # Update pong time
699
+ self._last_pong_time = time.time()
700
+
701
+ # Emit reconnected event
702
+ self._emit("reconnected", {"session_id": self._session_id})
703
+ logger.info("Reconnection complete")
704
+
705
+ @property
706
+ def is_healthy(self) -> bool:
707
+ if not self._connection or not self._connection.is_connected:
708
+ return False
709
+
710
+ if not self._is_authenticated:
711
+ return False
712
+
713
+ # Check if we received a pong recently
714
+ if self.heartbeat_interval > 0:
715
+ time_since_pong = time.time() - self._last_pong_time
716
+ max_pong_delay = self.heartbeat_interval * 2
717
+ if time_since_pong > max_pong_delay:
718
+ logger.warning(
719
+ f"No pong received for {time_since_pong:.1f}s (max: {max_pong_delay:.1f}s)"
720
+ )
721
+ return False
722
+
723
+ return True
724
+
725
+ async def disconnect(self) -> None:
726
+ """
727
+ Close connection gracefully.
728
+
729
+ Stops all background tasks and closes the WebSocket connection.
730
+ """
731
+ logger.info("Disconnecting...")
732
+
733
+ # Stop background tasks
734
+ self._is_running = False
735
+
736
+ # Cancel dispatch workers
737
+ for worker in self._dispatch_worker_tasks:
738
+ if not worker.done():
739
+ worker.cancel()
740
+ try:
741
+ await worker
742
+ except asyncio.CancelledError:
743
+ pass
744
+ self._dispatch_worker_tasks.clear()
745
+
746
+ # Cancel tasks
747
+ if self._heartbeat_task and not self._heartbeat_task.done():
748
+ self._heartbeat_task.cancel()
749
+ try:
750
+ await self._heartbeat_task
751
+ except asyncio.CancelledError:
752
+ pass
753
+
754
+ if self._message_handler_task and not self._message_handler_task.done():
755
+ self._message_handler_task.cancel()
756
+ try:
757
+ await self._message_handler_task
758
+ except asyncio.CancelledError:
759
+ pass
760
+
761
+ # Close connection
762
+ if self._connection:
763
+ await self._connection.close()
764
+
765
+ self._is_authenticated = False
766
+ logger.info("Disconnected")
767
+
768
+ async def __aenter__(self):
769
+ """Context manager support - connect on entry."""
770
+ await self.connect()
771
+ return self
772
+
773
+ async def __aexit__(self, exc_type, exc_val, exc_tb):
774
+ """Context manager cleanup - disconnect on exit."""
775
+ await self.disconnect()
776
+
777
+ def __aiter__(self):
778
+ """Allow async iteration over all messages via a shared queue."""
779
+ # Ensure a general queue exists for mixed iteration
780
+ self.queue("*")
781
+ return self
782
+
783
+ async def __anext__(self):
784
+ q = self._queues.get("*")
785
+ while self._is_running:
786
+ try:
787
+ return await asyncio.wait_for(q.get(), timeout=1.0)
788
+ except asyncio.TimeoutError:
789
+ continue
790
+ raise StopAsyncIteration