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.
- broker-api/get_list_care_by.py +22 -0
- dnse/__init__.py +4 -0
- dnse/client.py +408 -0
- dnse/common.py +103 -0
- dnse_sdk_openapi-0.0.1.dist-info/METADATA +120 -0
- dnse_sdk_openapi-0.0.1.dist-info/RECORD +50 -0
- dnse_sdk_openapi-0.0.1.dist-info/WHEEL +5 -0
- dnse_sdk_openapi-0.0.1.dist-info/top_level.txt +5 -0
- marketdata-api/get_instruments.py +22 -0
- marketdata-api/get_latest_trade.py +22 -0
- marketdata-api/get_ohlc.py +31 -0
- marketdata-api/get_security_definition.py +22 -0
- marketdata-api/get_trades.py +22 -0
- marketdata-api/get_working_dates.py +22 -0
- trading-api/cancel_order.py +29 -0
- trading-api/close_position.py +27 -0
- trading-api/create_trading_token.py +26 -0
- trading-api/get_accounts.py +22 -0
- trading-api/get_balances.py +22 -0
- trading-api/get_close_price.py +22 -0
- trading-api/get_execution_detail.py +28 -0
- trading-api/get_loan_packages.py +27 -0
- trading-api/get_order_detail.py +28 -0
- trading-api/get_order_history.py +30 -0
- trading-api/get_orders.py +27 -0
- trading-api/get_position_by_id.py +26 -0
- trading-api/get_positions.py +26 -0
- trading-api/get_ppse.py +29 -0
- trading-api/post_order.py +38 -0
- trading-api/put_order.py +35 -0
- trading-api/send_email_otp.py +22 -0
- websocket-marketdata/expected_price.py +53 -0
- websocket-marketdata/foreign_investor.py +51 -0
- websocket-marketdata/market_index.py +52 -0
- websocket-marketdata/ohlc.py +55 -0
- websocket-marketdata/ohlc_closed.py +55 -0
- websocket-marketdata/order.py +51 -0
- websocket-marketdata/quote.py +50 -0
- websocket-marketdata/sec_def.py +52 -0
- websocket-marketdata/trade.py +52 -0
- websocket-marketdata/trade_extra.py +51 -0
- websocket-marketdata/trading_websocket/__init__.py +33 -0
- websocket-marketdata/trading_websocket/_version.py +3 -0
- websocket-marketdata/trading_websocket/auth.py +59 -0
- websocket-marketdata/trading_websocket/client.py +790 -0
- websocket-marketdata/trading_websocket/connection.py +151 -0
- websocket-marketdata/trading_websocket/encoding.py +78 -0
- websocket-marketdata/trading_websocket/exceptions.py +38 -0
- websocket-marketdata/trading_websocket/models.py +525 -0
- 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
|