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/__init__.py +68 -0
- pipsocket/_core.py +712 -0
- pipsocket/_generated/__init__.py +1 -0
- pipsocket/_generated/operations.py +35 -0
- pipsocket/_generated/tables.py +95 -0
- pipsocket/_generated/types.py +542 -0
- pipsocket/_http.py +235 -0
- pipsocket/_stream_core.py +916 -0
- pipsocket/_version.py +1 -0
- pipsocket/aio.py +702 -0
- pipsocket/client.py +680 -0
- pipsocket/errors.py +275 -0
- pipsocket/models.py +151 -0
- pipsocket/py.typed +0 -0
- pipsocket/stream.py +713 -0
- pipsocket/types.py +48 -0
- pipsocket-0.1.0.dist-info/METADATA +358 -0
- pipsocket-0.1.0.dist-info/RECORD +20 -0
- pipsocket-0.1.0.dist-info/WHEEL +4 -0
- pipsocket-0.1.0.dist-info/licenses/LICENSE +21 -0
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)
|