xtb-api-python 0.5.2__py3-none-any.whl → 0.5.3__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/ws/parsers.py CHANGED
@@ -49,36 +49,41 @@ def parse_balance(
49
49
  )
50
50
 
51
51
 
52
+ def parse_position_trade(trade: dict[str, Any]) -> Position:
53
+ """Convert a single `xcfdtrade` dict (as pushed on the POSITIONS
54
+ subscription) into a `Position`.
55
+
56
+ Callers that receive the wrapped `{value: {xcfdtrade: ...}}` shape
57
+ should use `parse_positions()` instead.
58
+ """
59
+ side_val = int(trade.get("side", 0))
60
+ return Position(
61
+ symbol=str(trade.get("symbol", "")),
62
+ instrument_id=int(trade["idQuote"]) if trade.get("idQuote") is not None else None,
63
+ volume=float(trade.get("volume", 0)),
64
+ current_price=0.0,
65
+ open_price=float(trade.get("openPrice", 0)),
66
+ stop_loss=float(trade["sl"]) if trade.get("sl") and trade["sl"] != 0 else None,
67
+ take_profit=float(trade["tp"]) if trade.get("tp") and trade["tp"] != 0 else None,
68
+ profit_percent=0.0,
69
+ profit_net=0.0,
70
+ swap=float(trade["swap"]) if trade.get("swap") is not None else None,
71
+ side="buy" if side_val == Xs6Side.BUY else "sell",
72
+ order_id=str(trade["positionId"]) if trade.get("positionId") is not None else None,
73
+ commission=float(trade["commission"]) if trade.get("commission") is not None else None,
74
+ margin=float(trade["margin"]) if trade.get("margin") is not None else None,
75
+ open_time=int(trade["openTime"]) if trade.get("openTime") is not None else None,
76
+ )
77
+
78
+
52
79
  def parse_positions(elements: list[dict[str, Any]]) -> list[Position]:
53
80
  """Parse open trading positions from subscription elements."""
54
81
  positions: list[Position] = []
55
-
56
82
  for el in elements:
57
83
  trade = (el or {}).get("value", {}).get("xcfdtrade")
58
84
  if not trade:
59
85
  continue
60
-
61
- side_val = int(trade.get("side", 0))
62
- positions.append(
63
- Position(
64
- symbol=str(trade.get("symbol", "")),
65
- instrument_id=int(trade["idQuote"]) if trade.get("idQuote") is not None else None,
66
- volume=float(trade.get("volume", 0)),
67
- current_price=0.0,
68
- open_price=float(trade.get("openPrice", 0)),
69
- stop_loss=float(trade["sl"]) if trade.get("sl") and trade["sl"] != 0 else None,
70
- take_profit=float(trade["tp"]) if trade.get("tp") and trade["tp"] != 0 else None,
71
- profit_percent=0.0,
72
- profit_net=0.0,
73
- swap=float(trade["swap"]) if trade.get("swap") is not None else None,
74
- side="buy" if side_val == Xs6Side.BUY else "sell",
75
- order_id=str(trade["positionId"]) if trade.get("positionId") is not None else None,
76
- commission=float(trade["commission"]) if trade.get("commission") is not None else None,
77
- margin=float(trade["margin"]) if trade.get("margin") is not None else None,
78
- open_time=int(trade["openTime"]) if trade.get("openTime") is not None else None,
79
- )
80
- )
81
-
86
+ positions.append(parse_position_trade(trade))
82
87
  return positions
83
88
 
84
89
 
xtb_api/ws/ws_client.py CHANGED
@@ -52,7 +52,7 @@ from xtb_api.ws.parsers import (
52
52
  parse_balance,
53
53
  parse_instruments,
54
54
  parse_orders,
55
- parse_positions,
55
+ parse_position_trade,
56
56
  parse_quote,
57
57
  )
58
58
 
@@ -533,15 +533,81 @@ class XTBWebSocketClient:
533
533
 
534
534
  return parse_balance(self._extract_elements(res), account.currency, account.accountNo)
