fxsocket 0.2__tar.gz → 0.4.0__tar.gz
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-0.2 → fxsocket-0.4.0}/PKG-INFO +56 -8
- {fxsocket-0.2 → fxsocket-0.4.0}/README.md +55 -7
- {fxsocket-0.2 → fxsocket-0.4.0}/src/fxsocket/__init__.py +20 -0
- fxsocket-0.4.0/src/fxsocket/_version.py +1 -0
- {fxsocket-0.2 → fxsocket-0.4.0}/src/fxsocket/client.py +14 -6
- {fxsocket-0.2 → fxsocket-0.4.0}/src/fxsocket/enums.py +19 -0
- {fxsocket-0.2 → fxsocket-0.4.0}/src/fxsocket/errors.py +22 -0
- fxsocket-0.4.0/src/fxsocket/management.py +264 -0
- {fxsocket-0.2 → fxsocket-0.4.0}/src/fxsocket/models.py +164 -2
- {fxsocket-0.2 → fxsocket-0.4.0}/src/fxsocket/terminal/client.py +56 -0
- fxsocket-0.4.0/tests/test_private_servers.py +181 -0
- {fxsocket-0.2 → fxsocket-0.4.0}/tests/test_terminal.py +154 -0
- fxsocket-0.2/src/fxsocket/_version.py +0 -1
- fxsocket-0.2/src/fxsocket/management.py +0 -118
- {fxsocket-0.2 → fxsocket-0.4.0}/.github/workflows/ci.yml +0 -0
- {fxsocket-0.2 → fxsocket-0.4.0}/.github/workflows/publish.yml +0 -0
- {fxsocket-0.2 → fxsocket-0.4.0}/.gitignore +0 -0
- {fxsocket-0.2 → fxsocket-0.4.0}/LICENSE +0 -0
- {fxsocket-0.2 → fxsocket-0.4.0}/examples/manage_accounts.py +0 -0
- {fxsocket-0.2 → fxsocket-0.4.0}/examples/stream_quotes.py +0 -0
- {fxsocket-0.2 → fxsocket-0.4.0}/examples/terminal_rest.py +0 -0
- {fxsocket-0.2 → fxsocket-0.4.0}/pyproject.toml +0 -0
- {fxsocket-0.2 → fxsocket-0.4.0}/src/fxsocket/_http.py +0 -0
- {fxsocket-0.2 → fxsocket-0.4.0}/src/fxsocket/config.py +0 -0
- {fxsocket-0.2 → fxsocket-0.4.0}/src/fxsocket/py.typed +0 -0
- {fxsocket-0.2 → fxsocket-0.4.0}/src/fxsocket/terminal/__init__.py +0 -0
- {fxsocket-0.2 → fxsocket-0.4.0}/src/fxsocket/terminal/stream.py +0 -0
- {fxsocket-0.2 → fxsocket-0.4.0}/tests/test_errors.py +0 -0
- {fxsocket-0.2 → fxsocket-0.4.0}/tests/test_management.py +0 -0
- {fxsocket-0.2 → fxsocket-0.4.0}/tests/test_stream.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: fxsocket
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: Python SDK for the FxSocket API — MT4/MT5 account management, trading, and real-time streaming.
|
|
5
5
|
Project-URL: Homepage, https://fxsocket.com
|
|
6
6
|
Project-URL: Documentation, https://api.fxsocket.com/v1/docs
|
|
@@ -40,8 +40,11 @@ interfaces.
|
|
|
40
40
|
## Features
|
|
41
41
|
|
|
42
42
|
- **Account management** — link, list, fetch, and disconnect MT4/MT5 accounts.
|
|
43
|
-
- **
|
|
44
|
-
|
|
43
|
+
- **Private servers** — list your dedicated hosting servers and manage the
|
|
44
|
+
accounts on them.
|
|
45
|
+
- **Trading** — market & pending orders, modify, close, close-all, plus margin/profit calculators.
|
|
46
|
+
- **Market data** — quotes, symbol specifications (incl. commission rules & trading
|
|
47
|
+
sessions), OHLC history, account state & info.
|
|
45
48
|
- **Live streaming** — ticks, bars, account, positions, trades, and terminal status
|
|
46
49
|
over WebSocket, with automatic reconnect + subscription replay.
|
|
47
50
|
- **Sync *and* async** — `Client` / `AsyncClient`, method-for-method mirrors.
|
|
@@ -155,6 +158,21 @@ if res.is_effective: # applied now, or already in effect
|
|
|
155
158
|
...
|
|
156
159
|
```
|
|
157
160
|
|
|
161
|
+
There's also a panic button. `close_all()` closes every open position in one
|
|
162
|
+
trade-EA pass — optionally filtered by `symbol` and/or `magic` (`magic=0`
|
|
163
|
+
matches manually-opened orders), and `delete_pending=True` also deletes
|
|
164
|
+
matching pending orders. It returns a `CloseAllSummary` with per-ticket
|
|
165
|
+
results. On a 504 the pass *continues inside the terminal* — check
|
|
166
|
+
`opened_orders()` before acting again rather than re-sending:
|
|
167
|
+
|
|
168
|
+
```python
|
|
169
|
+
summary = term.close_all(symbol="EURUSD", delete_pending=True)
|
|
170
|
+
if summary.failed:
|
|
171
|
+
for r in summary.results:
|
|
172
|
+
if not r.success:
|
|
173
|
+
print(r.ticket, r.retcode, r.retcode_description)
|
|
174
|
+
```
|
|
175
|
+
|
|
158
176
|
Inputs are validated client-side before they're sent. One guard worth knowing:
|
|
159
177
|
in `order_modify`, a literal `stop_loss=0.0` would *remove* your stop-loss, so
|
|
160
178
|
it's rejected — pass `clear_stop_loss=True` to remove one deliberately, while
|
|
@@ -238,11 +256,41 @@ except AccountCapError as e:
|
|
|
238
256
|
|
|
239
257
|
## Private hosting
|
|
240
258
|
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
259
|
+
Dedicated private servers are managed through `client.private_servers`:
|
|
260
|
+
|
|
261
|
+
```python
|
|
262
|
+
import time
|
|
263
|
+
|
|
264
|
+
from fxsocket import Client, PrivateAccountStatus, SlotsFullError
|
|
265
|
+
|
|
266
|
+
with Client(api_key="fxs_live_...", verify_terminal_tls=False) as fx:
|
|
267
|
+
[server] = fx.private_servers.list()
|
|
268
|
+
print(server.name, server.status, f"{server.used_slots}/{server.purchased_slots}")
|
|
269
|
+
|
|
270
|
+
try:
|
|
271
|
+
account = fx.private_servers.add_account(
|
|
272
|
+
server, server="ICMarkets-Demo", login=1150125, password="..."
|
|
273
|
+
)
|
|
274
|
+
except SlotsFullError as err:
|
|
275
|
+
print(f"Server full ({err.used}/{err.cap}) — raise the limit in the dashboard.")
|
|
276
|
+
|
|
277
|
+
# Poll until the on-server agent has the terminal up, then trade as usual.
|
|
278
|
+
while True:
|
|
279
|
+
server = fx.private_servers.get(server)
|
|
280
|
+
account = next(a for a in server.accounts if a.id == account.id)
|
|
281
|
+
if account.status == PrivateAccountStatus.READY:
|
|
282
|
+
break
|
|
283
|
+
time.sleep(5)
|
|
284
|
+
|
|
285
|
+
print(fx.terminal(account).account_summary())
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
Accounts on a private server are traded and streamed exactly like
|
|
289
|
+
shared-cluster accounts — their `rest_url` / `ws_url` simply point at the
|
|
290
|
+
server's dedicated IP. The server presents a self-signed certificate, so reach
|
|
291
|
+
it with `Client(..., verify_terminal_tls=False)` (or supply a pinned CA).
|
|
292
|
+
*Purchasing* a server, canceling, and slot changes happen in the dashboard;
|
|
293
|
+
the API deliberately exposes no billing operations.
|
|
246
294
|
|
|
247
295
|
## Timestamps
|
|
248
296
|
|
|
@@ -13,8 +13,11 @@ interfaces.
|
|
|
13
13
|
## Features
|
|
14
14
|
|
|
15
15
|
- **Account management** — link, list, fetch, and disconnect MT4/MT5 accounts.
|
|
16
|
-
- **
|
|
17
|
-
|
|
16
|
+
- **Private servers** — list your dedicated hosting servers and manage the
|
|
17
|
+
accounts on them.
|
|
18
|
+
- **Trading** — market & pending orders, modify, close, close-all, plus margin/profit calculators.
|
|
19
|
+
- **Market data** — quotes, symbol specifications (incl. commission rules & trading
|
|
20
|
+
sessions), OHLC history, account state & info.
|
|
18
21
|
- **Live streaming** — ticks, bars, account, positions, trades, and terminal status
|
|
19
22
|
over WebSocket, with automatic reconnect + subscription replay.
|
|
20
23
|
- **Sync *and* async** — `Client` / `AsyncClient`, method-for-method mirrors.
|
|
@@ -128,6 +131,21 @@ if res.is_effective: # applied now, or already in effect
|
|
|
128
131
|
...
|
|
129
132
|
```
|
|
130
133
|
|
|
134
|
+
There's also a panic button. `close_all()` closes every open position in one
|
|
135
|
+
trade-EA pass — optionally filtered by `symbol` and/or `magic` (`magic=0`
|
|
136
|
+
matches manually-opened orders), and `delete_pending=True` also deletes
|
|
137
|
+
matching pending orders. It returns a `CloseAllSummary` with per-ticket
|
|
138
|
+
results. On a 504 the pass *continues inside the terminal* — check
|
|
139
|
+
`opened_orders()` before acting again rather than re-sending:
|
|
140
|
+
|
|
141
|
+
```python
|
|
142
|
+
summary = term.close_all(symbol="EURUSD", delete_pending=True)
|
|
143
|
+
if summary.failed:
|
|
144
|
+
for r in summary.results:
|
|
145
|
+
if not r.success:
|
|
146
|
+
print(r.ticket, r.retcode, r.retcode_description)
|
|
147
|
+
```
|
|
148
|
+
|
|
131
149
|
Inputs are validated client-side before they're sent. One guard worth knowing:
|
|
132
150
|
in `order_modify`, a literal `stop_loss=0.0` would *remove* your stop-loss, so
|
|
133
151
|
it's rejected — pass `clear_stop_loss=True` to remove one deliberately, while
|
|
@@ -211,11 +229,41 @@ except AccountCapError as e:
|
|
|
211
229
|
|
|
212
230
|
## Private hosting
|
|
213
231
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
232
|
+
Dedicated private servers are managed through `client.private_servers`:
|
|
233
|
+
|
|
234
|
+
```python
|
|
235
|
+
import time
|
|
236
|
+
|
|
237
|
+
from fxsocket import Client, PrivateAccountStatus, SlotsFullError
|
|
238
|
+
|
|
239
|
+
with Client(api_key="fxs_live_...", verify_terminal_tls=False) as fx:
|
|
240
|
+
[server] = fx.private_servers.list()
|
|
241
|
+
print(server.name, server.status, f"{server.used_slots}/{server.purchased_slots}")
|
|
242
|
+
|
|
243
|
+
try:
|
|
244
|
+
account = fx.private_servers.add_account(
|
|
245
|
+
server, server="ICMarkets-Demo", login=1150125, password="..."
|
|
246
|
+
)
|
|
247
|
+
except SlotsFullError as err:
|
|
248
|
+
print(f"Server full ({err.used}/{err.cap}) — raise the limit in the dashboard.")
|
|
249
|
+
|
|
250
|
+
# Poll until the on-server agent has the terminal up, then trade as usual.
|
|
251
|
+
while True:
|
|
252
|
+
server = fx.private_servers.get(server)
|
|
253
|
+
account = next(a for a in server.accounts if a.id == account.id)
|
|
254
|
+
if account.status == PrivateAccountStatus.READY:
|
|
255
|
+
break
|
|
256
|
+
time.sleep(5)
|
|
257
|
+
|
|
258
|
+
print(fx.terminal(account).account_summary())
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
Accounts on a private server are traded and streamed exactly like
|
|
262
|
+
shared-cluster accounts — their `rest_url` / `ws_url` simply point at the
|
|
263
|
+
server's dedicated IP. The server presents a self-signed certificate, so reach
|
|
264
|
+
it with `Client(..., verify_terminal_tls=False)` (or supply a pinned CA).
|
|
265
|
+
*Purchasing* a server, canceling, and slot changes happen in the dashboard;
|
|
266
|
+
the API deliberately exposes no billing operations.
|
|
219
267
|
|
|
220
268
|
## Timestamps
|
|
221
269
|
|
|
@@ -16,6 +16,8 @@ from .enums import (
|
|
|
16
16
|
OrderOperation,
|
|
17
17
|
OrderOutcome,
|
|
18
18
|
Platform,
|
|
19
|
+
PrivateAccountStatus,
|
|
20
|
+
PrivateServerStatus,
|
|
19
21
|
Timeframe,
|
|
20
22
|
TradingStatus,
|
|
21
23
|
)
|
|
@@ -28,6 +30,7 @@ from .errors import (
|
|
|
28
30
|
NoSubscriptionError,
|
|
29
31
|
NotFoundError,
|
|
30
32
|
RateLimitError,
|
|
33
|
+
SlotsFullError,
|
|
31
34
|
StreamError,
|
|
32
35
|
TerminalNotReadyError,
|
|
33
36
|
TerminalTimeoutError,
|
|
@@ -39,6 +42,10 @@ from .models import (
|
|
|
39
42
|
AccountInfo,
|
|
40
43
|
AccountSummary,
|
|
41
44
|
Candle,
|
|
45
|
+
CloseAllResult,
|
|
46
|
+
CloseAllSummary,
|
|
47
|
+
CommissionRule,
|
|
48
|
+
CommissionTier,
|
|
42
49
|
Health,
|
|
43
50
|
HealthChecks,
|
|
44
51
|
HistoryTrade,
|
|
@@ -46,12 +53,15 @@ from .models import (
|
|
|
46
53
|
OpenedOrder,
|
|
47
54
|
OrderResult,
|
|
48
55
|
PositionTrade,
|
|
56
|
+
PrivateServer,
|
|
57
|
+
PrivateServerAccount,
|
|
49
58
|
ProfitCalc,
|
|
50
59
|
Quote,
|
|
51
60
|
ServerTimezone,
|
|
52
61
|
SymbolInfo,
|
|
53
62
|
TerminalStatusData,
|
|
54
63
|
TradeEventData,
|
|
64
|
+
TradingSession,
|
|
55
65
|
)
|
|
56
66
|
from .terminal import (
|
|
57
67
|
AccountUpdate,
|
|
@@ -97,6 +107,8 @@ __all__ = [
|
|
|
97
107
|
"TradeEventData",
|
|
98
108
|
"TerminalStatusData",
|
|
99
109
|
"Account",
|
|
110
|
+
"PrivateServer",
|
|
111
|
+
"PrivateServerAccount",
|
|
100
112
|
"AccountSummary",
|
|
101
113
|
"AccountInfo",
|
|
102
114
|
"OpenedOrder",
|
|
@@ -105,13 +117,20 @@ __all__ = [
|
|
|
105
117
|
"ServerTimezone",
|
|
106
118
|
"Quote",
|
|
107
119
|
"SymbolInfo",
|
|
120
|
+
"CommissionRule",
|
|
121
|
+
"CommissionTier",
|
|
122
|
+
"TradingSession",
|
|
108
123
|
"Candle",
|
|
109
124
|
"OrderResult",
|
|
125
|
+
"CloseAllResult",
|
|
126
|
+
"CloseAllSummary",
|
|
110
127
|
"MarginCalc",
|
|
111
128
|
"ProfitCalc",
|
|
112
129
|
"Health",
|
|
113
130
|
"HealthChecks",
|
|
114
131
|
"Platform",
|
|
132
|
+
"PrivateAccountStatus",
|
|
133
|
+
"PrivateServerStatus",
|
|
115
134
|
"TradingStatus",
|
|
116
135
|
"OrderOperation",
|
|
117
136
|
"OrderOutcome",
|
|
@@ -128,6 +147,7 @@ __all__ = [
|
|
|
128
147
|
"AccountCapError",
|
|
129
148
|
"NoSubscriptionError",
|
|
130
149
|
"DuplicateAccountError",
|
|
150
|
+
"SlotsFullError",
|
|
131
151
|
"ConnectFailedError",
|
|
132
152
|
"TerminalNotReadyError",
|
|
133
153
|
"TerminalTimeoutError",
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.4.0"
|
|
@@ -15,8 +15,13 @@ import httpx
|
|
|
15
15
|
from ._http import AsyncHTTP, SyncHTTP, auth_headers
|
|
16
16
|
from .config import DEFAULT_BASE_URL, DEFAULT_TIMEOUT, ENV_API_KEY
|
|
17
17
|
from .errors import AuthError, TerminalNotReadyError
|
|
18
|
-
from .management import
|
|
19
|
-
|
|
18
|
+
from .management import (
|
|
19
|
+
Accounts,
|
|
20
|
+
AsyncAccounts,
|
|
21
|
+
AsyncPrivateServers,
|
|
22
|
+
PrivateServers,
|
|
23
|
+
)
|
|
24
|
+
from .models import Account, PrivateServerAccount
|
|
20
25
|
from .terminal.client import AsyncTerminalClient, TerminalClient
|
|
21
26
|
from .terminal.stream import AsyncStream, Stream
|
|
22
27
|
|
|
@@ -63,11 +68,13 @@ class Client:
|
|
|
63
68
|
)
|
|
64
69
|
#: Account management (the v1 API).
|
|
65
70
|
self.accounts = Accounts(SyncHTTP(self._http_client))
|
|
71
|
+
#: Private hosting servers (the v1 API).
|
|
72
|
+
self.private_servers = PrivateServers(SyncHTTP(self._http_client))
|
|
66
73
|
self._terminals: dict[tuple[str, str], TerminalClient] = {}
|
|
67
74
|
|
|
68
75
|
def terminal(
|
|
69
76
|
self,
|
|
70
|
-
account: Account,
|
|
77
|
+
account: Account | PrivateServerAccount,
|
|
71
78
|
*,
|
|
72
79
|
verify: bool | None = None,
|
|
73
80
|
timeout: float | None = None,
|
|
@@ -100,7 +107,7 @@ class Client:
|
|
|
100
107
|
|
|
101
108
|
def stream(
|
|
102
109
|
self,
|
|
103
|
-
account: Account,
|
|
110
|
+
account: Account | PrivateServerAccount,
|
|
104
111
|
*,
|
|
105
112
|
verify: bool | None = None,
|
|
106
113
|
auto_reconnect: bool = True,
|
|
@@ -178,11 +185,12 @@ class AsyncClient:
|
|
|
178
185
|
timeout=timeout,
|
|
179
186
|
)
|
|
180
187
|
self.accounts = AsyncAccounts(AsyncHTTP(self._http_client))
|
|
188
|
+
self.private_servers = AsyncPrivateServers(AsyncHTTP(self._http_client))
|
|
181
189
|
self._terminals: dict[tuple[str, str], AsyncTerminalClient] = {}
|
|
182
190
|
|
|
183
191
|
def terminal(
|
|
184
192
|
self,
|
|
185
|
-
account: Account,
|
|
193
|
+
account: Account | PrivateServerAccount,
|
|
186
194
|
*,
|
|
187
195
|
verify: bool | None = None,
|
|
188
196
|
timeout: float | None = None,
|
|
@@ -212,7 +220,7 @@ class AsyncClient:
|
|
|
212
220
|
|
|
213
221
|
def stream(
|
|
214
222
|
self,
|
|
215
|
-
account: Account,
|
|
223
|
+
account: Account | PrivateServerAccount,
|
|
216
224
|
*,
|
|
217
225
|
verify: bool | None = None,
|
|
218
226
|
auto_reconnect: bool = True,
|
|
@@ -76,6 +76,25 @@ class OrderOutcome(str, Enum):
|
|
|
76
76
|
REJECTED = "rejected" #: anything else — inspect ``retcode`` / ``comment``
|
|
77
77
|
|
|
78
78
|
|
|
79
|
+
class PrivateServerStatus(str, Enum):
|
|
80
|
+
"""Lifecycle of a private hosting server (v1 ``status``)."""
|
|
81
|
+
|
|
82
|
+
PENDING_PAYMENT = "pending_payment"
|
|
83
|
+
PROVISIONING = "provisioning"
|
|
84
|
+
READY = "ready"
|
|
85
|
+
RESIZING = "resizing"
|
|
86
|
+
EXPIRED = "expired"
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class PrivateAccountStatus(str, Enum):
|
|
90
|
+
"""Lifecycle of an account living on a private server (v1 ``status``)."""
|
|
91
|
+
|
|
92
|
+
PROVISIONING = "provisioning"
|
|
93
|
+
READY = "ready"
|
|
94
|
+
ERROR = "error"
|
|
95
|
+
EXPIRED = "expired"
|
|
96
|
+
|
|
97
|
+
|
|
79
98
|
class OrderKind(str, Enum):
|
|
80
99
|
"""Whether an opened row is a live position or a resting pending order."""
|
|
81
100
|
|
|
@@ -74,6 +74,24 @@ class DuplicateAccountError(FxSocketError):
|
|
|
74
74
|
"""This account is already linked (HTTP 409)."""
|
|
75
75
|
|
|
76
76
|
|
|
77
|
+
class SlotsFullError(FxSocketError):
|
|
78
|
+
"""Every purchased slot on the private server is taken (HTTP 409
|
|
79
|
+
``slots_full``). Raise the server's limit from the dashboard."""
|
|
80
|
+
|
|
81
|
+
def __init__(
|
|
82
|
+
self,
|
|
83
|
+
message: str,
|
|
84
|
+
*,
|
|
85
|
+
used: int | None = None,
|
|
86
|
+
cap: int | None = None,
|
|
87
|
+
**kw: Any,
|
|
88
|
+
):
|
|
89
|
+
super().__init__(message, **kw)
|
|
90
|
+
self.used = used
|
|
91
|
+
self.cap = cap
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
|
|
77
95
|
class ConnectFailedError(FxSocketError):
|
|
78
96
|
"""The broker rejected the login during account creation (HTTP 400).
|
|
79
97
|
|
|
@@ -145,6 +163,10 @@ def error_from_response(resp: httpx.Response) -> FxSocketError:
|
|
|
145
163
|
if status == 404:
|
|
146
164
|
return NotFoundError(message, **common)
|
|
147
165
|
if status == 409:
|
|
166
|
+
if code == "slots_full" and isinstance(body, dict):
|
|
167
|
+
return SlotsFullError(
|
|
168
|
+
message, used=body.get("used"), cap=body.get("cap"), **common
|
|
169
|
+
)
|
|
148
170
|
return DuplicateAccountError(message, **common)
|
|
149
171
|
if status == 402:
|
|
150
172
|
if code == "account_cap_reached" and isinstance(body, dict):
|
|
@@ -0,0 +1,264 @@
|
|
|
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, PrivateServer, PrivateServerAccount
|
|
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)}")
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
# --------------------------------------------------------------------------- #
|
|
122
|
+
# Private servers (``/v1/private-servers``)
|
|
123
|
+
# --------------------------------------------------------------------------- #
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def private_server_id_of(server: PrivateServer | str) -> str:
|
|
127
|
+
"""Accept either a :class:`PrivateServer` or a bare id string."""
|
|
128
|
+
return server.id if isinstance(server, PrivateServer) else str(server)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _private_account_payload(
|
|
132
|
+
*,
|
|
133
|
+
server: str,
|
|
134
|
+
login: int,
|
|
135
|
+
password: str,
|
|
136
|
+
platform: Platform | str,
|
|
137
|
+
nickname: str,
|
|
138
|
+
trade_ea_symbol: str,
|
|
139
|
+
) -> dict[str, object]:
|
|
140
|
+
return {
|
|
141
|
+
"platform": Platform(platform).value,
|
|
142
|
+
"server": server,
|
|
143
|
+
"login": login,
|
|
144
|
+
"password": password,
|
|
145
|
+
"nickname": nickname,
|
|
146
|
+
"trade_ea_symbol": trade_ea_symbol,
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
class PrivateServers:
|
|
151
|
+
"""Synchronous private-server operations.
|
|
152
|
+
|
|
153
|
+
Read + on-server account management only: purchasing a server,
|
|
154
|
+
canceling, and slot changes happen in the dashboard.
|
|
155
|
+
"""
|
|
156
|
+
|
|
157
|
+
def __init__(self, http: SyncHTTP) -> None:
|
|
158
|
+
self._http = http
|
|
159
|
+
|
|
160
|
+
def list(self) -> list[PrivateServer]:
|
|
161
|
+
"""List every private server owned by the authenticated user."""
|
|
162
|
+
data = self._http.request("GET", "/private-servers")
|
|
163
|
+
return [PrivateServer.model_validate(row) for row in data]
|
|
164
|
+
|
|
165
|
+
def get(self, server: PrivateServer | str) -> PrivateServer:
|
|
166
|
+
"""Fetch one server by id (use this to poll account readiness)."""
|
|
167
|
+
data = self._http.request(
|
|
168
|
+
"GET", f"/private-servers/{private_server_id_of(server)}"
|
|
169
|
+
)
|
|
170
|
+
return PrivateServer.model_validate(data)
|
|
171
|
+
|
|
172
|
+
def add_account(
|
|
173
|
+
self,
|
|
174
|
+
private_server: PrivateServer | str,
|
|
175
|
+
*,
|
|
176
|
+
server: str,
|
|
177
|
+
login: int,
|
|
178
|
+
password: str,
|
|
179
|
+
platform: Platform | str = Platform.MT5,
|
|
180
|
+
nickname: str = "",
|
|
181
|
+
trade_ea_symbol: str = "",
|
|
182
|
+
) -> PrivateServerAccount:
|
|
183
|
+
"""Connect an MT4/MT5 account onto the server.
|
|
184
|
+
|
|
185
|
+
The on-server agent brings the terminal up asynchronously — poll
|
|
186
|
+
:meth:`get` until the account's ``status`` reaches ``ready``.
|
|
187
|
+
Raises :class:`~fxsocket.SlotsFullError` when every purchased slot
|
|
188
|
+
is taken and :class:`~fxsocket.DuplicateAccountError` when the
|
|
189
|
+
account is already linked.
|
|
190
|
+
"""
|
|
191
|
+
data = self._http.request(
|
|
192
|
+
"POST",
|
|
193
|
+
f"/private-servers/{private_server_id_of(private_server)}/accounts",
|
|
194
|
+
json=_private_account_payload(
|
|
195
|
+
server=server,
|
|
196
|
+
login=login,
|
|
197
|
+
password=password,
|
|
198
|
+
platform=platform,
|
|
199
|
+
nickname=nickname,
|
|
200
|
+
trade_ea_symbol=trade_ea_symbol,
|
|
201
|
+
),
|
|
202
|
+
)
|
|
203
|
+
return PrivateServerAccount.model_validate(data)
|
|
204
|
+
|
|
205
|
+
def remove_account(
|
|
206
|
+
self,
|
|
207
|
+
private_server: PrivateServer | str,
|
|
208
|
+
account: PrivateServerAccount | str,
|
|
209
|
+
) -> None:
|
|
210
|
+
"""Detach an account from the server, freeing its slot."""
|
|
211
|
+
sid = private_server_id_of(private_server)
|
|
212
|
+
aid = account.id if isinstance(account, PrivateServerAccount) else str(account)
|
|
213
|
+
self._http.request("DELETE", f"/private-servers/{sid}/accounts/{aid}")
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
class AsyncPrivateServers:
|
|
217
|
+
"""Asynchronous mirror of :class:`PrivateServers`."""
|
|
218
|
+
|
|
219
|
+
def __init__(self, http: AsyncHTTP) -> None:
|
|
220
|
+
self._http = http
|
|
221
|
+
|
|
222
|
+
async def list(self) -> list[PrivateServer]:
|
|
223
|
+
data = await self._http.request("GET", "/private-servers")
|
|
224
|
+
return [PrivateServer.model_validate(row) for row in data]
|
|
225
|
+
|
|
226
|
+
async def get(self, server: PrivateServer | str) -> PrivateServer:
|
|
227
|
+
data = await self._http.request(
|
|
228
|
+
"GET", f"/private-servers/{private_server_id_of(server)}"
|
|
229
|
+
)
|
|
230
|
+
return PrivateServer.model_validate(data)
|
|
231
|
+
|
|
232
|
+
async def add_account(
|
|
233
|
+
self,
|
|
234
|
+
private_server: PrivateServer | str,
|
|
235
|
+
*,
|
|
236
|
+
server: str,
|
|
237
|
+
login: int,
|
|
238
|
+
password: str,
|
|
239
|
+
platform: Platform | str = Platform.MT5,
|
|
240
|
+
nickname: str = "",
|
|
241
|
+
trade_ea_symbol: str = "",
|
|
242
|
+
) -> PrivateServerAccount:
|
|
243
|
+
data = await self._http.request(
|
|
244
|
+
"POST",
|
|
245
|
+
f"/private-servers/{private_server_id_of(private_server)}/accounts",
|
|
246
|
+
json=_private_account_payload(
|
|
247
|
+
server=server,
|
|
248
|
+
login=login,
|
|
249
|
+
password=password,
|
|
250
|
+
platform=platform,
|
|
251
|
+
nickname=nickname,
|
|
252
|
+
trade_ea_symbol=trade_ea_symbol,
|
|
253
|
+
),
|
|
254
|
+
)
|
|
255
|
+
return PrivateServerAccount.model_validate(data)
|
|
256
|
+
|
|
257
|
+
async def remove_account(
|
|
258
|
+
self,
|
|
259
|
+
private_server: PrivateServer | str,
|
|
260
|
+
account: PrivateServerAccount | str,
|
|
261
|
+
) -> None:
|
|
262
|
+
sid = private_server_id_of(private_server)
|
|
263
|
+
aid = account.id if isinstance(account, PrivateServerAccount) else str(account)
|
|
264
|
+
await self._http.request("DELETE", f"/private-servers/{sid}/accounts/{aid}")
|