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/instruments.py
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""Persistent instrument-ID cache for symbol lookups.
|
|
2
|
+
|
|
3
|
+
Downstream consumers (e.g. trading bots) need to map a symbol to the XTB
|
|
4
|
+
instrument ID that the gRPC trading endpoint requires. Looking this up on
|
|
5
|
+
every trade is slow; caching it in memory only loses the mapping on restart.
|
|
6
|
+
|
|
7
|
+
This module persists the mapping to disk as JSON so restarts stay cheap.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import logging
|
|
14
|
+
from collections.abc import Iterable
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Protocol
|
|
17
|
+
|
|
18
|
+
from xtb_api.types.instrument import InstrumentSearchResult
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class _SearchClient(Protocol):
|
|
24
|
+
async def search_instrument(self, query: str) -> list[InstrumentSearchResult]: ...
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class InstrumentRegistry:
|
|
28
|
+
"""Symbol → instrument-ID cache, persisted as JSON.
|
|
29
|
+
|
|
30
|
+
Example::
|
|
31
|
+
|
|
32
|
+
from xtb_api import XTBClient, InstrumentRegistry
|
|
33
|
+
|
|
34
|
+
client = XTBClient(email=..., password=..., account_number=...)
|
|
35
|
+
await client.connect()
|
|
36
|
+
|
|
37
|
+
reg = InstrumentRegistry("data/instrument_ids.json")
|
|
38
|
+
matched = await reg.populate(client, ["CIG.PL", "AAPL.US"])
|
|
39
|
+
print(f"Matched {len(matched)} instruments")
|
|
40
|
+
|
|
41
|
+
# Later:
|
|
42
|
+
instrument_id = reg.get("CIG.PL") # 12345
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
def __init__(self, path: Path | str) -> None:
|
|
46
|
+
self._path = Path(path)
|
|
47
|
+
self._ids: dict[str, int] = {}
|
|
48
|
+
self._load()
|
|
49
|
+
|
|
50
|
+
def _load(self) -> None:
|
|
51
|
+
if not self._path.exists():
|
|
52
|
+
return
|
|
53
|
+
try:
|
|
54
|
+
raw = json.loads(self._path.read_text())
|
|
55
|
+
except (json.JSONDecodeError, OSError) as exc:
|
|
56
|
+
logger.warning("Failed to load instrument IDs from %s: %s", self._path, exc)
|
|
57
|
+
return
|
|
58
|
+
if not isinstance(raw, dict) or not all(isinstance(k, str) and isinstance(v, int) for k, v in raw.items()):
|
|
59
|
+
logger.warning("Instrument ID file %s has unexpected structure, ignoring", self._path)
|
|
60
|
+
return
|
|
61
|
+
self._ids = raw
|
|
62
|
+
logger.info("Loaded %d instrument IDs from %s", len(self._ids), self._path)
|
|
63
|
+
|
|
64
|
+
def _save(self) -> None:
|
|
65
|
+
self._path.parent.mkdir(parents=True, exist_ok=True)
|
|
66
|
+
self._path.write_text(json.dumps(self._ids, indent=2, sort_keys=True))
|
|
67
|
+
|
|
68
|
+
def get(self, symbol: str) -> int | None:
|
|
69
|
+
"""Return the cached instrument ID for `symbol`, or None."""
|
|
70
|
+
return self._ids.get(symbol)
|
|
71
|
+
|
|
72
|
+
def set(self, symbol: str, instrument_id: int) -> None:
|
|
73
|
+
"""Cache `symbol → instrument_id` and persist to disk immediately."""
|
|
74
|
+
self._ids[symbol] = instrument_id
|
|
75
|
+
self._save()
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def ids(self) -> dict[str, int]:
|
|
79
|
+
"""Read-only view of the full cache."""
|
|
80
|
+
return dict(self._ids)
|
|
81
|
+
|
|
82
|
+
async def populate(
|
|
83
|
+
self,
|
|
84
|
+
client: _SearchClient,
|
|
85
|
+
symbols: Iterable[str],
|
|
86
|
+
) -> dict[str, int]:
|
|
87
|
+
"""Download the full instrument list via `client.search_instrument` and
|
|
88
|
+
match every requested symbol against it. Persist the matches.
|
|
89
|
+
|
|
90
|
+
Matches are case-insensitive. If a symbol like `BRK.B.US` does not
|
|
91
|
+
appear in the downloaded list, fall back to the dot-less variant
|
|
92
|
+
(`BRKB.US`).
|
|
93
|
+
|
|
94
|
+
Symbols are stored under the caller's original casing; subsequent
|
|
95
|
+
calls to `get()` must use the same case.
|
|
96
|
+
|
|
97
|
+
Returns a dict of the matches written during this call (does not
|
|
98
|
+
include pre-existing entries).
|
|
99
|
+
"""
|
|
100
|
+
# Prime the client's in-memory cache (first call downloads all 11,888+
|
|
101
|
+
# instruments under the WS client's lock). Then read the full cache
|
|
102
|
+
# directly — search_instrument() only returns up to 100 filtered matches,
|
|
103
|
+
# which isn't enough for matching against a full universe.
|
|
104
|
+
await client.search_instrument("a")
|
|
105
|
+
|
|
106
|
+
ws = getattr(client, "ws", client)
|
|
107
|
+
raw = getattr(ws, "_symbols_cache", None)
|
|
108
|
+
if isinstance(raw, list) and raw:
|
|
109
|
+
all_results: list[InstrumentSearchResult] = list(raw)
|
|
110
|
+
else:
|
|
111
|
+
# Fallback when caller isn't an XTBClient (e.g., pure protocol implementer).
|
|
112
|
+
all_results = await client.search_instrument("a")
|
|
113
|
+
|
|
114
|
+
index = {r.symbol.upper(): r.instrument_id for r in all_results}
|
|
115
|
+
|
|
116
|
+
matched: dict[str, int] = {}
|
|
117
|
+
for sym in symbols:
|
|
118
|
+
key = sym.upper()
|
|
119
|
+
if key in index:
|
|
120
|
+
matched[sym] = index[key]
|
|
121
|
+
continue
|
|
122
|
+
# Dot-less fallback: BRK.B.US → BRKB.US. Skip symbols with no inner
|
|
123
|
+
# dots (e.g. AAPL.US) where the dot-less variant would be identical.
|
|
124
|
+
base, _, suffix = key.rpartition(".")
|
|
125
|
+
if base and "." in base:
|
|
126
|
+
alt = base.replace(".", "") + "." + suffix
|
|
127
|
+
if alt in index:
|
|
128
|
+
matched[sym] = index[alt]
|
|
129
|
+
|
|
130
|
+
self._ids.update(matched)
|
|
131
|
+
self._save()
|
|
132
|
+
return matched
|
xtb_api/py.typed
ADDED
|
File without changes
|
xtb_api/types/enums.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Enumerations for XTB API protocol."""
|
|
2
|
+
|
|
3
|
+
from enum import IntEnum, StrEnum
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Xs6Side(IntEnum):
|
|
7
|
+
"""Trade side enumeration for buy/sell operations (WebSocket protocol).
|
|
8
|
+
|
|
9
|
+
WARNING: These values (BUY=0, SELL=1) differ from the gRPC protocol
|
|
10
|
+
constants in ``grpc.proto`` (SIDE_BUY=1, SIDE_SELL=2). Do NOT pass
|
|
11
|
+
``Xs6Side`` values to gRPC functions or vice-versa.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
BUY = 0 # WebSocket only — gRPC uses SIDE_BUY=1
|
|
15
|
+
SELL = 1 # WebSocket only — gRPC uses SIDE_SELL=2
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class TradeCommand(IntEnum):
|
|
19
|
+
"""Trade command types for different order types."""
|
|
20
|
+
|
|
21
|
+
BUY = 0
|
|
22
|
+
SELL = 1
|
|
23
|
+
BUY_LIMIT = 2
|
|
24
|
+
SELL_LIMIT = 3
|
|
25
|
+
BUY_STOP = 4
|
|
26
|
+
SELL_STOP = 5
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class TradeType(IntEnum):
|
|
30
|
+
"""Trade type classification for order execution."""
|
|
31
|
+
|
|
32
|
+
MARKET = 0
|
|
33
|
+
LIMIT = 1
|
|
34
|
+
STOP = 2
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class RequestTradeData(IntEnum):
|
|
38
|
+
"""Field identifiers for trade request data."""
|
|
39
|
+
|
|
40
|
+
TYPE = 1
|
|
41
|
+
TRADE_TYPE = 2
|
|
42
|
+
SIDE = 3
|
|
43
|
+
VOLUME = 4
|
|
44
|
+
AMOUNT = 5
|
|
45
|
+
SL = 6
|
|
46
|
+
TP = 7
|
|
47
|
+
OFFSET = 8
|
|
48
|
+
PRICE = 9
|
|
49
|
+
EXPIRATION = 10
|
|
50
|
+
ORDER_ID = 11
|
|
51
|
+
INSTRUMENT_ID = 12
|
|
52
|
+
SL_AMOUNT = 13
|
|
53
|
+
TP_AMOUNT = 14
|
|
54
|
+
SYMBOL_KEY = 15
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class SymbolSessionType(IntEnum):
|
|
58
|
+
"""Symbol trading session status."""
|
|
59
|
+
|
|
60
|
+
CLOSED = 0
|
|
61
|
+
OPEN = 1
|
|
62
|
+
LOBBY = 2
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class SocketStatus(StrEnum):
|
|
66
|
+
"""WebSocket connection status."""
|
|
67
|
+
|
|
68
|
+
CONNECTING = "CONNECTING"
|
|
69
|
+
CONNECTED = "CONNECTED"
|
|
70
|
+
DISCONNECTING = "DISCONNECTING"
|
|
71
|
+
CLOSED = "CLOSED"
|
|
72
|
+
ERROR = "SOCKET_ERROR"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class XTBEnvironment(StrEnum):
|
|
76
|
+
"""XTB trading environment type."""
|
|
77
|
+
|
|
78
|
+
REAL = "real"
|
|
79
|
+
DEMO = "demo"
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class SubscriptionEid(IntEnum):
|
|
83
|
+
"""Element IDs for WebSocket data subscriptions."""
|
|
84
|
+
|
|
85
|
+
POSITIONS = 1
|
|
86
|
+
TICKS = 2
|
|
87
|
+
SYMBOLS = 3
|
|
88
|
+
SYMBOL_GROUPS = 4
|
|
89
|
+
GROUP_SETTINGS = 5
|
|
90
|
+
REQUEST_STATUS = 6
|
|
91
|
+
ORDERS = 7
|
|
92
|
+
TOTAL_BALANCE = 1043
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Instrument and quote type definitions."""
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class InstrumentSymbol(BaseModel):
|
|
7
|
+
"""Complete instrument symbol definition from XTB."""
|
|
8
|
+
|
|
9
|
+
name: str
|
|
10
|
+
quote_id: int
|
|
11
|
+
instrument_id: int
|
|
12
|
+
id_asset_class: int
|
|
13
|
+
display_name: str
|
|
14
|
+
description: str
|
|
15
|
+
full_description: str = ""
|
|
16
|
+
group_id: int
|
|
17
|
+
search_group: str = ""
|
|
18
|
+
precision: int = 2
|
|
19
|
+
lot_min: float = 1.0
|
|
20
|
+
lot_step: float = 1.0
|
|
21
|
+
instrument_tag: str | None = None
|
|
22
|
+
has_depth: bool | None = None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Quote(BaseModel):
|
|
26
|
+
"""Real-time quote/tick data."""
|
|
27
|
+
|
|
28
|
+
symbol: str
|
|
29
|
+
ask: float
|
|
30
|
+
bid: float
|
|
31
|
+
spread: float
|
|
32
|
+
high: float | None = None
|
|
33
|
+
low: float | None = None
|
|
34
|
+
time: int | None = None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class InstrumentSearchResult(BaseModel):
|
|
38
|
+
"""Instrument search result."""
|
|
39
|
+
|
|
40
|
+
symbol: str
|
|
41
|
+
instrument_id: int
|
|
42
|
+
name: str
|
|
43
|
+
description: str
|
|
44
|
+
asset_class: str
|
|
45
|
+
symbol_key: str
|
xtb_api/types/trading.py
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""Trading type definitions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Literal
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel
|
|
8
|
+
|
|
9
|
+
from xtb_api.types.enums import Xs6Side
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class IPrice(BaseModel):
|
|
13
|
+
"""Price representation with value and scale.
|
|
14
|
+
|
|
15
|
+
Actual price = value × 10^(-scale)
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
value: int
|
|
19
|
+
scale: int
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class IVolume(BaseModel):
|
|
23
|
+
"""Volume representation with value and scale."""
|
|
24
|
+
|
|
25
|
+
value: int
|
|
26
|
+
scale: int = 0
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ISize(BaseModel):
|
|
30
|
+
"""Trade size specification — either volume or amount."""
|
|
31
|
+
|
|
32
|
+
volume: IVolume | None = None
|
|
33
|
+
amount: float | None = None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class IStopLossInput(BaseModel):
|
|
37
|
+
"""Stop loss configuration."""
|
|
38
|
+
|
|
39
|
+
price: IPrice | None = None
|
|
40
|
+
trailingstopinput: dict | None = None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class ITakeProfitInput(BaseModel):
|
|
44
|
+
"""Take profit configuration."""
|
|
45
|
+
|
|
46
|
+
price: IPrice | None = None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class INewMarketOrder(BaseModel):
|
|
50
|
+
"""Market order definition for WebSocket trading."""
|
|
51
|
+
|
|
52
|
+
instrumentid: int
|
|
53
|
+
size: ISize
|
|
54
|
+
side: Xs6Side
|
|
55
|
+
stoploss: IStopLossInput | None = None
|
|
56
|
+
takeprofit: ITakeProfitInput | None = None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class IXs6AuthAccount(BaseModel):
|
|
60
|
+
"""Account information for trade events."""
|
|
61
|
+
|
|
62
|
+
number: int
|
|
63
|
+
server: str
|
|
64
|
+
currency: str
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class INewMarketOrderEvent(BaseModel):
|
|
68
|
+
"""Complete market order event for WebSocket API."""
|
|
69
|
+
|
|
70
|
+
order: INewMarketOrder
|
|
71
|
+
uiTrackingId: str | None = None
|
|
72
|
+
account: IXs6AuthAccount
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class TradeOptions(BaseModel):
|
|
76
|
+
"""Simplified trade options for high-level API."""
|
|
77
|
+
|
|
78
|
+
stop_loss: float | None = None
|
|
79
|
+
take_profit: float | None = None
|
|
80
|
+
trailing_stop: float | None = None
|
|
81
|
+
amount: float | None = None
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class Position(BaseModel):
|
|
85
|
+
"""Open trading position information."""
|
|
86
|
+
|
|
87
|
+
symbol: str
|
|
88
|
+
instrument_id: int | None = None
|
|
89
|
+
volume: float
|
|
90
|
+
current_price: float = 0.0
|
|
91
|
+
open_price: float
|
|
92
|
+
stop_loss: float | None = None
|
|
93
|
+
take_profit: float | None = None
|
|
94
|
+
profit_percent: float = 0.0
|
|
95
|
+
profit_net: float = 0.0
|
|
96
|
+
swap: float | None = None
|
|
97
|
+
side: Literal["buy", "sell"]
|
|
98
|
+
order_id: str | None = None
|
|
99
|
+
commission: float | None = None
|
|
100
|
+
margin: float | None = None
|
|
101
|
+
open_time: int | None = None
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class PendingOrder(BaseModel):
|
|
105
|
+
"""Pending (limit/stop) order information."""
|
|
106
|
+
|
|
107
|
+
symbol: str
|
|
108
|
+
instrument_id: int | None = None
|
|
109
|
+
volume: float
|
|
110
|
+
price: float
|
|
111
|
+
stop_loss: float | None = None
|
|
112
|
+
take_profit: float | None = None
|
|
113
|
+
side: Literal["buy", "sell"]
|
|
114
|
+
order_id: str | None = None
|
|
115
|
+
order_type: str | None = None
|
|
116
|
+
expiration: int | None = None
|
|
117
|
+
open_time: int | None = None
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class AccountBalance(BaseModel):
|
|
121
|
+
"""Account balance and equity information."""
|
|
122
|
+
|
|
123
|
+
balance: float
|
|
124
|
+
equity: float
|
|
125
|
+
free_margin: float
|
|
126
|
+
currency: str
|
|
127
|
+
account_number: int
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class TradeResult(BaseModel):
|
|
131
|
+
"""Trade execution result."""
|
|
132
|
+
|
|
133
|
+
success: bool
|
|
134
|
+
order_id: str | None = None
|
|
135
|
+
symbol: str
|
|
136
|
+
side: Literal["buy", "sell"]
|
|
137
|
+
volume: float | None = None
|
|
138
|
+
price: float | None = None
|
|
139
|
+
error: str | None = None
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""WebSocket protocol type definitions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Literal
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class CoreAPIPayload(BaseModel):
|
|
11
|
+
"""Inner CoreAPI command payload."""
|
|
12
|
+
|
|
13
|
+
endpoint: str
|
|
14
|
+
accountId: str | None = None
|
|
15
|
+
subscribeElement: dict | None = None
|
|
16
|
+
unsubscribeElement: dict | None = None
|
|
17
|
+
getAndSubscribeElement: dict | None = None
|
|
18
|
+
ping: dict | None = None
|
|
19
|
+
tradeTransaction: dict | None = None
|
|
20
|
+
registerClientInfo: dict | None = None
|
|
21
|
+
logonWithServiceTicket: dict | None = None
|
|
22
|
+
|
|
23
|
+
model_config = {"extra": "allow"}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class CoreAPICommand(BaseModel):
|
|
27
|
+
"""CoreAPI command wrapper."""
|
|
28
|
+
|
|
29
|
+
CoreAPI: CoreAPIPayload
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class WSRequest(BaseModel):
|
|
33
|
+
"""WebSocket request message format."""
|
|
34
|
+
|
|
35
|
+
reqId: str
|
|
36
|
+
command: list[CoreAPICommand]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class WSResponse(BaseModel):
|
|
40
|
+
"""WebSocket response message format."""
|
|
41
|
+
|
|
42
|
+
reqId: str = ""
|
|
43
|
+
response: list[Any] | None = None
|
|
44
|
+
data: Any | None = None
|
|
45
|
+
error: dict | None = None
|
|
46
|
+
status: int | None = None
|
|
47
|
+
events: list[dict] | None = None
|
|
48
|
+
|
|
49
|
+
model_config = {"extra": "allow"}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class WSAuthOptions(BaseModel):
|
|
53
|
+
"""Authentication options for WebSocket client."""
|
|
54
|
+
|
|
55
|
+
service_ticket: str | None = None
|
|
56
|
+
tgt: str | None = None
|
|
57
|
+
credentials: WSCredentials | None = None
|
|
58
|
+
browser_auth: bool = False
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class WSCredentials(BaseModel):
|
|
62
|
+
"""Email/password credentials."""
|
|
63
|
+
|
|
64
|
+
email: str
|
|
65
|
+
password: str
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class WSClientConfig(BaseModel):
|
|
69
|
+
"""WebSocket client configuration."""
|
|
70
|
+
|
|
71
|
+
url: str
|
|
72
|
+
account_number: int
|
|
73
|
+
endpoint: str = "meta1"
|
|
74
|
+
ping_interval: int = 10000
|
|
75
|
+
auto_reconnect: bool = True
|
|
76
|
+
max_reconnect_delay: int = 30000
|
|
77
|
+
app_name: str = "xStation5"
|
|
78
|
+
app_version: str = "2.94.1"
|
|
79
|
+
device: str = "Linux x86_64"
|
|
80
|
+
auth: WSAuthOptions | None = None
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class ClientInfo(BaseModel):
|
|
84
|
+
"""Client information for registerClientInfo command."""
|
|
85
|
+
|
|
86
|
+
appName: str
|
|
87
|
+
appVersion: str
|
|
88
|
+
appBuildNumber: str = "0"
|
|
89
|
+
device: str
|
|
90
|
+
osVersion: str = ""
|
|
91
|
+
comment: str = "Python"
|
|
92
|
+
apiVersion: str = "2.73.0"
|
|
93
|
+
osType: int = 0
|
|
94
|
+
deviceType: int = 1
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class XLoginAccountInfo(BaseModel):
|
|
98
|
+
"""Account info from login result."""
|
|
99
|
+
|
|
100
|
+
accountNo: int
|
|
101
|
+
currency: str
|
|
102
|
+
endpointType: str
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class XLoginResult(BaseModel):
|
|
106
|
+
"""Login response data from successful authentication."""
|
|
107
|
+
|
|
108
|
+
accountList: list[XLoginAccountInfo] = []
|
|
109
|
+
endpointList: list[str] = []
|
|
110
|
+
userData: dict = {}
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class WSPushEventRow(BaseModel):
|
|
114
|
+
"""Push event row data."""
|
|
115
|
+
|
|
116
|
+
key: str = ""
|
|
117
|
+
value: dict = {}
|
|
118
|
+
|
|
119
|
+
model_config = {"extra": "allow"}
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class WSPushEvent(BaseModel):
|
|
123
|
+
"""Push event in a push message."""
|
|
124
|
+
|
|
125
|
+
eid: int
|
|
126
|
+
row: WSPushEventRow
|
|
127
|
+
|
|
128
|
+
model_config = {"extra": "allow"}
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class WSPushMessage(BaseModel):
|
|
132
|
+
"""Push message structure for real-time data updates."""
|
|
133
|
+
|
|
134
|
+
reqId: str = ""
|
|
135
|
+
status: int = 1
|
|
136
|
+
events: list[WSPushEvent] = []
|
|
137
|
+
|
|
138
|
+
model_config = {"extra": "allow"}
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class CASLoginSuccess(BaseModel):
|
|
142
|
+
"""Successful CAS login result."""
|
|
143
|
+
|
|
144
|
+
type: Literal["success"] = "success"
|
|
145
|
+
tgt: str
|
|
146
|
+
expires_at: float
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
class CASLoginTwoFactorRequired(BaseModel):
|
|
150
|
+
"""CAS login requiring two-factor authentication."""
|
|
151
|
+
|
|
152
|
+
type: Literal["requires_2fa"] = "requires_2fa"
|
|
153
|
+
login_ticket: str
|
|
154
|
+
session_id: str = "" # backward compat (may be same as login_ticket or empty)
|
|
155
|
+
two_factor_auth_type: str = "SMS"
|
|
156
|
+
methods: list[str] = ["TOTP"]
|
|
157
|
+
expires_at: float
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
CASLoginResult = CASLoginSuccess | CASLoginTwoFactorRequired
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
# Backward-compatible re-export — canonical location is xtb_api.exceptions
|
|
164
|
+
from xtb_api.exceptions import CASError as CASError # noqa: E402, F401
|
xtb_api/utils.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Utility functions for price/volume conversion and helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import random
|
|
7
|
+
import time
|
|
8
|
+
|
|
9
|
+
from pydantic import BaseModel
|
|
10
|
+
|
|
11
|
+
from xtb_api.types.trading import IPrice, IVolume
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def price_from_decimal(price: float, precision: int) -> IPrice:
|
|
15
|
+
"""Create IPrice from decimal: price_from_decimal(2.62, 2) → IPrice(value=262, scale=2)"""
|
|
16
|
+
value = round(price * (10**precision))
|
|
17
|
+
return IPrice(value=value, scale=precision)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def price_to_decimal(price: IPrice) -> float:
|
|
21
|
+
"""Convert IPrice to decimal: price_to_decimal(IPrice(value=262, scale=2)) → 2.62"""
|
|
22
|
+
return float(price.value) * float(10 ** (-price.scale))
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def volume_from(qty: int, scale: int = 0) -> IVolume:
|
|
26
|
+
"""Create IVolume: volume_from(19) → IVolume(value=19, scale=0)"""
|
|
27
|
+
return IVolume(value=qty, scale=scale)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def generate_req_id(prefix: str) -> str:
|
|
31
|
+
"""Generate unique request ID."""
|
|
32
|
+
return f"{prefix}_{int(time.time() * 1000)}_{random.randint(0, 999)}"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def build_account_id(account_number: int, endpoint: str = "meta1") -> str:
|
|
36
|
+
"""Build accountId: 'meta1_{accountNumber}'"""
|
|
37
|
+
return f"{endpoint}_{account_number}"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class ParsedSymbolKey(BaseModel):
|
|
41
|
+
"""Parsed symbol key components."""
|
|
42
|
+
|
|
43
|
+
asset_class_id: int
|
|
44
|
+
symbol_name: str
|
|
45
|
+
group_id: int
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def parse_symbol_key(key: str) -> ParsedSymbolKey | None:
|
|
49
|
+
"""Parse symbol key: '9_CIG.PL_6' → ParsedSymbolKey(9, 'CIG.PL', 6)"""
|
|
50
|
+
parts = key.split("_")
|
|
51
|
+
if len(parts) < 3:
|
|
52
|
+
return None
|
|
53
|
+
return ParsedSymbolKey(
|
|
54
|
+
asset_class_id=int(parts[0]),
|
|
55
|
+
symbol_name="_".join(parts[1:-1]),
|
|
56
|
+
group_id=int(parts[-1]),
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
async def sleep(ms: int) -> None:
|
|
61
|
+
"""Sleep helper (milliseconds)."""
|
|
62
|
+
await asyncio.sleep(ms / 1000)
|
xtb_api/ws/__init__.py
ADDED