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.
Files changed (36) hide show
  1. {fxsocket-0.4.0 → fxsocket-0.6.0}/PKG-INFO +197 -4
  2. {fxsocket-0.4.0 → fxsocket-0.6.0}/README.md +195 -2
  3. fxsocket-0.6.0/examples/multi_account_trade.py +49 -0
  4. {fxsocket-0.4.0 → fxsocket-0.6.0}/src/fxsocket/__init__.py +58 -2
  5. {fxsocket-0.4.0 → fxsocket-0.6.0}/src/fxsocket/_http.py +8 -2
  6. fxsocket-0.6.0/src/fxsocket/_version.py +1 -0
  7. {fxsocket-0.4.0 → fxsocket-0.6.0}/src/fxsocket/client.py +20 -4
  8. {fxsocket-0.4.0 → fxsocket-0.6.0}/src/fxsocket/enums.py +108 -1
  9. {fxsocket-0.4.0 → fxsocket-0.6.0}/src/fxsocket/errors.py +102 -8
  10. fxsocket-0.6.0/src/fxsocket/management.py +604 -0
  11. fxsocket-0.6.0/src/fxsocket/models.py +1134 -0
  12. fxsocket-0.6.0/src/fxsocket/trading.py +393 -0
  13. {fxsocket-0.4.0 → fxsocket-0.6.0}/tests/test_errors.py +48 -0
  14. {fxsocket-0.4.0 → fxsocket-0.6.0}/tests/test_management.py +98 -0
  15. fxsocket-0.6.0/tests/test_readonly_keys.py +217 -0
  16. {fxsocket-0.4.0 → fxsocket-0.6.0}/tests/test_stream.py +64 -1
  17. {fxsocket-0.4.0 → fxsocket-0.6.0}/tests/test_terminal.py +48 -0
  18. fxsocket-0.6.0/tests/test_trading.py +516 -0
  19. fxsocket-0.6.0/tests/test_wallet.py +97 -0
  20. fxsocket-0.4.0/src/fxsocket/_version.py +0 -1
  21. fxsocket-0.4.0/src/fxsocket/management.py +0 -264
  22. fxsocket-0.4.0/src/fxsocket/models.py +0 -578
  23. {fxsocket-0.4.0 → fxsocket-0.6.0}/.github/workflows/ci.yml +0 -0
  24. {fxsocket-0.4.0 → fxsocket-0.6.0}/.github/workflows/publish.yml +0 -0
  25. {fxsocket-0.4.0 → fxsocket-0.6.0}/.gitignore +0 -0
  26. {fxsocket-0.4.0 → fxsocket-0.6.0}/LICENSE +0 -0
  27. {fxsocket-0.4.0 → fxsocket-0.6.0}/examples/manage_accounts.py +0 -0
  28. {fxsocket-0.4.0 → fxsocket-0.6.0}/examples/stream_quotes.py +0 -0
  29. {fxsocket-0.4.0 → fxsocket-0.6.0}/examples/terminal_rest.py +0 -0
  30. {fxsocket-0.4.0 → fxsocket-0.6.0}/pyproject.toml +0 -0
  31. {fxsocket-0.4.0 → fxsocket-0.6.0}/src/fxsocket/config.py +0 -0
  32. {fxsocket-0.4.0 → fxsocket-0.6.0}/src/fxsocket/py.typed +0 -0
  33. {fxsocket-0.4.0 → fxsocket-0.6.0}/src/fxsocket/terminal/__init__.py +0 -0
  34. {fxsocket-0.4.0 → fxsocket-0.6.0}/src/fxsocket/terminal/client.py +0 -0
  35. {fxsocket-0.4.0 → fxsocket-0.6.0}/src/fxsocket/terminal/stream.py +0 -0
  36. {fxsocket-0.4.0 → fxsocket-0.6.0}/tests/test_private_servers.py +0 -0
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: fxsocket
3
- Version: 0.4.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()