xtb-api-python 0.5.3__tar.gz → 0.5.4__tar.gz

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.
Files changed (51) hide show
  1. {xtb_api_python-0.5.3 → xtb_api_python-0.5.4}/CHANGELOG.md +29 -0
  2. {xtb_api_python-0.5.3 → xtb_api_python-0.5.4}/PKG-INFO +1 -1
  3. {xtb_api_python-0.5.3 → xtb_api_python-0.5.4}/pyproject.toml +1 -1
  4. {xtb_api_python-0.5.3 → xtb_api_python-0.5.4}/src/xtb_api/ws/ws_client.py +13 -73
  5. xtb_api_python-0.5.3/tests/test_get_positions_push.py +0 -155
  6. {xtb_api_python-0.5.3 → xtb_api_python-0.5.4}/.gitignore +0 -0
  7. {xtb_api_python-0.5.3 → xtb_api_python-0.5.4}/LICENSE +0 -0
  8. {xtb_api_python-0.5.3 → xtb_api_python-0.5.4}/README.md +0 -0
  9. {xtb_api_python-0.5.3 → xtb_api_python-0.5.4}/examples/basic_usage.py +0 -0
  10. {xtb_api_python-0.5.3 → xtb_api_python-0.5.4}/examples/grpc_trade.py +0 -0
  11. {xtb_api_python-0.5.3 → xtb_api_python-0.5.4}/examples/live_quotes.py +0 -0
  12. {xtb_api_python-0.5.3 → xtb_api_python-0.5.4}/src/xtb_api/__init__.py +0 -0
  13. {xtb_api_python-0.5.3 → xtb_api_python-0.5.4}/src/xtb_api/__main__.py +0 -0
  14. {xtb_api_python-0.5.3 → xtb_api_python-0.5.4}/src/xtb_api/auth/__init__.py +0 -0
  15. {xtb_api_python-0.5.3 → xtb_api_python-0.5.4}/src/xtb_api/auth/auth_manager.py +0 -0
  16. {xtb_api_python-0.5.3 → xtb_api_python-0.5.4}/src/xtb_api/auth/browser_auth.py +0 -0
  17. {xtb_api_python-0.5.3 → xtb_api_python-0.5.4}/src/xtb_api/auth/cas_client.py +0 -0
  18. {xtb_api_python-0.5.3 → xtb_api_python-0.5.4}/src/xtb_api/client.py +0 -0
  19. {xtb_api_python-0.5.3 → xtb_api_python-0.5.4}/src/xtb_api/exceptions.py +0 -0
  20. {xtb_api_python-0.5.3 → xtb_api_python-0.5.4}/src/xtb_api/grpc/__init__.py +0 -0
  21. {xtb_api_python-0.5.3 → xtb_api_python-0.5.4}/src/xtb_api/grpc/client.py +0 -0
  22. {xtb_api_python-0.5.3 → xtb_api_python-0.5.4}/src/xtb_api/grpc/proto.py +0 -0
  23. {xtb_api_python-0.5.3 → xtb_api_python-0.5.4}/src/xtb_api/grpc/types.py +0 -0
  24. {xtb_api_python-0.5.3 → xtb_api_python-0.5.4}/src/xtb_api/instruments.py +0 -0
  25. {xtb_api_python-0.5.3 → xtb_api_python-0.5.4}/src/xtb_api/py.typed +0 -0
  26. {xtb_api_python-0.5.3 → xtb_api_python-0.5.4}/src/xtb_api/types/__init__.py +0 -0
  27. {xtb_api_python-0.5.3 → xtb_api_python-0.5.4}/src/xtb_api/types/enums.py +0 -0
  28. {xtb_api_python-0.5.3 → xtb_api_python-0.5.4}/src/xtb_api/types/instrument.py +0 -0
  29. {xtb_api_python-0.5.3 → xtb_api_python-0.5.4}/src/xtb_api/types/trading.py +0 -0
  30. {xtb_api_python-0.5.3 → xtb_api_python-0.5.4}/src/xtb_api/types/websocket.py +0 -0
  31. {xtb_api_python-0.5.3 → xtb_api_python-0.5.4}/src/xtb_api/utils.py +0 -0
  32. {xtb_api_python-0.5.3 → xtb_api_python-0.5.4}/src/xtb_api/ws/__init__.py +0 -0
  33. {xtb_api_python-0.5.3 → xtb_api_python-0.5.4}/src/xtb_api/ws/parsers.py +0 -0
  34. {xtb_api_python-0.5.3 → xtb_api_python-0.5.4}/tests/__init__.py +0 -0
  35. {xtb_api_python-0.5.3 → xtb_api_python-0.5.4}/tests/conftest.py +0 -0
  36. {xtb_api_python-0.5.3 → xtb_api_python-0.5.4}/tests/test_auth.py +0 -0
  37. {xtb_api_python-0.5.3 → xtb_api_python-0.5.4}/tests/test_auth_manager.py +0 -0
  38. {xtb_api_python-0.5.3 → xtb_api_python-0.5.4}/tests/test_browser_auth.py +0 -0
  39. {xtb_api_python-0.5.3 → xtb_api_python-0.5.4}/tests/test_browser_auth_chromium_missing.py +0 -0
  40. {xtb_api_python-0.5.3 → xtb_api_python-0.5.4}/tests/test_cas_cookies.py +0 -0
  41. {xtb_api_python-0.5.3 → xtb_api_python-0.5.4}/tests/test_client.py +0 -0
  42. {xtb_api_python-0.5.3 → xtb_api_python-0.5.4}/tests/test_client_fill_price.py +0 -0
  43. {xtb_api_python-0.5.3 → xtb_api_python-0.5.4}/tests/test_client_volume_validation.py +0 -0
  44. {xtb_api_python-0.5.3 → xtb_api_python-0.5.4}/tests/test_doctor.py +0 -0
  45. {xtb_api_python-0.5.3 → xtb_api_python-0.5.4}/tests/test_exceptions.py +0 -0
  46. {xtb_api_python-0.5.3 → xtb_api_python-0.5.4}/tests/test_grpc_client.py +0 -0
  47. {xtb_api_python-0.5.3 → xtb_api_python-0.5.4}/tests/test_instruments.py +0 -0
  48. {xtb_api_python-0.5.3 → xtb_api_python-0.5.4}/tests/test_parsers.py +0 -0
  49. {xtb_api_python-0.5.3 → xtb_api_python-0.5.4}/tests/test_proto.py +0 -0
  50. {xtb_api_python-0.5.3 → xtb_api_python-0.5.4}/tests/test_version.py +0 -0
  51. {xtb_api_python-0.5.3 → xtb_api_python-0.5.4}/tests/test_ws_client.py +0 -0
