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/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
@@ -0,0 +1,6 @@
1
+ """XTB API type definitions."""
2
+
3
+ from xtb_api.types.enums import * # noqa: F403
4
+ from xtb_api.types.instrument import * # noqa: F403
5
+ from xtb_api.types.trading import * # noqa: F403
6
+ from xtb_api.types.websocket import * # noqa: F403
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
@@ -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
@@ -0,0 +1,3 @@
1
+ """WebSocket client module."""
2
+
3
+ from xtb_api.ws.ws_client import XTBWebSocketClient as XTBWebSocketClient