diff --git a/NANDR_ORB_OB_EA.ex5 b/NANDR_ORB_OB_EA.ex5 new file mode 100644 index 0000000..2e91e24 Binary files /dev/null and b/NANDR_ORB_OB_EA.ex5 differ diff --git a/NANDR_ORB_OB_EA.mq5 b/NANDR_ORB_OB_EA.mq5 new file mode 100644 index 0000000..9185b94 --- /dev/null +++ b/NANDR_ORB_OB_EA.mq5 @@ -0,0 +1,2523 @@ +//+------------------------------------------------------------------+ +//| NANDR_ORB_OB_EA.mq5 | +//| ORB + Order Block Scalping EA for XAUUSD | +//| Supports M1 / M5 / M15 (default M15). ORB = 15 real minutes. | +//| Based on: ORB-All-Sessions.pine + LuxAlgo Order Block Detector | +//+------------------------------------------------------------------+ +#property copyright "NANDR" +#property version "1.40" +#property strict + +#include +#include +#include + +//+------------------------------------------------------------------+ +//| Enumerations | +//+------------------------------------------------------------------+ +enum ENUM_ORB_TF +{ + ORB_TF_M1 = 1, // M1 (15 bars = 15min ORB) + ORB_TF_M5 = 5, // M5 (3 bars = 15min ORB) + ORB_TF_M15 = 15 // M15 (1 bar = 15min ORB) [Default] +}; + +enum ENUM_SL_MODE +{ + SL_OB_BOUNDARY, // OB Boundary + SL_FIXED_PIPS, // Fixed Pips + SL_ATR_BASED, // ATR-Based + SL_ORB_OPPOSITE, // ORB Opposite Boundary (buy=ORL, sell=ORH) + SL_ORB_MID // ORB Range Midpoint +}; + +enum ENUM_TP_MODE +{ + TP_FIXED_PIPS, // Fixed Pips + TP_RR_RATIO // Risk:Reward Ratio +}; + +enum ENUM_LOT_MODE +{ + LOT_FIXED, // Fixed Lot Size + LOT_RISK_PERCENT // Risk % of Balance +}; + +enum ENUM_ENTRY_MODE +{ + ENTRY_MARKET, // Market Order (on retest bar close) + ENTRY_LIMIT // Limit Order (at ORB level) +}; + +enum ENUM_OB_MITIGATION +{ + MITIG_WICK, // Wick (high/low penetrates zone) + MITIG_CLOSE // Close (close penetrates zone) +}; + +enum ENUM_LADDER_SL_MODE +{ + LADDER_SL_FIXED_PIPS, // Use InpSLPips + LADDER_SL_LEVEL_BUFFER // Buffer beyond source level +}; + +// Coupled ORB risk methods (per ORB strategy spec). Each option fixes BOTH the +// stop-loss placement and the reward:risk ratio together. +enum ENUM_ORB_RISK_METHOD +{ + ORB_RISK_LEGACY, // Use InpSLMode / InpTPMode / InpRRRatio (manual) + ORB_RISK_BOUNDARY_1_2, // SL at broken range boundary → 1:2 RR + ORB_RISK_MID_1_1_5, // SL at range midpoint (50%) → 1:1.5 RR + ORB_RISK_OPPOSITE_1_1 // SL at opposite range boundary→ 1:1 RR +}; + +// Daily directional bias control. +enum ENUM_BIAS_MODE +{ + BIAS_AUTO, // Auto: first ORB breakout of the day sets the tradeable direction + BIAS_OFF, // Flat: take no trades today (news / high-uncertainty days) + BIAS_LONG_ONLY, // Manual: only long entries allowed + BIAS_SHORT_ONLY // Manual: only short entries allowed +}; + +//+------------------------------------------------------------------+ +//| Input Parameters | +//+------------------------------------------------------------------+ + +// --- Symbol --- +input group "═══ Symbol Settings ═══" +input string InpSymbolName = "XAUUSD"; // Symbol Name (auto-detected) + +// --- Timeframe --- +input group "═══ Timeframe Settings ═══" +input ENUM_ORB_TF InpOrbTimeframe = ORB_TF_M15; // ORB Timeframe (M1/M5/M15) +input int InpBreakoutConfBars = 0; // Breakout Confirm Bars (0=auto) + +// --- Sessions --- +input group "═══ Session Settings ═══" +input bool InpUseDailyOpen = true; // Daily Open Session (22:00 UTC, 01:00 Kuwait) +input bool InpUseTokyoSession = true; // Tokyo Session (00:00 UTC) +input bool InpUseLondonSession = true; // London Session (08:00 UTC = 8AM GMT) +input bool InpUseNYSession = true; // NY Session (12:00 UTC) +input bool InpUseNYOrbSession = true; // NY ORB Session (13:30 UTC = 9:30 EDT) +// Session start times are UTC-based (converted to broker server time internally). +input int InpDailyOpenHour = 22; // Daily Open Start Hour (UTC) +input int InpDailyOpenMin = 0; // Daily Open Start Minute +input int InpTokyoHour = 0; // Tokyo Start Hour (UTC) +input int InpTokyoMin = 0; // Tokyo Start Minute +input int InpLondonHour = 8; // London Start Hour (UTC, 8=8AM GMT) +input int InpLondonMin = 0; // London Start Minute +input int InpNYHour = 12; // NY Start Hour (UTC) +input int InpNYMin = 0; // NY Start Minute +input int InpNYOrbHour = 13; // NY ORB Start Hour (UTC) +input int InpNYOrbMin = 30; // NY ORB Start Minute + +// --- Order Block Settings --- +input group "═══ Order Block Settings ═══" +input int InpOBPivotLength = 0; // OB Pivot Length (0=auto by TF) +input int InpOBMaxCount = 5; // Max Active OBs per direction +input ENUM_OB_MITIGATION InpOBMitig = MITIG_WICK; // Mitigation Method +input bool InpOBRequireConf = false; // Require OB Confirmation +input double InpOBProximityPips = 50.0; // OB Proximity (pips) + +// --- Entry Settings --- +input group "═══ Entry Settings ═══" +input ENUM_ENTRY_MODE InpEntryMode = ENTRY_MARKET; // Entry Mode +input bool InpUseStrictFilter = false; // Use Strict Breakout Filter (false=simple close-cross) +input bool InpWaitForRetest = true; // Wait for ORB Retest Before Entry +input int InpBreakoutExpireBars = 20; // Bars Before Breakout Expires (0=never) +input bool InpUseOBRetestEntry = false; // OB Retest Entry (optional add-on, default OFF) +input int InpMaxRetestsPerSession = 3; // Max Retest Entries Per Session (per breakout) +input ENUM_BIAS_MODE InpDailyBiasMode = BIAS_AUTO; // Daily Bias: AUTO / OFF(no trades) / LONG-only / SHORT-only +input bool InpUseBreakoutVolFilter = false; // Require Volume Spike on Breakout Bar (fakeout filter) +input double InpBreakoutVolMult = 1.5; // Breakout Volume >= X * Average Volume +input int InpBreakoutVolAvgBars = 20; // Bars to Average Volume Over + +// --- Session Level Ladder --- +input group "═══ Session Level Ladder ═══" +input bool InpUseSessionLevelLadder = false; // Enable Session Level Ladder Entries +input bool InpLadderOnDailyOpen = true; // Ladder on Daily Open session +input bool InpLadderOnTokyo = true; // Ladder on Tokyo session +input bool InpLadderOnLondon = true; // Ladder on London session +input bool InpLadderOnNY = true; // Ladder on NY session +input bool InpLadderOnNYORB = true; // Ladder on NY ORB session +input bool InpUseLadderATRVolCeiling = false; // Use ATR Volatility Ceiling for Ladder Entries +input double InpLadderATRMaxPips = 200.0; // Max ATR (pips) allowed for Ladder Entries +input bool InpUseLadderSessionCaps = false; // Use Per-Session Ladder Trade Caps +input int InpLadderCapDailyOpen = 2; // Daily Open Ladder Cap (0=unlimited) +input int InpLadderCapTokyo = 2; // Tokyo Ladder Cap (0=unlimited) +input int InpLadderCapLondon = 2; // London Ladder Cap (0=unlimited) +input int InpLadderCapNY = 2; // NY Ladder Cap (0=unlimited) +input int InpLadderCapNYORB = 2; // NY ORB Ladder Cap (0=unlimited) +input ENUM_LADDER_SL_MODE InpLadderSLMode = LADDER_SL_FIXED_PIPS; // Ladder SL Mode +input double InpLadderSLBufferPips = 5.0; // Ladder SL Buffer Pips (when level-buffer mode) +input int InpLadderMaxTradesPerSession = 2; // Max Ladder Trades Per Session (0=unlimited) + +// --- Lot Size & Risk --- +input group "═══ Lot Size & Risk ═══" +input ENUM_LOT_MODE InpLotMode = LOT_RISK_PERCENT; // Lot Mode +input double InpFixedLotSize = 0.05; // Fixed Lot Size +input double InpRiskPercent = 0.05; // Risk % per Trade + +// --- Stop Loss --- +input group "═══ Stop Loss ═══" +input ENUM_ORB_RISK_METHOD InpOrbRiskMethod = ORB_RISK_BOUNDARY_1_2; // ORB Risk Method (sets SL + RR together) +input ENUM_SL_MODE InpSLMode = SL_OB_BOUNDARY; // SL Mode (used only when Risk Method = LEGACY) +input double InpSLPips = 100.0; // SL Fixed Pips +input double InpATRMultiplier = 1.5; // ATR Multiplier (for SL_ATR) +input int InpATRPeriod = 14; // ATR Period + +// --- Take Profit --- +input group "═══ Take Profit ═══" +input ENUM_TP_MODE InpTPMode = TP_RR_RATIO; // TP Mode (used only when Risk Method = LEGACY) +input double InpTPPips = 100.0; // TP Fixed Pips +input double InpRRRatio = 2.0; // Risk:Reward Ratio (LEGACY mode) + +// --- Trade Management --- +input group "═══ Trade Management ═══" +input double InpBreakevenTrigPips = 50.0; // Breakeven Trigger (pips) +input double InpBreakevenOffPips = 2.0; // Breakeven Offset (pips) +input double InpPartialCloseRatio = 0.5; // Partial Close Ratio at BE (0=disabled, 0.5=half) +input double InpTrailingStopPips = 0.0; // Trailing Stop Pips (0=off) +input int InpMaxTradesPerDay = 8; // Max Trades per Day +input int InpMaxPosPerSession = 2; // Max Positions per Session + +// --- Risk Management --- +input group "═══ Risk Management ═══" +input double InpMaxDailyLossUSD = 150.0; // Max Daily Loss USD (0=off) +input double InpMaxDailyLossPct = 5.0; // Max Daily Loss % (0=off) + +// --- Display --- +input group "═══ Display Settings ═══" +input bool InpShowDashboard = true; // Show Dashboard +input bool InpShowORBLines = true; // Show ORB Lines +input bool InpShowOBZones = true; // Show OB Zones +input bool InpShowTradeLabels = true; // Show Trade Labels + +// --- Magic --- +input int InpMagicNumber = 202601; // Magic Number + +//+------------------------------------------------------------------+ +//| Session structure | +//+------------------------------------------------------------------+ +struct SSession +{ + string name; + int startHour; + int startMin; + bool enabled; + + double orbHigh; + double orbLow; + int orbBarCount; + bool orbComplete; + datetime orbStartTime; + + int breakoutDir; // 0=none, 1=bull, -1=bear + bool inBreakout; + int breakoutBarsAgo; + bool breakoutEntryTaken; // one entry max per breakout confirmation leg + + bool ladderBullLowDone; // Bull ladder: low -> mid done + bool ladderBullMidDone; // Bull ladder: mid -> high done + bool ladderBearHighDone; // Bear ladder: high -> mid done + bool ladderBearMidDone; // Bear ladder: mid -> low done + + int ladderTradesThisSession; + + int tradesThisSession; + bool retestFiredThisBar; // blocks duplicate entries on consecutive ticks within same bar + + void Reset() + { + orbHigh = 0; + orbLow = DBL_MAX; + orbBarCount = 0; + orbComplete = false; + orbStartTime = 0; + breakoutDir = 0; + inBreakout = false; + breakoutBarsAgo = 0; + breakoutEntryTaken = false; + + ladderBullLowDone = false; + ladderBullMidDone = false; + ladderBearHighDone = false; + ladderBearMidDone = false; + ladderTradesThisSession = 0; + + tradesThisSession = 0; + retestFiredThisBar = false; + } +}; + +//+------------------------------------------------------------------+ +//| Order Block structure | +//+------------------------------------------------------------------+ +struct SOrderBlock +{ + double top; + double bottom; + double mid; + datetime time; + bool active; + bool traded; // Prevents re-entering the same OB zone twice in one day + string objName; +}; + +//+------------------------------------------------------------------+ +//| Global variables | +//+------------------------------------------------------------------+ +CTrade g_Trade; +CPositionInfo g_Position; +COrderInfo g_Order; + +string g_Symbol; +int g_OrbBarsNeeded; +int g_BreakoutConfBars; +int g_OBPivotLength; +double g_PipSize; +double g_PointSize; + +// Sessions array (max 5) +SSession g_Sessions[5]; +int g_SessionCount = 0; + +// Order blocks +SOrderBlock g_BullOBs[]; +SOrderBlock g_BearOBs[]; +int g_BullOBCount = 0; +int g_BearOBCount = 0; + +// Day bias: in AUTO mode this holds the direction of the first confirmed ORB +// breakout of the day (0=none, 1=bull, -1=bear). In manual bias modes it is unused. +// Breakout DETECTION is never suppressed (levels/state keep updating); entries are +// gated by IsDirectionAllowed() so the bias only filters trade execution. +int g_GlobalBiasDir = 0; + +// Tracks tickets that have already had partial close executed so it only fires once per position +#define MAX_PARTIAL_TRACKED 20 +ulong g_PartialClosedTickets[MAX_PARTIAL_TRACKED]; +int g_PartialClosedCount = 0; + +bool IsPartialClosed(ulong ticket) +{ + for(int i = 0; i < g_PartialClosedCount; i++) + if(g_PartialClosedTickets[i] == ticket) return true; + return false; +} + +void MarkPartialClosed(ulong ticket) +{ + if(g_PartialClosedCount < MAX_PARTIAL_TRACKED) + { + g_PartialClosedTickets[g_PartialClosedCount] = ticket; + g_PartialClosedCount++; + } +} + +// Daily stats +datetime g_LastDayReset = 0; +int g_TodayTrades = 0; +int g_TodayWins = 0; +int g_TodayLosses = 0; +double g_TodayPnL = 0; +double g_DayStartEquity = 0; +bool g_TradingHalted = false; +double g_TotalPnL = 0; + +// Bar tracking +datetime g_LastBarTime = 0; +bool g_IsNewBar = false; +int g_ServerUtcOffsetSec = 0; + +// ATR handle +int g_ATRHandle = INVALID_HANDLE; + +//+------------------------------------------------------------------+ +//| Utility: resolve actual symbol | +//+------------------------------------------------------------------+ +string ResolveSymbol(const string base) +{ + // Exact match first + if(SymbolInfoDouble(base, SYMBOL_BID) > 0) + return base; + + // Scan all symbols for XAUUSD variants + int total = SymbolsTotal(false); + for(int i = 0; i < total; i++) + { + string sym = SymbolName(i, false); + if(StringFind(sym, "XAUUSD") >= 0 || StringFind(sym, "GOLD") >= 0 || StringFind(sym, "XAU") >= 0) + { + if(SymbolInfoDouble(sym, SYMBOL_BID) > 0) + { + PrintFormat("NANDR EA: Symbol '%s' not found. Using '%s' instead.", base, sym); + return sym; + } + } + } + Print("NANDR EA: WARNING — could not resolve symbol '", base, "'. Using as-is."); + return base; +} + +//+------------------------------------------------------------------+ +//| Utility: pip value | +//+------------------------------------------------------------------+ +double PipsToPrice(double pips) +{ + return pips * g_PipSize; +} + +double PriceToPips(double price) +{ + return price / g_PipSize; +} + +//+------------------------------------------------------------------+ +//| Utility: normalize lot size | +//+------------------------------------------------------------------+ +double NormalizeLot(double lots) +{ + double step = SymbolInfoDouble(g_Symbol, SYMBOL_VOLUME_STEP); + double minL = SymbolInfoDouble(g_Symbol, SYMBOL_VOLUME_MIN); + double maxL = SymbolInfoDouble(g_Symbol, SYMBOL_VOLUME_MAX); + lots = MathFloor(lots / step) * step; + lots = MathMax(lots, minL); + lots = MathMin(lots, maxL); + return NormalizeDouble(lots, 2); +} + +//+------------------------------------------------------------------+ +//| Calculate lot size from risk | +//+------------------------------------------------------------------+ +double CalcLotSize(double slPips) +{ + if(InpLotMode == LOT_FIXED) + return NormalizeLot(InpFixedLotSize); + + double balance = AccountInfoDouble(ACCOUNT_BALANCE); + double riskUSD = balance * InpRiskPercent / 100.0; + double tickVal = SymbolInfoDouble(g_Symbol, SYMBOL_TRADE_TICK_VALUE); + double tickSize = SymbolInfoDouble(g_Symbol, SYMBOL_TRADE_TICK_SIZE); + double pipValue = tickVal * (g_PipSize / tickSize); + + if(pipValue <= 0 || slPips <= 0) + return NormalizeLot(InpFixedLotSize); + + double lots = riskUSD / (slPips * pipValue); + + // Safety cap: never risk more than 5% of balance per trade regardless of settings. + // This prevents runaway lot sizes caused by an incorrect SL distance (e.g. pip size bug, + // misconfigured pips, or SL that is too tight). + double maxRiskUSD = balance * 5.0 / 100.0; + double maxSafeLots = maxRiskUSD / (slPips * pipValue); + if(lots > maxSafeLots) + { + PrintFormat("NANDR EA: WARNING — Computed lots %.2f exceeds 5%% risk cap. " + "Clamped to %.2f. Check SL pips (%.1f) and pip size (%.5f).", + lots, maxSafeLots, slPips, g_PipSize); + lots = maxSafeLots; + } + + return NormalizeLot(lots); +} + +//+------------------------------------------------------------------+ +//| Calculate SL price | +//+------------------------------------------------------------------+ +double CalcSL(int dir, double entry, double obBottom, double obTop, double orbHigh, double orbLow) +{ + double sl = 0; + double fixedFallback = (dir > 0) ? entry - PipsToPrice(InpSLPips) + : entry + PipsToPrice(InpSLPips); + + if(InpSLMode == SL_FIXED_PIPS) + { + sl = fixedFallback; + } + else if(InpSLMode == SL_OB_BOUNDARY) + { + if(dir > 0 && obBottom > 0) + sl = obBottom - PipsToPrice(2.0); // 2 pip buffer below OB bottom + else if(dir < 0 && obTop > 0) + sl = obTop + PipsToPrice(2.0); // 2 pip buffer above OB top + else + sl = fixedFallback; + } + else if(InpSLMode == SL_ORB_OPPOSITE) + { + // Buy → SL at ORB Low; Sell → SL at ORB High (2 pip buffer beyond). + double raw = (dir > 0) ? orbLow - PipsToPrice(2.0) + : orbHigh + PipsToPrice(2.0); + bool valid = (dir > 0) ? (raw < entry) : (raw > entry); + sl = valid ? raw : fixedFallback; + } + else if(InpSLMode == SL_ORB_MID) + { + // SL at the midpoint of the opening range. + double mid = (orbHigh + orbLow) / 2.0; + bool valid = (dir > 0) ? (mid < entry) : (mid > entry); + sl = valid ? mid : fixedFallback; + } + else // SL_ATR_BASED + { + double atr[1]; + if(CopyBuffer(g_ATRHandle, 0, 0, 1, atr) > 0) + sl = (dir > 0) ? entry - atr[0] * InpATRMultiplier + : entry + atr[0] * InpATRMultiplier; + else + sl = fixedFallback; + } + return NormalizeDouble(sl, (int)SymbolInfoInteger(g_Symbol, SYMBOL_DIGITS)); +} + +//+------------------------------------------------------------------+ +//| Calculate TP price | +//+------------------------------------------------------------------+ +double CalcTP(int dir, double entry, double sl) +{ + double tp = 0; + double slDist = MathAbs(entry - sl); + if(InpTPMode == TP_FIXED_PIPS) + { + tp = (dir > 0) ? entry + PipsToPrice(InpTPPips) + : entry - PipsToPrice(InpTPPips); + } + else // RR ratio + { + tp = (dir > 0) ? entry + slDist * InpRRRatio + : entry - slDist * InpRRRatio; + } + return NormalizeDouble(tp, (int)SymbolInfoInteger(g_Symbol, SYMBOL_DIGITS)); +} + +//+------------------------------------------------------------------+ +//| Coupled ORB risk: derive SL and TP together from the opening | +//| range, per the selected risk method. | +//| BOUNDARY → SL at broken boundary (long=ORH, short=ORL), 1:2 | +//| MID → SL at range midpoint, 1:1.5 | +//| OPPOSITE → SL at opposite boundary (long=ORL, short=ORH), 1:1 | +//| Returns false when the method is LEGACY or the geometry is | +//| invalid (caller then falls back to CalcSL/CalcTP). | +//+------------------------------------------------------------------+ +bool CalcOrbRisk(int dir, double entry, double orbHigh, double orbLow, + double &slOut, double &tpOut) +{ + if(InpOrbRiskMethod == ORB_RISK_LEGACY) return false; + if(orbHigh <= 0 || orbLow >= DBL_MAX || orbHigh <= orbLow) return false; + + double buf = PipsToPrice(2.0); + double mid = (orbHigh + orbLow) / 2.0; + double slLevel = 0.0; + double rr = 0.0; + + switch(InpOrbRiskMethod) + { + case ORB_RISK_BOUNDARY_1_2: + slLevel = (dir > 0) ? orbHigh - buf : orbLow + buf; + rr = 2.0; + break; + case ORB_RISK_MID_1_1_5: + slLevel = mid; + rr = 1.5; + break; + case ORB_RISK_OPPOSITE_1_1: + slLevel = (dir > 0) ? orbLow - buf : orbHigh + buf; + rr = 1.0; + break; + default: + return false; + } + + // SL must sit on the correct side of entry. + if((dir > 0 && slLevel >= entry) || (dir < 0 && slLevel <= entry)) + return false; + + double slDist = MathAbs(entry - slLevel); + if(slDist <= 0) return false; + + double tpLevel = (dir > 0) ? entry + slDist * rr : entry - slDist * rr; + + int digits = (int)SymbolInfoInteger(g_Symbol, SYMBOL_DIGITS); + slOut = NormalizeDouble(slLevel, digits); + tpOut = NormalizeDouble(tpLevel, digits); + return true; +} + +//+------------------------------------------------------------------+ +//| Daily bias gate: is a trade in this direction allowed today? | +//+------------------------------------------------------------------+ +bool IsDirectionAllowed(int dir) +{ + switch(InpDailyBiasMode) + { + case BIAS_OFF: return false; // skip the day entirely + case BIAS_LONG_ONLY: return (dir > 0); + case BIAS_SHORT_ONLY: return (dir < 0); + case BIAS_AUTO: + default: + // First breakout of the day establishes the direction; afterwards only + // trades aligned with that direction are permitted. + if(g_GlobalBiasDir == 0) return true; + return (dir == g_GlobalBiasDir); + } +} + +//+------------------------------------------------------------------+ +//| Initialize sessions | +//+------------------------------------------------------------------+ +void InitSessions() +{ + g_SessionCount = 0; + + // Daily Open + if(InpUseDailyOpen) + { + g_Sessions[g_SessionCount].name = "DailyOpen"; + g_Sessions[g_SessionCount].startHour = InpDailyOpenHour; + g_Sessions[g_SessionCount].startMin = InpDailyOpenMin; + g_Sessions[g_SessionCount].enabled = true; + g_Sessions[g_SessionCount].Reset(); + g_SessionCount++; + } + // Tokyo + if(InpUseTokyoSession) + { + g_Sessions[g_SessionCount].name = "Tokyo"; + g_Sessions[g_SessionCount].startHour = InpTokyoHour; + g_Sessions[g_SessionCount].startMin = InpTokyoMin; + g_Sessions[g_SessionCount].enabled = true; + g_Sessions[g_SessionCount].Reset(); + g_SessionCount++; + } + // London + if(InpUseLondonSession) + { + g_Sessions[g_SessionCount].name = "London"; + g_Sessions[g_SessionCount].startHour = InpLondonHour; + g_Sessions[g_SessionCount].startMin = InpLondonMin; + g_Sessions[g_SessionCount].enabled = true; + g_Sessions[g_SessionCount].Reset(); + g_SessionCount++; + } + // NY + if(InpUseNYSession) + { + g_Sessions[g_SessionCount].name = "NY"; + g_Sessions[g_SessionCount].startHour = InpNYHour; + g_Sessions[g_SessionCount].startMin = InpNYMin; + g_Sessions[g_SessionCount].enabled = true; + g_Sessions[g_SessionCount].Reset(); + g_SessionCount++; + } + // NY ORB (stock market open) + if(InpUseNYOrbSession) + { + g_Sessions[g_SessionCount].name = "NYORB"; + g_Sessions[g_SessionCount].startHour = InpNYOrbHour; + g_Sessions[g_SessionCount].startMin = InpNYOrbMin; + g_Sessions[g_SessionCount].enabled = true; + g_Sessions[g_SessionCount].Reset(); + g_SessionCount++; + } +} + +//+------------------------------------------------------------------+ +//| Check if current bar is the session open bar | +//+------------------------------------------------------------------+ +bool IsSessionOpenBar(const SSession &sess, datetime barTime) +{ + // Session inputs are UTC-based; convert server bar time to UTC before matching. + datetime utcBarTime = barTime - g_ServerUtcOffsetSec; + MqlDateTime dt; + TimeToStruct(utcBarTime, dt); + return (dt.hour == sess.startHour && dt.min == sess.startMin); +} + +//+------------------------------------------------------------------+ +//| Check if current bar is within session ORB accumulation window | +//+------------------------------------------------------------------+ +bool IsWithinOrbWindow(const SSession &sess, datetime barTime) +{ + if(sess.orbStartTime == 0) return false; + int elapsed = (int)(barTime - sess.orbStartTime); + return elapsed < g_OrbBarsNeeded * (int)InpOrbTimeframe * 60; +} + +//+------------------------------------------------------------------+ +//| Update ORB for all sessions on new bar | +//| IMPORTANT: always reads bar[1] (the just-CLOSED candle) so the | +//| true high/low is captured. bar[0] at new-bar time = open only. | +//+------------------------------------------------------------------+ +void UpdateORBSessions() +{ + datetime bar1Time = iTime(g_Symbol, PERIOD_CURRENT, 1); + double bar1High = iHigh(g_Symbol, PERIOD_CURRENT, 1); + double bar1Low = iLow(g_Symbol, PERIOD_CURRENT, 1); + + // New bar: clear per-bar retest flag so the next wick can trigger a fresh entry + for(int s = 0; s < g_SessionCount; s++) + g_Sessions[s].retestFiredThisBar = false; + + for(int s = 0; s < g_SessionCount; s++) + { + if(!g_Sessions[s].enabled) continue; + + // Session open bar just closed: bar[1] time matches session start + if(IsSessionOpenBar(g_Sessions[s], bar1Time)) + { + g_Sessions[s].orbHigh = bar1High; + g_Sessions[s].orbLow = bar1Low; + g_Sessions[s].orbBarCount = 1; + g_Sessions[s].orbStartTime = bar1Time; + g_Sessions[s].orbComplete = (g_OrbBarsNeeded == 1); + g_Sessions[s].breakoutDir = 0; + g_Sessions[s].inBreakout = false; + g_Sessions[s].breakoutBarsAgo = 0; + g_Sessions[s].tradesThisSession= 0; + ResetLadderProgress(s); + + if(g_Sessions[s].orbComplete) + PrintFormat("NANDR EA: [%s] ORB complete. High=%.2f Low=%.2f", + g_Sessions[s].name, bar1High, bar1Low); + if(InpShowORBLines) + DrawORBLines(s); + continue; + } + + // Still accumulating (M5 or M1): append bar[1] closed data to range + if(!g_Sessions[s].orbComplete && g_Sessions[s].orbBarCount > 0 + && IsWithinOrbWindow(g_Sessions[s], bar1Time)) + { + g_Sessions[s].orbHigh = MathMax(g_Sessions[s].orbHigh, bar1High); + g_Sessions[s].orbLow = MathMin(g_Sessions[s].orbLow, bar1Low); + g_Sessions[s].orbBarCount++; + if(g_Sessions[s].orbBarCount >= g_OrbBarsNeeded) + { + g_Sessions[s].orbComplete = true; + PrintFormat("NANDR EA: [%s] ORB complete. High=%.2f Low=%.2f", + g_Sessions[s].name, g_Sessions[s].orbHigh, g_Sessions[s].orbLow); + } + if(InpShowORBLines) + DrawORBLines(s); + } + + // Track breakout bar age (only while breakout is active) + // NOTE: incremented here before DetectBreakouts, so newly-detected + // breakouts start at 0 here but will be seen as 0 in CheckEntrySignals + // because DetectBreakouts resets it to 0 AFTER this runs. + // The entry guard uses <=1 to handle both cases safely. + if(g_Sessions[s].inBreakout) + g_Sessions[s].breakoutBarsAgo++; + } +} + +//+------------------------------------------------------------------+ +//| Strict breakout filter (mirrors Pine filteredHighCrossBO/Low) | +//+------------------------------------------------------------------+ +bool CheckStrictBreakout(int dir, double orbLevel) +{ + int N = g_BreakoutConfBars; + if(N < 2) N = 2; + + // Need at least N+3 closed bars (all indices shifted +1 vs simple version) + int bars = Bars(g_Symbol, PERIOD_CURRENT); + if(bars < N + 3) return false; + + if(dir > 0) // bullish + { + if(iLow(g_Symbol, PERIOD_CURRENT, N + 2) >= orbLevel) return false; + for(int i = 2; i <= N + 1; i++) + { + if(iLow(g_Symbol, PERIOD_CURRENT, i) <= orbLevel) return false; + if(iClose(g_Symbol, PERIOD_CURRENT, i) <= orbLevel) return false; + } + if(iClose(g_Symbol, PERIOD_CURRENT, 1) <= iLow(g_Symbol, PERIOD_CURRENT, 2)) return false; + if(iLow(g_Symbol, PERIOD_CURRENT, 1) <= orbLevel) return false; + return true; + } + else // bearish + { + if(iHigh(g_Symbol, PERIOD_CURRENT, N + 2) <= orbLevel) return false; + for(int i = 2; i <= N + 1; i++) + { + if(iHigh(g_Symbol, PERIOD_CURRENT, i) >= orbLevel) return false; + if(iClose(g_Symbol, PERIOD_CURRENT, i) >= orbLevel) return false; + } + if(iClose(g_Symbol, PERIOD_CURRENT, 1) >= iHigh(g_Symbol, PERIOD_CURRENT, 2)) return false; + if(iHigh(g_Symbol, PERIOD_CURRENT, 1) >= orbLevel) return false; + return true; + } +} + +//+------------------------------------------------------------------+ +//| Simple breakout: uses bar[1] (just-closed) vs bar[2] | +//| Matches Pine Script bar-close evaluation — never checks an | +//| in-progress candle so no premature signals at bar open. | +//+------------------------------------------------------------------+ +bool CheckSimpleBreakout(int dir, double orbLevel) +{ + double c0 = iClose(g_Symbol, PERIOD_CURRENT, 1); // just-closed bar + double c1 = iClose(g_Symbol, PERIOD_CURRENT, 2); // bar before it + if(dir > 0) return (c1 <= orbLevel && c0 > orbLevel); + else return (c1 >= orbLevel && c0 < orbLevel); +} + +//+------------------------------------------------------------------+ +//| Volume-spike confirmation for the breakout bar (bar[1]) | +//| Optional fakeout filter: requires the just-closed breakout bar's | +//| tick volume to exceed a multiple of the recent average volume. | +//| Returns true (pass) when the filter is disabled or data is thin. | +//+------------------------------------------------------------------+ +bool IsBreakoutVolumeConfirmed() +{ + if(!InpUseBreakoutVolFilter) return true; + + int n = InpBreakoutVolAvgBars; + if(n < 1) n = 1; + + int bars = Bars(g_Symbol, PERIOD_CURRENT); + if(bars < n + 2) return true; // not enough history — don't block + + double sum = 0; + for(int i = 2; i <= n + 1; i++) + sum += (double)iVolume(g_Symbol, PERIOD_CURRENT, i); + + double avg = sum / n; + if(avg <= 0) return true; + + double breakoutVol = (double)iVolume(g_Symbol, PERIOD_CURRENT, 1); + return (breakoutVol >= avg * InpBreakoutVolMult); +} + +//+------------------------------------------------------------------+ +//| Detect breakout for all sessions | +//+------------------------------------------------------------------+ +void DetectBreakouts() +{ + for(int s = 0; s < g_SessionCount; s++) + { + if(!g_Sessions[s].enabled || !g_Sessions[s].orbComplete) continue; + if(g_Sessions[s].orbHigh <= 0 || g_Sessions[s].orbLow >= DBL_MAX) continue; + if(g_Sessions[s].inBreakout) continue; + + double c0 = iClose(g_Symbol, PERIOD_CURRENT, 1); // just-closed bar + bool outsideBull = (c0 > g_Sessions[s].orbHigh); + bool outsideBear = (c0 < g_Sessions[s].orbLow); + + bool bullBO = InpUseStrictFilter + ? CheckStrictBreakout( 1, g_Sessions[s].orbHigh) + : CheckSimpleBreakout( 1, g_Sessions[s].orbHigh); + bool bearBO = InpUseStrictFilter + ? CheckStrictBreakout(-1, g_Sessions[s].orbLow) + : CheckSimpleBreakout(-1, g_Sessions[s].orbLow); + + // Fallback arming for simple mode: if the exact close-cross event was missed, + // still arm breakout while price is already closed beyond the ORB boundary. + if(!InpUseStrictFilter) + { + if(!bullBO && outsideBull) + { + bullBO = true; + PrintFormat("NANDR EA: [%s] Bullish breakout armed by outside-range fallback. Close=%.2f ORBHigh=%.2f", + g_Sessions[s].name, c0, g_Sessions[s].orbHigh); + } + if(!bearBO && outsideBear) + { + bearBO = true; + PrintFormat("NANDR EA: [%s] Bearish breakout armed by outside-range fallback. Close=%.2f ORBLow=%.2f", + g_Sessions[s].name, c0, g_Sessions[s].orbLow); + } + } + + // Optional volume-spike confirmation (fakeout filter). Applies to whichever + // direction is breaking out; a weak-volume breakout bar is rejected. + if((bullBO || bearBO) && !IsBreakoutVolumeConfirmed()) + { + PrintFormat("NANDR EA: [%s] Breakout rejected — volume on breakout bar below %.2fx average.", + g_Sessions[s].name, InpBreakoutVolMult); + bullBO = false; + bearBO = false; + } + + if(bullBO) + { + g_Sessions[s].breakoutDir = 1; + g_Sessions[s].inBreakout = true; + g_Sessions[s].breakoutBarsAgo= 0; + g_Sessions[s].breakoutEntryTaken = false; + ResetLadderDirection(s, 1); + // In AUTO bias mode, the first breakout of the day sets the tradeable direction. + if(InpDailyBiasMode == BIAS_AUTO && g_GlobalBiasDir == 0) g_GlobalBiasDir = 1; + PrintFormat("NANDR EA: [%s] Bullish ORB breakout at %.2f [DayBias=%d]", + g_Sessions[s].name, g_Sessions[s].orbHigh, g_GlobalBiasDir); + } + else if(bearBO) + { + g_Sessions[s].breakoutDir = -1; + g_Sessions[s].inBreakout = true; + g_Sessions[s].breakoutBarsAgo= 0; + g_Sessions[s].breakoutEntryTaken = false; + ResetLadderDirection(s, -1); + // In AUTO bias mode, the first breakout of the day sets the tradeable direction. + if(InpDailyBiasMode == BIAS_AUTO && g_GlobalBiasDir == 0) g_GlobalBiasDir = -1; + PrintFormat("NANDR EA: [%s] Bearish ORB breakout at %.2f [DayBias=%d]", + g_Sessions[s].name, g_Sessions[s].orbLow, g_GlobalBiasDir); + } + } +} + +//+------------------------------------------------------------------+ +//| Update retest state on bar close: sweep reversals + invalidation | +//| Called once per new bar. Does NOT fire any trades. | +//+------------------------------------------------------------------+ +void UpdateRetestState(int sessIdx) +{ + if(!g_Sessions[sessIdx].inBreakout) return; + + // Use bar[1] (just-closed) and bar[2] for bar-close confirmation. + double c0 = iClose(g_Symbol, PERIOD_CURRENT, 1); + double c1 = iClose(g_Symbol, PERIOD_CURRENT, 2); + double h0 = iHigh(g_Symbol, PERIOD_CURRENT, 1); + double l0 = iLow(g_Symbol, PERIOD_CURRENT, 1); + + double orbHigh = g_Sessions[sessIdx].orbHigh; + double orbLow = g_Sessions[sessIdx].orbLow; + double orbMid = (orbHigh + orbLow) / 2.0; + + if(g_Sessions[sessIdx].breakoutDir == 1) + { + // Sweep reversal: bullish breakout failed — bar closed below orbHigh → flip to bearish + bool sweepRev = (c1 > orbHigh) && (l0 < orbHigh) && (c0 < orbHigh) && (c0 >= orbMid); + if(sweepRev) + { + g_Sessions[sessIdx].breakoutDir = -1; + g_Sessions[sessIdx].breakoutEntryTaken = false; + ResetLadderDirection(sessIdx, -1); + if(InpDailyBiasMode == BIAS_AUTO) g_GlobalBiasDir = -1; // swept & closed below → bias bearish + PrintFormat("NANDR EA: [%s] Bullish sweep reversal at orbHigh %.2f — flipping to BEARISH [DayBias=%d]", + g_Sessions[sessIdx].name, orbHigh, g_GlobalBiasDir); + } + // Full invalidation: closed below mid + else if(c0 < orbMid && c1 > orbMid) + { + PrintFormat("NANDR EA: [%s] Failed bullish retest (closed below mid %.2f)", + g_Sessions[sessIdx].name, orbMid); + g_Sessions[sessIdx].inBreakout = false; + g_Sessions[sessIdx].breakoutDir = 0; + g_Sessions[sessIdx].breakoutEntryTaken = false; + ResetLadderProgress(sessIdx); + } + } + else if(g_Sessions[sessIdx].breakoutDir == -1) + { + // Sweep reversal: bearish breakout failed — bar closed above orbLow → flip to bullish + bool sweepRev = (c1 < orbLow) && (h0 > orbLow) && (c0 > orbLow) && (c0 < orbHigh); + if(sweepRev) + { + g_Sessions[sessIdx].breakoutDir = 1; + g_Sessions[sessIdx].breakoutEntryTaken = false; + ResetLadderDirection(sessIdx, 1); + if(InpDailyBiasMode == BIAS_AUTO) g_GlobalBiasDir = 1; // swept & closed above → bias bullish + PrintFormat("NANDR EA: [%s] Bearish sweep reversal at orbLow %.2f — flipping to BULLISH [DayBias=%d]", + g_Sessions[sessIdx].name, orbLow, g_GlobalBiasDir); + } + // Full invalidation: closed above mid + else if(c0 > orbMid && c1 < orbMid) + { + PrintFormat("NANDR EA: [%s] Failed bearish retest (closed above mid %.2f)", + g_Sessions[sessIdx].name, orbMid); + g_Sessions[sessIdx].inBreakout = false; + g_Sessions[sessIdx].breakoutDir = 0; + g_Sessions[sessIdx].breakoutEntryTaken = false; + ResetLadderProgress(sessIdx); + } + } +} + +//+------------------------------------------------------------------+ +//| Order Block: scan for new OBs on new bar | +//+------------------------------------------------------------------+ +void ScanOrderBlocks() +{ + int length = g_OBPivotLength; + int barsNeeded = length * 2 + 2; + if(Bars(g_Symbol, PERIOD_CURRENT) < barsNeeded) return; + + // Check for volume pivot at bar[length] + // Volume at bar[length] must be highest in [0..2*length] + long pivotVol = iVolume(g_Symbol, PERIOD_CURRENT, length); + bool isVolPivot = true; + for(int i = 0; i < length * 2 + 1; i++) + { + if(i == length) continue; + if(iVolume(g_Symbol, PERIOD_CURRENT, i) >= pivotVol) + { + isVolPivot = false; + break; + } + } + if(!isVolPivot) return; + + // Market structure: os + // os=1 (bullish): low[length] is lowest low in the window + // os=0 (bearish): high[length] is highest high in the window + double pivotHigh = iHigh(g_Symbol, PERIOD_CURRENT, length); + double pivotLow = iLow(g_Symbol, PERIOD_CURRENT, length); + + double highestHigh = pivotHigh; + double lowestLow = pivotLow; + for(int i = 0; i < length * 2 + 1; i++) + { + highestHigh = MathMax(highestHigh, iHigh(g_Symbol, PERIOD_CURRENT, i)); + lowestLow = MathMin(lowestLow, iLow(g_Symbol, PERIOD_CURRENT, i)); + } + + int os = -1; // unknown + if(pivotHigh >= highestHigh) os = 0; // bearish structure + else if(pivotLow <= lowestLow) os = 1; // bullish structure + + if(os < 0) return; + + datetime obTime = iTime(g_Symbol, PERIOD_CURRENT, length); + double hl2 = (pivotHigh + pivotLow) / 2.0; + + if(os == 1) // Bullish OB: zone [low[length], hl2] + { + if(g_BullOBCount < InpOBMaxCount) + { + // Check not duplicate + for(int i = 0; i < g_BullOBCount; i++) + if(g_BullOBs[i].time == obTime) return; + + g_BullOBs[g_BullOBCount].top = hl2; + g_BullOBs[g_BullOBCount].bottom = pivotLow; + g_BullOBs[g_BullOBCount].mid = (hl2 + pivotLow) / 2.0; + g_BullOBs[g_BullOBCount].time = obTime; + g_BullOBs[g_BullOBCount].active = true; + g_BullOBs[g_BullOBCount].traded = false; + g_BullOBs[g_BullOBCount].objName= "NANDR_BullOB_" + IntegerToString(obTime); + g_BullOBCount++; + if(InpShowOBZones) DrawOBZone(g_BullOBCount - 1, true); + PrintFormat("NANDR EA: Bullish OB detected. Zone [%.2f - %.2f]", pivotLow, hl2); + } + } + else // Bearish OB: zone [hl2, high[length]] + { + if(g_BearOBCount < InpOBMaxCount) + { + for(int i = 0; i < g_BearOBCount; i++) + if(g_BearOBs[i].time == obTime) return; + + g_BearOBs[g_BearOBCount].top = pivotHigh; + g_BearOBs[g_BearOBCount].bottom = hl2; + g_BearOBs[g_BearOBCount].mid = (pivotHigh + hl2) / 2.0; + g_BearOBs[g_BearOBCount].time = obTime; + g_BearOBs[g_BearOBCount].active = true; + g_BearOBs[g_BearOBCount].traded = false; + g_BearOBs[g_BearOBCount].objName= "NANDR_BearOB_" + IntegerToString(obTime); + g_BearOBCount++; + if(InpShowOBZones) DrawOBZone(g_BearOBCount - 1, false); + PrintFormat("NANDR EA: Bearish OB detected. Zone [%.2f - %.2f]", hl2, pivotHigh); + } + } +} + +//+------------------------------------------------------------------+ +//| Remove mitigated OBs (Wick or Close method) | +//+------------------------------------------------------------------+ +void MitigateOrderBlocks() +{ + double c0 = iClose(g_Symbol, PERIOD_CURRENT, 0); + double l0 = iLow(g_Symbol, PERIOD_CURRENT, 0); + double h0 = iHigh(g_Symbol, PERIOD_CURRENT, 0); + + // Bullish OBs: mitigated if price goes below bottom + for(int i = g_BullOBCount - 1; i >= 0; i--) + { + if(!g_BullOBs[i].active) continue; + double target = (InpOBMitig == MITIG_WICK) ? l0 : c0; + if(target < g_BullOBs[i].bottom) + { + g_BullOBs[i].active = false; + if(InpShowOBZones) RemoveOBZone(g_BullOBs[i].objName); + // Shift array left + for(int j = i; j < g_BullOBCount - 1; j++) + g_BullOBs[j] = g_BullOBs[j+1]; + g_BullOBCount--; + } + } + + // Bearish OBs: mitigated if price goes above top + for(int i = g_BearOBCount - 1; i >= 0; i--) + { + if(!g_BearOBs[i].active) continue; + double target = (InpOBMitig == MITIG_WICK) ? h0 : c0; + if(target > g_BearOBs[i].top) + { + g_BearOBs[i].active = false; + if(InpShowOBZones) RemoveOBZone(g_BearOBs[i].objName); + for(int j = i; j < g_BearOBCount - 1; j++) + g_BearOBs[j] = g_BearOBs[j+1]; + g_BearOBCount--; + } + } +} + +//+------------------------------------------------------------------+ +//| Find nearest OB to a given price and direction | +//+------------------------------------------------------------------+ +bool IsOBNearLevel(double price, int dir, double &obTop, double &obBottom) +{ + double proxPrice = PipsToPrice(InpOBProximityPips); + + if(dir > 0) // Look for bullish OB near or below entry + { + double nearest = DBL_MAX; + int bestIdx = -1; + for(int i = 0; i < g_BullOBCount; i++) + { + if(!g_BullOBs[i].active) continue; + double dist = MathAbs(price - g_BullOBs[i].top); + if(dist <= proxPrice && dist < nearest) + { + nearest = dist; + bestIdx = i; + } + } + if(bestIdx >= 0) + { + obTop = g_BullOBs[bestIdx].top; + obBottom = g_BullOBs[bestIdx].bottom; + return true; + } + } + else // Look for bearish OB near or above entry + { + double nearest = DBL_MAX; + int bestIdx = -1; + for(int i = 0; i < g_BearOBCount; i++) + { + if(!g_BearOBs[i].active) continue; + double dist = MathAbs(price - g_BearOBs[i].bottom); + if(dist <= proxPrice && dist < nearest) + { + nearest = dist; + bestIdx = i; + } + } + if(bestIdx >= 0) + { + obTop = g_BearOBs[bestIdx].top; + obBottom = g_BearOBs[bestIdx].bottom; + return true; + } + } + + obTop = obBottom = 0; + return false; +} + +//+------------------------------------------------------------------+ +//| Session-scoped active exposure | +//+------------------------------------------------------------------+ +bool CommentHasSessionTag(const string comment, const string sessionName) +{ + if(comment == "" || sessionName == "") + return false; + return (StringFind(comment, "|" + sessionName + "|") >= 0); +} + +string ResolveSessionNameForUtcTime(datetime utcTime) +{ + MqlDateTime t; + TimeToStruct(utcTime, t); + int nowMin = t.hour * 60 + t.min; + + int bestIdx = -1; + int bestStart = -1; + for(int s = 0; s < g_SessionCount; s++) + { + if(!g_Sessions[s].enabled) + continue; + + int startMin = g_Sessions[s].startHour * 60 + g_Sessions[s].startMin; + if(startMin <= nowMin && startMin >= bestStart) + { + bestStart = startMin; + bestIdx = s; + } + } + + if(bestIdx >= 0) + return g_Sessions[bestIdx].name; + + for(int s = 0; s < g_SessionCount; s++) + { + if(g_Sessions[s].enabled) + return g_Sessions[s].name; + } + + return ""; +} + +int CountOpenPositionsForSession(const string sessionName) +{ + int count = 0; + for(int i = PositionsTotal() - 1; i >= 0; i--) + { + if(g_Position.SelectByIndex(i)) + { + if(g_Position.Magic() == InpMagicNumber && g_Position.Symbol() == g_Symbol) + { + string comment = PositionGetString(POSITION_COMMENT); + if(CommentHasSessionTag(comment, sessionName)) + count++; + } + } + } + return count; +} + +int CountPendingOrdersForSession(const string sessionName) +{ + int count = 0; + for(int i = OrdersTotal() - 1; i >= 0; i--) + { + if(g_Order.SelectByIndex(i)) + { + if(g_Order.Magic() == InpMagicNumber && g_Order.Symbol() == g_Symbol) + { + string comment = OrderGetString(ORDER_COMMENT); + if(CommentHasSessionTag(comment, sessionName)) + count++; + } + } + } + return count; +} + +bool HasActiveExposureForSession(const string sessionName) +{ + return (CountOpenPositionsForSession(sessionName) + CountPendingOrdersForSession(sessionName)) > 0; +} + +bool HasActiveExposureForSession(int sessIdx) +{ + if(sessIdx < 0 || sessIdx >= g_SessionCount) + return false; + return HasActiveExposureForSession(g_Sessions[sessIdx].name); +} + +double SessionMid(const SSession &sess) +{ + return (sess.orbHigh + sess.orbLow) / 2.0; +} + +void ResetLadderProgress(int sessIdx) +{ + g_Sessions[sessIdx].ladderBullLowDone = false; + g_Sessions[sessIdx].ladderBullMidDone = false; + g_Sessions[sessIdx].ladderBearHighDone = false; + g_Sessions[sessIdx].ladderBearMidDone = false; + g_Sessions[sessIdx].ladderTradesThisSession = 0; +} + +void ResetLadderDirection(int sessIdx, int dir) +{ + if(dir > 0) + { + g_Sessions[sessIdx].ladderBullLowDone = false; + g_Sessions[sessIdx].ladderBullMidDone = false; + } + else + { + g_Sessions[sessIdx].ladderBearHighDone = false; + g_Sessions[sessIdx].ladderBearMidDone = false; + } +} + +void RefreshBreakoutEntryLocks() +{ + for(int s = 0; s < g_SessionCount; s++) + { + if(g_Sessions[s].inBreakout && !HasActiveExposureForSession(s)) + g_Sessions[s].breakoutEntryTaken = false; + } +} + +bool OpenLadderTrade(int sessIdx, int dir, double srcLevel, double dstLevel, const string stepTag) +{ + if(g_TradingHalted) return false; + if(g_TodayTrades >= InpMaxTradesPerDay) return false; + if(HasActiveExposureForSession(sessIdx)) return false; + int ladderCap = GetEffectiveLadderCap(sessIdx); + if(ladderCap > 0 && g_Sessions[sessIdx].ladderTradesThisSession >= ladderCap) + return false; + + double ask = SymbolInfoDouble(g_Symbol, SYMBOL_ASK); + double bid = SymbolInfoDouble(g_Symbol, SYMBOL_BID); + double entry = (dir > 0) ? ask : bid; + double tp = NormalizeDouble(dstLevel, (int)SymbolInfoInteger(g_Symbol, SYMBOL_DIGITS)); + double sl = 0.0; + + if(InpLadderSLMode == LADDER_SL_LEVEL_BUFFER) + { + sl = (dir > 0) ? srcLevel - PipsToPrice(InpLadderSLBufferPips) + : srcLevel + PipsToPrice(InpLadderSLBufferPips); + } + else + { + sl = (dir > 0) ? entry - PipsToPrice(InpSLPips) + : entry + PipsToPrice(InpSLPips); + } + sl = NormalizeDouble(sl, (int)SymbolInfoInteger(g_Symbol, SYMBOL_DIGITS)); + + if((dir > 0 && (tp <= entry || sl >= entry)) + || (dir < 0 && (tp >= entry || sl <= entry))) + return false; + + double slPips = PriceToPips(MathAbs(entry - sl)); + if(slPips <= 0) return false; + double lots = CalcLotSize(slPips); + + string comment = StringFormat("NANDR|Ladder|%s|%s|%s", + g_Sessions[sessIdx].name, + (dir > 0 ? "BUY" : "SELL"), + stepTag); + + bool result = (dir > 0) + ? g_Trade.Buy(lots, g_Symbol, 0, sl, tp, comment) + : g_Trade.Sell(lots, g_Symbol, 0, sl, tp, comment); + + if(!result) + { + PrintFormat("NANDR EA: Ladder trade failed. Step=%s Session=%s Error=%d", + stepTag, g_Sessions[sessIdx].name, GetLastError()); + return false; + } + + g_TodayTrades++; + g_Sessions[sessIdx].tradesThisSession++; + g_Sessions[sessIdx].ladderTradesThisSession++; + g_Sessions[sessIdx].breakoutEntryTaken = true; + + PrintFormat("NANDR EA: Ladder trade opened. Step=%s Dir=%s Lots=%.2f Entry=%.2f SL=%.2f TP=%.2f Session=%s", + stepTag, (dir > 0 ? "BUY" : "SELL"), lots, entry, sl, tp, g_Sessions[sessIdx].name); + if(InpShowTradeLabels) + DrawTradeLabel(dir, entry, sl, tp); + + return true; +} + +bool IsLadderEnabledForSession(int sessIdx) +{ + string name = g_Sessions[sessIdx].name; + if(name == "DailyOpen") return InpLadderOnDailyOpen; + if(name == "Tokyo") return InpLadderOnTokyo; + if(name == "London") return InpLadderOnLondon; + if(name == "NY") return InpLadderOnNY; + if(name == "NYORB") return InpLadderOnNYORB; + return true; +} + +int GetLadderSessionCap(int sessIdx) +{ + if(!InpUseLadderSessionCaps) + return 0; + + string name = g_Sessions[sessIdx].name; + if(name == "DailyOpen") return InpLadderCapDailyOpen; + if(name == "Tokyo") return InpLadderCapTokyo; + if(name == "London") return InpLadderCapLondon; + if(name == "NY") return InpLadderCapNY; + if(name == "NYORB") return InpLadderCapNYORB; + return 0; +} + +int GetEffectiveLadderCap(int sessIdx) +{ + int globalCap = InpLadderMaxTradesPerSession; + int sessionCap = GetLadderSessionCap(sessIdx); + + if(globalCap <= 0) return sessionCap; + if(sessionCap <= 0) return globalCap; + return MathMin(globalCap, sessionCap); +} + +bool IsLadderVolatilityTooHigh(double &atrPips) +{ + atrPips = 0.0; + if(!InpUseLadderATRVolCeiling) + return false; + if(g_ATRHandle == INVALID_HANDLE) + return false; + + double atr[1]; + if(CopyBuffer(g_ATRHandle, 0, 0, 1, atr) <= 0) + return false; + + atrPips = PriceToPips(atr[0]); + return (atrPips > InpLadderATRMaxPips); +} + +bool TrySessionLadderEntry(int sessIdx, + double c1, + double h_cur, + double l_cur, + double ask, + double bid, + double tol) +{ + if(!InpUseSessionLevelLadder) return false; + if(!IsLadderEnabledForSession(sessIdx)) return false; + if(!g_Sessions[sessIdx].enabled || !g_Sessions[sessIdx].orbComplete) return false; + if(!g_Sessions[sessIdx].inBreakout) return false; + if(!IsDirectionAllowed(g_Sessions[sessIdx].breakoutDir)) return false; + if(HasActiveExposureForSession(sessIdx)) return false; + if(g_Sessions[sessIdx].retestFiredThisBar) return false; + if(InpBreakoutExpireBars > 0 && g_Sessions[sessIdx].breakoutBarsAgo >= InpBreakoutExpireBars) return false; + int ladderCap = GetEffectiveLadderCap(sessIdx); + if(ladderCap > 0 && g_Sessions[sessIdx].ladderTradesThisSession >= ladderCap) + return false; + + double atrPips = 0.0; + if(IsLadderVolatilityTooHigh(atrPips)) + { + PrintFormat("NANDR EA: Ladder skipped [%s] - ATR %.1f pips exceeds ceiling %.1f pips.", + g_Sessions[sessIdx].name, atrPips, InpLadderATRMaxPips); + return false; + } + + double orbHigh = g_Sessions[sessIdx].orbHigh; + double orbLow = g_Sessions[sessIdx].orbLow; + double orbMid = SessionMid(g_Sessions[sessIdx]); + + if(g_Sessions[sessIdx].breakoutDir > 0) + { + if(!g_Sessions[sessIdx].ladderBullLowDone + && c1 > orbLow && l_cur <= orbLow && ask >= orbLow - tol) + { + if(OpenLadderTrade(sessIdx, 1, orbLow, orbMid, "LOW_TO_MID")) + { + g_Sessions[sessIdx].ladderBullLowDone = true; + g_Sessions[sessIdx].retestFiredThisBar = true; + return true; + } + } + + if(g_Sessions[sessIdx].ladderBullLowDone + && !g_Sessions[sessIdx].ladderBullMidDone + && c1 > orbMid && l_cur <= orbMid && ask >= orbMid - tol) + { + if(OpenLadderTrade(sessIdx, 1, orbMid, orbHigh, "MID_TO_HIGH")) + { + g_Sessions[sessIdx].ladderBullMidDone = true; + g_Sessions[sessIdx].retestFiredThisBar = true; + return true; + } + } + } + else if(g_Sessions[sessIdx].breakoutDir < 0) + { + if(!g_Sessions[sessIdx].ladderBearHighDone + && c1 < orbHigh && h_cur >= orbHigh && bid <= orbHigh + tol) + { + if(OpenLadderTrade(sessIdx, -1, orbHigh, orbMid, "HIGH_TO_MID")) + { + g_Sessions[sessIdx].ladderBearHighDone = true; + g_Sessions[sessIdx].retestFiredThisBar = true; + return true; + } + } + + if(g_Sessions[sessIdx].ladderBearHighDone + && !g_Sessions[sessIdx].ladderBearMidDone + && c1 < orbMid && h_cur >= orbMid && bid <= orbMid + tol) + { + if(OpenLadderTrade(sessIdx, -1, orbMid, orbLow, "MID_TO_LOW")) + { + g_Sessions[sessIdx].ladderBearMidDone = true; + g_Sessions[sessIdx].retestFiredThisBar = true; + return true; + } + } + } + + return false; +} + +//+------------------------------------------------------------------+ +//| Cancel all pending orders | +//+------------------------------------------------------------------+ +void CancelAllPending() +{ + for(int i = OrdersTotal() - 1; i >= 0; i--) + { + if(g_Order.SelectByIndex(i)) + if(g_Order.Magic() == InpMagicNumber && g_Order.Symbol() == g_Symbol) + g_Trade.OrderDelete(g_Order.Ticket()); + } +} + +//+------------------------------------------------------------------+ +//| Open trade | +//+------------------------------------------------------------------+ +void OpenTrade(int dir, int sessIdx, double orbLevel, double obTop, double obBottom) +{ + if(g_TradingHalted) return; + if(!IsDirectionAllowed(dir)) return; + if(g_TodayTrades >= InpMaxTradesPerDay) return; + if(g_Sessions[sessIdx].tradesThisSession >= InpMaxRetestsPerSession) return; + if(HasActiveExposureForSession(sessIdx)) return; + if(g_Sessions[sessIdx].breakoutEntryTaken) return; + + double ask = SymbolInfoDouble(g_Symbol, SYMBOL_ASK); + double bid = SymbolInfoDouble(g_Symbol, SYMBOL_BID); + double entry = (dir > 0) ? ask : bid; + + // For limit orders, entry is at the ORB level + if(InpEntryMode == ENTRY_LIMIT) + entry = orbLevel; + + // Risk: prefer the coupled ORB risk method (SL+TP from the range); fall back + // to the legacy SL/TP inputs when the method is LEGACY or the geometry fails. + double sl, tp; + if(!CalcOrbRisk(dir, entry, g_Sessions[sessIdx].orbHigh, g_Sessions[sessIdx].orbLow, sl, tp)) + { + sl = CalcSL(dir, entry, obBottom, obTop, + g_Sessions[sessIdx].orbHigh, g_Sessions[sessIdx].orbLow); + tp = CalcTP(dir, entry, sl); + } + double slPips = PriceToPips(MathAbs(entry - sl)); + double lots = CalcLotSize(slPips); + + string comment = StringFormat("NANDR|%s|%s", g_Sessions[sessIdx].name, (dir > 0 ? "BUY" : "SELL")); + + bool result = false; + if(InpEntryMode == ENTRY_MARKET) + { + if(dir > 0) + result = g_Trade.Buy(lots, g_Symbol, 0, sl, tp, comment); + else + result = g_Trade.Sell(lots, g_Symbol, 0, sl, tp, comment); + } + else // Limit + { + ENUM_ORDER_TYPE otype = (dir > 0) ? ORDER_TYPE_BUY_LIMIT : ORDER_TYPE_SELL_LIMIT; + result = g_Trade.OrderOpen(g_Symbol, otype, lots, orbLevel, orbLevel, sl, tp, ORDER_TIME_DAY, 0, comment); + } + + if(result) + { + g_TodayTrades++; + g_Sessions[sessIdx].tradesThisSession++; + g_Sessions[sessIdx].breakoutEntryTaken = true; + // Do NOT clear inBreakout — the ORB level remains valid for subsequent retests + // until the breakout expires, fails, or the daily reset. This allows the EA to + // take multiple retest entries on the same level (e.g. 18:45 AND 20:45 retests). + // Re-entry is naturally gated by session-scoped exposure checks at the top of OpenTrade. + PrintFormat("NANDR EA: Trade opened. Dir=%s Lots=%.2f Entry=%.2f SL=%.2f TP=%.2f Session=%s", + (dir > 0 ? "BUY" : "SELL"), lots, entry, sl, tp, g_Sessions[sessIdx].name); + if(InpShowTradeLabels) + DrawTradeLabel(dir, entry, sl, tp); + } + else + { + PrintFormat("NANDR EA: OrderSend failed. Error=%d Session=%s", + GetLastError(), g_Sessions[sessIdx].name); + } +} + +//+------------------------------------------------------------------+ +//| Tick-based retest entries | +//| Core ORB strategy: | +//| 1. Session ORB complete | +//| 2. Breakout confirmed (close crossed ORB level) | +//| 3. Wick of current bar touches the broken level | +//| 4. Enter in breakout direction | +//+------------------------------------------------------------------+ +void CheckRetestEntriesTick() +{ + if(g_TradingHalted) return; + if(g_TodayTrades >= InpMaxTradesPerDay) return; + + double h_cur = iHigh(g_Symbol, PERIOD_CURRENT, 0); + double l_cur = iLow(g_Symbol, PERIOD_CURRENT, 0); + double c1 = iClose(g_Symbol, PERIOD_CURRENT, 1); + double open0 = iOpen(g_Symbol, PERIOD_CURRENT, 0); + double ask = SymbolInfoDouble(g_Symbol, SYMBOL_ASK); + double bid = SymbolInfoDouble(g_Symbol, SYMBOL_BID); + double tol = PipsToPrice(1.0); + string obSessionName = ResolveSessionNameForUtcTime(TimeCurrent() - g_ServerUtcOffsetSec); + + // ── ORB Session Retest Entries ────────────────────────────────── + if(InpWaitForRetest) + { + for(int s = 0; s < g_SessionCount; s++) + { + // --- Gate 1: session must have a live confirmed breakout --- + if(!g_Sessions[s].enabled || !g_Sessions[s].orbComplete) continue; + if(!g_Sessions[s].inBreakout) continue; + + // --- Gate 2: no active exposure for this session --- + if(HasActiveExposureForSession(s)) continue; + + // --- Gate 3: entry already taken and not yet released --- + if(g_Sessions[s].breakoutEntryTaken) continue; + + // --- Gate 4: session trade count limit --- + if(g_Sessions[s].tradesThisSession >= InpMaxRetestsPerSession) continue; + + // --- Gate 5: breakout expiry --- + if(InpBreakoutExpireBars > 0 && g_Sessions[s].breakoutBarsAgo >= InpBreakoutExpireBars) continue; + + // --- Gate 6: one entry per bar to prevent duplicate ticks --- + if(g_Sessions[s].retestFiredThisBar) continue; + + double orbHigh = g_Sessions[s].orbHigh; + double orbLow = g_Sessions[s].orbLow; + int dir = 0; + double orbLevel = 0; + + if(g_Sessions[s].breakoutDir == 1) // Bullish breakout: price broke above orbHigh + { + // Retest: wick comes back down to touch orbHigh + if(l_cur <= orbHigh) + { + orbLevel = orbHigh; + dir = 1; + } + } + else if(g_Sessions[s].breakoutDir == -1) // Bearish breakout: price broke below orbLow + { + // Retest: wick comes back up to touch orbLow + if(h_cur >= orbLow) + { + orbLevel = orbLow; + dir = -1; + } + } + + if(dir == 0) continue; + + g_Sessions[s].retestFiredThisBar = true; + + double obTop = 0, obBottom = 0; + IsOBNearLevel(orbLevel, dir, obTop, obBottom); + if(InpOBRequireConf && obTop == 0) + { + PrintFormat("NANDR EA: [%s] Retest at %.2f blocked — OB confirmation required but no OB nearby.", + g_Sessions[s].name, orbLevel); + continue; + } + OpenTrade(dir, s, orbLevel, obTop, obBottom); + } + } + + // ── Session Level Ladder (fallback, only when no ORB entry taken this tick) ── + if(InpUseSessionLevelLadder) + { + for(int s = 0; s < g_SessionCount; s++) + { + if(TrySessionLadderEntry(s, c1, h_cur, l_cur, ask, bid, tol)) + break; + } + } + + if(!InpUseOBRetestEntry) return; + + // ── Bearish OB Retest → SELL at wick touch ─────────────────────── + // Gated by the daily bias (only short entries when bias permits). + if(IsDirectionAllowed(-1)) + for(int i = 0; i < g_BearOBCount; i++) + { + if(!g_BearOBs[i].active || g_BearOBs[i].traded) continue; + if(g_TodayTrades >= InpMaxTradesPerDay) break; + + if(obSessionName == "") continue; + if(HasActiveExposureForSession(obSessionName)) continue; + + double obBottom = g_BearOBs[i].bottom; + double obTop2 = g_BearOBs[i].top; + + bool prevBelow = (c1 <= obBottom || open0 <= obBottom); + bool wickTouched = (h_cur >= obBottom); + if(!prevBelow || !wickTouched) continue; + + double entry = bid; + double sl = NormalizeDouble(obTop2 + PipsToPrice(2.0), + (int)SymbolInfoInteger(g_Symbol, SYMBOL_DIGITS)); + double slPips = PriceToPips(MathAbs(entry - sl)); + double tp = CalcTP(-1, entry, sl); + double lots = CalcLotSize(slPips); + + bool result = g_Trade.Sell(lots, g_Symbol, 0, sl, tp, + StringFormat("NANDR|%s|OBRetest|SELL|%.2f", obSessionName, obBottom)); + if(result) + { + g_TodayTrades++; + g_BearOBs[i].traded = true; + PrintFormat("NANDR EA: OB Retest SELL. Entry=%.2f SL=%.2f TP=%.2f OB=[%.2f-%.2f]", + entry, sl, tp, obBottom, obTop2); + if(InpShowTradeLabels) DrawTradeLabel(-1, entry, sl, tp); + } + else + PrintFormat("NANDR EA: OB Retest SELL failed. Error=%d", GetLastError()); + break; + } + + // ── Bullish OB Retest → BUY at wick touch ──────────────────────── + // Gated by the daily bias (only long entries when bias permits). + if(IsDirectionAllowed(1)) + for(int i = 0; i < g_BullOBCount; i++) + { + if(!g_BullOBs[i].active || g_BullOBs[i].traded) continue; + if(g_TodayTrades >= InpMaxTradesPerDay) break; + + if(obSessionName == "") continue; + if(HasActiveExposureForSession(obSessionName)) continue; + + double obTop3 = g_BullOBs[i].top; + double obBottom2 = g_BullOBs[i].bottom; + + bool prevAbove = (c1 >= obTop3 || open0 >= obTop3); + bool wickTouched = (l_cur <= obTop3); + if(!prevAbove || !wickTouched) continue; + + double entry = ask; + double sl = NormalizeDouble(obBottom2 - PipsToPrice(2.0), + (int)SymbolInfoInteger(g_Symbol, SYMBOL_DIGITS)); + double slPips = PriceToPips(MathAbs(entry - sl)); + double tp = CalcTP(1, entry, sl); + double lots = CalcLotSize(slPips); + + bool result = g_Trade.Buy(lots, g_Symbol, 0, sl, tp, + StringFormat("NANDR|%s|OBRetest|BUY|%.2f", obSessionName, obTop3)); + if(result) + { + g_TodayTrades++; + g_BullOBs[i].traded = true; + PrintFormat("NANDR EA: OB Retest BUY. Entry=%.2f SL=%.2f TP=%.2f OB=[%.2f-%.2f]", + entry, sl, tp, obBottom2, obTop3); + if(InpShowTradeLabels) DrawTradeLabel(1, entry, sl, tp); + } + else + PrintFormat("NANDR EA: OB Retest BUY failed. Error=%d", GetLastError()); + break; + } +} + +//+------------------------------------------------------------------+ +//| Check all sessions for entry signals | +//+------------------------------------------------------------------+ +void CheckEntrySignals() +{ + for(int s = 0; s < g_SessionCount; s++) + { + if(!g_Sessions[s].enabled || !g_Sessions[s].orbComplete) continue; + // Guard: allow up to InpMaxRetestsPerSession retest entries on the same breakout level. + // Previously used InpMaxPosPerSession (=1) which permanently blocked the second retest. + if(g_Sessions[s].tradesThisSession >= InpMaxRetestsPerSession) continue; + if(!g_Sessions[s].inBreakout) continue; + + // --- Breakout expiry --- + if(InpBreakoutExpireBars > 0 + && g_Sessions[s].breakoutBarsAgo >= InpBreakoutExpireBars) + { + PrintFormat("NANDR EA: [%s] Breakout expired after %d bars. Resetting.", + g_Sessions[s].name, g_Sessions[s].breakoutBarsAgo); + g_Sessions[s].inBreakout = false; + g_Sessions[s].breakoutDir = 0; + g_Sessions[s].breakoutEntryTaken = false; + ResetLadderProgress(s); + continue; + } + + int dir = g_Sessions[s].breakoutDir; + double orbLevel = (dir > 0) ? g_Sessions[s].orbHigh : g_Sessions[s].orbLow; + + // --- LIMIT mode: place order immediately on breakout bar --- + if(InpEntryMode == ENTRY_LIMIT) + { + // Place once — on the bar breakout is first detected (age 0 or 1 due to update order) + if(g_Sessions[s].breakoutBarsAgo <= 1) + { + double obTop = 0, obBottom = 0; + IsOBNearLevel(orbLevel, dir, obTop, obBottom); + if(InpOBRequireConf && obTop == 0) continue; + OpenTrade(dir, s, orbLevel, obTop, obBottom); + } + continue; + } + + // --- MARKET mode --- + if(!InpWaitForRetest) + { + // Direct entry on the bar the breakout is confirmed (age 0 or 1) + if(g_Sessions[s].breakoutBarsAgo <= 1) + { + double obTop = 0, obBottom = 0; + IsOBNearLevel(orbLevel, dir, obTop, obBottom); + if(InpOBRequireConf && obTop == 0) continue; + OpenTrade(dir, s, orbLevel, obTop, obBottom); + } + } + else + { + // Retest entries fire tick-based in CheckRetestEntriesTick(). + // Here we only update state: sweep reversals and failed retest cancellation. + UpdateRetestState(s); + } + } +} + +//+------------------------------------------------------------------+ +//| Manage open positions: breakeven + trailing stop | +//+------------------------------------------------------------------+ +void ManageOpenTrades() +{ + for(int i = PositionsTotal() - 1; i >= 0; i--) + { + if(!g_Position.SelectByIndex(i)) continue; + if(g_Position.Magic() != InpMagicNumber) continue; + if(g_Position.Symbol() != g_Symbol) continue; + + double entry = g_Position.PriceOpen(); + double sl = g_Position.StopLoss(); + double currentSL= sl; + double price = g_Position.PriceCurrent(); + ulong ticket = g_Position.Ticket(); + int posDir = (g_Position.PositionType() == POSITION_TYPE_BUY) ? 1 : -1; + + // Breakeven + Partial Close + if(InpBreakevenTrigPips > 0) + { + double trigDist = PipsToPrice(InpBreakevenTrigPips); + double beDist = PipsToPrice(InpBreakevenOffPips); + double beLevel = (posDir > 0) ? entry + beDist : entry - beDist; + bool triggered = (posDir > 0) + ? (price >= entry + trigDist && sl < beLevel) + : (price <= entry - trigDist && (sl > beLevel || sl == 0)); + + if(triggered) + { + // Step 1: partial close — fire once per position + if(InpPartialCloseRatio > 0 && !IsPartialClosed(ticket)) + { + double fullLots = g_Position.Volume(); + double closeLots = NormalizeLot(fullLots * InpPartialCloseRatio); + double minLot = SymbolInfoDouble(g_Symbol, SYMBOL_VOLUME_MIN); + double stepLot = SymbolInfoDouble(g_Symbol, SYMBOL_VOLUME_STEP); + // Ensure we leave at least minLot remaining so the position stays open + double remaining = NormalizeLot(fullLots - closeLots); + if(closeLots >= minLot && remaining >= minLot) + { + PrintFormat("NANDR EA: Partial close trigger. Ticket=%llu Full=%.2f Ratio=%.2f Close=%.2f Remaining=%.2f Step=%.2f Min=%.2f", + ticket, fullLots, InpPartialCloseRatio, closeLots, remaining, stepLot, minLot); + ResetLastError(); + bool partResult = g_Trade.PositionClosePartial(ticket, closeLots); + if(partResult) + { + // Verify the same position volume actually decreased. + if(PositionSelectByTicket(ticket)) + { + double afterLots = PositionGetDouble(POSITION_VOLUME); + if(afterLots < fullLots) + { + MarkPartialClosed(ticket); + PrintFormat("NANDR EA: Partial close %.2f lots at %.2f (%.0f pips profit). Ticket=%llu Remaining=%.2f", + closeLots, price, InpBreakevenTrigPips, ticket, afterLots); + } + else + { + PrintFormat("NANDR EA: Partial close returned true but volume did not reduce. Ticket=%llu Before=%.2f After=%.2f", + ticket, fullLots, afterLots); + } + } + else + { + // If ticket no longer exists it may have been fully closed; do not mark partial as successful. + PrintFormat("NANDR EA: Partial close check could not reselect ticket %llu after close call.", ticket); + } + } + else + PrintFormat("NANDR EA: Partial close failed. Error=%d Ticket=%llu", + GetLastError(), ticket); + } + } + // Step 2: move SL to breakeven + currentSL = NormalizeDouble(beLevel, (int)SymbolInfoInteger(g_Symbol, SYMBOL_DIGITS)); + } + } + + // Trailing stop + if(InpTrailingStopPips > 0) + { + double trailDist = PipsToPrice(InpTrailingStopPips); + if(posDir > 0) + { + double newSL = price - trailDist; + if(newSL > currentSL) currentSL = NormalizeDouble(newSL, (int)SymbolInfoInteger(g_Symbol, SYMBOL_DIGITS)); + } + else + { + double newSL = price + trailDist; + if(currentSL == 0 || newSL < currentSL) + currentSL = NormalizeDouble(newSL, (int)SymbolInfoInteger(g_Symbol, SYMBOL_DIGITS)); + } + } + + if(currentSL != sl && currentSL > 0) + g_Trade.PositionModify(ticket, currentSL, g_Position.TakeProfit()); + } +} + +//+------------------------------------------------------------------+ +//| Daily reset logic | +//+------------------------------------------------------------------+ +void CheckDailyReset() +{ + datetime now = TimeCurrent(); + // The trading day is anchored to the 22:00 UTC Daily Open session. Shifting + // UTC time forward by 2h moves that anchor onto a UTC-midnight boundary, so + // comparing calendar days resets exactly at 22:00 UTC regardless of the + // broker server timezone. This prevents a server-midnight reset from + // truncating the Daily Open session only a couple of hours after it starts. + datetime tradingNow = (now - g_ServerUtcOffsetSec) + 2 * 3600; + datetime tradingLast = (g_LastDayReset - g_ServerUtcOffsetSec) + 2 * 3600; + MqlDateTime dt, dtLast; + TimeToStruct(tradingNow, dt); + TimeToStruct(tradingLast, dtLast); + + if(dt.day != dtLast.day || dt.mon != dtLast.mon || dt.year != dtLast.year) + { + g_LastDayReset = now; + g_TodayTrades = 0; + g_TodayWins = 0; + g_TodayLosses = 0; + g_TodayPnL = 0; + g_DayStartEquity = AccountInfoDouble(ACCOUNT_EQUITY); + g_TradingHalted = false; + g_GlobalBiasDir = 0; // clear day bias — first session breakout will re-establish it + g_PartialClosedCount = 0; // clear partial close tracker for the new day + + // Reset all session ORB data for the new day + for(int s = 0; s < g_SessionCount; s++) + g_Sessions[s].Reset(); + + // Reset OB traded flags so the same zones can re-trigger on the new day + for(int i = 0; i < g_BullOBCount; i++) g_BullOBs[i].traded = false; + for(int i = 0; i < g_BearOBCount; i++) g_BearOBs[i].traded = false; + + Print("NANDR EA: Daily reset. Equity=", g_DayStartEquity); + } +} + +//+------------------------------------------------------------------+ +//| Check risk limits | +//+------------------------------------------------------------------+ +void CheckRiskLimits() +{ + if(g_TradingHalted) return; + + double equity = AccountInfoDouble(ACCOUNT_EQUITY); + bool halt = false; + + if(InpMaxDailyLossUSD > 0 && (g_DayStartEquity - equity) >= InpMaxDailyLossUSD) + { + PrintFormat("NANDR EA: Max daily loss USD reached (%.2f). Trading halted.", InpMaxDailyLossUSD); + halt = true; + } + if(InpMaxDailyLossPct > 0 && g_DayStartEquity > 0) + { + double lossPct = (g_DayStartEquity - equity) / g_DayStartEquity * 100.0; + if(lossPct >= InpMaxDailyLossPct) + { + PrintFormat("NANDR EA: Max daily loss %% reached (%.2f%%). Trading halted.", InpMaxDailyLossPct); + halt = true; + } + } + + if(halt) + { + g_TradingHalted = true; + CancelAllPending(); + } +} + +//+------------------------------------------------------------------+ +//| Update PnL stats from closed trades | +//+------------------------------------------------------------------+ +void UpdateClosedTrades() +{ + // Scan deal history for today + datetime dayStart = iTime(g_Symbol, PERIOD_D1, 0); + HistorySelect(dayStart, TimeCurrent()); + + int deals = HistoryDealsTotal(); + double newPnL = 0; + int newWins = 0, newLosses = 0; + + for(int i = 0; i < deals; i++) + { + ulong ticket = HistoryDealGetTicket(i); + if(HistoryDealGetInteger(ticket, DEAL_MAGIC) != InpMagicNumber) continue; + if(HistoryDealGetString(ticket, DEAL_SYMBOL) != g_Symbol) continue; + if(HistoryDealGetInteger(ticket, DEAL_ENTRY) != DEAL_ENTRY_OUT) continue; + + double profit = HistoryDealGetDouble(ticket, DEAL_PROFIT) + + HistoryDealGetDouble(ticket, DEAL_SWAP) + + HistoryDealGetDouble(ticket, DEAL_COMMISSION); + newPnL += profit; + if(profit >= 0) newWins++; + else newLosses++; + } + + g_TodayPnL = newPnL; + g_TodayWins = newWins; + g_TodayLosses = newLosses; +} + +//+------------------------------------------------------------------+ +//| Draw ORB lines for a session | +//+------------------------------------------------------------------+ +void DrawORBLines(int sessIdx) +{ + if(sessIdx < 0 || sessIdx >= g_SessionCount) return; + + color lineColor = clrWhite; + if(g_Sessions[sessIdx].name == "Tokyo") lineColor = clrDodgerBlue; + else if(g_Sessions[sessIdx].name == "London") lineColor = clrTomato; + else if(g_Sessions[sessIdx].name == "NY" + || g_Sessions[sessIdx].name == "NYORB") lineColor = clrGold; + else if(g_Sessions[sessIdx].name == "DailyOpen") lineColor = clrSilver; + + string prefix = "NANDR_ORB_" + g_Sessions[sessIdx].name + "_"; + + // High line + string hName = prefix + "High"; + ObjectDelete(0, hName); + if(g_Sessions[sessIdx].orbHigh > 0) + { + ObjectCreate(0, hName, OBJ_HLINE, 0, 0, g_Sessions[sessIdx].orbHigh); + ObjectSetInteger(0, hName, OBJPROP_COLOR, lineColor); + ObjectSetInteger(0, hName, OBJPROP_STYLE, STYLE_SOLID); + ObjectSetInteger(0, hName, OBJPROP_WIDTH, 1); + ObjectSetString(0, hName, OBJPROP_TOOLTIP, + g_Sessions[sessIdx].name + " ORB High: " + + DoubleToString(g_Sessions[sessIdx].orbHigh, 2)); + } + + // Low line + string lName = prefix + "Low"; + ObjectDelete(0, lName); + if(g_Sessions[sessIdx].orbLow < DBL_MAX && g_Sessions[sessIdx].orbLow > 0) + { + ObjectCreate(0, lName, OBJ_HLINE, 0, 0, g_Sessions[sessIdx].orbLow); + ObjectSetInteger(0, lName, OBJPROP_COLOR, lineColor); + ObjectSetInteger(0, lName, OBJPROP_STYLE, STYLE_SOLID); + ObjectSetInteger(0, lName, OBJPROP_WIDTH, 1); + ObjectSetString(0, lName, OBJPROP_TOOLTIP, + g_Sessions[sessIdx].name + " ORB Low: " + + DoubleToString(g_Sessions[sessIdx].orbLow, 2)); + } + + // Mid line (dotted) + if(g_Sessions[sessIdx].orbHigh > 0 + && g_Sessions[sessIdx].orbLow < DBL_MAX && g_Sessions[sessIdx].orbLow > 0) + { + string mName = prefix + "Mid"; + ObjectDelete(0, mName); + double mid = (g_Sessions[sessIdx].orbHigh + g_Sessions[sessIdx].orbLow) / 2.0; + ObjectCreate(0, mName, OBJ_HLINE, 0, 0, mid); + ObjectSetInteger(0, mName, OBJPROP_COLOR, lineColor); + ObjectSetInteger(0, mName, OBJPROP_STYLE, STYLE_DOT); + ObjectSetInteger(0, mName, OBJPROP_WIDTH, 1); + } + + ChartRedraw(0); +} + +//+------------------------------------------------------------------+ +//| Draw OB zone rectangle | +//+------------------------------------------------------------------+ +void DrawOBZone(int idx, bool isBull) +{ + SOrderBlock ob; + if(isBull) ob = g_BullOBs[idx]; + else ob = g_BearOBs[idx]; + if(!ob.active) return; + + color bgColor = isBull ? (color)ColorToARGB(clrGreen, 40) : (color)ColorToARGB(clrRed, 40); + color borderColor = isBull ? clrGreen : clrRed; + + datetime t1 = ob.time; + datetime t2 = ob.time + PeriodSeconds(PERIOD_CURRENT) * 200; // extend right + + ObjectDelete(0, ob.objName); + ObjectCreate(0, ob.objName, OBJ_RECTANGLE, 0, t1, ob.top, t2, ob.bottom); + ObjectSetInteger(0, ob.objName, OBJPROP_COLOR, borderColor); + ObjectSetInteger(0, ob.objName, OBJPROP_BGCOLOR, isBull ? clrGreen : clrRed); + ObjectSetInteger(0, ob.objName, OBJPROP_FILL, true); + ObjectSetInteger(0, ob.objName, OBJPROP_BACK, true); + ObjectSetInteger(0, ob.objName, OBJPROP_STYLE, STYLE_SOLID); + ObjectSetInteger(0, ob.objName, OBJPROP_WIDTH, 1); + ObjectSetString(0, ob.objName, OBJPROP_TOOLTIP, + (isBull ? "Bull OB" : "Bear OB") + " [" + + DoubleToString(ob.bottom, 2) + " - " + DoubleToString(ob.top, 2) + "]"); + + // Mid line + string midName = ob.objName + "_mid"; + ObjectDelete(0, midName); + ObjectCreate(0, midName, OBJ_TREND, 0, t1, ob.mid, t2, ob.mid); + ObjectSetInteger(0, midName, OBJPROP_COLOR, isBull ? clrLimeGreen : clrOrangeRed); + ObjectSetInteger(0, midName, OBJPROP_STYLE, STYLE_DASH); + ObjectSetInteger(0, midName, OBJPROP_RAY_RIGHT, true); + + ChartRedraw(0); +} + +//+------------------------------------------------------------------+ +//| Remove OB zone objects | +//+------------------------------------------------------------------+ +void RemoveOBZone(const string name) +{ + ObjectDelete(0, name); + ObjectDelete(0, name + "_mid"); + ChartRedraw(0); +} + +//+------------------------------------------------------------------+ +//| Draw trade entry label | +//+------------------------------------------------------------------+ +void DrawTradeLabel(int dir, double entry, double sl, double tp) +{ + string name = "NANDR_Trade_" + IntegerToString(TimeCurrent()); + datetime t = iTime(g_Symbol, PERIOD_CURRENT, 0); + + ObjectCreate(0, name, OBJ_ARROW, 0, t, entry); + ObjectSetInteger(0, name, OBJPROP_ARROWCODE, (dir > 0) ? 233 : 234); + ObjectSetInteger(0, name, OBJPROP_COLOR, (dir > 0) ? clrLime : clrRed); + ObjectSetInteger(0, name, OBJPROP_WIDTH, 2); + + // SL line + string slName = name + "_SL"; + ObjectCreate(0, slName, OBJ_HLINE, 0, t, sl); + ObjectSetInteger(0, slName, OBJPROP_COLOR, clrRed); + ObjectSetInteger(0, slName, OBJPROP_STYLE, STYLE_DASH); + + // TP line + string tpName = name + "_TP"; + ObjectCreate(0, tpName, OBJ_HLINE, 0, t, tp); + ObjectSetInteger(0, tpName, OBJPROP_COLOR, clrLime); + ObjectSetInteger(0, tpName, OBJPROP_STYLE, STYLE_DASH); + + ChartRedraw(0); +} + +// Dashboard helper — module-level state set by DrawDashboard before each call +string g_DBPrefix = "NANDR_DB_"; +int g_DBX = 15; +int g_DBY = 30; +int g_DBDY = 18; +int g_DBLine = 0; + +void DBLine(string txt, color col, int fontSize = 9) +{ + string name = g_DBPrefix + IntegerToString(g_DBLine); + ObjectDelete(0, name); + ObjectCreate(0, name, OBJ_LABEL, 0, 0, 0); + ObjectSetInteger(0, name, OBJPROP_CORNER, CORNER_LEFT_UPPER); + ObjectSetInteger(0, name, OBJPROP_XDISTANCE, g_DBX); + ObjectSetInteger(0, name, OBJPROP_YDISTANCE, g_DBY + g_DBLine * g_DBDY); + ObjectSetInteger(0, name, OBJPROP_COLOR, col); + ObjectSetInteger(0, name, OBJPROP_FONTSIZE, fontSize); + ObjectSetString(0, name, OBJPROP_FONT, "Consolas"); + ObjectSetString(0, name, OBJPROP_TEXT, txt); + g_DBLine++; +} + +//+------------------------------------------------------------------+ +//| Draw dashboard | +//+------------------------------------------------------------------+ +void DrawDashboard() +{ + if(!InpShowDashboard) return; + + color title = clrGold; + color normal = clrSilver; + color good = clrLimeGreen; + color bad = clrTomato; + + g_DBPrefix = "NANDR_DB_"; + g_DBX = 15; + g_DBY = 30; + g_DBDY = 18; + g_DBLine = 0; + + DBLine("== NANDR ORB+OB EA ==", title, 10); + DBLine("-------------------------", normal); + DBLine(StringFormat("Symbol : %s TF: %s", g_Symbol, EnumToString(Period())), normal); + DBLine(StringFormat("OrbTF : M%d BarsNeeded: %d", (int)InpOrbTimeframe, g_OrbBarsNeeded), normal); + DBLine("-------------------------", normal); + + // Sessions + for(int s = 0; s < g_SessionCount; s++) + { + string status; + if(g_Sessions[s].orbComplete) + status = "ARMED"; + else if(g_Sessions[s].orbBarCount > 0) + status = "ACCUM"; + else + status = "WAIT "; + if(g_Sessions[s].inBreakout) + status = (g_Sessions[s].breakoutDir > 0 ? "BULL+" : "BEAR-"); + color sc = g_Sessions[s].orbComplete ? good : normal; + if(g_TradingHalted) sc = bad; + + DBLine(StringFormat("%-8s H:%.2f L:%.2f [%s]", + g_Sessions[s].name, + g_Sessions[s].orbHigh > 0 ? g_Sessions[s].orbHigh : 0, + g_Sessions[s].orbLow < DBL_MAX ? g_Sessions[s].orbLow : 0, + status), sc); + } + + DBLine("-------------------------", normal); + DBLine(StringFormat("BullOBs: %d BearOBs: %d", g_BullOBCount, g_BearOBCount), normal); + DBLine("-------------------------", normal); + + // Daily stats + double wr = (g_TodayWins + g_TodayLosses > 0) + ? (double)g_TodayWins / (g_TodayWins + g_TodayLosses) * 100.0 : 0; + DBLine(StringFormat("Trades : %d / %d", g_TodayTrades, InpMaxTradesPerDay), normal); + DBLine(StringFormat("W/L : %d / %d WR: %.0f%%", g_TodayWins, g_TodayLosses, wr), + g_TodayWins >= g_TodayLosses ? good : normal); + DBLine(StringFormat("Today PnL: %+.2f USD", g_TodayPnL), + g_TodayPnL >= 0 ? good : bad); + DBLine(StringFormat("Equity : %.2f", AccountInfoDouble(ACCOUNT_EQUITY)), normal); + + if(g_TradingHalted) + DBLine("! TRADING HALTED - Daily limit", bad, 10); + else if(g_SessionCount == 0) + DBLine("! No sessions enabled", bad); + + ChartRedraw(0); +} + +//+------------------------------------------------------------------+ +//| Remove all EA objects from chart | +//+------------------------------------------------------------------+ +void RemoveAllObjects() +{ + int total = ObjectsTotal(0, 0, -1); + for(int i = total - 1; i >= 0; i--) + { + string name = ObjectName(0, i, 0, -1); + if(StringFind(name, "NANDR_") >= 0) + ObjectDelete(0, name); + } + ChartRedraw(0); +} + +//+------------------------------------------------------------------+ +//| Reconstruct ORB levels from historical bars on startup | +//| Handles mid-day EA restarts — finds today's session open bars | +//| in price history and restores High/Low so sessions show ARMED. | +//+------------------------------------------------------------------+ +void ReconstructORBFromHistory() +{ + MqlDateTime dtNow; + TimeToStruct(TimeCurrent(), dtNow); + + for(int s = 0; s < g_SessionCount; s++) + { + if(!g_Sessions[s].enabled || g_Sessions[s].orbComplete) continue; + + // Build this session's open datetime for today + // Session start hours are UTC; convert to server time so iBarShift/iTime + // (which operate in server time) locate the correct opening bar. + MqlDateTime dtSess = dtNow; + dtSess.hour = g_Sessions[s].startHour; + dtSess.min = g_Sessions[s].startMin; + dtSess.sec = 0; + datetime sessOpenTime = StructToTime(dtSess) + g_ServerUtcOffsetSec; + + // Only reconstruct if the opening bar has fully closed + long barSecs = (long)g_OrbBarsNeeded * (int)InpOrbTimeframe * 60; + if(TimeCurrent() < sessOpenTime + barSecs) continue; + + // Find the bar index for this session open time + int barIdx = iBarShift(g_Symbol, PERIOD_CURRENT, sessOpenTime, false); + if(barIdx < 1) continue; // bar[0] is the live bar + + // Verify exact time match (session bar must exist in chart history) + if(iTime(g_Symbol, PERIOD_CURRENT, barIdx) != sessOpenTime) continue; + + // Accumulate ORB range across all required bars (M1: 15 bars, M5: 3, M15: 1) + double h = iHigh(g_Symbol, PERIOD_CURRENT, barIdx); + double l = iLow(g_Symbol, PERIOD_CURRENT, barIdx); + for(int b = 1; b < g_OrbBarsNeeded; b++) + { + int idx = barIdx - b; + if(idx < 1) break; + h = MathMax(h, iHigh(g_Symbol, PERIOD_CURRENT, idx)); + l = MathMin(l, iLow(g_Symbol, PERIOD_CURRENT, idx)); + } + + g_Sessions[s].orbHigh = h; + g_Sessions[s].orbLow = l; + g_Sessions[s].orbBarCount = g_OrbBarsNeeded; + g_Sessions[s].orbStartTime = sessOpenTime; + g_Sessions[s].orbComplete = true; + + PrintFormat("NANDR EA: [%s] ORB reconstructed from history. High=%.2f Low=%.2f", + g_Sessions[s].name, h, l); + if(InpShowORBLines) DrawORBLines(s); + } +} + +//+------------------------------------------------------------------+ +//| Sync daily state from deal history on startup/restart | +//| Restores g_DayStartEquity so the daily loss limit persists across| +//| EA restarts, and syncs g_TodayTrades to prevent double-counting. | +//+------------------------------------------------------------------+ +void SyncDailyState() +{ + datetime dayStart = iTime(g_Symbol, PERIOD_D1, 0); + if(dayStart == 0) return; + HistorySelect(dayStart, TimeCurrent()); + + int deals = HistoryDealsTotal(); + double closedPnL = 0; + int tradeOpens = 0; + + for(int i = 0; i < deals; i++) + { + ulong ticket = HistoryDealGetTicket(i); + if(HistoryDealGetInteger(ticket, DEAL_MAGIC) != InpMagicNumber) continue; + if(HistoryDealGetString(ticket, DEAL_SYMBOL) != g_Symbol) continue; + + long entry = HistoryDealGetInteger(ticket, DEAL_ENTRY); + if(entry == DEAL_ENTRY_IN) + tradeOpens++; + if(entry == DEAL_ENTRY_OUT) + closedPnL += HistoryDealGetDouble(ticket, DEAL_PROFIT) + + HistoryDealGetDouble(ticket, DEAL_SWAP) + + HistoryDealGetDouble(ticket, DEAL_COMMISSION); + } + + // Reconstruct day-start equity: current balance minus today's already-realized P&L. + // This ensures the daily loss limit check works correctly after a mid-day restart. + double currentBalance = AccountInfoDouble(ACCOUNT_BALANCE); + g_DayStartEquity = currentBalance - closedPnL; + g_TodayTrades = tradeOpens; + + PrintFormat("NANDR EA: Daily state synced. DayStartEquity=%.2f TodayTrades=%d ClosedPnL=%.2f", + g_DayStartEquity, g_TodayTrades, closedPnL); +} + +//+------------------------------------------------------------------+ +//| OnInit | +//+------------------------------------------------------------------+ +int OnInit() +{ + // Resolve symbol + g_Symbol = ResolveSymbol(InpSymbolName); + + // Validate timeframe + ENUM_TIMEFRAMES chartTF = Period(); + int chartMins = (int)(PeriodSeconds(chartTF) / 60); + if(chartMins != (int)InpOrbTimeframe) + { + PrintFormat("NANDR EA: WARNING — Chart TF is M%d but ORB Timeframe input is M%d. " + "Attach EA to M%d chart for best results.", + chartMins, (int)InpOrbTimeframe, (int)InpOrbTimeframe); + } + + // Bars needed for 15min ORB at current TF + switch(InpOrbTimeframe) + { + case ORB_TF_M1: g_OrbBarsNeeded = 15; break; + case ORB_TF_M5: g_OrbBarsNeeded = 3; break; + case ORB_TF_M15: g_OrbBarsNeeded = 1; break; + default: g_OrbBarsNeeded = 1; break; + } + + // Breakout confirmation bars + if(InpBreakoutConfBars > 0) + g_BreakoutConfBars = InpBreakoutConfBars; + else + { + switch(InpOrbTimeframe) + { + case ORB_TF_M1: g_BreakoutConfBars = 5; break; + case ORB_TF_M5: g_BreakoutConfBars = 3; break; + case ORB_TF_M15: g_BreakoutConfBars = 2; break; + default: g_BreakoutConfBars = 2; break; + } + } + + // OB pivot length + if(InpOBPivotLength > 0) + g_OBPivotLength = InpOBPivotLength; + else + { + switch(InpOrbTimeframe) + { + case ORB_TF_M1: g_OBPivotLength = 10; break; + default: g_OBPivotLength = 5; break; + } + } + + // Pip size: 1 pip = 10 points for all standard decimal counts (2, 3, 5). + // digits==2 (XAUUSD): point=0.01 → pip=0.10 → 100 pips = $10 price distance + // digits==3 (USDJPY): point=0.001 → pip=0.01 + // digits==5 (EURUSD): point=0.00001 → pip=0.0001 + // BUG FIX: digits==2 previously used g_PointSize directly (0.01) making 100 pips = $1.00, + // causing a near-zero SL and massively oversized lot on XAUUSD. + double tickSize = SymbolInfoDouble(g_Symbol, SYMBOL_TRADE_TICK_SIZE); + int digits = (int)SymbolInfoInteger(g_Symbol, SYMBOL_DIGITS); + g_PointSize = SymbolInfoDouble(g_Symbol, SYMBOL_POINT); + g_PipSize = (digits == 2 || digits == 3 || digits == 5) ? g_PointSize * 10 : g_PointSize; + g_ServerUtcOffsetSec = (int)(TimeCurrent() - TimeGMT()); + PrintFormat("NANDR EA: PipSize=%.5f (digits=%d). 100 pips = %.2f price units.", + g_PipSize, digits, PipsToPrice(100)); + PrintFormat("NANDR EA: Server UTC offset = %+d seconds (%+.1f hours)", + g_ServerUtcOffsetSec, g_ServerUtcOffsetSec / 3600.0); + + // Initialize OB arrays + ArrayResize(g_BullOBs, InpOBMaxCount); + ArrayResize(g_BearOBs, InpOBMaxCount); + g_BullOBCount = 0; + g_BearOBCount = 0; + + // ATR indicator + g_ATRHandle = iATR(g_Symbol, PERIOD_CURRENT, InpATRPeriod); + if(g_ATRHandle == INVALID_HANDLE) + Print("NANDR EA: WARNING — could not create ATR indicator handle."); + + // Trade object + g_Trade.SetExpertMagicNumber(InpMagicNumber); + g_Trade.SetDeviationInPoints(20); + g_Trade.SetTypeFilling(ORDER_FILLING_IOC); + + // Init sessions + InitSessions(); + if(g_SessionCount == 0) + Print("NANDR EA: WARNING — No sessions enabled. EA will not trade. Enable at least one session."); + + // Daily init — set conservative defaults; SyncDailyState() will correct them from history + g_DayStartEquity = AccountInfoDouble(ACCOUNT_EQUITY); + g_LastDayReset = TimeCurrent(); + + // Sync trade count and day-start equity from today's deal history + // (handles mid-day restarts so daily loss limits carry over correctly) + SyncDailyState(); + + // Restore ORB levels from historical bars (handles mid-day restarts + // where the session opening bars have already closed before EA loaded) + ReconstructORBFromHistory(); + + PrintFormat("NANDR EA: Initialized. Symbol=%s OrbBarsNeeded=%d BreakoutConf=%d OBPivot=%d Sessions=%d", + g_Symbol, g_OrbBarsNeeded, g_BreakoutConfBars, g_OBPivotLength, g_SessionCount); + + DrawDashboard(); + return INIT_SUCCEEDED; +} + +//+------------------------------------------------------------------+ +//| OnDeinit | +//+------------------------------------------------------------------+ +void OnDeinit(const int reason) +{ + RemoveAllObjects(); + if(g_ATRHandle != INVALID_HANDLE) + IndicatorRelease(g_ATRHandle); + Comment(""); +} + +//+------------------------------------------------------------------+ +//| Returns true if current server time is Saturday or Sunday | +//+------------------------------------------------------------------+ +bool IsWeekend() +{ + MqlDateTime dt; + TimeToStruct(TimeCurrent(), dt); + return (dt.day_of_week == 0 || dt.day_of_week == 6); // 0=Sunday, 6=Saturday +} + +//+------------------------------------------------------------------+ +//| OnTick | +//+------------------------------------------------------------------+ +void OnTick() +{ + // Silence all processing during weekend market closure + if(IsWeekend()) return; + + // New bar detection + datetime barTime = iTime(g_Symbol, PERIOD_CURRENT, 0); + g_IsNewBar = (barTime != g_LastBarTime); + if(g_IsNewBar) + g_LastBarTime = barTime; + + // Reset day/session state before any tick-based entry logic runs. + if(g_IsNewBar) + CheckDailyReset(); + + // Per-tick management + ManageOpenTrades(); + RefreshBreakoutEntryLocks(); + CheckRiskLimits(); + + // Retest entries fire on tick (at wick touch), not on bar close + CheckRetestEntriesTick(); + + if(!g_IsNewBar) return; + + // Always update ORB and OB state regardless of halt status. + // This keeps session levels current for display and ensures breakouts are detected + // as soon as trading resumes (e.g. next day after a daily-loss halt). + UpdateORBSessions(); + ScanOrderBlocks(); + MitigateOrderBlocks(); + DetectBreakouts(); + UpdateClosedTrades(); + DrawDashboard(); + + // Skip trade execution only when halted — state tracking continues above + if(g_TradingHalted) + return; + + // Entry signals (direct breakout / limit orders on new bar) + CheckEntrySignals(); +} + +//+------------------------------------------------------------------+ +//| OnTradeTransaction — handle position close events | +//+------------------------------------------------------------------+ +void OnTradeTransaction(const MqlTradeTransaction &trans, + const MqlTradeRequest &request, + const MqlTradeResult &result) +{ + if(trans.type == TRADE_TRANSACTION_DEAL_ADD) + { + ulong ticket = trans.deal; + if(HistoryDealSelect(ticket)) + { + if(HistoryDealGetInteger(ticket, DEAL_MAGIC) == InpMagicNumber && + HistoryDealGetString(ticket, DEAL_SYMBOL) == g_Symbol && + HistoryDealGetInteger(ticket, DEAL_ENTRY) == DEAL_ENTRY_OUT) + { + double profit = HistoryDealGetDouble(ticket, DEAL_PROFIT) + + HistoryDealGetDouble(ticket, DEAL_SWAP) + + HistoryDealGetDouble(ticket, DEAL_COMMISSION); + g_TotalPnL += profit; + PrintFormat("NANDR EA: Trade closed. Profit=%.2f TotalPnL=%.2f", profit, g_TotalPnL); + } + } + } +} +//+------------------------------------------------------------------+ diff --git a/README.md b/README.md index 537c7b9..fa63817 100644 --- a/README.md +++ b/README.md @@ -11,10 +11,13 @@ mt5_backtest/ │ ├── ini_generator.py # 生成MT5配置文件 │ ├── mt5_auto_runner.py # 自动运行回测 │ ├── batch_executor.py # 批量执行器 -│ ├── result_parser.py # 解析HTM/XML报告 -│ └── report_generator.py # 生成汇总报告 +│ ├── result_parser.py # 解析HTM/XML报告 +│ ├── report_generator.py # 生成汇总报告 +│ ├── symbol_resolver.py # 根品种别名解析(XAUUSD→XAUUSDc) +│ └── optimizer/ # EA参数优化系统 ├── config/ # 配置文件目录 ├── reports/ # 回测报告输出目录 +├── tests/ # 自检脚本(test_*.py / smoke_*.py) ├── logs/ # 日志目录 └── README.md # 本文档 ``` @@ -26,6 +29,7 @@ mt5_backtest/ - 扫描 MT5 的 `MQL5/Experts` 目录获取 EA 列表 - 根据配置的 EA品种、时间周期、参数组合生成 INI 配置文件 - 每个 INI 文件对应一次回测任务 +- 写入 INI 的 `Symbol=` 行通过 `scripts/symbol_resolver.py` 查表替换(见下文"根品种别名映射") ### 2. 自动执行阶段 (mt5_auto_runner.py) - 通过命令行调用 MT5 终端: `terminal64.exe /config:xxx.ini` @@ -79,6 +83,10 @@ eas: backtest_settings: symbols: ["EURUSD", "GBPUSD", "XAUUSD", "USDJPY"] timeframes: ["M1", "M5", "M15", "H1"] + # 可选:根品种别名映射(见下文"根品种别名映射") + # symbol_aliases: + # XAUUSD: XAUUSDc # 逻辑 XAUUSD -> 实际 XAUUSDc + # XAGUSD: XAGUSDm # 逻辑 XAGUSD -> 实际 XAGUSDm date_range: from: "2026.01.01" to: "2026.01.04" @@ -147,6 +155,66 @@ backtest_settings: | 原油 | XBRUSD, XTIUSD | | 数字货币 | BTCUSD, ETHUSD, LTCUSD | +## 根品种别名映射 (`symbol_aliases`) + +MT5 自带品种(如 `XAUUSD`)历史深度和精度有限;用户常在 MT5 里添加自定义品种 +(如 `XAUUSDc` / `XAUUSDm`)作为更长时间范围或优化用途的"替身"。本系统通过 `symbol_aliases` +让你在 yaml 里写一次逻辑品种名、由系统按表替换成实际 MT5 品种。 + +### 写入 `config/ea_configs.yaml` + +```yaml +backtest_settings: + symbols: [XAUUSD, XAGUSD, EURUSD] # 用户的"逻辑品种" + symbol_aliases: + XAUUSD: XAUUSDc # 逻辑 XAUUSD -> 实际 XAUUSDc + XAGUSD: XAGUSDm # 逻辑 XAGUSD -> 实际 XAGUSDm + # EURUSD 不列 -> 始终 EURUSD +``` + +### 工作机制 + +1. `scripts/ini_generator.py` 和 `scripts/optimizer/ea_batch_optimizer.py` 在 + 写 INI 的 `Symbol=` 之前调用 `scripts/symbol_resolver.resolve_symbol()`。 +2. 解析器是**纯查表**——没有任何"基于日期/优化/周期的智能判断"——写啥用啥: + - 别名表里有这个逻辑品种 → 用别名替换 + - 没有 → 原样写入 `Symbol=` +3. 两条调用路径共用同一份解析器(`scripts/symbol_resolver.py`),保证 GUI 回测页 + 和优化器生成的 INI 行为一致。 + +### GUI 优化页 + +GUI 优化页下拉只显示**逻辑品种**(不再同时列出 XAUUSD / XAUUSDc)。旁边有一个 +`→ XAUUSDc` 提示标签,**实时反映 yaml 里 `symbol_aliases` 当前配置下实际会用什么品种**。 +改 yaml 后重启 GUI 即可看到提示变化,无需在 GUI 里反复切换。 + +### 验证 + +无需 MT5 也能验: + +```bash +python tests/test_symbol_resolver.py # 9 条查表断言 +python tests/test_ini_generator.py # 5 条端到端断言 +``` + +需要 MT5 验证完整流水线: + +```bash +python tests/smoke_alias_backtest.py +# 跑两次真实回测:一次无别名 (Symbol=XAUUSD),一次有别名 (Symbol=XAUUSDc) +# 期望:C:\Users\Administrator\Desktop\mt5-backtest\reports 下生成两份 .htm 报告 +# 报告"交易品种"字段分别是 XAUUSD 和 XAUUSDc +``` + +> 注:`smoke_alias_backtest.py` 顶部硬编码了 MT5 路径和 data dir hash,复制到其他电脑后 +> 需要相应修改 `MT5_PATH` / `MT5_DATA_DIR` 常量。 + +### 扩展点(ponytail) + +`scripts/symbol_resolver.py` 是共享函数入口,将来要把"配置源"切换成数据库或要做 +MT5 `SymbolSelect` 校验,**只改这一个文件**即可,所有调用方(ini 生成器、批量优化器、 +GUI 预览)同步生效。 + ## 如何添加新的时间周期 编辑 `config/ea_configs.yaml`: @@ -537,6 +605,7 @@ mt5_backtest/ │ ├── batch_executor.py # 批量执行器 │ ├── result_parser.py # 解析HTM/XML报告 │ ├── report_generator.py # 生成汇总报告 +│ ├── symbol_resolver.py # 根品种别名解析(symbol_aliases 查表) │ └── optimizer/ # EA优化系统 │ ├── ea_config_parser.py # EA配置解析 │ ├── smart_search.py # 搜索策略 @@ -545,6 +614,7 @@ mt5_backtest/ │ └── ea_batch_optimizer.py # 批量优化器 ├── config/ # 配置文件 ├── reports/ # 回测报告目录 +├── tests/ # 自检脚本(test_symbol_resolver / test_ini_generator / smoke_alias_backtest) ├── optimizer/ # 优化系统目录 │ ├── configs/ # EA配置 │ ├── set_files/ # SET文件 @@ -566,6 +636,19 @@ mt5_backtest/ ### Q: 中文显示乱码 HTM 报告采用 UTF-16-LE 编码,result_parser.py 会自动处理,无需手动转换 +### Q: 想用 MT5 自定义品种(如 XAUUSDc)跑同一 EA +在 `config/ea_configs.yaml` 的 `backtest_settings` 下加: +```yaml +symbol_aliases: + XAUUSD: XAUUSDc +``` +yaml 符号表里仍写 `XAUUSD`,系统会自动把 INI 的 `Symbol=` 替换为 `XAUUSDc`。 +详见上文"根品种别名映射"一节。 + +### Q: 改了 `symbol_aliases` 后 GUI 提示没变 +GUI 启动时读取一次 `config/ea_configs.yaml`,改完后重启 GUI 即可看到 +`→ XAUUSDc` 提示标签更新。无需重新生成报告历史。 + ## 许可证 MIT License \ No newline at end of file diff --git a/config/ea_configs.yaml b/config/ea_configs.yaml index 45aec16..28fb55d 100644 --- a/config/ea_configs.yaml +++ b/config/ea_configs.yaml @@ -14,10 +14,46 @@ backtest_settings: shutdown_terminal: true symbols: - EURUSD + - GBPUSD + - USDJPY + - USDCHF + - EURJPY + - EURGBP + - GBPJPY - XAUUSD timeframes: - H1 visual: 0 + + # ---- 根品种别名映射(可选,留空/删除整块 = 关闭此功能,行为与传统一致)---- + # 纯查表,没有任何"自动判断"。逻辑品种写在上面的 symbols 列表里, + # 想让系统实际用哪个 MT5 品种就在这里写死。 + # + # 用法:取消下面想启用的那一行(或多行)的注释即可。 + # 同一时间只能有一条 XAUUSD 映射生效;切换另一种后缀时把上一条注释掉。 + # 注意:GUI 的"保存配置"不会动这块内容(symbol_aliases 在 GUI 里没有编辑控件), + # 改完直接保存即可,存盘不会被覆盖。GUI 重启后提示标签自动更新。 + # symbol_aliases: + # XAUUSD: XAUUSDc # 常用:自定义品种 XAUUSDc(带扩展历史 / 精度) + # XAUUSD: XAUUSDm # 切换:自定义品种 XAUUSDm + # XAUUSD: Gold.v2 # 切换:完全不同的名字(broker 自定义 / ECN 后缀等) + # XAUUSD: XAUUSD # 显式直通:等价于不写这条(写出来仅为自解释) + # XAGUSD: XAGUSDc # 银同理 + # XAGUSD: XAGUSDm + # BTCUSD: BTCUSD.sb # 加密货币自定义后缀 + # # EURUSD 没列 # 永远不需要列:未列出的逻辑品种按原名直传 + # + # 关闭整个功能:把整块(包括 symbol_aliases: 这行)注释掉或删除。 + # 关闭后 GUI "实际品种"提示行自动消失,INI 里的 Symbol= 与逻辑名完全一致。 + symbol_aliases: + EURUSD: EURUSDc + GBPUSD: GBPUSDc + USDJPY: USDJPYc + USDCHF: USDCHFc + EURJPY: EURJPYc + EURGBP: EURGBPc + GBPJPY: GBPJPYc + XAUUSD: XAUUSDc eas: [] execution: kill_between: true diff --git a/config/generated_smoke/no_alias/MACD_MA_XAUUSD_5M_XAUUSD_H1_default.ini b/config/generated_smoke/no_alias/MACD_MA_XAUUSD_5M_XAUUSD_H1_default.ini new file mode 100644 index 0000000..de3537d Binary files /dev/null and b/config/generated_smoke/no_alias/MACD_MA_XAUUSD_5M_XAUUSD_H1_default.ini differ diff --git a/config/generated_smoke/with_alias/MACD_MA_XAUUSD_5M_XAUUSDc_H1_default.ini b/config/generated_smoke/with_alias/MACD_MA_XAUUSD_5M_XAUUSDc_H1_default.ini new file mode 100644 index 0000000..2c8c330 Binary files /dev/null and b/config/generated_smoke/with_alias/MACD_MA_XAUUSD_5M_XAUUSDc_H1_default.ini differ diff --git a/gui.py b/gui.py index d4ff7b1..6fb25dd 100644 --- a/gui.py +++ b/gui.py @@ -21,6 +21,7 @@ try: from mt5_auto_runner import MT5AutoRunner from result_parser import ResultParser from report_generator import ReportGenerator + from symbol_resolver import resolve_symbol except Exception as _e: _IMPORT_ERRORS.append(repr(_e)) sys.stderr.write(f"[IMPORT FAIL] {_e}\n") @@ -29,6 +30,7 @@ except Exception as _e: MT5AutoRunner = None # type: ignore ResultParser = None # type: ignore ReportGenerator = None # type: ignore + resolve_symbol = None # type: ignore def _gui_log(msg: str) -> None: @@ -66,6 +68,18 @@ def _mt5_startupinfo(): return si +# MT5 策略测试器回测模式(与 MT5 官方界面"每个报价/1分钟OHLC/仅开盘价/数学计算/基于每个真实报价"一致) +# 值是写入 INI 的 Model= 编号。批量页和优化页共用本列表,切换显示文字时只改这一处。 +MT5_MODELS = [ + '每个报价 (0)', + '1分钟OHLC (1)', + '仅开盘价 (2)', + '数学计算 (3)', + '基于每个真实报价 (4)', +] +MT5_MODEL_MAP = {int(s.split('(')[1].rstrip(')')): s for s in MT5_MODELS} + + def _find_mt5_hwnds(pid, max_seconds=3.0): hwnds = [] deadline = time.time() + max_seconds @@ -401,6 +415,13 @@ class MT5BacktestGUI: cb = ttk.Checkbutton(sym_frame, text=sym, variable=var) cb.grid(row=i//10, column=i%10, sticky='w', padx=2, pady=1) self.symbol_vars[sym] = var + var.trace_add('write', lambda *_: self._update_batch_symbol_hint()) + + self.batch_sym_hint_var = tk.StringVar(value="") + ttk.Label(sym_frame, textvariable=self.batch_sym_hint_var, + foreground='#0066cc', wraplength=900, justify='left' + ).grid(row=99, column=0, columnspan=10, sticky='w', padx=2, pady=(4, 2)) + self._update_batch_symbol_hint() tf_frame = ttk.LabelFrame(main_frame, text="时间周期") tf_frame.pack(fill='x', padx=5, pady=5) @@ -450,21 +471,32 @@ class MT5BacktestGUI: model_frame.pack(side='left', fill='x', padx=5) self.model_var = tk.IntVar(value=self.config["backtest_settings"]["model"]) - ttk.Radiobutton(model_frame, text="Every Tick (0)", variable=self.model_var, value=0).pack(anchor='w', padx=2) - ttk.Radiobutton(model_frame, text="1分钟OHLC (1)", variable=self.model_var, value=1).pack(anchor='w', padx=2) - ttk.Radiobutton(model_frame, text="真实Tick (4)", variable=self.model_var, value=4).pack(anchor='w', padx=2) + for m in MT5_MODELS: + val = int(m.split('(')[1].rstrip(')')) + ttk.Radiobutton(model_frame, text=m, variable=self.model_var, value=val).pack(anchor='w', padx=2) delay_frame = ttk.LabelFrame(date_model_frame, text="执行延迟") delay_frame.pack(side='left', fill='x', padx=5) - self.delay_type = tk.IntVar(value=0) + saved_delay = int((self.config.get("backtest_settings") or {}).get("execution_delay", 0) or 0) + if saved_delay == 0: + delay_type_value = 0 + delay_value_text = "100" + elif saved_delay == -1: + delay_type_value = 1 + delay_value_text = "100" + else: + delay_type_value = 2 + delay_value_text = str(saved_delay) + + self.delay_type = tk.IntVar(value=delay_type_value) ttk.Radiobutton(delay_frame, text="无延迟 (0)", variable=self.delay_type, value=0).pack(anchor='w', padx=2) ttk.Radiobutton(delay_frame, text="随机延迟 (-1)", variable=self.delay_type, value=1).pack(anchor='w', padx=2) delay_row = ttk.Frame(delay_frame) delay_row.pack() ttk.Radiobutton(delay_row, text="固定延迟:", variable=self.delay_type, value=2).pack(side='left', padx=2) self.delay_value = ttk.Entry(delay_row, width=6) - self.delay_value.insert(0, "100") + self.delay_value.insert(0, delay_value_text) self.delay_value.pack(side='left') ttk.Label(delay_row, text="ms").pack(side='left') @@ -645,7 +677,9 @@ class MT5BacktestGUI: "shutdown_terminal": True, "optimization": 0, "forward_mode": forward_mode, - "forward_date": forward_date + "forward_date": forward_date, + # symbol_aliases 在 GUI 里没有编辑控件,直接从内存配置透传(避免保存时把它清掉) + "symbol_aliases": (self.config.get("backtest_settings") or {}).get("symbol_aliases") or {}, }, "mt5_settings": { "terminal_path": self.mt5_path.get(), @@ -1469,18 +1503,21 @@ class MT5BacktestGUI: sym_row = ttk.Frame(test_frame) sym_row.pack(fill='x', padx=5, pady=3) - ttk.Label(sym_row, text="品种:").pack(side='left', padx=5) + ttk.Label(sym_row, text="逻辑品种:").pack(side='left', padx=5) self.opt_symbol = ttk.Combobox(sym_row, width=12, values=['EURUSD', 'GBPUSD', 'USDJPY', 'USDCAD', 'USDCHF', 'AUDUSD', 'NZDUSD', 'EURGBP', 'EURJPY', 'GBPJPY', 'EURCHF', 'AUDJPY', 'EURAUD', 'GBPAUD', 'EURCAD', 'GBPCAD', 'XAUUSD', 'XAGUSD', 'XBRUSD', 'XTIUSD', 'BTCUSD', 'ETHUSD', 'LTCUSD', 'USDCNH', 'USDHKD', 'USDCZK', 'USDDKK', 'USDHUF', 'USDMXN', 'USDPLN', 'USDTHB', 'NOKJPY', 'SEKJPY', 'XRPUSD', 'XLMUSD', 'AUS200', 'CHINA50', 'ES35', 'STOXX50', 'F40', 'HK50', 'IT40', 'JP225', 'UK100', 'US2000', 'US30', 'US500', 'USTEC', 'CA60', 'NETH25', 'SE30', 'SWI20', 'CHINAH', 'NOR25', 'TecDE30']) self.opt_symbol.set('XAUUSD') self.opt_symbol.pack(side='left', padx=5) + self.opt_symbol_resolved = ttk.Label(sym_row, text="→ XAUUSD", foreground='#666666') + self.opt_symbol_resolved.pack(side='left', padx=(10, 2)) + ttk.Label(sym_row, text="周期:").pack(side='left', padx=10) self.opt_period = ttk.Combobox(sym_row, width=8, values=['M1', 'M5', 'M15', 'M30', 'H1', 'H4', 'D1', 'W1']) self.opt_period.set('M1') self.opt_period.pack(side='left', padx=5) ttk.Label(sym_row, text="模式:").pack(side='left', padx=10) - self.opt_model = ttk.Combobox(sym_row, width=18, values=['Every Tick (0)', '1分钟OHLC (1)', '仅开盘价 (2)', '数学计算 (3)', '真实Tick (4)']) + self.opt_model = ttk.Combobox(sym_row, width=20, values=MT5_MODELS) self.opt_model.set('1分钟OHLC (1)') self.opt_model.pack(side='left', padx=5) @@ -1521,6 +1558,9 @@ class MT5BacktestGUI: self.opt_to_date.insert(0, "2026.01.04") self.opt_to_date.pack(side='left', padx=5) + self.opt_symbol.bind('<>', lambda e: self._update_resolved_symbol_hint()) + self.opt_symbol.bind('', lambda e: self._update_resolved_symbol_hint()) + acct_row = ttk.Frame(test_frame) acct_row.pack(fill='x', padx=5, pady=3) ttk.Label(acct_row, text="保证金:").pack(side='left', padx=5) @@ -1574,6 +1614,7 @@ class MT5BacktestGUI: self.opt_log.pack(fill='both', expand=True, padx=5, pady=5) self._refresh_opt_ea_list() + self._update_resolved_symbol_hint() def _browse_opt_dir(self): path = filedialog.askdirectory(initialdir=self.opt_dir.get()) @@ -1736,18 +1777,12 @@ class MT5BacktestGUI: if test_cfg.get('symbol'): self.opt_symbol.set(str(test_cfg.get('symbol'))) + self._update_resolved_symbol_hint() if test_cfg.get('period'): self.opt_period.set(str(test_cfg.get('period'))) if 'model' in test_cfg: model_val = int(test_cfg.get('model', 1)) - model_map = { - 0: 'Every Tick (0)', - 1: '1分钟OHLC (1)', - 2: '仅开盘价 (2)', - 3: '数学计算 (3)', - 4: '真实Tick (4)', - } - self.opt_model.set(model_map.get(model_val, '1分钟OHLC (1)')) + self.opt_model.set(MT5_MODEL_MAP.get(model_val, '1分钟OHLC (1)')) if test_cfg.get('from_date'): self.opt_from_date.delete(0, tk.END) self.opt_from_date.insert(0, str(test_cfg.get('from_date'))) @@ -1861,6 +1896,53 @@ class MT5BacktestGUI: mode_val = self.wf_mode_map.get(self.wf_mode.get(), 2) if enabled else 0 self.wf_custom_date.configure(state='normal' if (enabled and mode_val == 4) else 'disabled') + def _format_resolved_hint(self, symbols): + """Format a hint string showing logical -> resolved symbol mapping. + + Shared by batch backtest tab (multi-select) and optimizer tab (single). + Returns "" when nothing is selected or no aliases are configured. + """ + if not symbols: + return "" + aliases = (self.config.get('backtest_settings') or {}).get('symbol_aliases') or {} + if not aliases: + return "" + parts = [] + for sym in symbols: + if not sym: + continue + resolved = resolve_symbol(sym, {'symbol_aliases': aliases}) if resolve_symbol else sym + if resolved == sym: + parts.append(f"{sym}(直通)") + else: + parts.append(f"{sym}→{resolved}") + return "实际品种:" + ", ".join(parts) if parts else "" + + def _update_batch_symbol_hint(self): + if not hasattr(self, 'batch_sym_hint_var'): + return + checked = [s for s, v in self.symbol_vars.items() if v.get()] + self.batch_sym_hint_var.set(self._format_resolved_hint(checked)) + + def _resolve_test_config(self): + """Build the minimal dict the resolver needs (only symbol_aliases).""" + return { + 'symbol_aliases': (self.config.get('backtest_settings') or {}).get('symbol_aliases') or {}, + } + + def _update_resolved_symbol_hint(self): + if not hasattr(self, 'opt_symbol_resolved') or resolve_symbol is None: + return + try: + sym = self.opt_symbol.get().split()[0] if ' ' in self.opt_symbol.get() else self.opt_symbol.get() + resolved = resolve_symbol(sym, self._resolve_test_config()) + except Exception: + resolved = self.opt_symbol.get() + if resolved == sym: + self.opt_symbol_resolved.config(text=f"→ {resolved}", foreground='#666666') + else: + self.opt_symbol_resolved.config(text=f"→ {resolved} (别名)", foreground='#0066cc') + def _save_ea_config(self, quiet=False): ea_name = self.opt_ea_var.get() if not ea_name: @@ -1895,39 +1977,39 @@ class MT5BacktestGUI: 'step': step_val, 'optimize': poptimize == 'Y' if poptimize else True } - import json - config_path = os.path.join(configs_dir, ea_name + '.json') - config_data = { + import json + config_path = os.path.join(configs_dir, ea_name + '.json') + config_data = { 'ea_name': ea_name, 'ea_path': self.opt_ea_paths.get(ea_name, f"MY-EA\\{ea_name}.ex5"), 'description': 'Edited via GUI', - 'search_strategy': 'auto', - 'parameters': params, - 'test_config': { + 'search_strategy': 'auto', + 'parameters': params, +'test_config': { 'symbol': self.opt_symbol.get().split()[0] if ' ' in self.opt_symbol.get() else self.opt_symbol.get(), 'period': self.opt_period.get(), 'from_date': self.opt_from_date.get(), 'to_date': self.opt_to_date.get(), - 'model': int(self.opt_model.get().split('(')[1].split(')')[0]) if '(' in self.opt_model.get() else 1, - 'execution_delay': self._get_opt_execution_delay(), - 'optimization_mode': self._get_opt_optimization_mode(), - 'optimization_criterion': self._get_opt_optimization_criterion(), - 'deposit': int(self.opt_deposit.get()) if self.opt_deposit.get().isdigit() else 10000, - 'leverage': self.opt_leverage.get(), - 'timeout_min': int(self.opt_timeout_min.get()) if self.opt_timeout_min.get().isdigit() else 60 - }, - 'walk_forward': { - 'enabled': self.wf_enabled.get(), - 'forward_mode': wf_mode_val, - 'forward_date': wf_forward_date if (self.wf_enabled.get() and wf_mode_val == 4) else '' - } + 'model': int(self.opt_model.get().split('(')[1].split(')')[0]) if '(' in self.opt_model.get() else 1, + 'execution_delay': self._get_opt_execution_delay(), + 'optimization_mode': self._get_opt_optimization_mode(), + 'optimization_criterion': self._get_opt_optimization_criterion(), + 'deposit': int(self.opt_deposit.get()) if self.opt_deposit.get().isdigit() else 10000, + 'leverage': self.opt_leverage.get(), + 'timeout_min': int(self.opt_timeout_min.get()) if self.opt_timeout_min.get().isdigit() else 60 + }, + 'walk_forward': { + 'enabled': self.wf_enabled.get(), + 'forward_mode': wf_mode_val, + 'forward_date': wf_forward_date if (self.wf_enabled.get() and wf_mode_val == 4) else '' + } } with open(config_path, 'w', encoding='utf-8') as f: json.dump(config_data, f, indent=4, ensure_ascii=False) - self.opt_log.insert('end', f"配置已保存: {config_path}\n") - if not quiet: - self.opt_log.update() - return True + self.opt_log.insert('end', f"配置已保存: {config_path}\n") + if not quiet: + self.opt_log.update() + return True def _get_opt_execution_delay(self): delay_type = self.opt_delay_type.get() diff --git a/logs/smoke_alias.log b/logs/smoke_alias.log new file mode 100644 index 0000000..f1c6d02 --- /dev/null +++ b/logs/smoke_alias.log @@ -0,0 +1,28 @@ +2026-07-06 20:26:24,598 - INFO - Executing: MACD_MA_XAUUSD_5M_XAUUSD_H1_default.ini +2026-07-06 20:26:24,620 - INFO - MT5 started PID: 18668 +2026-07-06 20:27:22,638 - INFO - MT5 process ended +2026-07-06 20:27:22,638 - INFO - Report copied to: C:\Users\Administrator\Desktop\mt5-backtest\reports\MACD_MA_XAUUSD_5M_XAUUSD_H1_default.htm +2026-07-06 20:27:22,638 - INFO - Success: MACD_MA_XAUUSD_5M_XAUUSD_H1_default.ini (58s) +2026-07-06 20:27:22,644 - INFO - Executing: MACD_MA_XAUUSD_5M_XAUUSDc_H1_default.ini +2026-07-06 20:27:22,670 - INFO - MT5 started PID: 21372 +2026-07-06 20:27:48,676 - INFO - MT5 process ended +2026-07-06 20:27:48,679 - INFO - Report copied to: C:\Users\Administrator\Desktop\mt5-backtest\reports\MACD_MA_XAUUSD_5M_XAUUSDc_H1_default.htm +2026-07-06 20:27:48,679 - INFO - Success: MACD_MA_XAUUSD_5M_XAUUSDc_H1_default.ini (26s) +=== run 1: no alias (should pick XAUUSD) === +[no_alias] INI Symbol line: Symbol=XAUUSD +[no_alias] runner status: completed, report_path: C:\Users\Administrator\Desktop\mt5-backtest\reports\MACD_MA_XAUUSD_5M_XAUUSD_H1_default.htm + +=== run 2: alias XAUUSD -> XAUUSDc (should pick XAUUSDc) === +[with_alias] INI Symbol line: Symbol=XAUUSDc +[with_alias] runner status: completed, report_path: C:\Users\Administrator\Desktop\mt5-backtest\reports\MACD_MA_XAUUSD_5M_XAUUSDc_H1_default.htm + +=== summary === + INI1 (MACD_MA_XAUUSD_5M_XAUUSD_H1_default.ini) -> completed, C:\Users\Administrator\Desktop\mt5-backtest\reports\MACD_MA_XAUUSD_5M_XAUUSD_H1_default.htm + INI2 (MACD_MA_XAUUSD_5M_XAUUSDc_H1_default.ini) -> completed, C:\Users\Administrator\Desktop\mt5-backtest\reports\MACD_MA_XAUUSD_5M_XAUUSDc_H1_default.htm + +Reports in C:\Users\Administrator\Desktop\mt5-backtest\reports: + - MACD_MA_XAUUSD_5M_XAUUSD_H1_default.htm (52680 bytes) symbol=XAUUSD + - MACD_MA_XAUUSD_5M_XAUUSDc_H1_default.htm (28932 bytes) symbol=XAUUSDc + +Distinct symbols found: ['XAUUSD', 'XAUUSDc'] +SUCCESS: both XAUUSD and XAUUSDc reports present in target dir. diff --git a/scripts/ini_generator.py b/scripts/ini_generator.py index 0fbe87f..e81b766 100644 --- a/scripts/ini_generator.py +++ b/scripts/ini_generator.py @@ -7,6 +7,7 @@ from typing import Dict, List, Any, Optional sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) from mt5_paths import resolve_mt5_settings +from symbol_resolver import resolve_symbol class INIGenerator: @@ -115,12 +116,16 @@ class INIGenerator: else: ini_lines.append("ExpertParameters=") + # MT5 tester delay is controlled by ExecutionMode itself: + # 0 = no delay + # -1 = random delay + # > 0 = fixed delay in milliseconds + execution_delay = int(bt_settings.get('execution_delay', 0) or 0) ini_lines.extend([ f"Symbol={symbol}", f"Period={self._get_timeframe_code(timeframe)}", f"Model={bt_settings['model']}", - f"ExecutionMode={bt_settings.get('execution_mode', 0)}", - f"ExecutionDelay={bt_settings.get('execution_delay', 0)}", + f"ExecutionMode={execution_delay}", f"Optimization={bt_settings['optimization']}", ]) if bt_settings.get("optimization"): @@ -184,14 +189,15 @@ class INIGenerator: param_combinations = self._generate_parameter_combinations(parameters) for symbol in bt_settings["symbols"]: + resolved_symbol = resolve_symbol(symbol, bt_settings) for timeframe in bt_settings["timeframes"]: for param_combo in param_combinations: ini_content = self._build_ini_content( - ea_filename, symbol, timeframe, + ea_filename, resolved_symbol, timeframe, bt_settings, param_combo, ea_name, set_file ) ini_filename = self._generate_filename( - ea_name, symbol, timeframe, param_combo + ea_name, resolved_symbol, timeframe, param_combo ) ini_path = os.path.join(self.output_dir, ini_filename) @@ -253,4 +259,4 @@ def main(): if __name__ == "__main__": - main() \ No newline at end of file + main() diff --git a/scripts/mt5_auto_runner.py b/scripts/mt5_auto_runner.py index 0fff17e..fa17f11 100644 --- a/scripts/mt5_auto_runner.py +++ b/scripts/mt5_auto_runner.py @@ -225,6 +225,13 @@ class MT5AutoRunner: logger.info(f"Total INI files: {total}") + # First run must also start from a clean tester state. Otherwise the first test + # can inherit an already-open terminal's previous deposit/delay/symbol settings, + # while later tests look correct only because kill_between_tests runs after them. + if kill_between_tests: + self._kill_mt5() + time.sleep(2) + for idx, ini_path in enumerate(ini_files, 1): logger.info(f"[{idx}/{total}] {os.path.basename(ini_path)}") @@ -352,4 +359,4 @@ def main(): if __name__ == "__main__": - main() \ No newline at end of file + main() diff --git a/scripts/optimizer/ea_batch_optimizer.py b/scripts/optimizer/ea_batch_optimizer.py index 3d8b252..6cd71ed 100644 --- a/scripts/optimizer/ea_batch_optimizer.py +++ b/scripts/optimizer/ea_batch_optimizer.py @@ -13,6 +13,7 @@ from ea_config_parser import load_ea_config, load_all_ea_configs, EAConfig from parameter_constraint import create_constraint_engine from smart_search import SmartSearch from analyze_results import ResultAnalyzer +from symbol_resolver import resolve_symbol class EABatchOptimizer: @@ -157,7 +158,7 @@ class EABatchOptimizer: '[Tester]', 'Expert=' + ea_config.ea_path, 'ExpertParameters=' + set_filename, - 'Symbol=' + test_config.get('symbol', 'EURUSD'), + 'Symbol=' + resolve_symbol(test_config.get('symbol', 'EURUSD'), test_config), 'Period=' + test_config.get('period', 'H1'), 'Model=' + str(test_config.get('model', 1)), 'FromDate=' + date_from, diff --git a/scripts/symbol_resolver.py b/scripts/symbol_resolver.py new file mode 100644 index 0000000..1c810ea --- /dev/null +++ b/scripts/symbol_resolver.py @@ -0,0 +1,28 @@ +"""Root symbol alias resolver (pure lookup, no auto-switching). + +用户视角的"逻辑品种"(如 XAUUSD)映射到 MT5 里实际使用的品种。 + +ponytail: + - 没有别名表 / 没条目 -> 原样返回(零行为变化,向后兼容) + - 有条目 -> 一比一映射,写啥用啥,不做任何基于日期/优化/周期的判断 + - 升级路径:要从 yaml 切到数据库 / MT5 SymbolSelect 校验,只改这里 + +配置示例(ea_configs.yaml): + symbol_aliases: + XAUUSD: XAUUSDc # 逻辑 XAUUSD -> 实际 XAUUSDc + XAGUSD: XAGUSDm # 逻辑 XAGUSD -> 实际 XAGUSDm + # EURUSD 没列 -> 始终 EURUSD +""" + +from typing import Any, Dict + + +def resolve_symbol(symbol: str, bt_settings: Dict[str, Any]) -> str: + """逻辑品种 -> 实际 MT5 品种。无任何自动判断,按表直查。""" + if not symbol: + return symbol + aliases = bt_settings.get("symbol_aliases") or {} + mapped = aliases.get(symbol) + if not mapped: # None / "" / 缺失 -> 直通 + return symbol + return mapped \ No newline at end of file diff --git a/tests/smoke_alias_backtest.py b/tests/smoke_alias_backtest.py new file mode 100644 index 0000000..5cd27c3 --- /dev/null +++ b/tests/smoke_alias_backtest.py @@ -0,0 +1,143 @@ +# Real backtest smoke test for the symbol alias resolver. +# Runs two backtests via the project's pipeline (INIGenerator + MT5AutoRunner): +# 1) XAUUSD (no alias) -> INI Symbol=XAUUSD +# 2) XAUUSD (alias -> XAUUSDc) -> INI Symbol=XAUUSDc +# Success: two .htm reports in C:\Users\Administrator\Desktop\mt5-backtest\reports, +# one with Symbol=XAUUSD, one with Symbol=XAUUSDc. + +import os +import sys +import re +import shutil + +HERE = os.path.dirname(os.path.abspath(__file__)) +PROJECT_ROOT = os.path.normpath(os.path.join(HERE, "..")) +SCRIPTS = os.path.join(PROJECT_ROOT, "scripts") +EXAMPLES_ROOT = os.path.normpath(os.path.join(PROJECT_ROOT, "..", "examples")) + +TARGET_REPORTS = r"C:\Users\Administrator\Desktop\mt5-backtest\reports" +MT5_PATH = r"C:\Program Files\MetaTrader 5 IC Markets Global\terminal64.exe" +MT5_DATA_DIR = r"C:\Users\Administrator\AppData\Roaming\MetaQuotes\Terminal\010E047102812FC0C18890992854220E" +MT5_TESTER_DIR = os.path.join(MT5_DATA_DIR, "MQL5", "Profiles", "Tester") + +EA_NAME = "MACD_MA_XAUUSD_5M" +EA_BASENAME = f"{EA_NAME}.ex5" +EA_FULL_PATH = os.path.join(MT5_DATA_DIR, "MQL5", "Experts", EA_BASENAME) +SET_SRC = os.path.join(EXAMPLES_ROOT, "configs", f"{EA_NAME}.set") +SET_DEST_NAME = "MACD_MA_XAUUSD_5M_smoke.set" +SET_DEST_PATH = os.path.join(MT5_TESTER_DIR, SET_DEST_NAME) + +os.makedirs(TARGET_REPORTS, exist_ok=True) +shutil.copy2(SET_SRC, SET_DEST_PATH) + +sys.path.insert(0, SCRIPTS) +from ini_generator import INIGenerator +from mt5_auto_runner import MT5AutoRunner + + +def build_cfg(aliases, out_ini_dir): + return { + "backtest_settings": { + "currency": "USD", + "date_range": {"from": "2026.05.01", "to": "2026.06.05"}, + "deposit": 10000, + "execution_delay": 0, + "forward_date": "", + "forward_mode": 0, + "leverage": "1:100", + "model": 0, + "optimization": 0, + "replace_report": True, + "shutdown_terminal": True, + "symbols": ["XAUUSD"], + "timeframes": ["H1"], + "visual": 0, + "symbol_aliases": aliases or {}, + }, + "eas": [ + {"name": EA_NAME, "filename": EA_FULL_PATH, "set_file": SET_DEST_NAME}, + ], + "mt5_settings": { + "terminal_path": MT5_PATH, + "data_dir": MT5_DATA_DIR, + "ini_dir": out_ini_dir, + "reports_dir": TARGET_REPORTS, + }, + } + + +def patch_ini_to_inline(ini_path): + """Patch the INI to use relative Expert + inline [TesterInputs] + (the format MT5 actually auto-starts with on this terminal).""" + with open(ini_path, encoding="utf-8") as f: + text = f.read() + text = text.replace(f"Expert={EA_FULL_PATH}", f"Expert={EA_BASENAME}") + text = text.replace(f"ExpertParameters={SET_DEST_NAME}", "ExpertParameters=") + with open(SET_DEST_PATH, encoding="utf-8", errors="ignore") as f: + set_text = f.read() + if not text.rstrip().endswith("[TesterInputs]"): + text = text.rstrip() + "\n\n[TesterInputs]\n" + set_text + with open(ini_path, "w", encoding="utf-8") as f: + f.write(text) + + +def run_one(label, aliases, out_dir): + cfg = build_cfg(aliases=aliases, out_ini_dir=out_dir) + gen = INIGenerator(cfg) + ini_files = gen.generate_ini_files() + assert len(ini_files) == 1, f"{label}: expected 1 INI, got {len(ini_files)}" + ini = ini_files[0] + patch_ini_to_inline(ini) + with open(ini, encoding="utf-8") as f: + sym_line = next(l for l in f if l.startswith("Symbol=")) + print(f"[{label}] INI Symbol line: {sym_line.strip()}") + + runner = MT5AutoRunner(cfg) + res = runner._execute_single_ini(ini, timeout_min=15) + print(f"[{label}] runner status: {res['status']}, report_path: {res.get('report_path')}") + return ini, res + + +def main(): + tmp_root = os.path.join(PROJECT_ROOT, "config", "generated_smoke") + if os.path.exists(tmp_root): + shutil.rmtree(tmp_root) + os.makedirs(tmp_root, exist_ok=True) + out1 = os.path.join(tmp_root, "no_alias"); os.makedirs(out1, exist_ok=True) + out2 = os.path.join(tmp_root, "with_alias"); os.makedirs(out2, exist_ok=True) + + print("=== run 1: no alias (should pick XAUUSD) ===") + ini1, r1 = run_one("no_alias", aliases=None, out_dir=out1) + print() + print("=== run 2: alias XAUUSD -> XAUUSDc (should pick XAUUSDc) ===") + ini2, r2 = run_one("with_alias", aliases={"XAUUSD": "XAUUSDc"}, out_dir=out2) + print() + + print("=== summary ===") + print(f" INI1 ({os.path.basename(ini1)}) -> {r1['status']}, {r1.get('report_path')}") + print(f" INI2 ({os.path.basename(ini2)}) -> {r2['status']}, {r2.get('report_path')}") + + reports = sorted([f for f in os.listdir(TARGET_REPORTS) + if f.startswith('MACD_MA_XAUUSD_5M_') and f.endswith('.htm')]) + print(f"\nReports in {TARGET_REPORTS}:") + found = {"XAUUSD": [], "XAUUSDc": []} + for r in reports: + path = os.path.join(TARGET_REPORTS, r) + size = os.path.getsize(path) + # MT5 saves reports as UTF-16 LE with BOM + text = open(path, encoding='utf-16', errors='ignore').read() + m = re.search(r'交易品种:\s*]*>([^<]+)', text) + sym = m.group(1) if m else "?" + found[sym].append(r) + print(f" - {r} ({size} bytes) symbol={sym}") + + print(f"\nDistinct symbols found: {sorted({k for k, v in found.items() if v})}") + if not found["XAUUSD"]: + raise AssertionError("No report with Symbol=XAUUSD") + if not found["XAUUSDc"]: + raise AssertionError("No report with Symbol=XAUUSDc") + print("SUCCESS: both XAUUSD and XAUUSDc reports present in target dir.") + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/tests/test_ini_generator.py b/tests/test_ini_generator.py new file mode 100644 index 0000000..f90a38d --- /dev/null +++ b/tests/test_ini_generator.py @@ -0,0 +1,96 @@ +"""End-to-end: INIGenerator picks the right MT5 Symbol from explicit alias map. + +Run: python tests/test_ini_generator.py +""" + +import os +import sys +import tempfile + +HERE = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, os.path.normpath(os.path.join(HERE, "..", "scripts"))) + +from ini_generator import INIGenerator + + +def _make_cfg(aliases, symbols, out_dir): + return { + "mt5_settings": {"terminal_path": "", "data_dir": "", + "reports_dir": "{PROJECT_ROOT}/reports", "ini_dir": out_dir}, + "backtest_settings": { + "currency": "USD", + "date_range": {"from": "2010.01.01", "to": "2024.12.31"}, + "deposit": 10000, "execution_delay": 0, "forward_date": "", + "forward_mode": 0, "leverage": "1:100", "model": 0, + "optimization": 0, "replace_report": True, "shutdown_terminal": True, + "symbols": symbols, "timeframes": ["H1"], "visual": 0, + "symbol_aliases": aliases, + }, + "eas": [{"name": "DummyEA", "filename": "MQL5/Experts/DummyEA.ex5", "parameters": {}}], + } + + +def _grep_symbol(ini_path, target): + with open(ini_path, "r", encoding="utf-8") as f: + for line in f: + if line.startswith("Symbol="): + return line.strip().split("=", 1)[1] == target + return False + + +def _get_ini_value(ini_path, key): + with open(ini_path, "r", encoding="utf-8") as f: + for line in f: + if line.startswith(f"{key}="): + return line.strip().split("=", 1)[1] + return None + + +def expect(label, ok): + print(f" {'PASS' if ok else 'FAIL'} {label}") + if not ok: + raise AssertionError(label) + + +def main(): + with tempfile.TemporaryDirectory() as tmp: + cfg = _make_cfg({"XAUUSD": "XAUUSDc"}, ["XAUUSD", "EURUSD"], tmp) + files = INIGenerator(cfg).generate_ini_files() + expect("2 files", len(files) == 2) + expect("XAUUSD alias applied", any(_grep_symbol(f, "XAUUSDc") for f in files)) + expect("EURUSD passes through", any(_grep_symbol(f, "EURUSD") for f in files)) + + with tempfile.TemporaryDirectory() as tmp: + cfg = _make_cfg({}, ["XAUUSD"], tmp) + files = INIGenerator(cfg).generate_ini_files() + expect("no alias -> bare", any(_grep_symbol(f, "XAUUSD") for f in files)) + + with tempfile.TemporaryDirectory() as tmp: + cfg = _make_cfg({"XAUUSD": "XAUUSDm"}, ["XAUUSD"], tmp) + files = INIGenerator(cfg).generate_ini_files() + expect("explicit override wins", any(_grep_symbol(f, "XAUUSDm") for f in files)) + + with tempfile.TemporaryDirectory() as tmp: + cfg = _make_cfg({}, ["XAUUSD"], tmp) + cfg["backtest_settings"]["execution_delay"] = 0 + files = INIGenerator(cfg).generate_ini_files() + expect("delay=0 -> ExecutionMode=0", _get_ini_value(files[0], "ExecutionMode") == "0") + expect("delay=0 -> no ExecutionDelay line", _get_ini_value(files[0], "ExecutionDelay") is None) + + with tempfile.TemporaryDirectory() as tmp: + cfg = _make_cfg({}, ["XAUUSD"], tmp) + cfg["backtest_settings"]["execution_delay"] = -1 + files = INIGenerator(cfg).generate_ini_files() + expect("delay=-1 -> ExecutionMode=-1", _get_ini_value(files[0], "ExecutionMode") == "-1") + + with tempfile.TemporaryDirectory() as tmp: + cfg = _make_cfg({}, ["XAUUSD"], tmp) + cfg["backtest_settings"]["execution_delay"] = 250 + files = INIGenerator(cfg).generate_ini_files() + expect("delay=250 -> ExecutionMode=250", _get_ini_value(files[0], "ExecutionMode") == "250") + + print("\nAll INI generator alias checks passed.") + + +if __name__ == "__main__": + main() diff --git a/tests/test_symbol_resolver.py b/tests/test_symbol_resolver.py new file mode 100644 index 0000000..798da35 --- /dev/null +++ b/tests/test_symbol_resolver.py @@ -0,0 +1,60 @@ +"""Symbol resolver self-check (pure lookup, no auto logic). + +Run: python tests/test_symbol_resolver.py +""" + +import os +import sys + +HERE = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, os.path.normpath(os.path.join(HERE, "..", "scripts"))) + +from symbol_resolver import resolve_symbol + + +def _bt(aliases=None): + return {"symbol_aliases": aliases or {}} + + +def expect(label, got, want): + ok = got == want + print(f" {'PASS' if ok else 'FAIL'} {label}: got={got!r} want={want!r}") + if not ok: + raise AssertionError(label) + + +def main(): + print("[1] has alias -> use alias") + expect("XAUUSD -> XAUUSDc", resolve_symbol("XAUUSD", _bt({"XAUUSD": "XAUUSDc"})), "XAUUSDc") + + print("[2] has alias for another symbol -> that one passes through") + expect("EURUSD no alias", resolve_symbol("EURUSD", _bt({"XAUUSD": "XAUUSDc"})), "EURUSD") + + print("[3] no aliases config -> bare (back-compat)") + expect("no config", resolve_symbol("XAUUSD", _bt()), "XAUUSD") + + print("[4] alias value can be any string (suffix, full name, even identical)") + expect("alias = c suffix", resolve_symbol("XAUUSD", _bt({"XAUUSD": "c"})), "c") + + print("[5] empty symbol -> empty") + expect("empty", resolve_symbol("", _bt({"": "XAUUSDc"})), "") + + print("[6] aliases value None -> bare") + expect("None value", resolve_symbol("XAUUSD", _bt({"XAUUSD": None})), "XAUUSD") + + print("[7] symbol_aliases missing key entirely -> bare") + expect("missing key", resolve_symbol("XAUUSD", {"date_range": {"from": "2020.01.01", "to": "2024.01.01"}}), "XAUUSD") + + print("[8] long date span does NOT auto-switch (explicit override only)") + expect("long span no alias", resolve_symbol("XAUUSD", {"date_range": {"from": "2010.01.01", "to": "2024.01.01"}}), "XAUUSD") + + print("[9] explicit alias always wins regardless of date/optimization context") + ctx = {"date_range": {"from": "2010.01.01", "to": "2024.01.01"}, + "optimization": 1, "symbol_aliases": {"XAUUSD": "XAUUSDm"}} + expect("explicit + opt", resolve_symbol("XAUUSD", ctx), "XAUUSDm") + + print("\nAll symbol resolver checks passed.") + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/tests/verify_reports.py b/tests/verify_reports.py new file mode 100644 index 0000000..dcadd5a --- /dev/null +++ b/tests/verify_reports.py @@ -0,0 +1,16 @@ +# Verify the two backtest reports. +import os, re + +TARGET = r"C:\Users\Administrator\Desktop\mt5-backtest\reports" +reports = sorted([f for f in os.listdir(TARGET) if f.startswith('MACD_MA_XAUUSD_5M_') and f.endswith('.htm')]) + +print(f"Found {len(reports)} candidate reports:") +for r in reports: + p = os.path.join(TARGET, r) + size = os.path.getsize(p) + text = open(p, encoding='utf-8', errors='ignore').read() + m = re.search(r'交易品种:\s*]*>([^<]+)', text) + sym = m.group(1) if m else "(not found)" + has_xauusd = 'XAUUSD' in text + has_xauusdc = 'XAUUSDc' in text + print(f" {r} size={size} symbol_in_report={sym} contains_XAUUSD={has_xauusd} contains_XAUUSDc={has_xauusdc}") \ No newline at end of file