@@ -1,6 +1,35 @@
1
1
  # CHANGELOG
2
2
 
3
3
 
4
+ ## v0.5.4 (2026-04-15)
5
+
6
+ ### Bug Fixes
7
+
8
+ - **ws**: Revert get_positions to reqId-based send
9
+ ([#9](https://github.com/liskeee/xtb-api-unofficial-python/pull/9),
10
+ [`d197b08`](https://github.com/liskeee/xtb-api-unofficial-python/commit/d197b08350e57a46b82adee5dcb206b2d068b852))
11
+
12
+ v0.5.3's push-channel collection was based on a wrong diagnosis. Live API probing confirms the
13
+ xStation5 CoreAPI echoes getPositions on the NORMAL reqId response channel (status=0,
14
+ response=[...]), not as push events. The original implementation was correct.
15
+
16
+ The 30s timeouts observed on the original 0.5.2 bot were something else — probably container-level
17
+ networking / first-call timing — not a protocol mismatch. The wrong fix in 0.5.3 made the problem
18
+ worse because get_positions() started returning [] instantly, causing the downstream bot to
19
+ auto-close positions it mistakenly thought were gone from the broker.
20
+
21
+ Reverts the get_positions implementation to: res = await self.send("getPositions",
22
+ {"getAndSubscribeElement": {"eid": POSITIONS}}, timeout_ms=30000) return
23
+ parse_positions(self._extract_elements(res))
24
+
25
+ Keeps `parse_position_trade` helper in parsers.py — harmless refactor. Removes
26
+ tests/test_get_positions_push.py — tested wrong behavior.
27
+
28
+ Verified against real XTB account: all 5 open positions returned in ~1-2s via normal reqId response.
29
+
30
+ Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
31
+
32
+
4
33
  ## v0.5.3 (2026-04-15)
5
34
 
6
35
  ### Bug Fixes
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: xtb-api-python
3
- Version: 0.5.3
3
+ Version: 0.5.4
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
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "xtb-api-python"
7
- version = "0.5.3"
7
+ version = "0.5.4"
8
8
  description = "Python port of unofficial XTB xStation5 API client"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -52,7 +52,7 @@ from xtb_api.ws.parsers import (
52
52
  parse_balance,
53
53
  parse_instruments,
54
54
  parse_orders,
55
- parse_position_trade,
55
+ parse_positions,
56
56
  parse_quote,
57
57
  )
58
58
 
@@ -533,81 +533,21 @@ class XTBWebSocketClient:
533
533
 
534
534
  return parse_balance(self._extract_elements(res), account.currency, account.accountNo)
535
535
 
536
- async def get_positions(
537
- self,
538
- *,
539
- max_wait_ms: int = 5000,
540
- quiet_ms: int = 500,
541
- ) -> list[Position]:
536
+ async def get_positions(self) -> list[Position]:
542
537
  """Get all open trading positions.
543
538
 
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.
539
+ XTB's xStation5 CoreAPI echoes the position snapshot on the normal
540
+ reqId-correlated response channel (not the push channel, despite the
541
+ `getAndSubscribeElement` keyword). The subscription it sets up also
542
+ emits per-position updates via `_emit("position", ...)`; those are
543
+ orthogonal to the snapshot returned here.
555
544
  """
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))
593
-
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
+ res = await self.send(
546
+ "getPositions",
547
+ {"getAndSubscribeElement": {"eid": SubscriptionEid.POSITIONS}},
548
+ timeout_ms=30000,
549
+ )
550
+ return parse_positions(self._extract_elements(res))
611
551
 
612
552
  async def get_orders(self) -> list[PendingOrder]:
613
553
  """Get all pending (limit/stop) orders."""
@@ -1,155 +0,0 @@
1
- """`get_positions()` consumes the POSITIONS subscription's push channel.
2
-
3
- XTB's xStation5 CoreAPI doesn't echo a `reqId`-correlated response for
4
- `getPositions`; position data arrives via `status=1` push events with
5
- `eid=POSITIONS`. These tests verify the push-collecting implementation.
6
- """
7
-
8
- from __future__ import annotations
9
-
10
- import asyncio
11
- from unittest.mock import AsyncMock, MagicMock
12
-
13
- import pytest
14
-
15
- from xtb_api.types.enums import SocketStatus
16
- from xtb_api.types.websocket import WSClientConfig
17
- from xtb_api.ws.parsers import parse_position_trade
18
- from xtb_api.ws.ws_client import XTBWebSocketClient
19
-
20
-
21
- def _mock_ws_send() -> AsyncMock:
22
- return AsyncMock(return_value=None)
23
-
24
-
25
- def _trade(position_id: str, symbol: str = "CIG.PL", side: int = 0) -> dict:
26
- return {
27
- "positionId": position_id,
28
- "symbol": symbol,
29
- "side": side,
30
- "volume": 10,
31
- "openPrice": 23.17,
32
- "sl": 22.0,
33
- "tp": 25.0,
34
- "swap": 0.0,
35
- "commission": 0.1,
36
- "margin": 50.0,
37
- "openTime": 1700000000,
38
- "idQuote": 123,
39
- }
40
-
41
-
42
- def _make_client() -> XTBWebSocketClient:
43
- config = WSClientConfig(
44
- url="wss://api5demoa.x-station.eu/v1/xstation",
45
- account_number=12345678,
46
- )
47
- client = XTBWebSocketClient(config)
48
- # Bypass real connect: set status to CONNECTED (what is_connected reads)
49
- # and stub the underlying socket.
50
- client._status = SocketStatus.CONNECTED
51
- client._ws = MagicMock()
52
- client._ws.send = _mock_ws_send()
53
- return client
54
-
55
-
56
- # ── parse_position_trade (extracted helper) ────────────────────────
57
-
58
-
59
- def test_parse_position_trade_extracts_expected_fields() -> None:
60
- pos = parse_position_trade(_trade("P1"))
61
- assert pos.symbol == "CIG.PL"
62
- assert pos.order_id == "P1"
63
- assert pos.volume == 10
64
- assert pos.open_price == 23.17
65
- assert pos.side == "buy"
66
- assert pos.stop_loss == 22.0
67
- assert pos.take_profit == 25.0
68
-
69
-
70
- def test_parse_position_trade_side_sell() -> None:
71
- pos = parse_position_trade(_trade("P1", side=1))
72
- assert pos.side == "sell"
73
-
74
-
75
- def test_parse_position_trade_zero_sl_tp_become_none() -> None:
76
- trade = _trade("P1")
77
- trade["sl"] = 0
78
- trade["tp"] = 0
79
- pos = parse_position_trade(trade)
80
- assert pos.stop_loss is None
81
- assert pos.take_profit is None
82
-
83
-
84
- # ── get_positions push-collection ──────────────────────────────────
85
-
86
-
87
- @pytest.mark.asyncio
88
- async def test_get_positions_collects_first_burst(monkeypatch: pytest.MonkeyPatch) -> None:
89
- client = _make_client()
90
-
91
- async def fire_pushes_after_subscribe() -> None:
92
- # Give the coroutine a moment to register its 'position' listener.
93
- await asyncio.sleep(0.05)
94
- client._emit("position", _trade("P1", "CIG.PL"))
95
- client._emit("position", _trade("P2", "AAPL.US"))
96
-
97
- # Run the push firing concurrently with get_positions.
98
- pushes_task = asyncio.create_task(fire_pushes_after_subscribe())
99
- positions = await client.get_positions(max_wait_ms=2000, quiet_ms=300)
100
- await pushes_task
101
-
102
- assert len(positions) == 2
103
- assert {p.order_id for p in positions} == {"P1", "P2"}
104
- client._ws.send.assert_awaited() # subscription was fired
105
-
106
-
107
- @pytest.mark.asyncio
108
- async def test_get_positions_dedups_by_position_id() -> None:
109
- client = _make_client()
110
-
111
- async def fire_duplicates() -> None:
112
- await asyncio.sleep(0.05)
113
- client._emit("position", _trade("P1", "CIG.PL"))
114
- client._emit("position", _trade("P1", "CIG.PL")) # duplicate update
115
- client._emit("position", _trade("P2", "LPP.PL"))
116
-
117
- task = asyncio.create_task(fire_duplicates())
118
- positions = await client.get_positions(max_wait_ms=2000, quiet_ms=300)
119
- await task
120
-
121
- assert len(positions) == 2
122
- assert {p.order_id for p in positions} == {"P1", "P2"}
123
-
124
-
125
- @pytest.mark.asyncio
126
- async def test_get_positions_returns_empty_on_no_pushes() -> None:
127
- client = _make_client()
128
- # No pushes fired — must return quickly after max_wait.
129
- positions = await client.get_positions(max_wait_ms=300, quiet_ms=50)
130
- assert positions == []
131
-
132
-
133
- @pytest.mark.asyncio
134
- async def test_get_positions_removes_listener_on_exit() -> None:
135
- client = _make_client()
136
- # Record initial listener count on the 'position' event (may be non-zero
137
- # from other hooks registered in the live client).
138
- before = len(client._event_handlers.get("position", []))
139
- await client.get_positions(max_wait_ms=200, quiet_ms=50)
140
- after = len(client._event_handlers.get("position", []))
141
- assert before == after, "Handler leaked after get_positions exits"
142
-
143
-
144
- @pytest.mark.asyncio
145
- async def test_get_positions_raises_when_not_connected() -> None:
146
- config = WSClientConfig(
147
- url="wss://api5demoa.x-station.eu/v1/xstation",
148
- account_number=12345678,
149
- )
150
- client = XTBWebSocketClient(config)
151
- # Default state: not connected.
152
- from xtb_api.exceptions import XTBConnectionError
153
-
154
- with pytest.raises(XTBConnectionError):
155
- await client.get_positions(max_wait_ms=100)
File without changes
File without changes