fxsocket 0.6.0__tar.gz → 0.7.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 (35) hide show
  1. {fxsocket-0.6.0 → fxsocket-0.7.0}/PKG-INFO +67 -5
  2. {fxsocket-0.6.0 → fxsocket-0.7.0}/README.md +66 -4
  3. {fxsocket-0.6.0 → fxsocket-0.7.0}/src/fxsocket/__init__.py +12 -0
  4. fxsocket-0.7.0/src/fxsocket/_version.py +1 -0
  5. {fxsocket-0.6.0 → fxsocket-0.7.0}/src/fxsocket/errors.py +36 -2
  6. {fxsocket-0.6.0 → fxsocket-0.7.0}/src/fxsocket/management.py +143 -2
  7. {fxsocket-0.6.0 → fxsocket-0.7.0}/src/fxsocket/models.py +49 -2
  8. fxsocket-0.7.0/tests/test_private_servers.py +421 -0
  9. fxsocket-0.6.0/src/fxsocket/_version.py +0 -1
  10. fxsocket-0.6.0/tests/test_private_servers.py +0 -181
  11. {fxsocket-0.6.0 → fxsocket-0.7.0}/.github/workflows/ci.yml +0 -0
  12. {fxsocket-0.6.0 → fxsocket-0.7.0}/.github/workflows/publish.yml +0 -0
  13. {fxsocket-0.6.0 → fxsocket-0.7.0}/.gitignore +0 -0
  14. {fxsocket-0.6.0 → fxsocket-0.7.0}/LICENSE +0 -0
  15. {fxsocket-0.6.0 → fxsocket-0.7.0}/examples/manage_accounts.py +0 -0
  16. {fxsocket-0.6.0 → fxsocket-0.7.0}/examples/multi_account_trade.py +0 -0
  17. {fxsocket-0.6.0 → fxsocket-0.7.0}/examples/stream_quotes.py +0 -0
  18. {fxsocket-0.6.0 → fxsocket-0.7.0}/examples/terminal_rest.py +0 -0
  19. {fxsocket-0.6.0 → fxsocket-0.7.0}/pyproject.toml +0 -0
  20. {fxsocket-0.6.0 → fxsocket-0.7.0}/src/fxsocket/_http.py +0 -0
  21. {fxsocket-0.6.0 → fxsocket-0.7.0}/src/fxsocket/client.py +0 -0
  22. {fxsocket-0.6.0 → fxsocket-0.7.0}/src/fxsocket/config.py +0 -0
  23. {fxsocket-0.6.0 → fxsocket-0.7.0}/src/fxsocket/enums.py +0 -0
  24. {fxsocket-0.6.0 → fxsocket-0.7.0}/src/fxsocket/py.typed +0 -0
  25. {fxsocket-0.6.0 → fxsocket-0.7.0}/src/fxsocket/terminal/__init__.py +0 -0
  26. {fxsocket-0.6.0 → fxsocket-0.7.0}/src/fxsocket/terminal/client.py +0 -0
  27. {fxsocket-0.6.0 → fxsocket-0.7.0}/src/fxsocket/terminal/stream.py +0 -0
  28. {fxsocket-0.6.0 → fxsocket-0.7.0}/src/fxsocket/trading.py +0 -0
  29. {fxsocket-0.6.0 → fxsocket-0.7.0}/tests/test_errors.py +0 -0
  30. {fxsocket-0.6.0 → fxsocket-0.7.0}/tests/test_management.py +0 -0
  31. {fxsocket-0.6.0 → fxsocket-0.7.0}/tests/test_readonly_keys.py +0 -0
  32. {fxsocket-0.6.0 → fxsocket-0.7.0}/tests/test_stream.py +0 -0
  33. {fxsocket-0.6.0 → fxsocket-0.7.0}/tests/test_terminal.py +0 -0
  34. {fxsocket-0.6.0 → fxsocket-0.7.0}/tests/test_trading.py +0 -0
  35. {fxsocket-0.6.0 → fxsocket-0.7.0}/tests/test_wallet.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: fxsocket
3
- Version: 0.6.0
3
+ Version: 0.7.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
@@ -443,7 +443,8 @@ with Client(api_key="fxs_live_...", verify_terminal_tls=False) as fx:
443
443
  server, server="ICMarkets-Demo", login=1150125, password="..."
444
444
  )
445
445
  except SlotsFullError as err:
446
- print(f"Server full ({err.used}/{err.cap}) — raise the limit in the dashboard.")
446
+ print(f"Server full ({err.used}/{err.cap})")
447
+ server = fx.private_servers.resize(server, slots=err.cap + 1)
447
448
 
448
449
  # Poll until the on-server agent has the terminal up, then trade as usual.
449
450
  while True:
@@ -460,8 +461,68 @@ Accounts on a private server are traded and streamed exactly like
460
461
  shared-cluster accounts — their `rest_url` / `ws_url` simply point at the
461
462
  server's dedicated IP. The server presents a self-signed certificate, so reach
462
463
  it with `Client(..., verify_terminal_tls=False)` (or supply a pinned CA).
