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.
Files changed (30) hide show
  1. {fxsocket-0.1 → fxsocket-0.3.0}/PKG-INFO +55 -6
  2. {fxsocket-0.1 → fxsocket-0.3.0}/README.md +54 -5
  3. {fxsocket-0.1 → fxsocket-0.3.0}/src/fxsocket/__init__.py +12 -0
  4. fxsocket-0.3.0/src/fxsocket/_version.py +1 -0
  5. {fxsocket-0.1 → fxsocket-0.3.0}/src/fxsocket/client.py +14 -6
  6. {fxsocket-0.1 → fxsocket-0.3.0}/src/fxsocket/enums.py +34 -0
  7. {fxsocket-0.1 → fxsocket-0.3.0}/src/fxsocket/errors.py +22 -0
  8. fxsocket-0.3.0/src/fxsocket/management.py +264 -0
  9. {fxsocket-0.1 → fxsocket-0.3.0}/src/fxsocket/models.py +88 -2
  10. fxsocket-0.3.0/tests/test_private_servers.py +181 -0
  11. {fxsocket-0.1 → fxsocket-0.3.0}/tests/test_terminal.py +42 -0
  12. fxsocket-0.1/src/fxsocket/_version.py +0 -1
  13. fxsocket-0.1/src/fxsocket/management.py +0 -118
  14. {fxsocket-0.1 → fxsocket-0.3.0}/.github/workflows/ci.yml +0 -0
  15. {fxsocket-0.1 → fxsocket-0.3.0}/.github/workflows/publish.yml +0 -0
  16. {fxsocket-0.1 → fxsocket-0.3.0}/.gitignore +0 -0
  17. {fxsocket-0.1 → fxsocket-0.3.0}/LICENSE +0 -0
  18. {fxsocket-0.1 → fxsocket-0.3.0}/examples/manage_accounts.py +0 -0
  19. {fxsocket-0.1 → fxsocket-0.3.0}/examples/stream_quotes.py +0 -0
  20. {fxsocket-0.1 → fxsocket-0.3.0}/examples/terminal_rest.py +0 -0
  21. {fxsocket-0.1 → fxsocket-0.3.0}/pyproject.toml +0 -0
  22. {fxsocket-0.1 → fxsocket-0.3.0}/src/fxsocket/_http.py +0 -0
  23. {fxsocket-0.1 → fxsocket-0.3.0}/src/fxsocket/config.py +0 -0
  24. {fxsocket-0.1 → fxsocket-0.3.0}/src/fxsocket/py.typed +0 -0
  25. {fxsocket-0.1 → fxsocket-0.3.0}/src/fxsocket/terminal/__init__.py +0 -0
  26. {fxsocket-0.1 → fxsocket-0.3.0}/src/fxsocket/terminal/client.py +0 -0
  27. {fxsocket-0.1 → fxsocket-0.3.0}/src/fxsocket/terminal/stream.py +0 -0
  28. {fxsocket-0.1 → fxsocket-0.3.0}/tests/test_errors.py +0 -0
  29. {fxsocket-0.1 → fxsocket-0.3.0}/tests/test_management.py +0 -0
  30. {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.1
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
- Privately-hosted accounts (a dedicated droplet) are listed, traded, and streamed
225
- exactly like shared-cluster accounts — their `rest_url` / `ws_url` simply point
226
- at the droplet. The droplet serves a self-signed certificate, so reach it with
227
- `Client(..., verify_terminal_tls=False)` (or supply a pinned CA). *Creating* a
228
- private-hosted account is done in the dashboard.
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
- Privately-hosted accounts (a dedicated droplet) are listed, traded, and streamed
198
- exactly like shared-cluster accounts — their `rest_url` / `ws_url` simply point
199
- at the droplet. The droplet serves a self-signed certificate, so reach it with
200
- `Client(..., verify_terminal_tls=False)` (or supply a pinned CA). *Creating* a
201
- private-hosted account is done in the dashboard.
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 Accounts, AsyncAccounts
19
- from .models import Account
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 (10010).
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