fxsocket 0.1__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.
fxsocket/errors.py ADDED
@@ -0,0 +1,163 @@
1
+ """Exception hierarchy and HTTP-response → exception mapping.
2
+
3
+ A single :func:`error_from_response` maps both error envelopes the platform
4
+ uses — the management API's ``{"error", "detail"}`` and the terminal API's
5
+ ``{"error", "message", "command_id"}`` — onto typed exceptions.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any
11
+
12
+ import httpx
13
+
14
+
15
+ class FxSocketError(Exception):
16
+ """Base class for every error raised by the SDK."""
17
+
18
+ def __init__(
19
+ self,
20
+ message: str,
21
+ *,
22
+ status_code: int | None = None,
23
+ code: str | None = None,
24
+ response: httpx.Response | None = None,
25
+ ) -> None:
26
+ super().__init__(message)
27
+ self.message = message
28
+ self.status_code = status_code
29
+ self.code = code
30
+ self.response = response
31
+
32
+
33
+ class AuthError(FxSocketError):
34
+ """Missing or invalid API key (HTTP 401), or no key configured."""
35
+
36
+
37
+ class RateLimitError(FxSocketError):
38
+ """Too many requests (HTTP 429). ``retry_after`` is seconds, if given."""
39
+
40
+ def __init__(self, message: str, *, retry_after: float | None = None, **kw: Any):
41
+ super().__init__(message, **kw)
42
+ self.retry_after = retry_after
43
+
44
+
45
+ class ValidationError(FxSocketError):
46
+ """The request was rejected as malformed (HTTP 400 / ``MRPC_VALIDATION``)."""
47
+
48
+
49
+ class NotFoundError(FxSocketError):
50
+ """The referenced account or resource does not exist (HTTP 404)."""
51
+
52
+
53
+ class AccountCapError(FxSocketError):
54
+ """Plan account limit reached (HTTP 402 ``account_cap_reached``)."""
55
+
56
+ def __init__(
57
+ self,
58
+ message: str,
59
+ *,
60
+ cap: int | None = None,
61
+ current: int | None = None,
62
+ **kw: Any,
63
+ ):
64
+ super().__init__(message, **kw)
65
+ self.cap = cap
66
+ self.current = current
67
+
68
+
69
+ class NoSubscriptionError(FxSocketError):
70
+ """No plan permits linking accounts (HTTP 402 ``no_subscription``)."""
71
+
72
+
73
+ class DuplicateAccountError(FxSocketError):
74
+ """This account is already linked (HTTP 409)."""
75
+
76
+
77
+ class ConnectFailedError(FxSocketError):
78
+ """The broker rejected the login during account creation (HTTP 400).
79
+
80
+ ``code`` is one of ``invalid_credentials``, ``server_not_found``,
81
+ ``unknown``.
82
+ """
83
+
84
+
85
+ class TerminalNotReadyError(FxSocketError):
86
+ """The account's terminal isn't reachable yet.
87
+
88
+ Raised on HTTP 503 (trade EA not registered) and when an account has no
89
+ ``rest_url`` — it is still provisioning, or is bridge-only and exposes no
90
+ per-account terminal API.
91
+ """
92
+
93
+
94
+ class TerminalTimeoutError(FxSocketError):
95
+ """The terminal didn't answer in time (HTTP 504 / ``MRPC_TIMEOUT``)."""
96
+
97
+
98
+ class UnsupportedOnPlatformError(FxSocketError):
99
+ """A requested feature doesn't exist on the account's platform.
100
+
101
+ Enforced client-side — e.g. stop-limit orders or MT5-only timeframes on
102
+ an MT4 account.
103
+ """
104
+
105
+
106
+ class StreamError(FxSocketError):
107
+ """A WebSocket-level error (server error frame, or dropped connection)."""
108
+
109
+
110
+ _CONNECT_CODES = frozenset({"invalid_credentials", "server_not_found", "unknown"})
111
+
112
+
113
+ def error_from_response(resp: httpx.Response) -> FxSocketError:
114
+ """Build the most specific :class:`FxSocketError` for a failed response."""
115
+ status = resp.status_code
116
+ body: Any = None
117
+ try:
118
+ body = resp.json()
119
+ except ValueError:
120
+ body = None
121
+
122
+ code: str | None = None
123
+ detail: str | None = None
124
+ if isinstance(body, dict):
125
+ code = body.get("error")
126
+ detail = body.get("detail") or body.get("message")
127
+ message = detail or code or f"HTTP {status}"
128
+ common: dict[str, Any] = {
129
+ "status_code": status,
130
+ "code": code,
131
+ "response": resp,
132
+ }
133
+
134
+ if status == 401:
135
+ return AuthError(message, **common)
136
+ if status == 429:
137
+ raw = resp.headers.get("Retry-After")
138
+ retry = None
139
+ if raw:
140
+ try:
141
+ retry = float(raw)
142
+ except ValueError:
143
+ retry = None
144
+ return RateLimitError(message, retry_after=retry, **common)
145
+ if status == 404:
146
+ return NotFoundError(message, **common)
147
+ if status == 409:
148
+ return DuplicateAccountError(message, **common)
149
+ if status == 402:
150
+ if code == "account_cap_reached" and isinstance(body, dict):
151
+ return AccountCapError(
152
+ message, cap=body.get("cap"), current=body.get("current"), **common
153
+ )
154
+ return NoSubscriptionError(message, **common)
155
+ if status == 400:
156
+ if code in _CONNECT_CODES:
157
+ return ConnectFailedError(message, **common)
158
+ return ValidationError(message, **common)
159
+ if status == 503:
160
+ return TerminalNotReadyError(message, **common)
161
+ if status == 504:
162
+ return TerminalTimeoutError(message, **common)
163
+ return FxSocketError(message, **common)
fxsocket/management.py ADDED
@@ -0,0 +1,118 @@
1
+ """Account management — the public v1 API (``/v1/accounts``).
2
+
3
+ Accessible as ``client.accounts`` on both :class:`~fxsocket.Client` and
4
+ :class:`~fxsocket.AsyncClient`.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from ._http import AsyncHTTP, SyncHTTP
10
+ from .enums import Platform
11
+ from .models import Account
12
+
13
+
14
+ def account_id_of(account: Account | str) -> str:
15
+ """Accept either an :class:`Account` or a bare id string."""
16
+ return account.id if isinstance(account, Account) else str(account)
17
+
18
+
19
+ def _create_payload(
20
+ *,
21
+ server: str,
22
+ login: int,
23
+ password: str,
24
+ platform: Platform | str,
25
+ nickname: str,
26
+ ) -> dict[str, object]:
27
+ return {
28
+ "platform": Platform(platform).value,
29
+ "server": server,
30
+ "login": login,
31
+ "password": password,
32
+ "nickname": nickname,
33
+ }
34
+
35
+
36
+ class Accounts:
37
+ """Synchronous account operations."""
38
+
39
+ def __init__(self, http: SyncHTTP) -> None:
40
+ self._http = http
41
+
42
+ def list(self) -> list[Account]:
43
+ """List every account owned by the authenticated user."""
44
+ data = self._http.request("GET", "/accounts")
45
+ return [Account.model_validate(row) for row in data]
46
+
47
+ def get(self, account: Account | str) -> Account:
48
+ """Fetch one account by id (use this to poll connection status)."""
49
+ data = self._http.request("GET", f"/accounts/{account_id_of(account)}")
50
+ return Account.model_validate(data)
51
+
52
+ def create(
53
+ self,
54
+ *,
55
+ server: str,
56
+ login: int,
57
+ password: str,
58
+ platform: Platform | str = Platform.MT5,
59
+ nickname: str = "",
60
+ ) -> Account:
61
+ """Link (connect) a new MT4/MT5 account. Returns it in ``connecting``
62
+ state when terminal pods are enabled, else ``connected``."""
63
+ data = self._http.request(
64
+ "POST",
65
+ "/accounts",
66
+ json=_create_payload(
67
+ server=server,
68
+ login=login,
69
+ password=password,
70
+ platform=platform,
71
+ nickname=nickname,
72
+ ),
73
+ )
74
+ return Account.model_validate(data)
75
+
76
+ def delete(self, account: Account | str) -> None:
77
+ """Unlink (disconnect) an account and tear down its terminal."""
78
+ self._http.request("DELETE", f"/accounts/{account_id_of(account)}")
79
+
80
+
81
+ class AsyncAccounts:
82
+ """Asynchronous mirror of :class:`Accounts`."""
83
+
84
+ def __init__(self, http: AsyncHTTP) -> None:
85
+ self._http = http
86
+
87
+ async def list(self) -> list[Account]:
88
+ data = await self._http.request("GET", "/accounts")
89
+ return [Account.model_validate(row) for row in data]
90
+
91
+ async def get(self, account: Account | str) -> Account:
92
+ data = await self._http.request("GET", f"/accounts/{account_id_of(account)}")
93
+ return Account.model_validate(data)
94
+
95
+ async def create(
96
+ self,
97
+ *,
98
+ server: str,
99
+ login: int,
100
+ password: str,
101
+ platform: Platform | str = Platform.MT5,
102
+ nickname: str = "",
103
+ ) -> Account:
104
+ data = await self._http.request(
105
+ "POST",
106
+ "/accounts",
107
+ json=_create_payload(
108
+ server=server,
109
+ login=login,
110
+ password=password,
111
+ platform=platform,
112
+ nickname=nickname,
113
+ ),
114
+ )
115
+ return Account.model_validate(data)
116
+
117
+ async def delete(self, account: Account | str) -> None:
118
+ await self._http.request("DELETE", f"/accounts/{account_id_of(account)}")
fxsocket/models.py ADDED
@@ -0,0 +1,391 @@
1
+ """Pydantic models for FxSocket API payloads.
2
+
3
+ Two families:
4
+
5
+ * The management model (:class:`Account`) — the v1 API, which already speaks
6
+ ``snake_case`` and returns a genuine UTC ``created_at``.
7
+ * Terminal payloads — ``camelCase`` on the wire (accepted via aliases). Two
8
+ deliberate typing choices keep these robust:
9
+
10
+ - **MetaTrader vocabulary fields** (``type``, ``kind``, ``entry``,
11
+ ``status``) are plain ``str``, not enums: the exact serialized casing
12
+ varies and a strict enum would raise on an unrecognized value. Compare
13
+ them against the str-enums in :mod:`fxsocket.enums` (``OrderOperation``,
14
+ ``HealthStatus``, …) — those compare equal to the raw string.
15
+ - **Timestamps** are ``str``, not ``datetime``: they are in *broker server
16
+ time* with a stylistic trailing ``Z``, so decoding them as UTC would be
17
+ silently wrong. Use :class:`ServerTimezone` to convert if needed.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from datetime import datetime
23
+
24
+ from pydantic import BaseModel, ConfigDict
25
+ from pydantic.alias_generators import to_camel
26
+
27
+ from .enums import Platform, TradingStatus
28
+
29
+
30
+ class _Camel(BaseModel):
31
+ """Terminal payloads: read camelCase aliases, also accept field names."""
32
+
33
+ model_config = ConfigDict(
34
+ populate_by_name=True,
35
+ alias_generator=to_camel,
36
+ extra="ignore",
37
+ )
38
+
39
+
40
+ # --------------------------------------------------------------------------- #
41
+ # Management API (v1)
42
+ # --------------------------------------------------------------------------- #
43
+
44
+
45
+ class Account(BaseModel):
46
+ """A linked trading account, as returned by the management API (v1).
47
+
48
+ ``rest_url`` / ``ws_url`` are where this account's terminal REST and
49
+ WebSocket APIs live. Both are empty until the account has a reachable
50
+ terminal (shared pod or private droplet); a bridge-only account exposes
51
+ none.
52
+ """
53
+
54
+ model_config = ConfigDict(populate_by_name=True, extra="ignore")
55
+
56
+ id: str
57
+ nickname: str = ""
58
+ platform: Platform
59
+ server: str
60
+ login: int
61
+ status: TradingStatus
62
+ error: str = ""
63
+ rest_url: str = ""
64
+ ws_url: str = ""
65
+ created_at: datetime
66
+
67
+ @property
68
+ def has_terminal(self) -> bool:
69
+ """True when this account exposes a reachable terminal API."""
70
+ return bool(self.rest_url)
71
+
72
+
73
+ # --------------------------------------------------------------------------- #
74
+ # Terminal — account state
75
+ # --------------------------------------------------------------------------- #
76
+
77
+
78
+ class AccountSummary(_Camel):
79
+ """Live financial snapshot (``GET /AccountSummary``)."""
80
+
81
+ balance: float
82
+ credit: float
83
+ profit: float
84
+ equity: float
85
+ margin: float
86
+ free_margin: float
87
+ margin_level: float
88
+ leverage: int
89
+ currency: str
90
+ type: str
91
+
92
+
93
+ class AccountInfo(_Camel):
94
+ """Static account identity + configuration (``GET /AccountInfo``).
95
+
96
+ On MT4 ``margin_mode`` is always ``"Hedging"`` and ``fifo_close`` always
97
+ ``False`` (the platform has no native equivalent).
98
+ """
99
+
100
+ name: str
101
+ login: int
102
+ server: str
103
+ company: str
104
+ currency: str
105
+ currency_digits: int
106
+ leverage: int
107
+ type: str
108
+ margin_mode: str
109
+ margin_so_mode: str
110
+ margin_call_level: float
111
+ stop_out_level: float
112
+ trade_allowed: bool
113
+ trade_expert: bool
114
+ limit_orders: int
115
+ fifo_close: bool
116
+
117
+
118
+ class OpenedOrder(_Camel):
119
+ """An open position or resting pending order (``GET /OpenedOrders``)."""
120
+
121
+ ticket: int
122
+ symbol: str
123
+ type: str
124
+ kind: str
125
+ lots: float
126
+ open_price: float
127
+ current_price: float
128
+ stop_loss: float
129
+ take_profit: float
130
+ swap: float
131
+ profit: float
132
+ magic: int
133
+ comment: str
134
+ open_time: str
135
+
136
+ @property
137
+ def is_pending(self) -> bool:
138
+ """True for a resting pending order (vs. a live position)."""
139
+ return self.kind.lower() == "pending"
140
+
141
+
142
+ class HistoryTrade(_Camel):
143
+ """A historical deal / closed order (``GET /OrderHistory``).
144
+
145
+ On MT4 this is one row per closed *order* (no per-deal granularity);
146
+ ``order`` aliases the ticket and ``entry`` is constant.
147
+ """
148
+
149
+ ticket: int
150
+ order: int
151
+ symbol: str
152
+ type: str
153
+ entry: str
154
+ volume: float
155
+ price: float
156
+ commission: float
157
+ swap: float
158
+ profit: float
159
+ magic: int
160
+ comment: str
161
+ time: str
162
+
163
+
164
+ class PositionTrade(_Camel):
165
+ """A closed round-trip position (``GET /PositionHistory``)."""
166
+
167
+ position_id: int
168
+ symbol: str
169
+ type: str
170
+ volume: float
171
+ open_time: str
172
+ open_price: float
173
+ close_time: str
174
+ close_price: float
175
+ profit: float
176
+ swap: float
177
+ commission: float
178
+ net_profit: float
179
+ magic: int
180
+ comment: str
181
+
182
+
183
+ class ServerTimezone(_Camel):
184
+ """Broker server clock + UTC offset (``GET /ServerTimezone``).
185
+
186
+ ``utc_offset_seconds`` is ``server_time - UTC``; subtract it from a
187
+ broker-server timestamp to get UTC.
188
+ """
189
+
190
+ server_time: str
191
+ utc_offset_seconds: int
192
+
193
+
194
+ # --------------------------------------------------------------------------- #
195
+ # Terminal — market data
196
+ # --------------------------------------------------------------------------- #
197
+
198
+
199
+ class Quote(_Camel):
200
+ """Latest tick for a symbol (``GET /getQuote``).
201
+
202
+ ``last`` / ``volume`` are ~0 on forex (and always 0 on MT4).
203
+ """
204
+
205
+ symbol: str
206
+ bid: float
207
+ ask: float
208
+ time: str
209
+ last: float
210
+ volume: int
211
+
212
+
213
+ class SymbolInfo(_Camel):
214
+ """Contract specification for a symbol (``GET /SymbolInfo``)."""
215
+
216
+ symbol: str
217
+ description: str
218
+ digits: int
219
+ point: float
220
+ tick_size: float
221
+ tick_value: float
222
+ contract_size: float
223
+ volume_min: float
224
+ volume_max: float
225
+ volume_step: float
226
+ stops_level: int
227
+ freeze_level: int
228
+ spread: int
229
+ trade_mode: str
230
+ swap_long: float
231
+ swap_short: float
232
+ bid: float
233
+ ask: float
234
+ currency_base: str
235
+ currency_profit: str
236
+ currency_margin: str
237
+
238
+
239
+ class Candle(_Camel):
240
+ """One OHLC bar (``GET /PriceHistory``). ``real_volume`` is 0 on MT4."""
241
+
242
+ time: str
243
+ open: float
244
+ high: float
245
+ low: float
246
+ close: float
247
+ tick_volume: int
248
+ real_volume: int
249
+ spread: int
250
+
251
+
252
+ # --------------------------------------------------------------------------- #
253
+ # Terminal — trading
254
+ # --------------------------------------------------------------------------- #
255
+
256
+
257
+ class OrderResult(_Camel):
258
+ """Result of an order send / modify / close.
259
+
260
+ ``success`` is true when ``retcode`` is DONE (10009) or PLACED (10010).
261
+ ``deal`` is the executed deal ticket (0 for pending placement, and always
262
+ 0 on MT4); ``order`` is the resulting position / pending-order ticket.
263
+ """
264
+
265
+ success: bool
266
+ retcode: int
267
+ retcode_description: str
268
+ deal: int
269
+ order: int
270
+ volume: float
271
+ price: float
272
+ bid: float
273
+ ask: float
274
+ comment: str
275
+
276
+
277
+ class MarginCalc(_Camel):
278
+ """Required margin for a hypothetical order (``GET /OrderCalcMargin``)."""
279
+
280
+ symbol: str
281
+ operation: str
282
+ volume: float
283
+ price: float
284
+ margin: float
285
+ currency: str
286
+
287
+
288
+ class ProfitCalc(_Camel):
289
+ """Projected P/L for a hypothetical trade (``GET /OrderCalcProfit``)."""
290
+
291
+ symbol: str
292
+ operation: str
293
+ volume: float
294
+ price_open: float
295
+ price_close: float
296
+ profit: float
297
+ currency: str
298
+
299
+
300
+ # --------------------------------------------------------------------------- #
301
+ # Terminal — health
302
+ # --------------------------------------------------------------------------- #
303
+
304
+
305
+ class TerminalHealth(_Camel):
306
+ alive: bool
307
+ build: int = 0
308
+ ping_ms: int = 0
309
+
310
+
311
+ class BrokerHealth(_Camel):
312
+ connected: bool
313
+ server: str = ""
314
+
315
+
316
+ class AccountHealth(_Camel):
317
+ """Account section of ``/status``. ``currency`` / ``type`` are blank when
318
+ not logged in; ``login`` is always the configured account."""
319
+
320
+ logged_in: bool
321
+ login: int = 0
322
+ currency: str = ""
323
+ type: str = ""
324
+ trade_allowed: bool = False
325
+
326
+
327
+ class BridgeHealth(_Camel):
328
+ version: str = ""
329
+ trade_ea_ready: bool = False
330
+ symbols_synced: bool = False
331
+
332
+
333
+ class Health(_Camel):
334
+ """Full health snapshot (``GET /status``) — always HTTP 200.
335
+
336
+ ``status`` is one of ``ready`` / ``starting`` / ``degraded`` / ``down``
337
+ (compare against :class:`fxsocket.HealthStatus`).
338
+ """
339
+
340
+ status: str
341
+ terminal: TerminalHealth
342
+ broker: BrokerHealth
343
+ account: AccountHealth
344
+ bridge: BridgeHealth
345
+ server_time: str = ""
346
+
347
+ @property
348
+ def is_ready(self) -> bool:
349
+ return self.status == "ready"
350
+
351
+
352
+ class HealthChecks(_Camel):
353
+ """PII-free probe body from ``/healthz`` and ``/livez``."""
354
+
355
+ status: str
356
+ terminal: bool
357
+ broker: bool
358
+ account: bool
359
+
360
+
361
+ # --------------------------------------------------------------------------- #
362
+ # Terminal — streaming payloads (the inner ``data`` of some WS events)
363
+ # --------------------------------------------------------------------------- #
364
+
365
+
366
+ class TradeEventData(_Camel):
367
+ """A trade transaction pushed on the ``trades`` stream.
368
+
369
+ ``deal`` / ``position`` are 0 on MT4 (no per-deal model); ``entry`` is the
370
+ deal direction (compare against :class:`fxsocket.DealEntry`).
371
+ """
372
+
373
+ deal: int
374
+ order: int
375
+ position: int
376
+ symbol: str
377
+ type: str
378
+ entry: str
379
+ volume: float
380
+ price: float
381
+ profit: float
382
+ comment: str
383
+ time: str
384
+
385
+
386
+ class TerminalStatusData(_Camel):
387
+ """Terminal status pushed (~1/s) on the ``terminal`` stream."""
388
+
389
+ connected: bool
390
+ trade_allowed: bool
391
+ server_time: str
fxsocket/py.typed ADDED
File without changes
@@ -0,0 +1,45 @@
1
+ """Per-account terminal API — REST trading/market-data and WebSocket streaming.
2
+
3
+ The endpoints for an account come from ``Account.rest_url`` / ``Account.ws_url``
4
+ (populated by the management API), which already resolve shared-pod vs
5
+ private-droplet hosting.
6
+ """
7
+
8
+ from .client import AsyncTerminalClient, TerminalClient
9
+ from .stream import (
10
+ AccountUpdate,
11
+ AsyncStream,
12
+ Bar,
13
+ PositionsUpdate,
14
+ Stream,
15
+ StreamErrorEvent,
16
+ StreamEvent,
17
+ StreamWarning,
18
+ Subscribed,
19
+ Subscriptions,
20
+ TerminalUpdate,
21
+ Tick,
22
+ TradeUpdate,
23
+ UnknownEvent,
24
+ Unsubscribed,
25
+ )
26
+
27
+ __all__ = [
28
+ "TerminalClient",
29
+ "AsyncTerminalClient",
30
+ "AsyncStream",
31
+ "Stream",
32
+ "StreamEvent",
33
+ "Tick",
34
+ "Bar",
35
+ "AccountUpdate",
36
+ "PositionsUpdate",
37
+ "TradeUpdate",
38
+ "TerminalUpdate",
39
+ "StreamWarning",
40
+ "Subscribed",
41
+ "Unsubscribed",
42
+ "StreamErrorEvent",
43
+ "Subscriptions",
44
+ "UnknownEvent",
45
+ ]