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.
- {xtb_api_python-0.5.3 → xtb_api_python-0.5.4}/CHANGELOG.md +29 -0
- {xtb_api_python-0.5.3 → xtb_api_python-0.5.4}/PKG-INFO +1 -1
- {xtb_api_python-0.5.3 → xtb_api_python-0.5.4}/pyproject.toml +1 -1
- {xtb_api_python-0.5.3 → xtb_api_python-0.5.4}/src/xtb_api/ws/ws_client.py +13 -73
- xtb_api_python-0.5.3/tests/test_get_positions_push.py +0 -155
- {xtb_api_python-0.5.3 → xtb_api_python-0.5.4}/.gitignore +0 -0
- {xtb_api_python-0.5.3 → xtb_api_python-0.5.4}/LICENSE +0 -0
- {xtb_api_python-0.5.3 → xtb_api_python-0.5.4}/README.md +0 -0
- {xtb_api_python-0.5.3 → xtb_api_python-0.5.4}/examples/basic_usage.py +0 -0
- {xtb_api_python-0.5.3 → xtb_api_python-0.5.4}/examples/grpc_trade.py +0 -0
- {xtb_api_python-0.5.3 → xtb_api_python-0.5.4}/examples/live_quotes.py +0 -0
- {xtb_api_python-0.5.3 → xtb_api_python-0.5.4}/src/xtb_api/__init__.py +0 -0
- {xtb_api_python-0.5.3 → xtb_api_python-0.5.4}/src/xtb_api/__main__.py +0 -0
- {xtb_api_python-0.5.3 → xtb_api_python-0.5.4}/src/xtb_api/auth/__init__.py +0 -0
- {xtb_api_python-0.5.3 → xtb_api_python-0.5.4}/src/xtb_api/auth/auth_manager.py +0 -0
- {xtb_api_python-0.5.3 → xtb_api_python-0.5.4}/src/xtb_api/auth/browser_auth.py +0 -0
- {xtb_api_python-0.5.3 → xtb_api_python-0.5.4}/src/xtb_api/auth/cas_client.py +0 -0
- {xtb_api_python-0.5.3 → xtb_api_python-0.5.4}/src/xtb_api/client.py +0 -0
- {xtb_api_python-0.5.3 → xtb_api_python-0.5.4}/src/xtb_api/exceptions.py +0 -0
- {xtb_api_python-0.5.3 → xtb_api_python-0.5.4}/src/xtb_api/grpc/__init__.py +0 -0
- {xtb_api_python-0.5.3 → xtb_api_python-0.5.4}/src/xtb_api/grpc/client.py +0 -0
- {xtb_api_python-0.5.3 → xtb_api_python-0.5.4}/src/xtb_api/grpc/proto.py +0 -0
- {xtb_api_python-0.5.3 → xtb_api_python-0.5.4}/src/xtb_api/grpc/types.py +0 -0
- {xtb_api_python-0.5.3 → xtb_api_python-0.5.4}/src/xtb_api/instruments.py +0 -0
- {xtb_api_python-0.5.3 → xtb_api_python-0.5.4}/src/xtb_api/py.typed +0 -0
- {xtb_api_python-0.5.3 → xtb_api_python-0.5.4}/src/xtb_api/types/__init__.py +0 -0
- {xtb_api_python-0.5.3 → xtb_api_python-0.5.4}/src/xtb_api/types/enums.py +0 -0
- {xtb_api_python-0.5.3 → xtb_api_python-0.5.4}/src/xtb_api/types/instrument.py +0 -0
- {xtb_api_python-0.5.3 → xtb_api_python-0.5.4}/src/xtb_api/types/trading.py +0 -0
- {xtb_api_python-0.5.3 → xtb_api_python-0.5.4}/src/xtb_api/types/websocket.py +0 -0
- {xtb_api_python-0.5.3 → xtb_api_python-0.5.4}/src/xtb_api/utils.py +0 -0
- {xtb_api_python-0.5.3 → xtb_api_python-0.5.4}/src/xtb_api/ws/__init__.py +0 -0
- {xtb_api_python-0.5.3 → xtb_api_python-0.5.4}/src/xtb_api/ws/parsers.py +0 -0
- {xtb_api_python-0.5.3 → xtb_api_python-0.5.4}/tests/__init__.py +0 -0
- {xtb_api_python-0.5.3 → xtb_api_python-0.5.4}/tests/conftest.py +0 -0
- {xtb_api_python-0.5.3 → xtb_api_python-0.5.4}/tests/test_auth.py +0 -0
- {xtb_api_python-0.5.3 → xtb_api_python-0.5.4}/tests/test_auth_manager.py +0 -0
- {xtb_api_python-0.5.3 → xtb_api_python-0.5.4}/tests/test_browser_auth.py +0 -0
- {xtb_api_python-0.5.3 → xtb_api_python-0.5.4}/tests/test_browser_auth_chromium_missing.py +0 -0
- {xtb_api_python-0.5.3 → xtb_api_python-0.5.4}/tests/test_cas_cookies.py +0 -0
- {xtb_api_python-0.5.3 → xtb_api_python-0.5.4}/tests/test_client.py +0 -0
- {xtb_api_python-0.5.3 → xtb_api_python-0.5.4}/tests/test_client_fill_price.py +0 -0
- {xtb_api_python-0.5.3 → xtb_api_python-0.5.4}/tests/test_client_volume_validation.py +0 -0
- {xtb_api_python-0.5.3 → xtb_api_python-0.5.4}/tests/test_doctor.py +0 -0
- {xtb_api_python-0.5.3 → xtb_api_python-0.5.4}/tests/test_exceptions.py +0 -0
- {xtb_api_python-0.5.3 → xtb_api_python-0.5.4}/tests/test_grpc_client.py +0 -0
- {xtb_api_python-0.5.3 → xtb_api_python-0.5.4}/tests/test_instruments.py +0 -0
- {xtb_api_python-0.5.3 → xtb_api_python-0.5.4}/tests/test_parsers.py +0 -0
- {xtb_api_python-0.5.3 → xtb_api_python-0.5.4}/tests/test_proto.py +0 -0
- {xtb_api_python-0.5.3 → xtb_api_python-0.5.4}/tests/test_version.py +0 -0
- {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
|
+
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
|
|
@@ -52,7 +52,7 @@ from xtb_api.ws.parsers import (
|
|
|
52
52
|
parse_balance,
|
|
53
53
|
parse_instruments,
|
|
54
54
|
parse_orders,
|
|
55
|
-
|
|
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
|
|
545
|
-
channel (
|
|
546
|
-
`
|
|
547
|
-
|
|
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
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|