pipsocket 0.1.0__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.
pipsocket/client.py ADDED
@@ -0,0 +1,680 @@
1
+ """The synchronous client."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import contextlib
6
+ import math
7
+ import threading
8
+ import time
9
+ from collections.abc import Callable, Iterable
10
+ from types import TracebackType
11
+ from typing import Any, cast
12
+
13
+ import httpx
14
+
15
+ from ._core import (
16
+ SEND,
17
+ Attempt,
18
+ Failed,
19
+ Look,
20
+ Spec,
21
+ Wait,
22
+ account_path,
23
+ attach_key,
24
+ drive,
25
+ drive_wait,
26
+ history_range,
27
+ json_body,
28
+ lookup,
29
+ safe,
30
+ symbol_path,
31
+ ticket_segment,
32
+ trade,
33
+ )
34
+ from ._http import (
35
+ DEFAULT_MAX_RETRIES,
36
+ DEFAULT_RETRY_DEADLINE,
37
+ DEFAULT_TIMEOUT,
38
+ Options,
39
+ answer,
40
+ body_lost,
41
+ build_request,
42
+ check_wait,
43
+ no_answer,
44
+ options,
45
+ watch_response,
46
+ )
47
+ from .errors import APITimeoutError, PipsocketError
48
+ from .models import TradeRequest
49
+ from .stream import (
50
+ DEFAULT_QUEUE_SIZE,
51
+ Stream,
52
+ new_machine,
53
+ report,
54
+ settled,
55
+ stream_url,
56
+ sync_reader,
57
+ watch,
58
+ )
59
+ from .types import (
60
+ AccountView,
61
+ CheckView,
62
+ DealView,
63
+ FillingParam,
64
+ HistoryOrderView,
65
+ KillSwitchOutcome,
66
+ KillSwitchView,
67
+ OrderTypeParam,
68
+ PendingOrderView,
69
+ PositionView,
70
+ RequestView,
71
+ SymbolSummaryView,
72
+ SymbolView,
73
+ TickView,
74
+ TypeTimeParam,
75
+ )
76
+
77
+ __all__ = ["Account", "Pipsocket"]
78
+
79
+
80
+ class Pipsocket:
81
+ """A client for the Pipsocket API.
82
+
83
+ `api_key` defaults to the PIPSOCKET_KEY environment variable. `timeout` is per attempt, in
84
+ seconds. A call that fails in a way worth repeating is tried again up to `max_retries`
85
+ times, with the same idempotency key, and no new attempt starts `retry_deadline` seconds
86
+ after the first one began. Pass your own `http_client` for proxies or custom transports.
87
+ """
88
+
89
+ def __init__(
90
+ self,
91
+ api_key: str | None = None,
92
+ *,
93
+ base_url: str | None = None,
94
+ stream_url: str | None = None,
95
+ timeout: float = DEFAULT_TIMEOUT,
96
+ max_retries: int = DEFAULT_MAX_RETRIES,
97
+ retry_deadline: float = DEFAULT_RETRY_DEADLINE,
98
+ http_client: httpx.Client | None = None,
99
+ _clock: Callable[[], float] | None = None,
100
+ _sleep: Callable[[float], None] | None = None,
101
+ _random: Callable[[], float] | None = None,
102
+ ) -> None:
103
+ opts = options(
104
+ api_key, base_url, stream_url, timeout, max_retries, retry_deadline, _clock, _random
105
+ )
106
+ self._owns_http = http_client is None
107
+ self._requester = _Requester(opts, http_client or httpx.Client(), _sleep or time.sleep)
108
+
109
+ def account(self, account_id: str) -> Account:
110
+ """The handle for one account: every call is for one account."""
111
+ account_path(account_id, "/")
112
+ return Account(self._requester, account_id)
113
+
114
+ def close(self) -> None:
115
+ """Close the HTTP connections, unless the client was given its own http_client."""
116
+ if self._owns_http:
117
+ self._requester.http.close()
118
+
119
+ def __enter__(self) -> Pipsocket:
120
+ return self
121
+
122
+ def __exit__(
123
+ self,
124
+ exc_type: type[BaseException] | None,
125
+ exc: BaseException | None,
126
+ traceback: TracebackType | None,
127
+ ) -> None:
128
+ self.close()
129
+
130
+ def __repr__(self) -> str:
131
+ return f"Pipsocket(base_url={self._requester.options.base_url!r})"
132
+
133
+
134
+ class _Requester:
135
+ """Runs a call's spec through the core, doing the HTTP and the sleeping."""
136
+
137
+ def __init__(self, opts: Options, http: httpx.Client, sleep: Callable[[float], None]) -> None:
138
+ self.options = opts
139
+ self.http = http
140
+ self.sleep = sleep
141
+
142
+ def call(self, spec: Spec, timeout: float | None, max_retries: int | None) -> Any:
143
+ settings = self.options.settings(max_retries)
144
+ attempt_timeout = self.options.attempt_timeout(timeout)
145
+ run = drive(spec, settings)
146
+ step = next(run)
147
+ try:
148
+ while True:
149
+ if step is SEND:
150
+ step = run.send(self.attempt(spec, attempt_timeout))
151
+ else:
152
+ assert isinstance(step, Wait)
153
+ try:
154
+ self.sleep(step.seconds)
155
+ except Exception as error:
156
+ step = run.throw(error) # the core decides how the call ends
157
+ else:
158
+ step = run.send(None)
159
+ except StopIteration as done:
160
+ return done.value
161
+ finally:
162
+ run.close()
163
+
164
+ def trade(
165
+ self, spec: Spec, timeout: float | None, max_retries: int | None, streams: list[Stream]
166
+ ) -> TradeRequest:
167
+ """A trade call. A cancellation or interrupt passes through as it is, with the key
168
+ set on it where it can carry one; nothing is sent after it."""
169
+ try:
170
+ request = cast(TradeRequest, self.call(spec, timeout, max_retries))
171
+ except PipsocketError:
172
+ raise
173
+ except BaseException as error:
174
+ attach_key(error, spec.idempotency_key)
175
+ raise
176
+ return newest(request, streams, spec.idempotency_key)
177
+
178
+ def wait(
179
+ self, spec: Spec, request_id: str, timeout: float, streams: list[Stream]
180
+ ) -> TradeRequest:
181
+ known = settled(streams, request_id)
182
+ if known is not None:
183
+ return TradeRequest(cast(RequestView, known))
184
+ run = drive_wait(check_wait(timeout), self.options.clock)
185
+ seen = _Seen(lambda error: report(streams, error)) if streams else None
186
+ undo = [watch(stream, request_id, seen.saw) for stream in list(streams)] if seen else []
187
+ step = next(run)
188
+ try:
189
+ while True:
190
+ if isinstance(step, Look):
191
+ outcome = self.look(spec, step.seconds, seen)
192
+ if isinstance(outcome, PipsocketError):
193
+ report(streams, outcome)
194
+ step = run.send(outcome)
195
+ elif seen is not None:
196
+ step = run.send(seen.wait(step.seconds))
197
+ else:
198
+ self.sleep(step.seconds)
199
+ step = run.send(None)
200
+ except StopIteration as done:
201
+ return cast(TradeRequest, done.value)
202
+ finally:
203
+ run.close()
204
+ for unwatch in undo:
205
+ unwatch()
206
+
207
+ def look(self, spec: Spec, seconds: float, seen: _Seen | None) -> TradeRequest | PipsocketError:
208
+ """One lookup for wait(), over within `seconds` all told (the HTTP timeout applies to
209
+ each network operation, so a slow answer could otherwise run past wait()'s deadline),
210
+ and ended at once by a settlement an open stream sees meanwhile. A lookup that runs
211
+ over, or that the stream beats, finishes on its own: it is only a read."""
212
+ attempt_timeout = min(self.options.timeout, seconds)
213
+
214
+ def lookup() -> TradeRequest:
215
+ return cast(TradeRequest, self.call(spec, attempt_timeout, 0))
216
+
217
+ try:
218
+ if seen is None and math.isinf(seconds):
219
+ return lookup()
220
+ return (seen or _Seen()).race(lookup, seconds)
221
+ except PipsocketError as error:
222
+ return error
223
+
224
+ def attempt(self, spec: Spec, timeout: float) -> Attempt:
225
+ """One attempt, and what it came to. A failure is placed by when it happened: before
226
+ sending (nothing sent), or once sending began (it may have reached the host)."""
227
+ if self.http.is_closed:
228
+ return Failed(RuntimeError("The HTTP client has been closed."), maybe_sent=False)
229
+ try:
230
+ request, body = build_request(self.http, spec, self.options, timeout)
231
+ except Exception as error:
232
+ return Failed(error, maybe_sent=False)
233
+ try:
234
+ # Redirects would be sends the retry policy can't see: the API doesn't redirect.
235
+ response = self.http.send(request, stream=True, follow_redirects=False)
236
+ except httpx.TransportError as error:
237
+ return no_answer(error, body)
238
+ except Exception as error:
239
+ # Not the transport's: a request or response hook, say. Response hooks run after
240
+ # the answer arrived, so it may have been sent.
241
+ return Failed(error, maybe_sent=True)
242
+ received = watch_response(response)
243
+ content = bytearray()
244
+ try:
245
+ for chunk in response.iter_bytes():
246
+ content += chunk
247
+ except Exception as error:
248
+ if not received.finished:
249
+ if isinstance(error, httpx.HTTPError):
250
+ return body_lost(response, error, bytes(content))
251
+ return Failed(error, maybe_sent=True)
252
+ # The body was read to the end and closing it failed: the answer is all here.
253
+ # (Were a compressed tail lost with it, the JSON won't parse: maybe sent too.)
254
+ finally:
255
+ # A failure to clean up doesn't change what the attempt came to.
256
+ with contextlib.suppress(Exception):
257
+ response.close()
258
+ return answer(response, bytes(content))
259
+
260
+
261
+ class _Seen:
262
+ """A request an open stream saw settle, for a wait()."""
263
+
264
+ def __init__(self, failed: Callable[[PipsocketError], None] = lambda error: None) -> None:
265
+ self.request: TradeRequest | None = None
266
+ self.changed = threading.Condition()
267
+ # Told of a lookup's failure even when the stream won the race: a 401 stops it too.
268
+ self.failed = failed
269
+
270
+ def saw(self, body: Any) -> None:
271
+ request = TradeRequest(body)
272
+ if request.final:
273
+ with self.changed:
274
+ self.request = request
275
+ self.changed.notify_all()
276
+
277
+ def wait(self, seconds: float) -> TradeRequest | None:
278
+ """Wait up to `seconds`, or until a stream sees the request settle."""
279
+ with self.changed:
280
+ self.changed.wait_for(lambda: self.request is not None, seconds)
281
+ return self.request
282
+
283
+ def race(self, lookup: Callable[[], TradeRequest], seconds: float) -> TradeRequest:
284
+ """Look the request up within `seconds`, unless a stream sees it settle first. A final
285
+ state seen on the stream is never replaced by an older, outstanding one from the
286
+ lookup. (The lookup runs on, as a read, if it is beaten or runs over.)"""
287
+ answers: list[TradeRequest | BaseException] = []
288
+
289
+ def run() -> None:
290
+ answer: TradeRequest | BaseException
291
+ try:
292
+ answer = lookup()
293
+ except BaseException as error:
294
+ answer = error
295
+ if isinstance(error, PipsocketError):
296
+ self.failed(error)
297
+ with self.changed:
298
+ answers.append(answer)
299
+ self.changed.notify_all()
300
+
301
+ threading.Thread(target=run, daemon=True).start()
302
+ limit = None if math.isinf(seconds) else seconds
303
+ with self.changed:
304
+ self.changed.wait_for(lambda: bool(answers) or self.request is not None, limit)
305
+ if self.request is not None:
306
+ return self.request
307
+ if not answers:
308
+ raise APITimeoutError("The lookup did not answer within wait()'s timeout.")
309
+ answer = answers[0]
310
+ if isinstance(answer, BaseException):
311
+ raise answer
312
+ return answer
313
+
314
+
315
+ def newest(request: TradeRequest, streams: list[Any], key: str | None) -> TradeRequest:
316
+ """A trade call's answer, unless an open stream already saw that request settle: a slower
317
+ 202 never moves a request seen as final back to pending."""
318
+ if request.outstanding:
319
+ known = settled(streams, request.id)
320
+ if known is not None:
321
+ return TradeRequest(cast(RequestView, known), idempotency_key=key)
322
+ return request
323
+
324
+
325
+ class Account:
326
+ """One account's calls. Get it with `Pipsocket.account(id)`."""
327
+
328
+ def __init__(self, requester: _Requester, account_id: str) -> None:
329
+ self._requester = requester
330
+ self.id = account_id
331
+ self._streams: list[Stream] = []
332
+ self.positions = Positions(requester, account_id, self._streams)
333
+ self.orders = Orders(requester, account_id, self._streams)
334
+ self.requests = Requests(requester, account_id, self._streams)
335
+ self.history = History(requester, account_id, self._streams)
336
+ self.symbols = Symbols(requester, account_id, self._streams)
337
+ self.kill_switch = KillSwitch(requester, account_id, self._streams)
338
+
339
+ def get(self, *, timeout: float | None = None, max_retries: int | None = None) -> AccountView:
340
+ """The account: its terminal's status, and figures from the terminal's last report."""
341
+ spec = safe("account", "GET", self.id, "/")
342
+ return cast(AccountView, self._requester.call(spec, timeout, max_retries))
343
+
344
+ def stream(
345
+ self,
346
+ ticks: Iterable[str] = (),
347
+ *,
348
+ snapshot: bool = True,
349
+ queue_size: int = DEFAULT_QUEUE_SIZE,
350
+ ) -> Stream:
351
+ """The account's events as they happen, as `with acct.stream(ticks=[...]) as events`.
352
+
353
+ Events are the API's messages as they are, plus `resync`, which the helper sends after
354
+ every (re)connection and gap with a new baseline: the account, positions and pending
355
+ orders read over REST, and the history since the last checkpoint. `snapshot=False`
356
+ sends `resync` without reading anything. The socket is kept open and reconnected.
357
+ """
358
+ machine = new_machine(self._requester.options, ticks, snapshot, queue_size)
359
+ url = stream_url(self._requester.options, self.id)
360
+ return Stream(
361
+ url=url,
362
+ options=self._requester.options,
363
+ machine=machine,
364
+ reader=sync_reader(self),
365
+ streams=self._streams,
366
+ )
367
+
368
+ def __repr__(self) -> str:
369
+ return f"Account({self.id!r})"
370
+
371
+
372
+ class _Resource:
373
+ def __init__(self, requester: _Requester, account_id: str, streams: list[Stream]) -> None:
374
+ self._requester = requester
375
+ self._account = account_id
376
+ self._streams = streams
377
+
378
+
379
+ class Positions(_Resource):
380
+ def list(
381
+ self, *, timeout: float | None = None, max_retries: int | None = None
382
+ ) -> list[PositionView]:
383
+ """The open positions."""
384
+ spec = safe("positions", "GET", self._account, "/positions")
385
+ return cast(list[PositionView], self._requester.call(spec, timeout, max_retries))
386
+
387
+ def set_levels(
388
+ self,
389
+ ticket: int,
390
+ *,
391
+ sl: float,
392
+ tp: float,
393
+ idempotency_key: str | None = None,
394
+ timeout: float | None = None,
395
+ max_retries: int | None = None,
396
+ ) -> TradeRequest:
397
+ """Set a position's stop loss and take profit (0 removes one). Give both."""
398
+ path = f"/positions/{ticket_segment(ticket)}"
399
+ body = {"sl": sl, "tp": tp}
400
+ spec = trade("set_position_levels", "PATCH", self._account, path, body, idempotency_key)
401
+ return self._requester.trade(spec, timeout, max_retries, self._streams)
402
+
403
+ def close(
404
+ self,
405
+ ticket: int,
406
+ *,
407
+ volume: float,
408
+ type_filling: FillingParam,
409
+ price: float | None = None,
410
+ deviation: int | None = None,
411
+ magic: int | None = None,
412
+ comment: str | None = None,
413
+ idempotency_key: str | None = None,
414
+ timeout: float | None = None,
415
+ max_retries: int | None = None,
416
+ ) -> TradeRequest:
417
+ """Close all of a position, or part of it with a smaller volume."""
418
+ path = f"/positions/{ticket_segment(ticket)}/close"
419
+ body = {
420
+ "volume": volume,
421
+ "type_filling": type_filling,
422
+ "price": price,
423
+ "deviation": deviation,
424
+ "magic": magic,
425
+ "comment": comment,
426
+ }
427
+ spec = trade("close_position", "POST", self._account, path, body, idempotency_key)
428
+ return self._requester.trade(spec, timeout, max_retries, self._streams)
429
+
430
+ def close_by(
431
+ self,
432
+ ticket: int,
433
+ *,
434
+ position_by: int,
435
+ idempotency_key: str | None = None,
436
+ timeout: float | None = None,
437
+ max_retries: int | None = None,
438
+ ) -> TradeRequest:
439
+ """Close a position against an opposite one on the same symbol."""
440
+ path = f"/positions/{ticket_segment(ticket)}/close-by"
441
+ body = {"position_by": position_by}
442
+ spec = trade("close_by", "POST", self._account, path, body, idempotency_key)
443
+ return self._requester.trade(spec, timeout, max_retries, self._streams)
444
+
445
+
446
+ class Orders(_Resource):
447
+ def list(
448
+ self, *, timeout: float | None = None, max_retries: int | None = None
449
+ ) -> list[PendingOrderView]:
450
+ """The pending orders."""
451
+ spec = safe("pending_orders", "GET", self._account, "/orders")
452
+ return cast(list[PendingOrderView], self._requester.call(spec, timeout, max_retries))
453
+
454
+ def place(
455
+ self,
456
+ *,
457
+ symbol: str,
458
+ type: OrderTypeParam,
459
+ volume: float,
460
+ type_filling: FillingParam,
461
+ price: float | None = None,
462
+ stoplimit: float | None = None,
463
+ sl: float | None = None,
464
+ tp: float | None = None,
465
+ deviation: int | None = None,
466
+ type_time: TypeTimeParam | None = None,
467
+ expiration: int | None = None,
468
+ magic: int | None = None,
469
+ comment: str | None = None,
470
+ idempotency_key: str | None = None,
471
+ timeout: float | None = None,
472
+ max_retries: int | None = None,
473
+ ) -> TradeRequest:
474
+ """Place a market or pending order: one MT5 trade request, sent once.
475
+
476
+ The answer is the recorded request in whatever state it reached (read `state`). Every
477
+ retry inside this call sends the same idempotency key; calling again is a new trade.
478
+ """
479
+ body = order_fields(locals())
480
+ spec = trade("place_order", "POST", self._account, "/orders", body, idempotency_key)
481
+ return self._requester.trade(spec, timeout, max_retries, self._streams)
482
+
483
+ def check(
484
+ self,
485
+ *,
486
+ symbol: str,
487
+ type: OrderTypeParam,
488
+ volume: float,
489
+ type_filling: FillingParam,
490
+ price: float | None = None,
491
+ stoplimit: float | None = None,
492
+ sl: float | None = None,
493
+ tp: float | None = None,
494
+ deviation: int | None = None,
495
+ type_time: TypeTimeParam | None = None,
496
+ expiration: int | None = None,
497
+ magic: int | None = None,
498
+ comment: str | None = None,
499
+ timeout: float | None = None,
500
+ max_retries: int | None = None,
501
+ ) -> CheckView:
502
+ """MT5's dry run of an order (OrderCheck): sends nothing to the broker."""
503
+ body = json_body(order_fields(locals()))
504
+ spec = safe("check_order", "POST", self._account, "/orders/check", body=body)
505
+ return cast(CheckView, self._requester.call(spec, timeout, max_retries))
506
+
507
+ def modify(
508
+ self,
509
+ ticket: int,
510
+ *,
511
+ price: float,
512
+ sl: float,
513
+ tp: float,
514
+ type_time: TypeTimeParam,
515
+ stoplimit: float | None = None,
516
+ expiration: int | None = None,
517
+ idempotency_key: str | None = None,
518
+ timeout: float | None = None,
519
+ max_retries: int | None = None,
520
+ ) -> TradeRequest:
521
+ """Change a pending order's levels. MT5 replaces every level: give the ones you keep."""
522
+ path = f"/orders/{ticket_segment(ticket)}"
523
+ body = {
524
+ "price": price,
525
+ "sl": sl,
526
+ "tp": tp,
527
+ "type_time": type_time,
528
+ "stoplimit": stoplimit,
529
+ "expiration": expiration,
530
+ }
531
+ spec = trade("modify_order", "PATCH", self._account, path, body, idempotency_key)
532
+ return self._requester.trade(spec, timeout, max_retries, self._streams)
533
+
534
+ def cancel(
535
+ self,
536
+ ticket: int,
537
+ *,
538
+ idempotency_key: str | None = None,
539
+ timeout: float | None = None,
540
+ max_retries: int | None = None,
541
+ ) -> TradeRequest:
542
+ """Cancel a pending order."""
543
+ path = f"/orders/{ticket_segment(ticket)}"
544
+ spec = trade("cancel_order", "DELETE", self._account, path, None, idempotency_key)
545
+ return self._requester.trade(spec, timeout, max_retries, self._streams)
546
+
547
+
548
+ class Requests(_Resource):
549
+ def get(
550
+ self, request_id: str, *, timeout: float | None = None, max_retries: int | None = None
551
+ ) -> TradeRequest:
552
+ """A recorded trade request by its id (`req_...`), whatever its state."""
553
+ spec = lookup(self._account, request_id)
554
+ return cast(TradeRequest, self._requester.call(spec, timeout, max_retries))
555
+
556
+ def wait(self, request_id: str, *, timeout: float = 30.0) -> TradeRequest:
557
+ """Look a request up until it is final or `timeout` seconds pass, then return it.
558
+
559
+ It returns the request as it stands at the timeout (pending or unknown included): it
560
+ never raises for "still pending", and it only ever looks up, never sends. A 401 or 404
561
+ is raised; other failures are polled through until the timeout. With a stream open on
562
+ this account handle, a request event for it ends the wait at once.
563
+ """
564
+ spec = lookup(self._account, request_id)
565
+ return self._requester.wait(spec, request_id, timeout, self._streams)
566
+
567
+
568
+ class History(_Resource):
569
+ def deals(
570
+ self,
571
+ from_: int,
572
+ to: int,
573
+ *,
574
+ timeout: float | None = None,
575
+ max_retries: int | None = None,
576
+ ) -> list[DealView]:
577
+ """Deals from `from_` to `to`, both included: broker server time in seconds, not UTC."""
578
+ spec = safe(
579
+ "history_deals",
580
+ "GET",
581
+ self._account,
582
+ "/history/deals",
583
+ query=history_range(from_, to),
584
+ )
585
+ return cast(list[DealView], self._requester.call(spec, timeout, max_retries))
586
+
587
+ def orders(
588
+ self,
589
+ from_: int,
590
+ to: int,
591
+ *,
592
+ timeout: float | None = None,
593
+ max_retries: int | None = None,
594
+ ) -> list[HistoryOrderView]:
595
+ """Orders no longer pending, from `from_` to `to` (broker server time in seconds)."""
596
+ spec = safe(
597
+ "history_orders",
598
+ "GET",
599
+ self._account,
600
+ "/history/orders",
601
+ query=history_range(from_, to),
602
+ )
603
+ return cast(list[HistoryOrderView], self._requester.call(spec, timeout, max_retries))
604
+
605
+
606
+ class Symbols(_Resource):
607
+ def list(
608
+ self, *, timeout: float | None = None, max_retries: int | None = None
609
+ ) -> list[SymbolSummaryView]:
610
+ """Every symbol the broker has."""
611
+ spec = safe("symbols", "GET", self._account, "/symbols")
612
+ return cast(list[SymbolSummaryView], self._requester.call(spec, timeout, max_retries))
613
+
614
+ def get(
615
+ self, name: str, *, timeout: float | None = None, max_retries: int | None = None
616
+ ) -> SymbolView:
617
+ """A symbol's specification, by its name exactly as the broker spells it."""
618
+ spec = safe("symbol", "GET", self._account, symbol_path(name))
619
+ return cast(SymbolView, self._requester.call(spec, timeout, max_retries))
620
+
621
+ def tick(
622
+ self, name: str, *, timeout: float | None = None, max_retries: int | None = None
623
+ ) -> TickView:
624
+ """A symbol's latest price."""
625
+ spec = safe("tick", "GET", self._account, symbol_path(name) + "/tick")
626
+ return cast(TickView, self._requester.call(spec, timeout, max_retries))
627
+
628
+
629
+ class KillSwitch(_Resource):
630
+ def get(
631
+ self, *, timeout: float | None = None, max_retries: int | None = None
632
+ ) -> KillSwitchView:
633
+ """Whether the account's kill switch is engaged."""
634
+ spec = safe("kill_switch", "GET", self._account, "/kill-switch")
635
+ return cast(KillSwitchView, self._requester.call(spec, timeout, max_retries))
636
+
637
+ def engage(
638
+ self,
639
+ *,
640
+ deviation: int | None = None,
641
+ timeout: float | None = None,
642
+ max_retries: int | None = None,
643
+ ) -> KillSwitchOutcome:
644
+ """Engage the kill switch: close every position and cancel every pending order, and
645
+ refuse new exposure until it is lifted. Safe to repeat: engaging again retries what is
646
+ left. If the terminal doesn't answer, AccountUnavailable carries `kill_switch`: the
647
+ switch is engaged all the same.
648
+ """
649
+ body = None if deviation is None else json_body({"deviation": deviation})
650
+ spec = safe("engage_kill_switch", "POST", self._account, "/kill-switch", body=body)
651
+ return cast(KillSwitchOutcome, self._requester.call(spec, timeout, max_retries))
652
+
653
+ def lift(
654
+ self, *, timeout: float | None = None, max_retries: int | None = None
655
+ ) -> KillSwitchView:
656
+ """Lift the kill switch, so new exposure is allowed again."""
657
+ spec = safe("lift_kill_switch", "DELETE", self._account, "/kill-switch")
658
+ return cast(KillSwitchView, self._requester.call(spec, timeout, max_retries))
659
+
660
+
661
+ ORDER_FIELDS = (
662
+ "symbol",
663
+ "type",
664
+ "volume",
665
+ "type_filling",
666
+ "price",
667
+ "stoplimit",
668
+ "sl",
669
+ "tp",
670
+ "deviation",
671
+ "type_time",
672
+ "expiration",
673
+ "magic",
674
+ "comment",
675
+ )
676
+
677
+
678
+ def order_fields(arguments: dict[str, Any]) -> dict[str, Any]:
679
+ """An order's body from a method's arguments: the API's field names, as they are."""
680
+ return {name: arguments[name] for name in ORDER_FIELDS}