fxsocket 0.6.0__tar.gz → 0.8.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 (36) hide show
  1. {fxsocket-0.6.0 → fxsocket-0.8.0}/.github/workflows/ci.yml +2 -2
  2. {fxsocket-0.6.0 → fxsocket-0.8.0}/.github/workflows/publish.yml +5 -5
  3. fxsocket-0.8.0/CHANGELOG.md +39 -0
  4. {fxsocket-0.6.0 → fxsocket-0.8.0}/PKG-INFO +72 -7
  5. {fxsocket-0.6.0 → fxsocket-0.8.0}/README.md +71 -6
  6. {fxsocket-0.6.0 → fxsocket-0.8.0}/src/fxsocket/__init__.py +12 -0
  7. fxsocket-0.8.0/src/fxsocket/_version.py +1 -0
  8. {fxsocket-0.6.0 → fxsocket-0.8.0}/src/fxsocket/enums.py +7 -19
  9. {fxsocket-0.6.0 → fxsocket-0.8.0}/src/fxsocket/errors.py +39 -4
  10. {fxsocket-0.6.0 → fxsocket-0.8.0}/src/fxsocket/management.py +143 -2
  11. {fxsocket-0.6.0 → fxsocket-0.8.0}/src/fxsocket/models.py +49 -2
  12. {fxsocket-0.6.0 → fxsocket-0.8.0}/src/fxsocket/terminal/client.py +8 -23
  13. {fxsocket-0.6.0 → fxsocket-0.8.0}/src/fxsocket/terminal/stream.py +5 -11
  14. fxsocket-0.8.0/tests/test_private_servers.py +421 -0
  15. {fxsocket-0.6.0 → fxsocket-0.8.0}/tests/test_stream.py +6 -4
  16. {fxsocket-0.6.0 → fxsocket-0.8.0}/tests/test_terminal.py +25 -3
  17. fxsocket-0.6.0/src/fxsocket/_version.py +0 -1
  18. fxsocket-0.6.0/tests/test_private_servers.py +0 -181
  19. {fxsocket-0.6.0 → fxsocket-0.8.0}/.gitignore +0 -0
  20. {fxsocket-0.6.0 → fxsocket-0.8.0}/LICENSE +0 -0
  21. {fxsocket-0.6.0 → fxsocket-0.8.0}/examples/manage_accounts.py +0 -0
  22. {fxsocket-0.6.0 → fxsocket-0.8.0}/examples/multi_account_trade.py +0 -0
  23. {fxsocket-0.6.0 → fxsocket-0.8.0}/examples/stream_quotes.py +0 -0
  24. {fxsocket-0.6.0 → fxsocket-0.8.0}/examples/terminal_rest.py +0 -0
  25. {fxsocket-0.6.0 → fxsocket-0.8.0}/pyproject.toml +0 -0
  26. {fxsocket-0.6.0 → fxsocket-0.8.0}/src/fxsocket/_http.py +0 -0
  27. {fxsocket-0.6.0 → fxsocket-0.8.0}/src/fxsocket/client.py +0 -0
  28. {fxsocket-0.6.0 → fxsocket-0.8.0}/src/fxsocket/config.py +0 -0
  29. {fxsocket-0.6.0 → fxsocket-0.8.0}/src/fxsocket/py.typed +0 -0
  30. {fxsocket-0.6.0 → fxsocket-0.8.0}/src/fxsocket/terminal/__init__.py +0 -0
  31. {fxsocket-0.6.0 → fxsocket-0.8.0}/src/fxsocket/trading.py +0 -0
  32. {fxsocket-0.6.0 → fxsocket-0.8.0}/tests/test_errors.py +0 -0
  33. {fxsocket-0.6.0 → fxsocket-0.8.0}/tests/test_management.py +0 -0
  34. {fxsocket-0.6.0 → fxsocket-0.8.0}/tests/test_readonly_keys.py +0 -0
  35. {fxsocket-0.6.0 → fxsocket-0.8.0}/tests/test_trading.py +0 -0
  36. {fxsocket-0.6.0 → fxsocket-0.8.0}/tests/test_wallet.py +0 -0
@@ -13,8 +13,8 @@ jobs:
13
13
  matrix:
14
14
  python-version: ["3.10", "3.11", "3.12", "3.13"]
15
15
  steps:
16
- - uses: actions/checkout@v4
17
- - uses: actions/setup-python@v5
16
+ - uses: actions/checkout@v7
17
+ - uses: actions/setup-python@v7
18
18
  with:
19
19
  python-version: ${{ matrix.python-version }}
20
20
  - name: Install
@@ -13,8 +13,8 @@ jobs:
13
13
  build:
