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.
Files changed (30) hide show
  1. {fxsocket-0.2 → fxsocket-0.4.0}/PKG-INFO +56 -8
  2. {fxsocket-0.2 → fxsocket-0.4.0}/README.md +55 -7
  3. {fxsocket-0.2 → fxsocket-0.4.0}/src/fxsocket/__init__.py +20 -0
  4. fxsocket-0.4.0/src/fxsocket/_version.py +1 -0
  5. {fxsocket-0.2 → fxsocket-0.4.0}/src/fxsocket/client.py +14 -6
  6. {fxsocket-0.2 → fxsocket-0.4.0}/src/fxsocket/enums.py +19 -0
  7. {fxsocket-0.2 → fxsocket-0.4.0}/src/fxsocket/errors.py +22 -0
  8. fxsocket-0.4.0/src/fxsocket/management.py +264 -0
  9. {fxsocket-0.2 → fxsocket-0.4.0}/src/fxsocket/models.py +164 -2
  10. {fxsocket-0.2 → fxsocket-0.4.0}/src/fxsocket/terminal/client.py +56 -0
  11. fxsocket-0.4.0/tests/test_private_servers.py +181 -0
  12. {fxsocket-0.2 → fxsocket-0.4.0}/tests/test_terminal.py +154 -0
  13. fxsocket-0.2/src/fxsocket/_version.py +0 -1
  14. fxsocket-0.2/src/fxsocket/management.py +0 -118
  15. {fxsocket-0.2 → fxsocket-0.4.0}/.github/workflows/ci.yml +0 -0
  16. {fxsocket-0.2 → fxsocket-0.4.0}/.github/workflows/publish.yml +0 -0
  17. {fxsocket-0.2 → fxsocket-0.4.0}/.gitignore +0 -0
  18. {fxsocket-0.2 → fxsocket-0.4.0}/LICENSE +0 -0
  19. {fxsocket-0.2 → fxsocket-0.4.0}/examples/manage_accounts.py +0 -0
  20. {fxsocket-0.2 → fxsocket-0.4.0}/examples/stream_quotes.py +0 -0
  21. {fxsocket-0.2 → fxsocket-0.4.0}/examples/terminal_rest.py +0 -0
  22. {fxsocket-0.2 → fxsocket-0.4.0}/pyproject.toml +0 -0
  23. {fxsocket-0.2 → fxsocket-0.4.0}/src/fxsocket/_http.py +0 -0
  24. {fxsocket-0.2 → fxsocket-0.4.0}/src/fxsocket/config.py +0 -0
  25. {fxsocket-0.2 → fxsocket-0.4.0}/src/fxsocket/py.typed +0 -0
  26. {fxsocket-0.2 → fxsocket-0.4.0}/src/fxsocket/terminal/__init__.py +0 -0
  27. {fxsocket-0.2 → fxsocket-0.4.0}/src/fxsocket/terminal/stream.py +0 -0
  28. {fxsocket-0.2 → fxsocket-0.4.0}/tests/test_errors.py +0 -0
  29. {fxsocket-0.2 → fxsocket-0.4.0}/tests/test_management.py +0 -0
  30. {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.2
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
- - **Trading** — market & pending orders, modify, close, plus margin/profit calculators.
44
- - **Market data** — quotes, symbol specifications, OHLC history, account state & info.
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
- Privately-hosted accounts (a dedicated droplet) are listed, traded, and streamed
242
- exactly like shared-cluster accounts — their `rest_url` / `ws_url` simply point
243
- at the droplet. The droplet serves a self-signed certificate, so reach it with
244
- `Client(..., verify_terminal_tls=False)` (or supply a pinned CA). *Creating* a
245
- private-hosted account is done in the dashboard.
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
- - **Trading** — market & pending orders, modify, close, plus margin/profit calculators.
17
- - **Market data** — quotes, symbol specifications, OHLC history, account state & info.
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
- Privately-hosted accounts (a dedicated droplet) are listed, traded, and streamed
215
- exactly like shared-cluster accounts — their `rest_url` / `ws_url` simply point
216
- at the droplet. The droplet serves a self-signed certificate, so reach it with
217
- `Client(..., verify_terminal_tls=False)` (or supply a pinned CA). *Creating* a
218
- private-hosted account is done in the dashboard.
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 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,
@@ -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}")