fxsocket 0.5.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.5.0 → fxsocket-0.7.0}/PKG-INFO +232 -6
- {fxsocket-0.5.0 → fxsocket-0.7.0}/README.md +231 -5
- fxsocket-0.7.0/examples/multi_account_trade.py +49 -0
- {fxsocket-0.5.0 → fxsocket-0.7.0}/src/fxsocket/__init__.py +70 -2
- {fxsocket-0.5.0 → fxsocket-0.7.0}/src/fxsocket/_http.py +8 -2
- fxsocket-0.7.0/src/fxsocket/_version.py +1 -0
- {fxsocket-0.5.0 → fxsocket-0.7.0}/src/fxsocket/client.py +20 -4
- {fxsocket-0.5.0 → fxsocket-0.7.0}/src/fxsocket/enums.py +101 -0
- fxsocket-0.7.0/src/fxsocket/errors.py +313 -0
- fxsocket-0.7.0/src/fxsocket/management.py +745 -0
- fxsocket-0.7.0/src/fxsocket/models.py +1181 -0
- fxsocket-0.7.0/src/fxsocket/trading.py +393 -0
- {fxsocket-0.5.0 → fxsocket-0.7.0}/tests/test_errors.py +48 -0
- {fxsocket-0.5.0 → fxsocket-0.7.0}/tests/test_management.py +98 -0
- fxsocket-0.7.0/tests/test_private_servers.py +421 -0
- fxsocket-0.7.0/tests/test_readonly_keys.py +217 -0
- fxsocket-0.7.0/tests/test_trading.py +516 -0
- fxsocket-0.7.0/tests/test_wallet.py +97 -0
- fxsocket-0.5.0/src/fxsocket/_version.py +0 -1
- fxsocket-0.5.0/src/fxsocket/errors.py +0 -185
- fxsocket-0.5.0/src/fxsocket/management.py +0 -264
- fxsocket-0.5.0/src/fxsocket/models.py +0 -618
- fxsocket-0.5.0/tests/test_private_servers.py +0 -181
- {fxsocket-0.5.0 → fxsocket-0.7.0}/.github/workflows/ci.yml +0 -0
- {fxsocket-0.5.0 → fxsocket-0.7.0}/.github/workflows/publish.yml +0 -0
- {fxsocket-0.5.0 → fxsocket-0.7.0}/.gitignore +0 -0
- {fxsocket-0.5.0 → fxsocket-0.7.0}/LICENSE +0 -0
- {fxsocket-0.5.0 → fxsocket-0.7.0}/examples/manage_accounts.py +0 -0
- {fxsocket-0.5.0 → fxsocket-0.7.0}/examples/stream_quotes.py +0 -0
- {fxsocket-0.5.0 → fxsocket-0.7.0}/examples/terminal_rest.py +0 -0
- {fxsocket-0.5.0 → fxsocket-0.7.0}/pyproject.toml +0 -0
- {fxsocket-0.5.0 → fxsocket-0.7.0}/src/fxsocket/config.py +0 -0
- {fxsocket-0.5.0 → fxsocket-0.7.0}/src/fxsocket/py.typed +0 -0
- {fxsocket-0.5.0 → fxsocket-0.7.0}/src/fxsocket/terminal/__init__.py +0 -0
- {fxsocket-0.5.0 → fxsocket-0.7.0}/src/fxsocket/terminal/client.py +0 -0
- {fxsocket-0.5.0 → fxsocket-0.7.0}/src/fxsocket/terminal/stream.py +0 -0
- {fxsocket-0.5.0 → fxsocket-0.7.0}/tests/test_stream.py +0 -0
- {fxsocket-0.5.0 → fxsocket-0.7.0}/tests/test_terminal.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
|
|
@@ -42,7 +42,12 @@ interfaces.
|
|
|
42
42
|
- **Account management** — link, list, fetch, and disconnect MT4/MT5 accounts.
|
|
43
43
|
- **Private servers** — list your dedicated hosting servers and manage the
|
|
44
44
|
accounts on them.
|
|
45
|
+
- **Read-only keys** — mint, scope, rotate and revoke `fxs_ro_…` keys for
|
|
46
|
+
dashboards and monitors.
|
|
45
47
|
- **Trading** — market & pending orders, modify, close, close-all, plus margin/profit calculators.
|
|
48
|
+
- **Multi-account trading** — one order, or one close, fanned out to several
|
|
49
|
+
accounts in a single request, with idempotency keys for safe retries.
|
|
50
|
+
- **Wallet** — read your prepaid balance, pending top-ups and upcoming charges.
|
|
46
51
|
- **Market data** — quotes, symbol specifications (incl. commission rules & trading
|
|
47
52
|
sessions), OHLC history, account state & info.
|
|
48
53
|
- **Live streaming** — ticks, bars, account, positions, trades, and terminal status
|
|
@@ -104,9 +109,28 @@ with Client(api_key="fxs_live_…") as fx:
|
|
|
104
109
|
# Where this account's terminal API lives (empty until provisioned).
|
|
105
110
|
print(account.rest_url, account.ws_url)
|
|
106
111
|
|
|
112
|
+
# Move the trade expert to a specific chart symbol (the terminal
|
|
113
|
+
# restarts on it — poll until connected again). "" reverts to automatic.
|
|
114
|
+
account = fx.accounts.update(account, trade_ea_symbol="EURUSDm")
|
|
115
|
+
|
|
107
116
|
fx.accounts.delete(account.id) # unlink
|
|
108
117
|
```
|
|
109
118
|
|
|
119
|
+
An account's terminal can be routed through an outbound proxy at link time.
|
|
120
|
+
The proxy is verified first — an unreachable one raises `ConnectFailedError`
|
|
121
|
+
with `code == "proxy_unreachable"` and nothing is created. The address, type
|
|
122
|
+
and local port are readable on the `Account`; the credentials never come
|
|
123
|
+
back.
|
|
124
|
+
|
|
125
|
+
```python
|
|
126
|
+
account = fx.accounts.create(
|
|
127
|
+
server="ICMarkets-Demo", login=1150125, password="…",
|
|
128
|
+
trade_ea_symbol="EURUSDm", # optional, broker-exact
|
|
129
|
+
proxy_address="10.0.0.5:1080", proxy_type="socks5",
|
|
130
|
+
proxy_auth="user:secret", proxy_local_port=1080,
|
|
131
|
+
)
|
|
132
|
+
```
|
|
133
|
+
|
|
110
134
|
Everything is also available on `AsyncClient`:
|
|
111
135
|
|
|
112
136
|
```python
|
|
@@ -116,6 +140,36 @@ async with AsyncClient(api_key="fxs_live_…") as fx:
|
|
|
116
140
|
accounts = await fx.accounts.list()
|
|
117
141
|
```
|
|
118
142
|
|
|
143
|
+
## Read-only keys
|
|
144
|
+
|
|
145
|
+
A read-only key (`fxs_ro_…`) can call every GET endpoint but nothing that
|
|
146
|
+
mutates state. `fx.readonly_keys` manages named ones; each has a `scope` —
|
|
147
|
+
`all` sees every account, `selected` only the accounts attached to it
|
|
148
|
+
(everything else is absent from lists, 404 by id and 401 at the terminal).
|
|
149
|
+
Passing `accounts` implies `selected`.
|
|
150
|
+
|
|
151
|
+
```python
|
|
152
|
+
from fxsocket import Client, KeyScope
|
|
153
|
+
|
|
154
|
+
with Client(api_key="fxs_live_…") as fx:
|
|
155
|
+
key = fx.readonly_keys.create(name="dashboard", accounts=[account])
|
|
156
|
+
print(key.key) # plaintext, returned on every read
|
|
157
|
+
|
|
158
|
+
key = fx.readonly_keys.update(key, name="ops-dashboard")
|
|
159
|
+
key = fx.readonly_keys.rotate(key) # new secret, same name & scope
|
|
160
|
+
fx.readonly_keys.delete(key) # revoke — immediate, irreversible
|
|
161
|
+
|
|
162
|
+
for k in fx.readonly_keys.list():
|
|
163
|
+
print(k.name, k.scope == KeyScope.ALL, k.last_used_at)
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
Terminals are started with the exact set of read-only keys they accept, so
|
|
167
|
+
creating, re-scoping, rotating or revoking a key **restarts the terminals of
|
|
168
|
+
every account in its scope** — each goes briefly offline, typically a few
|
|
169
|
+
minutes. Renaming is free. Key management itself always needs the full
|
|
170
|
+
`fxs_live_…` key, even for reads, because the replies contain plaintext
|
|
171
|
+
key values.
|
|
172
|
+
|
|
119
173
|
## Trading & market data
|
|
120
174
|
|
|
121
175
|
`fx.terminal(account)` returns a REST client bound to that account's terminal
|
|
@@ -186,6 +240,86 @@ MT4 and MT5 share one interface. MT5-only timeframes (`M2`, `M3`, `H2`, `H6`,
|
|
|
186
240
|
> hasn't loaded that history. Calling `price_history(symbol, timeframe)` without
|
|
187
241
|
> date bounds returns the most recent bars reliably.
|
|
188
242
|
|
|
243
|
+
## Multi-account trading
|
|
244
|
+
|
|
245
|
+
`fx.orders` sends one order — or one close — to several accounts in a single
|
|
246
|
+
request, through the management API rather than each terminal. Every leg
|
|
247
|
+
inherits `defaults` and may override any of it, so "same trade, three
|
|
248
|
+
accounts, three lot sizes" stays short. A leg can be an `OrderLeg`, a plain
|
|
249
|
+
dict, or just the account (object or id) when `defaults` say everything else.
|
|
250
|
+
|
|
251
|
+
```python
|
|
252
|
+
from fxsocket import Client, OrderDefaults, OrderLeg
|
|
253
|
+
|
|
254
|
+
with Client(api_key="fxs_live_…") as fx:
|
|
255
|
+
accounts = [a for a in fx.accounts.list() if a.has_terminal]
|
|
256
|
+
|
|
257
|
+
result = fx.orders.send(
|
|
258
|
+
[OrderLeg(account_id=a, volume=0.1 * (i + 1)) for i, a in enumerate(accounts)],
|
|
259
|
+
defaults=OrderDefaults(symbol="EURUSD", operation="buy", stop_loss=1.07),
|
|
260
|
+
idempotency_key="signal-4711",
|
|
261
|
+
)
|
|
262
|
+
for account, leg in zip(accounts, result.results):
|
|
263
|
+
print(account.nickname, leg.status, leg.order, leg.message)
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
**Validation is all-or-nothing, execution is not.** A malformed leg raises
|
|
267
|
+
`ValidationError` before anything is sent (the SDK checks what the API
|
|
268
|
+
checks — symbol / operation / volume present, a price for pending orders,
|
|
269
|
+
…). Once dispatched the legs are independent: you get a `BatchOrderResult`
|
|
270
|
+
with a positional `results` list — `zip` it with what you sent rather than
|
|
271
|
+
matching on `account_id`, which repeats when several legs target one
|
|
272
|
+
account. Each `OrderLegResult.status` is only trustworthy as `filled`; a
|
|
273
|
+
`timeout` leg *may* have executed (`is_unknown`), so never blind-retry it.
|
|
274
|
+
`result.all_filled`, `filled_legs`, `failed_legs` and `unknown_legs` roll
|
|
275
|
+
this up.
|
|
276
|
+
|
|
277
|
+
Symbols are per broker (`EURUSD`, `EURUSD.sd`, `EURUSDm`), so a single
|
|
278
|
+
`defaults` symbol across mixed brokers will partly fail by design — that is
|
|
279
|
+
what a leg-level `symbol` is for.
|
|
280
|
+
|
|
281
|
+
**Idempotency.** Pass an `idempotency_key` (any opaque string, ≤ 128 chars)
|
|
282
|
+
whenever a retry is possible: replaying the identical batch with the same
|
|
283
|
+
key returns the stored reply (`idempotent_replay=True`) and sends nothing.
|
|
284
|
+
Keys are remembered for 15 minutes. A key that is still in flight, was
|
|
285
|
+
already used for a different body, or can't currently be guaranteed raises
|
|
286
|
+
`IdempotencyError` — nothing is sent in any of those cases.
|
|
287
|
+
|
|
288
|
+
Closing works by **selector**, not by ticket: each account is matched
|
|
289
|
+
against `symbol` (plus optional `side`, `kind`, `magic`), the backend
|
|
290
|
+
resolves the tickets from that account's open orders and closes them.
|
|
291
|
+
`symbol` is required — type `"*"` to mean every symbol; it is never implied.
|
|
292
|
+
`symbol_match="base"` lets one selector reach `EURUSD`, `EURUSD.sd` and
|
|
293
|
+
`EURUSDm` across brokers. `kind` defaults to `position`, so a routine close
|
|
294
|
+
does not also delete resting pending orders.
|
|
295
|
+
|
|
296
|
+
```python
|
|
297
|
+
from fxsocket import CloseDefaults, CloseLeg
|
|
298
|
+
|
|
299
|
+
closed = fx.orders.close(
|
|
300
|
+
[
|
|
301
|
+
CloseLeg(account_id=accounts[0], side="long", volume=0.05), # partial
|
|
302
|
+
CloseLeg(account_id=accounts[1], tickets=[123456, 123457]), # explicit
|
|
303
|
+
accounts[2], # defaults only
|
|
304
|
+
],
|
|
305
|
+
defaults=CloseDefaults(symbol="EURUSD", symbol_match="base"),
|
|
306
|
+
idempotency_key="flatten-4711",
|
|
307
|
+
)
|
|
308
|
+
for leg in closed.results:
|
|
309
|
+
print(leg.account_id, leg.status, f"{leg.closed}/{leg.matched}")
|
|
310
|
+
for ticket in leg.results:
|
|
311
|
+
print(" ", ticket.ticket, ticket.status, ticket.retcode_description)
|
|
312
|
+
```
|
|
313
|
+
|
|
314
|
+
`nothing_matched` is a normal answer, not an error. Per-account `status`
|
|
315
|
+
compares against `CloseLegStatus` and per-ticket against
|
|
316
|
+
`ClosedTicketStatus`; `skipped` tickets were never sent, `timeout` ones may
|
|
317
|
+
well have closed. An `idempotency_key` matters most for partial closes,
|
|
318
|
+
where a blind retry genuinely over-closes.
|
|
319
|
+
|
|
320
|
+
Both calls need the full `fxs_live_…` key — a read-only key raises
|
|
321
|
+
`ForbiddenError`.
|
|
322
|
+
|
|
189
323
|
## Streaming (WebSocket)
|
|
190
324
|
|
|
191
325
|
Subscribe to live ticks, bars, account, positions, trades, and terminal status.
|
|
@@ -265,22 +399,30 @@ Every failure raises a subclass of `fxsocket.FxSocketError`:
|
|
|
265
399
|
| Exception | When |
|
|
266
400
|
|---|---|
|
|
267
401
|
| `AuthError` | missing/invalid API key |
|
|
402
|
+
| `ForbiddenError` | key not allowed to do this (read-only key on a mutating call) |
|
|
268
403
|
| `RateLimitError` | rate limited (`.retry_after`) |
|
|
269
|
-
| `ValidationError` | malformed request |
|
|
404
|
+
| `ValidationError` | malformed request (`.code`: `invalid_batch`, `unknown_account`, …) |
|
|
405
|
+
| `IdempotencyError` | batch refused because of its `Idempotency-Key` (`.code`) |
|
|
270
406
|
| `NotFoundError` | account/resource not found |
|
|
407
|
+
| `PaymentRequiredError` | base for every 402 below (plan / balance doesn't allow it) |
|
|
271
408
|
| `AccountCapError` | plan account limit reached (`.cap`, `.current`) |
|
|
409
|
+
| `NoSubscriptionError` | no plan permits linking accounts |
|
|
410
|
+
| `InsufficientBalanceError` | prepaid balance too low (`.shortfall_eur_cents`, `.shortfall_eur`) |
|
|
411
|
+
| `SeatLapsedError` | seats lapsed, existing accounts unseated — renew first |
|
|
272
412
|
| `DuplicateAccountError` | account already linked |
|
|
273
413
|
| `ConnectFailedError` | broker rejected the login |
|
|
274
414
|
| `TerminalNotReadyError` | terminal not provisioned / not ready |
|
|
275
415
|
| `UnsupportedOnPlatformError` | feature not available on this platform |
|
|
276
416
|
|
|
277
417
|
```python
|
|
278
|
-
from fxsocket import Client, AccountCapError
|
|
418
|
+
from fxsocket import Client, AccountCapError, InsufficientBalanceError
|
|
279
419
|
|
|
280
420
|
try:
|
|
281
421
|
fx.accounts.create(server="Demo", login=1, password="…")
|
|
282
422
|
except AccountCapError as e:
|
|
283
423
|
print(f"Plan limit reached: {e.current}/{e.cap}")
|
|
424
|
+
except InsufficientBalanceError as e:
|
|
425
|
+
print(f"Top up {e.shortfall_eur} EUR first") # None if the API gave no figure
|
|
284
426
|
```
|
|
285
427
|
|
|
286
428
|
## Private hosting
|
|
@@ -301,7 +443,8 @@ with Client(api_key="fxs_live_...", verify_terminal_tls=False) as fx:
|
|
|
301
443
|
server, server="ICMarkets-Demo", login=1150125, password="..."
|
|
302
444
|
)
|
|
303
445
|
except SlotsFullError as err:
|
|
304
|
-
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)
|
|
305
448
|
|
|
306
449
|
# Poll until the on-server agent has the terminal up, then trade as usual.
|
|
307
450
|
while True:
|
|
@@ -318,8 +461,91 @@ Accounts on a private server are traded and streamed exactly like
|
|
|
318
461
|
shared-cluster accounts — their `rest_url` / `ws_url` simply point at the
|
|
319
462
|
server's dedicated IP. The server presents a self-signed certificate, so reach
|
|
320
463
|
it with `Client(..., verify_terminal_tls=False)` (or supply a pinned CA).
|
|
321
|
-
|
|
322
|
-
|
|
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`.
|
|
526
|
+
|
|
527
|
+
## Wallet
|
|
528
|
+
|
|
529
|
+
`fx.wallet.get()` is a read-only view of your prepaid balance: what is in
|
|
530
|
+
it, top-ups that haven't landed yet, and what the balance will pay for over
|
|
531
|
+
the next 30 days (account seats and balance-funded private servers
|
|
532
|
+
together). Amounts are integer EUR cents; the `*_eur` properties give
|
|
533
|
+
`Decimal` euros.
|
|
534
|
+
|
|
535
|
+
```python
|
|
536
|
+
wallet = fx.wallet.get()
|
|
537
|
+
print(f"balance {wallet.balance_eur} EUR, covers next 30 days: {wallet.covers_upcoming}")
|
|
538
|
+
for charge in wallet.upcoming:
|
|
539
|
+
print(f" {charge.when:%Y-%m-%d} {charge.kind:6} {charge.label} {charge.amount_eur} EUR")
|
|
540
|
+
if not wallet.covers_upcoming:
|
|
541
|
+
print(f"top up at least {wallet.shortfall_eur} EUR")
|
|
542
|
+
```
|
|
543
|
+
|
|
544
|
+
Affordability is cumulative — with 24 EUR and three 12 EUR renewals the
|
|
545
|
+
first two are covered and the third is not — so `shortfall_eur` is the
|
|
546
|
+
total gap, not the size of any single charge. Topping up happens in the
|
|
547
|
+
dashboard; the balance is only ever *spent* through the SDK (account seats
|
|
548
|
+
and `private_servers.create()` / `resize()`), never topped up.
|
|
323
549
|
|
|
324
550
|
## Timestamps
|
|
325
551
|
|
|
@@ -15,7 +15,12 @@ interfaces.
|
|
|
15
15
|
- **Account management** — link, list, fetch, and disconnect MT4/MT5 accounts.
|
|
16
16
|
- **Private servers** — list your dedicated hosting servers and manage the
|
|
17
17
|
accounts on them.
|
|
18
|
+
- **Read-only keys** — mint, scope, rotate and revoke `fxs_ro_…` keys for
|
|
19
|
+
dashboards and monitors.
|
|
18
20
|
- **Trading** — market & pending orders, modify, close, close-all, plus margin/profit calculators.
|
|
21
|
+
- **Multi-account trading** — one order, or one close, fanned out to several
|
|
22
|
+
accounts in a single request, with idempotency keys for safe retries.
|
|
23
|
+
- **Wallet** — read your prepaid balance, pending top-ups and upcoming charges.
|
|
19
24
|
- **Market data** — quotes, symbol specifications (incl. commission rules & trading
|
|
20
25
|
sessions), OHLC history, account state & info.
|
|
21
26
|
- **Live streaming** — ticks, bars, account, positions, trades, and terminal status
|
|
@@ -77,9 +82,28 @@ with Client(api_key="fxs_live_…") as fx:
|
|
|
77
82
|
# Where this account's terminal API lives (empty until provisioned).
|
|
78
83
|
print(account.rest_url, account.ws_url)
|
|
79
84
|
|
|
85
|
+
# Move the trade expert to a specific chart symbol (the terminal
|
|
86
|
+
# restarts on it — poll until connected again). "" reverts to automatic.
|
|
87
|
+
account = fx.accounts.update(account, trade_ea_symbol="EURUSDm")
|
|
88
|
+
|
|
80
89
|
fx.accounts.delete(account.id) # unlink
|
|
81
90
|
```
|
|
82
91
|
|
|
92
|
+
An account's terminal can be routed through an outbound proxy at link time.
|
|
93
|
+
The proxy is verified first — an unreachable one raises `ConnectFailedError`
|
|
94
|
+
with `code == "proxy_unreachable"` and nothing is created. The address, type
|
|
95
|
+
and local port are readable on the `Account`; the credentials never come
|
|
96
|
+
back.
|
|
97
|
+
|
|
98
|
+
```python
|
|
99
|
+
account = fx.accounts.create(
|
|
100
|
+
server="ICMarkets-Demo", login=1150125, password="…",
|
|
101
|
+
trade_ea_symbol="EURUSDm", # optional, broker-exact
|
|
102
|
+
proxy_address="10.0.0.5:1080", proxy_type="socks5",
|
|
103
|
+
proxy_auth="user:secret", proxy_local_port=1080,
|
|
104
|
+
)
|
|
105
|
+
```
|
|
106
|
+
|
|
83
107
|
Everything is also available on `AsyncClient`:
|
|
84
108
|
|
|
85
109
|
```python
|
|
@@ -89,6 +113,36 @@ async with AsyncClient(api_key="fxs_live_…") as fx:
|
|
|
89
113
|
accounts = await fx.accounts.list()
|
|
90
114
|
```
|
|
91
115
|
|
|
116
|
+
## Read-only keys
|
|
117
|
+
|
|
118
|
+
A read-only key (`fxs_ro_…`) can call every GET endpoint but nothing that
|
|
119
|
+
mutates state. `fx.readonly_keys` manages named ones; each has a `scope` —
|
|
120
|
+
`all` sees every account, `selected` only the accounts attached to it
|
|
121
|
+
(everything else is absent from lists, 404 by id and 401 at the terminal).
|
|
122
|
+
Passing `accounts` implies `selected`.
|
|
123
|
+
|
|
124
|
+
```python
|
|
125
|
+
from fxsocket import Client, KeyScope
|
|
126
|
+
|
|
127
|
+
with Client(api_key="fxs_live_…") as fx:
|
|
128
|
+
key = fx.readonly_keys.create(name="dashboard", accounts=[account])
|
|
129
|
+
print(key.key) # plaintext, returned on every read
|
|
130
|
+
|
|
131
|
+
key = fx.readonly_keys.update(key, name="ops-dashboard")
|
|
132
|
+
key = fx.readonly_keys.rotate(key) # new secret, same name & scope
|
|
133
|
+
fx.readonly_keys.delete(key) # revoke — immediate, irreversible
|
|
134
|
+
|
|
135
|
+
for k in fx.readonly_keys.list():
|
|
136
|
+
print(k.name, k.scope == KeyScope.ALL, k.last_used_at)
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
Terminals are started with the exact set of read-only keys they accept, so
|
|
140
|
+
creating, re-scoping, rotating or revoking a key **restarts the terminals of
|
|
141
|
+
every account in its scope** — each goes briefly offline, typically a few
|
|
142
|
+
minutes. Renaming is free. Key management itself always needs the full
|
|
143
|
+
`fxs_live_…` key, even for reads, because the replies contain plaintext
|
|
144
|
+
key values.
|
|
145
|
+
|
|
92
146
|
## Trading & market data
|
|
93
147
|
|
|
94
148
|
`fx.terminal(account)` returns a REST client bound to that account's terminal
|
|
@@ -159,6 +213,86 @@ MT4 and MT5 share one interface. MT5-only timeframes (`M2`, `M3`, `H2`, `H6`,
|
|
|
159
213
|
> hasn't loaded that history. Calling `price_history(symbol, timeframe)` without
|
|
160
214
|
> date bounds returns the most recent bars reliably.
|
|
161
215
|
|
|
216
|
+
## Multi-account trading
|
|
217
|
+
|
|
218
|
+
`fx.orders` sends one order — or one close — to several accounts in a single
|
|
219
|
+
request, through the management API rather than each terminal. Every leg
|
|
220
|
+
inherits `defaults` and may override any of it, so "same trade, three
|
|
221
|
+
accounts, three lot sizes" stays short. A leg can be an `OrderLeg`, a plain
|
|
222
|
+
dict, or just the account (object or id) when `defaults` say everything else.
|
|
223
|
+
|
|
224
|
+
```python
|
|
225
|
+
from fxsocket import Client, OrderDefaults, OrderLeg
|
|
226
|
+
|
|
227
|
+
with Client(api_key="fxs_live_…") as fx:
|
|
228
|
+
accounts = [a for a in fx.accounts.list() if a.has_terminal]
|
|
229
|
+
|
|
230
|
+
result = fx.orders.send(
|
|
231
|
+
[OrderLeg(account_id=a, volume=0.1 * (i + 1)) for i, a in enumerate(accounts)],
|
|
232
|
+
defaults=OrderDefaults(symbol="EURUSD", operation="buy", stop_loss=1.07),
|
|
233
|
+
idempotency_key="signal-4711",
|
|
234
|
+
)
|
|
235
|
+
for account, leg in zip(accounts, result.results):
|
|
236
|
+
print(account.nickname, leg.status, leg.order, leg.message)
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
**Validation is all-or-nothing, execution is not.** A malformed leg raises
|
|
240
|
+
`ValidationError` before anything is sent (the SDK checks what the API
|
|
241
|
+
checks — symbol / operation / volume present, a price for pending orders,
|
|
242
|
+
…). Once dispatched the legs are independent: you get a `BatchOrderResult`
|
|
243
|
+
with a positional `results` list — `zip` it with what you sent rather than
|
|
244
|
+
matching on `account_id`, which repeats when several legs target one
|
|
245
|
+
account. Each `OrderLegResult.status` is only trustworthy as `filled`; a
|
|
246
|
+
`timeout` leg *may* have executed (`is_unknown`), so never blind-retry it.
|
|
247
|
+
`result.all_filled`, `filled_legs`, `failed_legs` and `unknown_legs` roll
|
|
248
|
+
this up.
|
|
249
|
+
|
|
250
|
+
Symbols are per broker (`EURUSD`, `EURUSD.sd`, `EURUSDm`), so a single
|
|
251
|
+
`defaults` symbol across mixed brokers will partly fail by design — that is
|
|
252
|
+
what a leg-level `symbol` is for.
|
|
253
|
+
|
|
254
|
+
**Idempotency.** Pass an `idempotency_key` (any opaque string, ≤ 128 chars)
|
|
255
|
+
whenever a retry is possible: replaying the identical batch with the same
|
|
256
|
+
key returns the stored reply (`idempotent_replay=True`) and sends nothing.
|
|
257
|
+
Keys are remembered for 15 minutes. A key that is still in flight, was
|
|
258
|
+
already used for a different body, or can't currently be guaranteed raises
|
|
259
|
+
`IdempotencyError` — nothing is sent in any of those cases.
|
|
260
|
+
|
|
261
|
+
Closing works by **selector**, not by ticket: each account is matched
|
|
262
|
+
against `symbol` (plus optional `side`, `kind`, `magic`), the backend
|
|
263
|
+
resolves the tickets from that account's open orders and closes them.
|
|
264
|
+
`symbol` is required — type `"*"` to mean every symbol; it is never implied.
|
|
265
|
+
`symbol_match="base"` lets one selector reach `EURUSD`, `EURUSD.sd` and
|
|
266
|
+
`EURUSDm` across brokers. `kind` defaults to `position`, so a routine close
|
|
267
|
+
does not also delete resting pending orders.
|
|
268
|
+
|
|
269
|
+
```python
|
|
270
|
+
from fxsocket import CloseDefaults, CloseLeg
|
|
271
|
+
|
|
272
|
+
closed = fx.orders.close(
|
|
273
|
+
[
|
|
274
|
+
CloseLeg(account_id=accounts[0], side="long", volume=0.05), # partial
|
|
275
|
+
CloseLeg(account_id=accounts[1], tickets=[123456, 123457]), # explicit
|
|
276
|
+
accounts[2], # defaults only
|
|
277
|
+
],
|
|
278
|
+
defaults=CloseDefaults(symbol="EURUSD", symbol_match="base"),
|
|
279
|
+
idempotency_key="flatten-4711",
|
|
280
|
+
)
|
|
281
|
+
for leg in closed.results:
|
|
282
|
+
print(leg.account_id, leg.status, f"{leg.closed}/{leg.matched}")
|
|
283
|
+
for ticket in leg.results:
|
|
284
|
+
print(" ", ticket.ticket, ticket.status, ticket.retcode_description)
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
`nothing_matched` is a normal answer, not an error. Per-account `status`
|
|
288
|
+
compares against `CloseLegStatus` and per-ticket against
|
|
289
|
+
`ClosedTicketStatus`; `skipped` tickets were never sent, `timeout` ones may
|
|
290
|
+
well have closed. An `idempotency_key` matters most for partial closes,
|
|
291
|
+
where a blind retry genuinely over-closes.
|
|
292
|
+
|
|
293
|
+
Both calls need the full `fxs_live_…` key — a read-only key raises
|
|
294
|
+
`ForbiddenError`.
|
|
295
|
+
|
|
162
296
|
## Streaming (WebSocket)
|
|
163
297
|
|
|
164
298
|
Subscribe to live ticks, bars, account, positions, trades, and terminal status.
|
|
@@ -238,22 +372,30 @@ Every failure raises a subclass of `fxsocket.FxSocketError`:
|
|
|
238
372
|
| Exception | When |
|
|
239
373
|
|---|---|
|
|
240
374
|
| `AuthError` | missing/invalid API key |
|
|
375
|
+
| `ForbiddenError` | key not allowed to do this (read-only key on a mutating call) |
|
|
241
376
|
| `RateLimitError` | rate limited (`.retry_after`) |
|
|
242
|
-
| `ValidationError` | malformed request |
|
|
377
|
+
| `ValidationError` | malformed request (`.code`: `invalid_batch`, `unknown_account`, …) |
|
|
378
|
+
| `IdempotencyError` | batch refused because of its `Idempotency-Key` (`.code`) |
|
|
243
379
|
| `NotFoundError` | account/resource not found |
|
|
380
|
+
| `PaymentRequiredError` | base for every 402 below (plan / balance doesn't allow it) |
|
|
244
381
|
| `AccountCapError` | plan account limit reached (`.cap`, `.current`) |
|
|
382
|
+
| `NoSubscriptionError` | no plan permits linking accounts |
|
|
383
|
+
| `InsufficientBalanceError` | prepaid balance too low (`.shortfall_eur_cents`, `.shortfall_eur`) |
|
|
384
|
+
| `SeatLapsedError` | seats lapsed, existing accounts unseated — renew first |
|
|
245
385
|
| `DuplicateAccountError` | account already linked |
|
|
246
386
|
| `ConnectFailedError` | broker rejected the login |
|
|
247
387
|
| `TerminalNotReadyError` | terminal not provisioned / not ready |
|
|
248
388
|
| `UnsupportedOnPlatformError` | feature not available on this platform |
|
|
249
389
|
|
|
250
390
|
```python
|
|
251
|
-
from fxsocket import Client, AccountCapError
|
|
391
|
+
from fxsocket import Client, AccountCapError, InsufficientBalanceError
|
|
252
392
|
|
|
253
393
|
try:
|
|
254
394
|
fx.accounts.create(server="Demo", login=1, password="…")
|
|
255
395
|
except AccountCapError as e:
|
|
256
396
|
print(f"Plan limit reached: {e.current}/{e.cap}")
|
|
397
|
+
except InsufficientBalanceError as e:
|
|
398
|
+
print(f"Top up {e.shortfall_eur} EUR first") # None if the API gave no figure
|
|
257
399
|
```
|
|
258
400
|
|
|
259
401
|
## Private hosting
|
|
@@ -274,7 +416,8 @@ with Client(api_key="fxs_live_...", verify_terminal_tls=False) as fx:
|
|
|
274
416
|
server, server="ICMarkets-Demo", login=1150125, password="..."
|
|
275
417
|
)
|
|
276
418
|
except SlotsFullError as err:
|
|
277
|
-
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)
|
|
278
421
|
|
|
279
422
|
# Poll until the on-server agent has the terminal up, then trade as usual.
|
|
280
423
|
while True:
|
|
@@ -291,8 +434,91 @@ Accounts on a private server are traded and streamed exactly like
|
|
|
291
434
|
shared-cluster accounts — their `rest_url` / `ws_url` simply point at the
|
|
292
435
|
server's dedicated IP. The server presents a self-signed certificate, so reach
|
|
293
436
|
it with `Client(..., verify_terminal_tls=False)` (or supply a pinned CA).
|
|
294
|
-
|
|
295
|
-
|
|
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`.
|
|
499
|
+
|
|
500
|
+
## Wallet
|
|
501
|
+
|
|
502
|
+
`fx.wallet.get()` is a read-only view of your prepaid balance: what is in
|
|
503
|
+
it, top-ups that haven't landed yet, and what the balance will pay for over
|
|
504
|
+
the next 30 days (account seats and balance-funded private servers
|
|
505
|
+
together). Amounts are integer EUR cents; the `*_eur` properties give
|
|
506
|
+
`Decimal` euros.
|
|
507
|
+
|
|
508
|
+
```python
|
|
509
|
+
wallet = fx.wallet.get()
|
|
510
|
+
print(f"balance {wallet.balance_eur} EUR, covers next 30 days: {wallet.covers_upcoming}")
|
|
511
|
+
for charge in wallet.upcoming:
|
|
512
|
+
print(f" {charge.when:%Y-%m-%d} {charge.kind:6} {charge.label} {charge.amount_eur} EUR")
|
|
513
|
+
if not wallet.covers_upcoming:
|
|
514
|
+
print(f"top up at least {wallet.shortfall_eur} EUR")
|
|
515
|
+
```
|
|
516
|
+
|
|
517
|
+
Affordability is cumulative — with 24 EUR and three 12 EUR renewals the
|
|
518
|
+
first two are covered and the third is not — so `shortfall_eur` is the
|
|
519
|
+
total gap, not the size of any single charge. Topping up happens in the
|
|
520
|
+
dashboard; the balance is only ever *spent* through the SDK (account seats
|
|
521
|
+
and `private_servers.create()` / `resize()`), never topped up.
|
|
296
522
|
|
|
297
523
|
## Timestamps
|
|
298
524
|
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Send one trade to every connected account, then flatten it everywhere.
|
|
2
|
+
|
|
3
|
+
Run: FXSOCKET_API_KEY=fxs_live_... python examples/multi_account_trade.py
|
|
4
|
+
|
|
5
|
+
Requires the full ``fxs_live_…`` key — read-only keys cannot trade.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import uuid
|
|
9
|
+
|
|
10
|
+
from fxsocket import Client, CloseDefaults, OrderDefaults, OrderLeg
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def main() -> None:
|
|
14
|
+
with Client() as fx: # reads FXSOCKET_API_KEY from the environment
|
|
15
|
+
accounts = [a for a in fx.accounts.list() if a.has_terminal]
|
|
16
|
+
if not accounts:
|
|
17
|
+
print("no connected accounts")
|
|
18
|
+
return
|
|
19
|
+
|
|
20
|
+
# Same trade on every account; the first one gets a bigger size.
|
|
21
|
+
legs = [OrderLeg(account_id=a) for a in accounts]
|
|
22
|
+
legs[0].volume = 0.2
|
|
23
|
+
result = fx.orders.send(
|
|
24
|
+
legs,
|
|
25
|
+
defaults=OrderDefaults(symbol="EURUSD", operation="buy", volume=0.1),
|
|
26
|
+
idempotency_key=str(uuid.uuid4()), # makes a retry safe
|
|
27
|
+
)
|
|
28
|
+
print(f"batch {result.batch_id}: {result.summary}")
|
|
29
|
+
for account, leg in zip(accounts, result.results, strict=True):
|
|
30
|
+
print(f" {account.nickname or account.id}: {leg.status} {leg.message}")
|
|
31
|
+
if result.unknown_legs:
|
|
32
|
+
print(" some legs timed out — reconcile before re-sending")
|
|
33
|
+
|
|
34
|
+
# Flatten: close every EURUSD position on every account, whatever
|
|
35
|
+
# suffix the broker uses (EURUSD, EURUSD.sd, EURUSDm ...).
|
|
36
|
+
closed = fx.orders.close(
|
|
37
|
+
accounts,
|
|
38
|
+
defaults=CloseDefaults(symbol="EURUSD", symbol_match="base"),
|
|
39
|
+
idempotency_key=str(uuid.uuid4()),
|
|
40
|
+
)
|
|
41
|
+
for account, leg in zip(accounts, closed.results, strict=True):
|
|
42
|
+
print(
|
|
43
|
+
f" {account.nickname or account.id}: {leg.status} "
|
|
44
|
+
f"({leg.closed}/{leg.matched} closed)"
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
if __name__ == "__main__":
|
|
49
|
+
main()
|