From 215a4af23fdcbca6fbd7fbd7522d6fcffea15dbc Mon Sep 17 00:00:00 2001 From: codedpro Date: Wed, 1 Jul 2026 19:54:45 +0100 Subject: [PATCH 1/5] Harden safety and correctness of EA + bridge EA (bulk-add-signals.mq5): - Reject wrong-side order types instead of silently converting STOP<->LIMIT, which used to reverse trade intent (breakout vs pullback). Support all four pending types explicitly and validate SL/TP are on the correct side. - Store the actual TP prices per group and use them for trailing SL (real TP1) and safe shutdown (real TP2) instead of hardcoded pip offsets that broke on any non-default TP ladder. Fixes the converted-LIMIT direction bug. - Use a compact "#" order comment so group/TP tokens survive broker comment truncation; rebuild TP prices from live orders on recovery and accept both the new and legacy comment formats. - Reset the daily-loss baseline at each day boundary and measure against equity so floating losses count. Bridge (server.py): - Give each request its own response Event so concurrent calls can no longer receive each other's response; abandon a command on timeout so it can't fire late on the EA's next poll. - Add optional X-API-Key auth and bind to 127.0.0.1 by default; Docker sets HOST=0.0.0.0 with an API_KEY passthrough and no longer runs uvicorn --reload. Docs updated where behavior changed (no auto-convert; TP-aware trailing; new Security section). Verified: EA compiles with MetaEditor (0 errors, 0 warnings); bridge correlation + timeout-abandon covered by a local test. Co-Authored-By: Claude Opus 4.8 --- Dockerfile | 9 +- README.md | 33 +++- bulk-add-signals.mq5 | 430 +++++++++++++++++++++---------------------- docker-compose.yml | 10 +- server.py | 299 ++++++++++++++---------------- 5 files changed, 387 insertions(+), 394 deletions(-) diff --git a/Dockerfile b/Dockerfile index d696d8c..63529ae 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,5 +13,10 @@ COPY config.json . # Expose API port EXPOSE 8080 -# Run the server -CMD ["uvicorn", "server:app", "--host", "0.0.0.0", "--port", "8080", "--reload"] +# Inside a container we must bind all interfaces. Set API_KEY (e.g. via +# docker-compose environment or --env) to require auth on the trading endpoints. +ENV HOST=0.0.0.0 +ENV PORT=8080 + +# Run the server (no --reload in production) +CMD ["python", "server.py"] diff --git a/README.md b/README.md index e825a00..931b820 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ System: Splits order → Manages positions → Returns status - ✅ **Safe Shutdown Mode** - Protects positions when EA is offline - ✅ **REST API Integration** - FastAPI server for external signal processing - ✅ **TCP Socket Communication** - High-performance bidirectional communication -- ✅ **Smart Order Type Detection** - Automatically converts STOP/LIMIT based on market price +- ✅ **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 - ✅ **Multi-Symbol Support** - Optimized for Gold (XAUUSD) and Silver (XAGUSD) - ✅ **Risk Management** - Daily loss limits, max positions, spread checks @@ -174,7 +174,7 @@ The EA automatically adjusts pip values based on symbol: **When TP2 closes (45 pips profit):** 1. EA detects TP2 position is closed -2. Automatically moves SL to TP1 price (15 pips from entry) +2. Automatically moves SL to **your actual TP1 price** (the price you sent, not a fixed pip offset) 3. Remaining 30% of position (TP3, TP4, TP5) is now **risk-free** 4. Even if market reverses, you keep 15 pips profit on remaining positions @@ -317,7 +317,7 @@ Place a new order with automatic splitting. **Parameters:** - `symbol` (string): Trading symbol ("XAUUSD" or "XAGUSD") -- `order_type` (string): "BUY_STOP" or "SELL_STOP" +- `order_type` (string): "BUY_STOP", "SELL_STOP", "BUY_LIMIT", or "SELL_LIMIT". The price must be on the correct side of the market for the type (e.g. BUY_STOP above the ask); wrong-side requests are rejected with an explanatory message rather than converted. - `price` (float): Entry price - `sl` (float): Stop loss price - `tp_levels` (array): 5 take profit levels @@ -489,6 +489,23 @@ Configure in MT5 when attaching EA to chart: | `MaxSpreadPips` | 10 | Maximum allowed spread in pips | | `MaxDailyLossPercent` | 5.0 | Stop trading if daily loss exceeds % | +### 🔒 Security + +This API can place and close real trades, so it is locked down by default: + +- **Localhost only** — the HTTP server binds to `127.0.0.1` unless you set `HOST=0.0.0.0`. +- **Optional API key** — set the `API_KEY` environment variable and every trading endpoint (everything except `/` and `/health`) requires a matching `X-API-Key` header: + +```bash +API_KEY=your-secret HOST=0.0.0.0 python server.py + +curl -X POST http://localhost:8080/order \ + -H "X-API-Key: your-secret" -H "Content-Type: application/json" \ + -d '{ ... }' +``` + +If you expose the server beyond localhost (e.g. Docker with `HOST=0.0.0.0`) **always** set `API_KEY`. The server prints a warning if you don't. + ## 📦 Installation ### Option 1: Manual Installation @@ -590,13 +607,13 @@ curl -X POST http://localhost:8080/safe-shutdown **Symptoms:** All split orders fail immediately -**Cause:** Order type doesn't match price position relative to market +**Cause:** The order type doesn't match the price's position relative to the market. -**Solution:** The EA now auto-detects and converts: -- SELL STOP above market → SELL LIMIT -- BUY STOP below market → BUY LIMIT +**Solution:** The EA validates this and returns a clear message instead of silently changing your order. Pick the type that matches your intent: +- Entry **above** market → `BUY_STOP` (breakout) or `SELL_LIMIT` (fade) +- Entry **below** market → `SELL_STOP` (breakout) or `BUY_LIMIT` (dip-buy) -Recompile the EA with the latest code. +SL and TP prices must also be on the correct side of the entry, or the order is rejected. Recompile the EA with the latest code. ### Port Already in Use diff --git a/bulk-add-signals.mq5 b/bulk-add-signals.mq5 index df803d2..f579bd0 100644 --- a/bulk-add-signals.mq5 +++ b/bulk-add-signals.mq5 @@ -29,6 +29,8 @@ CPositionInfo position; 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 //--- Structures struct TradeCommand { @@ -46,6 +48,7 @@ struct TradeCommand { struct SplitOrderGroup { string groupId; // Unique identifier for the group (based on entry price + symbol) ulong tickets[5]; // Tickets for all 5 split orders + double tp_prices[5]; // Actual TP price placed for each split (source of truth) bool tp2_reached; // Flag to track if TP2 was reached double entry_price; // Entry price string symbol; // Symbol @@ -67,8 +70,9 @@ int OnInit() trade.SetTypeFilling(ORDER_FILLING_RETURN); // Changed from FOK to RETURN for better broker compatibility trade.SetAsyncMode(false); - // Store starting balance + // Store starting balance and current trading day (for daily loss reset) dailyStartBalance = account.Balance(); + dailyStartDay = TimeCurrent() - (TimeCurrent() % 86400); // Recover existing positions for split order tracking RecoverSplitOrders(); @@ -347,6 +351,26 @@ double ExtractDouble(string json, string key) return StringToDouble(value); } +//+------------------------------------------------------------------+ +//| Parse an order-type string into an ENUM (WRONG_VALUE if unknown) | +//+------------------------------------------------------------------+ +ENUM_ORDER_TYPE ParseOrderType(string s) +{ + if(s == "BUY_STOP") return ORDER_TYPE_BUY_STOP; + if(s == "SELL_STOP") return ORDER_TYPE_SELL_STOP; + if(s == "BUY_LIMIT") return ORDER_TYPE_BUY_LIMIT; + if(s == "SELL_LIMIT") return ORDER_TYPE_SELL_LIMIT; + return WRONG_VALUE; +} + +//+------------------------------------------------------------------+ +//| Is this a buy-side order type? | +//+------------------------------------------------------------------+ +bool IsBuyType(ENUM_ORDER_TYPE t) +{ + return (t == ORDER_TYPE_BUY_STOP || t == ORDER_TYPE_BUY_LIMIT); +} + //+------------------------------------------------------------------+ //| Validate command | //+------------------------------------------------------------------+ @@ -387,29 +411,50 @@ bool ValidateCommand(TradeCommand &cmd) //+------------------------------------------------------------------+ ulong ExecuteOrder(TradeCommand &cmd, string &errorMsg) { - // Smart order type detection based on price vs market - double currentPrice = SymbolInfoDouble(cmd.symbol, SYMBOL_ASK); - ENUM_ORDER_TYPE orderType; - - if(cmd.order_type == "BUY_STOP") + // Parse the requested order type explicitly. We do NOT silently convert a + // STOP into a LIMIT (or vice-versa): that would reverse the trade's intent + // (breakout vs. pullback entry). Ambiguous requests are rejected instead. + ENUM_ORDER_TYPE orderType = ParseOrderType(cmd.order_type); + if(orderType == WRONG_VALUE) { - // BUY STOP must be above market, BUY LIMIT below market - orderType = (cmd.price > currentPrice) ? ORDER_TYPE_BUY_STOP : ORDER_TYPE_BUY_LIMIT; + errorMsg = "Unknown order_type '" + cmd.order_type + "' (expected BUY_STOP/SELL_STOP/BUY_LIMIT/SELL_LIMIT)"; + Print("❌ ", errorMsg); + return 0; } - else // SELL_STOP + + double ask = SymbolInfoDouble(cmd.symbol, SYMBOL_ASK); + double bid = SymbolInfoDouble(cmd.symbol, SYMBOL_BID); + bool isBuy = IsBuyType(orderType); + + // Reject orders whose price is on the wrong side of the market for the + // requested type, rather than quietly re-interpreting them. + string sideError = ""; + if(orderType == ORDER_TYPE_BUY_STOP && cmd.price <= ask) sideError = "BUY_STOP requires price ABOVE ask - use BUY_LIMIT for entries below market"; + if(orderType == ORDER_TYPE_BUY_LIMIT && cmd.price >= ask) sideError = "BUY_LIMIT requires price BELOW ask - use BUY_STOP for entries above market"; + if(orderType == ORDER_TYPE_SELL_STOP && cmd.price >= bid) sideError = "SELL_STOP requires price BELOW bid - use SELL_LIMIT for entries above market"; + if(orderType == ORDER_TYPE_SELL_LIMIT && cmd.price <= bid) sideError = "SELL_LIMIT requires price ABOVE bid - use SELL_STOP for entries below market"; + if(sideError != "") { - // SELL STOP must be below market, SELL LIMIT above market - orderType = (cmd.price < currentPrice) ? ORDER_TYPE_SELL_STOP : ORDER_TYPE_SELL_LIMIT; + errorMsg = StringFormat("%s (ask=%.5f bid=%.5f price=%.5f)", sideError, ask, bid, cmd.price); + Print("❌ Refusing order: ", errorMsg); + return 0; } - string orderTypeStr = ""; - if(orderType == ORDER_TYPE_BUY_STOP) orderTypeStr = "BUY_STOP"; - else if(orderType == ORDER_TYPE_BUY_LIMIT) orderTypeStr = "BUY_LIMIT"; - else if(orderType == ORDER_TYPE_SELL_STOP) orderTypeStr = "SELL_STOP"; - else if(orderType == ORDER_TYPE_SELL_LIMIT) orderTypeStr = "SELL_LIMIT"; + // Validate SL / TP are on the correct side of the entry for the direction. + if(cmd.sl > 0) + { + if(isBuy && cmd.sl >= cmd.price) { errorMsg = "BUY stop-loss must be below entry price"; Print("❌ ", errorMsg); return 0; } + if(!isBuy && cmd.sl <= cmd.price) { errorMsg = "SELL stop-loss must be above entry price"; Print("❌ ", errorMsg); return 0; } + } + for(int t = 0; t < 5; t++) + { + if(cmd.tp_levels[t] <= 0) { errorMsg = StringFormat("TP%d is missing or invalid", t+1); Print("❌ ", errorMsg); return 0; } + if(isBuy && cmd.tp_levels[t] <= cmd.price) { errorMsg = StringFormat("BUY TP%d must be above entry price", t+1); Print("❌ ", errorMsg); return 0; } + if(!isBuy && cmd.tp_levels[t] >= cmd.price) { errorMsg = StringFormat("SELL TP%d must be below entry price", t+1); Print("❌ ", errorMsg); return 0; } + } - Print("Executing ", cmd.order_type, " → ", orderTypeStr, " order on ", cmd.symbol); - Print("Current price: ", currentPrice, " | Order price: ", cmd.price, " | SL: ", cmd.sl, " | Total Lot: ", cmd.lot_size); + Print("Executing ", cmd.order_type, " order on ", cmd.symbol); + Print("Ask: ", ask, " | Bid: ", bid, " | Order price: ", cmd.price, " | SL: ", cmd.sl, " | Total Lot: ", cmd.lot_size); Print("Will split into 5 orders: TP1=60%, TP2-TP5=10% each"); // Calculate volume splits @@ -422,8 +467,11 @@ ulong ExecuteOrder(TradeCommand &cmd, string &errorMsg) volumes[3] = NormalizeDouble(cmd.lot_size * 0.10, 2); // 10% for TP4 volumes[4] = NormalizeDouble(cmd.lot_size * 0.10, 2); // 10% for TP5 - // Create group ID - string groupId = StringFormat("%s_%.3f_%d", cmd.symbol, cmd.price, (int)TimeLocal()); + // Create a compact, unique group ID. Kept short so it fits inside the MT5 + // order comment (brokers truncate long comments, which used to silently + // break recovery). The monotonic counter guarantees uniqueness per second. + g_groupCounter++; + string groupId = StringFormat("SM%d_%d", (int)TimeLocal(), g_groupCounter); // Create split order group int groupIndex = ArraySize(orderGroups); @@ -433,6 +481,8 @@ ulong ExecuteOrder(TradeCommand &cmd, string &errorMsg) orderGroups[groupIndex].entry_price = cmd.price; orderGroups[groupIndex].symbol = cmd.symbol; orderGroups[groupIndex].order_type = orderType; + for(int t = 0; t < 5; t++) + orderGroups[groupIndex].tp_prices[t] = cmd.tp_levels[t]; // Place 5 separate orders int successCount = 0; @@ -440,7 +490,9 @@ ulong ExecuteOrder(TradeCommand &cmd, string &errorMsg) for(int i = 0; i < 5; i++) { - string orderComment = StringFormat("%s|GROUP:%s|TP:%d", cmd.comment, groupId, i+1); + // Functional comment only: "#". No free-text prefix, + // so the group/index tokens can't be pushed out by comment truncation. + string orderComment = StringFormat("%s#%d", groupId, i+1); bool success = trade.OrderOpen( cmd.symbol, @@ -482,8 +534,8 @@ ulong ExecuteOrder(TradeCommand &cmd, string &errorMsg) return 0; } - // Draw TP levels on chart (once for the group) - DrawTPLevels(groupId, cmd.symbol, cmd.price, orderType); + // Draw TP levels on chart (once for the group) using the ACTUAL TP prices + DrawTPLevels(groupId, cmd.symbol, cmd.price, orderGroups[groupIndex].tp_prices); Print("✅ Order group placed: ", successCount, "/5 orders successful"); return firstTicket; // Return first ticket as reference @@ -493,22 +545,16 @@ ulong ExecuteOrder(TradeCommand &cmd, string &errorMsg) //+------------------------------------------------------------------+ //| Draw TP levels on chart | //+------------------------------------------------------------------+ -void DrawTPLevels(string groupId, string symbol, double entryPrice, ENUM_ORDER_TYPE orderType) +void DrawTPLevels(string groupId, string symbol, double entryPrice, double &tpPrices[]) { - // Calculate TP levels: TP1=15 pips, then +30 pip intervals - double pipValue = (symbol == "XAGUSD") ? 0.01 : 0.10; // Silver: 0.01, Gold: 0.10 - int direction = (orderType == ORDER_TYPE_BUY_STOP) ? 1 : -1; - color levelColors[5] = {clrLime, clrGreen, clrYellow, clrOrange, clrRed}; string percentages[5] = {"60%", "10%", "10%", "10%", "10%"}; - // TP levels: 15, 45, 75, 105, 135 pips - int tpPips[5] = {15, 45, 75, 105, 135}; - for(int i = 0; i < 5; i++) { - double tpPrice = entryPrice + (direction * tpPips[i] * pipValue); + double tpPrice = tpPrices[i]; + if(tpPrice <= 0) continue; // TP already closed / unknown - skip drawing // Create horizontal line string lineName = StringFormat("TP%d_Line_%s", i+1, groupId); @@ -616,14 +662,11 @@ void CheckTP2ForTrailingSL() // TP2 was hit! Move SL to TP1 for all remaining positions Print("🎯 TP2 reached for group ", orderGroups[i].groupId, " - Moving SL to TP1 for all remaining positions"); - double entry = orderGroups[i].entry_price; - string symbol = orderGroups[i].symbol; - ENUM_ORDER_TYPE orderType = orderGroups[i].order_type; - - // Calculate TP1 price (15 pips from entry) - double pipValue = (symbol == "XAGUSD") ? 0.01 : 0.10; // Silver: 0.01, Gold: 0.10 - int direction = (orderType == ORDER_TYPE_BUY_STOP) ? 1 : -1; - double newSL = entry + (direction * 15 * pipValue); + // Use the ACTUAL TP1 price that was placed - not a hardcoded pip + // offset that may not match the caller's TP ladder. Fall back to + // breakeven (entry) if TP1's price is unknown after recovery. + double newSL = orderGroups[i].tp_prices[0]; + if(newSL <= 0) newSL = orderGroups[i].entry_price; // Update SL for all remaining positions (TP3, TP4, TP5) int movedCount = 0; @@ -714,12 +757,78 @@ int CountOpenPositions() //+------------------------------------------------------------------+ bool CheckDailyLossLimit() { - double loss = dailyStartBalance - account.Balance(); + // Reset the daily baseline when a new calendar day starts. + datetime today = TimeCurrent() - (TimeCurrent() % 86400); + if(today != dailyStartDay) + { + dailyStartDay = today; + dailyStartBalance = account.Balance(); + } + + if(dailyStartBalance <= 0) return true; + + // Measure against equity so floating (open) losses count too - more + // conservative than balance, which ignores open positions. + double loss = dailyStartBalance - account.Equity(); double lossPercent = (loss / dailyStartBalance) * 100.0; return (lossPercent < MaxDailyLossPercent); } +//+------------------------------------------------------------------+ +//| Parse a split-order comment into (groupId, tpLevel). | +//| Supports the new "#" format and the legacy | +//| "...|GROUP:|TP:" format for in-flight orders. | +//+------------------------------------------------------------------+ +bool ParseGroupComment(string comment, string &groupId, int &tpLevel) +{ + // New compact format: "#" + int hashPos = StringFind(comment, "#"); + if(hashPos > 0) + { + groupId = StringSubstr(comment, 0, hashPos); + tpLevel = (int)StringToInteger(StringSubstr(comment, hashPos + 1, 1)); + if(tpLevel >= 1 && tpLevel <= 5) return true; + } + + // Legacy format: "...|GROUP:|TP:" + int gPos = StringFind(comment, "|GROUP:"); + if(gPos >= 0) + { + int tpPos = StringFind(comment, "|TP:", gPos); + if(tpPos >= 0) + { + groupId = StringSubstr(comment, gPos + 7, tpPos - gPos - 7); + tpLevel = (int)StringToInteger(StringSubstr(comment, tpPos + 4, 1)); + if(tpLevel >= 1 && tpLevel <= 5) return true; + } + } + + return false; +} + +//+------------------------------------------------------------------+ +//| Find the group with this id, or create a fresh (empty) one. | +//+------------------------------------------------------------------+ +int FindOrCreateGroup(string groupId) +{ + for(int i = 0; i < ArraySize(orderGroups); i++) + if(orderGroups[i].groupId == groupId) return i; + + int idx = ArraySize(orderGroups); + ArrayResize(orderGroups, idx + 1); + orderGroups[idx].groupId = groupId; + orderGroups[idx].tp2_reached = false; + orderGroups[idx].entry_price = 0; + orderGroups[idx].symbol = ""; + for(int t = 0; t < 5; t++) + { + orderGroups[idx].tickets[t] = 0; + orderGroups[idx].tp_prices[t] = 0; + } + return idx; +} + //+------------------------------------------------------------------+ //| Recover split orders on EA restart | //+------------------------------------------------------------------+ @@ -729,206 +838,86 @@ void RecoverSplitOrders() ArrayResize(orderGroups, 0); // Clear array first - int totalPositions = PositionsTotal(); - int totalOrders = OrdersTotal(); - - Print("Total open positions found: ", totalPositions); - Print("Total pending orders found: ", totalOrders); - - // Build a map of group IDs from existing positions/orders - string groupIds[]; - int groupCount = 0; + Print("Total open positions found: ", PositionsTotal()); + Print("Total pending orders found: ", OrdersTotal()); - // Scan all positions and orders to find unique group IDs + // Rebuild groups directly from open positions. The actual TP price of each + // split is read back from the live order/position, so trailing and safe + // shutdown stay consistent with what was really placed. for(int i = 0; i < PositionsTotal(); i++) { if(position.SelectByIndex(i) && position.Magic() == MagicNumber) { - string comment = position.Comment(); - int groupPos = StringFind(comment, "|GROUP:"); - if(groupPos >= 0) - { - int tpPos = StringFind(comment, "|TP:", groupPos); - if(tpPos >= 0) - { - string groupId = StringSubstr(comment, groupPos + 7, tpPos - groupPos - 7); + string groupId; int tpLevel; + if(!ParseGroupComment(position.Comment(), groupId, tpLevel)) continue; - // Check if we already have this group - bool found = false; - for(int j = 0; j < groupCount; j++) - { - if(groupIds[j] == groupId) - { - found = true; - break; - } - } + int gi = FindOrCreateGroup(groupId); + orderGroups[gi].tickets[tpLevel - 1] = position.Ticket(); + orderGroups[gi].tp_prices[tpLevel - 1] = position.TakeProfit(); - if(!found) - { - ArrayResize(groupIds, groupCount + 1); - groupIds[groupCount] = groupId; - groupCount++; - } - } + if(orderGroups[gi].entry_price == 0) + { + orderGroups[gi].entry_price = position.PriceOpen(); + orderGroups[gi].symbol = position.Symbol(); + ENUM_POSITION_TYPE posType = (ENUM_POSITION_TYPE)position.Type(); + orderGroups[gi].order_type = (posType == POSITION_TYPE_BUY) ? ORDER_TYPE_BUY_STOP : ORDER_TYPE_SELL_STOP; } + + Print(" ✅ Found position TP", tpLevel, " - Ticket #", position.Ticket()); } } - // Also check pending orders + // Then fold in pending orders for the same groups. for(int i = 0; i < OrdersTotal(); i++) { ulong ticket = OrderGetTicket(i); if(ticket > 0 && OrderGetInteger(ORDER_MAGIC) == MagicNumber) { - string comment = OrderGetString(ORDER_COMMENT); - int groupPos = StringFind(comment, "|GROUP:"); - if(groupPos >= 0) - { - int tpPos = StringFind(comment, "|TP:", groupPos); - if(tpPos >= 0) - { - string groupId = StringSubstr(comment, groupPos + 7, tpPos - groupPos - 7); + string groupId; int tpLevel; + if(!ParseGroupComment(OrderGetString(ORDER_COMMENT), groupId, tpLevel)) continue; - // Check if we already have this group - bool found = false; - for(int j = 0; j < groupCount; j++) - { - if(groupIds[j] == groupId) - { - found = true; - break; - } - } + int gi = FindOrCreateGroup(groupId); + orderGroups[gi].tickets[tpLevel - 1] = ticket; + orderGroups[gi].tp_prices[tpLevel - 1] = OrderGetDouble(ORDER_TP); - if(!found) - { - ArrayResize(groupIds, groupCount + 1); - groupIds[groupCount] = groupId; - groupCount++; - } - } + if(orderGroups[gi].entry_price == 0) + { + orderGroups[gi].entry_price = OrderGetDouble(ORDER_PRICE_OPEN); + orderGroups[gi].symbol = OrderGetString(ORDER_SYMBOL); + orderGroups[gi].order_type = (ENUM_ORDER_TYPE)OrderGetInteger(ORDER_TYPE); } + + Print(" 📋 Found pending order TP", tpLevel, " - Ticket #", ticket); } } + int groupCount = ArraySize(orderGroups); Print("Found ", groupCount, " unique order group(s) to recover"); - // Rebuild each group + // Post-process: infer TP2-reached state and redraw visuals. for(int g = 0; g < groupCount; g++) { - string groupId = groupIds[g]; - Print("🔄 Recovering group: ", groupId); - - int groupIndex = ArraySize(orderGroups); - ArrayResize(orderGroups, groupIndex + 1); - - orderGroups[groupIndex].groupId = groupId; - orderGroups[groupIndex].tp2_reached = false; - - // Initialize tickets to 0 - for(int t = 0; t < 5; t++) + // TP2 was already hit if its ticket is gone but a later TP survives. + if(orderGroups[g].tickets[1] == 0 && + (orderGroups[g].tickets[2] > 0 || + orderGroups[g].tickets[3] > 0 || + orderGroups[g].tickets[4] > 0)) { - orderGroups[groupIndex].tickets[t] = 0; + orderGroups[g].tp2_reached = true; + Print(" 🎯 TP2 already reached for group ", orderGroups[g].groupId); } - // Find all tickets for this group (from positions) - for(int i = 0; i < PositionsTotal(); i++) + if(orderGroups[g].entry_price > 0) { - if(position.SelectByIndex(i) && position.Magic() == MagicNumber) - { - string comment = position.Comment(); - if(StringFind(comment, "|GROUP:" + groupId) >= 0) - { - // Extract TP level - int tpPos = StringFind(comment, "|TP:"); - if(tpPos >= 0) - { - string tpStr = StringSubstr(comment, tpPos + 4, 1); - int tpLevel = (int)StringToInteger(tpStr); - - if(tpLevel >= 1 && tpLevel <= 5) - { - orderGroups[groupIndex].tickets[tpLevel - 1] = position.Ticket(); - - // Store metadata from first position found - if(orderGroups[groupIndex].entry_price == 0) - { - orderGroups[groupIndex].entry_price = position.PriceOpen(); - orderGroups[groupIndex].symbol = position.Symbol(); - ENUM_POSITION_TYPE posType = (ENUM_POSITION_TYPE)position.Type(); - orderGroups[groupIndex].order_type = (posType == POSITION_TYPE_BUY) ? ORDER_TYPE_BUY_STOP : ORDER_TYPE_SELL_STOP; - } - - Print(" ✅ Found position TP", tpLevel, " - Ticket #", position.Ticket()); - } - } - } - } - } + DrawTPLevels(orderGroups[g].groupId, orderGroups[g].symbol, + orderGroups[g].entry_price, orderGroups[g].tp_prices); - // Find all tickets for this group (from pending orders) - for(int i = 0; i < OrdersTotal(); i++) - { - ulong ticket = OrderGetTicket(i); - if(ticket > 0 && OrderGetInteger(ORDER_MAGIC) == MagicNumber) - { - string comment = OrderGetString(ORDER_COMMENT); - if(StringFind(comment, "|GROUP:" + groupId) >= 0) - { - // Extract TP level - int tpPos = StringFind(comment, "|TP:"); - if(tpPos >= 0) - { - string tpStr = StringSubstr(comment, tpPos + 4, 1); - int tpLevel = (int)StringToInteger(tpStr); - - if(tpLevel >= 1 && tpLevel <= 5) - { - orderGroups[groupIndex].tickets[tpLevel - 1] = ticket; - - // Store metadata from first order found - if(orderGroups[groupIndex].entry_price == 0) - { - orderGroups[groupIndex].entry_price = OrderGetDouble(ORDER_PRICE_OPEN); - orderGroups[groupIndex].symbol = OrderGetString(ORDER_SYMBOL); - orderGroups[groupIndex].order_type = (ENUM_ORDER_TYPE)OrderGetInteger(ORDER_TYPE); - } - - Print(" 📋 Found pending order TP", tpLevel, " - Ticket #", ticket); - } - } - } - } - } - - // Check if TP2 was already hit (TP2 position doesn't exist, but TP3+ do) - if(orderGroups[groupIndex].tickets[1] == 0 && - (orderGroups[groupIndex].tickets[2] > 0 || - orderGroups[groupIndex].tickets[3] > 0 || - orderGroups[groupIndex].tickets[4] > 0)) - { - orderGroups[groupIndex].tp2_reached = true; - Print(" 🎯 TP2 already reached for this group"); - } - - // Redraw visuals if we have valid metadata - if(orderGroups[groupIndex].entry_price > 0) - { - DrawTPLevels(groupId, orderGroups[groupIndex].symbol, - orderGroups[groupIndex].entry_price, - orderGroups[groupIndex].order_type); - - // Mark closed TPs as gray + // Mark already-closed TPs as gray. for(int t = 0; t < 5; t++) - { - if(orderGroups[groupIndex].tickets[t] == 0) - { - UpdateTPLevelClosed(groupId, t + 1); - } - } + if(orderGroups[g].tickets[t] == 0) + UpdateTPLevelClosed(orderGroups[g].groupId, t + 1); - Print("✅ Group ", groupId, " recovered successfully"); + Print("✅ Group ", orderGroups[g].groupId, " recovered successfully"); } } @@ -1064,16 +1053,8 @@ string HandleClosePosition(string commandJson) string groupId = ""; if(PositionSelectByTicket(ticket)) { - string comment = PositionGetString(POSITION_COMMENT); - int groupPos = StringFind(comment, "|GROUP:"); - if(groupPos >= 0) - { - int tpPos = StringFind(comment, "|TP:", groupPos); - if(tpPos >= 0) - { - groupId = StringSubstr(comment, groupPos + 7, tpPos - groupPos - 7); - } - } + int tpLevel; + ParseGroupComment(PositionGetString(POSITION_COMMENT), groupId, tpLevel); } if(trade.PositionClose(ticket)) @@ -1143,13 +1124,14 @@ string HandleSafeShutdown() Print("🔄 Processing group: ", orderGroups[i].groupId); double entry = orderGroups[i].entry_price; - string symbol = orderGroups[i].symbol; - ENUM_ORDER_TYPE orderType = orderGroups[i].order_type; - // Calculate TP2 price (45 pips from entry) - double pipValue = (symbol == "XAGUSD") ? 0.01 : 0.10; // Silver: 0.01, Gold: 0.10 - int direction = (orderType == ORDER_TYPE_BUY_STOP) ? 1 : -1; - double tp2Price = entry + (direction * 45 * pipValue); + // Consolidate remaining TPs to the ACTUAL TP2 price that was placed. + double tp2Price = orderGroups[i].tp_prices[1]; + if(tp2Price <= 0) + { + Print("⚠️ Skipping group ", orderGroups[i].groupId, " - TP2 price unknown"); + continue; + } int modifiedInGroup = 0; diff --git a/docker-compose.yml b/docker-compose.yml index 122a9f3..15881cd 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,13 +4,15 @@ services: signal-server: build: . container_name: mt5-signal-server - ports: - - "8080:8080" volumes: - ./server.py:/app/server.py - ./config.json:/app/config.json - - ./.env:/app/.env environment: - PYTHONUNBUFFERED=1 - network_mode: "host" # Required for ZeroMQ to communicate with MT5 on localhost + - HOST=0.0.0.0 + # Set a secret to require X-API-Key on trading endpoints (recommended). + - API_KEY=${API_KEY:-} + # Host networking lets the TCP bridge talk to MT5 on localhost and exposes + # the HTTP API directly on the host, so an explicit port mapping is not used. + network_mode: "host" restart: unless-stopped diff --git a/server.py b/server.py index 2d79779..d1f7e83 100644 --- a/server.py +++ b/server.py @@ -1,33 +1,51 @@ """ MT5 TCP Server - Python listens on socket, MQL5 connects as client -Claude AI → FastAPI → Queue → TCP Server → MQL5 EA +Client -> FastAPI -> per-request envelope -> TCP Server -> MQL5 EA + +Safety notes: +- Every API request gets its own response Event, so concurrent requests can + never receive each other's response (the old shared-queue design could). +- A request that times out is marked "abandoned" so its command is NOT sent + to the EA later - important for a trading system (no surprise late orders). +- Optional API-key auth (set API_KEY) and localhost-only binding by default. """ import json +import os import socket import threading import queue -from fastapi import FastAPI, HTTPException +import time + +from fastapi import FastAPI, HTTPException, Header, Depends from pydantic import BaseModel -from typing import List +from typing import List, Optional import uvicorn # Load config with open('config.json', 'r') as f: config = json.load(f) -# Queue for commands -command_queue = queue.Queue() -response_queue = queue.Queue() - -# TCP Server settings +# TCP server (MQL5 connects here) TCP_HOST = '127.0.0.1' TCP_PORT = config['mt5']['zmq_port'] +# HTTP server. Default to localhost so trades can't be placed by anyone on the +# network. Override with HOST=0.0.0.0 (e.g. in Docker) only behind auth. +HTTP_HOST = os.environ.get('HOST', '127.0.0.1') +HTTP_PORT = int(os.environ.get('PORT', '8080')) + +# Optional shared secret. If set, every trading endpoint requires the matching +# X-API-Key header. Strongly recommended whenever HOST is not localhost. +API_KEY = os.environ.get('API_KEY', '') + +REQUEST_TIMEOUT = int(os.environ.get('REQUEST_TIMEOUT', '10')) + print(f"✅ Config loaded - TCP Server will listen on {TCP_HOST}:{TCP_PORT}") +if not API_KEY and HTTP_HOST != '127.0.0.1': + print("⚠️ WARNING: HOST is not localhost and API_KEY is unset - the trading API is UNAUTHENTICATED.") -# FastAPI app -app = FastAPI(title="MT5 TCP Bridge", version="4.0.0") +app = FastAPI(title="MT5 TCP Bridge", version="4.1.0") class OrderCommand(BaseModel): @@ -44,10 +62,46 @@ class OrderCommand(BaseModel): partial_close_percent: float = 20.0 +class Envelope: + """One in-flight command plus the machinery to deliver its response back to + exactly the request that issued it.""" + __slots__ = ("cmd", "event", "result", "abandoned") + + def __init__(self, cmd: dict): + self.cmd = cmd + self.event = threading.Event() + self.result = None + self.abandoned = False + + +# Queue of Envelopes waiting to be handed to the EA on its next poll. +command_queue: "queue.Queue[Envelope]" = queue.Queue() + + +def _read_json_response(sock: socket.socket) -> dict: + """Read from the EA until a complete JSON object has arrived.""" + sock.settimeout(REQUEST_TIMEOUT) + data = b'' + while True: + try: + chunk = sock.recv(4096) + except socket.timeout: + break + if not chunk: + break + data += chunk + try: + return json.loads(data.decode('utf-8')) + except json.JSONDecodeError: + continue + try: + return json.loads(data.decode('utf-8')) + except Exception: + return {"success": False, "message": "Invalid or empty response from EA"} + + def tcp_server(): - """ - TCP Server thread - listens for MQL5 connections - """ + """TCP Server thread - the MQL5 EA connects here on each poll.""" server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) server_socket.bind((TCP_HOST, TCP_PORT)) @@ -56,44 +110,48 @@ def tcp_server(): print(f"🚀 TCP Server listening on {TCP_HOST}:{TCP_PORT}") while True: + client_socket = None + env = None try: - # Accept connection from MQL5 client_socket, address = server_socket.accept() - print(f"📡 MQL5 connected from {address}") - # Check if there's a command waiting - if not command_queue.empty(): - command = command_queue.get() - - # Send command to MQL5 - command_json = json.dumps(command) - client_socket.sendall(command_json.encode('utf-8')) - print(f"📤 Sent to MQL5: {command_json[:100]}...") - - # Receive response from MQL5 - response_data = b'' - while True: - chunk = client_socket.recv(4096) - if not chunk: - break - response_data += chunk - # Check if we received complete JSON - try: - response = json.loads(response_data.decode('utf-8')) - break - except: - continue - - print(f"📥 Received from MQL5: {response}") - response_queue.put(response) - else: - # No command, send empty response + # Pull the next live (non-abandoned) command, if any. + env = None + while not command_queue.empty(): + candidate = command_queue.get() + if candidate.abandoned: + continue + env = candidate + break + + if env is None or env.abandoned: client_socket.sendall(b'{"status":"waiting"}') + client_socket.close() + continue + + print(f"📡 MQL5 connected from {address}") + command_json = json.dumps(env.cmd) + client_socket.sendall(command_json.encode('utf-8')) + print(f"📤 Sent to MQL5: {command_json[:100]}...") - client_socket.close() + response = _read_json_response(client_socket) + print(f"📥 Received from MQL5: {response}") + + # Deliver the response to the exact request that is waiting on it. + env.result = response + env.event.set() except Exception as e: print(f"❌ TCP Server error: {e}") + if env is not None and not env.event.is_set(): + env.result = {"success": False, "message": f"bridge error: {e}"} + env.event.set() + finally: + if client_socket is not None: + try: + client_socket.close() + except Exception: + pass # Start TCP server in background thread @@ -101,6 +159,26 @@ def tcp_server(): tcp_thread.start() +def require_key(x_api_key: Optional[str] = Header(default=None)): + """Enforce the API key on trading endpoints when one is configured.""" + if API_KEY and x_api_key != API_KEY: + raise HTTPException(status_code=401, detail="Invalid or missing API key") + + +def send_command_to_mt5(command: dict, timeout: int = REQUEST_TIMEOUT) -> dict: + """Queue a command and wait for its own response. On timeout the command is + abandoned so it will not be executed by the EA on a later poll.""" + env = Envelope(command) + command_queue.put(env) + + if env.event.wait(timeout): + return env.result + + # Timed out: prevent this command from firing late. + env.abandoned = True + raise HTTPException(status_code=504, detail="MT5 timeout - EA not connecting") + + @app.get("/") async def root(): return {"status": "online", "service": "MT5 TCP Bridge"} @@ -111,35 +189,14 @@ async def health(): return { "status": "healthy", "tcp_host": TCP_HOST, - "tcp_port": TCP_PORT + "tcp_port": TCP_PORT, + "auth_required": bool(API_KEY), } -def send_command_to_mt5(command: dict, timeout: int = 10): - """ - Helper function to send command to MT5 and wait for response - """ - import time - - # Add command to queue - command_queue.put(command) - - # Wait for response - start = time.time() - while time.time() - start < timeout: - if not response_queue.empty(): - response = response_queue.get() - return response - time.sleep(0.1) - - raise HTTPException(status_code=504, detail="MT5 timeout - EA not connecting") - - -@app.post("/order") +@app.post("/order", dependencies=[Depends(require_key)]) async def create_order(order: OrderCommand): - """ - Place order on MT5 via TCP - """ + """Place order on MT5 via TCP (split into 5 positions by the EA).""" command = { "action": "PLACE_ORDER", "data": { @@ -152,114 +209,44 @@ async def create_order(order: OrderCommand): "deviation": order.deviation, "comment": order.comment, "magic_number": order.magic_number, - "partial_close_percent": order.partial_close_percent - } + "partial_close_percent": order.partial_close_percent, + }, } - - try: - response = send_command_to_mt5(command) - return response - except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) + return send_command_to_mt5(command) -@app.get("/positions") +@app.get("/positions", dependencies=[Depends(require_key)]) async def get_positions(): - """ - Get all open positions from MT5 - """ - command = {"action": "GET_POSITIONS"} - - try: - response = send_command_to_mt5(command) - return response - except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) + return send_command_to_mt5({"action": "GET_POSITIONS"}) -@app.get("/orders") +@app.get("/orders", dependencies=[Depends(require_key)]) async def get_orders(): - """ - Get all pending orders from MT5 - """ - command = {"action": "GET_ORDERS"} - - try: - response = send_command_to_mt5(command) - return response - except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) + return send_command_to_mt5({"action": "GET_ORDERS"}) -@app.delete("/order/{ticket}") +@app.delete("/order/{ticket}", dependencies=[Depends(require_key)]) async def delete_order(ticket: int): - """ - Delete/cancel a pending order by ticket number - """ - command = { - "action": "DELETE_ORDER", - "data": {"ticket": ticket} - } + return send_command_to_mt5({"action": "DELETE_ORDER", "data": {"ticket": ticket}}) - try: - response = send_command_to_mt5(command) - return response - except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) - -@app.delete("/position/{ticket}") +@app.delete("/position/{ticket}", dependencies=[Depends(require_key)]) async def close_position(ticket: int): - """ - Close an open position by ticket number - """ - command = { - "action": "CLOSE_POSITION", - "data": {"ticket": ticket} - } - - try: - response = send_command_to_mt5(command) - return response - except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) + return send_command_to_mt5({"action": "CLOSE_POSITION", "data": {"ticket": ticket}}) -@app.get("/stats") +@app.get("/stats", dependencies=[Depends(require_key)]) async def get_stats(): - """ - Get account statistics and EA status - """ - command = {"action": "GET_STATS"} - - try: - response = send_command_to_mt5(command) - return response - except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) + return send_command_to_mt5({"action": "GET_STATS"}) -@app.post("/safe-shutdown") +@app.post("/safe-shutdown", dependencies=[Depends(require_key)]) async def safe_shutdown(): - """ - Safe shutdown mode - consolidates all TPs (TP2-TP5) to TP2 level (45 pips) - Protects positions when you need to close MT5 and go to sleep/away - """ - command = {"action": "SAFE_SHUTDOWN"} - - try: - response = send_command_to_mt5(command) - return response - except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) + """Consolidate remaining TPs (TP2-TP5) to the TP2 level before going away.""" + return send_command_to_mt5({"action": "SAFE_SHUTDOWN"}) if __name__ == "__main__": print("🚀 Starting MT5 TCP Bridge Server...") - uvicorn.run( - app, - host="0.0.0.0", - port=8080, - reload=False, - log_level="info" - ) + print(f" HTTP API: http://{HTTP_HOST}:{HTTP_PORT} (auth: {'on' if API_KEY else 'off'})") + uvicorn.run(app, host=HTTP_HOST, port=HTTP_PORT, reload=False, log_level="info") From e4b78931fb467a2b698b76d1f36735a768b1ba22 Mon Sep 17 00:00:00 2001 From: codedpro Date: Wed, 1 Jul 2026 20:16:53 +0100 Subject: [PATCH 2/5] ci: compile the EA with MetaEditor on push/PR Adds a GitHub Actions workflow (Windows runner) that installs MetaTrader 5, unpacks the MQL5 standard library, and compiles bulk-add-signals.mq5, failing the job on any compile error and uploading the .ex5 artifact on success. Adds a build-status badge to the README. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/compile-ea.yml | 92 ++++++++++++++++++++++++++++++++ README.md | 1 + 2 files changed, 93 insertions(+) create mode 100644 .github/workflows/compile-ea.yml diff --git a/.github/workflows/compile-ea.yml b/.github/workflows/compile-ea.yml new file mode 100644 index 0000000..d6557ae --- /dev/null +++ b/.github/workflows/compile-ea.yml @@ -0,0 +1,92 @@ +name: Compile EA + +# Compiles the MQL5 Expert Advisor with the real MetaEditor compiler so a PR +# that doesn't build can't be merged. Runs on a Windows runner (MetaEditor is +# a native Windows app) and fails the job on any compile error. + +on: + push: + paths: + - "bulk-add-signals.mq5" + - ".github/workflows/compile-ea.yml" + pull_request: + paths: + - "bulk-add-signals.mq5" + - ".github/workflows/compile-ea.yml" + workflow_dispatch: + +jobs: + compile: + runs-on: windows-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v4 + + - name: Download MetaTrader 5 installer + shell: pwsh + run: | + $url = "https://download.mql5.com/cdn/web/metaquotes.software.corp/mt5/mt5setup.exe" + Invoke-WebRequest -Uri $url -OutFile "$env:RUNNER_TEMP\mt5setup.exe" + Write-Host "Downloaded installer ($((Get-Item "$env:RUNNER_TEMP\mt5setup.exe").Length) bytes)" + + - name: Install MetaTrader 5 (silent) + shell: pwsh + run: | + Start-Process -FilePath "$env:RUNNER_TEMP\mt5setup.exe" -ArgumentList "/auto" | Out-Null + $me = "C:\Program Files\MetaTrader 5\MetaEditor64.exe" + $deadline = (Get-Date).AddMinutes(8) + while (-not (Test-Path $me) -and (Get-Date) -lt $deadline) { Start-Sleep -Seconds 5 } + if (-not (Test-Path $me)) { throw "MetaEditor not found - install did not complete" } + Write-Host "Installed: $me" + + - name: Unpack MQL5 standard library + shell: pwsh + run: | + $mt5 = "C:\Program Files\MetaTrader 5" + # Launch the terminal once (portable) so the standard library + # (Include\Trade\Trade.mqh, etc.) is unpacked next to the binary. + $proc = Start-Process -FilePath "$mt5\terminal64.exe" -ArgumentList "/portable" -PassThru + $inc = "$mt5\MQL5\Include\Trade\Trade.mqh" + $deadline = (Get-Date).AddMinutes(3) + while (-not (Test-Path $inc) -and (Get-Date) -lt $deadline) { Start-Sleep -Seconds 5 } + try { Stop-Process -Id $proc.Id -Force -ErrorAction SilentlyContinue } catch {} + if (-not (Test-Path $inc)) { throw "MQL5 standard library was not unpacked" } + Write-Host "Standard library ready: $inc" + + - name: Compile + shell: pwsh + run: | + $mt5 = "C:\Program Files\MetaTrader 5" + $experts = "$mt5\MQL5\Experts" + $src = "$experts\bulk-add-signals.mq5" + $ex5 = "$experts\bulk-add-signals.ex5" + $log = "$env:RUNNER_TEMP\compile.log" + + Copy-Item "bulk-add-signals.mq5" $src -Force + if (Test-Path $ex5) { Remove-Item $ex5 -Force } + + Start-Process -FilePath "$mt5\MetaEditor64.exe" ` + -ArgumentList "/compile:`"$src`"", "/log:`"$log`"" -Wait + + if (-not (Test-Path $log)) { throw "No compile log was produced" } + # MetaEditor writes the log as UTF-16. + $text = Get-Content -Path $log -Encoding Unicode -Raw + Write-Host "----------------- MetaEditor log -----------------" + Write-Host $text + Write-Host "--------------------------------------------------" + + $m = [regex]::Match($text, "Result:\s*(\d+)\s*error") + $errors = if ($m.Success) { [int]$m.Groups[1].Value } else { 1 } + + if ($errors -gt 0 -or -not (Test-Path $ex5)) { + throw "Compilation FAILED ($errors error(s)); see log above." + } + Write-Host "Compilation succeeded (0 errors)." + + - name: Upload compiled .ex5 + if: success() + uses: actions/upload-artifact@v4 + with: + name: bulk-add-signals-ex5 + path: C:\Program Files\MetaTrader 5\MQL5\Experts\bulk-add-signals.ex5 + if-no-files-found: error diff --git a/README.md b/README.md index 931b820..4ecead7 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,6 @@ # MT5 Trade Split Manager 🤖 +[![Compile EA](https://github.com/codedpro/mt5-trade-split-manager/actions/workflows/compile-ea.yml/badge.svg)](https://github.com/codedpro/mt5-trade-split-manager/actions/workflows/compile-ea.yml) [![MetaTrader 5](https://img.shields.io/badge/MetaTrader-5-blue.svg)](https://www.metatrader5.com/) [![Python 3.8+](https://img.shields.io/badge/python-3.8+-blue.svg)](https://www.python.org/downloads/) [![FastAPI](https://img.shields.io/badge/FastAPI-0.109.0-green.svg)](https://fastapi.tiangolo.com/) From f082a562283179b7cc6b4d84260ec973383e9d25 Mon Sep 17 00:00:00 2001 From: codedpro Date: Sat, 4 Jul 2026 11:39:10 +0100 Subject: [PATCH 3/5] Make EA source pure ASCII so it compiles on all MetaEditor builds The .mq5 was UTF-8 without a BOM and used emoji in Print() logs. MetaEditor treats a no-BOM source as ANSI, so on a fresh Windows install (the CI runner) each emoji corrupted its string literal -> 101 compile errors, even though a UTF-8-locale build (local Wine) compiled it cleanly. Replaced the emoji with short ASCII tags ([OK], [ERR], [WARN], ...) so the source compiles identically regardless of the compiler's codepage. Logs only; no behavior change. Co-Authored-By: Claude Opus 4.8 --- bulk-add-signals.mq5 | 66 ++++++++++++++++++++++---------------------- 1 file changed, 33 insertions(+), 33 deletions(-) diff --git a/bulk-add-signals.mq5 b/bulk-add-signals.mq5 index f579bd0..e34ecd2 100644 --- a/bulk-add-signals.mq5 +++ b/bulk-add-signals.mq5 @@ -169,7 +169,7 @@ void OnTimer() } // Only log actual trading commands - Print("📡 Connected - Received command: ", StringSubstr(commandJson, 0, MathMin(100, StringLen(commandJson))), "..."); + Print("[NET] Connected - Received command: ", StringSubstr(commandJson, 0, MathMin(100, StringLen(commandJson))), "..."); // Process command string response = ProcessCommand(commandJson); @@ -418,7 +418,7 @@ ulong ExecuteOrder(TradeCommand &cmd, string &errorMsg) if(orderType == WRONG_VALUE) { errorMsg = "Unknown order_type '" + cmd.order_type + "' (expected BUY_STOP/SELL_STOP/BUY_LIMIT/SELL_LIMIT)"; - Print("❌ ", errorMsg); + Print("[ERR] ", errorMsg); return 0; } @@ -436,21 +436,21 @@ ulong ExecuteOrder(TradeCommand &cmd, string &errorMsg) if(sideError != "") { errorMsg = StringFormat("%s (ask=%.5f bid=%.5f price=%.5f)", sideError, ask, bid, cmd.price); - Print("❌ Refusing order: ", errorMsg); + Print("[ERR] Refusing order: ", errorMsg); return 0; } // Validate SL / TP are on the correct side of the entry for the direction. if(cmd.sl > 0) { - if(isBuy && cmd.sl >= cmd.price) { errorMsg = "BUY stop-loss must be below entry price"; Print("❌ ", errorMsg); return 0; } - if(!isBuy && cmd.sl <= cmd.price) { errorMsg = "SELL stop-loss must be above entry price"; Print("❌ ", errorMsg); return 0; } + if(isBuy && cmd.sl >= cmd.price) { errorMsg = "BUY stop-loss must be below entry price"; Print("[ERR] ", errorMsg); return 0; } + if(!isBuy && cmd.sl <= cmd.price) { errorMsg = "SELL stop-loss must be above entry price"; Print("[ERR] ", errorMsg); return 0; } } for(int t = 0; t < 5; t++) { - if(cmd.tp_levels[t] <= 0) { errorMsg = StringFormat("TP%d is missing or invalid", t+1); Print("❌ ", errorMsg); return 0; } - if(isBuy && cmd.tp_levels[t] <= cmd.price) { errorMsg = StringFormat("BUY TP%d must be above entry price", t+1); Print("❌ ", errorMsg); return 0; } - if(!isBuy && cmd.tp_levels[t] >= cmd.price) { errorMsg = StringFormat("SELL TP%d must be below entry price", t+1); Print("❌ ", errorMsg); return 0; } + if(cmd.tp_levels[t] <= 0) { errorMsg = StringFormat("TP%d is missing or invalid", t+1); Print("[ERR] ", errorMsg); return 0; } + if(isBuy && cmd.tp_levels[t] <= cmd.price) { errorMsg = StringFormat("BUY TP%d must be above entry price", t+1); Print("[ERR] ", errorMsg); return 0; } + if(!isBuy && cmd.tp_levels[t] >= cmd.price) { errorMsg = StringFormat("SELL TP%d must be below entry price", t+1); Print("[ERR] ", errorMsg); return 0; } } Print("Executing ", cmd.order_type, " order on ", cmd.symbol); @@ -514,14 +514,14 @@ ulong ExecuteOrder(TradeCommand &cmd, string &errorMsg) if(firstTicket == 0) firstTicket = ticket; - Print("✅ Split order ", i+1, "/5 placed - Ticket: ", ticket, + Print("[OK] Split order ", i+1, "/5 placed - Ticket: ", ticket, " | Vol: ", volumes[i], " | TP", i+1, ": ", cmd.tp_levels[i]); successCount++; } else { errorMsg = trade.ResultRetcodeDescription(); - Print("❌ Split order ", i+1, "/5 failed - ", errorMsg); + Print("[ERR] Split order ", i+1, "/5 failed - ", errorMsg); orderGroups[groupIndex].tickets[i] = 0; } } @@ -537,7 +537,7 @@ ulong ExecuteOrder(TradeCommand &cmd, string &errorMsg) // Draw TP levels on chart (once for the group) using the ACTUAL TP prices DrawTPLevels(groupId, cmd.symbol, cmd.price, orderGroups[groupIndex].tp_prices); - Print("✅ Order group placed: ", successCount, "/5 orders successful"); + Print("[OK] Order group placed: ", successCount, "/5 orders successful"); return firstTicket; // Return first ticket as reference } @@ -614,7 +614,7 @@ void UpdateTPLevelClosed(string groupId, int level) // Update text to show "CLOSED" double price = ObjectGetDouble(0, lineName, OBJPROP_PRICE); - ObjectSetString(0, labelName, OBJPROP_TEXT, StringFormat(" TP%d: %.3f ✓CLOSED", level, price)); + ObjectSetString(0, labelName, OBJPROP_TEXT, StringFormat(" TP%d: %.3f CLOSED", level, price)); ChartRedraw(); } @@ -660,7 +660,7 @@ void CheckTP2ForTrailingSL() if(!PositionSelectByTicket(tp2_ticket)) { // TP2 was hit! Move SL to TP1 for all remaining positions - Print("🎯 TP2 reached for group ", orderGroups[i].groupId, " - Moving SL to TP1 for all remaining positions"); + Print("[HIT] TP2 reached for group ", orderGroups[i].groupId, " - Moving SL to TP1 for all remaining positions"); // Use the ACTUAL TP1 price that was placed - not a hardcoded pip // offset that may not match the caller's TP ladder. Fall back to @@ -692,12 +692,12 @@ void CheckTP2ForTrailingSL() { if(trade.PositionModify(ticket, newSL, currentTP)) { - Print("✅ SL moved to TP1 for position #", ticket, " (TP", j+1, ")"); + Print("[OK] SL moved to TP1 for position #", ticket, " (TP", j+1, ")"); movedCount++; } else { - Print("⚠️ Failed to move SL for position #", ticket, ": ", trade.ResultRetcodeDescription()); + Print("[WARN] Failed to move SL for position #", ticket, ": ", trade.ResultRetcodeDescription()); } } } @@ -705,7 +705,7 @@ void CheckTP2ForTrailingSL() if(movedCount > 0) { - Print("✅ Trailing SL applied: ", movedCount, " position(s) now have SL at TP1 (", newSL, ")"); + Print("[OK] Trailing SL applied: ", movedCount, " position(s) now have SL at TP1 (", newSL, ")"); } // Mark as TP2 reached @@ -729,7 +729,7 @@ void CheckTP2ForTrailingSL() if(allClosed) { - Print("🏁 All positions closed for group ", orderGroups[i].groupId); + Print("[END] All positions closed for group ", orderGroups[i].groupId); RemoveTPObjects(orderGroups[i].groupId); ArrayRemove(orderGroups, i, 1); } @@ -863,7 +863,7 @@ void RecoverSplitOrders() orderGroups[gi].order_type = (posType == POSITION_TYPE_BUY) ? ORDER_TYPE_BUY_STOP : ORDER_TYPE_SELL_STOP; } - Print(" ✅ Found position TP", tpLevel, " - Ticket #", position.Ticket()); + Print(" [OK] Found position TP", tpLevel, " - Ticket #", position.Ticket()); } } @@ -887,7 +887,7 @@ void RecoverSplitOrders() orderGroups[gi].order_type = (ENUM_ORDER_TYPE)OrderGetInteger(ORDER_TYPE); } - Print(" 📋 Found pending order TP", tpLevel, " - Ticket #", ticket); + Print(" [ORD] Found pending order TP", tpLevel, " - Ticket #", ticket); } } @@ -904,7 +904,7 @@ void RecoverSplitOrders() orderGroups[g].tickets[4] > 0)) { orderGroups[g].tp2_reached = true; - Print(" 🎯 TP2 already reached for group ", orderGroups[g].groupId); + Print(" [HIT] TP2 already reached for group ", orderGroups[g].groupId); } if(orderGroups[g].entry_price > 0) @@ -917,13 +917,13 @@ void RecoverSplitOrders() if(orderGroups[g].tickets[t] == 0) UpdateTPLevelClosed(orderGroups[g].groupId, t + 1); - Print("✅ Group ", orderGroups[g].groupId, " recovered successfully"); + Print("[OK] Group ", orderGroups[g].groupId, " recovered successfully"); } } Print(""); Print("==== Split Order Recovery Complete ===="); - Print("✅ Recovered ", groupCount, " order group(s)"); + Print("[OK] Recovered ", groupCount, " order group(s)"); Print("======================================="); } @@ -1020,13 +1020,13 @@ string HandleDeleteOrder(string commandJson) if(trade.OrderDelete(ticket)) { - Print("✅ Order #", ticket, " deleted successfully"); + Print("[OK] Order #", ticket, " deleted successfully"); return StringFormat("{\"success\":true,\"message\":\"Order deleted\",\"ticket\":%d}", ticket); } else { string error = trade.ResultRetcodeDescription(); - Print("❌ Failed to delete order #", ticket, " - ", error); + Print("[ERR] Failed to delete order #", ticket, " - ", error); return StringFormat("{\"success\":false,\"message\":\"%s\",\"ticket\":%d}", error, ticket); } } @@ -1059,7 +1059,7 @@ string HandleClosePosition(string commandJson) if(trade.PositionClose(ticket)) { - Print("✅ Position #", ticket, " closed successfully"); + Print("[OK] Position #", ticket, " closed successfully"); // Clean up visual objects if we have the group ID if(StringLen(groupId) > 0) @@ -1095,7 +1095,7 @@ string HandleClosePosition(string commandJson) else { string error = trade.ResultRetcodeDescription(); - Print("❌ Failed to close position #", ticket, " - ", error); + Print("[ERR] Failed to close position #", ticket, " - ", error); return StringFormat("{\"success\":false,\"message\":\"%s\",\"ticket\":%I64u}", error, ticket); } } @@ -1117,11 +1117,11 @@ string HandleSafeShutdown() // Skip groups that already reached TP2 (already protected) if(orderGroups[i].tp2_reached) { - Print("⏭️ Skipping group ", orderGroups[i].groupId, " - TP2 already reached"); + Print("[SKIP] Skipping group ", orderGroups[i].groupId, " - TP2 already reached"); continue; } - Print("🔄 Processing group: ", orderGroups[i].groupId); + Print("[REC] Processing group: ", orderGroups[i].groupId); double entry = orderGroups[i].entry_price; @@ -1129,7 +1129,7 @@ string HandleSafeShutdown() double tp2Price = orderGroups[i].tp_prices[1]; if(tp2Price <= 0) { - Print("⚠️ Skipping group ", orderGroups[i].groupId, " - TP2 price unknown"); + Print("[WARN] Skipping group ", orderGroups[i].groupId, " - TP2 price unknown"); continue; } @@ -1148,13 +1148,13 @@ string HandleSafeShutdown() if(trade.OrderModify(ticket, entry, currentSL, tp2Price, ORDER_TIME_GTC, 0)) { - Print("✅ Pending order #", ticket, " (TP", j+1, ") modified to TP2: ", tp2Price); + Print("[OK] Pending order #", ticket, " (TP", j+1, ") modified to TP2: ", tp2Price); pendingOrdersModified++; modifiedInGroup++; } else { - Print("⚠️ Failed to modify pending order #", ticket, ": ", trade.ResultRetcodeDescription()); + Print("[WARN] Failed to modify pending order #", ticket, ": ", trade.ResultRetcodeDescription()); } } // Check if it's an open position @@ -1164,13 +1164,13 @@ string HandleSafeShutdown() if(trade.PositionModify(ticket, currentSL, tp2Price)) { - Print("✅ Open position #", ticket, " (TP", j+1, ") modified to TP2: ", tp2Price); + Print("[OK] Open position #", ticket, " (TP", j+1, ") modified to TP2: ", tp2Price); openPositionsModified++; modifiedInGroup++; } else { - Print("⚠️ Failed to modify position #", ticket, ": ", trade.ResultRetcodeDescription()); + Print("[WARN] Failed to modify position #", ticket, ": ", trade.ResultRetcodeDescription()); } } } From d413669e25345f7b4f44780719cb364fce302f70 Mon Sep 17 00:00:00 2001 From: codedpro Date: Sat, 4 Jul 2026 11:42:16 +0100 Subject: [PATCH 4/5] ci: make MT5 install robust (wait for Trade.mqh; guarded terminal fallback) The installer writes files in stages, so waiting only for MetaEditor64.exe raced ahead of terminal64.exe and the "unpack" step crashed with file-not- found. Merge install+unpack into one step that waits for the header the compile actually needs (Include\Trade\Trade.mqh), and only launches the terminal as a guarded fallback if the installer didn't lay it down. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/compile-ea.yml | 52 +++++++++++++++++++++----------- 1 file changed, 34 insertions(+), 18 deletions(-) diff --git a/.github/workflows/compile-ea.yml b/.github/workflows/compile-ea.yml index d6557ae..581dacb 100644 --- a/.github/workflows/compile-ea.yml +++ b/.github/workflows/compile-ea.yml @@ -29,30 +29,46 @@ jobs: Invoke-WebRequest -Uri $url -OutFile "$env:RUNNER_TEMP\mt5setup.exe" Write-Host "Downloaded installer ($((Get-Item "$env:RUNNER_TEMP\mt5setup.exe").Length) bytes)" - - name: Install MetaTrader 5 (silent) + - name: Install MetaTrader 5 + standard library shell: pwsh run: | + $ErrorActionPreference = "Stop" + $mt5 = "C:\Program Files\MetaTrader 5" + $me = "$mt5\MetaEditor64.exe" + $inc = "$mt5\MQL5\Include\Trade\Trade.mqh" + + function Wait-ForPath($p, $minutes) { + $deadline = (Get-Date).AddMinutes($minutes) + while (-not (Test-Path -LiteralPath $p) -and (Get-Date) -lt $deadline) { Start-Sleep -Seconds 5 } + return (Test-Path -LiteralPath $p) + } + Start-Process -FilePath "$env:RUNNER_TEMP\mt5setup.exe" -ArgumentList "/auto" | Out-Null - $me = "C:\Program Files\MetaTrader 5\MetaEditor64.exe" - $deadline = (Get-Date).AddMinutes(8) - while (-not (Test-Path $me) -and (Get-Date) -lt $deadline) { Start-Sleep -Seconds 5 } - if (-not (Test-Path $me)) { throw "MetaEditor not found - install did not complete" } - Write-Host "Installed: $me" - - name: Unpack MQL5 standard library - shell: pwsh - run: | - $mt5 = "C:\Program Files\MetaTrader 5" - # Launch the terminal once (portable) so the standard library - # (Include\Trade\Trade.mqh, etc.) is unpacked next to the binary. - $proc = Start-Process -FilePath "$mt5\terminal64.exe" -ArgumentList "/portable" -PassThru - $inc = "$mt5\MQL5\Include\Trade\Trade.mqh" - $deadline = (Get-Date).AddMinutes(3) - while (-not (Test-Path $inc) -and (Get-Date) -lt $deadline) { Start-Sleep -Seconds 5 } - try { Stop-Process -Id $proc.Id -Force -ErrorAction SilentlyContinue } catch {} - if (-not (Test-Path $inc)) { throw "MQL5 standard library was not unpacked" } + # Wait for the compiler itself. + if (-not (Wait-ForPath $me 8)) { throw "MetaEditor not found - install did not complete" } + Write-Host "MetaEditor installed: $me" + + # The installer normally lays down the MQL5 standard library too, but it + # writes files in stages - wait for the header the compile actually needs. + if (-not (Wait-ForPath $inc 4)) { + # Fallback: unpack it by launching the terminal once (guard on the exe + # existing first, so we never race on a not-yet-written terminal64.exe). + $term = "$mt5\terminal64.exe" + if (Wait-ForPath $term 4) { + Write-Host "Standard library missing; launching terminal to unpack it..." + $p = Start-Process -FilePath $term -ArgumentList "/portable" -WorkingDirectory $mt5 -PassThru + Wait-ForPath $inc 3 | Out-Null + try { Stop-Process -Id $p.Id -Force -ErrorAction SilentlyContinue } catch {} + } + } + + if (-not (Test-Path -LiteralPath $inc)) { throw "MQL5 standard library not found at $inc" } Write-Host "Standard library ready: $inc" + # Ensure no terminal process is holding files open before we compile. + Get-Process terminal64 -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue + - name: Compile shell: pwsh run: | From b1b4fd039cb2012ea7c218b0a318441362eb00a0 Mon Sep 17 00:00:00 2001 From: codedpro Date: Sat, 4 Jul 2026 11:50:17 +0100 Subject: [PATCH 5/5] ci: compile with /portable so includes resolve from the install dir Root cause of the 101 errors: MetaEditor resolved from its roaming data directory (empty), not the Program Files install dir where the standard library lives, so every include failed and cascaded into undeclared CTrade/CPositionInfo/CAccountInfo errors. Compiling with /portable points MetaEditor at the install-dir MQL5 tree. (The earlier emoji were never the cause - same 101 errors before and after the ASCII change.) Co-Authored-By: Claude Opus 4.8 --- .github/workflows/compile-ea.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/compile-ea.yml b/.github/workflows/compile-ea.yml index 581dacb..cf8e6ac 100644 --- a/.github/workflows/compile-ea.yml +++ b/.github/workflows/compile-ea.yml @@ -81,8 +81,10 @@ jobs: Copy-Item "bulk-add-signals.mq5" $src -Force if (Test-Path $ex5) { Remove-Item $ex5 -Force } + # /portable makes MetaEditor resolve includes from the install-dir MQL5 + # tree (where Trade.mqh is) instead of the empty roaming data directory. Start-Process -FilePath "$mt5\MetaEditor64.exe" ` - -ArgumentList "/compile:`"$src`"", "/log:`"$log`"" -Wait + -ArgumentList "/portable", "/compile:`"$src`"", "/log:`"$log`"" -Wait if (-not (Test-Path $log)) { throw "No compile log was produced" } # MetaEditor writes the log as UTF-16.