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/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