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.
- {fxsocket-0.6.0 → fxsocket-0.7.0}/PKG-INFO +67 -5
- {fxsocket-0.6.0 → fxsocket-0.7.0}/README.md +66 -4
- {fxsocket-0.6.0 → fxsocket-0.7.0}/src/fxsocket/__init__.py +12 -0
- fxsocket-0.7.0/src/fxsocket/_version.py +1 -0
- {fxsocket-0.6.0 → fxsocket-0.7.0}/src/fxsocket/errors.py +36 -2
- {fxsocket-0.6.0 → fxsocket-0.7.0}/src/fxsocket/management.py +143 -2
- {fxsocket-0.6.0 → fxsocket-0.7.0}/src/fxsocket/models.py +49 -2
- fxsocket-0.7.0/tests/test_private_servers.py +421 -0
- fxsocket-0.6.0/src/fxsocket/_version.py +0 -1
- fxsocket-0.6.0/tests/test_private_servers.py +0 -181
- {fxsocket-0.6.0 → fxsocket-0.7.0}/.github/workflows/ci.yml +0 -0
- {fxsocket-0.6.0 → fxsocket-0.7.0}/.github/workflows/publish.yml +0 -0
- {fxsocket-0.6.0 → fxsocket-0.7.0}/.gitignore +0 -0
- {fxsocket-0.6.0 → fxsocket-0.7.0}/LICENSE +0 -0
- {fxsocket-0.6.0 → fxsocket-0.7.0}/examples/manage_accounts.py +0 -0
- {fxsocket-0.6.0 → fxsocket-0.7.0}/examples/multi_account_trade.py +0 -0
- {fxsocket-0.6.0 → fxsocket-0.7.0}/examples/stream_quotes.py +0 -0
- {fxsocket-0.6.0 → fxsocket-0.7.0}/examples/terminal_rest.py +0 -0
- {fxsocket-0.6.0 → fxsocket-0.7.0}/pyproject.toml +0 -0
- {fxsocket-0.6.0 → fxsocket-0.7.0}/src/fxsocket/_http.py +0 -0
- {fxsocket-0.6.0 → fxsocket-0.7.0}/src/fxsocket/client.py +0 -0
- {fxsocket-0.6.0 → fxsocket-0.7.0}/src/fxsocket/config.py +0 -0
- {fxsocket-0.6.0 → fxsocket-0.7.0}/src/fxsocket/enums.py +0 -0
- {fxsocket-0.6.0 → fxsocket-0.7.0}/src/fxsocket/py.typed +0 -0
- {fxsocket-0.6.0 → fxsocket-0.7.0}/src/fxsocket/terminal/__init__.py +0 -0
- {fxsocket-0.6.0 → fxsocket-0.7.0}/src/fxsocket/terminal/client.py +0 -0
- {fxsocket-0.6.0 → fxsocket-0.7.0}/src/fxsocket/terminal/stream.py +0 -0
- {fxsocket-0.6.0 → fxsocket-0.7.0}/src/fxsocket/trading.py +0 -0
- {fxsocket-0.6.0 → fxsocket-0.7.0}/tests/test_errors.py +0 -0
- {fxsocket-0.6.0 → fxsocket-0.7.0}/tests/test_management.py +0 -0
- {fxsocket-0.6.0 → fxsocket-0.7.0}/tests/test_readonly_keys.py +0 -0
- {fxsocket-0.6.0 → fxsocket-0.7.0}/tests/test_stream.py +0 -0
- {fxsocket-0.6.0 → fxsocket-0.7.0}/tests/test_terminal.py +0 -0
- {fxsocket-0.6.0 → fxsocket-0.7.0}/tests/test_trading.py +0 -0
- {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.
|
|
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})
|
|
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
|
-
|
|
464
|
-
|
|
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
|
|
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})
|
|
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
|
-
|
|
437
|
-
|
|
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
|
|
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``).
|
|
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
|
-
|
|
256
|
-
|
|
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
|
-
|
|
135
|
-
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|