fxsocket 0.3.0__tar.gz → 0.5.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 (29) hide show
  1. {fxsocket-0.3.0 → fxsocket-0.5.0}/PKG-INFO +49 -4
  2. {fxsocket-0.3.0 → fxsocket-0.5.0}/README.md +47 -2
  3. {fxsocket-0.3.0 → fxsocket-0.5.0}/src/fxsocket/__init__.py +10 -0
  4. fxsocket-0.5.0/src/fxsocket/_version.py +1 -0
  5. {fxsocket-0.3.0 → fxsocket-0.5.0}/src/fxsocket/enums.py +7 -1
  6. {fxsocket-0.3.0 → fxsocket-0.5.0}/src/fxsocket/models.py +145 -4
  7. {fxsocket-0.3.0 → fxsocket-0.5.0}/src/fxsocket/terminal/client.py +56 -0
  8. {fxsocket-0.3.0 → fxsocket-0.5.0}/tests/test_private_servers.py +2 -2
  9. {fxsocket-0.3.0 → fxsocket-0.5.0}/tests/test_stream.py +64 -1
  10. {fxsocket-0.3.0 → fxsocket-0.5.0}/tests/test_terminal.py +202 -0
  11. fxsocket-0.3.0/src/fxsocket/_version.py +0 -1
  12. {fxsocket-0.3.0 → fxsocket-0.5.0}/.github/workflows/ci.yml +0 -0
  13. {fxsocket-0.3.0 → fxsocket-0.5.0}/.github/workflows/publish.yml +0 -0
  14. {fxsocket-0.3.0 → fxsocket-0.5.0}/.gitignore +0 -0
  15. {fxsocket-0.3.0 → fxsocket-0.5.0}/LICENSE +0 -0
  16. {fxsocket-0.3.0 → fxsocket-0.5.0}/examples/manage_accounts.py +0 -0
  17. {fxsocket-0.3.0 → fxsocket-0.5.0}/examples/stream_quotes.py +0 -0
  18. {fxsocket-0.3.0 → fxsocket-0.5.0}/examples/terminal_rest.py +0 -0
  19. {fxsocket-0.3.0 → fxsocket-0.5.0}/pyproject.toml +0 -0
  20. {fxsocket-0.3.0 → fxsocket-0.5.0}/src/fxsocket/_http.py +0 -0
  21. {fxsocket-0.3.0 → fxsocket-0.5.0}/src/fxsocket/client.py +0 -0
  22. {fxsocket-0.3.0 → fxsocket-0.5.0}/src/fxsocket/config.py +0 -0
  23. {fxsocket-0.3.0 → fxsocket-0.5.0}/src/fxsocket/errors.py +0 -0
  24. {fxsocket-0.3.0 → fxsocket-0.5.0}/src/fxsocket/management.py +0 -0
  25. {fxsocket-0.3.0 → fxsocket-0.5.0}/src/fxsocket/py.typed +0 -0
  26. {fxsocket-0.3.0 → fxsocket-0.5.0}/src/fxsocket/terminal/__init__.py +0 -0
  27. {fxsocket-0.3.0 → fxsocket-0.5.0}/src/fxsocket/terminal/stream.py +0 -0
  28. {fxsocket-0.3.0 → fxsocket-0.5.0}/tests/test_errors.py +0 -0
  29. {fxsocket-0.3.0 → fxsocket-0.5.0}/tests/test_management.py +0 -0
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: fxsocket
3
- Version: 0.3.0
3
+ Version: 0.5.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,8 +42,9 @@ 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
- - **Trading** — market & pending orders, modify, close, plus margin/profit calculators.
46
- - **Market data** — quotes, symbol specifications, OHLC history, account state & info.
45
+ - **Trading** — market & pending orders, modify, close, close-all, plus margin/profit calculators.
46
+ - **Market data** — quotes, symbol specifications (incl. commission rules & trading
47
+ sessions), OHLC history, account state & info.
47
48
  - **Live streaming** — ticks, bars, account, positions, trades, and terminal status
48
49
  over WebSocket, with automatic reconnect + subscription replay.
49
50
  - **Sync *and* async** — `Client` / `AsyncClient`, method-for-method mirrors.
@@ -157,6 +158,21 @@ if res.is_effective: # applied now, or already in effect
157
158
  ...
158
159
  ```
159
160
 
161
+ There's also a panic button. `close_all()` closes every open position in one
162
+ trade-EA pass — optionally filtered by `symbol` and/or `magic` (`magic=0`
163
+ matches manually-opened orders), and `delete_pending=True` also deletes
164
+ matching pending orders. It returns a `CloseAllSummary` with per-ticket
165
+ results. On a 504 the pass *continues inside the terminal* — check
166
+ `opened_orders()` before acting again rather than re-sending:
167
+
168
+ ```python
169
+ summary = term.close_all(symbol="EURUSD", delete_pending=True)
170
+ if summary.failed:
171
+ for r in summary.results:
172
+ if not r.success:
173
+ print(r.ticket, r.retcode, r.retcode_description)
174
+ ```
175
+
160
176
  Inputs are validated client-side before they're sent. One guard worth knowing:
161
177
  in `order_modify`, a literal `stop_loss=0.0` would *remove* your stop-loss, so
162
178
  it's rejected — pass `clear_stop_loss=True` to remove one deliberately, while
@@ -213,6 +229,35 @@ with Client(api_key="fxs_live_…") as fx:
213
229
  print(event.data.bid, event.data.ask)
214
230
  ```
