xtb-api-python 0.5.2__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.
- xtb_api/__init__.py +70 -0
- xtb_api/__main__.py +154 -0
- xtb_api/auth/__init__.py +5 -0
- xtb_api/auth/auth_manager.py +321 -0
- xtb_api/auth/browser_auth.py +316 -0
- xtb_api/auth/cas_client.py +543 -0
- xtb_api/client.py +444 -0
- xtb_api/exceptions.py +56 -0
- xtb_api/grpc/__init__.py +25 -0
- xtb_api/grpc/client.py +329 -0
- xtb_api/grpc/proto.py +239 -0
- xtb_api/grpc/types.py +14 -0
- xtb_api/instruments.py +132 -0
- xtb_api/py.typed +0 -0
- xtb_api/types/__init__.py +6 -0
- xtb_api/types/enums.py +92 -0
- xtb_api/types/instrument.py +45 -0
- xtb_api/types/trading.py +139 -0
- xtb_api/types/websocket.py +164 -0
- xtb_api/utils.py +62 -0
- xtb_api/ws/__init__.py +3 -0
- xtb_api/ws/parsers.py +161 -0
- xtb_api/ws/ws_client.py +905 -0
- xtb_api_python-0.5.2.dist-info/METADATA +257 -0
- xtb_api_python-0.5.2.dist-info/RECORD +28 -0
- xtb_api_python-0.5.2.dist-info/WHEEL +4 -0
- xtb_api_python-0.5.2.dist-info/entry_points.txt +2 -0
- xtb_api_python-0.5.2.dist-info/licenses/LICENSE +21 -0
xtb_api/ws/ws_client.py
ADDED
|
@@ -0,0 +1,905 @@
|
|
|
1
|
+
"""Low-level WebSocket client for xStation5.
|
|
2
|
+
|
|
3
|
+
Implements the CoreAPI protocol with full CAS authentication support.
|
|
4
|
+
Provides real-time data subscriptions and trading capabilities via WebSocket.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import contextlib
|
|
11
|
+
import inspect
|
|
12
|
+
import json
|
|
13
|
+
import logging
|
|
14
|
+
import time
|
|
15
|
+
from collections.abc import Callable
|
|
16
|
+
from typing import TYPE_CHECKING, Any, Literal, cast
|
|
17
|
+
|
|
18
|
+
import websockets
|
|
19
|
+
import websockets.asyncio.client
|
|
20
|
+
|
|
21
|
+
if TYPE_CHECKING:
|
|
22
|
+
from xtb_api.auth.auth_manager import AuthManager
|
|
23
|
+
|
|
24
|
+
from xtb_api.auth.cas_client import CASClient
|
|
25
|
+
from xtb_api.exceptions import (
|
|
26
|
+
AuthenticationError,
|
|
27
|
+
ProtocolError,
|
|
28
|
+
ReconnectionError,
|
|
29
|
+
XTBConnectionError,
|
|
30
|
+
XTBTimeoutError,
|
|
31
|
+
)
|
|
32
|
+
from xtb_api.types.enums import SocketStatus, SubscriptionEid, Xs6Side
|
|
33
|
+
from xtb_api.types.instrument import InstrumentSearchResult, Quote
|
|
34
|
+
from xtb_api.types.trading import (
|
|
35
|
+
AccountBalance,
|
|
36
|
+
PendingOrder,
|
|
37
|
+
Position,
|
|
38
|
+
TradeOptions,
|
|
39
|
+
TradeResult,
|
|
40
|
+
)
|
|
41
|
+
from xtb_api.types.websocket import (
|
|
42
|
+
CASLoginTwoFactorRequired,
|
|
43
|
+
ClientInfo,
|
|
44
|
+
WSClientConfig,
|
|
45
|
+
WSPushMessage,
|
|
46
|
+
WSResponse,
|
|
47
|
+
XLoginAccountInfo,
|
|
48
|
+
XLoginResult,
|
|
49
|
+
)
|
|
50
|
+
from xtb_api.utils import build_account_id, price_from_decimal, volume_from
|
|
51
|
+
from xtb_api.ws.parsers import (
|
|
52
|
+
parse_balance,
|
|
53
|
+
parse_instruments,
|
|
54
|
+
parse_orders,
|
|
55
|
+
parse_positions,
|
|
56
|
+
parse_quote,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
logger = logging.getLogger(__name__)
|
|
60
|
+
|
|
61
|
+
# Type alias for event callbacks
|
|
62
|
+
EventCallback = Callable[..., Any]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class XTBWebSocketClient:
|
|
66
|
+
"""Low-level WebSocket client for xStation5.
|
|
67
|
+
|
|
68
|
+
Features:
|
|
69
|
+
- Full CAS authentication flow (credentials → TGT → Service Ticket → WebSocket auth)
|
|
70
|
+
- Real-time subscriptions (ticks, positions, request status)
|
|
71
|
+
- Symbol cache for fast instrument search (11,888+ instruments)
|
|
72
|
+
- Auto-reconnection with exponential backoff
|
|
73
|
+
- Direct trading via tradeTransaction commands
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
def __init__(
|
|
77
|
+
self,
|
|
78
|
+
config: WSClientConfig,
|
|
79
|
+
auth_manager: AuthManager | None = None,
|
|
80
|
+
) -> None:
|
|
81
|
+
self._config = config
|
|
82
|
+
self._auth_manager = auth_manager
|
|
83
|
+
self._ws: websockets.asyncio.client.ClientConnection | None = None
|
|
84
|
+
self._status = SocketStatus.CLOSED
|
|
85
|
+
self._pending_requests: dict[str, asyncio.Future[WSResponse]] = {}
|
|
86
|
+
self._req_sequence = 0
|
|
87
|
+
self._ping_task: asyncio.Task[None] | None = None
|
|
88
|
+
self._listen_task: asyncio.Task[None] | None = None
|
|
89
|
+
self._reconnect_delay = 1.0
|
|
90
|
+
self._reconnecting = False
|
|
91
|
+
self._reconnect_attempts = 0
|
|
92
|
+
self._max_reconnect_attempts = 10
|
|
93
|
+
self._intentional_disconnect = False
|
|
94
|
+
self._cas_client: CASClient | None = None
|
|
95
|
+
self._login_result: XLoginResult | None = None
|
|
96
|
+
self._authenticated = False
|
|
97
|
+
self._symbols_cache: list[InstrumentSearchResult] | None = None
|
|
98
|
+
self._symbols_lock = asyncio.Lock()
|
|
99
|
+
|
|
100
|
+
# Event handlers
|
|
101
|
+
self._event_handlers: dict[str, list[EventCallback]] = {}
|
|
102
|
+
|
|
103
|
+
# Initialize CAS client if auth credentials provided
|
|
104
|
+
if config.auth and config.auth.credentials:
|
|
105
|
+
self._cas_client = CASClient()
|
|
106
|
+
|
|
107
|
+
# ─── Properties ───
|
|
108
|
+
|
|
109
|
+
@property
|
|
110
|
+
def account_id(self) -> str:
|
|
111
|
+
"""Account ID in format 'meta1_12345678'."""
|
|
112
|
+
return build_account_id(self._config.account_number, self._config.endpoint)
|
|
113
|
+
|
|
114
|
+
@property
|
|
115
|
+
def connection_status(self) -> SocketStatus:
|
|
116
|
+
"""Current WebSocket connection status."""
|
|
117
|
+
return self._status
|
|
118
|
+
|
|
119
|
+
@property
|
|
120
|
+
def is_connected(self) -> bool:
|
|
121
|
+
"""Whether WebSocket is connected."""
|
|
122
|
+
return self._status == SocketStatus.CONNECTED
|
|
123
|
+
|
|
124
|
+
@property
|
|
125
|
+
def is_authenticated(self) -> bool:
|
|
126
|
+
"""Whether authenticated with XTB servers."""
|
|
127
|
+
return self._authenticated
|
|
128
|
+
|
|
129
|
+
@property
|
|
130
|
+
def account_info(self) -> XLoginResult | None:
|
|
131
|
+
"""Account information from login result."""
|
|
132
|
+
return self._login_result
|
|
133
|
+
|
|
134
|
+
# ─── Event System ───
|
|
135
|
+
|
|
136
|
+
def on(self, event: str, callback: EventCallback) -> None:
|
|
137
|
+
"""Register event handler.
|
|
138
|
+
|
|
139
|
+
Events:
|
|
140
|
+
- 'connected' - WebSocket connection established
|
|
141
|
+
- 'authenticated' - CAS authentication successful (XLoginResult)
|
|
142
|
+
- 'disconnected' - Connection closed (code, reason)
|
|
143
|
+
- 'error' - Error occurred (Exception)
|
|
144
|
+
- 'status_update' - Status changed (SocketStatus)
|
|
145
|
+
- 'push' - Generic push message (WSPushMessage)
|
|
146
|
+
- 'message' - Any WebSocket message (WSResponse)
|
|
147
|
+
- 'tick' - Real-time tick data (dict)
|
|
148
|
+
- 'position' - Position update (dict)
|
|
149
|
+
- 'symbol' - Symbol data update (dict)
|
|
150
|
+
- 'requires_2fa' - Two-factor auth required (dict)
|
|
151
|
+
"""
|
|
152
|
+
self._event_handlers.setdefault(event, []).append(callback)
|
|
153
|
+
|
|
154
|
+
def off(self, event: str, callback: EventCallback) -> None:
|
|
155
|
+
"""Remove event handler."""
|
|
156
|
+
handlers = self._event_handlers.get(event, [])
|
|
157
|
+
if callback in handlers:
|
|
158
|
+
handlers.remove(callback)
|
|
159
|
+
|
|
160
|
+
def _emit(self, event: str, *args: Any) -> None:
|
|
161
|
+
"""Emit event to all registered handlers.
|
|
162
|
+
|
|
163
|
+
Supports both sync and async callbacks. Async callbacks are
|
|
164
|
+
scheduled on the running event loop.
|
|
165
|
+
"""
|
|
166
|
+
for handler in self._event_handlers.get(event, []):
|
|
167
|
+
try:
|
|
168
|
+
result = handler(*args)
|
|
169
|
+
if inspect.iscoroutine(result):
|
|
170
|
+
try:
|
|
171
|
+
asyncio.get_running_loop().create_task(result)
|
|
172
|
+
except RuntimeError:
|
|
173
|
+
result.close() # Prevent "coroutine never awaited" warning
|
|
174
|
+
except Exception:
|
|
175
|
+
logger.error("Error in event handler for '%s'", event, exc_info=True)
|
|
176
|
+
|
|
177
|
+
# ─── Connection ───
|
|
178
|
+
|
|
179
|
+
async def connect(self) -> None:
|
|
180
|
+
"""Connect to WebSocket server and perform authentication if configured.
|
|
181
|
+
|
|
182
|
+
Raises:
|
|
183
|
+
RuntimeError: If already connected
|
|
184
|
+
Exception: If connection or authentication fails
|
|
185
|
+
"""
|
|
186
|
+
if self._ws is not None:
|
|
187
|
+
raise XTBConnectionError("Already connected or connecting")
|
|
188
|
+
|
|
189
|
+
await self._establish_connection()
|
|
190
|
+
|
|
191
|
+
if self._config.auth:
|
|
192
|
+
await self._perform_authentication()
|
|
193
|
+
|
|
194
|
+
async def _establish_connection(self) -> None:
|
|
195
|
+
"""Establish WebSocket connection."""
|
|
196
|
+
self._update_status(SocketStatus.CONNECTING)
|
|
197
|
+
|
|
198
|
+
try:
|
|
199
|
+
self._ws = await websockets.asyncio.client.connect(
|
|
200
|
+
self._config.url,
|
|
201
|
+
max_size=20 * 1024 * 1024, # 20MB for large symbol lists
|
|
202
|
+
)
|
|
203
|
+
except Exception:
|
|
204
|
+
self._update_status(SocketStatus.ERROR)
|
|
205
|
+
raise
|
|
206
|
+
|
|
207
|
+
self._update_status(SocketStatus.CONNECTED)
|
|
208
|
+
self._reconnect_delay = 1.0
|
|
209
|
+
self._reconnecting = False
|
|
210
|
+
self._reconnect_attempts = 0
|
|
211
|
+
self._start_ping()
|
|
212
|
+
self._start_listen()
|
|
213
|
+
self._emit("connected")
|
|
214
|
+
|
|
215
|
+
async def _perform_authentication(self) -> None:
|
|
216
|
+
"""Perform CAS authentication flow."""
|
|
217
|
+
auth = self._config.auth
|
|
218
|
+
if auth is None:
|
|
219
|
+
return
|
|
220
|
+
|
|
221
|
+
service_ticket: str | None = None
|
|
222
|
+
|
|
223
|
+
if auth.service_ticket:
|
|
224
|
+
service_ticket = auth.service_ticket
|
|
225
|
+
elif auth.tgt:
|
|
226
|
+
if not self._cas_client:
|
|
227
|
+
self._cas_client = CASClient()
|
|
228
|
+
result = await self._cas_client.get_service_ticket(auth.tgt, "xapi5")
|
|
229
|
+
service_ticket = result.service_ticket
|
|
230
|
+
elif auth.credentials:
|
|
231
|
+
if not self._cas_client:
|
|
232
|
+
self._cas_client = CASClient()
|
|
233
|
+
|
|
234
|
+
if auth.browser_auth:
|
|
235
|
+
self._browser_auth_active = True
|
|
236
|
+
login_result = await self._cas_client.login_with_browser(
|
|
237
|
+
auth.credentials.email, auth.credentials.password
|
|
238
|
+
)
|
|
239
|
+
else:
|
|
240
|
+
login_result = await self._cas_client.login(auth.credentials.email, auth.credentials.password)
|
|
241
|
+
|
|
242
|
+
if isinstance(login_result, CASLoginTwoFactorRequired):
|
|
243
|
+
self._emit(
|
|
244
|
+
"requires_2fa",
|
|
245
|
+
{
|
|
246
|
+
"login_ticket": login_result.login_ticket,
|
|
247
|
+
"session_id": login_result.session_id, # backward compat
|
|
248
|
+
"two_factor_auth_type": login_result.two_factor_auth_type,
|
|
249
|
+
"methods": login_result.methods,
|
|
250
|
+
"expires_at": login_result.expires_at,
|
|
251
|
+
},
|
|
252
|
+
)
|
|
253
|
+
return # Wait for 2FA completion
|
|
254
|
+
|
|
255
|
+
ticket_result = await self._cas_client.get_service_ticket(login_result.tgt, "xapi5")
|
|
256
|
+
service_ticket = ticket_result.service_ticket
|
|
257
|
+
else:
|
|
258
|
+
raise AuthenticationError("No valid authentication method provided")
|
|
259
|
+
|
|
260
|
+
# Register client info then login
|
|
261
|
+
await self.register_client_info()
|
|
262
|
+
await self.login_with_service_ticket(service_ticket)
|
|
263
|
+
|
|
264
|
+
def disconnect(self) -> None:
|
|
265
|
+
"""Disconnect from the WebSocket server.
|
|
266
|
+
|
|
267
|
+
Prefers async close when a running loop is available.
|
|
268
|
+
Falls back to cleanup-only when called outside async context.
|
|
269
|
+
"""
|
|
270
|
+
self._intentional_disconnect = True
|
|
271
|
+
ws = self._ws # Capture before cleanup nulls it
|
|
272
|
+
if ws:
|
|
273
|
+
self._update_status(SocketStatus.DISCONNECTING)
|
|
274
|
+
try:
|
|
275
|
+
loop = asyncio.get_running_loop()
|
|
276
|
+
loop.create_task(self._close_ws_ref(ws))
|
|
277
|
+
except RuntimeError:
|
|
278
|
+
pass # No running loop — ws will be cleaned up below
|
|
279
|
+
self._cleanup()
|
|
280
|
+
|
|
281
|
+
async def _close_ws_ref(self, ws: Any) -> None:
|
|
282
|
+
"""Close a specific WebSocket connection reference."""
|
|
283
|
+
with contextlib.suppress(Exception):
|
|
284
|
+
await ws.close()
|
|
285
|
+
|
|
286
|
+
async def _close_ws(self) -> None:
|
|
287
|
+
"""Close WebSocket connection."""
|
|
288
|
+
if self._ws:
|
|
289
|
+
with contextlib.suppress(Exception):
|
|
290
|
+
await self._ws.close()
|
|
291
|
+
|
|
292
|
+
async def disconnect_async(self) -> None:
|
|
293
|
+
"""Async disconnect from the WebSocket server."""
|
|
294
|
+
self._intentional_disconnect = True
|
|
295
|
+
if self._ws:
|
|
296
|
+
self._update_status(SocketStatus.DISCONNECTING)
|
|
297
|
+
with contextlib.suppress(Exception):
|
|
298
|
+
await self._ws.close()
|
|
299
|
+
self._cleanup()
|
|
300
|
+
|
|
301
|
+
# ─── Send Commands ───
|
|
302
|
+
|
|
303
|
+
async def send(self, command_name: str, payload: dict[str, Any], timeout_ms: int = 10000) -> WSResponse:
|
|
304
|
+
"""Send a raw CoreAPI command and wait for response.
|
|
305
|
+
|
|
306
|
+
Args:
|
|
307
|
+
command_name: Command name for request ID generation
|
|
308
|
+
payload: CoreAPI command payload
|
|
309
|
+
timeout_ms: Request timeout in milliseconds
|
|
310
|
+
|
|
311
|
+
Returns:
|
|
312
|
+
Command response
|
|
313
|
+
|
|
314
|
+
Raises:
|
|
315
|
+
RuntimeError: If not connected
|
|
316
|
+
TimeoutError: If request times out
|
|
317
|
+
"""
|
|
318
|
+
if not self.is_connected or not self._ws:
|
|
319
|
+
raise XTBConnectionError("Not connected")
|
|
320
|
+
|
|
321
|
+
req_id = self._next_req_id(command_name)
|
|
322
|
+
|
|
323
|
+
core_api: dict[str, Any] = {
|
|
324
|
+
"endpoint": self._config.endpoint,
|
|
325
|
+
**payload,
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
# Only add accountId for non-auth commands
|
|
329
|
+
if "registerClientInfo" not in payload and "logonWithServiceTicket" not in payload:
|
|
330
|
+
core_api["accountId"] = self.account_id
|
|
331
|
+
|
|
332
|
+
request = {
|
|
333
|
+
"reqId": req_id,
|
|
334
|
+
"command": [{"CoreAPI": core_api}],
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
loop = asyncio.get_running_loop()
|
|
338
|
+
future: asyncio.Future[WSResponse] = loop.create_future()
|
|
339
|
+
self._pending_requests[req_id] = future
|
|
340
|
+
|
|
341
|
+
try:
|
|
342
|
+
await self._ws.send(json.dumps(request))
|
|
343
|
+
return await asyncio.wait_for(future, timeout=timeout_ms / 1000)
|
|
344
|
+
except TimeoutError as e:
|
|
345
|
+
self._pending_requests.pop(req_id, None)
|
|
346
|
+
raise XTBTimeoutError(f"Request {req_id} timed out") from e
|
|
347
|
+
|
|
348
|
+
# ─── Subscriptions ───
|
|
349
|
+
|
|
350
|
+
async def subscribe_ticks(self, symbol_key: str) -> WSResponse:
|
|
351
|
+
"""Subscribe to real-time tick/quote data for a symbol.
|
|
352
|
+
|
|
353
|
+
Args:
|
|
354
|
+
symbol_key: Symbol key in format {assetClassId}_{symbolName}_{groupId}
|
|
355
|
+
"""
|
|
356
|
+
return await self.send(
|
|
357
|
+
"getAndSubscribeTicks",
|
|
358
|
+
{"getAndSubscribeElement": {"eid": SubscriptionEid.TICKS, "keys": [symbol_key]}},
|
|
359
|
+
)
|
|
360
|
+
|
|
361
|
+
async def unsubscribe_ticks(self, symbol_key: str) -> WSResponse:
|
|
362
|
+
"""Unsubscribe from tick data for a symbol."""
|
|
363
|
+
return await self.send(
|
|
364
|
+
"unsubscribeTicks",
|
|
365
|
+
{"unsubscribeElement": {"eid": SubscriptionEid.TICKS, "keys": [symbol_key]}},
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
async def subscribe_request_status(self) -> WSResponse:
|
|
369
|
+
"""Subscribe to request status updates for trade confirmations."""
|
|
370
|
+
return await self.send(
|
|
371
|
+
"subscribeRequestStatus",
|
|
372
|
+
{"subscribeElement": {"eid": SubscriptionEid.REQUEST_STATUS}},
|
|
373
|
+
)
|
|
374
|
+
|
|
375
|
+
async def ping(self) -> int:
|
|
376
|
+
"""Ping the server and return latency in milliseconds."""
|
|
377
|
+
start = time.monotonic()
|
|
378
|
+
await self.send("ping", {"ping": {}})
|
|
379
|
+
return int((time.monotonic() - start) * 1000)
|
|
380
|
+
|
|
381
|
+
# ─── Authentication ───
|
|
382
|
+
|
|
383
|
+
async def register_client_info(self) -> WSResponse:
|
|
384
|
+
"""Register client info — first step in authentication flow."""
|
|
385
|
+
client_info = ClientInfo(
|
|
386
|
+
appName=self._config.app_name,
|
|
387
|
+
appVersion=self._config.app_version,
|
|
388
|
+
appBuildNumber="0",
|
|
389
|
+
device=self._config.device,
|
|
390
|
+
osVersion="",
|
|
391
|
+
comment="Python",
|
|
392
|
+
apiVersion="2.73.0",
|
|
393
|
+
osType=0,
|
|
394
|
+
deviceType=1,
|
|
395
|
+
)
|
|
396
|
+
|
|
397
|
+
return await self.send(
|
|
398
|
+
"registerClientInfo",
|
|
399
|
+
{"registerClientInfo": {"clientInfo": client_info.model_dump()}},
|
|
400
|
+
)
|
|
401
|
+
|
|
402
|
+
async def login_with_service_ticket(self, service_ticket: str) -> XLoginResult:
|
|
403
|
+
"""Login with service ticket — second step in authentication flow.
|
|
404
|
+
|
|
405
|
+
Args:
|
|
406
|
+
service_ticket: Service ticket from CAS (format: ST-...)
|
|
407
|
+
|
|
408
|
+
Returns:
|
|
409
|
+
Login result with account list and user data
|
|
410
|
+
|
|
411
|
+
Raises:
|
|
412
|
+
RuntimeError: If login fails
|
|
413
|
+
"""
|
|
414
|
+
response = await self.send(
|
|
415
|
+
"loginWithServiceTicket",
|
|
416
|
+
{"logonWithServiceTicket": {"serviceTicket": service_ticket}},
|
|
417
|
+
)
|
|
418
|
+
|
|
419
|
+
# Parse login result
|
|
420
|
+
resp_list = response.response or []
|
|
421
|
+
if not resp_list:
|
|
422
|
+
raise AuthenticationError("Login failed: empty response")
|
|
423
|
+
|
|
424
|
+
first = resp_list[0] if resp_list else {}
|
|
425
|
+
if not isinstance(first, dict):
|
|
426
|
+
raise ProtocolError("Login failed: unexpected response format")
|
|
427
|
+
|
|
428
|
+
login_data = first.get("xloginresult")
|
|
429
|
+
if not login_data:
|
|
430
|
+
exception = first.get("exception", {})
|
|
431
|
+
error_msg = exception.get("message", "") if isinstance(exception, dict) else str(exception)
|
|
432
|
+
raise AuthenticationError(f"Login failed: {error_msg or 'Unknown error'}")
|
|
433
|
+
|
|
434
|
+
# Parse accountList
|
|
435
|
+
account_list = []
|
|
436
|
+
for acc in login_data.get("accountList", []):
|
|
437
|
+
wt_account_id = acc.get("wtAccountId", {})
|
|
438
|
+
account_no = int(wt_account_id.get("accountNo", acc.get("accountNo", 0)))
|
|
439
|
+
endpoint_type = acc.get("endpointType", {})
|
|
440
|
+
if isinstance(endpoint_type, dict):
|
|
441
|
+
endpoint_type = endpoint_type.get("name", "")
|
|
442
|
+
account_list.append(
|
|
443
|
+
XLoginAccountInfo(
|
|
444
|
+
accountNo=account_no,
|
|
445
|
+
currency=str(acc.get("currency", "")),
|
|
446
|
+
endpointType=str(endpoint_type),
|
|
447
|
+
)
|
|
448
|
+
)
|
|
449
|
+
|
|
450
|
+
user_data = login_data.get("userData", {})
|
|
451
|
+
self._login_result = XLoginResult(
|
|
452
|
+
accountList=account_list,
|
|
453
|
+
endpointList=login_data.get("endpointList", []),
|
|
454
|
+
userData={
|
|
455
|
+
"name": str(user_data.get("name", "")),
|
|
456
|
+
"surname": str(user_data.get("surname", "")),
|
|
457
|
+
},
|
|
458
|
+
)
|
|
459
|
+
|
|
460
|
+
self._authenticated = True
|
|
461
|
+
self._emit("authenticated", self._login_result)
|
|
462
|
+
return self._login_result
|
|
463
|
+
|
|
464
|
+
async def submit_two_factor_code(
|
|
465
|
+
self,
|
|
466
|
+
login_ticket: str,
|
|
467
|
+
code: str,
|
|
468
|
+
two_factor_auth_type: str = "SMS",
|
|
469
|
+
*,
|
|
470
|
+
session_id: str | None = None,
|
|
471
|
+
) -> None:
|
|
472
|
+
"""Submit 2FA code to complete login.
|
|
473
|
+
|
|
474
|
+
Args:
|
|
475
|
+
login_ticket: Login ticket from 'requires_2fa' event (MID-xxx).
|
|
476
|
+
For backward compat, ``session_id`` kwarg is also accepted.
|
|
477
|
+
code: 6-digit OTP code
|
|
478
|
+
two_factor_auth_type: Auth method, default ``"SMS"``
|
|
479
|
+
session_id: **Deprecated** — alias for ``login_ticket``
|
|
480
|
+
|
|
481
|
+
Raises:
|
|
482
|
+
RuntimeError: If CAS client not available
|
|
483
|
+
"""
|
|
484
|
+
if not self._cas_client:
|
|
485
|
+
raise AuthenticationError("No CAS client available - authentication not started")
|
|
486
|
+
|
|
487
|
+
# Route OTP to browser auth if active
|
|
488
|
+
if getattr(self, "_browser_auth_active", False):
|
|
489
|
+
two_factor_result = await self._cas_client.submit_browser_otp(code)
|
|
490
|
+
self._browser_auth_active = False
|
|
491
|
+
else:
|
|
492
|
+
ticket = login_ticket or session_id or ""
|
|
493
|
+
two_factor_result = await self._cas_client.login_with_two_factor(ticket, code, two_factor_auth_type)
|
|
494
|
+
|
|
495
|
+
if isinstance(two_factor_result, CASLoginTwoFactorRequired):
|
|
496
|
+
self._emit(
|
|
497
|
+
"requires_2fa",
|
|
498
|
+
{
|
|
499
|
+
"login_ticket": two_factor_result.login_ticket,
|
|
500
|
+
"session_id": two_factor_result.session_id,
|
|
501
|
+
"two_factor_auth_type": two_factor_result.two_factor_auth_type,
|
|
502
|
+
"methods": two_factor_result.methods,
|
|
503
|
+
"expires_at": two_factor_result.expires_at,
|
|
504
|
+
},
|
|
505
|
+
)
|
|
506
|
+
return
|
|
507
|
+
|
|
508
|
+
ticket_result = await self._cas_client.get_service_ticket(two_factor_result.tgt, "xapi5")
|
|
509
|
+
await self.register_client_info()
|
|
510
|
+
await self.login_with_service_ticket(ticket_result.service_ticket)
|
|
511
|
+
|
|
512
|
+
# ─── High-level API ───
|
|
513
|
+
|
|
514
|
+
async def get_balance(self) -> AccountBalance:
|
|
515
|
+
"""Get account balance and equity information."""
|
|
516
|
+
if not self._authenticated or not self._login_result:
|
|
517
|
+
raise AuthenticationError("Must be authenticated to get balance")
|
|
518
|
+
|
|
519
|
+
account = None
|
|
520
|
+
for acc in self._login_result.accountList:
|
|
521
|
+
if acc.accountNo == self._config.account_number:
|
|
522
|
+
account = acc
|
|
523
|
+
break
|
|
524
|
+
if not account and self._login_result.accountList:
|
|
525
|
+
account = self._login_result.accountList[0]
|
|
526
|
+
if not account:
|
|
527
|
+
raise ProtocolError("Account not found in login result")
|
|
528
|
+
|
|
529
|
+
res = await self.send(
|
|
530
|
+
"getBalance",
|
|
531
|
+
{"getAndSubscribeElement": {"eid": SubscriptionEid.TOTAL_BALANCE}},
|
|
532
|
+
)
|
|
533
|
+
|
|
534
|
+
return parse_balance(self._extract_elements(res), account.currency, account.accountNo)
|
|
535
|
+
|
|
536
|
+
async def get_positions(self) -> list[Position]:
|
|
537
|
+
"""Get all open trading positions."""
|
|
538
|
+
res = await self.send(
|
|
539
|
+
"getPositions",
|
|
540
|
+
{"getAndSubscribeElement": {"eid": SubscriptionEid.POSITIONS}},
|
|
541
|
+
timeout_ms=30000,
|
|
542
|
+
)
|
|
543
|
+
|
|
544
|
+
return parse_positions(self._extract_elements(res))
|
|
545
|
+
|
|
546
|
+
async def get_orders(self) -> list[PendingOrder]:
|
|
547
|
+
"""Get all pending (limit/stop) orders."""
|
|
548
|
+
res = await self.send(
|
|
549
|
+
"getAllOrders",
|
|
550
|
+
{"getAndSubscribeElement": {"eid": SubscriptionEid.ORDERS}},
|
|
551
|
+
)
|
|
552
|
+
|
|
553
|
+
return parse_orders(self._extract_elements(res))
|
|
554
|
+
|
|
555
|
+
async def buy(self, symbol: str, volume: int, options: TradeOptions | None = None) -> TradeResult:
|
|
556
|
+
"""Execute a BUY order via WebSocket using ``Xs6Side.BUY`` (value 0).
|
|
557
|
+
|
|
558
|
+
⚠️ WARNING: This executes real trades. Always test on demo accounts first.
|
|
559
|
+
|
|
560
|
+
Note: This uses the WebSocket protocol side constant (``Xs6Side.BUY=0``),
|
|
561
|
+
which differs from the gRPC constant (``SIDE_BUY=1``). Do not mix.
|
|
562
|
+
"""
|
|
563
|
+
return await self._execute_trade(symbol, volume, Xs6Side.BUY, options)
|
|
564
|
+
|
|
565
|
+
async def sell(self, symbol: str, volume: int, options: TradeOptions | None = None) -> TradeResult:
|
|
566
|
+
"""Execute a SELL order via WebSocket using ``Xs6Side.SELL`` (value 1).
|
|
567
|
+
|
|
568
|
+
⚠️ WARNING: This executes real trades. Always test on demo accounts first.
|
|
569
|
+
|
|
570
|
+
Note: This uses the WebSocket protocol side constant (``Xs6Side.SELL=1``),
|
|
571
|
+
which differs from the gRPC constant (``SIDE_SELL=2``). Do not mix.
|
|
572
|
+
"""
|
|
573
|
+
return await self._execute_trade(symbol, volume, Xs6Side.SELL, options)
|
|
574
|
+
|
|
575
|
+
def _filter_cached_symbols(self, query: str) -> list[InstrumentSearchResult]:
|
|
576
|
+
"""Filter the cached symbols list by substring match (symbol/name/description)."""
|
|
577
|
+
if self._symbols_cache is None:
|
|
578
|
+
return []
|
|
579
|
+
query_lower = query.lower()
|
|
580
|
+
return [
|
|
581
|
+
s
|
|
582
|
+
for s in self._symbols_cache
|
|
583
|
+
if query_lower in s.symbol.lower() or query_lower in s.name.lower() or query_lower in s.description.lower()
|
|
584
|
+
][:100]
|
|
585
|
+
|
|
586
|
+
async def search_instrument(self, query: str) -> list[InstrumentSearchResult]:
|
|
587
|
+
"""Search for financial instruments with caching.
|
|
588
|
+
|
|
589
|
+
First call downloads all 11,888+ instruments and caches them.
|
|
590
|
+
Subsequent searches are instant from cache. Uses a lock to
|
|
591
|
+
prevent concurrent callers from downloading the list multiple times.
|
|
592
|
+
"""
|
|
593
|
+
# Fast path: cache already populated (no lock needed)
|
|
594
|
+
if self._symbols_cache is not None:
|
|
595
|
+
return self._filter_cached_symbols(query)
|
|
596
|
+
|
|
597
|
+
async with self._symbols_lock:
|
|
598
|
+
# Re-check after acquiring lock (another coroutine may have populated it)
|
|
599
|
+
if self._symbols_cache is not None:
|
|
600
|
+
return self._filter_cached_symbols(query)
|
|
601
|
+
|
|
602
|
+
res = await self.send(
|
|
603
|
+
"searchInstruments",
|
|
604
|
+
{"getAndSubscribeElement": {"eid": SubscriptionEid.SYMBOLS}},
|
|
605
|
+
timeout_ms=30000,
|
|
606
|
+
)
|
|
607
|
+
|
|
608
|
+
self._symbols_cache = parse_instruments(self._extract_elements(res))
|
|
609
|
+
logger.info("Cached %d instruments for instant search", len(self._symbols_cache))
|
|
610
|
+
|
|
611
|
+
return self._filter_cached_symbols(query)
|
|
612
|
+
|
|
613
|
+
def get_account_number(self) -> int:
|
|
614
|
+
"""Get the account number for this WebSocket session."""
|
|
615
|
+
if self._login_result and self._login_result.accountList:
|
|
616
|
+
for acc in self._login_result.accountList:
|
|
617
|
+
if acc.accountNo == self._config.account_number:
|
|
618
|
+
return acc.accountNo
|
|
619
|
+
return self._login_result.accountList[0].accountNo
|
|
620
|
+
return self._config.account_number
|
|
621
|
+
|
|
622
|
+
async def get_quote(self, symbol: str) -> Quote | None:
|
|
623
|
+
"""Get current quote (bid/ask prices) for a symbol.
|
|
624
|
+
|
|
625
|
+
Args:
|
|
626
|
+
symbol: Symbol name or full symbol key
|
|
627
|
+
"""
|
|
628
|
+
is_key = "_" in symbol
|
|
629
|
+
keys_to_try = [symbol] if is_key else [f"9_{symbol}_6", symbol]
|
|
630
|
+
|
|
631
|
+
for key in keys_to_try:
|
|
632
|
+
try:
|
|
633
|
+
res = await self.subscribe_ticks(key)
|
|
634
|
+
try:
|
|
635
|
+
quote = parse_quote(self._extract_elements(res), symbol)
|
|
636
|
+
finally:
|
|
637
|
+
# Always unsubscribe to avoid leaking subscriptions
|
|
638
|
+
with contextlib.suppress(Exception):
|
|
639
|
+
await self.unsubscribe_ticks(key)
|
|
640
|
+
if quote:
|
|
641
|
+
return quote
|
|
642
|
+
except Exception:
|
|
643
|
+
continue
|
|
644
|
+
|
|
645
|
+
return None
|
|
646
|
+
|
|
647
|
+
# ─── Private helpers ───
|
|
648
|
+
|
|
649
|
+
async def _execute_trade(
|
|
650
|
+
self,
|
|
651
|
+
symbol: str,
|
|
652
|
+
volume: int,
|
|
653
|
+
side: Xs6Side,
|
|
654
|
+
options: TradeOptions | None = None,
|
|
655
|
+
) -> TradeResult:
|
|
656
|
+
"""Execute a trade order."""
|
|
657
|
+
results = await self.search_instrument(symbol)
|
|
658
|
+
instrument = None
|
|
659
|
+
for r in results:
|
|
660
|
+
if r.symbol.upper() == symbol.upper():
|
|
661
|
+
instrument = r
|
|
662
|
+
break
|
|
663
|
+
if not instrument and results:
|
|
664
|
+
instrument = results[0]
|
|
665
|
+
|
|
666
|
+
side_str = "buy" if side == Xs6Side.BUY else "sell"
|
|
667
|
+
if not instrument:
|
|
668
|
+
return TradeResult(
|
|
669
|
+
success=False,
|
|
670
|
+
symbol=symbol,
|
|
671
|
+
side=cast("Literal['buy', 'sell']", side_str),
|
|
672
|
+
error=f"Instrument not found: {symbol}",
|
|
673
|
+
)
|
|
674
|
+
|
|
675
|
+
size: dict[str, Any]
|
|
676
|
+
if options and options.amount is not None:
|
|
677
|
+
size = {"amount": options.amount}
|
|
678
|
+
else:
|
|
679
|
+
vol = volume_from(volume)
|
|
680
|
+
size = {"volume": {"value": vol.value, "scale": vol.scale}}
|
|
681
|
+
|
|
682
|
+
order: dict[str, Any] = {
|
|
683
|
+
"instrumentid": instrument.instrument_id,
|
|
684
|
+
"size": size,
|
|
685
|
+
"side": side.value,
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
if options and options.stop_loss is not None:
|
|
689
|
+
if options.trailing_stop is not None:
|
|
690
|
+
order["stoploss"] = {"trailingstopinput": {"pips": options.trailing_stop}}
|
|
691
|
+
else:
|
|
692
|
+
p = price_from_decimal(options.stop_loss, 2)
|
|
693
|
+
order["stoploss"] = {"price": {"value": p.value, "scale": p.scale}}
|
|
694
|
+
if options and options.take_profit is not None:
|
|
695
|
+
p = price_from_decimal(options.take_profit, 2)
|
|
696
|
+
order["takeprofit"] = {"price": {"value": p.value, "scale": p.scale}}
|
|
697
|
+
|
|
698
|
+
order_event = {
|
|
699
|
+
"order": order,
|
|
700
|
+
"uiTrackingId": f"ws_{int(time.time() * 1000)}",
|
|
701
|
+
"account": {
|
|
702
|
+
"number": self._config.account_number,
|
|
703
|
+
"server": self._config.endpoint,
|
|
704
|
+
"currency": "",
|
|
705
|
+
},
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
await self.subscribe_request_status()
|
|
709
|
+
|
|
710
|
+
res = await self.send(
|
|
711
|
+
"tradeTransaction",
|
|
712
|
+
{"tradeTransaction": {"newMarketOrder": order_event}},
|
|
713
|
+
timeout_ms=15000,
|
|
714
|
+
)
|
|
715
|
+
|
|
716
|
+
if res.error:
|
|
717
|
+
return TradeResult(
|
|
718
|
+
success=False,
|
|
719
|
+
symbol=symbol,
|
|
720
|
+
side=cast("Literal['buy', 'sell']", side_str),
|
|
721
|
+
error=res.error.get("message", "Unknown error"),
|
|
722
|
+
)
|
|
723
|
+
|
|
724
|
+
data = self._extract_response_data(res)
|
|
725
|
+
return TradeResult(
|
|
726
|
+
success=True,
|
|
727
|
+
order_id=str(data.get("orderId")) if data and data.get("orderId") is not None else None,
|
|
728
|
+
symbol=symbol,
|
|
729
|
+
side=cast("Literal['buy', 'sell']", side_str),
|
|
730
|
+
volume=float(volume),
|
|
731
|
+
price=float(data["price"]) if data and data.get("price") is not None else None,
|
|
732
|
+
)
|
|
733
|
+
|
|
734
|
+
def _extract_response_data(self, res: WSResponse) -> dict[str, Any] | None:
|
|
735
|
+
"""Extract response data from WSResponse."""
|
|
736
|
+
resp_list = res.response
|
|
737
|
+
if resp_list and len(resp_list) > 0:
|
|
738
|
+
first = resp_list[0]
|
|
739
|
+
if isinstance(first, dict):
|
|
740
|
+
return first
|
|
741
|
+
if res.data and isinstance(res.data, dict):
|
|
742
|
+
return cast("dict[str, Any] | None", res.data)
|
|
743
|
+
return None
|
|
744
|
+
|
|
745
|
+
def _extract_elements(self, res: WSResponse) -> list[dict[str, Any]]:
|
|
746
|
+
"""Extract all elements from a subscription response."""
|
|
747
|
+
resp_list = res.response
|
|
748
|
+
if not resp_list:
|
|
749
|
+
return []
|
|
750
|
+
first = resp_list[0] if resp_list else None
|
|
751
|
+
if not isinstance(first, dict):
|
|
752
|
+
return []
|
|
753
|
+
element = first.get("element", {})
|
|
754
|
+
if isinstance(element, dict) and isinstance(element.get("elements"), list):
|
|
755
|
+
return cast("list[dict[str, Any]]", element["elements"])
|
|
756
|
+
return []
|
|
757
|
+
|
|
758
|
+
def _next_req_id(self, prefix: str) -> str:
|
|
759
|
+
"""Generate next request ID."""
|
|
760
|
+
self._req_sequence += 1
|
|
761
|
+
return f"{prefix}_{int(time.time() * 1000)}_{self._req_sequence}"
|
|
762
|
+
|
|
763
|
+
def _handle_message(self, raw: str) -> None:
|
|
764
|
+
"""Handle incoming WebSocket message."""
|
|
765
|
+
try:
|
|
766
|
+
msg = json.loads(raw)
|
|
767
|
+
except json.JSONDecodeError as e:
|
|
768
|
+
self._emit("error", RuntimeError(f"Failed to parse message: {e}"))
|
|
769
|
+
return
|
|
770
|
+
|
|
771
|
+
req_id = msg.get("reqId", "")
|
|
772
|
+
|
|
773
|
+
# Handle request responses
|
|
774
|
+
if req_id and req_id in self._pending_requests:
|
|
775
|
+
future = self._pending_requests.pop(req_id)
|
|
776
|
+
if not future.done():
|
|
777
|
+
response = WSResponse(**msg) if isinstance(msg, dict) else WSResponse(reqId=req_id)
|
|
778
|
+
future.set_result(response)
|
|
779
|
+
return
|
|
780
|
+
|
|
781
|
+
# Handle push messages (status=1)
|
|
782
|
+
if msg.get("status") == 1 and msg.get("events"):
|
|
783
|
+
push_msg = WSPushMessage(**msg) if isinstance(msg, dict) else WSPushMessage()
|
|
784
|
+
self._emit("push", push_msg)
|
|
785
|
+
|
|
786
|
+
for event in msg.get("events", []):
|
|
787
|
+
eid = event.get("eid")
|
|
788
|
+
row = event.get("row", {})
|
|
789
|
+
value = row.get("value", {})
|
|
790
|
+
|
|
791
|
+
if eid == SubscriptionEid.TICKS and value.get("xcfdtick"):
|
|
792
|
+
self._emit("tick", value["xcfdtick"])
|
|
793
|
+
elif eid == SubscriptionEid.POSITIONS and value.get("xcfdtrade"):
|
|
794
|
+
self._emit("position", value["xcfdtrade"])
|
|
795
|
+
elif eid == SubscriptionEid.SYMBOLS and value.get("xcfdsymbol"):
|
|
796
|
+
self._emit("symbol", value["xcfdsymbol"])
|
|
797
|
+
return
|
|
798
|
+
|
|
799
|
+
# Generic message
|
|
800
|
+
response = WSResponse(**msg) if isinstance(msg, dict) else WSResponse()
|
|
801
|
+
self._emit("message", response)
|
|
802
|
+
|
|
803
|
+
def _start_ping(self) -> None:
|
|
804
|
+
"""Start ping keepalive task."""
|
|
805
|
+
self._stop_ping()
|
|
806
|
+
|
|
807
|
+
async def ping_loop() -> None:
|
|
808
|
+
while self.is_connected:
|
|
809
|
+
try:
|
|
810
|
+
await asyncio.sleep(self._config.ping_interval / 1000)
|
|
811
|
+
if self.is_connected:
|
|
812
|
+
await self.ping()
|
|
813
|
+
except Exception:
|
|
814
|
+
pass
|
|
815
|
+
|
|
816
|
+
self._ping_task = asyncio.get_running_loop().create_task(ping_loop())
|
|
817
|
+
|
|
818
|
+
def _stop_ping(self) -> None:
|
|
819
|
+
"""Stop ping keepalive task."""
|
|
820
|
+
if self._ping_task:
|
|
821
|
+
self._ping_task.cancel()
|
|
822
|
+
self._ping_task = None
|
|
823
|
+
|
|
824
|
+
def _start_listen(self) -> None:
|
|
825
|
+
"""Start listening for incoming messages."""
|
|
826
|
+
if self._listen_task:
|
|
827
|
+
self._listen_task.cancel()
|
|
828
|
+
|
|
829
|
+
async def listen_loop() -> None:
|
|
830
|
+
try:
|
|
831
|
+
assert self._ws is not None
|
|
832
|
+
async for message in self._ws:
|
|
833
|
+
if isinstance(message, (str, bytes)):
|
|
834
|
+
self._handle_message(message if isinstance(message, str) else message.decode())
|
|
835
|
+
except websockets.exceptions.ConnectionClosed as e:
|
|
836
|
+
self._cleanup()
|
|
837
|
+
self._update_status(SocketStatus.CLOSED)
|
|
838
|
+
self._emit("disconnected", e.code, str(e.reason))
|
|
839
|
+
if self._config.auto_reconnect and not self._reconnecting and not self._intentional_disconnect:
|
|
840
|
+
asyncio.get_running_loop().create_task(self._schedule_reconnect())
|
|
841
|
+
except Exception as e:
|
|
842
|
+
self._emit("error", e)
|
|
843
|
+
|
|
844
|
+
self._listen_task = asyncio.get_running_loop().create_task(listen_loop())
|
|
845
|
+
|
|
846
|
+
async def _schedule_reconnect(self) -> None:
|
|
847
|
+
"""Schedule reconnection with exponential backoff.
|
|
848
|
+
|
|
849
|
+
If an AuthManager is available, obtains a fresh service ticket
|
|
850
|
+
for re-authentication instead of reusing the (possibly stale) original.
|
|
851
|
+
|
|
852
|
+
Raises ReconnectionError after max_reconnect_attempts failures.
|
|
853
|
+
"""
|
|
854
|
+
self._reconnecting = True
|
|
855
|
+
self._reconnect_attempts += 1
|
|
856
|
+
|
|
857
|
+
if self._reconnect_attempts > self._max_reconnect_attempts:
|
|
858
|
+
self._reconnecting = False
|
|
859
|
+
error = ReconnectionError(f"Exhausted {self._max_reconnect_attempts} reconnection attempts")
|
|
860
|
+
self._emit("error", error)
|
|
861
|
+
return
|
|
862
|
+
|
|
863
|
+
await asyncio.sleep(self._reconnect_delay)
|
|
864
|
+
self._reconnect_delay = min(
|
|
865
|
+
self._reconnect_delay * 1.5,
|
|
866
|
+
self._config.max_reconnect_delay / 1000,
|
|
867
|
+
)
|
|
868
|
+
try:
|
|
869
|
+
if self._auth_manager:
|
|
870
|
+
# Get fresh ST from AuthManager (handles TGT refresh if needed)
|
|
871
|
+
fresh_st = await self._auth_manager.get_service_ticket()
|
|
872
|
+
await self._establish_connection()
|
|
873
|
+
await self.register_client_info()
|
|
874
|
+
await self.login_with_service_ticket(fresh_st)
|
|
875
|
+
else:
|
|
876
|
+
await self.connect()
|
|
877
|
+
except Exception as e:
|
|
878
|
+
logger.warning("Reconnection attempt %d failed: %s", self._reconnect_attempts, e)
|
|
879
|
+
self._reconnecting = False
|
|
880
|
+
if self._config.auto_reconnect and self._reconnect_attempts < self._max_reconnect_attempts:
|
|
881
|
+
asyncio.get_running_loop().create_task(self._schedule_reconnect())
|
|
882
|
+
elif self._reconnect_attempts >= self._max_reconnect_attempts:
|
|
883
|
+
error = ReconnectionError(f"Exhausted {self._max_reconnect_attempts} reconnection attempts")
|
|
884
|
+
self._emit("error", error)
|
|
885
|
+
|
|
886
|
+
def _cleanup(self) -> None:
|
|
887
|
+
"""Clean up connection resources."""
|
|
888
|
+
self._stop_ping()
|
|
889
|
+
if self._listen_task:
|
|
890
|
+
self._listen_task.cancel()
|
|
891
|
+
self._listen_task = None
|
|
892
|
+
|
|
893
|
+
for _req_id, future in self._pending_requests.items():
|
|
894
|
+
if not future.done():
|
|
895
|
+
future.set_exception(XTBConnectionError("Connection closed"))
|
|
896
|
+
self._pending_requests.clear()
|
|
897
|
+
self._ws = None
|
|
898
|
+
self._authenticated = False
|
|
899
|
+
self._login_result = None
|
|
900
|
+
self._symbols_cache = None
|
|
901
|
+
|
|
902
|
+
def _update_status(self, status: SocketStatus) -> None:
|
|
903
|
+
"""Update connection status and emit event."""
|
|
904
|
+
self._status = status
|
|
905
|
+
self._emit("status_update", status)
|