14
14
  runs-on: ubuntu-latest
15
15
  steps:
16
- - uses: actions/checkout@v4
17
- - uses: actions/setup-python@v5
16
+ - uses: actions/checkout@v7
17
+ - uses: actions/setup-python@v7
18
18
  with:
19
19
  python-version: "3.12"
20
20
  - name: Build sdist + wheel
@@ -22,7 +22,7 @@ jobs:
22
22
  python -m pip install --upgrade build twine
23
23
  python -m build
24
24
  twine check dist/*
25
- - uses: actions/upload-artifact@v4
25
+ - uses: actions/upload-artifact@v7
26
26
  with:
27
27
  name: dist
28
28
  path: dist/
@@ -36,7 +36,7 @@ jobs:
36
36
  permissions:
37
37
  id-token: write # required for trusted publishing
38
38
  steps:
39
- - uses: actions/download-artifact@v4
39
+ - uses: actions/download-artifact@v8
40
40
  with:
41
41
  name: dist
42
42
  path: dist/
@@ -53,7 +53,7 @@ jobs:
53
53
  permissions:
54
54
  id-token: write # required for trusted publishing
55
55
  steps:
56
- - uses: actions/download-artifact@v4
56
+ - uses: actions/download-artifact@v8
57
57
  with:
58
58
  name: dist
59
59
  path: dist/
@@ -0,0 +1,39 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented here. The format follows
4
+ [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project
5
+ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). Releases
6
+ up to 0.7.0 are described in their tagged commits.
7
+
8
+ ## [0.8.0] - 2026-10-26
9
+
10
+ ### Removed
11
+
12
+ - `Timeframe.M2`, `M3`, `H2`, `H6`, `H8` and `H12`, and the `2min`, `3min`,
13
+ `2h`, `6h`, `8h` and `12h` aliases. The SDK treated them as MT5-only, but
14
+ neither terminal API serves them: MT4 and MT5 both accept exactly `M1`, `M5`,
15
+ `M15`, `M30`, `H1`, `H4`, `D1`, `W1` and `MN1`, in REST `price_history` and on
16
+ the `bars` stream. On an MT5 account they passed the SDK and the terminal
17
+ refused them (HTTP 400 from `price_history`, an `error` frame and no bars
18
+ from the stream).
19
+
20
+ They are removed rather than kept and rejected on both platforms, so that
21
+ `Timeframe` lists exactly what the server accepts: iterating it gives the
22
+ usable set, and type checkers flag the removed names. The only code this
23
+ breaks is code that could never succeed.
24
+
25
+ ### Changed
26
+
27
+ - A timeframe outside those nine now raises `ValidationError` on both
28
+ platforms before anything is sent, and the message lists the accepted
29
+ labels. On MT4 the six removed ones used to raise
30
+ `UnsupportedOnPlatformError`, so code that caught that for timeframes should
31
+ catch `ValidationError` instead. Nothing raises `UnsupportedOnPlatformError`
32
+ now; it stays exported.
33
+
34
+ ### Deprecated
35
+
36
+ - `fxsocket.enums.MT5_ONLY_TIMEFRAMES` is always empty and will be removed in
37
+ 1.0.
38
+
39
+ [0.8.0]: https://github.com/fxsocket-com/FxSocket-Python-SDK/releases/tag/v0.8.0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: fxsocket
3
- Version: 0.6.0
3
+ Version: 0.8.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
@@ -232,8 +232,11 @@ in `order_modify`, a literal `stop_loss=0.0` would *remove* your stop-loss, so
232
232
  it's rejected — pass `clear_stop_loss=True` to remove one deliberately, while
233
233
  `None` (the default) keeps the current value.
234
234
 
235
- MT4 and MT5 share one interface. MT5-only timeframes (`M2`, `M3`, `H2`, `H6`,
236
- `H8`, `H12`) raise `UnsupportedOnPlatformError` on MT4 before any request.
235
+ MT4 and MT5 share one interface, timeframes included. Both accept the same nine:
236
+ `M1`, `M5`, `M15`, `M30`, `H1`, `H4`, `D1`, `W1`, `MN1` (or `1min`, `5min`,
237
+ `15min`, `30min`, `1h`, `4h`, `1d`, `1w`, `1month`). MT5's other periods, such
238
+ as `H2` or `H12`, aren't served by the terminal API, so anything else raises
239
+ `ValidationError` before any request.
237
240
 
238
241
  > **MT4 history note:** on MT4, `price_history` with `from_`/`to` bounds (or the
239
242
  > `D1` timeframe) can fail server-side with `CopyRates failed` when the terminal
@@ -443,7 +446,8 @@ with Client(api_key="fxs_live_...", verify_terminal_tls=False) as fx:
443
446
  server, server="ICMarkets-Demo", login=1150125, password="..."
444
447
  )
445
448
  except SlotsFullError as err:
446
- print(f"Server full ({err.used}/{err.cap}) — raise the limit in the dashboard.")
449
+ print(f"Server full ({err.used}/{err.cap})")
450
+ server = fx.private_servers.resize(server, slots=err.cap + 1)
447
451
 
448
452
  # Poll until the on-server agent has the terminal up, then trade as usual.
449
453
  while True:
@@ -460,8 +464,68 @@ Accounts on a private server are traded and streamed exactly like
460
464
  shared-cluster accounts — their `rest_url` / `ws_url` simply point at the
461
465
  server's dedicated IP. The server presents a self-signed certificate, so reach
462
466
  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.
467
+
468
+ `server.cancel_at_period_end` is `True` once a server has been told to stop
469
+ instead of renewing; it then runs until `server.period_end` and expires.
470
+
471
+ ### Buying, resizing and canceling
472
+
473
+ `fx.private_servers.regions()` returns where servers may run, how big they may
474
+ be and what that costs — call it before buying rather than hardcoding slugs:
475
+
476
+ ```python
477
+ options = fx.private_servers.regions()
478
+ if options.enabled:
479
+ print(options.region_codes) # ['fra1', 'lon1', ...]
480
+ print(options.max_slots, options.max_servers)
481
+ print(options.monthly_price_eur(3)) # Decimal('45') — 3 slots/month
482
+ ```
483
+
484
+ `create()` buys one, charged to the prepaid balance immediately. It comes back
485
+ `provisioning`; poll `get()` until it is `ready`, usually a couple of minutes:
486
+
487
+ ```python
488
+ from fxsocket import InsufficientBalanceError, PrivateServerStatus, ServerLimitError
489
+
490
+ try:
491
+ server = fx.private_servers.create(slots=2, region="fra1", name="prop-guard")
492
+ except InsufficientBalanceError as err:
493
+ print(f"Top up {err.shortfall_eur} EUR first")
494
+ except ServerLimitError:
495
+ print(f"Already own the maximum ({options.max_servers})")
496
+
497
+ while server.status != PrivateServerStatus.READY:
498
+ time.sleep(10)
499
+ server = fx.private_servers.get(server)
500
+ ```
501
+
502
+ `resize()` changes the slot count. Increases are prorated over the rest of the
503
+ period and charged now (the renewal date doesn't move); decreases are free and
504
+ apply at the next renewal, so paid-for capacity is never destroyed mid-month.
505
+ Shrinking below the accounts already on the server raises
506
+ `AccountsExceedTargetError` — remove accounts first:
507
+
508
+ ```python
509
+ server = fx.private_servers.resize(server, slots=4)
510
+ ```
511
+
512
+ `cancel()` stops the server renewing: it runs until `period_end`, then expires.
513
+ `resume()` undoes that while the period lasts (afterwards the machine is gone
514
+ and `AlreadyLapsedError` is raised). `delete()` destroys the machine and every
515
+ account on it right away, with **no refund** for the rest of the prepaid month —
516
+ prefer `cancel()` unless you really want it gone now:
517
+
518
+ ```python
519
+ server = fx.private_servers.cancel(server) # stop at period_end
520
+ assert server.cancel_at_period_end
521
+ server = fx.private_servers.resume(server) # changed your mind
522
+ fx.private_servers.delete(server) # irreversible, no refund
523
+ ```
524
+
525
+ All of these move the prepaid balance, so they only work on balance-funded
526
+ servers — a card- or crypto-funded one raises `NotBalanceFundedError` (a
527
+ `ForbiddenError` subclass) and is managed from the dashboard. Read-only
528
+ `fxs_ro_…` keys get a plain `ForbiddenError`.
465
529
 
466
530
  ## Wallet
467
531
 
@@ -483,7 +547,8 @@ if not wallet.covers_upcoming:
483
547
  Affordability is cumulative — with 24 EUR and three 12 EUR renewals the
484
548
  first two are covered and the third is not — so `shortfall_eur` is the
485
549
  total gap, not the size of any single charge. Topping up happens in the
486
- dashboard; the SDK deliberately exposes no payment operations.
550
+ dashboard; the balance is only ever *spent* through the SDK (account seats
551
+ and `private_servers.create()` / `resize()`), never topped up.
487
552
 
488
553
  ## Timestamps
489
554
 
@@ -205,8 +205,11 @@ in `order_modify`, a literal `stop_loss=0.0` would *remove* your stop-loss, so
205
205
  it's rejected — pass `clear_stop_loss=True` to remove one deliberately, while
206
206
  `None` (the default) keeps the current value.
207
207
 
208
- MT4 and MT5 share one interface. MT5-only timeframes (`M2`, `M3`, `H2`, `H6`,
209
- `H8`, `H12`) raise `UnsupportedOnPlatformError` on MT4 before any request.
208
+ MT4 and MT5 share one interface, timeframes included. Both accept the same nine:
209
+ `M1`, `M5`, `M15`, `M30`, `H1`, `H4`, `D1`, `W1`, `MN1` (or `1min`, `5min`,
210
+ `15min`, `30min`, `1h`, `4h`, `1d`, `1w`, `1month`). MT5's other periods, such
211
+ as `H2` or `H12`, aren't served by the terminal API, so anything else raises
212
+ `ValidationError` before any request.
210
213
 
211
214
  > **MT4 history note:** on MT4, `price_history` with `from_`/`to` bounds (or the
212
215
  > `D1` timeframe) can fail server-side with `CopyRates failed` when the terminal
@@ -416,7 +419,8 @@ with Client(api_key="fxs_live_...", verify_terminal_tls=False) as fx:
416
419
  server, server="ICMarkets-Demo", login=1150125, password="..."
417
420
  )
418
421
  except SlotsFullError as err:
419
- print(f"Server full ({err.used}/{err.cap}) — raise the limit in the dashboard.")
422
+ print(f"Server full ({err.used}/{err.cap})")
423
+ server = fx.private_servers.resize(server, slots=err.cap + 1)
420
424
 
421
425
  # Poll until the on-server agent has the terminal up, then trade as usual.
422
426
  while True:
@@ -433,8 +437,68 @@ Accounts on a private server are traded and streamed exactly like
433
437
  shared-cluster accounts — their `rest_url` / `ws_url` simply point at the
434
438
  server's dedicated IP. The server presents a self-signed certificate, so reach
435
439
  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.
440
+
441
+ `server.cancel_at_period_end` is `True` once a server has been told to stop
442
+ instead of renewing; it then runs until `server.period_end` and expires.
443
+
444
+ ### Buying, resizing and canceling
445
+
446
+ `fx.private_servers.regions()` returns where servers may run, how big they may
447
+ be and what that costs — call it before buying rather than hardcoding slugs:
448
+
449
+ ```python
450
+ options = fx.private_servers.regions()
451
+ if options.enabled:
452
+ print(options.region_codes) # ['fra1', 'lon1', ...]
453
+ print(options.max_slots, options.max_servers)
454
+ print(options.monthly_price_eur(3)) # Decimal('45') — 3 slots/month
455
+ ```
456
+
457
+ `create()` buys one, charged to the prepaid balance immediately. It comes back
458
+ `provisioning`; poll `get()` until it is `ready`, usually a couple of minutes:
459
+
460
+ ```python
461
+ from fxsocket import InsufficientBalanceError, PrivateServerStatus, ServerLimitError
462
+
463
+ try:
464
+ server = fx.private_servers.create(slots=2, region="fra1", name="prop-guard")
465
+ except InsufficientBalanceError as err:
466
+ print(f"Top up {err.shortfall_eur} EUR first")
467
+ except ServerLimitError:
468
+ print(f"Already own the maximum ({options.max_servers})")
469
+
470
+ while server.status != PrivateServerStatus.READY:
471
+ time.sleep(10)
472
+ server = fx.private_servers.get(server)
473
+ ```
474
+
475
+ `resize()` changes the slot count. Increases are prorated over the rest of the
476
+ period and charged now (the renewal date doesn't move); decreases are free and
477
+ apply at the next renewal, so paid-for capacity is never destroyed mid-month.
478
+ Shrinking below the accounts already on the server raises
479
+ `AccountsExceedTargetError` — remove accounts first:
480
+
481
+ ```python
482
+ server = fx.private_servers.resize(server, slots=4)
483
+ ```
484
+
485
+ `cancel()` stops the server renewing: it runs until `period_end`, then expires.
486
+ `resume()` undoes that while the period lasts (afterwards the machine is gone
487
+ and `AlreadyLapsedError` is raised). `delete()` destroys the machine and every
488
+ account on it right away, with **no refund** for the rest of the prepaid month —
489
+ prefer `cancel()` unless you really want it gone now:
490
+
491
+ ```python
492
+ server = fx.private_servers.cancel(server) # stop at period_end
493
+ assert server.cancel_at_period_end
494
+ server = fx.private_servers.resume(server) # changed your mind
495
+ fx.private_servers.delete(server) # irreversible, no refund
496
+ ```
497
+
498
+ All of these move the prepaid balance, so they only work on balance-funded
499
+ servers — a card- or crypto-funded one raises `NotBalanceFundedError` (a
500
+ `ForbiddenError` subclass) and is managed from the dashboard. Read-only
501
+ `fxs_ro_…` keys get a plain `ForbiddenError`.
438
502
 
439
503
  ## Wallet
440
504
 
@@ -456,7 +520,8 @@ if not wallet.covers_upcoming:
456
520
  Affordability is cumulative — with 24 EUR and three 12 EUR renewals the
457
521
  first two are covered and the third is not — so `shortfall_eur` is the
458
522
  total gap, not the size of any single charge. Topping up happens in the
459
- dashboard; the SDK deliberately exposes no payment operations.
523
+ dashboard; the balance is only ever *spent* through the SDK (account seats
524
+ and `private_servers.create()` / `resize()`), never topped up.
460
525
 
461
526
  ## Timestamps
462
527
 
@@ -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.8.0"
@@ -229,35 +229,23 @@ class HealthStatus(str, Enum):
229
229
  class Timeframe(str, Enum):
230
230
  """Candle timeframes.
231
231
 
232
- The MT5-only members (``M2``, ``M3``, ``H2``, ``H6``, ``H8``, ``H12``)
233
- are rejected client-side for MT4 accounts.
232
+ Exactly the nine the terminal API accepts, on MT4 and MT5 alike (REST
233
+ ``price_history`` and the ``bars`` stream). MT5's other native periods
234
+ (``M2``, ``H2``, ``H12`` …) are not exposed by the terminal API, so they
235
+ are not members.
234
236
  """
235
237
 
236
238
  M1 = "M1"
237
- M2 = "M2"
238
- M3 = "M3"
239
239
  M5 = "M5"
240
240
  M15 = "M15"
241
241
  M30 = "M30"
242
242
  H1 = "H1"
243
- H2 = "H2"
244
243
  H4 = "H4"
245
- H6 = "H6"
246
- H8 = "H8"
247
- H12 = "H12"
248
244
  D1 = "D1"
249
245
  W1 = "W1"
250
246
  MN1 = "MN1"
251
247
 
252
248
 
253
- #: Timeframes that only MT5 supports.
254
- MT5_ONLY_TIMEFRAMES = frozenset(
255
- {
256
- Timeframe.M2,
257
- Timeframe.M3,
258
- Timeframe.H2,
259
- Timeframe.H6,
260
- Timeframe.H8,
261
- Timeframe.H12,
262
- }
263
- )
249
+ #: Deprecated, always empty: both platforms accept every :class:`Timeframe`.
250
+ #: Kept so existing imports don't break; will be removed in 1.0.
251
+ MT5_ONLY_TIMEFRAMES: frozenset[Timeframe] = frozenset()
@@ -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
 
@@ -180,8 +206,9 @@ class TerminalTimeoutError(FxSocketError):
180
206
  class UnsupportedOnPlatformError(FxSocketError):
181
207
  """A requested feature doesn't exist on the account's platform.
182
208
 
183
- Enforced client-side — e.g. stop-limit orders or MT5-only timeframes on
184
- an MT4 account.
209
+ Nothing raises it at the moment: the MT4 and MT5 terminal APIs accept the
210
+ same timeframes and order operations. It stays exported for platform gaps
211
+ that need a client-side guard later.
185
212
  """
186
213
 
187
214
 
@@ -228,6 +255,8 @@ def error_from_response(resp: httpx.Response) -> FxSocketError:
228
255
  if status == 401:
229
256
  return AuthError(message, **common)
230
257
  if status == 403:
258
+ if code == "not_balance_funded":
259
+ return NotBalanceFundedError(message, **common)
231
260
  return ForbiddenError(message, **common)
232
261
  if code in _IDEMPOTENCY_CODES:
233
262
  return IdempotencyError(message, **common)
@@ -247,6 +276,12 @@ def error_from_response(resp: httpx.Response) -> FxSocketError:
247
276
  return SlotsFullError(
248
277
  message, used=body.get("used"), cap=body.get("cap"), **common
249
278
  )
279
+ if code == "server_limit_reached":
280
+ return ServerLimitError(message, **common)
281
+ if code == "accounts_exceed_target":
282
+ return AccountsExceedTargetError(message, **common)
283
+ if code == "already_lapsed":
284
+ return AlreadyLapsedError(message, **common)
250
285
  return DuplicateAccountError(message, **common)
251
286
  if status == 402:
252
287
  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,