215
231
 
232
+ ### Trade events
233
+
234
+ A `TradeUpdate` carries the full deal: `commission`, `swap`, `magic` and a
235
+ real `comment` alongside `profit` (bridges MT5 0.12+ / MT4 0.11+; zero on
236
+ older pods). Event-only P&L accounting is `data.net_profit`
237
+ (`profit + commission + swap`).
238
+
239
+ Correlate the `In` and `Out` events of one round-trip through
240
+ `data.position` — on MT5, `Out` deals carry `magic=0` / `comment=""` unless
241
+ the closing request set them (platform behavior, not a bridge gap), so
242
+ position id is the reliable join key. On MT4, `deal` is always 0 and
243
+ `position` equals the order ticket. The same id appears as `position` in
244
+ `order_history()` rows (bridges MT5 0.14+ / MT4 0.13+) and as
245
+ `position_id` in `position_history()`.
246
+
247
+ If the bridge can't fully enrich an event in time it sets
248
+ `data.degraded=True`: identifiers, `symbol`, `type`, `volume` and `price`
249
+ are still trustworthy, but `entry` is `"Unknown"` and the cost fields are
250
+ zeroed — reconcile that deal via `order_history()`.
251
+
252
+ ```python
253
+ async for event in s:
254
+ match event:
255
+ case TradeUpdate() as t if t.data.degraded:
256
+ reconcile_later(t.data.position) # costs/entry unreliable
257
+ case TradeUpdate() as t if t.data.entry == DealEntry.OUT:
258
+ print(t.data.position, "closed, net", t.data.net_profit)
259
+ ```
260
+
216
261
  ## Errors
217
262
 
218
263
  Every failure raises a subclass of `fxsocket.FxSocketError`:
@@ -15,8 +15,9 @@ 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
- - **Trading** — market & pending orders, modify, close, plus margin/profit calculators.
19
- - **Market data** — quotes, symbol specifications, OHLC history, account state & info.
18
+ - **Trading** — market & pending orders, modify, close, close-all, plus margin/profit calculators.
19
+ - **Market data** — quotes, symbol specifications (incl. commission rules & trading
20
+ sessions), OHLC history, account state & info.
20
21
  - **Live streaming** — ticks, bars, account, positions, trades, and terminal status
21
22
  over WebSocket, with automatic reconnect + subscription replay.
22
23
  - **Sync *and* async** — `Client` / `AsyncClient`, method-for-method mirrors.
@@ -130,6 +131,21 @@ if res.is_effective: # applied now, or already in effect
130
131
  ...
131
132
  ```
132
133
 
134
+ There's also a panic button. `close_all()` closes every open position in one
135
+ trade-EA pass — optionally filtered by `symbol` and/or `magic` (`magic=0`
136
+ matches manually-opened orders), and `delete_pending=True` also deletes
137
+ matching pending orders. It returns a `CloseAllSummary` with per-ticket
138
+ results. On a 504 the pass *continues inside the terminal* — check
139
+ `opened_orders()` before acting again rather than re-sending:
140
+
141
+ ```python
142
+ summary = term.close_all(symbol="EURUSD", delete_pending=True)
143
+ if summary.failed:
144
+ for r in summary.results:
145
+ if not r.success:
146
+ print(r.ticket, r.retcode, r.retcode_description)
147
+ ```
148
+
133
149
  Inputs are validated client-side before they're sent. One guard worth knowing:
134
150
  in `order_modify`, a literal `stop_loss=0.0` would *remove* your stop-loss, so
135
151
  it's rejected — pass `clear_stop_loss=True` to remove one deliberately, while
@@ -186,6 +202,35 @@ with Client(api_key="fxs_live_…") as fx:
186
202
  print(event.data.bid, event.data.ask)
187
203
  ```
188
204
 
