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