535
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
- )
536
+ async def get_positions(
537
+ self,
538
+ *,
539
+ max_wait_ms: int = 5000,
540
+ quiet_ms: int = 500,
541
+ ) -> list[Position]:
542
+ """Get all open trading positions.
543
+
544
+ XTB's xStation5 CoreAPI returns position data via the push/events
545
+ channel (`status=1` with `eid=POSITIONS`), NOT via the normal
546
+ `reqId`-echoed response channel. So we subscribe, then collect the
547
+ burst of push events that follow, rather than awaiting a direct reply.
548
+
549
+ The collection window closes when either:
550
+ * `max_wait_ms` has elapsed since the subscription fired, OR
551
+ * `quiet_ms` has passed with no new position push (whichever first).
552
+
553
+ Deduplicates by `positionId` so a retriggered snapshot + update sequence
554
+ won't produce duplicates.
555
+ """
556
+ if not self.is_connected or not self._ws:
557
+ raise XTBConnectionError("Not connected")
558
+
559
+ collected: dict[str, Position] = {}
560
+ loop = asyncio.get_running_loop()
561
+ last_push_ts = loop.time()
562
+
563
+ def on_position(trade: dict[str, Any]) -> None:
564
+ nonlocal last_push_ts
565
+ try:
566
+ pos = parse_position_trade(trade)
567
+ except Exception as exc: # pragma: no cover — defensive
568
+ logger.warning("Failed to parse position push: %s", exc)
569
+ return
570
+ # Dedup by positionId; fall back to object id if missing.
571
+ key = pos.order_id or str(id(trade))
572
+ collected[key] = pos
573
+ last_push_ts = loop.time()
574
+
575
+ self.on("position", on_position)
576
+ try:
577
+ # Fire the subscribe; do NOT await a reqId response (XTB doesn't
578
+ # echo one for POSITIONS). The handler above captures the pushes.
579
+ req_id = self._next_req_id("getPositions")
580
+ request = {
581
+ "reqId": req_id,
582
+ "command": [
583
+ {
584
+ "CoreAPI": {
585
+ "endpoint": self._config.endpoint,
586
+ "accountId": self.account_id,
587
+ "getAndSubscribeElement": {"eid": SubscriptionEid.POSITIONS},
588
+ }
589
+ }
590
+ ],
591
+ }
592
+ await self._ws.send(json.dumps(request))
543
593
 
544
- return parse_positions(self._extract_elements(res))
594
+ start = loop.time()
595
+ max_wait_s = max_wait_ms / 1000.0
596
+ quiet_s = quiet_ms / 1000.0
597
+ while True:
598
+ await asyncio.sleep(0.05)
599
+ elapsed = loop.time() - start
600
+ if elapsed >= max_wait_s:
601
+ break
602
+ # Quiet-period exit only after we have at least one position —
603
+ # otherwise a slow server that hasn't sent anything yet would
604
+ # trigger the quiet-exit prematurely.
605
+ if collected and (loop.time() - last_push_ts) >= quiet_s:
606
+ break
607
+ finally:
608
+ self.off("position", on_position)
609
+
610
+ return list(collected.values())
545
611
 
546
612
  async def get_orders(self) -> list[PendingOrder]:
547
613
  """Get all pending (limit/stop) orders."""
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: xtb-api-python
3
- Version: 0.5.2
3
+ Version: 0.5.3
4
4
  Summary: Python port of unofficial XTB xStation5 API client
5
5
  Project-URL: Homepage, https://github.com/liskeee/xtb-api-python
6
6
  Project-URL: Repository, https://github.com/liskeee/xtb-api-python
@@ -52,7 +52,9 @@ Python client for the XTB xStation5 trading platform. Dead-simple API that handl
52
52
  - **2FA Support** — Automatic TOTP handling when `totp_secret` is provided
53
53
  - **Real-time Data** — Live quotes, positions, balance via WebSocket push events
54
54
  - **Trading** — Buy/sell market orders with SL/TP via gRPC-web
55
- - **11,888+ Instruments** — Full symbol search with caching
55
+ - **Volume-Validated Orders** — `buy`/`sell` reject `volume < 1` before touching the wire
56
+ - **Persistent Instrument Cache** — `InstrumentRegistry` caches symbol → instrument-ID lookups to disk
57
+ - **Full Symbol Search** — Search and resolve all listed instruments with caching
56
58
  - **Modern Python** — async/await, Pydantic models, strict typing, Python 3.12+
57
59
 
58
60
  ## Requirements
@@ -129,6 +131,12 @@ async def main():
129
131
  # Search instruments
130
132
  results = await client.search_instrument("Apple")
131
133
 
134
+ # Persistent instrument cache (avoids re-fetching the full symbol list)
135
+ from xtb_api import InstrumentRegistry
136
+ registry = InstrumentRegistry("~/.xtb_instruments.json")
137
+ matched = await registry.populate(client, ["AAPL.US", "EURUSD"])
138
+ instrument_id = registry.get("AAPL.US") # int | None
139
+
132
140
  # Trading (USE WITH CAUTION!)
133
141
  result = await client.buy("AAPL.US", volume=1, stop_loss=150.0, take_profit=200.0)
134
142
  print(f"Order: {result.order_id}")
@@ -162,6 +170,8 @@ await client.sell("CIG.PL", volume=100, options=TradeOptions(
162
170
  ))
