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