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.
- {fxsocket-0.6.0 → fxsocket-0.8.0}/.github/workflows/ci.yml +2 -2
- {fxsocket-0.6.0 → fxsocket-0.8.0}/.github/workflows/publish.yml +5 -5
- fxsocket-0.8.0/CHANGELOG.md +39 -0
- {fxsocket-0.6.0 → fxsocket-0.8.0}/PKG-INFO +72 -7
- {fxsocket-0.6.0 → fxsocket-0.8.0}/README.md +71 -6
- {fxsocket-0.6.0 → fxsocket-0.8.0}/src/fxsocket/__init__.py +12 -0
- fxsocket-0.8.0/src/fxsocket/_version.py +1 -0
- {fxsocket-0.6.0 → fxsocket-0.8.0}/src/fxsocket/enums.py +7 -19
- {fxsocket-0.6.0 → fxsocket-0.8.0}/src/fxsocket/errors.py +39 -4
- {fxsocket-0.6.0 → fxsocket-0.8.0}/src/fxsocket/management.py +143 -2
- {fxsocket-0.6.0 → fxsocket-0.8.0}/src/fxsocket/models.py +49 -2
- {fxsocket-0.6.0 → fxsocket-0.8.0}/src/fxsocket/terminal/client.py +8 -23
- {fxsocket-0.6.0 → fxsocket-0.8.0}/src/fxsocket/terminal/stream.py +5 -11
- fxsocket-0.8.0/tests/test_private_servers.py +421 -0
- {fxsocket-0.6.0 → fxsocket-0.8.0}/tests/test_stream.py +6 -4
- {fxsocket-0.6.0 → fxsocket-0.8.0}/tests/test_terminal.py +25 -3
- 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.8.0}/.gitignore +0 -0
- {fxsocket-0.6.0 → fxsocket-0.8.0}/LICENSE +0 -0
- {fxsocket-0.6.0 → fxsocket-0.8.0}/examples/manage_accounts.py +0 -0
- {fxsocket-0.6.0 → fxsocket-0.8.0}/examples/multi_account_trade.py +0 -0
- {fxsocket-0.6.0 → fxsocket-0.8.0}/examples/stream_quotes.py +0 -0
- {fxsocket-0.6.0 → fxsocket-0.8.0}/examples/terminal_rest.py +0 -0
- {fxsocket-0.6.0 → fxsocket-0.8.0}/pyproject.toml +0 -0
- {fxsocket-0.6.0 → fxsocket-0.8.0}/src/fxsocket/_http.py +0 -0
- {fxsocket-0.6.0 → fxsocket-0.8.0}/src/fxsocket/client.py +0 -0
- {fxsocket-0.6.0 → fxsocket-0.8.0}/src/fxsocket/config.py +0 -0
- {fxsocket-0.6.0 → fxsocket-0.8.0}/src/fxsocket/py.typed +0 -0
- {fxsocket-0.6.0 → fxsocket-0.8.0}/src/fxsocket/terminal/__init__.py +0 -0
- {fxsocket-0.6.0 → fxsocket-0.8.0}/src/fxsocket/trading.py +0 -0
- {fxsocket-0.6.0 → fxsocket-0.8.0}/tests/test_errors.py +0 -0
- {fxsocket-0.6.0 → fxsocket-0.8.0}/tests/test_management.py +0 -0
- {fxsocket-0.6.0 → fxsocket-0.8.0}/tests/test_readonly_keys.py +0 -0
- {fxsocket-0.6.0 → fxsocket-0.8.0}/tests/test_trading.py +0 -0
- {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@
|
|
17
|
-
- uses: actions/setup-python@
|
|
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@
|
|
17
|
-
- uses: actions/setup-python@
|
|
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@
|
|
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@
|
|
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@
|
|
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.
|
|
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.
|
|
236
|
-
`
|
|
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})
|
|
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
|
-
|
|
464
|
-
|
|
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
|
|
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.
|
|
209
|
-
`
|
|
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})
|
|
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
|
-
|
|
437
|
-
|
|
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
|
|
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
|
-
|
|
233
|
-
|
|
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
|
-
#:
|
|
254
|
-
|
|
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``).
|
|
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
|
-
|
|
184
|
-
|
|
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
|
-
|
|
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,
|