diff --git a/README.md b/README.md index 0ea2a89..fa65a9f 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ System: Splits order → Manages positions → Returns status - ✅ **MCP Server** - Native Model Context Protocol server so Claude Code / Claude Desktop can inspect and control MT5 directly - ✅ **TCP Socket Communication** - High-performance bidirectional communication - ✅ **Order Type Safety** - Validates STOP/LIMIT (and SL/TP sides) against the live market and **rejects** wrong-side orders instead of silently reinterpreting your intent -- ✅ **Position Recovery** - Rebuilds tracking from existing orders on restart +- ✅ **Position Recovery** - Persists group state to disk and rebuilds tracking on restart, reconciling against live orders (survives broker comment rewrites) - ✅ **Any-Symbol Support** - Works on any MT5 symbol; volumes snap to the broker's lot step and prices normalize to the symbol's digits (Gold/XAUUSD and Silver/XAGUSD are examples, not limits) - ✅ **Risk Management** - Daily loss limits, max positions, spread checks - ✅ **AI Agent Ready** - Perfect for Claude AI, ChatGPT, and automation workflows @@ -632,8 +632,9 @@ Configure in MT5 when attaching EA to chart: | `ServerPort` | 5555 | TCP server port | | `MagicNumber` | 20250117 | Unique identifier for EA orders | | `SocketCheckIntervalMs` | 500 | How often to check for commands (ms) | -| `MaxSpreadPips` | 10 | Maximum allowed spread in pips | -| `MaxDailyLossPercent` | 5.0 | Stop trading if daily loss exceeds % | +| `MaxSpreadPips` | 10 | Reject new orders when the live spread exceeds this (0 disables). 1 pip = 10 points on 5/3-digit symbols, 1 point otherwise — metals/indices may need a higher value. | +| `MaxPositions` | 50 | Cap on this EA's **open positions + pending orders combined** | +| `MaxDailyLossPercent` | 5.0 | Stop trading if the loss since **broker midnight** exceeds % (measured against equity) | ### 🔒 Security @@ -803,14 +804,14 @@ python server.py **Symptoms:** TP2 closes but SL doesn't move to TP1 **Possible Causes:** -1. EA was restarted after orders placed (group tracking lost) -2. Orders placed manually instead of through API -3. Comment format doesn't match expected pattern +1. Orders placed manually instead of through the API (no tracked group) +2. TP2's volume was set to `0` in a custom `volume_split` (no TP2 order exists to trigger the move) +3. The group only has one or two levels (nothing beyond TP2 to trail) **Solution:** -- Always place orders through the API -- Don't restart EA while positions are open -- Check order comments include GROUP and TP fields +- Always place orders through the API so a group is tracked +- Keep a non-zero TP2 weight if you rely on the trailing trigger +- Group state now survives restarts (persisted to `MQL5/Files/`), so a restart no longer loses tracking ### Safe Shutdown Not Working @@ -831,7 +832,7 @@ python server.py A: Yes. You send absolute TP/SL/entry prices, and the EA reads each symbol's volume step, minimum lot, and digit precision directly from MT5, so any broker symbol works (FX pairs, metals, indices, crypto CFDs). XAUUSD/XAGUSD are just the examples this project started with. **Q: What happens if I restart MT5 while positions are open?** -A: The EA has position recovery logic that rebuilds order group tracking from position comments. However, trailing SL won't trigger during the restart period. +A: The EA persists each group's state (levels, TP prices, trailing status) to a per-account file in `MQL5/Files/` and reloads it on startup, reconciling against your live orders and positions. This survives even if your broker rewrites order comments. Trailing SL still won't *fire* during the moments the EA is actually offline, but tracking is restored intact once it restarts. **Q: Can I change the volume distribution (60/10/10/10/10)?** A: Yes — send a `volume_split` array in the API request (see [Split Order System](#-split-order-system)). No code editing required; the 60/40 default is only applied when you omit it. You can also use 1-10 levels instead of exactly 5. diff --git a/bulk-add-signals.mq5 b/bulk-add-signals.mq5 index 058ba90..b33cc21 100644 --- a/bulk-add-signals.mq5 +++ b/bulk-add-signals.mq5 @@ -23,8 +23,8 @@ input string ServerHost = "127.0.0.1"; // Python server host input int ServerPort = 5555; // Python server port input int MagicNumber = 20250117; // Magic number for orders input double DefaultLotSize = 0.1; // Default lot size -input int MaxSpreadPips = 10; // Maximum spread in pips -input int MaxPositions = 10; // Maximum open positions +input int MaxSpreadPips = 10; // Max spread in pips (0 disables the check) +input int MaxPositions = 50; // Max open positions + pending orders (combined) input double MaxDailyLossPercent = 5.0; // Max daily loss % input int SocketCheckIntervalMs = 500; // Socket check interval (ms) input int SocketTimeout = 3000; // Socket timeout (ms) @@ -37,6 +37,7 @@ CAccountInfo account; double dailyStartBalance = 0; datetime dailyStartDay = 0; // Start of the current trading day (for daily loss reset) int g_groupCounter = 0; // Monotonic counter to keep group IDs unique +bool g_groupsDirty = false; // Set when orderGroups changes; flushed to disk by OnTimer //--- Structures struct TradeCommand { @@ -81,7 +82,7 @@ int OnInit() // Store starting balance and current trading day (for daily loss reset) dailyStartBalance = account.Balance(); - dailyStartDay = TimeCurrent() - (TimeCurrent() % 86400); + dailyStartDay = BrokerDayStart(); // Recover existing positions for split order tracking RecoverSplitOrders(); @@ -105,6 +106,13 @@ void OnDeinit(const int reason) Print("==== EA Shutting Down ===="); EventKillTimer(); + // Persist any pending group-state change before we stop. + if(g_groupsDirty) + { + SaveGroups(); + g_groupsDirty = false; + } + // Clean up all chart objects created by this EA for(int i = ArraySize(orderGroups) - 1; i >= 0; i--) { @@ -122,6 +130,13 @@ void OnTimer() // trail until this chart happened to tick. CheckTP2ForTrailingSL(); + // Flush group state to disk when it changed (at most once per timer tick). + if(g_groupsDirty) + { + SaveGroups(); + g_groupsDirty = false; + } + // Create TCP socket int socket = SocketCreate(); if(socket == INVALID_HANDLE) @@ -482,6 +497,31 @@ bool IsBuyType(ENUM_ORDER_TYPE t) return (t == ORDER_TYPE_BUY_STOP || t == ORDER_TYPE_BUY_LIMIT); } +//+------------------------------------------------------------------+ +//| One pip in price terms for a symbol. | +//| 5/3-digit quotes: 1 pip = 10 points. Everything else (2/4-digit | +//| FX, metals, indices): 1 pip = 1 point (the broker's increment). | +//| Only used for the spread guard; order prices use real TP values. | +//+------------------------------------------------------------------+ +double SymbolPip(string sym) +{ + int digits = (int)SymbolInfoInteger(sym, SYMBOL_DIGITS); + double point = SymbolInfoDouble(sym, SYMBOL_POINT); + if(point <= 0) return 0; + return (digits == 3 || digits == 5) ? point * 10.0 : point; +} + +//+------------------------------------------------------------------+ +//| Start of the current broker trading day (broker midnight), so the | +//| daily-loss window follows the broker clock, not the server's UTC. | +//+------------------------------------------------------------------+ +datetime BrokerDayStart() +{ + datetime d = iTime(_Symbol, PERIOD_D1, 0); + if(d <= 0) d = TimeCurrent() - (TimeCurrent() % 86400); // fallback if no bar yet + return d; +} + //+------------------------------------------------------------------+ //| Validate command | //+------------------------------------------------------------------+ @@ -502,9 +542,28 @@ bool ValidateCommand(TradeCommand &cmd) return false; } - if(CountOpenPositions() >= MaxPositions) + // Spread guard: reject when the live spread exceeds MaxSpreadPips. + if(MaxSpreadPips > 0) { - Print("ERROR: Max positions reached: ", MaxPositions); + double pip = SymbolPip(cmd.symbol); + double ask = SymbolInfoDouble(cmd.symbol, SYMBOL_ASK); + double bid = SymbolInfoDouble(cmd.symbol, SYMBOL_BID); + if(pip > 0 && ask > 0 && bid > 0) + { + double spreadPips = (ask - bid) / pip; + if(spreadPips > MaxSpreadPips) + { + Print("ERROR: Spread ", DoubleToString(spreadPips, 1), " pips exceeds MaxSpreadPips (", MaxSpreadPips, ")"); + return false; + } + } + } + + // Combined cap: our open positions AND pending orders both count, so a + // burst of pending split orders can't blow past the limit unnoticed. + if(CountOpenPositions() + CountPendingOrders() >= MaxPositions) + { + Print("ERROR: Max positions+orders reached: ", MaxPositions); return false; } @@ -709,6 +768,7 @@ ulong ExecuteOrder(TradeCommand &cmd, string &errorMsg) DrawTPLevels(groupId, cmd.symbol, entryN, orderGroups[groupIndex].tp_prices, volumes, cmd.tp_count); Print("[OK] Order group placed: ", successCount, "/", cmd.tp_count, " orders successful"); + g_groupsDirty = true; // persist the new group on the next timer flush return firstTicket; // Return first ticket as reference } @@ -879,6 +939,7 @@ void CheckTP2ForTrailingSL() // Mark as TP2 reached orderGroups[i].tp2_reached = true; + g_groupsDirty = true; // Update visual UpdateTPLevelClosed(orderGroups[i].groupId, 2); @@ -904,6 +965,7 @@ void CheckTP2ForTrailingSL() Print("[END] All orders/positions closed for group ", orderGroups[i].groupId); RemoveTPObjects(orderGroups[i].groupId); ArrayRemove(orderGroups, i, 1); + g_groupsDirty = true; } } } @@ -924,13 +986,30 @@ int CountOpenPositions() return count; } +//+------------------------------------------------------------------+ +//| Count this EA's pending orders | +//+------------------------------------------------------------------+ +int CountPendingOrders() +{ + int count = 0; + for(int i = OrdersTotal() - 1; i >= 0; i--) + { + ulong ticket = OrderGetTicket(i); + if(ticket > 0 && OrderGetInteger(ORDER_MAGIC) == MagicNumber) + { + count++; + } + } + return count; +} + //+------------------------------------------------------------------+ //| Check daily loss | //+------------------------------------------------------------------+ bool CheckDailyLossLimit() { - // Reset the daily baseline when a new calendar day starts. - datetime today = TimeCurrent() - (TimeCurrent() % 86400); + // Reset the daily baseline when a new broker trading day starts. + datetime today = BrokerDayStart(); if(today != dailyStartDay) { dailyStartDay = today; @@ -1019,6 +1098,113 @@ int FindOrCreateGroup(string groupId) return idx; } +//+------------------------------------------------------------------+ +//| Per-EA state file name (magic + account keep instances separate). | +//+------------------------------------------------------------------+ +string GroupStateFile() +{ + return StringFormat("split_groups_%d_%I64d.csv", MagicNumber, account.Login()); +} + +//+------------------------------------------------------------------+ +//| Persist all tracked groups to disk. Called (via g_groupsDirty) | +//| whenever the group set changes, so a restart can rebuild tracking | +//| even if the broker rewrote order comments. | +//+------------------------------------------------------------------+ +void SaveGroups() +{ + int h = FileOpen(GroupStateFile(), FILE_WRITE|FILE_TXT|FILE_ANSI); + if(h == INVALID_HANDLE) + { + Print("[WARN] Could not write group state file: ", GetLastError()); + return; + } + int n = ArraySize(orderGroups); + for(int i = 0; i < n; i++) + { + string tickets = ""; + string prices = ""; + for(int t = 0; t < MAX_SPLITS; t++) + { + if(t > 0) { tickets += ","; prices += ","; } + tickets += StringFormat("%I64u", orderGroups[i].tickets[t]); + prices += StringFormat("%.8f", orderGroups[i].tp_prices[t]); + } + // groupId|count|tp2|entry|symbol|order_type|tickets|tp_prices + FileWrite(h, StringFormat("%s|%d|%d|%.8f|%s|%d|%s|%s", + orderGroups[i].groupId, orderGroups[i].count, + orderGroups[i].tp2_reached ? 1 : 0, orderGroups[i].entry_price, + orderGroups[i].symbol, (int)orderGroups[i].order_type, tickets, prices)); + } + FileClose(h); +} + +//+------------------------------------------------------------------+ +//| Load groups from disk. Returns true only if at least one valid | +//| group was loaded; an empty/malformed file falls back to comments. | +//+------------------------------------------------------------------+ +bool LoadGroups() +{ + if(!FileIsExist(GroupStateFile())) return false; + int h = FileOpen(GroupStateFile(), FILE_READ|FILE_TXT|FILE_ANSI); + if(h == INVALID_HANDLE) return false; + + while(!FileIsEnding(h)) + { + string line = FileReadString(h); + if(StringLen(line) < 5) continue; + + string f[]; + if(StringSplit(line, '|', f) != 8) continue; // skip malformed lines + + int gi = ArraySize(orderGroups); + ArrayResize(orderGroups, gi + 1); + orderGroups[gi].groupId = f[0]; + orderGroups[gi].count = (int)StringToInteger(f[1]); + orderGroups[gi].tp2_reached = (StringToInteger(f[2]) != 0); + orderGroups[gi].entry_price = StringToDouble(f[3]); + orderGroups[gi].symbol = f[4]; + orderGroups[gi].order_type = (ENUM_ORDER_TYPE)StringToInteger(f[5]); + + string ts[]; StringSplit(f[6], ',', ts); + string ps[]; StringSplit(f[7], ',', ps); + for(int t = 0; t < MAX_SPLITS; t++) + { + orderGroups[gi].tickets[t] = (t < ArraySize(ts)) ? (ulong)StringToInteger(ts[t]) : 0; + orderGroups[gi].tp_prices[t] = (t < ArraySize(ps)) ? StringToDouble(ps[t]) : 0; + } + if(orderGroups[gi].count < 1 || orderGroups[gi].count > MAX_SPLITS) + orderGroups[gi].count = MAX_SPLITS; + } + FileClose(h); + return (ArraySize(orderGroups) > 0); +} + +//+------------------------------------------------------------------+ +//| Redraw a recovered group's chart objects and gray out closed TPs. | +//+------------------------------------------------------------------+ +void RedrawRecoveredGroup(int g) +{ + if(orderGroups[g].entry_price <= 0) return; + + double vols[MAX_SPLITS]; + for(int t = 0; t < MAX_SPLITS; t++) + { + vols[t] = 0; + ulong tk = orderGroups[g].tickets[t]; + if(tk == 0) continue; + if(PositionSelectByTicket(tk)) vols[t] = PositionGetDouble(POSITION_VOLUME); + else if(OrderSelect(tk)) vols[t] = OrderGetDouble(ORDER_VOLUME_CURRENT); + } + + DrawTPLevels(orderGroups[g].groupId, orderGroups[g].symbol, + orderGroups[g].entry_price, orderGroups[g].tp_prices, vols, orderGroups[g].count); + + for(int t = 0; t < orderGroups[g].count; t++) + if(orderGroups[g].tickets[t] == 0) + UpdateTPLevelClosed(orderGroups[g].groupId, t + 1); +} + //+------------------------------------------------------------------+ //| Recover split orders on EA restart | //+------------------------------------------------------------------+ @@ -1028,6 +1214,47 @@ void RecoverSplitOrders() ArrayResize(orderGroups, 0); // Clear array first + // Prefer the state file (authoritative: knows real count, tp2_reached, and + // exact TP prices even if the broker rewrote order comments). Reconcile it + // against live orders/positions and drop anything that no longer exists. + if(LoadGroups()) + { + for(int g = ArraySize(orderGroups) - 1; g >= 0; g--) + { + bool anyAlive = false; + for(int t = 0; t < MAX_SPLITS; t++) + { + ulong tk = orderGroups[g].tickets[t]; + if(tk == 0) continue; + if(OrderSelect(tk) || PositionSelectByTicket(tk)) anyAlive = true; + else orderGroups[g].tickets[t] = 0; // stale ticket - drop it + } + + if(!anyAlive) + { + RemoveTPObjects(orderGroups[g].groupId); + ArrayRemove(orderGroups, g, 1); + continue; + } + + // TP2 closed while we were offline: mark reached (as the comment + // path does) so trailing state stays consistent. + bool laterAlive = false; + for(int t = 2; t < MAX_SPLITS; t++) + if(orderGroups[g].tickets[t] > 0) { laterAlive = true; break; } + if(orderGroups[g].tickets[1] == 0 && laterAlive) + orderGroups[g].tp2_reached = true; + + RedrawRecoveredGroup(g); + } + + Print("==== Recovered ", ArraySize(orderGroups), " group(s) from state file ===="); + SaveGroups(); // rewrite the reconciled state + return; + } + + // No state file (fresh install or first run after upgrade): fall back to + // rebuilding from order comments, then seed the state file. Print("Total open positions found: ", PositionsTotal()); Print("Total pending orders found: ", OrdersTotal()); @@ -1100,30 +1327,14 @@ void RecoverSplitOrders() if(orderGroups[g].entry_price > 0) { - // Read back the actual live volume of each surviving split so the - // chart labels are correct after recovery. - double vols[MAX_SPLITS]; - for(int t = 0; t < MAX_SPLITS; t++) - { - vols[t] = 0; - ulong tk = orderGroups[g].tickets[t]; - if(tk == 0) continue; - if(PositionSelectByTicket(tk)) vols[t] = PositionGetDouble(POSITION_VOLUME); - else if(OrderSelect(tk)) vols[t] = OrderGetDouble(ORDER_VOLUME_CURRENT); - } - - DrawTPLevels(orderGroups[g].groupId, orderGroups[g].symbol, - orderGroups[g].entry_price, orderGroups[g].tp_prices, vols, orderGroups[g].count); - - // Mark already-closed TPs as gray. - for(int t = 0; t < orderGroups[g].count; t++) - if(orderGroups[g].tickets[t] == 0) - UpdateTPLevelClosed(orderGroups[g].groupId, t + 1); - + RedrawRecoveredGroup(g); Print("[OK] Group ", orderGroups[g].groupId, " recovered successfully"); } } + // Seed the state file from comment-based recovery so future restarts use it. + SaveGroups(); + Print(""); Print("==== Split Order Recovery Complete ===="); Print("[OK] Recovered ", groupCount, " order group(s)"); @@ -1289,6 +1500,7 @@ string HandleClosePosition(string commandJson) { RemoveTPObjects(groupId); ArrayRemove(orderGroups, i, 1); + g_groupsDirty = true; } break; } diff --git a/config.json b/config.json index d4b68e0..656c5c1 100644 --- a/config.json +++ b/config.json @@ -18,9 +18,8 @@ "min_lot_per_trade": 0.01 }, "take_profit": { - "levels": 5, - "pip_step": 30, - "partial_close_percent": 20.0 + "default_levels": 5, + "pip_step": 30 }, "server": { "port": 8080, diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md deleted file mode 100644 index 3581b2d..0000000 --- a/docs/ROADMAP.md +++ /dev/null @@ -1,70 +0,0 @@ -# Roadmap: known gaps and next features - -Last updated: 2026-07-06 (post v4.2.0 expansion). Ordered by (risk x demand). -Each item is small enough for one focused PR. - -## Known bugs / correctness gaps - -1. **`MaxSpreadPips` input is dead code.** Declared in the EA inputs and - documented in the README, but never checked anywhere. Either wire it into - `ValidateCommand` (reject when `(ask-bid)` exceeds the limit, converting - pips per symbol digits) or remove the input and the README row. -2. **`MaxPositions` counts positions, not pending orders.** A burst of split - groups can queue 10 groups x 10 pending orders while `CountOpenPositions()` - returns 0. Count pending orders with the EA's magic number toward the cap - (or add a separate `MaxPendingOrders`). -3. **Recovered groups don't restore `count`** (set to `MAX_SPLITS` with - guards). Harmless today, but `UpdateTPLevelClosed` gray-marks slots that - never existed and the all-alive scan does extra `OrderSelect` calls. - Persisting group state (item 6) fixes this properly. -4. **`partial_close_percent` rides through the whole stack unused.** It's in - the API model, forwarded to the EA, and ignored there. Remove it or - implement partial closes. -5. **Daily-loss day boundary uses server time, not broker midnight.** - `TimeCurrent() % 86400` resets at UTC-day of the server clock; brokers with - offset timezones reset mid-session. Consider `iTime(_Symbol, PERIOD_D1, 0)` - as the boundary. -6. **Group state lives only in RAM + order comments.** If a broker rewrites - comments (some do on partial fills), recovery loses the group. Persist - `orderGroups` to a file (e.g. `MQL5/Files/split_groups.json`) on change and - prefer it during recovery, falling back to comments. - -## Incomplete / promised features - -7. **Trailing trigger/target are hardcoded (TP2 -> TP1).** Natural follow-up - to variable splits: optional `trailing` object in the API - (`{"trigger_level": 2, "sl_to_level": 1}` semantics, breakeven option). - Deliberately out of scope in the v4.2.0 spec. -8. **Safe-shutdown consolidation level is fixed at TP2.** Same - generalization as (7). -9. **Modify-order endpoint.** The API can place, list, close, delete - but - not modify SL/TP of an existing group. AI-agent workflows want - `PATCH /group/{groupId}` (move SL, shift remaining TPs). -10. **Group-level API.** `GET /groups` (tracked split groups with per-level - status) and `DELETE /group/{groupId}` (cancel/close a whole group). Today - callers must correlate tickets themselves from `/positions` + `/orders`. -11. **Telegram/webhook notifications** (README roadmap promise): order - placed/filled, TP hit, trailing applied, safe-shutdown done. -12. **Trade journal** (README roadmap promise): append fills/closures to CSV - or SQLite via the EA's `OnTradeTransaction`, expose `GET /journal`. -13. **Web dashboard** (README roadmap promise): small single-page UI over the - existing REST API - positions, groups, safe-shutdown button. -14. **MCP: group-aware tools.** Once (10) exists, add `list_groups` / - `close_group` tools; also a `dry_run` flag on `place_split_order` that - returns the computed per-level volumes without placing. - -## Infrastructure / quality - -15. **EA integration harness.** The socket protocol is testable without a - broker: a pytest fixture can speak the EA's TCP protocol (fake EA), and a - stub bridge can drive the EA in the Strategy Tester via a compile-time - `#ifdef` transport shim. Would finally cover the EA's JSON parser. -16. **Release automation.** Tag-triggered workflow attaching the compiled - `.ex5` (already built by compile-ea.yml) to a GitHub Release. -17. **CONTRIBUTING.md + issue templates** - the repo is viral; convert stars - into safe PRs (note the ASCII/CRLF rule for the .mq5, CI expectations). -18. **Rotate the exposed PAT** (owner action; noted 2026-07-04). -19. **Modernize pinned Python deps.** requirements.txt pins fastapi 0.109 / - pydantic 2.5.3 / uvicorn 0.27 (early-2024). Newer fastapi drops the - TestClient `app=` kwarg problem (tests currently pin httpx<0.28 to stay - compatible) and picks up security fixes. One PR: bump pins, rerun CI. diff --git a/server.py b/server.py index 7722529..db99340 100644 --- a/server.py +++ b/server.py @@ -60,7 +60,6 @@ class OrderCommand(BaseModel): deviation: int = 3 comment: str = "Claude AI" magic_number: int = 20250117 - partial_close_percent: float = 20.0 @field_validator("tp_levels") @classmethod @@ -254,7 +253,6 @@ async def create_order(order: OrderCommand): "deviation": order.deviation, "comment": order.comment, "magic_number": order.magic_number, - "partial_close_percent": order.partial_close_percent, }, } return send_command_to_mt5(command) diff --git a/tests/test_bridge.py b/tests/test_bridge.py index 143fba3..3b12cca 100644 --- a/tests/test_bridge.py +++ b/tests/test_bridge.py @@ -155,9 +155,12 @@ def test_envelope_correlation_concurrent(): results = {} errors = {} + # The caller timeout must exceed the fake-EA serve deadline: an abandoned + # (timed-out) command can never be served, so if a caller gave up before + # the EA got to it, `served` could never reach 2. Keep callers patient. def _caller(tag): try: - results[tag] = server.send_command_to_mt5({"action": "PING", "tag": tag}, timeout=5) + results[tag] = server.send_command_to_mt5({"action": "PING", "tag": tag}, timeout=20) except Exception as exc: # noqa: BLE001 - surfaced via the errors dict errors[tag] = exc @@ -165,10 +168,10 @@ def _caller(tag): for thread in threads: thread.start() - served = _fake_ea_serve(count=2, deadline=time.time() + 8) + served = _fake_ea_serve(count=2, deadline=time.time() + 15) for thread in threads: - thread.join(timeout=8) + thread.join(timeout=20) assert served == 2, f"fake EA only served {served}/2 commands" assert not errors, f"callers raised unexpectedly: {errors}"