fxsocket 0.1__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.
- fxsocket/__init__.py +134 -0
- fxsocket/_http.py +68 -0
- fxsocket/_version.py +1 -0
- fxsocket/client.py +254 -0
- fxsocket/config.py +12 -0
- fxsocket/enums.py +122 -0
- fxsocket/errors.py +163 -0
- fxsocket/management.py +118 -0
- fxsocket/models.py +391 -0
- fxsocket/py.typed +0 -0
- fxsocket/terminal/__init__.py +45 -0
- fxsocket/terminal/client.py +757 -0
- fxsocket/terminal/stream.py +594 -0
- fxsocket-0.1.dist-info/METADATA +256 -0
- fxsocket-0.1.dist-info/RECORD +17 -0
- fxsocket-0.1.dist-info/WHEEL +4 -0
- fxsocket-0.1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,757 @@
|
|
|
1
|
+
"""Per-account terminal REST client (MT4 + MT5).
|
|
2
|
+
|
|
3
|
+
One :class:`TerminalClient` (sync) / :class:`AsyncTerminalClient` (async) is
|
|
4
|
+
bound to a single account's terminal endpoint (``Account.rest_url``). The two
|
|
5
|
+
are method-for-method mirrors; only the awaiting differs.
|
|
6
|
+
|
|
7
|
+
Platform awareness is enforced **client-side** before the request goes out:
|
|
8
|
+
MT5-only timeframes (``M2``/``M3``/``H2``/``H6``/``H8``/``H12``) don't exist on
|
|
9
|
+
MT4, so the SDK raises :class:`~fxsocket.UnsupportedOnPlatformError` rather than
|
|
10
|
+
letting the terminal answer 400. (Order *operations*, including stop-limit, are
|
|
11
|
+
accepted by both platforms' terminal APIs, so they are not gated.)
|
|
12
|
+
|
|
13
|
+
Order inputs are validated client-side to fail fast and, above all, safely:
|
|
14
|
+
the same constraints the terminal enforces (volume > 0, an entry price for
|
|
15
|
+
pending orders, a stop-limit price for ``*StopLimit``) plus a guard against the
|
|
16
|
+
silent ``order_modify`` footgun where a literal ``0.0`` stop-loss / take-profit
|
|
17
|
+
*removes* the protection — pass ``clear_stop_loss`` / ``clear_take_profit`` to
|
|
18
|
+
do that explicitly, ``None`` (the default) keeps the current value.
|
|
19
|
+
|
|
20
|
+
No request is auto-retried — in particular order send/modify/close must never
|
|
21
|
+
replay, to avoid duplicate fills.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
from datetime import date, datetime
|
|
27
|
+
from typing import Any
|
|
28
|
+
|
|
29
|
+
import httpx
|
|
30
|
+
|
|
31
|
+
from .._http import AsyncHTTP, SyncHTTP, auth_headers
|
|
32
|
+
from ..enums import (
|
|
33
|
+
MT5_ONLY_TIMEFRAMES,
|
|
34
|
+
PENDING_OPERATIONS,
|
|
35
|
+
STOP_LIMIT_OPERATIONS,
|
|
36
|
+
OrderOperation,
|
|
37
|
+
Platform,
|
|
38
|
+
Timeframe,
|
|
39
|
+
)
|
|
40
|
+
from ..errors import (
|
|
41
|
+
UnsupportedOnPlatformError,
|
|
42
|
+
ValidationError,
|
|
43
|
+
error_from_response,
|
|
44
|
+
)
|
|
45
|
+
from ..models import (
|
|
46
|
+
AccountInfo,
|
|
47
|
+
AccountSummary,
|
|
48
|
+
Candle,
|
|
49
|
+
Health,
|
|
50
|
+
HealthChecks,
|
|
51
|
+
HistoryTrade,
|
|
52
|
+
MarginCalc,
|
|
53
|
+
OpenedOrder,
|
|
54
|
+
OrderResult,
|
|
55
|
+
PositionTrade,
|
|
56
|
+
ProfitCalc,
|
|
57
|
+
Quote,
|
|
58
|
+
ServerTimezone,
|
|
59
|
+
SymbolInfo,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
# --------------------------------------------------------------------------- #
|
|
63
|
+
# Coercion / validation helpers (shared by sync + async)
|
|
64
|
+
# --------------------------------------------------------------------------- #
|
|
65
|
+
|
|
66
|
+
_OP_BY_NORM = {op.value.lower(): op for op in OrderOperation}
|
|
67
|
+
|
|
68
|
+
_TF_HUMAN = {
|
|
69
|
+
"1min": Timeframe.M1,
|
|
70
|
+
"2min": Timeframe.M2,
|
|
71
|
+
"3min": Timeframe.M3,
|
|
72
|
+
"5min": Timeframe.M5,
|
|
73
|
+
"15min": Timeframe.M15,
|
|
74
|
+
"30min": Timeframe.M30,
|
|
75
|
+
"1h": Timeframe.H1,
|
|
76
|
+
"2h": Timeframe.H2,
|
|
77
|
+
"4h": Timeframe.H4,
|
|
78
|
+
"6h": Timeframe.H6,
|
|
79
|
+
"8h": Timeframe.H8,
|
|
80
|
+
"12h": Timeframe.H12,
|
|
81
|
+
"1d": Timeframe.D1,
|
|
82
|
+
"1w": Timeframe.W1,
|
|
83
|
+
"1month": Timeframe.MN1,
|
|
84
|
+
"1mn": Timeframe.MN1,
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def coerce_operation(value: OrderOperation | str) -> OrderOperation:
|
|
89
|
+
"""Normalize an order operation (case/separator-insensitive)."""
|
|
90
|
+
if isinstance(value, OrderOperation):
|
|
91
|
+
return value
|
|
92
|
+
key = "".join(ch for ch in str(value).lower() if ch.isalnum())
|
|
93
|
+
op = _OP_BY_NORM.get(key)
|
|
94
|
+
if op is None:
|
|
95
|
+
raise ValidationError(f"Unknown order operation: {value!r}")
|
|
96
|
+
return op
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def coerce_timeframe(value: Timeframe | str) -> Timeframe:
|
|
100
|
+
"""Normalize a timeframe label (``M5``, ``5min``, ``1h`` … )."""
|
|
101
|
+
if isinstance(value, Timeframe):
|
|
102
|
+
return value
|
|
103
|
+
raw = str(value).strip()
|
|
104
|
+
try:
|
|
105
|
+
return Timeframe(raw.upper())
|
|
106
|
+
except ValueError:
|
|
107
|
+
pass
|
|
108
|
+
tf = _TF_HUMAN.get(raw.lower())
|
|
109
|
+
if tf is None:
|
|
110
|
+
raise ValidationError(f"Unknown timeframe: {value!r}")
|
|
111
|
+
return tf
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _require_positive_volume(volume: float) -> None:
|
|
115
|
+
if volume <= 0:
|
|
116
|
+
raise ValidationError(f"volume must be > 0, got {volume}")
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _validate_order_send(
|
|
120
|
+
op: OrderOperation,
|
|
121
|
+
*,
|
|
122
|
+
volume: float,
|
|
123
|
+
price: float | None,
|
|
124
|
+
stop_limit_price: float | None,
|
|
125
|
+
stop_loss: float | None,
|
|
126
|
+
take_profit: float | None,
|
|
127
|
+
) -> None:
|
|
128
|
+
"""Mirror the terminal's own ``/OrderSend`` validation, fail-fast."""
|
|
129
|
+
_require_positive_volume(volume)
|
|
130
|
+
if op in PENDING_OPERATIONS and (price is None or price <= 0):
|
|
131
|
+
raise ValidationError(f"price is required for pending orders ({op.value})")
|
|
132
|
+
if op in STOP_LIMIT_OPERATIONS and (
|
|
133
|
+
stop_limit_price is None or stop_limit_price <= 0
|
|
134
|
+
):
|
|
135
|
+
raise ValidationError(f"stop_limit_price is required for {op.value} orders")
|
|
136
|
+
# On send, 0.0 / absent legitimately means "no SL/TP"; only negatives are wrong.
|
|
137
|
+
if stop_loss is not None and stop_loss < 0:
|
|
138
|
+
raise ValidationError(f"stop_loss must be >= 0, got {stop_loss}")
|
|
139
|
+
if take_profit is not None and take_profit < 0:
|
|
140
|
+
raise ValidationError(f"take_profit must be >= 0, got {take_profit}")
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _resolve_modify_sl_tp(
|
|
144
|
+
*,
|
|
145
|
+
stop_loss: float | None,
|
|
146
|
+
take_profit: float | None,
|
|
147
|
+
clear_stop_loss: bool,
|
|
148
|
+
clear_take_profit: bool,
|
|
149
|
+
price: float | None,
|
|
150
|
+
stop_limit_price: float | None,
|
|
151
|
+
) -> tuple[float | None, float | None]:
|
|
152
|
+
"""Validate an ``order_modify`` and resolve SL/TP to wire values.
|
|
153
|
+
|
|
154
|
+
Returns ``(sl, tp)`` where ``None`` means "omit → keep current" and ``0.0``
|
|
155
|
+
means "clear". A bare ``stop_loss=0.0`` is rejected because, sent verbatim,
|
|
156
|
+
the terminal would silently *remove* the protection — removal must be
|
|
157
|
+
explicit via ``clear_stop_loss``.
|
|
158
|
+
"""
|
|
159
|
+
if clear_stop_loss and stop_loss is not None:
|
|
160
|
+
raise ValidationError(
|
|
161
|
+
"pass either stop_loss=<price> or clear_stop_loss=True, not both"
|
|
162
|
+
)
|
|
163
|
+
if clear_take_profit and take_profit is not None:
|
|
164
|
+
raise ValidationError(
|
|
165
|
+
"pass either take_profit=<price> or clear_take_profit=True, not both"
|
|
166
|
+
)
|
|
167
|
+
if stop_loss is not None and stop_loss <= 0:
|
|
168
|
+
raise ValidationError(
|
|
169
|
+
"stop_loss must be > 0 in order_modify; pass clear_stop_loss=True to "
|
|
170
|
+
"remove it, or leave it None to keep the current value"
|
|
171
|
+
)
|
|
172
|
+
if take_profit is not None and take_profit <= 0:
|
|
173
|
+
raise ValidationError(
|
|
174
|
+
"take_profit must be > 0 in order_modify; pass clear_take_profit=True "
|
|
175
|
+
"to remove it, or leave it None to keep the current value"
|
|
176
|
+
)
|
|
177
|
+
if price is not None and price <= 0:
|
|
178
|
+
raise ValidationError(f"price must be > 0, got {price}")
|
|
179
|
+
if stop_limit_price is not None and stop_limit_price <= 0:
|
|
180
|
+
raise ValidationError(f"stop_limit_price must be > 0, got {stop_limit_price}")
|
|
181
|
+
sl = 0.0 if clear_stop_loss else stop_loss
|
|
182
|
+
tp = 0.0 if clear_take_profit else take_profit
|
|
183
|
+
return sl, tp
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _check_timeframe(tf: Timeframe, platform: Platform) -> None:
|
|
187
|
+
if platform is Platform.MT4 and tf in MT5_ONLY_TIMEFRAMES:
|
|
188
|
+
raise UnsupportedOnPlatformError(
|
|
189
|
+
f"{tf.value} is an MT5-only timeframe; not available on MT4."
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _fmt_time(value: str | datetime | date | None) -> str | None:
|
|
194
|
+
if value is None:
|
|
195
|
+
return None
|
|
196
|
+
if isinstance(value, (datetime, date)):
|
|
197
|
+
return value.isoformat()
|
|
198
|
+
return str(value)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _clean(params: dict[str, Any]) -> dict[str, Any]:
|
|
202
|
+
"""Drop keys whose value is None (so omitted fields aren't sent)."""
|
|
203
|
+
return {k: v for k, v in params.items() if v is not None}
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _order_send_body(
|
|
207
|
+
*,
|
|
208
|
+
symbol: str,
|
|
209
|
+
operation: OrderOperation,
|
|
210
|
+
volume: float,
|
|
211
|
+
price: float | None,
|
|
212
|
+
slippage: int | None,
|
|
213
|
+
stop_loss: float | None,
|
|
214
|
+
take_profit: float | None,
|
|
215
|
+
stop_limit_price: float | None,
|
|
216
|
+
expiration: str | datetime | date | None,
|
|
217
|
+
comment: str | None,
|
|
218
|
+
magic: int | None,
|
|
219
|
+
) -> dict[str, Any]:
|
|
220
|
+
return _clean(
|
|
221
|
+
{
|
|
222
|
+
"symbol": symbol,
|
|
223
|
+
"operation": operation.value,
|
|
224
|
+
"volume": volume,
|
|
225
|
+
"price": price,
|
|
226
|
+
"slippage": slippage,
|
|
227
|
+
"stopLoss": stop_loss,
|
|
228
|
+
"takeProfit": take_profit,
|
|
229
|
+
"stopLimitPrice": stop_limit_price,
|
|
230
|
+
"expiration": _fmt_time(expiration),
|
|
231
|
+
"comment": comment,
|
|
232
|
+
"expertId": magic,
|
|
233
|
+
}
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _order_modify_body(
|
|
238
|
+
*,
|
|
239
|
+
ticket: int,
|
|
240
|
+
stop_loss: float | None,
|
|
241
|
+
take_profit: float | None,
|
|
242
|
+
price: float | None,
|
|
243
|
+
stop_limit_price: float | None,
|
|
244
|
+
expiration: str | datetime | date | None,
|
|
245
|
+
) -> dict[str, Any]:
|
|
246
|
+
return _clean(
|
|
247
|
+
{
|
|
248
|
+
"ticket": ticket,
|
|
249
|
+
"stopLoss": stop_loss,
|
|
250
|
+
"takeProfit": take_profit,
|
|
251
|
+
"price": price,
|
|
252
|
+
"stopLimitPrice": stop_limit_price,
|
|
253
|
+
"expiration": _fmt_time(expiration),
|
|
254
|
+
}
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
# --------------------------------------------------------------------------- #
|
|
259
|
+
# Sync
|
|
260
|
+
# --------------------------------------------------------------------------- #
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
class TerminalClient:
|
|
264
|
+
"""Synchronous terminal client bound to one account's REST endpoint."""
|
|
265
|
+
|
|
266
|
+
def __init__(
|
|
267
|
+
self,
|
|
268
|
+
*,
|
|
269
|
+
base_url: str,
|
|
270
|
+
api_key: str,
|
|
271
|
+
platform: Platform | str,
|
|
272
|
+
verify: bool = True,
|
|
273
|
+
timeout: float = 30.0,
|
|
274
|
+
) -> None:
|
|
275
|
+
self.platform = Platform(platform)
|
|
276
|
+
self.base_url = base_url
|
|
277
|
+
self._client = httpx.Client(
|
|
278
|
+
base_url=base_url,
|
|
279
|
+
headers=auth_headers(api_key),
|
|
280
|
+
timeout=timeout,
|
|
281
|
+
verify=verify,
|
|
282
|
+
)
|
|
283
|
+
self._http = SyncHTTP(self._client)
|
|
284
|
+
|
|
285
|
+
# -- account state ----------------------------------------------------- #
|
|
286
|
+
|
|
287
|
+
def account_summary(self) -> AccountSummary:
|
|
288
|
+
return AccountSummary.model_validate(
|
|
289
|
+
self._http.request("GET", "/AccountSummary")
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
def account_info(self) -> AccountInfo:
|
|
293
|
+
return AccountInfo.model_validate(self._http.request("GET", "/AccountInfo"))
|
|
294
|
+
|
|
295
|
+
def opened_orders(self) -> list[OpenedOrder]:
|
|
296
|
+
rows = self._http.request("GET", "/OpenedOrders")
|
|
297
|
+
return [OpenedOrder.model_validate(r) for r in rows]
|
|
298
|
+
|
|
299
|
+
def order_history(
|
|
300
|
+
self,
|
|
301
|
+
from_: str | datetime | date | None = None,
|
|
302
|
+
to: str | datetime | date | None = None,
|
|
303
|
+
) -> list[HistoryTrade]:
|
|
304
|
+
params = _clean({"from": _fmt_time(from_), "to": _fmt_time(to)})
|
|
305
|
+
rows = self._http.request("GET", "/OrderHistory", params=params)
|
|
306
|
+
return [HistoryTrade.model_validate(r) for r in rows]
|
|
307
|
+
|
|
308
|
+
def position_history(
|
|
309
|
+
self,
|
|
310
|
+
from_: str | datetime | date | None = None,
|
|
311
|
+
to: str | datetime | date | None = None,
|
|
312
|
+
) -> list[PositionTrade]:
|
|
313
|
+
params = _clean({"from": _fmt_time(from_), "to": _fmt_time(to)})
|
|
314
|
+
rows = self._http.request("GET", "/PositionHistory", params=params)
|
|
315
|
+
return [PositionTrade.model_validate(r) for r in rows]
|
|
316
|
+
|
|
317
|
+
def server_timezone(self) -> ServerTimezone:
|
|
318
|
+
return ServerTimezone.model_validate(
|
|
319
|
+
self._http.request("GET", "/ServerTimezone")
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
# -- market data ------------------------------------------------------- #
|
|
323
|
+
|
|
324
|
+
def symbols(self) -> list[str]:
|
|
325
|
+
return list(self._http.request("GET", "/symbols"))
|
|
326
|
+
|
|
327
|
+
def quote(self, symbol: str) -> Quote:
|
|
328
|
+
return Quote.model_validate(
|
|
329
|
+
self._http.request("GET", "/getQuote", params={"symbol": symbol})
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
def symbol_info(self, symbol: str) -> SymbolInfo:
|
|
333
|
+
return SymbolInfo.model_validate(
|
|
334
|
+
self._http.request("GET", "/SymbolInfo", params={"symbol": symbol})
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
def price_history(
|
|
338
|
+
self,
|
|
339
|
+
symbol: str,
|
|
340
|
+
timeframe: Timeframe | str,
|
|
341
|
+
from_: str | datetime | date | None = None,
|
|
342
|
+
to: str | datetime | date | None = None,
|
|
343
|
+
) -> list[Candle]:
|
|
344
|
+
tf = coerce_timeframe(timeframe)
|
|
345
|
+
_check_timeframe(tf, self.platform)
|
|
346
|
+
params = _clean(
|
|
347
|
+
{
|
|
348
|
+
"symbol": symbol,
|
|
349
|
+
"timeframe": tf.value,
|
|
350
|
+
"from": _fmt_time(from_),
|
|
351
|
+
"to": _fmt_time(to),
|
|
352
|
+
}
|
|
353
|
+
)
|
|
354
|
+
rows = self._http.request("GET", "/PriceHistory", params=params)
|
|
355
|
+
return [Candle.model_validate(r) for r in rows]
|
|
356
|
+
|
|
357
|
+
# -- calculators ------------------------------------------------------- #
|
|
358
|
+
|
|
359
|
+
def calc_margin(
|
|
360
|
+
self, symbol: str, operation: OrderOperation | str, volume: float, price: float
|
|
361
|
+
) -> MarginCalc:
|
|
362
|
+
op = coerce_operation(operation)
|
|
363
|
+
_require_positive_volume(volume)
|
|
364
|
+
params = {
|
|
365
|
+
"symbol": symbol,
|
|
366
|
+
"operation": op.value,
|
|
367
|
+
"volume": volume,
|
|
368
|
+
"price": price,
|
|
369
|
+
}
|
|
370
|
+
return MarginCalc.model_validate(
|
|
371
|
+
self._http.request("GET", "/OrderCalcMargin", params=params)
|
|
372
|
+
)
|
|
373
|
+
|
|
374
|
+
def calc_profit(
|
|
375
|
+
self,
|
|
376
|
+
symbol: str,
|
|
377
|
+
operation: OrderOperation | str,
|
|
378
|
+
volume: float,
|
|
379
|
+
price_open: float,
|
|
380
|
+
price_close: float,
|
|
381
|
+
) -> ProfitCalc:
|
|
382
|
+
op = coerce_operation(operation)
|
|
383
|
+
_require_positive_volume(volume)
|
|
384
|
+
params = {
|
|
385
|
+
"symbol": symbol,
|
|
386
|
+
"operation": op.value,
|
|
387
|
+
"volume": volume,
|
|
388
|
+
"priceOpen": price_open,
|
|
389
|
+
"priceClose": price_close,
|
|
390
|
+
}
|
|
391
|
+
return ProfitCalc.model_validate(
|
|
392
|
+
self._http.request("GET", "/OrderCalcProfit", params=params)
|
|
393
|
+
)
|
|
394
|
+
|
|
395
|
+
# -- trading (never retried) ------------------------------------------- #
|
|
396
|
+
|
|
397
|
+
def order_send(
|
|
398
|
+
self,
|
|
399
|
+
*,
|
|
400
|
+
symbol: str,
|
|
401
|
+
operation: OrderOperation | str,
|
|
402
|
+
volume: float,
|
|
403
|
+
price: float | None = None,
|
|
404
|
+
slippage: int | None = None,
|
|
405
|
+
stop_loss: float | None = None,
|
|
406
|
+
take_profit: float | None = None,
|
|
407
|
+
stop_limit_price: float | None = None,
|
|
408
|
+
expiration: str | datetime | date | None = None,
|
|
409
|
+
comment: str | None = None,
|
|
410
|
+
magic: int | None = None,
|
|
411
|
+
) -> OrderResult:
|
|
412
|
+
op = coerce_operation(operation)
|
|
413
|
+
_validate_order_send(
|
|
414
|
+
op,
|
|
415
|
+
volume=volume,
|
|
416
|
+
price=price,
|
|
417
|
+
stop_limit_price=stop_limit_price,
|
|
418
|
+
stop_loss=stop_loss,
|
|
419
|
+
take_profit=take_profit,
|
|
420
|
+
)
|
|
421
|
+
body = _order_send_body(
|
|
422
|
+
symbol=symbol,
|
|
423
|
+
operation=op,
|
|
424
|
+
volume=volume,
|
|
425
|
+
price=price,
|
|
426
|
+
slippage=slippage,
|
|
427
|
+
stop_loss=stop_loss,
|
|
428
|
+
take_profit=take_profit,
|
|
429
|
+
stop_limit_price=stop_limit_price,
|
|
430
|
+
expiration=expiration,
|
|
431
|
+
comment=comment,
|
|
432
|
+
magic=magic,
|
|
433
|
+
)
|
|
434
|
+
return OrderResult.model_validate(
|
|
435
|
+
self._http.request("POST", "/OrderSend", json=body)
|
|
436
|
+
)
|
|
437
|
+
|
|
438
|
+
def order_modify(
|
|
439
|
+
self,
|
|
440
|
+
ticket: int,
|
|
441
|
+
*,
|
|
442
|
+
stop_loss: float | None = None,
|
|
443
|
+
take_profit: float | None = None,
|
|
444
|
+
price: float | None = None,
|
|
445
|
+
stop_limit_price: float | None = None,
|
|
446
|
+
expiration: str | datetime | date | None = None,
|
|
447
|
+
clear_stop_loss: bool = False,
|
|
448
|
+
clear_take_profit: bool = False,
|
|
449
|
+
) -> OrderResult:
|
|
450
|
+
sl, tp = _resolve_modify_sl_tp(
|
|
451
|
+
stop_loss=stop_loss,
|
|
452
|
+
take_profit=take_profit,
|
|
453
|
+
clear_stop_loss=clear_stop_loss,
|
|
454
|
+
clear_take_profit=clear_take_profit,
|
|
455
|
+
price=price,
|
|
456
|
+
stop_limit_price=stop_limit_price,
|
|
457
|
+
)
|
|
458
|
+
body = _order_modify_body(
|
|
459
|
+
ticket=ticket,
|
|
460
|
+
stop_loss=sl,
|
|
461
|
+
take_profit=tp,
|
|
462
|
+
price=price,
|
|
463
|
+
stop_limit_price=stop_limit_price,
|
|
464
|
+
expiration=expiration,
|
|
465
|
+
)
|
|
466
|
+
return OrderResult.model_validate(
|
|
467
|
+
self._http.request("POST", "/OrderModify", json=body)
|
|
468
|
+
)
|
|
469
|
+
|
|
470
|
+
def order_close(
|
|
471
|
+
self,
|
|
472
|
+
ticket: int,
|
|
473
|
+
*,
|
|
474
|
+
volume: float | None = None,
|
|
475
|
+
slippage: int | None = None,
|
|
476
|
+
) -> OrderResult:
|
|
477
|
+
if volume is not None and volume < 0:
|
|
478
|
+
raise ValidationError(f"volume must be >= 0, got {volume}")
|
|
479
|
+
body = _clean({"ticket": ticket, "volume": volume, "slippage": slippage})
|
|
480
|
+
return OrderResult.model_validate(
|
|
481
|
+
self._http.request("POST", "/OrderClose", json=body)
|
|
482
|
+
)
|
|
483
|
+
|
|
484
|
+
# -- health ------------------------------------------------------------ #
|
|
485
|
+
|
|
486
|
+
def status(self) -> Health:
|
|
487
|
+
return Health.model_validate(self._http.request("GET", "/status"))
|
|
488
|
+
|
|
489
|
+
def healthz(self) -> HealthChecks:
|
|
490
|
+
return self._probe("/healthz")
|
|
491
|
+
|
|
492
|
+
def livez(self) -> HealthChecks:
|
|
493
|
+
return self._probe("/livez")
|
|
494
|
+
|
|
495
|
+
def _probe(self, path: str) -> HealthChecks:
|
|
496
|
+
# /healthz and /livez answer 503 when not-ready, with a useful body —
|
|
497
|
+
# parse it instead of raising; only other codes are real errors.
|
|
498
|
+
resp = self._client.get(path)
|
|
499
|
+
if resp.status_code not in (200, 503):
|
|
500
|
+
raise error_from_response(resp)
|
|
501
|
+
return HealthChecks.model_validate(resp.json())
|
|
502
|
+
|
|
503
|
+
# -- lifecycle --------------------------------------------------------- #
|
|
504
|
+
|
|
505
|
+
def close(self) -> None:
|
|
506
|
+
self._client.close()
|
|
507
|
+
|
|
508
|
+
def __enter__(self) -> TerminalClient:
|
|
509
|
+
return self
|
|
510
|
+
|
|
511
|
+
def __exit__(self, *exc: object) -> None:
|
|
512
|
+
self.close()
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
# --------------------------------------------------------------------------- #
|
|
516
|
+
# Async
|
|
517
|
+
# --------------------------------------------------------------------------- #
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
class AsyncTerminalClient:
|
|
521
|
+
"""Asynchronous mirror of :class:`TerminalClient`."""
|
|
522
|
+
|
|
523
|
+
def __init__(
|
|
524
|
+
self,
|
|
525
|
+
*,
|
|
526
|
+
base_url: str,
|
|
527
|
+
api_key: str,
|
|
528
|
+
platform: Platform | str,
|
|
529
|
+
verify: bool = True,
|
|
530
|
+
timeout: float = 30.0,
|
|
531
|
+
) -> None:
|
|
532
|
+
self.platform = Platform(platform)
|
|
533
|
+
self.base_url = base_url
|
|
534
|
+
self._client = httpx.AsyncClient(
|
|
535
|
+
base_url=base_url,
|
|
536
|
+
headers=auth_headers(api_key),
|
|
537
|
+
timeout=timeout,
|
|
538
|
+
verify=verify,
|
|
539
|
+
)
|
|
540
|
+
self._http = AsyncHTTP(self._client)
|
|
541
|
+
|
|
542
|
+
async def account_summary(self) -> AccountSummary:
|
|
543
|
+
return AccountSummary.model_validate(
|
|
544
|
+
await self._http.request("GET", "/AccountSummary")
|
|
545
|
+
)
|
|
546
|
+
|
|
547
|
+
async def account_info(self) -> AccountInfo:
|
|
548
|
+
return AccountInfo.model_validate(
|
|
549
|
+
await self._http.request("GET", "/AccountInfo")
|
|
550
|
+
)
|
|
551
|
+
|
|
552
|
+
async def opened_orders(self) -> list[OpenedOrder]:
|
|
553
|
+
rows = await self._http.request("GET", "/OpenedOrders")
|
|
554
|
+
return [OpenedOrder.model_validate(r) for r in rows]
|
|
555
|
+
|
|
556
|
+
async def order_history(
|
|
557
|
+
self,
|
|
558
|
+
from_: str | datetime | date | None = None,
|
|
559
|
+
to: str | datetime | date | None = None,
|
|
560
|
+
) -> list[HistoryTrade]:
|
|
561
|
+
params = _clean({"from": _fmt_time(from_), "to": _fmt_time(to)})
|
|
562
|
+
rows = await self._http.request("GET", "/OrderHistory", params=params)
|
|
563
|
+
return [HistoryTrade.model_validate(r) for r in rows]
|
|
564
|
+
|
|
565
|
+
async def position_history(
|
|
566
|
+
self,
|
|
567
|
+
from_: str | datetime | date | None = None,
|
|
568
|
+
to: str | datetime | date | None = None,
|
|
569
|
+
) -> list[PositionTrade]:
|
|
570
|
+
params = _clean({"from": _fmt_time(from_), "to": _fmt_time(to)})
|
|
571
|
+
rows = await self._http.request("GET", "/PositionHistory", params=params)
|
|
572
|
+
return [PositionTrade.model_validate(r) for r in rows]
|
|
573
|
+
|
|
574
|
+
async def server_timezone(self) -> ServerTimezone:
|
|
575
|
+
return ServerTimezone.model_validate(
|
|
576
|
+
await self._http.request("GET", "/ServerTimezone")
|
|
577
|
+
)
|
|
578
|
+
|
|
579
|
+
async def symbols(self) -> list[str]:
|
|
580
|
+
return list(await self._http.request("GET", "/symbols"))
|
|
581
|
+
|
|
582
|
+
async def quote(self, symbol: str) -> Quote:
|
|
583
|
+
return Quote.model_validate(
|
|
584
|
+
await self._http.request("GET", "/getQuote", params={"symbol": symbol})
|
|
585
|
+
)
|
|
586
|
+
|
|
587
|
+
async def symbol_info(self, symbol: str) -> SymbolInfo:
|
|
588
|
+
return SymbolInfo.model_validate(
|
|
589
|
+
await self._http.request("GET", "/SymbolInfo", params={"symbol": symbol})
|
|
590
|
+
)
|
|
591
|
+
|
|
592
|
+
async def price_history(
|
|
593
|
+
self,
|
|
594
|
+
symbol: str,
|
|
595
|
+
timeframe: Timeframe | str,
|
|
596
|
+
from_: str | datetime | date | None = None,
|
|
597
|
+
to: str | datetime | date | None = None,
|
|
598
|
+
) -> list[Candle]:
|
|
599
|
+
tf = coerce_timeframe(timeframe)
|
|
600
|
+
_check_timeframe(tf, self.platform)
|
|
601
|
+
params = _clean(
|
|
602
|
+
{
|
|
603
|
+
"symbol": symbol,
|
|
604
|
+
"timeframe": tf.value,
|
|
605
|
+
"from": _fmt_time(from_),
|
|
606
|
+
"to": _fmt_time(to),
|
|
607
|
+
}
|
|
608
|
+
)
|
|
609
|
+
rows = await self._http.request("GET", "/PriceHistory", params=params)
|
|
610
|
+
return [Candle.model_validate(r) for r in rows]
|
|
611
|
+
|
|
612
|
+
async def calc_margin(
|
|
613
|
+
self, symbol: str, operation: OrderOperation | str, volume: float, price: float
|
|
614
|
+
) -> MarginCalc:
|
|
615
|
+
op = coerce_operation(operation)
|
|
616
|
+
_require_positive_volume(volume)
|
|
617
|
+
params = {
|
|
618
|
+
"symbol": symbol,
|
|
619
|
+
"operation": op.value,
|
|
620
|
+
"volume": volume,
|
|
621
|
+
"price": price,
|
|
622
|
+
}
|
|
623
|
+
return MarginCalc.model_validate(
|
|
624
|
+
await self._http.request("GET", "/OrderCalcMargin", params=params)
|
|
625
|
+
)
|
|
626
|
+
|
|
627
|
+
async def calc_profit(
|
|
628
|
+
self,
|
|
629
|
+
symbol: str,
|
|
630
|
+
operation: OrderOperation | str,
|
|
631
|
+
volume: float,
|
|
632
|
+
price_open: float,
|
|
633
|
+
price_close: float,
|
|
634
|
+
) -> ProfitCalc:
|
|
635
|
+
op = coerce_operation(operation)
|
|
636
|
+
_require_positive_volume(volume)
|
|
637
|
+
params = {
|
|
638
|
+
"symbol": symbol,
|
|
639
|
+
"operation": op.value,
|
|
640
|
+
"volume": volume,
|
|
641
|
+
"priceOpen": price_open,
|
|
642
|
+
"priceClose": price_close,
|
|
643
|
+
}
|
|
644
|
+
return ProfitCalc.model_validate(
|
|
645
|
+
await self._http.request("GET", "/OrderCalcProfit", params=params)
|
|
646
|
+
)
|
|
647
|
+
|
|
648
|
+
async def order_send(
|
|
649
|
+
self,
|
|
650
|
+
*,
|
|
651
|
+
symbol: str,
|
|
652
|
+
operation: OrderOperation | str,
|
|
653
|
+
volume: float,
|
|
654
|
+
price: float | None = None,
|
|
655
|
+
slippage: int | None = None,
|
|
656
|
+
stop_loss: float | None = None,
|
|
657
|
+
take_profit: float | None = None,
|
|
658
|
+
stop_limit_price: float | None = None,
|
|
659
|
+
expiration: str | datetime | date | None = None,
|
|
660
|
+
comment: str | None = None,
|
|
661
|
+
magic: int | None = None,
|
|
662
|
+
) -> OrderResult:
|
|
663
|
+
op = coerce_operation(operation)
|
|
664
|
+
_validate_order_send(
|
|
665
|
+
op,
|
|
666
|
+
volume=volume,
|
|
667
|
+
price=price,
|
|
668
|
+
stop_limit_price=stop_limit_price,
|
|
669
|
+
stop_loss=stop_loss,
|
|
670
|
+
take_profit=take_profit,
|
|
671
|
+
)
|
|
672
|
+
body = _order_send_body(
|
|
673
|
+
symbol=symbol,
|
|
674
|
+
operation=op,
|
|
675
|
+
volume=volume,
|
|
676
|
+
price=price,
|
|
677
|
+
slippage=slippage,
|
|
678
|
+
stop_loss=stop_loss,
|
|
679
|
+
take_profit=take_profit,
|
|
680
|
+
stop_limit_price=stop_limit_price,
|
|
681
|
+
expiration=expiration,
|
|
682
|
+
comment=comment,
|
|
683
|
+
magic=magic,
|
|
684
|
+
)
|
|
685
|
+
return OrderResult.model_validate(
|
|
686
|
+
await self._http.request("POST", "/OrderSend", json=body)
|
|
687
|
+
)
|
|
688
|
+
|
|
689
|
+
async def order_modify(
|
|
690
|
+
self,
|
|
691
|
+
ticket: int,
|
|
692
|
+
*,
|
|
693
|
+
stop_loss: float | None = None,
|
|
694
|
+
take_profit: float | None = None,
|
|
695
|
+
price: float | None = None,
|
|
696
|
+
stop_limit_price: float | None = None,
|
|
697
|
+
expiration: str | datetime | date | None = None,
|
|
698
|
+
clear_stop_loss: bool = False,
|
|
699
|
+
clear_take_profit: bool = False,
|
|
700
|
+
) -> OrderResult:
|
|
701
|
+
sl, tp = _resolve_modify_sl_tp(
|
|
702
|
+
stop_loss=stop_loss,
|
|
703
|
+
take_profit=take_profit,
|
|
704
|
+
clear_stop_loss=clear_stop_loss,
|
|
705
|
+
clear_take_profit=clear_take_profit,
|
|
706
|
+
price=price,
|
|
707
|
+
stop_limit_price=stop_limit_price,
|
|
708
|
+
)
|
|
709
|
+
body = _order_modify_body(
|
|
710
|
+
ticket=ticket,
|
|
711
|
+
stop_loss=sl,
|
|
712
|
+
take_profit=tp,
|
|
713
|
+
price=price,
|
|
714
|
+
stop_limit_price=stop_limit_price,
|
|
715
|
+
expiration=expiration,
|
|
716
|
+
)
|
|
717
|
+
return OrderResult.model_validate(
|
|
718
|
+
await self._http.request("POST", "/OrderModify", json=body)
|
|
719
|
+
)
|
|
720
|
+
|
|
721
|
+
async def order_close(
|
|
722
|
+
self,
|
|
723
|
+
ticket: int,
|
|
724
|
+
*,
|
|
725
|
+
volume: float | None = None,
|
|
726
|
+
slippage: int | None = None,
|
|
727
|
+
) -> OrderResult:
|
|
728
|
+
if volume is not None and volume < 0:
|
|
729
|
+
raise ValidationError(f"volume must be >= 0, got {volume}")
|
|
730
|
+
body = _clean({"ticket": ticket, "volume": volume, "slippage": slippage})
|
|
731
|
+
return OrderResult.model_validate(
|
|
732
|
+
await self._http.request("POST", "/OrderClose", json=body)
|
|
733
|
+
)
|
|
734
|
+
|
|
735
|
+
async def status(self) -> Health:
|
|
736
|
+
return Health.model_validate(await self._http.request("GET", "/status"))
|
|
737
|
+
|
|
738
|
+
async def healthz(self) -> HealthChecks:
|
|
739
|
+
return await self._probe("/healthz")
|
|
740
|
+
|
|
741
|
+
async def livez(self) -> HealthChecks:
|
|
742
|
+
return await self._probe("/livez")
|
|
743
|
+
|
|
744
|
+
async def _probe(self, path: str) -> HealthChecks:
|
|
745
|
+
resp = await self._client.get(path)
|
|
746
|
+
if resp.status_code not in (200, 503):
|
|
747
|
+
raise error_from_response(resp)
|
|
748
|
+
return HealthChecks.model_validate(resp.json())
|
|
749
|
+
|
|
750
|
+
async def aclose(self) -> None:
|
|
751
|
+
await self._client.aclose()
|
|
752
|
+
|
|
753
|
+
async def __aenter__(self) -> AsyncTerminalClient:
|
|
754
|
+
return self
|
|
755
|
+
|
|
756
|
+
async def __aexit__(self, *exc: object) -> None:
|
|
757
|
+
await self.aclose()
|