463
- *Purchasing* a server, canceling, and slot changes happen in the dashboard;
464
- the API deliberately exposes no billing operations.
464
+
465
+ `server.cancel_at_period_end` is `True` once a server has been told to stop
466
+ instead of renewing; it then runs until `server.period_end` and expires.
467
+
468
+ ### Buying, resizing and canceling
469
+
470
+ `fx.private_servers.regions()` returns where servers may run, how big they may
471
+ be and what that costs — call it before buying rather than hardcoding slugs:
472
+
473
+ ```python
474
+ options = fx.private_servers.regions()
475
+ if options.enabled:
476
+ print(options.region_codes) # ['fra1', 'lon1', ...]
477
+ print(options.max_slots, options.max_servers)
478
+ print(options.monthly_price_eur(3)) # Decimal('45') — 3 slots/month
479
+ ```
480
+
481
+ `create()` buys one, charged to the prepaid balance immediately. It comes back
482
+ `provisioning`; poll `get()` until it is `ready`, usually a couple of minutes:
483
+
484
+ ```python
485
+ from fxsocket import InsufficientBalanceError, PrivateServerStatus, ServerLimitError
486
+
487
+ try:
488
+ server = fx.private_servers.create(slots=2, region="fra1", name="prop-guard")
489
+ except InsufficientBalanceError as err:
490
+ print(f"Top up {err.shortfall_eur} EUR first")
491
+ except ServerLimitError:
492
+ print(f"Already own the maximum ({options.max_servers})")
493
+
494
+ while server.status != PrivateServerStatus.READY:
495
+ time.sleep(10)
496
+ server = fx.private_servers.get(server)
497
+ ```
498
+
499
+ `resize()` changes the slot count. Increases are prorated over the rest of the
500
+ period and charged now (the renewal date doesn't move); decreases are free and
501
+ apply at the next renewal, so paid-for capacity is never destroyed mid-month.
502
+ Shrinking below the accounts already on the server raises
503
+ `AccountsExceedTargetError` — remove accounts first:
504
+
505
+ ```python
506
+ server = fx.private_servers.resize(server, slots=4)
507
+ ```
508
+
509
+ `cancel()` stops the server renewing: it runs until `period_end`, then expires.
510
+ `resume()` undoes that while the period lasts (afterwards the machine is gone
511
+ and `AlreadyLapsedError` is raised). `delete()` destroys the machine and every
512
+ account on it right away, with **no refund** for the rest of the prepaid month —
513
+ prefer `cancel()` unless you really want it gone now:
514
+
515
+ ```python
516
+ server = fx.private_servers.cancel(server) # stop at period_end
517
+ assert server.cancel_at_period_end
518
+ server = fx.private_servers.resume(server) # changed your mind
519
+ fx.private_servers.delete(server) # irreversible, no refund
520
+ ```
521
+
522
+ All of these move the prepaid balance, so they only work on balance-funded
523
+ servers — a card- or crypto-funded one raises `NotBalanceFundedError` (a
524
+ `ForbiddenError` subclass) and is managed from the dashboard. Read-only
525
+ `fxs_ro_…` keys get a plain `ForbiddenError`.
465
526
 
466
527
  ## Wallet
467
528
 
@@ -483,7 +544,8 @@ if not wallet.covers_upcoming:
483
544
  Affordability is cumulative — with 24 EUR and three 12 EUR renewals the
484
545
  first two are covered and the third is not — so `shortfall_eur` is the
485
546
  total gap, not the size of any single charge. Topping up happens in the
486
- dashboard; the SDK deliberately exposes no payment operations.
547
+ dashboard; the balance is only ever *spent* through the SDK (account seats
548
+ and `private_servers.create()` / `resize()`), never topped up.
487
549
 
488
550
  ## Timestamps
489
551
 
@@ -416,7 +416,8 @@ with Client(api_key="fxs_live_...", verify_terminal_tls=False) as fx:
416
416
  server, server="ICMarkets-Demo", login=1150125, password="..."
417
417
  )
418
418
  except SlotsFullError as err:
419
- print(f"Server full ({err.used}/{err.cap}) — raise the limit in the dashboard.")
419
+ print(f"Server full ({err.used}/{err.cap})")
420
+ server = fx.private_servers.resize(server, slots=err.cap + 1)
420
421
 
421
422
  # Poll until the on-server agent has the terminal up, then trade as usual.
422
423
  while True:
@@ -433,8 +434,68 @@ Accounts on a private server are traded and streamed exactly like
433
434
  shared-cluster accounts — their `rest_url` / `ws_url` simply point at the
434
435
  server's dedicated IP. The server presents a self-signed certificate, so reach
435
436
  it with `Client(..., verify_terminal_tls=False)` (or supply a pinned CA).
