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