fxsocket 0.1__tar.gz → 0.3.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.1 → fxsocket-0.3.0}/PKG-INFO +55 -6
- {fxsocket-0.1 → fxsocket-0.3.0}/README.md +54 -5
- {fxsocket-0.1 → fxsocket-0.3.0}/src/fxsocket/__init__.py +12 -0
- fxsocket-0.3.0/src/fxsocket/_version.py +1 -0
- {fxsocket-0.1 → fxsocket-0.3.0}/src/fxsocket/client.py +14 -6
- {fxsocket-0.1 → fxsocket-0.3.0}/src/fxsocket/enums.py +34 -0
- {fxsocket-0.1 → fxsocket-0.3.0}/src/fxsocket/errors.py +22 -0
- fxsocket-0.3.0/src/fxsocket/management.py +264 -0
- {fxsocket-0.1 → fxsocket-0.3.0}/src/fxsocket/models.py +88 -2
- fxsocket-0.3.0/tests/test_private_servers.py +181 -0
- {fxsocket-0.1 → fxsocket-0.3.0}/tests/test_terminal.py +42 -0
- fxsocket-0.1/src/fxsocket/_version.py +0 -1
- fxsocket-0.1/src/fxsocket/management.py +0 -118
- {fxsocket-0.1 → fxsocket-0.3.0}/.github/workflows/ci.yml +0 -0
- {fxsocket-0.1 → fxsocket-0.3.0}/.github/workflows/publish.yml +0 -0
- {fxsocket-0.1 → fxsocket-0.3.0}/.gitignore +0 -0
- {fxsocket-0.1 → fxsocket-0.3.0}/LICENSE +0 -0
- {fxsocket-0.1 → fxsocket-0.3.0}/examples/manage_accounts.py +0 -0
- {fxsocket-0.1 → fxsocket-0.3.0}/examples/stream_quotes.py +0 -0
- {fxsocket-0.1 → fxsocket-0.3.0}/examples/terminal_rest.py +0 -0
- {fxsocket-0.1 → fxsocket-0.3.0}/pyproject.toml +0 -0
- {fxsocket-0.1 → fxsocket-0.3.0}/src/fxsocket/_http.py +0 -0
- {fxsocket-0.1 → fxsocket-0.3.0}/src/fxsocket/config.py +0 -0
- {fxsocket-0.1 → fxsocket-0.3.0}/src/fxsocket/py.typed +0 -0
- {fxsocket-0.1 → fxsocket-0.3.0}/src/fxsocket/terminal/__init__.py +0 -0
- {fxsocket-0.1 → fxsocket-0.3.0}/src/fxsocket/terminal/client.py +0 -0
- {fxsocket-0.1 → fxsocket-0.3.0}/src/fxsocket/terminal/stream.py +0 -0
- {fxsocket-0.1 → fxsocket-0.3.0}/tests/test_errors.py +0 -0
- {fxsocket-0.1 → fxsocket-0.3.0}/tests/test_management.py +0 -0
- {fxsocket-0.1 → fxsocket-0.3.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.3.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,6 +40,8 @@ interfaces.
|
|
|
40
40
|
## Features
|
|
41
41
|
|
|
42
42
|
- **Account management** — link, list, fetch, and disconnect MT4/MT5 accounts.
|
|
43
|
+
- **Private servers** — list your dedicated hosting servers and manage the
|
|
44
|
+
accounts on them.
|
|
43
45
|
- **Trading** — market & pending orders, modify, close, plus margin/profit calculators.
|
|
44
46
|
- **Market data** — quotes, symbol specifications, OHLC history, account state & info.
|
|
45
47
|
- **Live streaming** — ticks, bars, account, positions, trades, and terminal status
|
|
@@ -138,6 +140,23 @@ with Client(api_key="fxs_live_…") as fx:
|
|
|
138
140
|
term.order_close(result.order)
|
|
139
141
|
```
|
|
140
142
|
|
|
143
|
+
Every order call returns an `OrderResult`. A `200` only means the terminal
|
|
144
|
+
answered — check the body: `success` is true for `retcode` `10009` (done) or
|
|
145
|
+
`10008` (placed), and `outcome` classifies the result as `applied` /
|
|
146
|
+
`no_change` / `partial` / `rejected` (compare against `OrderOutcome`).
|
|
147
|
+
|
|
148
|
+
`no_change` (retcode `10025`) is a benign, idempotent no-op — the requested
|
|
149
|
+
SL/TP/price already match — so it's safe to treat as applied even though
|
|
150
|
+
`success` is `False`. For idempotent SL/TP management (e.g. re-sending after a
|
|
151
|
+
lost confirmation), send absolute values and gate on `result.is_effective`
|
|
152
|
+
(true for both `applied` and `no_change`):
|
|
153
|
+
|
|
154
|
+
```python
|
|
155
|
+
res = term.order_modify(ticket, stop_loss=1.0850)
|
|
156
|
+
if res.is_effective: # applied now, or already in effect
|
|
157
|
+
...
|
|
158
|
+
```
|
|
159
|
+
|
|
141
160
|
Inputs are validated client-side before they're sent. One guard worth knowing:
|
|
142
161
|
in `order_modify`, a literal `stop_loss=0.0` would *remove* your stop-loss, so
|
|
143
162
|
it's rejected — pass `clear_stop_loss=True` to remove one deliberately, while
|
|
@@ -221,11 +240,41 @@ except AccountCapError as e:
|
|
|
221
240
|
|
|
222
241
|
## Private hosting
|
|
223
242
|
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
243
|
+
Dedicated private servers are managed through `client.private_servers`:
|
|
244
|
+
|
|
245
|
+
```python
|
|
246
|
+
import time
|
|
247
|
+
|
|
248
|
+
from fxsocket import Client, PrivateAccountStatus, SlotsFullError
|
|
249
|
+
|
|
250
|
+
with Client(api_key="fxs_live_...", verify_terminal_tls=False) as fx:
|
|
251
|
+
[server] = fx.private_servers.list()
|
|
252
|
+
print(server.name, server.status, f"{server.used_slots}/{server.purchased_slots}")
|
|
253
|
+
|
|
254
|
+
try:
|
|
255
|
+
account = fx.private_servers.add_account(
|
|
256
|
+
server, server="ICMarkets-Demo", login=1150125, password="..."
|
|
257
|
+
)
|
|
258
|
+
except SlotsFullError as err:
|
|
259
|
+
print(f"Server full ({err.used}/{err.cap}) — raise the limit in the dashboard.")
|
|
260
|
+
|
|
261
|
+
# Poll until the on-server agent has the terminal up, then trade as usual.
|
|
262
|
+
while True:
|
|
263
|
+
server = fx.private_servers.get(server)
|
|
264
|
+
account = next(a for a in server.accounts if a.id == account.id)
|
|
265
|
+
if account.status == PrivateAccountStatus.READY:
|
|
266
|
+
break
|
|
267
|
+
time.sleep(5)
|
|
268
|
+
|
|
269
|
+
print(fx.terminal(account).account_summary())
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
Accounts on a private server are traded and streamed exactly like
|
|
273
|
+
shared-cluster accounts — their `rest_url` / `ws_url` simply point at the
|
|
274
|
+
server's dedicated IP. The server presents a self-signed certificate, so reach
|
|
275
|
+
it with `Client(..., verify_terminal_tls=False)` (or supply a pinned CA).
|
|
276
|
+
*Purchasing* a server, canceling, and slot changes happen in the dashboard;
|
|
277
|
+
the API deliberately exposes no billing operations.
|
|
229
278
|
|
|
230
279
|
## Timestamps
|
|
231
280
|
|
|
@@ -13,6 +13,8 @@ interfaces.
|
|
|
13
13
|
## Features
|
|
14
14
|
|
|
15
15
|
- **Account management** — link, list, fetch, and disconnect MT4/MT5 accounts.
|
|
16
|
+
- **Private servers** — list your dedicated hosting servers and manage the
|
|
17
|
+
accounts on them.
|
|
16
18
|
- **Trading** — market & pending orders, modify, close, plus margin/profit calculators.
|
|
17
19
|
- **Market data** — quotes, symbol specifications, OHLC history, account state & info.
|
|
18
20
|
- **Live streaming** — ticks, bars, account, positions, trades, and terminal status
|
|
@@ -111,6 +113,23 @@ with Client(api_key="fxs_live_…") as fx:
|
|
|
111
113
|
term.order_close(result.order)
|
|
112
114
|
```
|
|
113
115
|
|
|
116
|
+
Every order call returns an `OrderResult`. A `200` only means the terminal
|
|
117
|
+
answered — check the body: `success` is true for `retcode` `10009` (done) or
|
|
118
|
+
`10008` (placed), and `outcome` classifies the result as `applied` /
|
|
119
|
+
`no_change` / `partial` / `rejected` (compare against `OrderOutcome`).
|
|
120
|
+
|
|
121
|
+
`no_change` (retcode `10025`) is a benign, idempotent no-op — the requested
|
|
122
|
+
SL/TP/price already match — so it's safe to treat as applied even though
|
|
123
|
+
`success` is `False`. For idempotent SL/TP management (e.g. re-sending after a
|
|
124
|
+
lost confirmation), send absolute values and gate on `result.is_effective`
|
|
125
|
+
(true for both `applied` and `no_change`):
|
|
126
|
+
|
|
127
|
+
```python
|
|
128
|
+
res = term.order_modify(ticket, stop_loss=1.0850)
|
|
129
|
+
if res.is_effective: # applied now, or already in effect
|
|
130
|
+
...
|
|
131
|
+
```
|
|
132
|
+
|
|
114
133
|
Inputs are validated client-side before they're sent. One guard worth knowing:
|
|
115
134
|
in `order_modify`, a literal `stop_loss=0.0` would *remove* your stop-loss, so
|
|
116
135
|
it's rejected — pass `clear_stop_loss=True` to remove one deliberately, while
|
|
@@ -194,11 +213,41 @@ except AccountCapError as e:
|
|
|
194
213
|
|
|
195
214
|
## Private hosting
|
|
196
215
|
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
216
|
+
Dedicated private servers are managed through `client.private_servers`:
|
|
217
|
+
|
|
218
|
+
```python
|
|
219
|
+
import time
|
|
220
|
+
|
|
221
|
+
from fxsocket import Client, PrivateAccountStatus, SlotsFullError
|
|
222
|
+
|
|
223
|
+
with Client(api_key="fxs_live_...", verify_terminal_tls=False) as fx:
|
|
224
|
+
[server] = fx.private_servers.list()
|
|
225
|
+
print(server.name, server.status, f"{server.used_slots}/{server.purchased_slots}")
|
|
226
|
+
|
|
227
|
+
try:
|
|
228
|
+
account = fx.private_servers.add_account(
|
|
229
|
+
server, server="ICMarkets-Demo", login=1150125, password="..."
|
|
230
|
+
)
|
|
231
|
+
except SlotsFullError as err:
|
|
232
|
+
print(f"Server full ({err.used}/{err.cap}) — raise the limit in the dashboard.")
|
|
233
|
+
|
|
234
|
+
# Poll until the on-server agent has the terminal up, then trade as usual.
|
|
235
|
+
while True:
|
|
236
|
+
server = fx.private_servers.get(server)
|
|
237
|
+
account = next(a for a in server.accounts if a.id == account.id)
|
|
238
|
+
if account.status == PrivateAccountStatus.READY:
|
|
239
|
+
break
|
|
240
|
+
time.sleep(5)
|
|
241
|
+
|
|
242
|
+
print(fx.terminal(account).account_summary())
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
Accounts on a private server are traded and streamed exactly like
|
|
246
|
+
shared-cluster accounts — their `rest_url` / `ws_url` simply point at the
|
|
247
|
+
server's dedicated IP. The server presents a self-signed certificate, so reach
|
|
248
|
+
it with `Client(..., verify_terminal_tls=False)` (or supply a pinned CA).
|
|
249
|
+
*Purchasing* a server, canceling, and slot changes happen in the dashboard;
|
|
250
|
+
the API deliberately exposes no billing operations.
|
|
202
251
|
|
|
203
252
|
## Timestamps
|
|
204
253
|
|
|
@@ -14,7 +14,10 @@ from .enums import (
|
|
|
14
14
|
HealthStatus,
|
|
15
15
|
OrderKind,
|
|
16
16
|
OrderOperation,
|
|
17
|
+
OrderOutcome,
|
|
17
18
|
Platform,
|
|
19
|
+
PrivateAccountStatus,
|
|
20
|
+
PrivateServerStatus,
|
|
18
21
|
Timeframe,
|
|
19
22
|
TradingStatus,
|
|
20
23
|
)
|
|
@@ -27,6 +30,7 @@ from .errors import (
|
|
|
27
30
|
NoSubscriptionError,
|
|
28
31
|
NotFoundError,
|
|
29
32
|
RateLimitError,
|
|
33
|
+
SlotsFullError,
|
|
30
34
|
StreamError,
|
|
31
35
|
TerminalNotReadyError,
|
|
32
36
|
TerminalTimeoutError,
|
|
@@ -45,6 +49,8 @@ from .models import (
|
|
|
45
49
|
OpenedOrder,
|
|
46
50
|
OrderResult,
|
|
47
51
|
PositionTrade,
|
|
52
|
+
PrivateServer,
|
|
53
|
+
PrivateServerAccount,
|
|
48
54
|
ProfitCalc,
|
|
49
55
|
Quote,
|
|
50
56
|
ServerTimezone,
|
|
@@ -96,6 +102,8 @@ __all__ = [
|
|
|
96
102
|
"TradeEventData",
|
|
97
103
|
"TerminalStatusData",
|
|
98
104
|
"Account",
|
|
105
|
+
"PrivateServer",
|
|
106
|
+
"PrivateServerAccount",
|
|
99
107
|
"AccountSummary",
|
|
100
108
|
"AccountInfo",
|
|
101
109
|
"OpenedOrder",
|
|
@@ -111,8 +119,11 @@ __all__ = [
|
|
|
111
119
|
"Health",
|
|
112
120
|
"HealthChecks",
|
|
113
121
|
"Platform",
|
|
122
|
+
"PrivateAccountStatus",
|
|
123
|
+
"PrivateServerStatus",
|
|
114
124
|
"TradingStatus",
|
|
115
125
|
"OrderOperation",
|
|
126
|
+
"OrderOutcome",
|
|
116
127
|
"OrderKind",
|
|
117
128
|
"DealEntry",
|
|
118
129
|
"HealthStatus",
|
|
@@ -126,6 +137,7 @@ __all__ = [
|
|
|
126
137
|
"AccountCapError",
|
|
127
138
|
"NoSubscriptionError",
|
|
128
139
|
"DuplicateAccountError",
|
|
140
|
+
"SlotsFullError",
|
|
129
141
|
"ConnectFailedError",
|
|
130
142
|
"TerminalNotReadyError",
|
|
131
143
|
"TerminalTimeoutError",
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.3.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,
|
|
@@ -61,6 +61,40 @@ PENDING_OPERATIONS = frozenset(
|
|
|
61
61
|
)
|
|
62
62
|
|
|
63
63
|
|
|
64
|
+
class OrderOutcome(str, Enum):
|
|
65
|
+
"""Semantic classification of a trade result (``OrderResult.outcome``).
|
|
66
|
+
|
|
67
|
+
``success`` only tells you applied-or-not; ``outcome`` additionally
|
|
68
|
+
separates a benign no-op and a partial fill from a genuine rejection, so
|
|
69
|
+
clients don't have to hardcode retcode tables. Empty on bridges older than
|
|
70
|
+
MT5 0.6.1 / MT4 0.5.1.
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
APPLIED = "applied" #: retcode 10009 (done) / 10008 (placed)
|
|
74
|
+
NO_CHANGE = "no_change" #: retcode 10025 — requested state already in effect
|
|
75
|
+
PARTIAL = "partial" #: retcode 10010 (done partially)
|
|
76
|
+
REJECTED = "rejected" #: anything else — inspect ``retcode`` / ``comment``
|
|
77
|
+
|
|
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
|
+
|
|
64
98
|
class OrderKind(str, Enum):
|
|
65
99
|
"""Whether an opened row is a live position or a resting pending order."""
|
|
66
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}")
|
|
@@ -24,7 +24,7 @@ from datetime import datetime
|
|
|
24
24
|
from pydantic import BaseModel, ConfigDict
|
|
25
25
|
from pydantic.alias_generators import to_camel
|
|
26
26
|
|
|
27
|
-
from .enums import Platform, TradingStatus
|
|
27
|
+
from .enums import OrderOutcome, Platform, TradingStatus
|
|
28
28
|
|
|
29
29
|
|
|
30
30
|
class _Camel(BaseModel):
|
|
@@ -70,6 +70,67 @@ class Account(BaseModel):
|
|
|
70
70
|
return bool(self.rest_url)
|
|
71
71
|
|
|
72
72
|
|
|
73
|
+
class PrivateServerAccount(BaseModel):
|
|
74
|
+
"""An MT4/MT5 account living on a private server (v1 API).
|
|
75
|
+
|
|
76
|
+
``status`` is the private-hosting lifecycle — compare against
|
|
77
|
+
:class:`fxsocket.PrivateAccountStatus` (provisioning / ready / error /
|
|
78
|
+
expired). ``rest_url`` / ``ws_url`` are the account's terminal API on
|
|
79
|
+
the server's dedicated IP. Private servers use a self-signed TLS
|
|
80
|
+
certificate, so pass ``verify=False`` (or construct the client with
|
|
81
|
+
``verify_terminal_tls=False``) when calling ``client.terminal(...)``.
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
model_config = ConfigDict(populate_by_name=True, extra="ignore")
|
|
85
|
+
|
|
86
|
+
id: str
|
|
87
|
+
nickname: str = ""
|
|
88
|
+
platform: Platform
|
|
89
|
+
server: str
|
|
90
|
+
login: int
|
|
91
|
+
status: str
|
|
92
|
+
rest_url: str = ""
|
|
93
|
+
ws_url: str = ""
|
|
94
|
+
trade_ea_symbol: str = ""
|
|
95
|
+
created_at: datetime
|
|
96
|
+
|
|
97
|
+
@property
|
|
98
|
+
def has_terminal(self) -> bool:
|
|
99
|
+
"""True when this account exposes a reachable terminal API."""
|
|
100
|
+
return bool(self.rest_url)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class PrivateServer(BaseModel):
|
|
104
|
+
"""A dedicated private hosting server (v1 API).
|
|
105
|
+
|
|
106
|
+
``status`` is the server lifecycle — compare against
|
|
107
|
+
:class:`fxsocket.PrivateServerStatus`. ``purchased_slots`` is the paid
|
|
108
|
+
limit; ``used_slots`` how many accounts currently live on the server.
|
|
109
|
+
Purchasing, canceling and slot changes happen in the dashboard, not
|
|
110
|
+
the API.
|
|
111
|
+
"""
|
|
112
|
+
|
|
113
|
+
model_config = ConfigDict(populate_by_name=True, extra="ignore")
|
|
114
|
+
|
|
115
|
+
id: str
|
|
116
|
+
name: str = ""
|
|
117
|
+
status: str
|
|
118
|
+
region: str = ""
|
|
119
|
+
ip: str = ""
|
|
120
|
+
purchased_slots: int = 0
|
|
121
|
+
used_slots: int = 0
|
|
122
|
+
period_end: datetime | None = None
|
|
123
|
+
accounts: list[PrivateServerAccount] = []
|
|
124
|
+
|
|
125
|
+
@property
|
|
126
|
+
def is_ready(self) -> bool:
|
|
127
|
+
return self.status == "ready"
|
|
128
|
+
|
|
129
|
+
@property
|
|
130
|
+
def free_slots(self) -> int:
|
|
131
|
+
return max(self.purchased_slots - self.used_slots, 0)
|
|
132
|
+
|
|
133
|
+
|
|
73
134
|
# --------------------------------------------------------------------------- #
|
|
74
135
|
# Terminal — account state
|
|
75
136
|
# --------------------------------------------------------------------------- #
|
|
@@ -257,12 +318,23 @@ class Candle(_Camel):
|
|
|
257
318
|
class OrderResult(_Camel):
|
|
258
319
|
"""Result of an order send / modify / close.
|
|
259
320
|
|
|
260
|
-
``success`` is true when ``retcode`` is DONE (10009) or PLACED (
|
|
321
|
+
``success`` is true when ``retcode`` is DONE (10009) or PLACED (10008).
|
|
322
|
+
|
|
323
|
+
``outcome`` classifies the result further — ``"applied"`` /
|
|
324
|
+
``"no_change"`` / ``"partial"`` / ``"rejected"`` (compare against
|
|
325
|
+
:class:`fxsocket.OrderOutcome`). ``"no_change"`` (retcode 10025) is a
|
|
326
|
+
benign idempotent no-op — the requested SL/TP/price already match the
|
|
327
|
+
current values — so it is safe to treat as applied even though ``success``
|
|
328
|
+
is ``False``. Use :attr:`is_effective` when you only care that the
|
|
329
|
+
requested state is in effect (the idempotent-retry case). ``outcome`` is
|
|
330
|
+
empty on bridges older than MT5 0.6.1 / MT4 0.5.1.
|
|
331
|
+
|
|
261
332
|
``deal`` is the executed deal ticket (0 for pending placement, and always
|
|
262
333
|
0 on MT4); ``order`` is the resulting position / pending-order ticket.
|
|
263
334
|
"""
|
|
264
335
|
|
|
265
336
|
success: bool
|
|
337
|
+
outcome: str = ""
|
|
266
338
|
retcode: int
|
|
267
339
|
retcode_description: str
|
|
268
340
|
deal: int
|
|
@@ -273,6 +345,20 @@ class OrderResult(_Camel):
|
|
|
273
345
|
ask: float
|
|
274
346
|
comment: str
|
|
275
347
|
|
|
348
|
+
@property
|
|
349
|
+
def is_no_change(self) -> bool:
|
|
350
|
+
"""True for a benign no-op (retcode 10025 / ``outcome == "no_change"``):
|
|
351
|
+
the requested SL/TP/price already match the current values."""
|
|
352
|
+
return self.retcode == 10025 or self.outcome == OrderOutcome.NO_CHANGE
|
|
353
|
+
|
|
354
|
+
@property
|
|
355
|
+
def is_effective(self) -> bool:
|
|
356
|
+
"""True when the requested state is in effect — either ``success``
|
|
357
|
+
(applied) or a no-op (:attr:`is_no_change`). Use this for idempotent
|
|
358
|
+
SL/TP management, where re-sending an identical modify returns 10025
|
|
359
|
+
with ``success=False``."""
|
|
360
|
+
return self.success or self.is_no_change
|
|
361
|
+
|
|
276
362
|
|
|
277
363
|
class MarginCalc(_Camel):
|
|
278
364
|
"""Required margin for a hypothetical order (``GET /OrderCalcMargin``)."""
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
"""Tests for the private-server management client (sync + async)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
import pytest
|
|
7
|
+
import respx
|
|
8
|
+
|
|
9
|
+
from fxsocket import (
|
|
10
|
+
AsyncClient,
|
|
11
|
+
Client,
|
|
12
|
+
DuplicateAccountError,
|
|
13
|
+
PrivateAccountStatus,
|
|
14
|
+
PrivateServerStatus,
|
|
15
|
+
SlotsFullError,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
BASE = "https://api.fxsocket.com/v1"
|
|
19
|
+
|
|
20
|
+
SERVER_ID = "ecf2fa95-4177-4402-8a00-dea33ae0e79a"
|
|
21
|
+
|
|
22
|
+
SERVER_ACCOUNT = {
|
|
23
|
+
"id": "22222222-2222-2222-2222-222222222222",
|
|
24
|
+
"nickname": "prop-1",
|
|
25
|
+
"platform": "mt5",
|
|
26
|
+
"server": "ICMarkets-Demo",
|
|
27
|
+
"login": 7001,
|
|
28
|
+
"status": "ready",
|
|
29
|
+
"rest_url": f"https://159.223.244.125/22222222-2222-2222-2222-222222222222",
|
|
30
|
+
"ws_url": f"wss://159.223.244.125/22222222-2222-2222-2222-222222222222/ws",
|
|
31
|
+
"trade_ea_symbol": "",
|
|
32
|
+
"created_at": "2026-07-16T08:00:00Z",
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
SERVER = {
|
|
36
|
+
"id": SERVER_ID,
|
|
37
|
+
"name": "My Prop Guard",
|
|
38
|
+
"status": "ready",
|
|
39
|
+
"region": "lon1",
|
|
40
|
+
"ip": "159.223.244.125",
|
|
41
|
+
"purchased_slots": 2,
|
|
42
|
+
"used_slots": 1,
|
|
43
|
+
"period_end": "2026-08-16T07:01:08Z",
|
|
44
|
+
"accounts": [SERVER_ACCOUNT],
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _client() -> Client:
|
|
49
|
+
return Client(api_key="fxs_live_test")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@respx.mock
|
|
53
|
+
def test_list_servers_parses_models() -> None:
|
|
54
|
+
respx.get(f"{BASE}/private-servers").mock(
|
|
55
|
+
return_value=httpx.Response(200, json=[SERVER])
|
|
56
|
+
)
|
|
57
|
+
with _client() as fx:
|
|
58
|
+
[server] = fx.private_servers.list()
|
|
59
|
+
assert server.name == "My Prop Guard"
|
|
60
|
+
assert server.status == PrivateServerStatus.READY
|
|
61
|
+
assert server.is_ready
|
|
62
|
+
assert server.ip == "159.223.244.125"
|
|
63
|
+
assert server.free_slots == 1
|
|
64
|
+
[account] = server.accounts
|
|
65
|
+
assert account.status == PrivateAccountStatus.READY
|
|
66
|
+
assert account.has_terminal
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@respx.mock
|
|
70
|
+
def test_get_accepts_model_or_id() -> None:
|
|
71
|
+
route = respx.get(f"{BASE}/private-servers/{SERVER_ID}").mock(
|
|
72
|
+
return_value=httpx.Response(200, json=SERVER)
|
|
73
|
+
)
|
|
74
|
+
with _client() as fx:
|
|
75
|
+
by_id = fx.private_servers.get(SERVER_ID)
|
|
76
|
+
by_model = fx.private_servers.get(by_id)
|
|
77
|
+
assert route.call_count == 2
|
|
78
|
+
assert by_model.id == SERVER_ID
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@respx.mock
|
|
82
|
+
def test_add_account_payload_and_model() -> None:
|
|
83
|
+
route = respx.post(f"{BASE}/private-servers/{SERVER_ID}/accounts").mock(
|
|
84
|
+
return_value=httpx.Response(201, json=SERVER_ACCOUNT)
|
|
85
|
+
)
|
|
86
|
+
with _client() as fx:
|
|
87
|
+
account = fx.private_servers.add_account(
|
|
88
|
+
SERVER_ID,
|
|
89
|
+
server="ICMarkets-Demo",
|
|
90
|
+
login=7001,
|
|
91
|
+
password="pw",
|
|
92
|
+
nickname="prop-1",
|
|
93
|
+
)
|
|
94
|
+
import json
|
|
95
|
+
|
|
96
|
+
sent = json.loads(route.calls.last.request.content)
|
|
97
|
+
assert sent == {
|
|
98
|
+
"platform": "mt5",
|
|
99
|
+
"server": "ICMarkets-Demo",
|
|
100
|
+
"login": 7001,
|
|
101
|
+
"password": "pw",
|
|
102
|
+
"nickname": "prop-1",
|
|
103
|
+
"trade_ea_symbol": "",
|
|
104
|
+
}
|
|
105
|
+
assert account.login == 7001
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@respx.mock
|
|
109
|
+
def test_slots_full_maps_to_typed_error() -> None:
|
|
110
|
+
respx.post(f"{BASE}/private-servers/{SERVER_ID}/accounts").mock(
|
|
111
|
+
return_value=httpx.Response(
|
|
112
|
+
409,
|
|
113
|
+
json={
|
|
114
|
+
"error": "slots_full",
|
|
115
|
+
"detail": "Server is full (2/2).",
|
|
116
|
+
"used": 2,
|
|
117
|
+
"cap": 2,
|
|
118
|
+
},
|
|
119
|
+
)
|
|
120
|
+
)
|
|
121
|
+
with _client() as fx:
|
|
122
|
+
with pytest.raises(SlotsFullError) as err:
|
|
123
|
+
fx.private_servers.add_account(
|
|
124
|
+
SERVER_ID, server="Demo", login=1, password="pw"
|
|
125
|
+
)
|
|
126
|
+
assert err.value.used == 2
|
|
127
|
+
assert err.value.cap == 2
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@respx.mock
|
|
131
|
+
def test_duplicate_still_maps_to_duplicate_error() -> None:
|
|
132
|
+
respx.post(f"{BASE}/private-servers/{SERVER_ID}/accounts").mock(
|
|
133
|
+
return_value=httpx.Response(
|
|
134
|
+
409, json={"error": "duplicate", "detail": "Already linked."}
|
|
135
|
+
)
|
|
136
|
+
)
|
|
137
|
+
with _client() as fx:
|
|
138
|
+
with pytest.raises(DuplicateAccountError):
|
|
139
|
+
fx.private_servers.add_account(
|
|
140
|
+
SERVER_ID, server="Demo", login=1, password="pw"
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
@respx.mock
|
|
145
|
+
def test_remove_account() -> None:
|
|
146
|
+
account_id = SERVER_ACCOUNT["id"]
|
|
147
|
+
route = respx.delete(
|
|
148
|
+
f"{BASE}/private-servers/{SERVER_ID}/accounts/{account_id}"
|
|
149
|
+
).mock(return_value=httpx.Response(204))
|
|
150
|
+
with _client() as fx:
|
|
151
|
+
fx.private_servers.remove_account(SERVER_ID, account_id)
|
|
152
|
+
assert route.called
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
@respx.mock
|
|
156
|
+
def test_terminal_client_from_private_account() -> None:
|
|
157
|
+
respx.get(f"{BASE}/private-servers").mock(
|
|
158
|
+
return_value=httpx.Response(200, json=[SERVER])
|
|
159
|
+
)
|
|
160
|
+
with _client() as fx:
|
|
161
|
+
[server] = fx.private_servers.list()
|
|
162
|
+
term = fx.terminal(server.accounts[0], verify=False)
|
|
163
|
+
assert term is not None
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
@pytest.mark.asyncio
|
|
167
|
+
@respx.mock
|
|
168
|
+
async def test_async_mirror() -> None:
|
|
169
|
+
respx.get(f"{BASE}/private-servers").mock(
|
|
170
|
+
return_value=httpx.Response(200, json=[SERVER])
|
|
171
|
+
)
|
|
172
|
+
respx.post(f"{BASE}/private-servers/{SERVER_ID}/accounts").mock(
|
|
173
|
+
return_value=httpx.Response(201, json=SERVER_ACCOUNT)
|
|
174
|
+
)
|
|
175
|
+
async with AsyncClient(api_key="fxs_live_test") as fx:
|
|
176
|
+
[server] = await fx.private_servers.list()
|
|
177
|
+
account = await fx.private_servers.add_account(
|
|
178
|
+
server, server="ICMarkets-Demo", login=7001, password="pw"
|
|
179
|
+
)
|
|
180
|
+
assert server.is_ready
|
|
181
|
+
assert account.login == 7001
|
|
@@ -299,6 +299,48 @@ def test_order_send_allows_stop_limit_on_mt4() -> None:
|
|
|
299
299
|
assert sent["stopLimitPrice"] == 1.09
|
|
300
300
|
|
|
301
301
|
|
|
302
|
+
@respx.mock
|
|
303
|
+
def test_order_modify_no_change_is_effective() -> None:
|
|
304
|
+
# An idempotent re-send (SL/TP already match) returns retcode 10025 with
|
|
305
|
+
# success=False; outcome distinguishes the benign no-op from a rejection,
|
|
306
|
+
# and is_effective treats it as "requested state is in effect".
|
|
307
|
+
respx.post(f"{TERM}/OrderModify").mock(
|
|
308
|
+
return_value=httpx.Response(
|
|
309
|
+
200,
|
|
310
|
+
json={
|
|
311
|
+
"success": False,
|
|
312
|
+
"outcome": "no_change",
|
|
313
|
+
"retcode": 10025,
|
|
314
|
+
"retcodeDescription": "No changes",
|
|
315
|
+
"deal": 0,
|
|
316
|
+
"order": 100,
|
|
317
|
+
"volume": 0.1,
|
|
318
|
+
"price": 0.0,
|
|
319
|
+
"bid": 1.0849,
|
|
320
|
+
"ask": 1.0851,
|
|
321
|
+
"comment": "No changes",
|
|
322
|
+
},
|
|
323
|
+
),
|
|
324
|
+
)
|
|
325
|
+
with _term() as t:
|
|
326
|
+
res = t.order_modify(100, stop_loss=1.07)
|
|
327
|
+
assert res.success is False
|
|
328
|
+
assert res.outcome == "no_change"
|
|
329
|
+
assert res.is_no_change is True
|
|
330
|
+
assert res.is_effective is True
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def test_order_result_outcome_absent_defaults_empty() -> None:
|
|
334
|
+
# Bridges older than MT5 0.6.1 / MT4 0.5.1 omit `outcome` — it must default
|
|
335
|
+
# to "" (backward-compatible), not fail validation.
|
|
336
|
+
from fxsocket import OrderResult
|
|
337
|
+
|
|
338
|
+
res = OrderResult.model_validate(_ORDER_OK)
|
|
339
|
+
assert res.outcome == ""
|
|
340
|
+
assert res.success is True
|
|
341
|
+
assert res.is_effective is True
|
|
342
|
+
|
|
343
|
+
|
|
302
344
|
def test_order_send_rejects_nonpositive_volume() -> None:
|
|
303
345
|
from fxsocket import ValidationError
|
|
304
346
|
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.1"
|
|
@@ -1,118 +0,0 @@
|
|
|
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)}")
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|