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/errors.py
ADDED
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
"""The exceptions the SDK raises.
|
|
2
|
+
|
|
3
|
+
An exception means there is no recorded request in hand: either nothing was sent, or the
|
|
4
|
+
outcome is unknown and `OutcomeUnknown` carries the idempotency key to resolve it with. A trade
|
|
5
|
+
that MT5 rejected, or that the terminal did not send, is returned as a `TradeRequest`, not raised.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import TYPE_CHECKING, Any
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from .models import TradeRequest
|
|
14
|
+
from .types import Blocking, KillSwitchView
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"APIConnectionError",
|
|
18
|
+
"APIError",
|
|
19
|
+
"APITimeoutError",
|
|
20
|
+
"AccountUnavailable",
|
|
21
|
+
"AuthenticationError",
|
|
22
|
+
"BillingHold",
|
|
23
|
+
"EngineBusy",
|
|
24
|
+
"InvalidRequest",
|
|
25
|
+
"KillSwitchEngaged",
|
|
26
|
+
"NoPrice",
|
|
27
|
+
"NotFound",
|
|
28
|
+
"OutcomeUnknown",
|
|
29
|
+
"PipsocketError",
|
|
30
|
+
"RangeTooLarge",
|
|
31
|
+
"StreamError",
|
|
32
|
+
"StreamHandshakeError",
|
|
33
|
+
"SymbolBusy",
|
|
34
|
+
"TerminalError",
|
|
35
|
+
"TerminalTimeout",
|
|
36
|
+
"TradeNotAccepted",
|
|
37
|
+
"Unavailable",
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class PipsocketError(Exception):
|
|
42
|
+
"""The base of every exception this SDK raises.
|
|
43
|
+
|
|
44
|
+
`idempotency_key` is set on every exception a trade call raises: call again with it to get
|
|
45
|
+
the recorded request, or send the trade if it never arrived. None on other calls.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
idempotency_key: str | None = None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class APIConnectionError(PipsocketError):
|
|
52
|
+
"""The network failed: no usable answer came back."""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class APITimeoutError(APIConnectionError):
|
|
56
|
+
"""An attempt ran out of time before its answer arrived."""
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class APIError(PipsocketError):
|
|
60
|
+
"""The API refused the call, or answered with something that isn't its JSON.
|
|
61
|
+
|
|
62
|
+
`code` is the API's `error` code (None when the answer had none), `body` the parsed JSON
|
|
63
|
+
(None when it wasn't JSON), and `raw` the answer's bytes as they came.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
status: int
|
|
67
|
+
code: str | None
|
|
68
|
+
message: str
|
|
69
|
+
body: Any
|
|
70
|
+
raw: bytes
|
|
71
|
+
|
|
72
|
+
def __init__(
|
|
73
|
+
self, message: str, *, status: int, code: str | None, body: Any, raw: bytes
|
|
74
|
+
) -> None:
|
|
75
|
+
super().__init__(message)
|
|
76
|
+
self.message = message
|
|
77
|
+
self.status = status
|
|
78
|
+
self.code = code
|
|
79
|
+
self.body = body
|
|
80
|
+
self.raw = raw
|
|
81
|
+
|
|
82
|
+
def __str__(self) -> str:
|
|
83
|
+
label = f"{self.status} {self.code}" if self.code else str(self.status)
|
|
84
|
+
return f"{label}: {self.message}"
|
|
85
|
+
|
|
86
|
+
def __repr__(self) -> str:
|
|
87
|
+
return f"{type(self).__name__}(status={self.status}, code={self.code!r})"
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class AuthenticationError(APIError):
|
|
91
|
+
"""401: no API key, or one that isn't the workspace's current key."""
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class NotFound(APIError):
|
|
95
|
+
"""404: no such account, order, position, request or symbol."""
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class InvalidRequest(APIError):
|
|
99
|
+
"""A field, query parameter or header was refused (422), or a path held a ticket that isn't a
|
|
100
|
+
whole number (400, with no body). Nothing was sent."""
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class RangeTooLarge(APIError):
|
|
104
|
+
"""422 range_too_large: ask for a shorter history range."""
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class SymbolBusy(APIError):
|
|
108
|
+
"""409 symbol_busy: an earlier request on the symbol has no outcome yet. Nothing was sent.
|
|
109
|
+
|
|
110
|
+
`blocking` lists those requests with their states. `retry_after` is the answer's Retry-After
|
|
111
|
+
in seconds, or None when a blocking request is `unknown`: look it up instead of retrying.
|
|
112
|
+
"""
|
|
113
|
+
|
|
114
|
+
blocking: list[Blocking]
|
|
115
|
+
retry_after: int | None
|
|
116
|
+
|
|
117
|
+
def __init__(
|
|
118
|
+
self,
|
|
119
|
+
message: str,
|
|
120
|
+
*,
|
|
121
|
+
status: int,
|
|
122
|
+
code: str | None,
|
|
123
|
+
body: Any,
|
|
124
|
+
raw: bytes,
|
|
125
|
+
blocking: list[Blocking],
|
|
126
|
+
retry_after: int | None,
|
|
127
|
+
) -> None:
|
|
128
|
+
super().__init__(message, status=status, code=code, body=body, raw=raw)
|
|
129
|
+
self.blocking = blocking
|
|
130
|
+
self.retry_after = retry_after
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
class KillSwitchEngaged(APIError):
|
|
134
|
+
"""423 kill_switch_engaged: new exposure is refused until the kill switch is lifted."""
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
class BillingHold(APIError):
|
|
138
|
+
"""423 billing_hold: the account is close-only until the workspace's payment goes through."""
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class AccountUnavailable(APIError):
|
|
142
|
+
"""503: the account's terminal isn't connected, runs an outdated bridge, or is restarting.
|
|
143
|
+
|
|
144
|
+
`kill_switch` is set when engaging the kill switch answered this way: the switch is engaged
|
|
145
|
+
all the same, and the sweep isn't finished.
|
|
146
|
+
"""
|
|
147
|
+
|
|
148
|
+
kill_switch: KillSwitchView | None
|
|
149
|
+
|
|
150
|
+
def __init__(
|
|
151
|
+
self,
|
|
152
|
+
message: str,
|
|
153
|
+
*,
|
|
154
|
+
status: int,
|
|
155
|
+
code: str | None,
|
|
156
|
+
body: Any,
|
|
157
|
+
raw: bytes,
|
|
158
|
+
kill_switch: KillSwitchView | None = None,
|
|
159
|
+
) -> None:
|
|
160
|
+
super().__init__(message, status=status, code=code, body=body, raw=raw)
|
|
161
|
+
self.kill_switch = kill_switch
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
class EngineBusy(APIError):
|
|
165
|
+
"""503 engine_busy: the account's host had too many calls in flight. Nothing was done."""
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
class Unavailable(APIError):
|
|
169
|
+
"""503 engine_unavailable or 504 engine_timeout, on a call that is safe to repeat."""
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
class TerminalTimeout(APIError):
|
|
173
|
+
"""504 terminal_timeout: the account's terminal did not answer within 5 seconds."""
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
class TerminalError(APIError):
|
|
177
|
+
"""503 terminal_error, bad_command or symbol_too_large: the terminal did not give the answer."""
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
class NoPrice(APIError):
|
|
181
|
+
"""503 no_price: the terminal has no price for the symbol yet."""
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
class OutcomeUnknown(PipsocketError):
|
|
185
|
+
"""A trade call may have reached the account's host, and no recorded request came back.
|
|
186
|
+
|
|
187
|
+
The trade may or may not have been sent. Call again with the same `idempotency_key` to get
|
|
188
|
+
the recorded request (or send it, if it never arrived), or read positions and history first.
|
|
189
|
+
`cause` is the last error, `status` and `raw` the last answer's status and bytes, if any.
|
|
190
|
+
"""
|
|
191
|
+
|
|
192
|
+
idempotency_key: str # pyright: ignore[reportIncompatibleVariableOverride]
|
|
193
|
+
cause: PipsocketError
|
|
194
|
+
status: int | None
|
|
195
|
+
raw: bytes | None
|
|
196
|
+
|
|
197
|
+
def __init__(
|
|
198
|
+
self,
|
|
199
|
+
*,
|
|
200
|
+
idempotency_key: str,
|
|
201
|
+
cause: PipsocketError,
|
|
202
|
+
status: int | None,
|
|
203
|
+
raw: bytes | None,
|
|
204
|
+
) -> None:
|
|
205
|
+
super().__init__(
|
|
206
|
+
"The trade may have reached the account's host, and its outcome is unknown. "
|
|
207
|
+
"Call again with the same idempotency_key to get it, or check positions and "
|
|
208
|
+
f"history. Last error: {cause}"
|
|
209
|
+
)
|
|
210
|
+
self.idempotency_key = idempotency_key # pyright: ignore[reportIncompatibleVariableOverride]
|
|
211
|
+
self.cause = cause
|
|
212
|
+
self.status = status
|
|
213
|
+
self.raw = raw
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
class TradeNotAccepted(PipsocketError):
|
|
217
|
+
"""Raised by `TradeRequest.raise_for_state()` for a rejected, not_sent or not_executed
|
|
218
|
+
request."""
|
|
219
|
+
|
|
220
|
+
request: TradeRequest
|
|
221
|
+
|
|
222
|
+
def __init__(self, request: TradeRequest) -> None:
|
|
223
|
+
super().__init__(f"Request {request.id} is {request.state}: {request.message}")
|
|
224
|
+
self.request = request
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
class StreamError(PipsocketError):
|
|
228
|
+
"""The stream helper stopped for a reason that isn't one of the API's refusals."""
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
class StreamHandshakeError(StreamError):
|
|
232
|
+
"""The stream's upgrade was refused with a status that won't change by trying again (such
|
|
233
|
+
as 426), or no status the SDK could act on. `status` is the HTTP status, if there was one."""
|
|
234
|
+
|
|
235
|
+
status: int | None
|
|
236
|
+
|
|
237
|
+
def __init__(self, message: str, *, status: int | None) -> None:
|
|
238
|
+
super().__init__(message)
|
|
239
|
+
self.status = status
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
# The API's error codes and the exception each raises. Codes not listed raise a plain APIError.
|
|
243
|
+
# engine_unavailable and engine_timeout raise Unavailable only on calls that are safe to repeat;
|
|
244
|
+
# on a trade call they mean the outcome is unknown (see _core).
|
|
245
|
+
ERROR_CLASSES: dict[str, type[APIError]] = {
|
|
246
|
+
"missing_api_key": AuthenticationError,
|
|
247
|
+
"invalid_api_key": AuthenticationError,
|
|
248
|
+
"no_such_account": NotFound,
|
|
249
|
+
"no_such_order": NotFound,
|
|
250
|
+
"no_such_position": NotFound,
|
|
251
|
+
"no_such_request": NotFound,
|
|
252
|
+
"no_such_symbol": NotFound,
|
|
253
|
+
"invalid_request": InvalidRequest,
|
|
254
|
+
"magic_reserved": InvalidRequest,
|
|
255
|
+
"range_too_large": RangeTooLarge,
|
|
256
|
+
"symbol_busy": SymbolBusy,
|
|
257
|
+
"kill_switch_engaged": KillSwitchEngaged,
|
|
258
|
+
"billing_hold": BillingHold,
|
|
259
|
+
"account_not_connected": AccountUnavailable,
|
|
260
|
+
"bridge_outdated": AccountUnavailable,
|
|
261
|
+
"account_restarting": AccountUnavailable,
|
|
262
|
+
"engine_busy": EngineBusy,
|
|
263
|
+
"engine_unavailable": Unavailable,
|
|
264
|
+
"engine_timeout": Unavailable,
|
|
265
|
+
"terminal_timeout": TerminalTimeout,
|
|
266
|
+
"terminal_error": TerminalError,
|
|
267
|
+
"bad_command": TerminalError,
|
|
268
|
+
"symbol_too_large": TerminalError,
|
|
269
|
+
"no_price": NoPrice,
|
|
270
|
+
# Recorded requests come back as TradeRequest. A refusal with one of these codes but no
|
|
271
|
+
# request in its body isn't what the API sends, so it stays a plain APIError.
|
|
272
|
+
"trade_rejected": APIError,
|
|
273
|
+
"not_executed": APIError,
|
|
274
|
+
"not_sent": APIError,
|
|
275
|
+
}
|
pipsocket/models.py
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""TradeRequest: a recorded trade request, as a trade call, a lookup or the stream gives it."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from typing import Any, cast
|
|
7
|
+
|
|
8
|
+
from .errors import TradeNotAccepted
|
|
9
|
+
from .types import RequestView, SentRequest
|
|
10
|
+
|
|
11
|
+
__all__ = ["ACCEPTED_STATES", "FINAL_STATES", "OUTSTANDING_STATES", "TradeRequest"]
|
|
12
|
+
|
|
13
|
+
# MT5 took the request (not necessarily a fill: see TradeRequest).
|
|
14
|
+
ACCEPTED_STATES = frozenset({"completed", "placed", "partial"})
|
|
15
|
+
# Nothing more happens to the request.
|
|
16
|
+
FINAL_STATES = ACCEPTED_STATES | {"rejected", "not_sent", "not_executed", "executed"}
|
|
17
|
+
# No outcome yet: it may still execute.
|
|
18
|
+
OUTSTANDING_STATES = frozenset({"pending", "unknown"})
|
|
19
|
+
# `result` is MT5's own answer (a TradeResult) in these states.
|
|
20
|
+
_TRADE_SERVER_ANSWERED = ACCEPTED_STATES | {"rejected"}
|
|
21
|
+
# raise_for_state() raises for these.
|
|
22
|
+
_NOT_ACCEPTED = frozenset({"rejected", "not_sent", "not_executed"})
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class TradeRequest:
|
|
26
|
+
"""A recorded trade request. The wire body is kept untouched in `raw`.
|
|
27
|
+
|
|
28
|
+
`state` is the API's string as it came: one of the nine known states, or anything a newer
|
|
29
|
+
API sends (treated as neither accepted nor final). `result` depends on the state:
|
|
30
|
+
|
|
31
|
+
- completed, placed, partial, rejected: MT5's answer (`retcode`, `retcode_name`, `order`,
|
|
32
|
+
`deal` and the rest)
|
|
33
|
+
- not_sent: the terminal's refusal, `{error, message}`
|
|
34
|
+
- not_executed: the refusal when `settlement` is "evidence"; who settled it and why when it
|
|
35
|
+
is "operator"
|
|
36
|
+
- executed: who settled it and why, `{outcome, settled_by, reason}`
|
|
37
|
+
- pending, unknown: None
|
|
38
|
+
|
|
39
|
+
`completed` means MT5 completed the request, whatever it was: a market order filled, but
|
|
40
|
+
also a pending order placed, an order modified or cancelled, stops set. A fill shows as a
|
|
41
|
+
deal: `result["deal"]` is non-zero (and `sent["action"]` is "deal"). A placed pending order
|
|
42
|
+
has `result["order"]` set and `result["deal"]` 0.
|
|
43
|
+
|
|
44
|
+
`idempotency_key` is set only on the answer to a trade call this client made, and
|
|
45
|
+
`http_status` only on answers from the REST API (not on stream events).
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
__slots__ = (
|
|
49
|
+
"error",
|
|
50
|
+
"http_status",
|
|
51
|
+
"id",
|
|
52
|
+
"idempotency_key",
|
|
53
|
+
"message",
|
|
54
|
+
"raw",
|
|
55
|
+
"result",
|
|
56
|
+
"sent",
|
|
57
|
+
"settlement",
|
|
58
|
+
"state",
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
id: str
|
|
62
|
+
state: str
|
|
63
|
+
sent: SentRequest
|
|
64
|
+
result: Any
|
|
65
|
+
message: str
|
|
66
|
+
error: str | None
|
|
67
|
+
settlement: str | None
|
|
68
|
+
idempotency_key: str | None
|
|
69
|
+
http_status: int | None
|
|
70
|
+
raw: RequestView
|
|
71
|
+
|
|
72
|
+
def __init__(
|
|
73
|
+
self,
|
|
74
|
+
body: RequestView,
|
|
75
|
+
*,
|
|
76
|
+
http_status: int | None = None,
|
|
77
|
+
idempotency_key: str | None = None,
|
|
78
|
+
) -> None:
|
|
79
|
+
if not is_recorded_request(body):
|
|
80
|
+
raise ValueError("Not a recorded request: it needs a string `request` and `state`.")
|
|
81
|
+
self.raw = body
|
|
82
|
+
self.id = body["request"]
|
|
83
|
+
self.state = body["state"]
|
|
84
|
+
self.sent = body.get("sent", cast(SentRequest, {}))
|
|
85
|
+
self.result = body.get("result")
|
|
86
|
+
self.message = body.get("message", "")
|
|
87
|
+
self.error = body.get("error")
|
|
88
|
+
self.settlement = body.get("settlement")
|
|
89
|
+
self.idempotency_key = idempotency_key
|
|
90
|
+
self.http_status = http_status
|
|
91
|
+
|
|
92
|
+
@classmethod
|
|
93
|
+
def from_event(cls, message: Mapping[str, Any]) -> TradeRequest:
|
|
94
|
+
"""The TradeRequest for a stream `request` event. It has no idempotency key or status."""
|
|
95
|
+
if message.get("type") != "request":
|
|
96
|
+
raise ValueError(f"Not a request event: its type is {message.get('type')!r}.")
|
|
97
|
+
return cls(cast(RequestView, dict(message)))
|
|
98
|
+
|
|
99
|
+
@property
|
|
100
|
+
def accepted(self) -> bool:
|
|
101
|
+
"""MT5 took the request: completed, placed or partial. Not the same as filled: a fill
|
|
102
|
+
has a non-zero `result["deal"]`."""
|
|
103
|
+
return self.state in ACCEPTED_STATES
|
|
104
|
+
|
|
105
|
+
@property
|
|
106
|
+
def final(self) -> bool:
|
|
107
|
+
"""Nothing more happens to it. False for a state this SDK doesn't know."""
|
|
108
|
+
return self.state in FINAL_STATES
|
|
109
|
+
|
|
110
|
+
@property
|
|
111
|
+
def outstanding(self) -> bool:
|
|
112
|
+
"""No outcome yet (pending or unknown): it may still execute."""
|
|
113
|
+
return self.state in OUTSTANDING_STATES
|
|
114
|
+
|
|
115
|
+
@property
|
|
116
|
+
def retcode(self) -> int | None:
|
|
117
|
+
"""MT5's retcode, when `result` is MT5's answer."""
|
|
118
|
+
value = self._trade_result().get("retcode")
|
|
119
|
+
return value if isinstance(value, int) and not isinstance(value, bool) else None
|
|
120
|
+
|
|
121
|
+
@property
|
|
122
|
+
def retcode_name(self) -> str | None:
|
|
123
|
+
"""The retcode's name, such as TRADE_RETCODE_DONE, when `result` is MT5's answer."""
|
|
124
|
+
value = self._trade_result().get("retcode_name")
|
|
125
|
+
return value if isinstance(value, str) else None
|
|
126
|
+
|
|
127
|
+
def raise_for_state(self) -> TradeRequest:
|
|
128
|
+
"""Raise TradeNotAccepted if the request is rejected, not_sent or not_executed.
|
|
129
|
+
|
|
130
|
+
Returns the request otherwise, pending and unknown included: those may still execute.
|
|
131
|
+
"""
|
|
132
|
+
if self.state in _NOT_ACCEPTED:
|
|
133
|
+
raise TradeNotAccepted(self)
|
|
134
|
+
return self
|
|
135
|
+
|
|
136
|
+
def _trade_result(self) -> Mapping[str, Any]:
|
|
137
|
+
result: object = self.result
|
|
138
|
+
if self.state in _TRADE_SERVER_ANSWERED and isinstance(result, Mapping):
|
|
139
|
+
return cast(Mapping[str, Any], result)
|
|
140
|
+
return {}
|
|
141
|
+
|
|
142
|
+
def __repr__(self) -> str:
|
|
143
|
+
return f"TradeRequest(id={self.id!r}, state={self.state!r})"
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def is_recorded_request(body: object) -> bool:
|
|
147
|
+
"""The one check made on a body: a JSON object with a string `request` and `state`."""
|
|
148
|
+
if not isinstance(body, Mapping):
|
|
149
|
+
return False
|
|
150
|
+
fields = cast(Mapping[str, object], body)
|
|
151
|
+
return isinstance(fields.get("request"), str) and isinstance(fields.get("state"), str)
|
pipsocket/py.typed
ADDED
|
File without changes
|