fxsocket 0.4.0__tar.gz → 0.6.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.4.0 → fxsocket-0.6.0}/PKG-INFO +197 -4
- {fxsocket-0.4.0 → fxsocket-0.6.0}/README.md +195 -2
- fxsocket-0.6.0/examples/multi_account_trade.py +49 -0
- {fxsocket-0.4.0 → fxsocket-0.6.0}/src/fxsocket/__init__.py +58 -2
- {fxsocket-0.4.0 → fxsocket-0.6.0}/src/fxsocket/_http.py +8 -2
- fxsocket-0.6.0/src/fxsocket/_version.py +1 -0
- {fxsocket-0.4.0 → fxsocket-0.6.0}/src/fxsocket/client.py +20 -4
- {fxsocket-0.4.0 → fxsocket-0.6.0}/src/fxsocket/enums.py +108 -1
- {fxsocket-0.4.0 → fxsocket-0.6.0}/src/fxsocket/errors.py +102 -8
- fxsocket-0.6.0/src/fxsocket/management.py +604 -0
- fxsocket-0.6.0/src/fxsocket/models.py +1134 -0
- fxsocket-0.6.0/src/fxsocket/trading.py +393 -0
- {fxsocket-0.4.0 → fxsocket-0.6.0}/tests/test_errors.py +48 -0
- {fxsocket-0.4.0 → fxsocket-0.6.0}/tests/test_management.py +98 -0
- fxsocket-0.6.0/tests/test_readonly_keys.py +217 -0
- {fxsocket-0.4.0 → fxsocket-0.6.0}/tests/test_stream.py +64 -1
- {fxsocket-0.4.0 → fxsocket-0.6.0}/tests/test_terminal.py +48 -0
- fxsocket-0.6.0/tests/test_trading.py +516 -0
- fxsocket-0.6.0/tests/test_wallet.py +97 -0
- fxsocket-0.4.0/src/fxsocket/_version.py +0 -1
- fxsocket-0.4.0/src/fxsocket/management.py +0 -264
- fxsocket-0.4.0/src/fxsocket/models.py +0 -578
- {fxsocket-0.4.0 → fxsocket-0.6.0}/.github/workflows/ci.yml +0 -0
- {fxsocket-0.4.0 → fxsocket-0.6.0}/.github/workflows/publish.yml +0 -0
- {fxsocket-0.4.0 → fxsocket-0.6.0}/.gitignore +0 -0
- {fxsocket-0.4.0 → fxsocket-0.6.0}/LICENSE +0 -0
- {fxsocket-0.4.0 → fxsocket-0.6.0}/examples/manage_accounts.py +0 -0
- {fxsocket-0.4.0 → fxsocket-0.6.0}/examples/stream_quotes.py +0 -0
- {fxsocket-0.4.0 → fxsocket-0.6.0}/examples/terminal_rest.py +0 -0
- {fxsocket-0.4.0 → fxsocket-0.6.0}/pyproject.toml +0 -0
- {fxsocket-0.4.0 → fxsocket-0.6.0}/src/fxsocket/config.py +0 -0
- {fxsocket-0.4.0 → fxsocket-0.6.0}/src/fxsocket/py.typed +0 -0
- {fxsocket-0.4.0 → fxsocket-0.6.0}/src/fxsocket/terminal/__init__.py +0 -0
- {fxsocket-0.4.0 → fxsocket-0.6.0}/src/fxsocket/terminal/client.py +0 -0
- {fxsocket-0.4.0 → fxsocket-0.6.0}/src/fxsocket/terminal/stream.py +0 -0
- {fxsocket-0.4.0 → fxsocket-0.6.0}/tests/test_private_servers.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: fxsocket
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.6.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.
|
|
@@ -229,6 +363,35 @@ with Client(api_key="fxs_live_…") as fx:
|
|
|
229
363
|
print(event.data.bid, event.data.ask)
|
|
230
364
|
```
|
|
231
365
|
|
|
366
|
+
### Trade events
|
|
367
|
+
|
|
368
|
+
A `TradeUpdate` carries the full deal: `commission`, `swap`, `magic` and a
|
|
369
|
+
real `comment` alongside `profit` (bridges MT5 0.12+ / MT4 0.11+; zero on
|
|
370
|
+
older pods). Event-only P&L accounting is `data.net_profit`
|
|
371
|
+
(`profit + commission + swap`).
|
|
372
|
+
|
|
373
|
+
Correlate the `In` and `Out` events of one round-trip through
|
|
374
|
+
`data.position` — on MT5, `Out` deals carry `magic=0` / `comment=""` unless
|
|
375
|
+
the closing request set them (platform behavior, not a bridge gap), so
|
|
376
|
+
position id is the reliable join key. On MT4, `deal` is always 0 and
|
|
377
|
+
`position` equals the order ticket. The same id appears as `position` in
|
|
378
|
+
`order_history()` rows (bridges MT5 0.14+ / MT4 0.13+) and as
|
|
379
|
+
`position_id` in `position_history()`.
|
|
380
|
+
|
|
381
|
+
If the bridge can't fully enrich an event in time it sets
|
|
382
|
+
`data.degraded=True`: identifiers, `symbol`, `type`, `volume` and `price`
|
|
383
|
+
are still trustworthy, but `entry` is `"Unknown"` and the cost fields are
|
|
384
|
+
zeroed — reconcile that deal via `order_history()`.
|
|
385
|
+
|
|
386
|
+
```python
|
|
387
|
+
async for event in s:
|
|
388
|
+
match event:
|
|
389
|
+
case TradeUpdate() as t if t.data.degraded:
|
|
390
|
+
reconcile_later(t.data.position) # costs/entry unreliable
|
|
391
|
+
case TradeUpdate() as t if t.data.entry == DealEntry.OUT:
|
|
392
|
+
print(t.data.position, "closed, net", t.data.net_profit)
|
|
393
|
+
```
|
|
394
|
+
|
|
232
395
|
## Errors
|
|
233
396
|
|
|
234
397
|
Every failure raises a subclass of `fxsocket.FxSocketError`:
|
|
@@ -236,22 +399,30 @@ Every failure raises a subclass of `fxsocket.FxSocketError`:
|
|
|
236
399
|
| Exception | When |
|
|
237
400
|
|---|---|
|
|
238
401
|
| `AuthError` | missing/invalid API key |
|
|
402
|
+
| `ForbiddenError` | key not allowed to do this (read-only key on a mutating call) |
|
|
239
403
|
| `RateLimitError` | rate limited (`.retry_after`) |
|
|
240
|
-
| `ValidationError` | malformed request |
|
|
404
|
+
| `ValidationError` | malformed request (`.code`: `invalid_batch`, `unknown_account`, …) |
|
|
405
|
+
| `IdempotencyError` | batch refused because of its `Idempotency-Key` (`.code`) |
|
|
241
406
|
| `NotFoundError` | account/resource not found |
|
|
407
|
+
| `PaymentRequiredError` | base for every 402 below (plan / balance doesn't allow it) |
|
|
242
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 |
|
|
243
412
|
| `DuplicateAccountError` | account already linked |
|
|
244
413
|
| `ConnectFailedError` | broker rejected the login |
|
|
245
414
|
| `TerminalNotReadyError` | terminal not provisioned / not ready |
|
|
246
415
|
| `UnsupportedOnPlatformError` | feature not available on this platform |
|
|
247
416
|
|
|
248
417
|
```python
|
|
249
|
-
from fxsocket import Client, AccountCapError
|
|
418
|
+
from fxsocket import Client, AccountCapError, InsufficientBalanceError
|
|
250
419
|
|
|
251
420
|
try:
|
|
252
421
|
fx.accounts.create(server="Demo", login=1, password="…")
|
|
253
422
|
except AccountCapError as e:
|
|
254
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
|
|
255
426
|
```
|
|
256
427
|
|
|
257
428
|
## Private hosting
|
|
@@ -292,6 +463,28 @@ it with `Client(..., verify_terminal_tls=False)` (or supply a pinned CA).
|
|
|
292
463
|
*Purchasing* a server, canceling, and slot changes happen in the dashboard;
|
|
293
464
|
the API deliberately exposes no billing operations.
|
|
294
465
|
|
|
466
|
+
## Wallet
|
|
467
|
+
|
|
468
|
+
`fx.wallet.get()` is a read-only view of your prepaid balance: what is in
|
|
469
|
+
it, top-ups that haven't landed yet, and what the balance will pay for over
|
|
470
|
+
the next 30 days (account seats and balance-funded private servers
|
|
471
|
+
together). Amounts are integer EUR cents; the `*_eur` properties give
|
|
472
|
+
`Decimal` euros.
|
|
473
|
+
|
|
474
|
+
```python
|
|
475
|
+
wallet = fx.wallet.get()
|
|
476
|
+
print(f"balance {wallet.balance_eur} EUR, covers next 30 days: {wallet.covers_upcoming}")
|
|
477
|
+
for charge in wallet.upcoming:
|
|
478
|
+
print(f" {charge.when:%Y-%m-%d} {charge.kind:6} {charge.label} {charge.amount_eur} EUR")
|
|
479
|
+
if not wallet.covers_upcoming:
|
|
480
|
+
print(f"top up at least {wallet.shortfall_eur} EUR")
|
|
481
|
+
```
|
|
482
|
+
|
|
483
|
+
Affordability is cumulative — with 24 EUR and three 12 EUR renewals the
|
|
484
|
+
first two are covered and the third is not — so `shortfall_eur` is the
|
|
485
|
+
total gap, not the size of any single charge. Topping up happens in the
|
|
486
|
+
dashboard; the SDK deliberately exposes no payment operations.
|
|
487
|
+
|
|
295
488
|
## Timestamps
|
|
296
489
|
|
|
297
490
|
Terminal timestamps (`quote.time`, candle `time`, order times) are returned as
|
|
@@ -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.
|
|
@@ -202,6 +336,35 @@ with Client(api_key="fxs_live_…") as fx:
|
|
|
202
336
|
print(event.data.bid, event.data.ask)
|
|
203
337
|
```
|
|
204
338
|
|
|
339
|
+
### Trade events
|
|
340
|
+
|
|
341
|
+
A `TradeUpdate` carries the full deal: `commission`, `swap`, `magic` and a
|
|
342
|
+
real `comment` alongside `profit` (bridges MT5 0.12+ / MT4 0.11+; zero on
|
|
343
|
+
older pods). Event-only P&L accounting is `data.net_profit`
|
|
344
|
+
(`profit + commission + swap`).
|
|
345
|
+
|
|
346
|
+
Correlate the `In` and `Out` events of one round-trip through
|
|
347
|
+
`data.position` — on MT5, `Out` deals carry `magic=0` / `comment=""` unless
|
|
348
|
+
the closing request set them (platform behavior, not a bridge gap), so
|
|
349
|
+
position id is the reliable join key. On MT4, `deal` is always 0 and
|
|
350
|
+
`position` equals the order ticket. The same id appears as `position` in
|
|
351
|
+
`order_history()` rows (bridges MT5 0.14+ / MT4 0.13+) and as
|
|
352
|
+
`position_id` in `position_history()`.
|
|
353
|
+
|
|
354
|
+
If the bridge can't fully enrich an event in time it sets
|
|
355
|
+
`data.degraded=True`: identifiers, `symbol`, `type`, `volume` and `price`
|
|
356
|
+
are still trustworthy, but `entry` is `"Unknown"` and the cost fields are
|
|
357
|
+
zeroed — reconcile that deal via `order_history()`.
|
|
358
|
+
|
|
359
|
+
```python
|
|
360
|
+
async for event in s:
|
|
361
|
+
match event:
|
|
362
|
+
case TradeUpdate() as t if t.data.degraded:
|
|
363
|
+
reconcile_later(t.data.position) # costs/entry unreliable
|
|
364
|
+
case TradeUpdate() as t if t.data.entry == DealEntry.OUT:
|
|
365
|
+
print(t.data.position, "closed, net", t.data.net_profit)
|
|
366
|
+
```
|
|
367
|
+
|
|
205
368
|
## Errors
|
|
206
369
|
|
|
207
370
|
Every failure raises a subclass of `fxsocket.FxSocketError`:
|
|
@@ -209,22 +372,30 @@ Every failure raises a subclass of `fxsocket.FxSocketError`:
|
|
|
209
372
|
| Exception | When |
|
|
210
373
|
|---|---|
|
|
211
374
|
| `AuthError` | missing/invalid API key |
|
|
375
|
+
| `ForbiddenError` | key not allowed to do this (read-only key on a mutating call) |
|
|
212
376
|
| `RateLimitError` | rate limited (`.retry_after`) |
|
|
213
|
-
| `ValidationError` | malformed request |
|
|
377
|
+
| `ValidationError` | malformed request (`.code`: `invalid_batch`, `unknown_account`, …) |
|
|
378
|
+
| `IdempotencyError` | batch refused because of its `Idempotency-Key` (`.code`) |
|
|
214
379
|
| `NotFoundError` | account/resource not found |
|
|
380
|
+
| `PaymentRequiredError` | base for every 402 below (plan / balance doesn't allow it) |
|
|
215
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 |
|
|
216
385
|
| `DuplicateAccountError` | account already linked |
|
|
217
386
|
| `ConnectFailedError` | broker rejected the login |
|
|
218
387
|
| `TerminalNotReadyError` | terminal not provisioned / not ready |
|
|
219
388
|
| `UnsupportedOnPlatformError` | feature not available on this platform |
|
|
220
389
|
|
|
221
390
|
```python
|
|
222
|
-
from fxsocket import Client, AccountCapError
|
|
391
|
+
from fxsocket import Client, AccountCapError, InsufficientBalanceError
|
|
223
392
|
|
|
224
393
|
try:
|
|
225
394
|
fx.accounts.create(server="Demo", login=1, password="…")
|
|
226
395
|
except AccountCapError as e:
|
|
227
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
|
|
228
399
|
```
|
|
229
400
|
|
|
230
401
|
## Private hosting
|
|
@@ -265,6 +436,28 @@ it with `Client(..., verify_terminal_tls=False)` (or supply a pinned CA).
|
|
|
265
436
|
*Purchasing* a server, canceling, and slot changes happen in the dashboard;
|
|
266
437
|
the API deliberately exposes no billing operations.
|
|
267
438
|
|
|
439
|
+
## Wallet
|
|
440
|
+
|
|
441
|
+
`fx.wallet.get()` is a read-only view of your prepaid balance: what is in
|
|
442
|
+
it, top-ups that haven't landed yet, and what the balance will pay for over
|
|
443
|
+
the next 30 days (account seats and balance-funded private servers
|
|
444
|
+
together). Amounts are integer EUR cents; the `*_eur` properties give
|
|
445
|
+
`Decimal` euros.
|
|
446
|
+
|
|
447
|
+
```python
|
|
448
|
+
wallet = fx.wallet.get()
|
|
449
|
+
print(f"balance {wallet.balance_eur} EUR, covers next 30 days: {wallet.covers_upcoming}")
|
|
450
|
+
for charge in wallet.upcoming:
|
|
451
|
+
print(f" {charge.when:%Y-%m-%d} {charge.kind:6} {charge.label} {charge.amount_eur} EUR")
|
|
452
|
+
if not wallet.covers_upcoming:
|
|
453
|
+
print(f"top up at least {wallet.shortfall_eur} EUR")
|
|
454
|
+
```
|
|
455
|
+
|
|
456
|
+
Affordability is cumulative — with 24 EUR and three 12 EUR renewals the
|
|
457
|
+
first two are covered and the third is not — so `shortfall_eur` is the
|
|
458
|
+
total gap, not the size of any single charge. Topping up happens in the
|
|
459
|
+
dashboard; the SDK deliberately exposes no payment operations.
|
|
460
|
+
|
|
268
461
|
## Timestamps
|
|
269
462
|
|
|
270
463
|
Terminal timestamps (`quote.time`, candle `time`, order times) are returned as
|
|
@@ -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()
|