fxsocket 0.2__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.2 → fxsocket-0.3.0}/PKG-INFO +38 -6
  2. {fxsocket-0.2 → fxsocket-0.3.0}/README.md +37 -5
  3. {fxsocket-0.2 → fxsocket-0.3.0}/src/fxsocket/__init__.py +10 -0
  4. fxsocket-0.3.0/src/fxsocket/_version.py +1 -0
  5. {fxsocket-0.2 → fxsocket-0.3.0}/src/fxsocket/client.py +14 -6
  6. {fxsocket-0.2 → fxsocket-0.3.0}/src/fxsocket/enums.py +19 -0
  7. {fxsocket-0.2 → fxsocket-0.3.0}/src/fxsocket/errors.py +22 -0
  8. fxsocket-0.3.0/src/fxsocket/management.py +264 -0
  9. {fxsocket-0.2 → fxsocket-0.3.0}/src/fxsocket/models.py +61 -0
  10. fxsocket-0.3.0/tests/test_private_servers.py +181 -0
  11. fxsocket-0.2/src/fxsocket/_version.py +0 -1
  12. fxsocket-0.2/src/fxsocket/management.py +0 -118
  13. {fxsocket-0.2 → fxsocket-0.3.0}/.github/workflows/ci.yml +0 -0
  14. {fxsocket-0.2 → fxsocket-0.3.0}/.github/workflows/publish.yml +0 -0
  15. {fxsocket-0.2 → fxsocket-0.3.0}/.gitignore +0 -0
  16. {fxsocket-0.2 → fxsocket-0.3.0}/LICENSE +0 -0
  17. {fxsocket-0.2 → fxsocket-0.3.0}/examples/manage_accounts.py +0 -0
  18. {fxsocket-0.2 → fxsocket-0.3.0}/examples/stream_quotes.py +0 -0
  19. {fxsocket-0.2 → fxsocket-0.3.0}/examples/terminal_rest.py +0 -0
  20. {fxsocket-0.2 → fxsocket-0.3.0}/pyproject.toml +0 -0
  21. {fxsocket-0.2 → fxsocket-0.3.0}/src/fxsocket/_http.py +0 -0
  22. {fxsocket-0.2 → fxsocket-0.3.0}/src/fxsocket/config.py +0 -0
  23. {fxsocket-0.2 → fxsocket-0.3.0}/src/fxsocket/py.typed +0 -0
  24. {fxsocket-0.2 → fxsocket-0.3.0}/src/fxsocket/terminal/__init__.py +0 -0
  25. {fxsocket-0.2 → fxsocket-0.3.0}/src/fxsocket/terminal/client.py +0 -0
  26. {fxsocket-0.2 → fxsocket-0.3.0}/src/fxsocket/terminal/stream.py +0 -0
  27. {fxsocket-0.2 → fxsocket-0.3.0}/tests/test_errors.py +0 -0
  28. {fxsocket-0.2 → fxsocket-0.3.0}/tests/test_management.py +0 -0
  29. {fxsocket-0.2 → fxsocket-0.3.0}/tests/test_stream.py +0 -0
  30. {fxsocket-0.2 → fxsocket-0.3.0}/tests/test_terminal.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fxsocket
3
- Version: 0.2
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
@@ -238,11 +240,41 @@ except AccountCapError as e:
238
240
 
239
241
  ## Private hosting
240
242
 
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.
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.
246
278
 
247
279
  ## Timestamps
248
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
@@ -211,11 +213,41 @@ except AccountCapError as e:
211
213
 
212
214
  ## Private hosting
213
215
 
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.
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.
219
251
 
220
252
  ## Timestamps
221
253
 
@@ -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,
@@ -46,6 +49,8 @@ from .models import (
46
49
  OpenedOrder,
47
50
  OrderResult,
48
51
  PositionTrade,
52
+ PrivateServer,
53
+ PrivateServerAccount,
49
54
  ProfitCalc,
50
55
  Quote,
51
56
  ServerTimezone,
@@ -97,6 +102,8 @@ __all__ = [
97
102
  "TradeEventData",
98
103
  "TerminalStatusData",
99
104
  "Account",
105
+ "PrivateServer",
106
+ "PrivateServerAccount",
100
107
  "AccountSummary",
101
108
  "AccountInfo",
102
109
  "OpenedOrder",
@@ -112,6 +119,8 @@ __all__ = [
112
119
  "Health",
113
120
  "HealthChecks",
114
121
  "Platform",
122
+ "PrivateAccountStatus",
123
+ "PrivateServerStatus",
115
124
  "TradingStatus",
116
125
  "OrderOperation",
117
126
  "OrderOutcome",
@@ -128,6 +137,7 @@ __all__ = [
128
137
  "AccountCapError",
129
138
  "NoSubscriptionError",
130
139
  "DuplicateAccountError",
140
+ "SlotsFullError",
131
141
  "ConnectFailedError",
132
142
  "TerminalNotReadyError",
133
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,
@@ -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}")
@@ -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
  # --------------------------------------------------------------------------- #
@@ -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
@@ -1 +0,0 @@
1
- __version__ = "0.2"
@@ -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