436
- *Purchasing* a server, canceling, and slot changes happen in the dashboard;
437
- the API deliberately exposes no billing operations.
437
+
438
+ `server.cancel_at_period_end` is `True` once a server has been told to stop
439
+ instead of renewing; it then runs until `server.period_end` and expires.
440
+
441
+ ### Buying, resizing and canceling
442
+
443
+ `fx.private_servers.regions()` returns where servers may run, how big they may
444
+ be and what that costs — call it before buying rather than hardcoding slugs:
445
+
446
+ ```python
447
+ options = fx.private_servers.regions()
448
+ if options.enabled:
449
+ print(options.region_codes) # ['fra1', 'lon1', ...]
450
+ print(options.max_slots, options.max_servers)
451
+ print(options.monthly_price_eur(3)) # Decimal('45') — 3 slots/month
452
+ ```
453
+
454
+ `create()` buys one, charged to the prepaid balance immediately. It comes back
455
+ `provisioning`; poll `get()` until it is `ready`, usually a couple of minutes:
456
+
457
+ ```python
458
+ from fxsocket import InsufficientBalanceError, PrivateServerStatus, ServerLimitError
459
+
460
+ try:
461
+ server = fx.private_servers.create(slots=2, region="fra1", name="prop-guard")
462
+ except InsufficientBalanceError as err:
463
+ print(f"Top up {err.shortfall_eur} EUR first")
464
+ except ServerLimitError:
465
+ print(f"Already own the maximum ({options.max_servers})")
466
+
467
+ while server.status != PrivateServerStatus.READY:
468
+ time.sleep(10)
469
+ server = fx.private_servers.get(server)
470
+ ```
471
+
472
+ `resize()` changes the slot count. Increases are prorated over the rest of the
473
+ period and charged now (the renewal date doesn't move); decreases are free and
474
+ apply at the next renewal, so paid-for capacity is never destroyed mid-month.
475
+ Shrinking below the accounts already on the server raises
476
+ `AccountsExceedTargetError` — remove accounts first:
477
+
478
+ ```python
479
+ server = fx.private_servers.resize(server, slots=4)
480
+ ```
481
+
482
+ `cancel()` stops the server renewing: it runs until `period_end`, then expires.
483
+ `resume()` undoes that while the period lasts (afterwards the machine is gone
484
+ and `AlreadyLapsedError` is raised). `delete()` destroys the machine and every
485
+ account on it right away, with **no refund** for the rest of the prepaid month —
486
+ prefer `cancel()` unless you really want it gone now:
487
+
488
+ ```python
489
+ server = fx.private_servers.cancel(server) # stop at period_end
490
+ assert server.cancel_at_period_end
491
+ server = fx.private_servers.resume(server) # changed your mind
492
+ fx.private_servers.delete(server) # irreversible, no refund
493
+ ```
494
+
495
+ All of these move the prepaid balance, so they only work on balance-funded
496
+ servers — a card- or crypto-funded one raises `NotBalanceFundedError` (a
497
+ `ForbiddenError` subclass) and is managed from the dashboard. Read-only
498
+ `fxs_ro_…` keys get a plain `ForbiddenError`.
438
499
 
439
500
  ## Wallet
440
501
 
@@ -456,7 +517,8 @@ if not wallet.covers_upcoming:
456
517
  Affordability is cumulative — with 24 EUR and three 12 EUR renewals the
457
518
  first two are covered and the third is not — so `shortfall_eur` is the
458
519
  total gap, not the size of any single charge. Topping up happens in the
459
- dashboard; the SDK deliberately exposes no payment operations.
520
+ dashboard; the balance is only ever *spent* through the SDK (account seats
521
+ and `private_servers.create()` / `resize()`), never topped up.
460
522
 
461
523
  ## Timestamps
462
524
 
@@ -30,6 +30,8 @@ from .enums import (
30
30
  )
31
31
  from .errors import (
32
32
  AccountCapError,
33
+ AccountsExceedTargetError,
34
+ AlreadyLapsedError,
33
35
  AuthError,
34
36
  ConnectFailedError,
35
37
  DuplicateAccountError,
@@ -38,10 +40,12 @@ from .errors import (
38
40
  IdempotencyError,
39
41
  InsufficientBalanceError,
40
42
  NoSubscriptionError,
43
+ NotBalanceFundedError,
41
44
  NotFoundError,
42
45
  PaymentRequiredError,
43
46
  RateLimitError,
44
47
  SeatLapsedError,
48
+ ServerLimitError,
45
49
  SlotsFullError,
46
50
  StreamError,
47
51
  TerminalNotReadyError,
@@ -78,9 +82,11 @@ from .models import (
78
82
  PositionTrade,
79
83
  PrivateServer,
80
84
  PrivateServerAccount,
85
+ PrivateServerOptions,
81
86
  ProfitCalc,
82
87
  Quote,
83
88
  ReadOnlyKey,
89
+ Region,
84
90
  ScopedAccount,
85
91
  ServerTimezone,
86
92
  SymbolInfo,
@@ -137,6 +143,8 @@ __all__ = [
137
143
  "Account",
138
144
  "PrivateServer",
139
145
  "PrivateServerAccount",
146
+ "PrivateServerOptions",
147
+ "Region",
140
148
  "ReadOnlyKey",
141
149
  "ScopedAccount",
142
150
  "OrderDefaults",
@@ -203,6 +211,10 @@ __all__ = [
203
211
  "SeatLapsedError",
204
212
  "DuplicateAccountError",
205
213
  "SlotsFullError",
214
+ "ServerLimitError",
215
+ "AccountsExceedTargetError",
216
+ "AlreadyLapsedError",
217
+ "NotBalanceFundedError",
206
218
  "IdempotencyError",
207
219
  "ConnectFailedError",
208
220
  "TerminalNotReadyError",
@@ -0,0 +1 @@
1
+ __version__ = "0.7.0"
@@ -38,7 +38,15 @@ class AuthError(FxSocketError):
38
38
  class ForbiddenError(FxSocketError):
39
39
  """The key is valid but may not do this (HTTP 403) — typically a
40
40
  read-only ``fxs_ro_…`` key on an endpoint that mutates state, such as
41
- multi-account trading."""
41
+ multi-account trading.
42
+
43
+ Base class for :class:`NotBalanceFundedError`."""
44
+
45
+
46
+ class NotBalanceFundedError(ForbiddenError):
47
+ """This private server is not paid from the prepaid balance (HTTP 403
48
+ ``not_balance_funded``). Card- and crypto-funded servers are resized
49
+ and canceled in the dashboard, since the API only moves balance."""
42
50
 
43
51
 
44
52
  class RateLimitError(FxSocketError):
@@ -128,7 +136,7 @@ class DuplicateAccountError(FxSocketError):
128
136
 
129
137
  class SlotsFullError(FxSocketError):
130
138
  """Every purchased slot on the private server is taken (HTTP 409
131
- ``slots_full``). Raise the server's limit from the dashboard."""
139
+ ``slots_full``). Buy more with ``client.private_servers.resize()``."""
132
140
 
133
141
  def __init__(
134
142
  self,
@@ -143,6 +151,24 @@ class SlotsFullError(FxSocketError):
143
151
  self.cap = cap
144
152
 
145
153
 
154
+ class ServerLimitError(FxSocketError):
155
+ """You already own as many private servers as you may (HTTP 409
156
+ ``server_limit_reached``) — see ``max_servers`` on
157
+ :class:`~fxsocket.PrivateServerOptions`."""
158
+
159
+
160
+ class AccountsExceedTargetError(FxSocketError):
161
+ """The server holds more accounts than the requested slot count allows
162
+ (HTTP 409 ``accounts_exceed_target``). Remove accounts first, then
163
+ resize."""
164
+
165
+
166
+ class AlreadyLapsedError(FxSocketError):
167
+ """The paid period has already run out, so the cancellation can no
168
+ longer be undone (HTTP 409 ``already_lapsed``) — the machine is gone;
169
+ buy a new server."""
170
+
171
+
146
172
  class IdempotencyError(FxSocketError):
147
173
  """A multi-account batch was refused because of its ``Idempotency-Key``.
148
174
 
@@ -228,6 +254,8 @@ def error_from_response(resp: httpx.Response) -> FxSocketError:
228
254
  if status == 401:
229
255
  return AuthError(message, **common)
230
256
  if status == 403:
257
+ if code == "not_balance_funded":
258
+ return NotBalanceFundedError(message, **common)
231
259
  return ForbiddenError(message, **common)
232
260
  if code in _IDEMPOTENCY_CODES:
233
261
  return IdempotencyError(message, **common)
@@ -247,6 +275,12 @@ def error_from_response(resp: httpx.Response) -> FxSocketError:
247
275
  return SlotsFullError(
248
276
  message, used=body.get("used"), cap=body.get("cap"), **common
249
277
  )
278
+ if code == "server_limit_reached":
279
+ return ServerLimitError(message, **common)
280
+ if code == "accounts_exceed_target":
281
+ return AccountsExceedTargetError(message, **common)
282
+ if code == "already_lapsed":
283
+ return AlreadyLapsedError(message, **common)
250
284
  return DuplicateAccountError(message, **common)
251
285
  if status == 402:
252
286
  fields: dict[str, Any] = body if isinstance(body, dict) else {}
@@ -18,6 +18,7 @@ from .models import (
18
18
  Account,
19
19
  PrivateServer,
20
20
  PrivateServerAccount,
21
+ PrivateServerOptions,
21
22
  ReadOnlyKey,
22
23
  Wallet,
23
24
  )
@@ -249,11 +250,22 @@ def _private_account_payload(
249
250
  }
250
251
 
251
252
 
253
+ def _private_server_payload(*, slots: int, region: str, name: str) -> dict[str, object]:
254
+ body: dict[str, object] = {"slots": slots, "region": region}
255
+ if name:
256
+ body["name"] = name
257
+ return body
258
+
259
+
252
260
  class PrivateServers:
253
261
  """Synchronous private-server operations.
254
262
 
255
- Read + on-server account management only: purchasing a server,
256
- canceling, and slot changes happen in the dashboard.
263
+ Covers the whole lifecycle: :meth:`regions` to see what can be bought,
264
+ :meth:`create` to buy one from the prepaid balance, :meth:`resize`,
265
+ :meth:`cancel` / :meth:`resume`, :meth:`delete`, and
266
+ :meth:`add_account` / :meth:`remove_account` for the accounts on it.
267
+ Balance is the only payment method here — card and crypto purchases
268
+ need a checkout redirect, so those stay in the dashboard.
257
269
  """
258
270
 
259
271
  def __init__(self, http: SyncHTTP) -> None:
@@ -264,6 +276,15 @@ class PrivateServers:
264
276
  data = self._http.request("GET", "/private-servers")
265
277
  return [PrivateServer.model_validate(row) for row in data]
266
278
 
279
+ def regions(self) -> PrivateServerOptions:
280
+ """Where private servers may run, how big they may be, what it costs.
281
+
282
+ Returns the whole options payload, not just the region list — see
283
+ :class:`~fxsocket.PrivateServerOptions`.
284
+ """
285
+ data = self._http.request("GET", "/private-servers/regions")
286
+ return PrivateServerOptions.model_validate(data)
287
+
267
288
  def get(self, server: PrivateServer | str) -> PrivateServer:
268
289
  """Fetch one server by id (use this to poll account readiness)."""
269
290
  data = self._http.request(
@@ -271,6 +292,89 @@ class PrivateServers:
271
292
  )
272
293
  return PrivateServer.model_validate(data)
273
294
 
295
+ def create(self, *, slots: int, region: str, name: str = "") -> PrivateServer:
296
+ """Buy a dedicated server, charged to the prepaid balance now.
297
+
298
+ ``region`` must be one of the ``code`` values from :meth:`regions`,
299
+ ``slots`` how many accounts it should hold (priced
300
+ ``first_slot + additional_slot * (slots - 1)`` per month, billed
301
+ again every month until you :meth:`cancel` it) and ``name`` an
302
+ optional label for your own reference.
303
+
304
+ Comes back already ``provisioning`` — poll :meth:`get` until
305
+ ``status`` is ``ready``, usually a couple of minutes. Raises
306
+ :class:`~fxsocket.InsufficientBalanceError` when the balance does
307
+ not cover it, :class:`~fxsocket.ServerLimitError` when you already
308
+ own the maximum, and :class:`~fxsocket.ForbiddenError` when private
309
+ hosting is off for the deployment or the key is read-only.
310
+ """
311
+ data = self._http.request(
312
+ "POST",
313
+ "/private-servers",
314
+ json=_private_server_payload(slots=slots, region=region, name=name),
315
+ )
316
+ return PrivateServer.model_validate(data)
317
+
318
+ def resize(self, server: PrivateServer | str, *, slots: int) -> PrivateServer:
319
+ """Change how many accounts the server may hold.
320
+
321
+ Increases are prorated over what is left of the current period and
322
+ charged to the balance immediately; the renewal date does not move.
323
+ Decreases are free and take effect at the next renewal, so capacity
324
+ already paid for is never destroyed mid-period.
325
+
326
+ Raises :class:`~fxsocket.AccountsExceedTargetError` when more
327
+ accounts are on the server than the new limit allows (remove some
328
+ first), :class:`~fxsocket.InsufficientBalanceError` when the
329
+ balance does not cover a prorated increase, and
330
+ :class:`~fxsocket.NotBalanceFundedError` for card- or
331
+ crypto-funded servers.
332
+ """
333
+ data = self._http.request(
334
+ "PATCH",
335
+ f"/private-servers/{private_server_id_of(server)}",
336
+ json={"slots": slots},
337
+ )
338
+ return PrivateServer.model_validate(data)
339
+
340
+ def delete(self, server: PrivateServer | str) -> None:
341
+ """Destroy the machine and everything on it — irreversible.
342
+
343
+ The droplet is torn down, its IP released and every account hosted
344
+ on it removed. There is **no refund**: whatever is left of the
345
+ prepaid month is forfeited. To stop paying without losing the rest
346
+ of the period, use :meth:`cancel` instead.
347
+ """
348
+ self._http.request("DELETE", f"/private-servers/{private_server_id_of(server)}")
349
+
350
+ def cancel(self, server: PrivateServer | str) -> PrivateServer:
351
+ """Stop the server renewing, letting the paid period run out.
352
+
353
+ It keeps running until ``period_end``, then expires; nothing is
354
+ refunded and nothing is charged again. Prefer this to
355
+ :meth:`delete`, which forfeits the rest of the month. Reversible
356
+ with :meth:`resume` while the period lasts. Raises
357
+ :class:`~fxsocket.NotBalanceFundedError` for card- or
358
+ crypto-funded servers.
359
+ """
360
+ data = self._http.request(
361
+ "POST", f"/private-servers/{private_server_id_of(server)}/cancel"
362
+ )
363
+ return PrivateServer.model_validate(data)
364
+
365
+ def resume(self, server: PrivateServer | str) -> PrivateServer:
366
+ """Undo a :meth:`cancel`, so the server renews from the balance
367
+ again at the end of the current period.
368
+
369
+ Only works while it is still running: raises
370
+ :class:`~fxsocket.AlreadyLapsedError` once the paid period has
371
+ lapsed and the machine is gone — buy a new one with :meth:`create`.
372
+ """
373
+ data = self._http.request(
374
+ "DELETE", f"/private-servers/{private_server_id_of(server)}/cancel"
375
+ )
376
+ return PrivateServer.model_validate(data)
377
+
274
378
  def add_account(
275
379
  self,
276
380
  private_server: PrivateServer | str,
@@ -325,12 +429,49 @@ class AsyncPrivateServers:
325
429
  data = await self._http.request("GET", "/private-servers")
326
430
  return [PrivateServer.model_validate(row) for row in data]
327
431
 
432
+ async def regions(self) -> PrivateServerOptions:
433
+ data = await self._http.request("GET", "/private-servers/regions")
434
+ return PrivateServerOptions.model_validate(data)
435
+
328
436
  async def get(self, server: PrivateServer | str) -> PrivateServer:
329
437
  data = await self._http.request(
330
438
  "GET", f"/private-servers/{private_server_id_of(server)}"
331
439
  )
332
440
  return PrivateServer.model_validate(data)
333
441
 
442
+ async def create(self, *, slots: int, region: str, name: str = "") -> PrivateServer:
443
+ data = await self._http.request(
444
+ "POST",
445
+ "/private-servers",
446
+ json=_private_server_payload(slots=slots, region=region, name=name),
447
+ )
448
+ return PrivateServer.model_validate(data)
449
+
450
+ async def resize(self, server: PrivateServer | str, *, slots: int) -> PrivateServer:
451
+ data = await self._http.request(
452
+ "PATCH",
453
+ f"/private-servers/{private_server_id_of(server)}",
454
+ json={"slots": slots},
455
+ )
456
+ return PrivateServer.model_validate(data)
457
+
458
+ async def delete(self, server: PrivateServer | str) -> None:
459
+ await self._http.request(
460
+ "DELETE", f"/private-servers/{private_server_id_of(server)}"
461
+ )
462
+
463
+ async def cancel(self, server: PrivateServer | str) -> PrivateServer:
464
+ data = await self._http.request(
465
+ "POST", f"/private-servers/{private_server_id_of(server)}/cancel"
466
+ )
467
+ return PrivateServer.model_validate(data)
468
+
469
+ async def resume(self, server: PrivateServer | str) -> PrivateServer:
470
+ data = await self._http.request(
471
+ "DELETE", f"/private-servers/{private_server_id_of(server)}/cancel"
472
+ )
473
+ return PrivateServer.model_validate(data)
474
+
334
475
  async def add_account(
335
476
  self,
336
477
  private_server: PrivateServer | str,
@@ -131,8 +131,8 @@ class PrivateServer(BaseModel):
131
131
  ``status`` is the server lifecycle — compare against
132
132
  :class:`fxsocket.PrivateServerStatus`. ``purchased_slots`` is the paid
133
133
  limit; ``used_slots`` how many accounts currently live on the server.
134
- Purchasing, canceling and slot changes happen in the dashboard, not
135
- the API.
134
+ ``cancel_at_period_end`` is true once the server has been told to stop
135
+ instead of renewing — it then runs until ``period_end`` and expires.
136
136
  """
137
137
 
138
138
  model_config = ConfigDict(populate_by_name=True, extra="ignore")
@@ -144,6 +144,7 @@ class PrivateServer(BaseModel):
144
144
  ip: str = ""
145
145
  purchased_slots: int = 0
146
146
  used_slots: int = 0
147
+ cancel_at_period_end: bool = False
147
148
  period_end: datetime | None = None
148
149
  accounts: list[PrivateServerAccount] = []
149
150
 
@@ -156,6 +157,52 @@ class PrivateServer(BaseModel):
156
157
  return max(self.purchased_slots - self.used_slots, 0)
157
158
 
158
159
 
160
+ class Region(BaseModel):
161
+ """One place a private server can run in."""
162
+
163
+ model_config = ConfigDict(extra="ignore")
164
+
165
+ code: str
166
+ label: str = ""
167
+
168
+
169
+ class PrivateServerOptions(BaseModel):
170
+ """Where private servers may run, how big they may be and what that
171
+ costs (``GET /v1/private-servers/regions``).
172
+
173
+ ``enabled`` is false when private hosting is off for the deployment —
174
+ ``regions`` is then empty. Prices are integer EUR cents, and a server
175
+ costs ``first_slot_eur_cents + additional_slot_eur_cents * (slots -
176
+ 1)`` per month; :meth:`monthly_price_eur_cents` does that arithmetic.
177
+ Treat the returned list as authoritative rather than hardcoding region
178
+ slugs.
179
+ """
180
+
181
+ model_config = ConfigDict(extra="ignore")
182
+
183
+ enabled: bool = True
184
+ regions: list[Region] = []
185
+ max_slots: int = 0
186
+ max_servers: int = 0
187
+ first_slot_eur_cents: int = 0
188
+ additional_slot_eur_cents: int = 0
189
+
190
+ @property
191
+ def region_codes(self) -> list[str]:
192
+ """Just the slugs, in the order the API returned them."""
193
+ return [region.code for region in self.regions]
194
+
195
+ def monthly_price_eur_cents(self, slots: int) -> int:
196
+ """What a server of ``slots`` accounts costs per month, in cents."""
197
+ if slots < 1:
198
+ raise ValueError("slots must be at least 1")
199
+ return self.first_slot_eur_cents + self.additional_slot_eur_cents * (slots - 1)
200
+
201
+ def monthly_price_eur(self, slots: int) -> Decimal:
202
+ """:meth:`monthly_price_eur_cents` as euros."""
203
+ return _eur(self.monthly_price_eur_cents(slots))
204
+
205
+
159
206
  # --------------------------------------------------------------------------- #
160
207
  # Management API (v1) — read-only keys (``/v1/readonly-keys``)
161
208
  # --------------------------------------------------------------------------- #
@@ -0,0 +1,421 @@
1
+ """Tests for the private-server management client (sync + async)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from decimal import Decimal
7
+
8
+ import httpx
9
+ import pytest
10
+ import respx
11
+
12
+ from fxsocket import (
13
+ AccountsExceedTargetError,
14
+ AlreadyLapsedError,
15
+ AsyncClient,
16
+ Client,
17
+ DuplicateAccountError,
18
+ InsufficientBalanceError,
19
+ NotBalanceFundedError,
20
+ PrivateAccountStatus,
21
+ PrivateServerStatus,
22
+ ServerLimitError,
23
+ SlotsFullError,
24
+ )
25
+ from fxsocket.errors import ForbiddenError
26
+
27
+ BASE = "https://api.fxsocket.com/v1"
28
+
29
+ SERVER_ID = "ecf2fa95-4177-4402-8a00-dea33ae0e79a"
30
+
31
+ SERVER_ACCOUNT = {
32
+ "id": "22222222-2222-2222-2222-222222222222",
33
+ "nickname": "prop-1",
34
+ "platform": "mt5",
35
+ "server": "ICMarkets-Demo",
36
+ "login": 7001,
37
+ "status": "ready",
38
+ "rest_url": "https://159.223.244.125/22222222-2222-2222-2222-222222222222",
39
+ "ws_url": "wss://159.223.244.125/22222222-2222-2222-2222-222222222222/ws",
40
+ "trade_ea_symbol": "",
41
+ "created_at": "2026-07-16T08:00:00Z",
42
+ }
43
+
44
+ SERVER = {
45
+ "id": SERVER_ID,
46
+ "name": "My Prop Guard",
47
+ "status": "ready",
48
+ "region": "lon1",
49
+ "ip": "159.223.244.125",
50
+ "purchased_slots": 2,
51
+ "used_slots": 1,
52
+ "cancel_at_period_end": False,
53
+ "period_end": "2026-08-16T07:01:08Z",
54
+ "accounts": [SERVER_ACCOUNT],
55
+ }
56
+
57
+
58
+ REGIONS = {
59
+ "enabled": True,
60
+ "regions": [
61
+ {"code": "fra1", "label": "Frankfurt, Germany"},
62
+ {"code": "lon1", "label": "London, United Kingdom"},
63
+ ],
64
+ "max_slots": 10,
65
+ "max_servers": 3,
66
+ "first_slot_eur_cents": 1700,
67
+ "additional_slot_eur_cents": 1400,
68
+ }
69
+
70
+
71
+ def _client() -> Client:
72
+ return Client(api_key="fxs_live_test")
73
+
74
+
75
+ @respx.mock
76
+ def test_list_servers_parses_models() -> None:
77
+ respx.get(f"{BASE}/private-servers").mock(
78
+ return_value=httpx.Response(200, json=[SERVER])
79
+ )
80
+ with _client() as fx:
81
+ [server] = fx.private_servers.list()
82
+ assert server.name == "My Prop Guard"
83
+ assert server.status == PrivateServerStatus.READY
84
+ assert server.is_ready
85
+ assert server.ip == "159.223.244.125"
86
+ assert server.free_slots == 1
87
+ [account] = server.accounts
88
+ assert account.status == PrivateAccountStatus.READY
89
+ assert account.has_terminal
90
+
91
+
92
+ @respx.mock
93
+ def test_cancel_at_period_end_is_parsed() -> None:
94
+ canceled = {**SERVER, "cancel_at_period_end": True}
95
+ respx.get(f"{BASE}/private-servers").mock(
96
+ return_value=httpx.Response(200, json=[canceled])
97
+ )
98
+ with _client() as fx:
99
+ [server] = fx.private_servers.list()
100
+ assert server.cancel_at_period_end
101
+ assert server.period_end is not None
102
+
103
+
104
+ @respx.mock
105
+ def test_regions_parses_options_and_prices() -> None:
106
+ respx.get(f"{BASE}/private-servers/regions").mock(
107
+ return_value=httpx.Response(200, json=REGIONS)
108
+ )
109
+ with _client() as fx:
110
+ options = fx.private_servers.regions()
111
+ assert options.enabled
112
+ assert options.region_codes == ["fra1", "lon1"]
113
+ assert options.regions[0].label == "Frankfurt, Germany"
114
+ assert options.max_slots == 10
115
+ assert options.monthly_price_eur_cents(1) == 1700
116
+ assert options.monthly_price_eur_cents(3) == 4500
117
+ assert options.monthly_price_eur(3) == Decimal("45.00")
118
+ with pytest.raises(ValueError):
119
+ options.monthly_price_eur_cents(0)
120
+
121
+
122
+ @respx.mock
123
+ def test_regions_disabled_deployment() -> None:
124
+ respx.get(f"{BASE}/private-servers/regions").mock(
125
+ return_value=httpx.Response(
126
+ 200,
127
+ json={
128
+ "enabled": False,
129
+ "regions": [],
130
+ "max_slots": 0,
131
+ "max_servers": 0,
132
+ "first_slot_eur_cents": 0,
133
+ "additional_slot_eur_cents": 0,
134
+ },
135
+ )
136
+ )
137
+ with _client() as fx:
138
+ options = fx.private_servers.regions()
139
+ assert not options.enabled
140
+ assert options.region_codes == []
141
+
142
+
143
+ @respx.mock
144
+ def test_get_accepts_model_or_id() -> None:
145
+ route = respx.get(f"{BASE}/private-servers/{SERVER_ID}").mock(
146
+ return_value=httpx.Response(200, json=SERVER)
147
+ )
148
+ with _client() as fx:
149
+ by_id = fx.private_servers.get(SERVER_ID)
150
+ by_model = fx.private_servers.get(by_id)
151
+ assert route.call_count == 2
152
+ assert by_model.id == SERVER_ID
153
+
154
+
155
+ @respx.mock
156
+ def test_add_account_payload_and_model() -> None:
157
+ route = respx.post(f"{BASE}/private-servers/{SERVER_ID}/accounts").mock(
158
+ return_value=httpx.Response(201, json=SERVER_ACCOUNT)
159
+ )
160
+ with _client() as fx:
161
+ account = fx.private_servers.add_account(
162
+ SERVER_ID,
163
+ server="ICMarkets-Demo",
164
+ login=7001,
165
+ password="pw",
166
+ nickname="prop-1",
167
+ )
168
+ sent = json.loads(route.calls.last.request.content)
169
+ assert sent == {
170
+ "platform": "mt5",
171
+ "server": "ICMarkets-Demo",
172
+ "login": 7001,
173
+ "password": "pw",
174
+ "nickname": "prop-1",
175
+ "trade_ea_symbol": "",
176
+ }
177
+ assert account.login == 7001
178
+
179
+
180
+ @respx.mock
181
+ def test_slots_full_maps_to_typed_error() -> None:
182
+ respx.post(f"{BASE}/private-servers/{SERVER_ID}/accounts").mock(
183
+ return_value=httpx.Response(
184
+ 409,
185
+ json={
186
+ "error": "slots_full",
187
+ "detail": "Server is full (2/2).",
188
+ "used": 2,
189
+ "cap": 2,
190
+ },
191
+ )
192
+ )
193
+ with _client() as fx:
194
+ with pytest.raises(SlotsFullError) as err:
195
+ fx.private_servers.add_account(
196
+ SERVER_ID, server="Demo", login=1, password="pw"
197
+ )
198
+ assert err.value.used == 2
199
+ assert err.value.cap == 2
200
+
201
+
202
+ @respx.mock
203
+ def test_duplicate_still_maps_to_duplicate_error() -> None:
204
+ respx.post(f"{BASE}/private-servers/{SERVER_ID}/accounts").mock(
205
+ return_value=httpx.Response(
206
+ 409, json={"error": "duplicate", "detail": "Already linked."}
207
+ )
208
+ )
209
+ with _client() as fx:
210
+ with pytest.raises(DuplicateAccountError):
211
+ fx.private_servers.add_account(
212
+ SERVER_ID, server="Demo", login=1, password="pw"
213
+ )
214
+
215
+
216
+ @respx.mock
217
+ def test_remove_account() -> None:
218
+ account_id = SERVER_ACCOUNT["id"]
219
+ route = respx.delete(
220
+ f"{BASE}/private-servers/{SERVER_ID}/accounts/{account_id}"
221
+ ).mock(return_value=httpx.Response(204))
222
+ with _client() as fx:
223
+ fx.private_servers.remove_account(SERVER_ID, account_id)
224
+ assert route.called
225
+
226
+
227
+ @respx.mock
228
+ def test_create_payload_and_model() -> None:
229
+ route = respx.post(f"{BASE}/private-servers").mock(
230
+ return_value=httpx.Response(
231
+ 201, json={**SERVER, "status": "provisioning", "accounts": []}
232
+ )
233
+ )
234
+ with _client() as fx:
235
+ server = fx.private_servers.create(slots=2, region="lon1", name="My Prop Guard")
236
+ assert json.loads(route.calls.last.request.content) == {
237
+ "slots": 2,
238
+ "region": "lon1",
239
+ "name": "My Prop Guard",
240
+ }
241
+ assert server.status == PrivateServerStatus.PROVISIONING
242
+ assert not server.is_ready
243
+
244
+
245
+ @respx.mock
246
+ def test_create_omits_blank_name() -> None:
247
+ route = respx.post(f"{BASE}/private-servers").mock(
248
+ return_value=httpx.Response(201, json=SERVER)
249
+ )
250
+ with _client() as fx:
251
+ fx.private_servers.create(slots=1, region="fra1")
252
+ assert json.loads(route.calls.last.request.content) == {
253
+ "slots": 1,
254
+ "region": "fra1",
255
+ }
256
+
257
+
258
+ @respx.mock
259
+ def test_create_insufficient_balance() -> None:
260
+ respx.post(f"{BASE}/private-servers").mock(
261
+ return_value=httpx.Response(
262
+ 402,
263
+ json={
264
+ "error": "insufficient_balance",
265
+ "detail": "Balance does not cover it.",
266
+ "shortfall_eur_cents": 1200,
267
+ },
268
+ )
269
+ )
270
+ with _client() as fx:
271
+ with pytest.raises(InsufficientBalanceError) as err:
272
+ fx.private_servers.create(slots=2, region="lon1")
273
+ assert err.value.shortfall_eur == Decimal("12.00")
274
+
275
+
276
+ @respx.mock
277
+ def test_create_server_limit_reached() -> None:
278
+ respx.post(f"{BASE}/private-servers").mock(
279
+ return_value=httpx.Response(
280
+ 409, json={"error": "server_limit_reached", "detail": "You own 3 of 3."}
281
+ )
282
+ )
283
+ with _client() as fx:
284
+ with pytest.raises(ServerLimitError) as err:
285
+ fx.private_servers.create(slots=1, region="lon1")
286
+ assert err.value.code == "server_limit_reached"
287
+ assert not isinstance(err.value, DuplicateAccountError)
288
+
289
+
290
+ @respx.mock
291
+ def test_resize_sends_slots() -> None:
292
+ route = respx.patch(f"{BASE}/private-servers/{SERVER_ID}").mock(
293
+ return_value=httpx.Response(200, json={**SERVER, "purchased_slots": 4})
294
+ )
295
+ with _client() as fx:
296
+ server = fx.private_servers.resize(SERVER_ID, slots=4)
297
+ assert json.loads(route.calls.last.request.content) == {"slots": 4}
298
+ assert server.purchased_slots == 4
299
+ assert server.free_slots == 3
300
+
301
+
302
+ @respx.mock
303
+ def test_resize_below_accounts_is_typed() -> None:
304
+ respx.patch(f"{BASE}/private-servers/{SERVER_ID}").mock(
305
+ return_value=httpx.Response(
306
+ 409,
307
+ json={"error": "accounts_exceed_target", "detail": "2 accounts, 1 slot."},
308
+ )
309
+ )
310
+ with _client() as fx:
311
+ with pytest.raises(AccountsExceedTargetError):
312
+ fx.private_servers.resize(SERVER_ID, slots=1)
313
+
314
+
315
+ @respx.mock
316
+ def test_resize_not_balance_funded() -> None:
317
+ respx.patch(f"{BASE}/private-servers/{SERVER_ID}").mock(
318
+ return_value=httpx.Response(
319
+ 403, json={"error": "not_balance_funded", "detail": "Card-funded server."}
320
+ )
321
+ )
322
+ with _client() as fx:
323
+ with pytest.raises(NotBalanceFundedError) as err:
324
+ fx.private_servers.resize(SERVER_ID, slots=4)
325
+ assert isinstance(err.value, ForbiddenError)
326
+
327
+
328
+ @respx.mock
329
+ def test_delete_server() -> None:
330
+ route = respx.delete(f"{BASE}/private-servers/{SERVER_ID}").mock(
331
+ return_value=httpx.Response(204)
332
+ )
333
+ with _client() as fx:
334
+ fx.private_servers.delete(SERVER_ID)
335
+ assert route.called
336
+
337
+
338
+ @respx.mock
339
+ def test_cancel_and_resume() -> None:
340
+ canceled = {**SERVER, "cancel_at_period_end": True}
341
+ cancel_route = respx.post(f"{BASE}/private-servers/{SERVER_ID}/cancel").mock(
342
+ return_value=httpx.Response(200, json=canceled)
343
+ )
344
+ resume_route = respx.delete(f"{BASE}/private-servers/{SERVER_ID}/cancel").mock(
345
+ return_value=httpx.Response(200, json=SERVER)
346
+ )
347
+ with _client() as fx:
348
+ stopped = fx.private_servers.cancel(SERVER_ID)
349
+ resumed = fx.private_servers.resume(stopped)
350
+ assert cancel_route.called and resume_route.called
351
+ assert stopped.cancel_at_period_end
352
+ assert not resumed.cancel_at_period_end
353
+
354
+
355
+ @respx.mock
356
+ def test_resume_after_lapse() -> None:
357
+ respx.delete(f"{BASE}/private-servers/{SERVER_ID}/cancel").mock(
358
+ return_value=httpx.Response(
359
+ 409, json={"error": "already_lapsed", "detail": "Period has lapsed."}
360
+ )
361
+ )
362
+ with _client() as fx:
363
+ with pytest.raises(AlreadyLapsedError):
364
+ fx.private_servers.resume(SERVER_ID)
365
+
366
+
367
+ @respx.mock
368
+ def test_terminal_client_from_private_account() -> None:
369
+ respx.get(f"{BASE}/private-servers").mock(
370
+ return_value=httpx.Response(200, json=[SERVER])
371
+ )
372
+ with _client() as fx:
373
+ [server] = fx.private_servers.list()
374
+ term = fx.terminal(server.accounts[0], verify=False)
375
+ assert term is not None
376
+
377
+
378
+ @pytest.mark.asyncio
379
+ @respx.mock
380
+ async def test_async_mirror() -> None:
381
+ respx.get(f"{BASE}/private-servers").mock(
382
+ return_value=httpx.Response(200, json=[SERVER])
383
+ )
384
+ respx.post(f"{BASE}/private-servers/{SERVER_ID}/accounts").mock(
385
+ return_value=httpx.Response(201, json=SERVER_ACCOUNT)
386
+ )
387
+ respx.get(f"{BASE}/private-servers/regions").mock(
388
+ return_value=httpx.Response(200, json=REGIONS)
389
+ )
390
+ respx.post(f"{BASE}/private-servers").mock(
391
+ return_value=httpx.Response(201, json=SERVER)
392
+ )
393
+ respx.patch(f"{BASE}/private-servers/{SERVER_ID}").mock(
394
+ return_value=httpx.Response(200, json={**SERVER, "purchased_slots": 4})
395
+ )
396
+ respx.post(f"{BASE}/private-servers/{SERVER_ID}/cancel").mock(
397
+ return_value=httpx.Response(200, json={**SERVER, "cancel_at_period_end": True})
398
+ )
399
+ respx.delete(f"{BASE}/private-servers/{SERVER_ID}/cancel").mock(
400
+ return_value=httpx.Response(200, json=SERVER)
401
+ )
402
+ respx.delete(f"{BASE}/private-servers/{SERVER_ID}").mock(
403
+ return_value=httpx.Response(204)
404
+ )
405
+ async with AsyncClient(api_key="fxs_live_test") as fx:
406
+ [server] = await fx.private_servers.list()
407
+ account = await fx.private_servers.add_account(
408
+ server, server="ICMarkets-Demo", login=7001, password="pw"
409
+ )
410
+ options = await fx.private_servers.regions()
411
+ bought = await fx.private_servers.create(slots=2, region="lon1")
412
+ bigger = await fx.private_servers.resize(bought, slots=4)
413
+ stopped = await fx.private_servers.cancel(bought)
414
+ resumed = await fx.private_servers.resume(bought)
415
+ await fx.private_servers.delete(bought)
416
+ assert server.is_ready
417
+ assert options.region_codes == ["fra1", "lon1"]
418
+ assert bigger.purchased_slots == 4
419
+ assert stopped.cancel_at_period_end
420
+ assert not resumed.cancel_at_period_end
421
+ assert account.login == 7001
@@ -1 +0,0 @@
1
- __version__ = "0.6.0"
@@ -1,181 +0,0 @@
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": "https://159.223.244.125/22222222-2222-2222-2222-222222222222",
30
- "ws_url": "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
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes