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/client.py ADDED
@@ -0,0 +1,444 @@
1
+ """High-level XTB trading client.
2
+
3
+ Provides a dead-simple, single-client API that handles all auth lifecycle,
4
+ transport selection, and token refresh transparently.
5
+
6
+ ⚠️ Warning: This is an unofficial library. Use at your own risk.
7
+ Always test thoroughly on demo accounts before using with real money.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import asyncio
13
+ import logging
14
+ from collections.abc import Callable
15
+ from pathlib import Path
16
+ from typing import Any, Literal, cast
17
+
18
+ from xtb_api.auth.auth_manager import AuthManager
19
+ from xtb_api.auth.cas_client import CASClientConfig
20
+ from xtb_api.exceptions import InstrumentNotFoundError
21
+ from xtb_api.grpc.client import GrpcClient
22
+ from xtb_api.grpc.proto import SIDE_BUY, SIDE_SELL
23
+ from xtb_api.types.instrument import InstrumentSearchResult, Quote
24
+ from xtb_api.types.trading import AccountBalance, PendingOrder, Position, TradeOptions, TradeResult
25
+ from xtb_api.types.websocket import WSClientConfig
26
+ from xtb_api.utils import price_from_decimal
27
+ from xtb_api.ws.ws_client import XTBWebSocketClient
28
+
29
+ logger = logging.getLogger(__name__)
30
+
31
+
32
+ class XTBClient:
33
+ """High-level XTB trading client.
34
+
35
+ Handles all authentication, token refresh, and transport selection
36
+ automatically. Users never need to understand the auth lifecycle.
37
+
38
+ Read operations (balance, positions, quotes, instruments) go through
39
+ WebSocket. Trading (buy/sell) goes through gRPC-web (lazy-initialized
40
+ on first trade call).
41
+
42
+ Example::
43
+
44
+ client = XTBClient(
45
+ email="user@example.com",
46
+ password="secret",
47
+ account_number=51984891,
48
+ totp_secret="BASE32SECRET", # optional, auto-handles 2FA
49
+ session_file="~/.xtb_session", # optional, persists auth
50
+ )
51
+ await client.connect()
52
+
53
+ balance = await client.get_balance()
54
+ positions = await client.get_positions()
55
+ result = await client.buy("EURUSD", volume=1, stop_loss=1.0850)
56
+
57
+ await client.disconnect()
58
+ """
59
+
60
+ def __init__(
61
+ self,
62
+ email: str,
63
+ password: str,
64
+ account_number: int,
65
+ *,
66
+ totp_secret: str = "",
67
+ session_file: Path | str | None = None,
68
+ ws_url: str = "wss://api5reala.x-station.eu/v1/xstation",
69
+ endpoint: str = "meta1",
70
+ account_server: str = "XS-real1",
71
+ auto_reconnect: bool = True,
72
+ cas_config: CASClientConfig | None = None,
73
+ ) -> None:
74
+ """
75
+ Args:
76
+ email: XTB account email.
77
+ password: XTB account password.
78
+ account_number: XTB account number.
79
+ totp_secret: Base32 TOTP secret for automatic 2FA (optional).
80
+ session_file: Path to cache TGT on disk (optional).
81
+ ws_url: WebSocket endpoint URL.
82
+ endpoint: Server endpoint name (e.g., 'meta1').
83
+ account_server: gRPC account server name.
84
+ auto_reconnect: Auto-reconnect WebSocket on disconnect.
85
+ cas_config: Custom CAS client configuration (optional).
86
+ """
87
+ self._account_number = account_number
88
+ self._account_server = account_server
89
+
90
+ # Auth manager — shared by WS and gRPC
91
+ self._auth = AuthManager(
92
+ email=email,
93
+ password=password,
94
+ totp_secret=totp_secret,
95
+ session_file=session_file,
96
+ cas_config=cas_config,
97
+ )
98
+
99
+ # WebSocket client — always created
100
+ ws_config = WSClientConfig(
101
+ url=ws_url,
102
+ account_number=account_number,
103
+ endpoint=endpoint,
104
+ auto_reconnect=auto_reconnect,
105
+ )
106
+ self._ws = XTBWebSocketClient(ws_config, auth_manager=self._auth)
107
+
108
+ # gRPC client — lazy-initialized on first trade
109
+ self._grpc: GrpcClient | None = None
110
+
111
+ async def connect(self) -> None:
112
+ """Connect to XTB, authenticate, and start receiving data.
113
+
114
+ Handles the full auth flow: TGT → Service Ticket → WebSocket login.
115
+ """
116
+ service_ticket = await self._auth.get_service_ticket()
117
+ await self._ws._establish_connection()
118
+ await self._ws.register_client_info()
119
+ await self._ws.login_with_service_ticket(service_ticket)
120
+
121
+ async def disconnect(self) -> None:
122
+ """Disconnect from XTB and clean up all resources."""
123
+ await self._ws.disconnect_async()
124
+ if self._grpc:
125
+ await self._grpc.disconnect()
126
+ self._grpc = None
127
+ await self._auth.aclose()
128
+
129
+ # ── Read Operations (WebSocket) ──────────────────────────────
130
+
131
+ async def get_balance(self) -> AccountBalance:
132
+ """Get account balance and equity information."""
133
+ return await self._ws.get_balance()
134
+
135
+ async def get_positions(self) -> list[Position]:
136
+ """Get all open trading positions."""
137
+ return await self._ws.get_positions()
138
+
139
+ async def get_orders(self) -> list[PendingOrder]:
140
+ """Get all pending (limit/stop) orders."""
141
+ return await self._ws.get_orders()
142
+
143
+ async def get_quote(self, symbol: str) -> Quote | None:
144
+ """Get current quote (bid/ask prices) for a symbol.
145
+
146
+ Args:
147
+ symbol: Symbol name (e.g., 'EURUSD', 'CIG.PL')
148
+ """
149
+ return await self._ws.get_quote(symbol)
150
+
151
+ async def search_instrument(self, query: str) -> list[InstrumentSearchResult]:
152
+ """Search for financial instruments by name.
153
+
154
+ First call downloads all instruments and caches them.
155
+ Subsequent searches are instant.
156
+ """
157
+ return await self._ws.search_instrument(query)
158
+
159
+ # ── Trading (gRPC, lazy-initialized) ─────────────────────────
160
+
161
+ async def buy(
162
+ self,
163
+ symbol: str,
164
+ volume: int,
165
+ *,
166
+ stop_loss: float | None = None,
167
+ take_profit: float | None = None,
168
+ options: TradeOptions | None = None,
169
+ ) -> TradeResult:
170
+ """Execute a BUY market order via gRPC using ``SIDE_BUY`` (value 1).
171
+
172
+ ⚠️ WARNING: This executes real trades. Use demo accounts for testing.
173
+
174
+ Note: This uses the gRPC protocol side constant (``SIDE_BUY=1``),
175
+ which differs from the WebSocket constant (``Xs6Side.BUY=0``). Do not mix.
176
+
177
+ Args:
178
+ symbol: Symbol name (e.g., 'EURUSD', 'CIG.PL')
179
+ volume: Number of shares/lots
180
+ stop_loss: Stop loss price (flat kwarg for simple use)
181
+ take_profit: Take profit price (flat kwarg for simple use)
182
+ options: Advanced trade options (overrides stop_loss/take_profit)
183
+ """
184
+ return await self._execute_trade(symbol, volume, SIDE_BUY, stop_loss, take_profit, options)
185
+
186
+ async def sell(
187
+ self,
188
+ symbol: str,
189
+ volume: int,
190
+ *,
191
+ stop_loss: float | None = None,
192
+ take_profit: float | None = None,
193
+ options: TradeOptions | None = None,
194
+ ) -> TradeResult:
195
+ """Execute a SELL market order via gRPC using ``SIDE_SELL`` (value 2).
196
+
197
+ ⚠️ WARNING: This executes real trades. Use demo accounts for testing.
198
+
199
+ Note: This uses the gRPC protocol side constant (``SIDE_SELL=2``),
200
+ which differs from the WebSocket constant (``Xs6Side.SELL=1``). Do not mix.
201
+
202
+ Args:
203
+ symbol: Symbol name (e.g., 'EURUSD', 'CIG.PL')
204
+ volume: Number of shares/lots
205
+ stop_loss: Stop loss price (flat kwarg for simple use)
206
+ take_profit: Take profit price (flat kwarg for simple use)
207
+ options: Advanced trade options (overrides stop_loss/take_profit)
208
+ """
209
+ return await self._execute_trade(symbol, volume, SIDE_SELL, stop_loss, take_profit, options)
210
+
211
+ # ── Real-time Events ─────────────────────────────────────────
212
+
213
+ def on(self, event: str, callback: Callable[..., Any]) -> None:
214
+ """Register event handler.
215
+
216
+ Events:
217
+ - 'tick' — Real-time tick data
218
+ - 'position' — Position update
219
+ - 'symbol' — Symbol data update
220
+ - 'connected' — WebSocket connected
221
+ - 'disconnected' — Connection closed
222
+ - 'error' — Error occurred
223
+ """
224
+ self._ws.on(event, callback)
225
+
226
+ def off(self, event: str, callback: Callable[..., Any]) -> None:
227
+ """Remove event handler."""
228
+ self._ws.off(event, callback)
229
+
230
+ async def subscribe_ticks(self, symbol: str) -> None:
231
+ """Subscribe to real-time tick data for a symbol.
232
+
233
+ Args:
234
+ symbol: Symbol name (e.g., 'EURUSD'). Resolves to symbol key automatically.
235
+ """
236
+ symbol_key = await self._resolve_symbol_key(symbol)
237
+ await self._ws.subscribe_ticks(symbol_key)
238
+
239
+ async def unsubscribe_ticks(self, symbol: str) -> None:
240
+ """Unsubscribe from tick data for a symbol."""
241
+ symbol_key = await self._resolve_symbol_key(symbol)
242
+ await self._ws.unsubscribe_ticks(symbol_key)
243
+
244
+ # ── Properties ───────────────────────────────────────────────
245
+
246
+ @property
247
+ def is_connected(self) -> bool:
248
+ """Whether WebSocket is connected."""
249
+ return self._ws.is_connected
250
+
251
+ @property
252
+ def is_authenticated(self) -> bool:
253
+ """Whether authenticated with XTB servers."""
254
+ return self._ws.is_authenticated
255
+
256
+ @property
257
+ def account_number(self) -> int:
258
+ """Account number."""
259
+ return self._account_number
260
+
261
+ @property
262
+ def ws(self) -> XTBWebSocketClient:
263
+ """Access underlying WebSocket client for advanced use.
264
+
265
+ Warning: The WebSocket client's ``buy()``/``sell()`` methods use
266
+ ``Xs6Side`` constants (BUY=0, SELL=1), which differ from the gRPC
267
+ constants (SIDE_BUY=1, SIDE_SELL=2) used by ``XTBClient.buy()``/
268
+ ``sell()``. Do not pass side values between protocols.
269
+ """
270
+ return self._ws
271
+
272
+ @property
273
+ def grpc_client(self) -> GrpcClient | None:
274
+ """Access underlying gRPC client (None until first trade)."""
275
+ return self._grpc
276
+
277
+ @property
278
+ def auth(self) -> AuthManager:
279
+ """Access the auth manager."""
280
+ return self._auth
281
+
282
+ # ── Internal ─────────────────────────────────────────────────
283
+
284
+ def _ensure_grpc(self) -> GrpcClient:
285
+ """Lazy-initialize gRPC client on first trade."""
286
+ if self._grpc is None:
287
+ self._grpc = GrpcClient(
288
+ account_number=str(self._account_number),
289
+ account_server=self._account_server,
290
+ auth=self._auth,
291
+ )
292
+ return self._grpc
293
+
294
+ async def _resolve_symbol_key(self, symbol: str) -> str:
295
+ """Resolve a symbol name to its internal symbol key.
296
+
297
+ Uses the instrument cache (downloading it if needed).
298
+ If the symbol already looks like a key (contains '_'), returns as-is.
299
+ """
300
+ if "_" in symbol:
301
+ return symbol
302
+
303
+ results = await self._ws.search_instrument(symbol)
304
+ for r in results:
305
+ if r.symbol.upper() == symbol.upper():
306
+ return r.symbol_key
307
+ if results:
308
+ return results[0].symbol_key
309
+ raise InstrumentNotFoundError(f"Symbol not found: {symbol}")
310
+
311
+ async def _resolve_instrument_id(self, symbol: str) -> int:
312
+ """Resolve a symbol name to its gRPC instrument ID."""
313
+ results = await self._ws.search_instrument(symbol)
314
+ for r in results:
315
+ if r.symbol.upper() == symbol.upper():
316
+ return r.instrument_id
317
+ if results:
318
+ return results[0].instrument_id
319
+ raise InstrumentNotFoundError(f"Symbol not found: {symbol}")
320
+
321
+ async def _execute_trade(
322
+ self,
323
+ symbol: str,
324
+ volume: int,
325
+ side: int,
326
+ stop_loss: float | None,
327
+ take_profit: float | None,
328
+ options: TradeOptions | None,
329
+ ) -> TradeResult:
330
+ """Execute a trade via gRPC, resolving the symbol first."""
331
+ # Volume validation: reject anything that rounds to less than 1 share.
332
+ # The gRPC endpoint accepts integer volumes only; forwarding 0 would
333
+ # either silently no-op or explode with a cryptic error. Python's int()
334
+ # truncates toward zero, so negative inputs (e.g. -0.4 → 0, -2 → -1) are
335
+ # naturally rejected by the `< 1` check without a separate negativity
336
+ # guard — the half-up rounding is the single rule.
337
+ rounded = int(volume + 0.5)
338
+ if rounded < 1:
339
+ side_str = "buy" if side == SIDE_BUY else "sell"
340
+ return TradeResult(
341
+ success=False,
342
+ symbol=symbol,
343
+ side=cast("Literal['buy', 'sell']", side_str),
344
+ volume=float(volume),
345
+ order_id=None,
346
+ error=f"insufficient_volume: {volume} rounds to {rounded} (need >= 1)",
347
+ )
348
+
349
+ grpc = self._ensure_grpc()
350
+
351
+ instrument_id = await self._resolve_instrument_id(symbol)
352
+
353
+ # Merge flat kwargs into effective SL/TP (options take precedence)
354
+ effective_sl = options.stop_loss if options and options.stop_loss is not None else stop_loss
355
+ effective_tp = options.take_profit if options and options.take_profit is not None else take_profit
356
+
357
+ # Convert SL/TP floats to protobuf price (value, scale)
358
+ # Use scale derived from the float's own decimal places (up to 5)
359
+ sl_value = sl_scale = tp_value = tp_scale = None
360
+ if effective_sl is not None:
361
+ p = price_from_decimal(effective_sl, _decimal_places(effective_sl))
362
+ sl_value, sl_scale = p.value, p.scale
363
+ if effective_tp is not None:
364
+ p = price_from_decimal(effective_tp, _decimal_places(effective_tp))
365
+ tp_value, tp_scale = p.value, p.scale
366
+
367
+ result = await grpc.execute_order(
368
+ instrument_id,
369
+ volume,
370
+ side,
371
+ stop_loss_value=sl_value,
372
+ stop_loss_scale=sl_scale,
373
+ take_profit_value=tp_value,
374
+ take_profit_scale=tp_scale,
375
+ )
376
+
377
+ # Retry ONLY on auth error (RBAC/expired JWT), not on trade rejection
378
+ if not result.success and result.error and "RBAC" in result.error:
379
+ logger.info("RBAC error, refreshing JWT and retrying...")
380
+ grpc.invalidate_jwt()
381
+ result = await grpc.execute_order(
382
+ instrument_id,
383
+ volume,
384
+ side,
385
+ stop_loss_value=sl_value,
386
+ stop_loss_scale=sl_scale,
387
+ take_profit_value=tp_value,
388
+ take_profit_scale=tp_scale,
389
+ )
390
+
391
+ side_str = "buy" if side == SIDE_BUY else "sell"
392
+
393
+ if not result.success:
394
+ return TradeResult(
395
+ success=False,
396
+ symbol=symbol,
397
+ side=cast("Literal['buy', 'sell']", side_str),
398
+ volume=float(volume),
399
+ order_id=result.order_id,
400
+ error=result.error,
401
+ )
402
+
403
+ fill_price = await self._poll_fill_price(symbol)
404
+ return TradeResult(
405
+ success=True,
406
+ symbol=symbol,
407
+ side=cast("Literal['buy', 'sell']", side_str),
408
+ volume=float(volume),
409
+ price=fill_price,
410
+ order_id=result.order_id,
411
+ error=None,
412
+ )
413
+
414
+ async def _poll_fill_price(self, symbol: str, attempts: int = 3, delay_sec: float = 1.0) -> float | None:
415
+ """Poll positions after a successful trade to determine the actual fill price.
416
+
417
+ Returns None if the position does not appear within `attempts` tries.
418
+ The trade still succeeded — the order ID is the authoritative record.
419
+ """
420
+ target = symbol.upper()
421
+ for i in range(attempts):
422
+ try:
423
+ positions = await self._ws.get_positions()
424
+ # Position.symbol carries the plain symbol name from the WS
425
+ # xcfdtrade.symbol field (e.g. "CIG.PL"), not the _9-suffixed
426
+ # symbol_key. Case-insensitive compare is enough.
427
+ for p in positions:
428
+ if p.symbol.upper() == target:
429
+ return p.open_price
430
+ except Exception as exc:
431
+ logger.warning("Fill-price poll attempt %d/%d failed: %s", i + 1, attempts, exc)
432
+ if i < attempts - 1:
433
+ await asyncio.sleep(delay_sec)
434
+ logger.warning("Could not determine fill price for %s after %d attempts", symbol, attempts)
435
+ return None
436
+
437
+
438
+ def _decimal_places(value: float, max_scale: int = 5) -> int:
439
+ """Determine the number of decimal places in a float, up to max_scale."""
440
+ text = f"{value:.{max_scale}f}"
441
+ decimals = text.split(".")[1] if "." in text else ""
442
+ # Strip trailing zeros
443
+ stripped = decimals.rstrip("0")
444
+ return max(len(stripped), 2) # at least 2 for prices
xtb_api/exceptions.py ADDED
@@ -0,0 +1,56 @@
1
+ """Exception hierarchy for the XTB API client.
2
+
3
+ All exceptions inherit from XTBError, allowing callers to catch
4
+ any library error with a single ``except XTBError`` clause.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+
10
+ class XTBError(Exception):
11
+ """Base exception for all XTB API errors."""
12
+
13
+
14
+ class XTBConnectionError(XTBError):
15
+ """Failed to establish or maintain a connection."""
16
+
17
+
18
+ class AuthenticationError(XTBConnectionError):
19
+ """Authentication failed (invalid credentials, expired TGT, 2FA failure)."""
20
+
21
+
22
+ class CASError(AuthenticationError):
23
+ """CAS-specific error with an error code from XTB servers.
24
+
25
+ Backward-compatible with the original ``CASError`` that lived in
26
+ ``xtb_api.types.websocket``. The ``.code`` attribute carries the
27
+ raw CAS error code (e.g. ``"CAS_GET_TGT_UNAUTHORIZED"``).
28
+ """
29
+
30
+ def __init__(self, code: str, message: str) -> None:
31
+ self.code = code
32
+ super().__init__(message)
33
+
34
+
35
+ class ReconnectionError(XTBConnectionError):
36
+ """Exhausted all reconnection attempts."""
37
+
38
+
39
+ class TradeError(XTBError):
40
+ """Trade execution failed (order rejected, insufficient margin)."""
41
+
42
+
43
+ class InstrumentNotFoundError(TradeError):
44
+ """Symbol could not be resolved to a known instrument."""
45
+
46
+
47
+ class RateLimitError(XTBError):
48
+ """Too many requests or OTP attempts."""
49
+
50
+
51
+ class XTBTimeoutError(XTBError):
52
+ """A request timed out waiting for a response."""
53
+
54
+
55
+ class ProtocolError(XTBError):
56
+ """Malformed response or unexpected message format from the server."""
@@ -0,0 +1,25 @@
1
+ """gRPC-web trading module for XTB xStation5."""
2
+
3
+ from xtb_api.grpc.client import GrpcClient
4
+ from xtb_api.grpc.proto import (
5
+ GRPC_AUTH_ENDPOINT,
6
+ GRPC_BASE_URL,
7
+ GRPC_CLOSE_POSITION_ENDPOINT,
8
+ GRPC_CONFIRM_ENDPOINT,
9
+ GRPC_NEW_ORDER_ENDPOINT,
10
+ SIDE_BUY,
11
+ SIDE_SELL,
12
+ )
13
+ from xtb_api.grpc.types import GrpcTradeResult
14
+
15
+ __all__ = [
16
+ "GrpcClient",
17
+ "GrpcTradeResult",
18
+ "SIDE_BUY",
19
+ "SIDE_SELL",
20
+ "GRPC_BASE_URL",
21
+ "GRPC_AUTH_ENDPOINT",
22
+ "GRPC_NEW_ORDER_ENDPOINT",
23
+ "GRPC_CONFIRM_ENDPOINT",
24
+ "GRPC_CLOSE_POSITION_ENDPOINT",
25
+ ]