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