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/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."""
|
xtb_api/grpc/__init__.py
ADDED
|
@@ -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
|
+
]
|