205
+ ### Trade events
206
+
207
+ A `TradeUpdate` carries the full deal: `commission`, `swap`, `magic` and a
208
+ real `comment` alongside `profit` (bridges MT5 0.12+ / MT4 0.11+; zero on
209
+ older pods). Event-only P&L accounting is `data.net_profit`
210
+ (`profit + commission + swap`).
211
+
212
+ Correlate the `In` and `Out` events of one round-trip through
213
+ `data.position` — on MT5, `Out` deals carry `magic=0` / `comment=""` unless
214
+ the closing request set them (platform behavior, not a bridge gap), so
215
+ position id is the reliable join key. On MT4, `deal` is always 0 and
216
+ `position` equals the order ticket. The same id appears as `position` in
217
+ `order_history()` rows (bridges MT5 0.14+ / MT4 0.13+) and as
218
+ `position_id` in `position_history()`.
219
+
220
+ If the bridge can't fully enrich an event in time it sets
221
+ `data.degraded=True`: identifiers, `symbol`, `type`, `volume` and `price`
222
+ are still trustworthy, but `entry` is `"Unknown"` and the cost fields are
223
+ zeroed — reconcile that deal via `order_history()`.
224
+
225
+ ```python
226
+ async for event in s:
227
+ match event:
228
+ case TradeUpdate() as t if t.data.degraded:
229
+ reconcile_later(t.data.position) # costs/entry unreliable
230
+ case TradeUpdate() as t if t.data.entry == DealEntry.OUT:
231
+ print(t.data.position, "closed, net", t.data.net_profit)
232
+ ```
233
+
189
234
  ## Errors
190
235
 
191
236
  Every failure raises a subclass of `fxsocket.FxSocketError`:
@@ -42,6 +42,10 @@ from .models import (
42
42
  AccountInfo,
43
43
  AccountSummary,
44
44
  Candle,
45
+ CloseAllResult,
46
+ CloseAllSummary,
47
+ CommissionRule,
48
+ CommissionTier,
45
49
  Health,
46
50
  HealthChecks,
47
51
  HistoryTrade,
@@ -57,6 +61,7 @@ from .models import (
57
61
  SymbolInfo,
58
62
  TerminalStatusData,
59
63
  TradeEventData,
64
+ TradingSession,
60
65
  )
61
66
  from .terminal import (
62
67
  AccountUpdate,
@@ -112,8 +117,13 @@ __all__ = [
112
117
  "ServerTimezone",
113
118
  "Quote",
114
119
  "SymbolInfo",
120
+ "CommissionRule",
121
+ "CommissionTier",
122
+ "TradingSession",
115
123
  "Candle",
116
124
  "OrderResult",
125
+ "CloseAllResult",
126
+ "CloseAllSummary",
117
127
  "MarginCalc",
118
128
  "ProfitCalc",
119
129
  "Health",
@@ -0,0 +1 @@
1
+ __version__ = "0.5.0"
@@ -103,11 +103,17 @@ class OrderKind(str, Enum):
103
103
 
104
104
 
105
105
  class DealEntry(str, Enum):
106
- """Direction of a deal in trade history / the ``trades`` stream."""
106
+ """Direction of a deal in trade history / the ``trades`` stream.
107
+
108
+ ``UNKNOWN`` appears only on degraded ``trades``-stream frames, where the
109
+ bridge could not resolve the deal direction in time — see
110
+ ``TradeEventData.degraded``.
111
+ """
107
112
 
108
113
  IN = "In"
109
114
  OUT = "Out"
110
115
  IN_OUT = "InOut"
116
+ UNKNOWN = "Unknown"
111
117
 
112
118
 
113
119
  class HealthStatus(str, Enum):
@@ -21,7 +21,7 @@ from __future__ import annotations
21
21
 
22
22
  from datetime import datetime
23
23
 
24
- from pydantic import BaseModel, ConfigDict
24
+ from pydantic import BaseModel, ConfigDict, Field
25
25
  from pydantic.alias_generators import to_camel
26
26
 
27
27
  from .enums import OrderOutcome, Platform, TradingStatus
@@ -205,10 +205,20 @@ class HistoryTrade(_Camel):
205
205
 
206
206
  On MT4 this is one row per closed *order* (no per-deal granularity);
207
207
  ``order`` aliases the ticket and ``entry`` is constant.
208
+
209
+ ``position`` groups the rows of one round-trip: on MT5 it is the deal's
210
+ ``DEAL_POSITION_ID`` — the ``In`` and ``Out`` rows share it, and it
211
+ equals the ``trades``-stream events' ``position`` and
212
+ :attr:`PositionTrade.position_id` — so an exit row alone identifies the
213
+ position it closed even though MT5 exits usually carry ``magic=0`` /
214
+ ``comment=""``. On MT4 it equals the order ticket. 0 on pods older than
215
+ bridge MT5 0.14 / MT4 0.13. (Netting-account caveat: a reversal
216
+ ``InOut`` row reports the position it belongs to *after* processing.)
208
217
  """
209
218
 
210
219
  ticket: int
211
220
  order: int
221
+ position: int = 0
212
222
  symbol: str
213
223
  type: str
214
224
  entry: str
@@ -271,8 +281,65 @@ class Quote(_Camel):
271
281
  volume: int
272
282
 
273
283
 
284
+ class CommissionTier(_Camel):
285
+ """One tier of a commission rule — a value and the volume/turnover range
286
+ it applies to.
287
+
288
+ Enum-like fields (``mode``, ``volume_type``) carry the raw MQL5 constant
289
+ names (e.g. ``SYMBOL_COMMISSION_MODE_MONEY``) so nothing is lost in
290
+ translation. ``range_to == 0`` means unbounded; ``min_value`` /
291
+ ``max_value`` cap the charged amount (0 = no cap).
292
+ """
293
+
294
+ mode: str
295
+ volume_type: str
296
+ value: float
297
+ min_value: float
298
+ max_value: float
299
+ range_from: float
300
+ range_to: float
301
+ currency: str
302
+
303
+
304
+ class CommissionRule(_Camel):
305
+ """One broker commission rule for a symbol, as configured server-side
306
+ (MT5 ``SymbolInfoCommissions``).
307
+
308
+ A symbol can carry several rules; each has its own tiers. The mode fields
309
+ carry the MQL5 ``ENUM_SYMBOL_COMMISSION_*`` constant names verbatim.
310
+ """
311
+
312
+ currency: str
313
+ range_mode: str
314
+ charge_mode: str
315
+ entry_mode: str
316
+ direction_mode: str
317
+ profit_mode: str
318
+ tiers: list[CommissionTier] = []
319
+
320
+
321
+ class TradingSession(_Camel):
322
+ """One trading-session window of a symbol, in *broker server time*.
323
+
324
+ ``day`` uses the MQL ``ENUM_DAY_OF_WEEK`` constant names (``SUNDAY`` …
325
+ ``SATURDAY``); times are ``HH:MM`` where ``24:00`` means end of day, so a
326
+ 24-hour market shows ``00:00``–``24:00``.
327
+ """
328
+
329
+ day: str
330
+ from_: str = Field(alias="from")
331
+ to: str
332
+
333
+
274
334
  class SymbolInfo(_Camel):
275
- """Contract specification for a symbol (``GET /SymbolInfo``)."""
335
+ """Contract specification for a symbol (``GET /SymbolInfo``).
336
+
337
+ ``commissions`` are the broker's commission rules straight from the
338
+ server's symbol specification; ``sessions`` are the per-weekday trading
339
+ windows in broker server time. Both default to empty on pods older than
340
+ bridge 0.10 (and ``commissions`` also when the broker publishes none or
341
+ the terminal predates the API — build 6060+).
342
+ """
276
343
 
277
344
  symbol: str
278
345
  description: str
@@ -295,6 +362,8 @@ class SymbolInfo(_Camel):
295
362
  currency_base: str
296
363
  currency_profit: str
297
364
  currency_margin: str
365
+ commissions: list[CommissionRule] = []
366
+ sessions: list[TradingSession] = []
298
367
 
299
368
 
300
369
  class Candle(_Camel):
@@ -360,6 +429,39 @@ class OrderResult(_Camel):
360
429
  return self.success or self.is_no_change
361
430
 
362
431
 
432
+ class CloseAllResult(_Camel):
433
+ """One per-ticket outcome of a ``/CloseAll`` pass.
434
+
435
+ ``kind`` is ``"position"`` (closed) or ``"pending"`` (deleted).
436
+ """
437
+
438
+ ticket: int
439
+ kind: str
440
+ success: bool
441
+ retcode: int
442
+ retcode_description: str
443
+
444
+ @property
445
+ def is_pending(self) -> bool:
446
+ """True when this row is a deleted pending order (vs. a closed position)."""
447
+ return self.kind.lower() == "pending"
448
+
449
+
450
+ class CloseAllSummary(_Camel):
451
+ """Reply of ``POST /CloseAll`` — every matched position (and pending
452
+ order, when ``delete_pending`` was set) with its close/delete outcome.
453
+
454
+ ``requested`` is how many orders the filters matched and were attempted;
455
+ ``closed`` how many attempts the broker accepted; ``failed`` how many it
456
+ rejected — inspect ``results`` for the per-ticket retcodes.
457
+ """
458
+
459
+ requested: int
460
+ closed: int
461
+ failed: int
462
+ results: list[CloseAllResult] = []
463
+
464
+
363
465
  class MarginCalc(_Camel):
364
466
  """Required margin for a hypothetical order (``GET /OrderCalcMargin``)."""
365
467
 
@@ -411,8 +513,17 @@ class AccountHealth(_Camel):
411
513
 
412
514
 
413
515
  class BridgeHealth(_Camel):
516
+ """Bridge section of ``/status``.
517
+
518
+ ``trade_ea_heartbeat_age_ms`` is how long ago the trade EA last made
519
+ dispatcher progress (``-1`` = never registered, or a pod older than
520
+ bridge 0.10). A large age while ``trade_ea_ready`` is still ``True``
521
+ means the EA is blocked in a long dealer call or dead — worth alerting on.
522
+ """
523
+
414
524
  version: str = ""
415
525
  trade_ea_ready: bool = False
526
+ trade_ea_heartbeat_age_ms: int = -1
416
527
  symbols_synced: bool = False
417
528
 
418
529
 
@@ -452,8 +563,29 @@ class HealthChecks(_Camel):
452
563
  class TradeEventData(_Camel):
453
564
  """A trade transaction pushed on the ``trades`` stream.
454
565
 
455
- ``deal`` / ``position`` are 0 on MT4 (no per-deal model); ``entry`` is the
456
- deal direction (compare against :class:`fxsocket.DealEntry`).
566
+ ``entry`` is the deal direction (compare against
567
+ :class:`fxsocket.DealEntry`; ``"Unknown"`` appears only on degraded
568
+ frames). ``commission`` / ``swap`` / ``magic`` (and a real ``comment`` on
569
+ MT5) arrive on bridges MT5 0.12+ / MT4 0.11+ and default to 0 before
570
+ that; a deal's net P&L is ``profit + commission + swap``
571
+ (:attr:`net_profit`).
572
+
573
+ Platform semantics:
574
+
575
+ * **MT5** — ``Out`` deals carry ``magic=0`` / ``comment=""`` unless the
576
+ closing request set them (a platform property, not a bridge gap).
577
+ Correlate ``In``/``Out`` through ``position``, which is present on
578
+ every event.
579
+ * **MT4** — orders keep their magic/comment for the whole lifecycle, so
580
+ both ``In`` and ``Out`` events carry them; ``deal`` is always 0 and
581
+ ``position`` equals the order ticket.
582
+
583
+ ``degraded=True`` (bridges MT5 0.13+ / MT4 0.12+; structurally always
584
+ ``False`` on MT4) means the bridge could not fully enrich the event in
585
+ time: the identifiers, ``symbol``, ``type``, ``volume`` and ``price`` are
586
+ trustworthy, but ``entry`` is ``"Unknown"`` and ``profit`` /
587
+ ``commission`` / ``swap`` / ``magic`` / ``comment`` are zeroed —
588
+ reconcile the deal via ``GET /OrderHistory``.
457
589
  """
458
590
 
459
591
  deal: int
@@ -465,8 +597,17 @@ class TradeEventData(_Camel):
465
597
  volume: float
466
598
  price: float
467
599
  profit: float
600
+ commission: float = 0.0
601
+ swap: float = 0.0
602
+ magic: int = 0
468
603
  comment: str
469
604
  time: str
605
+ degraded: bool = False
606
+
607
+ @property
608
+ def net_profit(self) -> float:
609
+ """Deal P&L including costs: ``profit + commission + swap``."""
610
+ return self.profit + self.commission + self.swap
470
611
 
471
612
 
472
613
  class TerminalStatusData(_Camel):
@@ -46,6 +46,7 @@ from ..models import (
46
46
  AccountInfo,
47
47
  AccountSummary,
48
48
  Candle,
49
+ CloseAllSummary,
49
50
  Health,
50
51
  HealthChecks,
51
52
  HistoryTrade,
@@ -234,6 +235,20 @@ def _order_send_body(
234
235
  )
235
236
 
236
237
 
238
+ def _close_all_body(
239
+ *, symbol: str | None, magic: int | None, delete_pending: bool
240
+ ) -> dict[str, Any]:
241
+ # magic=0 is a real filter (manually-opened orders) — only None is omitted.
242
+ # An empty dict is a valid body: it means "close everything".
243
+ return _clean(
244
+ {
245
+ "symbol": symbol,
246
+ "magic": magic,
247
+ "deletePending": True if delete_pending else None,
248
+ }
249
+ )
250
+
251
+
237
252
  def _order_modify_body(
238
253
  *,
239
254
  ticket: int,
@@ -481,6 +496,32 @@ class TerminalClient:
481
496
  self._http.request("POST", "/OrderClose", json=body)
482
497
  )
483
498
 
499
+ def close_all(
500
+ self,
501
+ *,
502
+ symbol: str | None = None,
503
+ magic: int | None = None,
504
+ delete_pending: bool = False,
505
+ ) -> CloseAllSummary:
506
+ """Close every open position in one trade-EA pass (``POST /CloseAll``).
507
+
508
+ Optionally filtered by ``symbol`` and/or ``magic`` (``magic=0``
509
+ matches manually-opened orders; ``None`` means no filter);
510
+ ``delete_pending=True`` also deletes matching pending orders. The
511
+ reply carries a per-ticket result for every attempted close/delete.
512
+
513
+ Positions opened while the pass is running are not covered, and on a
514
+ 504 (:class:`~fxsocket.TerminalTimeoutError`) the pass *continues to
515
+ completion inside the terminal* — check :meth:`opened_orders` before
516
+ acting again rather than re-sending.
517
+ """
518
+ body = _close_all_body(
519
+ symbol=symbol, magic=magic, delete_pending=delete_pending
520
+ )
521
+ return CloseAllSummary.model_validate(
522
+ self._http.request("POST", "/CloseAll", json=body)
523
+ )
524
+
484
525
  # -- health ------------------------------------------------------------ #
485
526
 
486
527
  def status(self) -> Health:
@@ -732,6 +773,21 @@ class AsyncTerminalClient:
732
773
  await self._http.request("POST", "/OrderClose", json=body)
733
774
  )
734
775
 
776
+ async def close_all(
777
+ self,
778
+ *,
779
+ symbol: str | None = None,
780
+ magic: int | None = None,
781
+ delete_pending: bool = False,
782
+ ) -> CloseAllSummary:
783
+ """Async mirror of :meth:`TerminalClient.close_all`."""
784
+ body = _close_all_body(
785
+ symbol=symbol, magic=magic, delete_pending=delete_pending
786
+ )
787
+ return CloseAllSummary.model_validate(
788
+ await self._http.request("POST", "/CloseAll", json=body)
789
+ )
790
+
735
791
  async def status(self) -> Health:
736
792
  return Health.model_validate(await self._http.request("GET", "/status"))
737
793
 
@@ -26,8 +26,8 @@ SERVER_ACCOUNT = {
26
26
  "server": "ICMarkets-Demo",
27
27
  "login": 7001,
28
28
  "status": "ready",
29
- "rest_url": f"https://159.223.244.125/22222222-2222-2222-2222-222222222222",
30
- "ws_url": f"wss://159.223.244.125/22222222-2222-2222-2222-222222222222/ws",
29
+ "rest_url": "https://159.223.244.125/22222222-2222-2222-2222-222222222222",
30
+ "ws_url": "wss://159.223.244.125/22222222-2222-2222-2222-222222222222/ws",
31
31
  "trade_ea_symbol": "",
32
32
  "created_at": "2026-07-16T08:00:00Z",
33
33
  }
@@ -13,6 +13,7 @@ from fxsocket import (
13
13
  AccountUpdate,
14
14
  AsyncStream,
15
15
  Bar,
16
+ DealEntry,
16
17
  PositionsUpdate,
17
18
  Stream,
18
19
  StreamWarning,
@@ -157,12 +158,19 @@ def test_parse_event_all_types() -> None:
157
158
  "volume": 0.1,
158
159
  "price": 1.0,
159
160
  "profit": 0.0,
160
- "comment": "",
161
+ "commission": -0.04,
162
+ "swap": 0.0,
163
+ "magic": 1000999,
164
+ "comment": "CN_9999_8888",
161
165
  "time": "t",
166
+ "degraded": False,
162
167
  },
163
168
  }
164
169
  )
165
170
  assert isinstance(trade, TradeUpdate) and trade.data.entry == "In"
171
+ assert trade.data.commission == -0.04 and trade.data.magic == 1000999
172
+ assert trade.data.degraded is False
173
+ assert trade.data.net_profit == pytest.approx(-0.04)
166
174
 
167
175
  term = parse_event(
168
176
  {
@@ -192,6 +200,61 @@ def test_parse_event_all_types() -> None:
192
200
  assert isinstance(parse_event({"type": "subscriptions", "data": []}), Subscriptions)
193
201
 
194
202
 
203
+ def test_parse_trade_pre_012_bridge_defaults() -> None:
204
+ """Bridges older than MT5 0.12 / MT4 0.11 omit the enrichment fields."""
205
+ trade = parse_event(
206
+ {
207
+ "type": "trade",
208
+ "data": {
209
+ "deal": 9,
210
+ "order": 10,
211
+ "position": 10,
212
+ "symbol": "EURUSD",
213
+ "type": "Buy",
214
+ "entry": "In",
215
+ "volume": 0.1,
216
+ "price": 1.0,
217
+ "profit": 0.0,
218
+ "comment": "",
219
+ "time": "t",
220
+ },
221
+ }
222
+ )
223
+ assert isinstance(trade, TradeUpdate)
224
+ assert trade.data.commission == 0.0 and trade.data.swap == 0.0
225
+ assert trade.data.magic == 0 and trade.data.degraded is False
226
+
227
+
228
+ def test_parse_trade_degraded_frame() -> None:
229
+ """A degraded frame: identifiers/direction real, costs zeroed, entry Unknown."""
230
+ trade = parse_event(
231
+ {
232
+ "type": "trade",
233
+ "data": {
234
+ "deal": 9,
235
+ "order": 10,
236
+ "position": 10,
237
+ "symbol": "EURUSD",
238
+ "type": "Buy",
239
+ "entry": "Unknown",
240
+ "volume": 0.1,
241
+ "price": 1.0,
242
+ "profit": 0.0,
243
+ "commission": 0.0,
244
+ "swap": 0.0,
245
+ "magic": 0,
246
+ "comment": "",
247
+ "time": "2026-08-19T18:53:41.000Z",
248
+ "degraded": True,
249
+ },
250
+ }
251
+ )
252
+ assert isinstance(trade, TradeUpdate)
253
+ assert trade.data.degraded is True
254
+ assert trade.data.entry == DealEntry.UNKNOWN
255
+ assert trade.data.symbol == "EURUSD" and trade.data.position == 10
256
+
257
+
195
258
  # --------------------------------------------------------------------------- #
196
259
  # Validation (no connection needed — raised before send)
197
260
  # --------------------------------------------------------------------------- #
@@ -212,6 +212,22 @@ def test_status_nested_health() -> None:
212
212
  assert h.terminal.ping_ms == 108
213
213
  assert h.account.logged_in is True
214
214
  assert h.bridge.trade_ea_ready is True
215
+ # Pre-0.10 bridges omit the heartbeat — defaults to -1 (never/unknown).
216
+ assert h.bridge.trade_ea_heartbeat_age_ms == -1
217
+
218
+
219
+ def test_bridge_health_heartbeat_parses() -> None:
220
+ from fxsocket.models import BridgeHealth
221
+
222
+ b = BridgeHealth.model_validate(
223
+ {
224
+ "version": "0.10.0",
225
+ "tradeEaReady": True,
226
+ "tradeEaHeartbeatAgeMs": 8,
227
+ "symbolsSynced": True,
228
+ }
229
+ )
230
+ assert b.trade_ea_heartbeat_age_ms == 8
215
231
 
216
232
 
217
233
  # --------------------------------------------------------------------------- #
@@ -430,6 +446,76 @@ def test_order_modify_omits_unset_fields() -> None:
430
446
  }
431
447
 
432
448
 
449
+ @respx.mock
450
+ def test_close_all_sends_filters_and_parses() -> None:
451
+ route = respx.post(f"{TERM}/CloseAll").mock(
452
+ return_value=httpx.Response(
453
+ 200,
454
+ json={
455
+ "requested": 2,
456
+ "closed": 1,
457
+ "failed": 1,
458
+ "results": [
459
+ {
460
+ "ticket": 100,
461
+ "kind": "position",
462
+ "success": True,
463
+ "retcode": 10009,
464
+ "retcodeDescription": "Done",
465
+ },
466
+ {
467
+ "ticket": 101,
468
+ "kind": "pending",
469
+ "success": False,
470
+ "retcode": 10006,
471
+ "retcodeDescription": "Rejected",
472
+ },
473
+ ],
474
+ },
475
+ )
476
+ )
477
+ with _term() as t:
478
+ summary = t.close_all(symbol="EURUSD", magic=0, delete_pending=True)
479
+ # magic=0 is a real filter (manual orders) and must be sent, not dropped.
480
+ assert json.loads(route.calls.last.request.content) == {
481
+ "symbol": "EURUSD",
482
+ "magic": 0,
483
+ "deletePending": True,
484
+ }
485
+ assert (summary.requested, summary.closed, summary.failed) == (2, 1, 1)
486
+ assert [r.is_pending for r in summary.results] == [False, True]
487
+ assert summary.results[1].retcode_description == "Rejected"
488
+
489
+
490
+ @respx.mock
491
+ def test_close_all_no_filters_sends_empty_json_body() -> None:
492
+ # /CloseAll requires a JSON body; with no filters it must be exactly {}.
493
+ route = respx.post(f"{TERM}/CloseAll").mock(
494
+ return_value=httpx.Response(
495
+ 200, json={"requested": 0, "closed": 0, "failed": 0, "results": []}
496
+ )
497
+ )
498
+ with _term() as t:
499
+ summary = t.close_all()
500
+ req = route.calls.last.request
501
+ assert json.loads(req.content) == {}
502
+ assert req.headers["content-type"] == "application/json"
503
+ assert summary.requested == 0 and summary.results == []
504
+
505
+
506
+ @respx.mock
507
+ def test_close_all_504_maps_to_timeout() -> None:
508
+ # The pass continues inside the terminal after a 504 — the SDK must
509
+ # surface the timeout (never retry), so callers re-check /OpenedOrders.
510
+ respx.post(f"{TERM}/CloseAll").mock(
511
+ return_value=httpx.Response(
512
+ 504, json={"error": "MRPC_TIMEOUT", "message": "timed out", "command_id": 3}
513
+ )
514
+ )
515
+ with _term() as t, pytest.raises(TerminalTimeoutError):
516
+ t.close_all()
517
+
518
+
433
519
  # --------------------------------------------------------------------------- #
434
520
  # Health probes — 503 carries a body, not an error
435
521
  # --------------------------------------------------------------------------- #
@@ -552,6 +638,46 @@ def test_symbol_info_parses_camelcase() -> None:
552
638
  assert si.tick_size == 0.00001
553
639
  assert si.volume_min == 0.01
554
640
  assert si.currency_base == "EUR"
641
+ # Pods older than bridge 0.10 omit commissions/sessions — default empty.
642
+ assert si.commissions == [] and si.sessions == []
643
+
644
+
645
+ def test_symbol_info_commissions_and_sessions_parse() -> None:
646
+ from fxsocket import CommissionRule, TradingSession
647
+
648
+ rule = CommissionRule.model_validate(
649
+ {
650
+ "currency": "USD",
651
+ "rangeMode": "SYMBOL_COMMISSION_RANGE_VOLUME",
652
+ "chargeMode": "SYMBOL_COMMISSION_CHARGE_INSTANT",
653
+ "entryMode": "SYMBOL_COMMISSION_ENTRY_INOUT",
654
+ "directionMode": "SYMBOL_COMMISSION_DIRECTION_BOTH",
655
+ "profitMode": "SYMBOL_COMMISSION_PROFIT_ALL",
656
+ "tiers": [
657
+ {
658
+ "mode": "SYMBOL_COMMISSION_MONEY_DEPOSIT",
659
+ "volumeType": "SYMBOL_COMMISSION_VOLUME_TYPE_VOLUME",
660
+ "value": 3.5,
661
+ "minValue": 0.0,
662
+ "maxValue": 0.0,
663
+ "rangeFrom": 0.0,
664
+ "rangeTo": 1000000.0,
665
+ "currency": "USD",
666
+ }
667
+ ],
668
+ }
669
+ )
670
+ assert rule.range_mode == "SYMBOL_COMMISSION_RANGE_VOLUME"
671
+ assert rule.tiers[0].value == 3.5
672
+ assert rule.tiers[0].range_to == 1000000.0
673
+
674
+ # `from` is a Python keyword — the wire field maps to `from_`.
675
+ session = TradingSession.model_validate(
676
+ {"day": "FRIDAY", "from": "00:01", "to": "23:57"}
677
+ )
678
+ assert session.day == "FRIDAY"
679
+ assert session.from_ == "00:01"
680
+ assert session.to == "23:57"
555
681
 
556
682
 
557
683
  @respx.mock
@@ -567,6 +693,54 @@ def test_server_timezone_parses() -> None:
567
693
  assert isinstance(tz.server_time, str)
568
694
 
569
695
 
696
+ @respx.mock
697
+ def test_order_history_parses_position() -> None:
698
+ # Second row mimics a pod older than bridge MT5 0.14 / MT4 0.13 (no
699
+ # ``position`` yet) — must default to 0, not fail validation.
700
+ respx.get(f"{TERM}/OrderHistory").mock(
701
+ return_value=httpx.Response(
702
+ 200,
703
+ json=[
704
+ {
705
+ "ticket": 14541780,
706
+ "order": 13173527,
707
+ "position": 13173524,
708
+ "symbol": "EURUSD",
709
+ "type": "Sell",
710
+ "entry": "Out",
711
+ "volume": 0.01,
712
+ "price": 1.16635,
713
+ "profit": -0.02,
714
+ "commission": -0.04,
715
+ "swap": 0.0,
716
+ "magic": 0,
717
+ "comment": "",
718
+ "time": "2026-08-19T18:53:47.000Z",
719
+ },
720
+ {
721
+ "ticket": 14541777,
722
+ "order": 13173524,
723
+ "symbol": "EURUSD",
724
+ "type": "Buy",
725
+ "entry": "In",
726
+ "volume": 0.01,
727
+ "price": 1.16637,
728
+ "profit": 0.0,
729
+ "commission": -0.04,
730
+ "swap": 0.0,
731
+ "magic": 1000999,
732
+ "comment": "CN_9999_8888",
733
+ "time": "2026-08-19T18:53:41.000Z",
734
+ },
735
+ ],
736
+ )
737
+ )
738
+ with _term() as t:
739
+ rows = t.order_history()
740
+ assert rows[0].position == 13173524
741
+ assert rows[1].position == 0
742
+
743
+
570
744
  @respx.mock
571
745
  def test_position_history_sends_dates_and_parses() -> None:
572
746
  route = respx.get(f"{TERM}/PositionHistory").mock(
@@ -721,3 +895,31 @@ async def test_async_order_send_body_and_validation() -> None:
721
895
  "volume": 0.2,
722
896
  "expertId": 7,
723
897
  }
898
+
899
+
900
+ @respx.mock
901
+ async def test_async_close_all() -> None:
902
+ route = respx.post(f"{TERM}/CloseAll").mock(
903
+ return_value=httpx.Response(
904
+ 200,
905
+ json={
906
+ "requested": 1,
907
+ "closed": 1,
908
+ "failed": 0,
909
+ "results": [
910
+ {
911
+ "ticket": 100,
912
+ "kind": "position",
913
+ "success": True,
914
+ "retcode": 10009,
915
+ "retcodeDescription": "Done",
916
+ }
917
+ ],
918
+ },
919
+ )
920
+ )
921
+ async with AsyncClient(api_key="fxs_live_k") as fx:
922
+ term = fx.terminal(_account())
923
+ summary = await term.close_all(symbol="EURUSD")
924
+ assert json.loads(route.calls.last.request.content) == {"symbol": "EURUSD"}
925
+ assert summary.closed == 1 and summary.results[0].success
@@ -1 +0,0 @@
1
- __version__ = "0.3.0"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes