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/grpc/client.py
ADDED
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
"""gRPC-web client for XTB xStation5 trading.
|
|
2
|
+
|
|
3
|
+
Uses native HTTP POST via httpx for gRPC-web calls to ipax.xtb.com.
|
|
4
|
+
Requires a valid TGT (obtained via AuthManager) to create JWT tokens.
|
|
5
|
+
|
|
6
|
+
Flow:
|
|
7
|
+
1. Build CreateAccessTokenRequest protobuf (TGT + Account)
|
|
8
|
+
2. Send auth request → get JWT with account scope (acn/acs)
|
|
9
|
+
3. Send trade requests with JWT
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import base64
|
|
15
|
+
import contextlib
|
|
16
|
+
import logging
|
|
17
|
+
import re
|
|
18
|
+
import time
|
|
19
|
+
from typing import TYPE_CHECKING
|
|
20
|
+
|
|
21
|
+
import httpx
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
from xtb_api.auth.auth_manager import AuthManager
|
|
25
|
+
|
|
26
|
+
from xtb_api.exceptions import (
|
|
27
|
+
AuthenticationError,
|
|
28
|
+
ProtocolError,
|
|
29
|
+
)
|
|
30
|
+
from xtb_api.grpc.proto import (
|
|
31
|
+
GRPC_AUTH_ENDPOINT,
|
|
32
|
+
GRPC_NEW_ORDER_ENDPOINT,
|
|
33
|
+
GRPC_WEB_TEXT_CONTENT_TYPE,
|
|
34
|
+
SIDE_BUY,
|
|
35
|
+
SIDE_SELL,
|
|
36
|
+
build_create_access_token_request,
|
|
37
|
+
build_grpc_web_text_body,
|
|
38
|
+
build_new_market_order,
|
|
39
|
+
extract_jwt,
|
|
40
|
+
)
|
|
41
|
+
from xtb_api.grpc.types import GrpcTradeResult
|
|
42
|
+
|
|
43
|
+
logger = logging.getLogger(__name__)
|
|
44
|
+
|
|
45
|
+
# JWT cache lifetime
|
|
46
|
+
_JWT_VALIDITY_SEC = 300 # 5 minutes
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class GrpcClient:
|
|
50
|
+
"""gRPC-web client for XTB xStation5 trading.
|
|
51
|
+
|
|
52
|
+
When an AuthManager is provided, JWT tokens are automatically
|
|
53
|
+
refreshed from the shared TGT — no manual token management needed.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
def __init__(
|
|
57
|
+
self,
|
|
58
|
+
account_number: str,
|
|
59
|
+
account_server: str = "XS-real1",
|
|
60
|
+
auth: AuthManager | None = None,
|
|
61
|
+
) -> None:
|
|
62
|
+
self._account_number = account_number
|
|
63
|
+
self._account_server = account_server
|
|
64
|
+
self._auth = auth
|
|
65
|
+
self._jwt: str | None = None
|
|
66
|
+
self._jwt_timestamp: float = 0.0
|
|
67
|
+
self._http: httpx.AsyncClient | None = None
|
|
68
|
+
|
|
69
|
+
def invalidate_jwt(self) -> None:
|
|
70
|
+
"""Clear the cached JWT so the next call fetches a fresh one."""
|
|
71
|
+
self._jwt = None
|
|
72
|
+
self._jwt_timestamp = 0.0
|
|
73
|
+
|
|
74
|
+
async def _ensure_http(self) -> httpx.AsyncClient:
|
|
75
|
+
"""Get or create the long-lived httpx client."""
|
|
76
|
+
if self._http is None or self._http.is_closed:
|
|
77
|
+
self._http = httpx.AsyncClient(timeout=20.0)
|
|
78
|
+
return self._http
|
|
79
|
+
|
|
80
|
+
async def _grpc_call(
|
|
81
|
+
self,
|
|
82
|
+
endpoint: str,
|
|
83
|
+
body_b64: str,
|
|
84
|
+
jwt: str | None = None,
|
|
85
|
+
) -> bytes:
|
|
86
|
+
"""Make a gRPC-web call via httpx.
|
|
87
|
+
|
|
88
|
+
Args:
|
|
89
|
+
endpoint: Full gRPC-web endpoint URL.
|
|
90
|
+
body_b64: Base64-encoded protobuf body.
|
|
91
|
+
jwt: Optional JWT bearer token.
|
|
92
|
+
|
|
93
|
+
Returns:
|
|
94
|
+
Decoded protobuf response bytes.
|
|
95
|
+
"""
|
|
96
|
+
headers = {
|
|
97
|
+
"Content-Type": GRPC_WEB_TEXT_CONTENT_TYPE,
|
|
98
|
+
"Accept": GRPC_WEB_TEXT_CONTENT_TYPE,
|
|
99
|
+
"X-Grpc-Web": "1",
|
|
100
|
+
"x-user-agent": "grpc-web-javascript/0.1",
|
|
101
|
+
}
|
|
102
|
+
if jwt:
|
|
103
|
+
headers["Authorization"] = f"Bearer {jwt}"
|
|
104
|
+
|
|
105
|
+
client = await self._ensure_http()
|
|
106
|
+
resp = await client.post(endpoint, content=body_b64, headers=headers)
|
|
107
|
+
resp.raise_for_status()
|
|
108
|
+
|
|
109
|
+
if not resp.text:
|
|
110
|
+
raise ProtocolError("gRPC call returned empty response")
|
|
111
|
+
|
|
112
|
+
return base64.b64decode(resp.text)
|
|
113
|
+
|
|
114
|
+
async def get_jwt(self, tgt: str | None = None) -> str:
|
|
115
|
+
"""Get JWT with account scope via CreateAccessToken gRPC call.
|
|
116
|
+
|
|
117
|
+
If an AuthManager is configured, the TGT is obtained automatically.
|
|
118
|
+
Otherwise, a TGT must be passed explicitly.
|
|
119
|
+
|
|
120
|
+
Args:
|
|
121
|
+
tgt: TGT string. If None, uses AuthManager to get one.
|
|
122
|
+
|
|
123
|
+
Returns:
|
|
124
|
+
JWT string with acn/acs fields for trading.
|
|
125
|
+
"""
|
|
126
|
+
now = time.monotonic()
|
|
127
|
+
if self._jwt and (now - self._jwt_timestamp) < _JWT_VALIDITY_SEC:
|
|
128
|
+
return self._jwt
|
|
129
|
+
|
|
130
|
+
if tgt is None:
|
|
131
|
+
if self._auth is None:
|
|
132
|
+
raise AuthenticationError("No TGT provided and no AuthManager configured")
|
|
133
|
+
tgt = await self._auth.get_tgt()
|
|
134
|
+
|
|
135
|
+
logger.info("Requesting new JWT via CreateAccessToken...")
|
|
136
|
+
|
|
137
|
+
proto_msg = build_create_access_token_request(
|
|
138
|
+
tgt=tgt,
|
|
139
|
+
account_number=self._account_number,
|
|
140
|
+
account_server=self._account_server,
|
|
141
|
+
)
|
|
142
|
+
body_b64 = build_grpc_web_text_body(proto_msg)
|
|
143
|
+
|
|
144
|
+
response_bytes = await self._grpc_call(GRPC_AUTH_ENDPOINT, body_b64, jwt=None)
|
|
145
|
+
|
|
146
|
+
jwt = extract_jwt(response_bytes)
|
|
147
|
+
if not jwt:
|
|
148
|
+
raise AuthenticationError(
|
|
149
|
+
"Failed to extract JWT from CreateAccessToken response "
|
|
150
|
+
f"({len(response_bytes)} bytes). "
|
|
151
|
+
"Check that TGT is valid and account info is correct."
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
self._jwt = jwt
|
|
155
|
+
self._jwt_timestamp = now
|
|
156
|
+
logger.info("JWT obtained (with account scope)")
|
|
157
|
+
return jwt
|
|
158
|
+
|
|
159
|
+
async def _ensure_jwt(self) -> str:
|
|
160
|
+
"""Ensure a valid JWT is available, refreshing if needed."""
|
|
161
|
+
now = time.monotonic()
|
|
162
|
+
if self._jwt and (now - self._jwt_timestamp) < _JWT_VALIDITY_SEC:
|
|
163
|
+
return self._jwt
|
|
164
|
+
return await self.get_jwt()
|
|
165
|
+
|
|
166
|
+
async def buy(
|
|
167
|
+
self,
|
|
168
|
+
instrument_id: int,
|
|
169
|
+
volume: int,
|
|
170
|
+
*,
|
|
171
|
+
stop_loss_value: int | None = None,
|
|
172
|
+
stop_loss_scale: int | None = None,
|
|
173
|
+
take_profit_value: int | None = None,
|
|
174
|
+
take_profit_scale: int | None = None,
|
|
175
|
+
) -> GrpcTradeResult:
|
|
176
|
+
"""Execute BUY market order."""
|
|
177
|
+
return await self.execute_order(
|
|
178
|
+
instrument_id,
|
|
179
|
+
volume,
|
|
180
|
+
SIDE_BUY,
|
|
181
|
+
stop_loss_value=stop_loss_value,
|
|
182
|
+
stop_loss_scale=stop_loss_scale,
|
|
183
|
+
take_profit_value=take_profit_value,
|
|
184
|
+
take_profit_scale=take_profit_scale,
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
async def sell(
|
|
188
|
+
self,
|
|
189
|
+
instrument_id: int,
|
|
190
|
+
volume: int,
|
|
191
|
+
*,
|
|
192
|
+
stop_loss_value: int | None = None,
|
|
193
|
+
stop_loss_scale: int | None = None,
|
|
194
|
+
take_profit_value: int | None = None,
|
|
195
|
+
take_profit_scale: int | None = None,
|
|
196
|
+
) -> GrpcTradeResult:
|
|
197
|
+
"""Execute SELL market order."""
|
|
198
|
+
return await self.execute_order(
|
|
199
|
+
instrument_id,
|
|
200
|
+
volume,
|
|
201
|
+
SIDE_SELL,
|
|
202
|
+
stop_loss_value=stop_loss_value,
|
|
203
|
+
stop_loss_scale=stop_loss_scale,
|
|
204
|
+
take_profit_value=take_profit_value,
|
|
205
|
+
take_profit_scale=take_profit_scale,
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
async def execute_order(
|
|
209
|
+
self,
|
|
210
|
+
instrument_id: int,
|
|
211
|
+
volume: int,
|
|
212
|
+
side: int,
|
|
213
|
+
*,
|
|
214
|
+
stop_loss_value: int | None = None,
|
|
215
|
+
stop_loss_scale: int | None = None,
|
|
216
|
+
take_profit_value: int | None = None,
|
|
217
|
+
take_profit_scale: int | None = None,
|
|
218
|
+
) -> GrpcTradeResult:
|
|
219
|
+
"""Execute market order via gRPC-web NewMarketOrder.
|
|
220
|
+
|
|
221
|
+
Args:
|
|
222
|
+
instrument_id: gRPC instrument ID (e.g., 9438 for CIG.PL)
|
|
223
|
+
volume: Number of shares
|
|
224
|
+
side: SIDE_BUY (1) or SIDE_SELL (2)
|
|
225
|
+
stop_loss_value: SL price as integer (e.g., 10850 for 1.0850 with scale=4)
|
|
226
|
+
stop_loss_scale: SL price scale (decimal places)
|
|
227
|
+
take_profit_value: TP price as integer
|
|
228
|
+
take_profit_scale: TP price scale (decimal places)
|
|
229
|
+
|
|
230
|
+
Returns:
|
|
231
|
+
GrpcTradeResult with success status and order details.
|
|
232
|
+
"""
|
|
233
|
+
jwt = await self._ensure_jwt()
|
|
234
|
+
|
|
235
|
+
side_name = "BUY" if side == SIDE_BUY else "SELL"
|
|
236
|
+
logger.info("gRPC trade: %s instrument=%d volume=%d", side_name, instrument_id, volume)
|
|
237
|
+
|
|
238
|
+
proto_msg = build_new_market_order(
|
|
239
|
+
instrument_id,
|
|
240
|
+
volume,
|
|
241
|
+
side,
|
|
242
|
+
stop_loss_value=stop_loss_value,
|
|
243
|
+
stop_loss_scale=stop_loss_scale,
|
|
244
|
+
take_profit_value=take_profit_value,
|
|
245
|
+
take_profit_scale=take_profit_scale,
|
|
246
|
+
)
|
|
247
|
+
body_b64 = build_grpc_web_text_body(proto_msg)
|
|
248
|
+
|
|
249
|
+
try:
|
|
250
|
+
response_bytes = await self._grpc_call(GRPC_NEW_ORDER_ENDPOINT, body_b64, jwt=jwt)
|
|
251
|
+
except Exception as e:
|
|
252
|
+
return GrpcTradeResult(success=False, error=str(e))
|
|
253
|
+
|
|
254
|
+
logger.debug(
|
|
255
|
+
"gRPC response: %d bytes — %s",
|
|
256
|
+
len(response_bytes),
|
|
257
|
+
response_bytes[:50].hex(),
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
return self._parse_trade_response(response_bytes)
|
|
261
|
+
|
|
262
|
+
def _parse_trade_response(self, response_bytes: bytes) -> GrpcTradeResult:
|
|
263
|
+
"""Parse gRPC-web trade response into GrpcTradeResult.
|
|
264
|
+
|
|
265
|
+
Uses proper gRPC frame parsing instead of string matching to avoid
|
|
266
|
+
false-positive success on rejected trades (e.g. 'grpc-status: 16'
|
|
267
|
+
containing '0' as a substring in error details).
|
|
268
|
+
"""
|
|
269
|
+
# Parse gRPC frames: flag 0x00 = data, flag 0x80 = trailers
|
|
270
|
+
grpc_status: int | None = None
|
|
271
|
+
grpc_message: str | None = None
|
|
272
|
+
data_payload: bytes = b""
|
|
273
|
+
|
|
274
|
+
pos = 0
|
|
275
|
+
while pos + 5 <= len(response_bytes):
|
|
276
|
+
flag = response_bytes[pos]
|
|
277
|
+
import struct
|
|
278
|
+
|
|
279
|
+
length = struct.unpack(">I", response_bytes[pos + 1 : pos + 5])[0]
|
|
280
|
+
pos += 5
|
|
281
|
+
if pos + length > len(response_bytes):
|
|
282
|
+
break
|
|
283
|
+
frame_data = response_bytes[pos : pos + length]
|
|
284
|
+
pos += length
|
|
285
|
+
|
|
286
|
+
if flag & 0x80:
|
|
287
|
+
# Trailer frame — parse as HTTP/2 headers (key: value\r\n)
|
|
288
|
+
trailer_text = frame_data.decode("latin-1", errors="replace")
|
|
289
|
+
for line in trailer_text.split("\r\n"):
|
|
290
|
+
if line.startswith("grpc-status:"):
|
|
291
|
+
with contextlib.suppress(ValueError):
|
|
292
|
+
grpc_status = int(line.split(":", 1)[1].strip())
|
|
293
|
+
elif line.startswith("grpc-message:"):
|
|
294
|
+
grpc_message = line.split(":", 1)[1].strip()
|
|
295
|
+
else:
|
|
296
|
+
# Data frame
|
|
297
|
+
data_payload = frame_data
|
|
298
|
+
|
|
299
|
+
# Success requires explicit grpc-status 0 from trailer
|
|
300
|
+
if grpc_status == 0:
|
|
301
|
+
response_text = data_payload.decode("latin-1", errors="replace")
|
|
302
|
+
uuid_match = re.search(
|
|
303
|
+
r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}",
|
|
304
|
+
response_text,
|
|
305
|
+
)
|
|
306
|
+
order_id = uuid_match.group(0) if uuid_match else None
|
|
307
|
+
logger.info("Trade executed successfully via gRPC")
|
|
308
|
+
return GrpcTradeResult(success=True, order_id=order_id, grpc_status=0)
|
|
309
|
+
|
|
310
|
+
# Error cases
|
|
311
|
+
status = grpc_status if grpc_status is not None else 0
|
|
312
|
+
response_text = response_bytes.decode("latin-1", errors="replace")
|
|
313
|
+
if grpc_message and "RBAC" in grpc_message or "RBAC" in response_text:
|
|
314
|
+
error_msg = "gRPC RBAC: access denied — JWT may be expired"
|
|
315
|
+
elif grpc_message:
|
|
316
|
+
error_msg = f"gRPC error: grpc-message: {grpc_message}"
|
|
317
|
+
else:
|
|
318
|
+
error_msg = f"gRPC order rejected: {response_text[:200]}"
|
|
319
|
+
|
|
320
|
+
logger.error(error_msg)
|
|
321
|
+
return GrpcTradeResult(success=False, grpc_status=status, error=error_msg)
|
|
322
|
+
|
|
323
|
+
async def disconnect(self) -> None:
|
|
324
|
+
"""Clean up resources."""
|
|
325
|
+
self._jwt = None
|
|
326
|
+
self._jwt_timestamp = 0.0
|
|
327
|
+
if self._http and not self._http.is_closed:
|
|
328
|
+
await self._http.aclose()
|
|
329
|
+
self._http = None
|
xtb_api/grpc/proto.py
ADDED
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
"""Minimal protobuf encoder/decoder for XTB gRPC-web protocol.
|
|
2
|
+
|
|
3
|
+
No external dependencies — manual varint/length-delimited encoding
|
|
4
|
+
matching the wire format observed in HAR captures from xStation5.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import base64
|
|
10
|
+
import re
|
|
11
|
+
import struct
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def encode_varint(value: int) -> bytes:
|
|
15
|
+
"""Encode an unsigned integer as a protobuf varint."""
|
|
16
|
+
parts: list[int] = []
|
|
17
|
+
while value > 0x7F:
|
|
18
|
+
parts.append((value & 0x7F) | 0x80)
|
|
19
|
+
value >>= 7
|
|
20
|
+
parts.append(value & 0x7F)
|
|
21
|
+
return bytes(parts)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def decode_varint(data: bytes, pos: int = 0) -> tuple[int, int]:
|
|
25
|
+
"""Decode a varint at position. Returns (value, new_pos)."""
|
|
26
|
+
result = 0
|
|
27
|
+
shift = 0
|
|
28
|
+
while pos < len(data):
|
|
29
|
+
byte = data[pos]
|
|
30
|
+
result |= (byte & 0x7F) << shift
|
|
31
|
+
pos += 1
|
|
32
|
+
if not (byte & 0x80):
|
|
33
|
+
return result, pos
|
|
34
|
+
shift += 7
|
|
35
|
+
raise ValueError("Truncated varint")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def encode_field_varint(field_num: int, value: int) -> bytes:
|
|
39
|
+
"""Encode a varint field (wire type 0)."""
|
|
40
|
+
tag = (field_num << 3) | 0 # wire type 0 = varint
|
|
41
|
+
return encode_varint(tag) + encode_varint(value)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def encode_field_bytes(field_num: int, data: bytes) -> bytes:
|
|
45
|
+
"""Encode a length-delimited field (wire type 2)."""
|
|
46
|
+
tag = (field_num << 3) | 2 # wire type 2 = length-delimited
|
|
47
|
+
return encode_varint(tag) + encode_varint(len(data)) + data
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _encode_price(value: int, scale: int) -> bytes:
|
|
51
|
+
"""Encode a Price protobuf sub-message: { field 1: value, field 2: scale }."""
|
|
52
|
+
return encode_field_varint(1, value) + encode_field_varint(2, scale)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def build_new_market_order(
|
|
56
|
+
instrument_id: int,
|
|
57
|
+
volume: int,
|
|
58
|
+
side: int,
|
|
59
|
+
*,
|
|
60
|
+
stop_loss_value: int | None = None,
|
|
61
|
+
stop_loss_scale: int | None = None,
|
|
62
|
+
take_profit_value: int | None = None,
|
|
63
|
+
take_profit_scale: int | None = None,
|
|
64
|
+
) -> bytes:
|
|
65
|
+
"""Build NewMarketOrder protobuf message.
|
|
66
|
+
|
|
67
|
+
Args:
|
|
68
|
+
instrument_id: gRPC instrument ID (e.g., 9438 for CIG.PL)
|
|
69
|
+
volume: Number of shares
|
|
70
|
+
side: 1=BUY, 2=SELL
|
|
71
|
+
stop_loss_value: SL price as integer (e.g., 10850 for 1.0850 with scale=4)
|
|
72
|
+
stop_loss_scale: SL price scale (decimal places)
|
|
73
|
+
take_profit_value: TP price as integer
|
|
74
|
+
take_profit_scale: TP price scale (decimal places)
|
|
75
|
+
|
|
76
|
+
Returns:
|
|
77
|
+
Serialized protobuf bytes
|
|
78
|
+
|
|
79
|
+
Wire format (from HAR analysis):
|
|
80
|
+
Field 1 (varint): instrument_id
|
|
81
|
+
Field 2 (bytes): order {
|
|
82
|
+
Field 2 (bytes): volume { Field 1 (varint): value }
|
|
83
|
+
Field 3 (bytes): stoploss { Field 1 (bytes): price { value, scale } }
|
|
84
|
+
Field 4 (bytes): takeprofit { Field 1 (bytes): price { value, scale } }
|
|
85
|
+
}
|
|
86
|
+
Field 3 (varint): side
|
|
87
|
+
"""
|
|
88
|
+
# Inner: volume message — field 1 = value
|
|
89
|
+
volume_msg = encode_field_varint(1, volume)
|
|
90
|
+
# Middle: order message — field 2 = volume
|
|
91
|
+
order_msg = encode_field_bytes(2, volume_msg)
|
|
92
|
+
|
|
93
|
+
# Optional SL: order field 3 = stoploss { field 1 = price { value, scale } }
|
|
94
|
+
if stop_loss_value is not None and stop_loss_scale is not None:
|
|
95
|
+
price_msg = _encode_price(stop_loss_value, stop_loss_scale)
|
|
96
|
+
sl_msg = encode_field_bytes(1, price_msg) # stoploss.price
|
|
97
|
+
order_msg += encode_field_bytes(3, sl_msg)
|
|
98
|
+
|
|
99
|
+
# Optional TP: order field 4 = takeprofit { field 1 = price { value, scale } }
|
|
100
|
+
if take_profit_value is not None and take_profit_scale is not None:
|
|
101
|
+
price_msg = _encode_price(take_profit_value, take_profit_scale)
|
|
102
|
+
tp_msg = encode_field_bytes(1, price_msg) # takeprofit.price
|
|
103
|
+
order_msg += encode_field_bytes(4, tp_msg)
|
|
104
|
+
|
|
105
|
+
# Outer: full message
|
|
106
|
+
return encode_field_varint(1, instrument_id) + encode_field_bytes(2, order_msg) + encode_field_varint(3, side)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def build_grpc_frame(proto_msg: bytes) -> bytes:
|
|
110
|
+
"""Wrap protobuf message in a gRPC-web frame.
|
|
111
|
+
|
|
112
|
+
Frame format: 1 byte flag + 4 bytes big-endian length + payload
|
|
113
|
+
Flag 0 = data frame (uncompressed)
|
|
114
|
+
"""
|
|
115
|
+
return struct.pack(">BI", 0, len(proto_msg)) + proto_msg
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def build_grpc_web_text_body(proto_msg: bytes) -> str:
|
|
119
|
+
"""Build gRPC-web-text body (base64-encoded gRPC frame)."""
|
|
120
|
+
frame = build_grpc_frame(proto_msg)
|
|
121
|
+
return base64.b64encode(frame).decode("ascii")
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def build_create_access_token_request(tgt: str, account_number: str, account_server: str) -> bytes:
|
|
125
|
+
"""Build CreateAccessTokenRequest protobuf.
|
|
126
|
+
|
|
127
|
+
Proto structure (discovered via proto classes in xStation5):
|
|
128
|
+
message CreateAccessTokenRequest {
|
|
129
|
+
string tgt = 1; // TGT/ST cookie value (optional if CASTGT cookie present)
|
|
130
|
+
Account account = 2; // Account info
|
|
131
|
+
}
|
|
132
|
+
message Account {
|
|
133
|
+
uint64 number = 1; // e.g. 51984891 (varint, NOT string)
|
|
134
|
+
string server = 2; // e.g. "XS-real1"
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
The JWT returned will contain:
|
|
138
|
+
- pid: person ID
|
|
139
|
+
- acn: account number (REQUIRED for trading!)
|
|
140
|
+
- acs: account server (REQUIRED for trading!)
|
|
141
|
+
"""
|
|
142
|
+
# Build inner Account message
|
|
143
|
+
# account_number is varint-encoded (field type 0), not length-delimited
|
|
144
|
+
account_msg = encode_field_varint(1, int(account_number)) + encode_field_bytes(2, account_server.encode("utf-8"))
|
|
145
|
+
# Build outer CreateAccessTokenRequest
|
|
146
|
+
return encode_field_bytes(1, tgt.encode("utf-8")) + encode_field_bytes(2, account_msg)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def parse_grpc_frames(data: bytes) -> list[bytes]:
|
|
150
|
+
"""Parse one or more gRPC-web frames from response data.
|
|
151
|
+
|
|
152
|
+
Returns list of payload bytes (one per frame).
|
|
153
|
+
"""
|
|
154
|
+
frames: list[bytes] = []
|
|
155
|
+
pos = 0
|
|
156
|
+
while pos + 5 <= len(data):
|
|
157
|
+
_flag = data[pos]
|
|
158
|
+
length = struct.unpack(">I", data[pos + 1 : pos + 5])[0]
|
|
159
|
+
pos += 5
|
|
160
|
+
if pos + length > len(data):
|
|
161
|
+
break
|
|
162
|
+
frames.append(data[pos : pos + length])
|
|
163
|
+
pos += length
|
|
164
|
+
return frames
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def parse_proto_fields(data: bytes) -> dict[int, list[tuple[int, bytes | int]]]:
|
|
168
|
+
"""Parse protobuf fields into {field_num: [(wire_type, value), ...]}.
|
|
169
|
+
|
|
170
|
+
Wire type 0 → value is int (varint)
|
|
171
|
+
Wire type 2 → value is bytes (length-delimited)
|
|
172
|
+
Wire type 5 → value is bytes (4 bytes, fixed32)
|
|
173
|
+
Wire type 1 → value is bytes (8 bytes, fixed64)
|
|
174
|
+
"""
|
|
175
|
+
fields: dict[int, list[tuple[int, bytes | int]]] = {}
|
|
176
|
+
pos = 0
|
|
177
|
+
while pos < len(data):
|
|
178
|
+
try:
|
|
179
|
+
tag, pos = decode_varint(data, pos)
|
|
180
|
+
except ValueError:
|
|
181
|
+
break
|
|
182
|
+
wire_type = tag & 0x07
|
|
183
|
+
field_num = tag >> 3
|
|
184
|
+
|
|
185
|
+
if wire_type == 0: # varint
|
|
186
|
+
value, pos = decode_varint(data, pos)
|
|
187
|
+
fields.setdefault(field_num, []).append((wire_type, value))
|
|
188
|
+
elif wire_type == 2: # length-delimited
|
|
189
|
+
length, pos = decode_varint(data, pos)
|
|
190
|
+
value_bytes = data[pos : pos + length]
|
|
191
|
+
pos += length
|
|
192
|
+
fields.setdefault(field_num, []).append((wire_type, value_bytes))
|
|
193
|
+
elif wire_type == 5: # fixed32
|
|
194
|
+
value_bytes = data[pos : pos + 4]
|
|
195
|
+
pos += 4
|
|
196
|
+
fields.setdefault(field_num, []).append((wire_type, value_bytes))
|
|
197
|
+
elif wire_type == 1: # fixed64
|
|
198
|
+
value_bytes = data[pos : pos + 8]
|
|
199
|
+
pos += 8
|
|
200
|
+
fields.setdefault(field_num, []).append((wire_type, value_bytes))
|
|
201
|
+
else:
|
|
202
|
+
break # Unknown wire type
|
|
203
|
+
|
|
204
|
+
return fields
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def extract_jwt(data: bytes) -> str | None:
|
|
208
|
+
"""Extract JWT token from gRPC response bytes.
|
|
209
|
+
|
|
210
|
+
Searches for the JWT pattern in the raw bytes (works regardless
|
|
211
|
+
of protobuf nesting level).
|
|
212
|
+
"""
|
|
213
|
+
text = data.decode("latin-1")
|
|
214
|
+
match = re.search(r"eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+", text)
|
|
215
|
+
return match.group(0) if match else None
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
# Side constants for gRPC protocol.
|
|
219
|
+
# WARNING: These differ from WebSocket Xs6Side enum (BUY=0, SELL=1).
|
|
220
|
+
# Do NOT interchange with Xs6Side values — wrong side will be sent.
|
|
221
|
+
SIDE_BUY = 1 # gRPC only — WebSocket uses Xs6Side.BUY=0
|
|
222
|
+
SIDE_SELL = 2 # gRPC only — WebSocket uses Xs6Side.SELL=1
|
|
223
|
+
|
|
224
|
+
# Content type for gRPC-web-text (base64 encoded)
|
|
225
|
+
GRPC_WEB_TEXT_CONTENT_TYPE = "application/grpc-web-text"
|
|
226
|
+
|
|
227
|
+
# gRPC-web endpoints
|
|
228
|
+
GRPC_BASE_URL = "https://ipax.xtb.com"
|
|
229
|
+
GRPC_AUTH_ENDPOINT = f"{GRPC_BASE_URL}/pl.xtb.ipax.pub.grpc.auth.v2.AuthService/CreateAccessToken"
|
|
230
|
+
GRPC_NEW_ORDER_ENDPOINT = (
|
|
231
|
+
f"{GRPC_BASE_URL}/pl.xtb.ipax.pub.grpc.cashtradingneworder.v1.CashTradingNewOrderService/NewMarketOrder"
|
|
232
|
+
)
|
|
233
|
+
GRPC_CONFIRM_ENDPOINT = (
|
|
234
|
+
f"{GRPC_BASE_URL}/pl.xtb.ipax.pub.grpc.cashtradingconfirmation.v1"
|
|
235
|
+
".CashTradingConfirmationService/SubscribeNewMarketOrderConfirmation"
|
|
236
|
+
)
|
|
237
|
+
GRPC_CLOSE_POSITION_ENDPOINT = (
|
|
238
|
+
f"{GRPC_BASE_URL}/pl.xtb.ipax.pub.grpc.cashtradingneworder.v1.CashTradingNewOrderService/CloseSinglePosition"
|
|
239
|
+
)
|
xtb_api/grpc/types.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Result types for gRPC-web trading."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class GrpcTradeResult(BaseModel):
|
|
9
|
+
"""Result of a gRPC-web trade execution."""
|
|
10
|
+
|
|
11
|
+
success: bool
|
|
12
|
+
order_id: str | None = None
|
|
13
|
+
grpc_status: int = 0
|
|
14
|
+
error: str | None = None
|