163
171
  ```
164
172
 
173
+ > `TradeResult.price` is populated by polling open positions immediately after fill. If the position cannot be located within the poll window, `price` remains `None`.
174
+
165
175
  ## API Reference
166
176
 
167
177
  ### `XTBClient`
@@ -194,6 +204,18 @@ await client.sell("CIG.PL", volume=100, options=TradeOptions(
194
204
  | `account_server` | No | `"XS-real1"` | gRPC account server |
195
205
  | `auto_reconnect` | No | `True` | Auto-reconnect on disconnect |
196
206
 
207
+ ### `InstrumentRegistry`
208
+
209
+ Persistent symbol → instrument-ID cache, stored as JSON.
210
+
211
+ | Method | Returns | Description |
212
+ |--------|---------|-------------|
213
+ | `InstrumentRegistry(path)` | — | Load (or create) the JSON cache at `path` |
214
+ | `get(symbol)` | `int \| None` | Cached instrument ID for `symbol`, or `None` |
215
+ | `set(symbol, id)` | `None` | Cache one mapping and persist immediately |
216
+ | `populate(client, symbols)` | `dict[str, int]` | Download the full symbol list via `client`, match requested `symbols` (case-insensitive, dot-less fallback), persist, return new matches |
217
+ | `ids` | `dict[str, int]` | Read-only copy of the full cache |
218
+
197
219
  ### WebSocket URLs
198
220
 
199
221
  | Environment | URL |
@@ -212,8 +234,9 @@ ws = client.ws
212
234
  # gRPC client (available after first trade)
213
235
  grpc = client.grpc_client
214
236
 
215
- # Auth manager
237
+ # Auth manager (accessor, or import the public alias)
216
238
  auth = client.auth
239
+ from xtb_api import XTBAuth # public alias for the AuthManager class
217
240
  tgt = await auth.get_tgt()
218
241
  ```
219
242
 
@@ -19,10 +19,10 @@ xtb_api/types/instrument.py,sha256=uxGB3LrE1IgP3UeEMu9c50Mvqmplr3DmgUbLwjg4i14,9
19
19
  xtb_api/types/trading.py,sha256=fGLGga-kMX7vDvE848H7lUnw-kHpTcqq6a8P25RDu2k,3033
20
20
  xtb_api/types/websocket.py,sha256=66vZSC6hdlglO5EEqPSciBbtBVunGV0L-0pLiZwVrQo,3705
21
21
  xtb_api/ws/__init__.py,sha256=6QNzj6rXiGWa9vU-o_IvVTqU7nDqPaYDJnTQhX-1pXY,106
22
- xtb_api/ws/parsers.py,sha256=ApEjm3uX-QhiyaM8U1RpZByVbU0ijL5jnm4FFIApyNk,6222
23
- xtb_api/ws/ws_client.py,sha256=X_bBaAw80aHEECRol33aDJqMfVu5onqrQ8a7ANrrZ8s,34077
24
- xtb_api_python-0.5.2.dist-info/METADATA,sha256=QNHFjD2C2vtf_aqSVn6pT9ikkPhBcfEK9K0pN6Coxpc,8464
25
- xtb_api_python-0.5.2.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
26
- xtb_api_python-0.5.2.dist-info/entry_points.txt,sha256=HhB6G6gQU-c43Y0YowXSqXXOTCd1CK8fvy5guFdzy0s,50
27
- xtb_api_python-0.5.2.dist-info/licenses/LICENSE,sha256=fEnrQRlNl8P7GRU9YAaLjbNRfQ0qe0At5LRkCmM4j0I,1073
28
- xtb_api_python-0.5.2.dist-info/RECORD,,
22
+ xtb_api/ws/parsers.py,sha256=ipTkURuz-WF8ZgYOgWDSNBbUp1eYaNQScrQN6rUufDI,6397
23
+ xtb_api/ws/ws_client.py,sha256=qpZ3tiEgf2BIEvouK1xdGhrAvFw6_AwYzbREP5bHtlY,36768
24
+ xtb_api_python-0.5.3.dist-info/METADATA,sha256=PpVsMp3Z6JgKPMyRd4XBANA-ivBWIAYn79uzEqkIP0E,9918
25
+ xtb_api_python-0.5.3.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
26
+ xtb_api_python-0.5.3.dist-info/entry_points.txt,sha256=HhB6G6gQU-c43Y0YowXSqXXOTCd1CK8fvy5guFdzy0s,50
27
+ xtb_api_python-0.5.3.dist-info/licenses/LICENSE,sha256=fEnrQRlNl8P7GRU9YAaLjbNRfQ0qe0At5LRkCmM4j0I,1073
28
+ xtb_api_python-0.5.3.dist-info/RECORD,,