//+------------------------------------------------------------------+
//|                                                   LSMS_EA_V1.2.1.mq5 |
//|   Liquidity Sweep + Market Structure Shift (LSMS)                 |
//|   Pure Price Action / Market Structure EA — NO LAGGING INDICATORS |
//|                                                                    |
//|   Core flow:                                                      |
//|   Liquidity Level -> Sweep -> Rejection -> Displacement ->         |
//|   MSS/BOS (closed-candle confirmed) -> Entry -> SL/TP structural   |
//|                                                                    |
//|   NO RSI / MACD / MA-EMA-SMA / ADX / ATR / Bollinger / Oscillator  |
//|   NO look-ahead. NO repaint. NO martingale. NO grid.               |
//|                                                                    |
//|   V1.2.1: Adaptive Small-Equity Risk Engine — minimum-lot fallback |
//|   with actual-monetary-risk cap. Core LSMS logic unchanged from    |
//|   V1.2. Structural SL is never modified by the risk engine.        |
//+------------------------------------------------------------------+
#property copyright "LSMS EA"
#property version   "1.21"
#property strict

#include <Trade\Trade.mqh>

//====================================================================
// INPUT PARAMETERS  (kept minimal per anti-overfitting audit)
//====================================================================
input group "=== Core Structure Parameters ==="
input int    InpSwingK              = 2;     // Swing confirmation candles (left/right)
input int    InpAvgRangeN           = 20;    // Period for Average Range calculation

input group "=== Liquidity / Sweep Parameters ==="
input int    InpMinPenetrationPts   = 30;    // Min penetration beyond level (points)
input double InpMinWickRatio        = 0.33;  // Min wick/range ratio for rejection
input int    InpEqTolerancePts      = 20;    // Equal High/Low tolerance (points)
input int    InpLiquidityLookback   = 100;   // Bars lookback to search swing liquidity levels

input group "=== Displacement Parameters ==="
input double InpDisplacementFactor  = 1.5;   // Displacement range >= factor x AvgRange
input double InpMinBodyRatio        = 0.6;   // Min body/range ratio for displacement
input bool   InpAllowTwoCandleDisplacement = false; // V1.2.2: allow 2-candle displacement fallback when 1-candle fails (same thresholds, no loosening)
input bool   InpEnableDisplacementDebug    = false; // V1.2.2: verbose [DISPLACEMENT DEBUG] journal logging
input int    InpMaxWaitBars         = 20;     // Max bars to wait for displacement+MSS after sweep

input group "=== BOS/MSS Parameters ==="
input int    InpMinBOSPenetrationPts= 10;    // Min points close must break swing point

input group "=== SL / TP Parameters ==="
input double InpSLBufferFactor      = 0.15;  // SL buffer = factor x AvgRange
input double InpMinRR               = 1.2;   // Minimum reward:risk to accept TP

input group "=== Regime Filter (optional) ==="
input bool   InpUseRegimeFilter     = true;  // Enable regime filtering
input int    InpRegimeSwingCount    = 2;     // Min swing legs to establish trend
input int    InpChoppyCHOCHCount    = 2;     // CHOCH-like legs (of InpRegimeSwingCount) within lookback to positively flag CHOPPY
input bool   InpEnableRegimeDebug   = false; // Verbose [REGIME REJECT]/[REGIME DEBUG] journal logging

input group "=== Session Filter (optional) ==="
input bool   InpUseSessionFilter    = false; // Restrict trading to sessions below
input bool   InpTradeAsia           = true;
input bool   InpTradeLondon         = true;
input bool   InpTradeNY             = true;
// Server-time session hours (adjust to broker GMT offset)
input int    InpAsiaStartHour       = 0;
input int    InpAsiaEndHour         = 8;
input int    InpLondonStartHour     = 8;
input int    InpLondonEndHour       = 16;
input int    InpNYStartHour         = 13;
input int    InpNYEndHour           = 21;

input group "=== Risk Management ==="
input double InpRiskPercent         = 0.5;   // Risk per trade (% of equity) — TARGET risk
input double InpMaxDailyLossPercent = 3.0;   // Max daily loss (%)
input int    InpMaxConsecLosses     = 3;     // Max consecutive losses before cooldown
input int    InpCooldownBars        = 20;    // Cooldown bars after hitting max consec losses
input int    InpMaxOpenPositions    = 1;     // Max concurrent open positions (this EA)

input group "=== V1.2.1: Adaptive Small-Equity Risk Engine ==="
input bool   InpUseMinimumLotFallback  = true; // Allow broker-minimum-lot fallback when calculated lot < min
input double InpMaxFallbackRiskPercent = 1.0;  // Max ACTUAL risk% allowed when using fallback minimum lot
input bool   InpEnableRiskDebug        = true; // Verbose [RISK BLOCK]/[RISK FALLBACK] journal logging

input group "=== Execution / Protection ==="
input double InpMaxSpreadPoints     = 50;    // Safe-fallback max spread (points) — used until adaptive baseline has enough samples, and as absolute floor
input ulong  InpMagicNumber         = 20260801;
input int    InpSlippagePoints      = 20;
input bool   InpEnableTPDebug       = false; // Verbose [TP CANDIDATES] journal logging (V1.2.2)

input group "=== V1.2.1: Auto-Adaptive Spread Filter ==="
input bool   InpUseAdaptiveSpread   = true;  // Enable rolling-median adaptive spread filter (recommended for both Tester and Live)
input int    InpSpreadSampleSize    = 200;   // Rolling sample size (ticks/bars) used to compute TypicalSpread (median)
input int    InpSpreadMinSamples    = 30;    // Minimum samples required before adaptive baseline is trusted; below this, safe fallback (InpMaxSpreadPoints) is used
input double InpSpreadAdaptiveMult  = 3.0;   // AdaptiveLimit = TypicalSpread x this multiplier
input double InpHardMaxSpreadPoints = 400;   // Absolute safety ceiling (points) — never allow a trade above this regardless of adaptive baseline
input bool   InpEnableSpreadDebug   = false; // Verbose [SPREAD AUTO] journal logging (Environment/Digits/Point/Bid/Ask/Current/Typical/AdaptiveLimit/HardMax/Decision)

input group "=== V1.2: Timezone / GMT Architecture ==="
input bool   InpUseAutoGMT          = true;  // Auto-detect broker GMT offset (recommended)
input int    InpManualBrokerGMT     = 0;     // Manual broker GMT offset (used if auto fails/disabled)
input int    InpWIBOffsetHours      = 7;     // WIB = UTC+7 (fixed, do not change)

input group "=== V1.2: Trading Window (WIB) ==="
input bool   InpUseTradingWindow    = true;  // Enable 06:00-22:00 WIB entry window
input int    InpTradingStartHourWIB = 6;     // Entry re-enabled (WIB hour)
input int    InpTradingEndHourWIB   = 22;    // Entry lock starts (WIB hour)
input bool   InpClosePositionsAtCutoff = false; // Force-close open positions at 22:00 WIB (default: false)

input group "=== V1.2: Profit Lock (R-based) ==="
input bool   InpEnableProfitLock    = true;  // Enable break-even style profit lock
input double InpProfitLockTriggerR  = 0.50;  // Trigger profit lock at +X R
input double InpProfitLockR         = 0.10;  // Lock SL at +X R

input group "=== V1.2: R-Step Trailing ==="
input bool   InpEnableRStepTrailing = true;  // Enable staged R-based trailing
input double InpTrailStep1TriggerR  = 1.00;
input double InpTrailStep1LockR     = 0.50;
input double InpTrailStep2TriggerR  = 1.50;
input double InpTrailStep2LockR     = 1.00;
input double InpTrailStep3TriggerR  = 2.00;
input double InpTrailStep3LockR     = 1.50;
input double InpTrailStep4TriggerR  = 3.00;
input double InpTrailStep4LockR     = 2.00;

input group "=== V1.2: Notifications ==="
input bool   InpEnableNotifications = true;
input bool   InpNotifyOnAttach      = true;
input bool   InpNotifyOnDailyOpen   = true;
input bool   InpNotifyOnEntry       = true;
input bool   InpNotifyOnTP          = true;
input bool   InpNotifyOnSL          = true;
input bool   InpNotifyOnTrailing    = true;
input bool   InpNotifyOnDailyClose  = true;

input group "=== V1.2: Recovery ==="
input bool   InpAllowSafeModeTrading= false;  // If true, existing-position management continues even in SAFE RECOVERY MODE (new entries always stay blocked in SAFE MODE regardless)


input bool   InpDebugMode           = true;  // Print debug logs

//====================================================================
// GLOBALS
//====================================================================
CTrade trade;

//====================================================================
// V1.2 GLOBALS — Time Architecture
//====================================================================
int      g_detectedBrokerGMT   = 0;      // auto-detected broker GMT offset (hours from UTC)
bool     g_gmtDetectionValid   = false;  // true if auto detection succeeded this session
bool     g_timeSafeMode        = false;  // true = block time-dependent entries (unreliable GMT)
datetime g_lastGMTLogTime      = 0;      // throttle repeated GMT warnings

//====================================================================
// V1.2 GLOBALS — Trading Window State Machine
//====================================================================
enum TradingMode
{
   MODE_ACTIVE_TRADING,   // 06:00-21:59 WIB: entries + management + trailing all active
   MODE_SCAN_ONLY         // 22:00-05:59 WIB: entries locked, management/trailing still active
};
TradingMode g_tradingMode        = MODE_ACTIVE_TRADING;
bool        g_entryAllowed       = true;
bool        g_dailyOpenNotified  = false;   // 06:00 notification sent for current WIB day
bool        g_dailyCloseNotified = false;   // 22:00/daily-close notification sent for current WIB day
datetime    g_currentWIBDay      = 0;       // WIB calendar day stamp (00:00 WIB, expressed as broker-time datetime for GVs)

//====================================================================
// V1.2 GLOBALS — Daily Statistics (persisted, WIB-day scoped)
//====================================================================
int    g_dailyTrades = 0;
int    g_dailyWins   = 0;
int    g_dailyLosses = 0;
double g_dailyPL      = 0;

//====================================================================
// V1.2.1 GLOBALS — Adaptive Risk Engine Statistics (session-scoped, not persisted)
//====================================================================
int g_statValidSignals       = 0; // setups that reached lot-sizing stage (structural SL known)
int g_statRiskBlockCount     = 0; // total blocked due to lot/risk (any reason)
int g_statMinLotFallbackCount= 0; // times minimum-lot fallback path was evaluated
int g_statActualRiskBlockCount = 0; // fallback evaluated but actual risk > MaxFallbackRiskPercent
int g_statExecutedTradeCount = 0; // trades actually sent successfully via risk engine

//====================================================================
// V1.2 GLOBALS — Safe Recovery Mode
//====================================================================
bool   g_safeRecoveryMode = false;
string g_safeRecoveryReason = "";

//====================================================================
// V1.2 — Per-position R-based state (Initial Entry/SL/1R, trailing, profit lock)
//====================================================================
struct PositionRState
{
   ulong    ticket;
   double   initialEntry;
   double   initialSL;
   double   initialR;         // absolute price distance, NEVER recalculated after set
   bool     profitLockActive;
   double   currentLockedR;   // last R-multiple that has been locked via SL move
   int      lastTrailStepApplied; // 0=none,1..4=step index, used to avoid duplicate notif/mod
   bool     valid;            // false if state incomplete/unrecoverable -> safe mode for this ticket
};
PositionRState g_posState[]; // in-memory cache; source of truth is Global Variables of Terminal

//====================================================================
// V1.2 — Notification de-duplication tracking (in-memory, backed by GV for restart safety)
//====================================================================
// Notification events tracked by a composite key so restarts do not resend history:
// "NOTIFY_<type>_<ticket_or_day>" as Global Variable name (existence = already sent)


// --- Confirmed swing storage (append-only, immutable) ---
struct SwingPoint
{
   datetime time;
   double   price;
   int      shift;      // shift at time of confirmation (for reference only, not reused for future logic)
   bool     isHigh;      // true = swing high, false = swing low
   bool     used;         // used as liquidity already
};

SwingPoint g_swingHighs[];
SwingPoint g_swingLows[];

// last processed bar time to prevent duplicate processing / repaint
datetime g_lastBarTime = 0;

// Pending setup state machine (single active liquidity hunt at a time to enforce quality>frequency)
enum SetupStage
{
   STAGE_NONE = 0,
   STAGE_SWEPT,        // liquidity swept, waiting displacement
   STAGE_DISPLACED      // displacement found, waiting MSS/BOS
};

struct ActiveSetup
{
   SetupStage stage;
   bool       isBullish;      // true = looking for BUY, false = looking for SELL
   double     liquidityLevel;
   datetime   liquidityTime;
   double     sweepExtreme;    // low of sweep candle (buy) or high (sell)
   datetime   sweepTime;
   int        barsWaited;
   double     displacementExtreme; // for reference/logging
};

ActiveSetup g_setup;

// Risk tracking
double   g_dayStartEquity = 0;
int      g_consecLosses = 0;
int      g_cooldownUntilBar = -1;
int      g_barIndexCounter = 0;
ulong    g_lastCheckedDealTicket = 0;

// Used liquidity levels (to prevent duplicate entries on same level)
double   g_usedLevels[];
datetime g_usedLevelTimes[];

//====================================================================
// V1.2.1 — ENVIRONMENT DETECTION (resolved once at OnInit, never changes
// mid-run; used to route GMT source and to LABEL spread debug logs — it
// does NOT change how spread itself is read, since SYMBOL_SPREAD/Bid/Ask
// are already correct in both Tester and Live via the platform itself).
//====================================================================
bool     g_isTester = false;

//====================================================================
// V1.2.1 — AUTO ADAPTIVE SPREAD FILTER (rolling median baseline)
//====================================================================
double   g_spreadSamples[];      // circular buffer of recent CurrentSpreadPoints readings
int      g_spreadSampleCount = 0; // total samples ever pushed (caps display; buffer itself is circular)
int      g_spreadWriteIdx    = 0; // next write index into g_spreadSamples (circular)
double   g_statTypicalSpread = 0;
double   g_statAdaptiveLimit = 0;

// V1.2.1 — Regime instrumentation / flow statistics
int      g_statValidLSMSSignalCount = 0;
int      g_statRegimeEvaluatedCount = 0;
int      g_statRegimeAcceptedCount  = 0;
int      g_statRegimeRejectedCount  = 0;
int      g_statChoppyRejectCount    = 0;
int      g_statRangingCount         = 0;
int      g_statTrendingCount        = 0;
int      g_statUndefinedCount       = 0;

// V1.2.2 — TP candidate selection / Displacement / MSS instrumentation
int      g_statTPCandidatesFound      = 0;
int      g_statTPCandidatesRRValid    = 0;
int      g_statTPCandidatesRRInvalid  = 0;
int      g_statTPRejectedCount        = 0;
int      g_statFinalTPSelectedCount   = 0;
int      g_statDisplacementDetected      = 0;
int      g_statDisplacement1CandleCount  = 0;
int      g_statDisplacement2CandleCount  = 0;
int      g_statDisplacementTimeoutCount  = 0;
int      g_statMSSDetectedCount       = 0;
int      g_statMSSTimeoutCount        = 0;
int      g_statMSSCandidateChecked    = 0;

//====================================================================
// V1.2 — FORWARD DECLARATIONS
//====================================================================
// MQL5 requires a function be declared/defined before its first use in the
// file. Several V1.2 helpers are called from functions defined earlier in
// the file than their own implementation (e.g. session/entry logic calling
// time or notification helpers). Rather than reordering large blocks of
// already-reviewed code, these are declared once here.
void   GetWIBNow(int &outHour, int &outMin, datetime &outDayStamp, datetime &outWIBTime);
void   SendSafeNotificationOnce(string eventKey, string message);
void   RegisterNewPositionRState(ulong ticket, double entryPrice, double slPrice);
void   ProcessClosedDeal(ulong dealTicket);

//====================================================================
// V1.2 FIX — TIMEFRAME DISPLAY HELPER
//====================================================================
// PERIOD_CURRENT has enum value 0, so printing it directly (e.g. via %d)
// shows "TF: 0" instead of the actual chart timeframe. This resolves
// PERIOD_CURRENT to the real active chart period via _Period before mapping
// to a readable string. Only used for display/notification purposes — never
// used to alter any price-data call (those correctly keep using
// PERIOD_CURRENT directly, which MT5 resolves internally at call time).
string TimeframeToString(ENUM_TIMEFRAMES tf)
{
   if(tf == PERIOD_CURRENT)
      tf = (ENUM_TIMEFRAMES)_Period;

   switch(tf)
   {
      case PERIOD_M1:  return "M1";
      case PERIOD_M2:  return "M2";
      case PERIOD_M3:  return "M3";
      case PERIOD_M4:  return "M4";
      case PERIOD_M5:  return "M5";
      case PERIOD_M6:  return "M6";
      case PERIOD_M10: return "M10";
      case PERIOD_M12: return "M12";
      case PERIOD_M15: return "M15";
      case PERIOD_M20: return "M20";
      case PERIOD_M30: return "M30";
      case PERIOD_H1:  return "H1";
      case PERIOD_H2:  return "H2";
      case PERIOD_H3:  return "H3";
      case PERIOD_H4:  return "H4";
      case PERIOD_H6:  return "H6";
      case PERIOD_H8:  return "H8";
      case PERIOD_H12: return "H12";
      case PERIOD_D1:  return "D1";
      case PERIOD_W1:  return "W1";
      case PERIOD_MN1: return "MN1";
      default:         return "TF" + IntegerToString((int)tf); // safe fallback, never prints bare "0"
   }
}

//====================================================================
// DEBUG LOG
//====================================================================
void DLog(string category, string msg)
{
   if(!InpDebugMode) return;
   Print("[LSMS][", category, "] ", msg);
}

//====================================================================
// UTILITY: Points normalization
//====================================================================
double PointsToPrice(int points)
{
   return points * _Point;
}

double NormalizePriceValue(double price)
{
   int digits = (int)SymbolInfoInteger(_Symbol, SYMBOL_DIGITS);
   return NormalizeDouble(price, digits);
}

//====================================================================
// RAW PRICE HELPERS (candle range/body/wick) — replaces ATR
//====================================================================
double CandleRange(int shift)
{
   return iHigh(_Symbol, PERIOD_CURRENT, shift) - iLow(_Symbol, PERIOD_CURRENT, shift);
}

double CandleBody(int shift)
{
   return MathAbs(iClose(_Symbol, PERIOD_CURRENT, shift) - iOpen(_Symbol, PERIOD_CURRENT, shift));
}

double CandleBodyRatio(int shift)
{
   double range = CandleRange(shift);
   if(range <= 0) return 0;
   return CandleBody(shift) / range;
}

double CandleUpperWick(int shift)
{
   double o = iOpen(_Symbol, PERIOD_CURRENT, shift);
   double c = iClose(_Symbol, PERIOD_CURRENT, shift);
   double h = iHigh(_Symbol, PERIOD_CURRENT, shift);
   return h - MathMax(o,c);
}

double CandleLowerWick(int shift)
{
   double o = iOpen(_Symbol, PERIOD_CURRENT, shift);
   double c = iClose(_Symbol, PERIOD_CURRENT, shift);
   double l = iLow(_Symbol, PERIOD_CURRENT, shift);
   return MathMin(o,c) - l;
}

double CloseLocationValueBull(int shift) // 0..1, close near high = 1
{
   double range = CandleRange(shift);
   if(range <= 0) return 0;
   return (iClose(_Symbol, PERIOD_CURRENT, shift) - iLow(_Symbol, PERIOD_CURRENT, shift)) / range;
}

double CloseLocationValueBear(int shift) // 0..1, close near low = 1
{
   double range = CandleRange(shift);
   if(range <= 0) return 0;
   return (iHigh(_Symbol, PERIOD_CURRENT, shift) - iClose(_Symbol, PERIOD_CURRENT, shift)) / range;
}

// Average Range from N CLOSED candles, starting at 'startShift' (exclusive of look-ahead)
double AverageRange(int startShift, int n)
{
   double sum = 0;
   int count = 0;
   for(int i = startShift; i < startShift + n; i++)
   {
      if(i >= iBars(_Symbol, PERIOD_CURRENT)) break;
      sum += CandleRange(i);
      count++;
   }
   if(count == 0) return 0;
   return sum / count;
}

//====================================================================
// SWING DETECTION (deterministic, delayed-confirmation, no look-ahead)
//====================================================================
// A swing at shift 's' (s = center) requires k candles to the LEFT (higher shift, older)
// and k candles to the RIGHT (lower shift, newer) to already be CLOSED.
// In MQL5 series indexing: shift 0 = current forming bar, shift 1 = last closed bar.
// When we are evaluating on a newly closed bar (shift 1 just closed),
// the earliest swing center we can NOW confirm is at shift = 1 + InpSwingK.
// This function checks ONE fixed candidate center and returns true/false — it never
// mutates prior confirmed swings.
bool IsSwingHigh(int centerShift, int k)
{
   double centerHigh = iHigh(_Symbol, PERIOD_CURRENT, centerShift);
   for(int i = 1; i <= k; i++)
   {
      if(iHigh(_Symbol, PERIOD_CURRENT, centerShift - i) >= centerHigh) return false; // right side (newer)
      if(iHigh(_Symbol, PERIOD_CURRENT, centerShift + i) >= centerHigh) return false; // left side (older)
   }
   return true;
}

bool IsSwingLow(int centerShift, int k)
{
   double centerLow = iLow(_Symbol, PERIOD_CURRENT, centerShift);
   for(int i = 1; i <= k; i++)
   {
      if(iLow(_Symbol, PERIOD_CURRENT, centerShift - i) <= centerLow) return false;
      if(iLow(_Symbol, PERIOD_CURRENT, centerShift + i) <= centerLow) return false;
   }
   return true;
}

// Called once per new closed bar. Checks exactly the newly-confirmable swing center
// (shift = 1 + k) and appends to history if valid. This guarantees:
// - no look-ahead (right-side candles are already closed at call time)
// - no repaint (each center is evaluated exactly once, ever, and result is permanent)
void UpdateSwingStructure()
{
   int centerShift = 1 + InpSwingK;

   // avoid duplicate append: check if this exact bar time already recorded
   datetime centerTime = iTime(_Symbol, PERIOD_CURRENT, centerShift);

   if(IsSwingHigh(centerShift, InpSwingK))
   {
      // duplicate guard
      bool exists = false;
      for(int i = ArraySize(g_swingHighs)-1; i >= 0 && i >= ArraySize(g_swingHighs)-5; i--)
         if(g_swingHighs[i].time == centerTime) { exists = true; break; }
      if(!exists)
      {
         SwingPoint sp;
         sp.time = centerTime;
         sp.price = iHigh(_Symbol, PERIOD_CURRENT, centerShift);
         sp.shift = centerShift;
         sp.isHigh = true;
         sp.used = false;
         int sz = ArraySize(g_swingHighs);
         ArrayResize(g_swingHighs, sz+1);
         g_swingHighs[sz] = sp;
         DLog("STRUCTURE", StringFormat("Swing HIGH confirmed @ %s price=%s", TimeToString(sp.time), DoubleToString(sp.price,_Digits)));
      }
   }

   if(IsSwingLow(centerShift, InpSwingK))
   {
      bool exists = false;
      for(int i = ArraySize(g_swingLows)-1; i >= 0 && i >= ArraySize(g_swingLows)-5; i--)
         if(g_swingLows[i].time == centerTime) { exists = true; break; }
      if(!exists)
      {
         SwingPoint sp;
         sp.time = centerTime;
         sp.price = iLow(_Symbol, PERIOD_CURRENT, centerShift);
         sp.shift = centerShift;
         sp.isHigh = false;
         sp.used = false;
         int sz = ArraySize(g_swingLows);
         ArrayResize(g_swingLows, sz+1);
         g_swingLows[sz] = sp;
         DLog("STRUCTURE", StringFormat("Swing LOW confirmed @ %s price=%s", TimeToString(sp.time), DoubleToString(sp.price,_Digits)));
      }
   }

   // cap array sizes to avoid unbounded memory growth
   int maxKeep = InpLiquidityLookback * 2 + 50;
   if(ArraySize(g_swingHighs) > maxKeep)
   {
      int removeCount = ArraySize(g_swingHighs) - maxKeep;
      for(int i = 0; i < ArraySize(g_swingHighs) - removeCount; i++)
         g_swingHighs[i] = g_swingHighs[i + removeCount];
      ArrayResize(g_swingHighs, ArraySize(g_swingHighs) - removeCount);
   }
   if(ArraySize(g_swingLows) > maxKeep)
   {
      int removeCount = ArraySize(g_swingLows) - maxKeep;
      for(int i = 0; i < ArraySize(g_swingLows) - removeCount; i++)
         g_swingLows[i] = g_swingLows[i + removeCount];
      ArrayResize(g_swingLows, ArraySize(g_swingLows) - removeCount);
   }
}

// Get most recent N confirmed swing highs/lows (index 0 = most recent)
int GetRecentSwingHighs(SwingPoint &out[], int count)
{
   int total = ArraySize(g_swingHighs);
   int n = MathMin(count, total);
   ArrayResize(out, n);
   for(int i = 0; i < n; i++)
      out[i] = g_swingHighs[total - 1 - i];
   return n;
}

int GetRecentSwingLows(SwingPoint &out[], int count)
{
   int total = ArraySize(g_swingLows);
   int n = MathMin(count, total);
   ArrayResize(out, n);
   for(int i = 0; i < n; i++)
      out[i] = g_swingLows[total - 1 - i];
   return n;
}

//====================================================================
// MARKET REGIME (deterministic, price-structure only)
//====================================================================
enum MarketRegime
{
   REGIME_TRENDING_BULL,
   REGIME_TRENDING_BEAR,
   REGIME_RANGING,
   REGIME_CHOPPY,
   REGIME_UNDEFINED
};

//====================================================================
// V1.2.1 AUDIT FIX — MARKET REGIME (deterministic, price-structure only)
//====================================================================
// ROOT CAUSE (confirmed by audit, evidence in prior turn):
//   The original DetectRegime() had NO positive definition of "choppy" —
//   it was `return REGIME_CHOPPY;` as the unconditional fallback whenever
//   the (very strict) trending test and the (very narrow) ranging test both
//   failed. Combined with InpChoppyCHOCHCount being a declared-but-never-used
//   input (dead code), CHOPPY had zero real detection criteria of its own.
//   That is why it dominated the classification almost every bar.
//
// FIX:
//   - CHOPPY is now a POSITIVE detection: count how many of the last
//     (InpRegimeSwingCount+1) confirmed swing legs represent a "CHOCH-like"
//     break — i.e. a swing that breaks the immediately-preceding opposite
//     swing's extreme without the prior leg confirming continuation (a
//     genuine back-and-forth failure-to-extend pattern), NOT merely "not a
//     clean trend". If that count >= InpChoppyCHOCHCount, regime = CHOPPY.
//   - If structure is insufficient to establish TRENDING, RANGING, or the
//     CHOCH-based CHOPPY criteria, the result is REGIME_UNDEFINED — which
//     the entry gate already treats as non-blocking (only CHOPPY blocks).
//     Ambiguous/insufficient evidence is no longer silently mislabeled as
//     "confirmed choppy".
//   - TRENDING and RANGING math is UNCHANGED from V1.2.1 (no threshold was
//     loosened) — only the fallback semantics changed.
//
// out params (for [REGIME DEBUG] instrumentation — does not affect the
// decision itself):
//====================================================================
MarketRegime DetectRegime(int &outChochCount, double &outTol, bool &outHH, bool &outHL, bool &outLH, bool &outLL, bool &outRangeBound)
{
   outChochCount = 0; outTol = 0; outHH = false; outHL = false; outLH = false; outLL = false; outRangeBound = false;

   SwingPoint highs[], lows[];
   int nh = GetRecentSwingHighs(highs, InpRegimeSwingCount + 1);
   int nl = GetRecentSwingLows(lows, InpRegimeSwingCount + 1);

   if(nh < InpRegimeSwingCount + 1 || nl < InpRegimeSwingCount + 1)
      return REGIME_UNDEFINED; // not enough structure yet — genuinely unknown, not choppy

   // Check HH+HL (bullish trending): highs[0] > highs[1] and lows[0] > lows[1] (index0=most recent)
   bool hh = true, hl = true, lh = true, ll = true;
   for(int i = 0; i < InpRegimeSwingCount; i++)
   {
      if(!(highs[i].price > highs[i+1].price)) hh = false;
      if(!(lows[i].price  > lows[i+1].price))  hl = false;
      if(!(highs[i].price < highs[i+1].price)) lh = false;
      if(!(lows[i].price  < lows[i+1].price))  ll = false;
   }
   outHH = hh; outHL = hl; outLH = lh; outLL = ll;

   if(hh && hl) return REGIME_TRENDING_BULL;
   if(lh && ll) return REGIME_TRENDING_BEAR;

   // Ranging: highs and lows oscillate near same boundaries (within EqTolerance) without continuation
   double tol = PointsToPrice(InpEqTolerancePts);
   outTol = tol;
   bool rangeBound = (MathAbs(highs[0].price - highs[1].price) <= tol * 3) &&
                      (MathAbs(lows[0].price  - lows[1].price)  <= tol * 3);
   outRangeBound = rangeBound;
   if(rangeBound) return REGIME_RANGING;

   // --- CHOPPY: positive detection via CHOCH-like leg count ---
   // A leg is CHOCH-like when a swing high breaks below the previous swing
   // high AND the paired swing low breaks above the previous swing low (or
   // vice versa) within the same lookback window — i.e. neither side is
   // making clean higher-highs/higher-lows or lower-highs/lower-lows, which
   // is exactly the back-and-forth character "choppy" is meant to describe.
   // This reuses the same hh/hl/lh/ll booleans already computed per-leg
   // above, just interpreted for genuine failure-to-extend rather than as a
   // remainder bucket.
   int chochCount = 0;
   for(int i = 0; i < InpRegimeSwingCount; i++)
   {
      bool highBrokeDown = !(highs[i].price > highs[i+1].price); // this leg's high failed to make a higher high
      bool lowBrokeUp    = !(lows[i].price  < lows[i+1].price);  // this leg's low failed to make a lower low
      if(highBrokeDown && lowBrokeUp)
         chochCount++;
   }
   outChochCount = chochCount;

   if(chochCount >= InpChoppyCHOCHCount)
      return REGIME_CHOPPY;

   // Structure exists but doesn't meet the positive bar for TRENDING,
   // RANGING, or CHOPPY — genuinely ambiguous, not "confirmed choppy".
   return REGIME_UNDEFINED;
}

MarketRegime DetectRegime()
{
   int chochCount; double tol; bool hh, hl, lh, ll, rangeBound;
   return DetectRegime(chochCount, tol, hh, hl, lh, ll, rangeBound);
}

string RegimeLabel(MarketRegime r)
{
   switch(r)
   {
      case REGIME_TRENDING_BULL: return "TRENDING_BULL";
      case REGIME_TRENDING_BEAR: return "TRENDING_BEAR";
      case REGIME_RANGING:       return "RANGING";
      case REGIME_CHOPPY:        return "CHOPPY";
      default:                   return "UNDEFINED";
   }
}

//====================================================================
// V1.2.1 — REGIME GATE (evaluated only at MSS-confirmed setup, per Option A:
// Hard Filter, fixed placement). Returns true = allow execution to proceed.
//====================================================================
bool EvaluateRegimeGate(string setupLabel)
{
   g_statValidLSMSSignalCount++;

   int chochCount; double tol; bool hh, hl, lh, ll, rangeBound;
   MarketRegime regime = DetectRegime(chochCount, tol, hh, hl, lh, ll, rangeBound);

   if(!InpUseRegimeFilter)
   {
      // Filter disabled entirely — still log for visibility, but never reject.
      if(InpEnableRegimeDebug)
         DLog("REGIME DEBUG", StringFormat("Filter=OFF FinalRegime=%s (not evaluated as gate)", RegimeLabel(regime)));
      return true;
   }

   g_statRegimeEvaluatedCount++;

   switch(regime)
   {
      case REGIME_TRENDING_BULL: g_statTrendingCount++; break;
      case REGIME_TRENDING_BEAR: g_statTrendingCount++; break;
      case REGIME_RANGING:       g_statRangingCount++;  break;
      case REGIME_UNDEFINED:     g_statUndefinedCount++; break;
      default: break;
   }

   bool accept = (regime != REGIME_CHOPPY);

   if(accept)
      g_statRegimeAcceptedCount++;
   else
   {
      g_statRegimeRejectedCount++;
      g_statChoppyRejectCount++;
   }

   if(InpEnableRegimeDebug)
   {
      if(!accept)
      {
         DLog("REGIME REJECT", StringFormat(
            "Symbol=%s TF=%s CandleTime=%s Setup=%s DetectedRegime=%s CHOCHCount=%d ChoppyThreshold=%d Reason=REGIME_CHOPPY_HARD_BLOCK",
            _Symbol, TimeframeToString(PERIOD_CURRENT), TimeToString(iTime(_Symbol,PERIOD_CURRENT,1), TIME_DATE|TIME_MINUTES),
            setupLabel, RegimeLabel(regime), chochCount, InpChoppyCHOCHCount));
      }

      DLog("REGIME DEBUG", StringFormat(
         "TrendMetric(HH=%s,HL=%s,LH=%s,LL=%s) RangeMetric(tol=%.5f,rangeBound=%s) ChoppyMetric(chochCount=%d) ChoppyThreshold=%d FinalRegime=%s Decision=%s",
         hh?"T":"F", hl?"T":"F", lh?"T":"F", ll?"T":"F",
         tol, rangeBound?"T":"F",
         chochCount, InpChoppyCHOCHCount,
         RegimeLabel(regime), accept?"ACCEPT":"REJECT"));
   }

   return accept;
}

//====================================================================
// LIQUIDITY LEVELS
//====================================================================
struct LiquidityLevel
{
   double   price;
   datetime time;
   bool     isHighSide; // true = resistance-type (sell-side liquidity above), false = support-type
   string   label;
};

// Previous Day High/Low
bool GetPrevDayHL(double &prevHigh, double &prevLow)
{
   MqlDateTime dtNow;
   TimeToStruct(TimeCurrent(), dtNow);
   // find start of today (server time) and go back to previous day range
   datetime todayStart = TimeCurrent() - (dtNow.hour*3600 + dtNow.min*60 + dtNow.sec);
   datetime prevDayStart = todayStart - 86400;

   int startBar = iBarShift(_Symbol, PERIOD_CURRENT, prevDayStart, false);
   int endBar   = iBarShift(_Symbol, PERIOD_CURRENT, todayStart, false);
   if(startBar <= endBar) return false;
   endBar = MathMax(endBar, 1); // never include unclosed bar

   double hi = -DBL_MAX, lo = DBL_MAX;
   for(int i = endBar; i <= startBar; i++)
   {
      if(i >= iBars(_Symbol, PERIOD_CURRENT)) break;
      hi = MathMax(hi, iHigh(_Symbol, PERIOD_CURRENT, i));
      lo = MathMin(lo, iLow(_Symbol, PERIOD_CURRENT, i));
   }
   if(hi <= -DBL_MAX || lo >= DBL_MAX) return false;
   prevHigh = hi; prevLow = lo;
   return true;
}

// Session detection based on server time hour, previous COMPLETED session only
int GetSessionOfHour(int hour)
{
   // 0 = Asia, 1 = London, 2 = NY, -1 = none
   if(hour >= InpAsiaStartHour && hour < InpAsiaEndHour) return 0;
   if(hour >= InpLondonStartHour && hour < InpLondonEndHour) return 1;
   if(hour >= InpNYStartHour && hour < InpNYEndHour) return 2;
   return -1;
}

bool GetPrevSessionHL(double &sessHigh, double &sessLow)
{
   MqlDateTime dtNow;
   TimeToStruct(TimeCurrent(), dtNow);
   int curSession = GetSessionOfHour(dtNow.hour);

   // scan backward through closed bars (shift>=1) to find the most recent fully-completed
   // session block that is different from current session
   int bars = iBars(_Symbol, PERIOD_CURRENT);
   int lookback = MathMin(bars-2, 500);
   int foundSession = -2;
   double hi = -DBL_MAX, lo = DBL_MAX;
   bool started = false;

   for(int i = 1; i <= lookback; i++)
   {
      MqlDateTime dt;
      TimeToStruct(iTime(_Symbol, PERIOD_CURRENT, i), dt);
      int s = GetSessionOfHour(dt.hour);
      if(s == -1) continue;
      if(s == curSession && !started) continue; // skip current in-progress session

      if(!started)
      {
         foundSession = s;
         started = true;
      }
      if(s != foundSession) break; // session block ended

      hi = MathMax(hi, iHigh(_Symbol, PERIOD_CURRENT, i));
      lo = MathMin(lo, iLow(_Symbol, PERIOD_CURRENT, i));
   }

   if(!started || hi <= -DBL_MAX || lo >= DBL_MAX) return false;
   sessHigh = hi; sessLow = lo;
   return true;
}

// Collect candidate liquidity levels: swing highs/lows, PDH/PDL, prev session H/L, equal highs/lows
int CollectLiquidityLevels(LiquidityLevel &levels[])
{
   ArrayResize(levels, 0);
   int cnt = 0;

   // Swing-based levels
   SwingPoint highs[], lows[];
   int nh = GetRecentSwingHighs(highs, InpLiquidityLookback);
   int nl = GetRecentSwingLows(lows, InpLiquidityLookback);

   for(int i = 0; i < nh; i++)
   {
      if(highs[i].used) continue;
      ArrayResize(levels, cnt+1);
      levels[cnt].price = highs[i].price;
      levels[cnt].time = highs[i].time;
      levels[cnt].isHighSide = true;
      levels[cnt].label = "SwingHigh";
      cnt++;
   }
   for(int i = 0; i < nl; i++)
   {
      if(lows[i].used) continue;
      ArrayResize(levels, cnt+1);
      levels[cnt].price = lows[i].price;
      levels[cnt].time = lows[i].time;
      levels[cnt].isHighSide = false;
      levels[cnt].label = "SwingLow";
      cnt++;
   }

   // Equal highs/lows (within tolerance) - mark as distinct higher-priority label
   double tol = PointsToPrice(InpEqTolerancePts);
   for(int i = 0; i < nh; i++)
      for(int j = i+1; j < nh; j++)
         if(MathAbs(highs[i].price - highs[j].price) <= tol)
         {
            ArrayResize(levels, cnt+1);
            levels[cnt].price = (highs[i].price + highs[j].price)/2.0;
            levels[cnt].time = highs[i].time;
            levels[cnt].isHighSide = true;
            levels[cnt].label = "EqualHigh";
            cnt++;
         }
   for(int i = 0; i < nl; i++)
      for(int j = i+1; j < nl; j++)
         if(MathAbs(lows[i].price - lows[j].price) <= tol)
         {
            ArrayResize(levels, cnt+1);
            levels[cnt].price = (lows[i].price + lows[j].price)/2.0;
            levels[cnt].time = lows[i].time;
            levels[cnt].isHighSide = false;
            levels[cnt].label = "EqualLow";
            cnt++;
         }

   // Previous Day H/L
   double pdh, pdl;
   if(GetPrevDayHL(pdh, pdl))
   {
      ArrayResize(levels, cnt+1);
      levels[cnt].price = pdh; levels[cnt].time = TimeCurrent(); levels[cnt].isHighSide = true; levels[cnt].label = "PDH";
      cnt++;
      ArrayResize(levels, cnt+1);
      levels[cnt].price = pdl; levels[cnt].time = TimeCurrent(); levels[cnt].isHighSide = false; levels[cnt].label = "PDL";
      cnt++;
   }

   // Previous Session H/L
   double sh, sl;
   if(GetPrevSessionHL(sh, sl))
   {
      ArrayResize(levels, cnt+1);
      levels[cnt].price = sh; levels[cnt].time = TimeCurrent(); levels[cnt].isHighSide = true; levels[cnt].label = "SessionHigh";
      cnt++;
      ArrayResize(levels, cnt+1);
      levels[cnt].price = sl; levels[cnt].time = TimeCurrent(); levels[cnt].isHighSide = false; levels[cnt].label = "SessionLow";
      cnt++;
   }

   return cnt;
}

bool IsLevelUsed(double price)
{
   double tol = PointsToPrice(InpEqTolerancePts);
   for(int i = 0; i < ArraySize(g_usedLevels); i++)
      if(MathAbs(g_usedLevels[i] - price) <= tol) return true;
   return false;
}

void MarkLevelUsed(double price)
{
   int sz = ArraySize(g_usedLevels);
   ArrayResize(g_usedLevels, sz+1);
   ArrayResize(g_usedLevelTimes, sz+1);
   g_usedLevels[sz] = price;
   g_usedLevelTimes[sz] = TimeCurrent();
}

//====================================================================
// SWEEP DETECTION (on the just-closed bar, shift=1)
//====================================================================
bool CheckSweepUpside(double level, double &outSweepHigh, double &outCloseVal)
{
   // resistance-type level swept upside: high[1] > level+minpen, close[1] < level, upper wick dominant
   double high1 = iHigh(_Symbol, PERIOD_CURRENT, 1);
   double close1 = iClose(_Symbol, PERIOD_CURRENT, 1);
   double minPen = PointsToPrice(InpMinPenetrationPts);

   if(high1 <= level + minPen) return false;
   if(close1 >= level) return false;

   double range = CandleRange(1);
   if(range <= 0) return false;
   double upperWickRatio = CandleUpperWick(1) / range;
   if(upperWickRatio < InpMinWickRatio) return false;

   outSweepHigh = high1;
   outCloseVal = close1;
   return true;
}

bool CheckSweepDownside(double level, double &outSweepLow, double &outCloseVal)
{
   double low1 = iLow(_Symbol, PERIOD_CURRENT, 1);
   double close1 = iClose(_Symbol, PERIOD_CURRENT, 1);
   double minPen = PointsToPrice(InpMinPenetrationPts);

   if(low1 >= level - minPen) return false;
   if(close1 <= level) return false;

   double range = CandleRange(1);
   if(range <= 0) return false;
   double lowerWickRatio = CandleLowerWick(1) / range;
   if(lowerWickRatio < InpMinWickRatio) return false;

   outSweepLow = low1;
   outCloseVal = close1;
   return true;
}

//====================================================================
// DISPLACEMENT DETECTION (on just-closed bar, shift=1)
//====================================================================
// V1.2.2 ROOT-CAUSE FIX: 1-candle path is UNCHANGED and remains primary.
// A 2-candle fallback is added (opt-in via InpAllowTwoCandleDisplacement,
// default false) per the agreed FINAL DESIGN SPEC. Thresholds
// (InpDisplacementFactor, InpMinBodyRatio, CLV>=0.7) are IDENTICAL to the
// 1-candle path — nothing is loosened. Hard gates prevent a weak/counter
// candle from "hiding" behind a strong one.
//====================================================================
bool SingleCandleDisplacementBullish(int shift)
{
   double avgRange = AverageRange(shift+1, InpAvgRangeN); // strictly candles BEFORE shift
   if(avgRange <= 0) return false;
   double range = CandleRange(shift);
   if(range < InpDisplacementFactor * avgRange) return false;
   if(CandleBodyRatio(shift) < InpMinBodyRatio) return false;
   double close_ = iClose(_Symbol, PERIOD_CURRENT, shift);
   double open_  = iOpen(_Symbol, PERIOD_CURRENT, shift);
   if(close_ <= open_) return false; // must be bullish candle
   if(CloseLocationValueBull(shift) < 0.7) return false;
   return true;
}

bool SingleCandleDisplacementBearish(int shift)
{
   double avgRange = AverageRange(shift+1, InpAvgRangeN);
   if(avgRange <= 0) return false;
   double range = CandleRange(shift);
   if(range < InpDisplacementFactor * avgRange) return false;
   if(CandleBodyRatio(shift) < InpMinBodyRatio) return false;
   double close_ = iClose(_Symbol, PERIOD_CURRENT, shift);
   double open_  = iOpen(_Symbol, PERIOD_CURRENT, shift);
   if(close_ >= open_) return false; // must be bearish candle
   if(CloseLocationValueBear(shift) < 0.7) return false;
   return true;
}

// Two-candle fallback: candle_A = shift+1 (older), candle_B = shift (newer,
// the closing candle of the sequence). Both must be closed bars already
// (shift and shift+1 are both >=1 at every call site), so this adds no
// look-ahead versus the 1-candle path.
bool TwoCandleDisplacementBullish(int shift, string &outReason)
{
   int a = shift+1, b = shift;

   double openA = iOpen(_Symbol, PERIOD_CURRENT, a), closeA = iClose(_Symbol, PERIOD_CURRENT, a);
   double openB = iOpen(_Symbol, PERIOD_CURRENT, b), closeB = iClose(_Symbol, PERIOD_CURRENT, b);
   double highA = iHigh(_Symbol, PERIOD_CURRENT, a), lowA = iLow(_Symbol, PERIOD_CURRENT, a);
   double highB = iHigh(_Symbol, PERIOD_CURRENT, b), lowB = iLow(_Symbol, PERIOD_CURRENT, b);

   // Hard gate (a): both candles must be same-direction (bullish)
   if(!(closeA > openA && closeB > openB)) { outReason = "NOT_SAME_DIRECTION"; return false; }

   // Hard gate (b): candle B must not make a new low below candle A
   // (prevents a "up-down-up" zigzag from being counted as one impulse)
   if(!(lowB >= lowA)) { outReason = "OVERLAP_BACKWARD"; return false; }

   double avgRange = AverageRange(a+1, InpAvgRangeN); // strictly before candle A, no overlap with A/B
   if(avgRange <= 0) { outReason = "NO_AVG_RANGE"; return false; }

   double combinedRange = MathAbs(highB - lowA);
   if(combinedRange <= 0) { outReason = "ZERO_RANGE"; return false; }

   double combinedBody = MathAbs(closeB - openA);
   double combinedBodyRatio = combinedBody / combinedRange;

   double lowOfCombined = MathMin(lowA, lowB);
   double combinedCLV = (closeB - lowOfCombined) / combinedRange;

   if(combinedRange < InpDisplacementFactor * avgRange) { outReason = "RANGE_TOO_SMALL"; return false; }
   if(combinedBodyRatio < InpMinBodyRatio)               { outReason = "BODY_RATIO_TOO_LOW"; return false; }
   if(combinedCLV < 0.7)                                 { outReason = "CLV_TOO_LOW"; return false; }

   outReason = "OK";
   return true;
}

bool TwoCandleDisplacementBearish(int shift, string &outReason)
{
   int a = shift+1, b = shift;

   double openA = iOpen(_Symbol, PERIOD_CURRENT, a), closeA = iClose(_Symbol, PERIOD_CURRENT, a);
   double openB = iOpen(_Symbol, PERIOD_CURRENT, b), closeB = iClose(_Symbol, PERIOD_CURRENT, b);
   double highA = iHigh(_Symbol, PERIOD_CURRENT, a), lowA = iLow(_Symbol, PERIOD_CURRENT, a);
   double highB = iHigh(_Symbol, PERIOD_CURRENT, b), lowB = iLow(_Symbol, PERIOD_CURRENT, b);

   if(!(closeA < openA && closeB < openB)) { outReason = "NOT_SAME_DIRECTION"; return false; }
   if(!(highB <= highA)) { outReason = "OVERLAP_BACKWARD"; return false; }

   double avgRange = AverageRange(a+1, InpAvgRangeN);
   if(avgRange <= 0) { outReason = "NO_AVG_RANGE"; return false; }

   double combinedRange = MathAbs(highA - lowB);
   if(combinedRange <= 0) { outReason = "ZERO_RANGE"; return false; }

   double combinedBody = MathAbs(closeB - openA);
   double combinedBodyRatio = combinedBody / combinedRange;

   double highOfCombined = MathMax(highA, highB);
   double combinedCLV = (highOfCombined - closeB) / combinedRange;

   if(combinedRange < InpDisplacementFactor * avgRange) { outReason = "RANGE_TOO_SMALL"; return false; }
   if(combinedBodyRatio < InpMinBodyRatio)               { outReason = "BODY_RATIO_TOO_LOW"; return false; }
   if(combinedCLV < 0.7)                                 { outReason = "CLV_TOO_LOW"; return false; }

   outReason = "OK";
   return true;
}

bool CheckDisplacementBullish(int shift, string &outMode, string &outReason)
{
   if(SingleCandleDisplacementBullish(shift)) { outMode = "1-CANDLE"; outReason = "OK"; return true; }

   if(!InpAllowTwoCandleDisplacement) { outMode = "NONE"; outReason = "1C_FAILED_2C_DISABLED"; return false; }

   string reason2c;
   if(TwoCandleDisplacementBullish(shift, reason2c)) { outMode = "2-CANDLE"; outReason = "OK"; return true; }

   outMode = "NONE"; outReason = reason2c;
   return false;
}

bool CheckDisplacementBearish(int shift, string &outMode, string &outReason)
{
   if(SingleCandleDisplacementBearish(shift)) { outMode = "1-CANDLE"; outReason = "OK"; return true; }

   if(!InpAllowTwoCandleDisplacement) { outMode = "NONE"; outReason = "1C_FAILED_2C_DISABLED"; return false; }

   string reason2c;
   if(TwoCandleDisplacementBearish(shift, reason2c)) { outMode = "2-CANDLE"; outReason = "OK"; return true; }

   outMode = "NONE"; outReason = reason2c;
   return false;
}

// Backward-compatible overloads (kept in case any other call site uses the
// 1-arg form without needing mode/reason detail).
bool CheckDisplacementBullish(int shift) { string m,r; return CheckDisplacementBullish(shift, m, r); }
bool CheckDisplacementBearish(int shift) { string m,r; return CheckDisplacementBearish(shift, m, r); }

//====================================================================
// MSS / BOS CONFIRMATION — SWEEP-CONTEXTUAL, deterministic post-sweep structure
//====================================================================
// CRITICAL FIX: previous version scanned the last 5 confirmed swings regardless of
// when they formed relative to the sweep. This allowed MSS to fire off a break of an
// OLD swing that has nothing to do with the current liquidity-sweep setup (e.g. price
// breaking a swing high from weeks ago while the actual post-sweep reaction was still
// forming). That is a false-context MSS, not a valid one.
//
// Deterministic rule now enforced:
//   LIQUIDITY SWEEP (time = sweepTime)
//     -> POST-SWEEP STRUCTURE: only swing points CONFIRMED AT OR AFTER sweepTime are
//        eligible reference points for MSS/BOS. A swing confirmed before the sweep
//        happened is stale context and is rejected.
//     -> DISPLACEMENT (already required upstream in the state machine)
//     -> VALID MSS/BOS: closed candle must break the NEAREST eligible post-sweep swing
//        point counter to the sweep direction (i.e. the immediate structural point the
//        reversal must clear to be considered a genuine character change). We take the
//        MOST RECENT (nearest in time) eligible swing rather than scanning the whole
//        list, because that is the swing that actually defines the post-sweep range.
//     -> ENTRY
//
// No look-ahead: swing.time is a CONFIRMED swing time (see UpdateSwingStructure — a
// swing only exists in the array once its right-side confirmation candles are closed).
// We only compare swing.time >= sweepTime, both of which are already-known historical
// timestamps at the moment this function runs (on the newly closed bar). Nothing here
// references any bar that has not closed yet.
// V1.2.2 ROOT-CAUSE FIX: previously only the NEAREST post-sweep swing was
// tested; if it wasn't broken yet, the function returned false without
// trying any other eligible swing. This could miss a valid MSS when an
// EARLIER post-sweep swing was already broken by the current close (e.g.
// two post-sweep swings both broken by a strong reversal — the earlier one
// is the structurally correct BOS, not the nearest one).
//
// FIX: gather ALL post-sweep confirmed swings (constraint UNCHANGED: only
// swing.time >= sweepTime is eligible), sort them CHRONOLOGICALLY ASCENDING
// (oldest first), and test in that order — the FIRST (earliest) one broken
// is reported, so we never skip past an earlier valid BOS to reach a later
// one. No look-ahead: highs[]/lows[] still only contain already-confirmed
// swings (unchanged source, GetRecentSwingHighs/Lows), and close1 is still
// shift=1 (the just-closed bar) — identical data sources to before, only
// the iteration order/scope changed.
bool CheckMSSBullish(datetime sweepTime, double &outSwingBroken, int &outCandidatesChecked)
{
   outCandidatesChecked = 0;
   SwingPoint highs[];
   int n = GetRecentSwingHighs(highs, InpLiquidityLookback);
   if(n == 0) return false;

   double close1 = iClose(_Symbol, PERIOD_CURRENT, 1);
   double minPen = PointsToPrice(InpMinBOSPenetrationPts);

   // highs[] is time-descending (index 0 = latest). Collect post-sweep
   // eligible candidates first, then walk them oldest-to-newest.
   int eligibleIdx[];
   int ne = 0;
   for(int i = 0; i < n; i++)
   {
      if(highs[i].time < sweepTime) break; // array is time-descending; once we hit a
                                            // pre-sweep swing, nothing further is eligible
      ArrayResize(eligibleIdx, ne+1);
      eligibleIdx[ne] = i;
      ne++;
   }
   // eligibleIdx currently holds indices newest-first (since highs[] is
   // descending); reverse to get oldest-first (chronological ascending).
   for(int i = 0; i < ne/2; i++)
   {
      int tmp = eligibleIdx[i];
      eligibleIdx[i] = eligibleIdx[ne-1-i];
      eligibleIdx[ne-1-i] = tmp;
   }

   for(int k = 0; k < ne; k++)
   {
      outCandidatesChecked++;
      int i = eligibleIdx[k];
      if(close1 > highs[i].price + minPen)
      {
         outSwingBroken = highs[i].price;
         return true; // earliest post-sweep swing broken — do not continue
                       // to later ones, this IS the valid BOS point
      }
   }
   return false; // no eligible post-sweep swing has been broken yet
}

bool CheckMSSBearish(datetime sweepTime, double &outSwingBroken, int &outCandidatesChecked)
{
   outCandidatesChecked = 0;
   SwingPoint lows[];
   int n = GetRecentSwingLows(lows, InpLiquidityLookback);
   if(n == 0) return false;

   double close1 = iClose(_Symbol, PERIOD_CURRENT, 1);
   double minPen = PointsToPrice(InpMinBOSPenetrationPts);

   int eligibleIdx[];
   int ne = 0;
   for(int i = 0; i < n; i++)
   {
      if(lows[i].time < sweepTime) break;
      ArrayResize(eligibleIdx, ne+1);
      eligibleIdx[ne] = i;
      ne++;
   }
   for(int i = 0; i < ne/2; i++)
   {
      int tmp = eligibleIdx[i];
      eligibleIdx[i] = eligibleIdx[ne-1-i];
      eligibleIdx[ne-1-i] = tmp;
   }

   for(int k = 0; k < ne; k++)
   {
      outCandidatesChecked++;
      int i = eligibleIdx[k];
      if(close1 < lows[i].price - minPen)
      {
         outSwingBroken = lows[i].price;
         return true;
      }
   }
   return false;
}

// Backward-compatible overloads (kept in case any other call site uses the
// 2-arg form without needing the candidate-checked count).
bool CheckMSSBullish(datetime sweepTime, double &outSwingBroken) { int c; return CheckMSSBullish(sweepTime, outSwingBroken, c); }
bool CheckMSSBearish(datetime sweepTime, double &outSwingBroken) { int c; return CheckMSSBearish(sweepTime, outSwingBroken, c); }

//====================================================================
// V1.2.2 ROOT-CAUSE FIX — TP CANDIDATE SELECTION
//====================================================================
// SPEC (agreed): generate ALL structural/liquidity candidates in the
// correct direction -> compute RR for each against the already-known
// SL/entry -> discard RR-invalid candidates -> among RR-valid candidates,
// pick by hierarchy (EqualHigh/Low > SwingHigh/Low > PDH/PDL >
// PrevSession) with NEAREST-within-same-hierarchy as tie-breaker.
//
// Explicitly UNCHANGED: SL/riskDistance computation (still sweepExtreme
// +/- buffer, computed by the caller exactly as before), InpMinRR value
// and meaning (still a hard filter, just applied earlier in the pipeline
// instead of after-the-fact), NormalizeSLTP/broker constraints (still
// applied by the caller). This function does not touch the Risk Engine.
//====================================================================
int HierarchyRank(string label)
{
   if(label == "EqualHigh" || label == "EqualLow")   return 1;
   if(label == "SwingHigh" || label == "SwingLow")   return 2;
   if(label == "PDH" || label == "PDL")              return 3;
   return 4; // PrevSessionH/L or any other future label
}

struct TPCandidateStat
{
   double price;
   string label;
   double rr;
   bool   valid;
};

bool FindTPTarget(bool isBuy, double entryPrice, double riskDistance, double &outTP,
                   int &outCandidatesFound, int &outCandidatesRRValid, int &outCandidatesRRInvalid,
                   string &outChosenLabel, double &outChosenRR)
{
   outCandidatesFound = 0; outCandidatesRRValid = 0; outCandidatesRRInvalid = 0;
   outChosenLabel = ""; outChosenRR = 0;

   LiquidityLevel levels[];
   int cnt = CollectLiquidityLevels(levels);

   // Step 1: gather directionally-correct candidates
   TPCandidateStat cands[];
   int nc = 0;
   for(int i = 0; i < cnt; i++)
   {
      bool directionOK = (isBuy  && levels[i].isHighSide && levels[i].price > entryPrice) ||
                          (!isBuy && !levels[i].isHighSide && levels[i].price < entryPrice);
      if(!directionOK) continue;

      ArrayResize(cands, nc+1);
      cands[nc].price = levels[i].price;
      cands[nc].label = levels[i].label;
      double reward = MathAbs(levels[i].price - entryPrice);
      cands[nc].rr = (riskDistance > 0) ? reward / riskDistance : 0;
      cands[nc].valid = (riskDistance > 0) && (cands[nc].rr >= InpMinRR);
      nc++;
   }
   outCandidatesFound = nc;

   // Step 2: count valid/invalid (InpMinRR itself is NOT changed, only
   // applied at this earlier stage instead of after TP was already fixed)
   for(int i = 0; i < nc; i++)
   {
      if(cands[i].valid) outCandidatesRRValid++;
      else outCandidatesRRInvalid++;
   }

   if(outCandidatesRRValid == 0)
   {
      if(InpEnableTPDebug)
      {
         string dump = "";
         for(int i = 0; i < nc; i++)
            dump += StringFormat("[%s@%.5f RR=%.2f] ", cands[i].label, cands[i].price, cands[i].rr);
         DLog("TP CANDIDATES", StringFormat("Found=%d Valid=0 Invalid=%d Detail=%s Decision=NONE_VALID",
              nc, outCandidatesRRInvalid, dump));
      }
      return false;
   }

   // Step 3: among RR-valid candidates, pick by hierarchy rank ASC, then
   // nearest-to-entry as tie-breaker within the same rank. Simple selection
   // sort is fine here (candidate lists are small, typically < 20).
   int bestIdx = -1;
   for(int i = 0; i < nc; i++)
   {
      if(!cands[i].valid) continue;
      if(bestIdx == -1) { bestIdx = i; continue; }

      int rankI = HierarchyRank(cands[i].label);
      int rankBest = HierarchyRank(cands[bestIdx].label);
      if(rankI < rankBest)
      {
         bestIdx = i;
      }
      else if(rankI == rankBest)
      {
         double distI    = MathAbs(cands[i].price - entryPrice);
         double distBest = MathAbs(cands[bestIdx].price - entryPrice);
         if(distI < distBest) bestIdx = i;
      }
   }

   outTP = cands[bestIdx].price;
   outChosenLabel = cands[bestIdx].label;
   outChosenRR = cands[bestIdx].rr;

   if(InpEnableTPDebug)
   {
      string dump = "";
      for(int i = 0; i < nc; i++)
         dump += StringFormat("[%s@%.5f RR=%.2f %s] ", cands[i].label, cands[i].price, cands[i].rr, cands[i].valid?"VALID":"invalid");
      DLog("TP CANDIDATES", StringFormat("Found=%d Valid=%d Invalid=%d Detail=%s ChosenTP=%s@%.5f ChosenRR=%.2f",
           nc, outCandidatesRRValid, outCandidatesRRInvalid, dump, outChosenLabel, outTP, outChosenRR));
   }

   return true;
}

// Backward-compatible overload for any call site that only needs the
// price (kept in case other code paths call the 3-arg form).
bool FindTPTarget(bool isBuy, double entryPrice, double &outTP)
{
   // NOTE: this overload cannot compute RR-based selection without a risk
   // distance, so it falls back to nearest-only behavior (legacy path).
   // It is not used by the BUY/SELL execution blocks after this fix (they
   // call the 7-arg form above); kept only for compatibility.
   LiquidityLevel levels[];
   int cnt = CollectLiquidityLevels(levels);
   double best = 0; bool found = false;
   for(int i = 0; i < cnt; i++)
   {
      if(isBuy && levels[i].isHighSide && levels[i].price > entryPrice)
      {
         if(!found || levels[i].price < best) { best = levels[i].price; found = true; }
      }
      if(!isBuy && !levels[i].isHighSide && levels[i].price < entryPrice)
      {
         if(!found || levels[i].price > best) { best = levels[i].price; found = true; }
      }
   }
   if(found) { outTP = best; return true; }
   return false;
}

//====================================================================
// SESSION FILTER (V1.2 FIX: now evaluated in WIB, not raw broker/server time)
//====================================================================
// V1.1 used TimeCurrent() (broker server time) directly against
// InpAsiaStartHour/InpLondonStartHour/InpNYStartHour etc. Per V1.2 instruction,
// all schedule-dependent logic must use WIB. The session hour inputs themselves
// keep their meaning (Asia/London/NY hour boundaries) but are now compared
// against the WIB clock so session filtering is consistent with the rest of the
// V1.2 time architecture (trading window, daily reset, entry lock).
bool IsSessionAllowed()
{
   if(!InpUseSessionFilter) return true;

   int wibHour, wibMin; datetime wibDay, wibNow;
   GetWIBNow(wibHour, wibMin, wibDay, wibNow);

   int s = GetSessionOfHour(wibHour);
   if(s == 0) return InpTradeAsia;
   if(s == 1) return InpTradeLondon;
   if(s == 2) return InpTradeNY;
   return false; // outside defined sessions
}

//====================================================================
// V1.2.1 — TIME ARCHITECTURE: Broker Server Time -> UTC -> WIB (UTC+7)
//====================================================================
// DESIGN:
//   BrokerServerTime (TimeCurrent()) is the only clock this EA ever reads for
//   scheduling. Local PC time (TimeLocal()) is NEVER used for trading decisions.
//
//   To convert broker time to WIB we need the broker's GMT offset (hours from UTC).
//
//   V1.2.1 AUDIT FINDING (confirmed against official MQL5 documentation —
//   https://www.mql5.com/en/docs/dateandtime/timegmt):
//   "During testing in the strategy tester, TimeGMT() is always equal to
//    TimeTradeServer() simulated server time." This is deliberate platform
//   behavior (deterministic backtests without a live connection), not a bug
//   and not something an EA can work around by reading TimeGMT() differently.
//
//   Consequence for the OLD V1.2 auto-detect (serverTime - TimeGMT()): in the
//   Tester this difference is ALWAYS exactly 0, so the old sanity checks
//   (range -12..14, fractional-hour check) always PASSED and always returned
//   offset=0 with g_gmtDetectionValid=true — i.e. auto-detect did not fail
//   loudly in the Tester, it silently and confidently reported the WRONG
//   offset (GMT+0) whenever the real broker offset isn't 0. That is what
//   caused sessions/WIB to look wrong specifically in the Tester while
//   InpUseAutoGMT=true, and why disabling it (forcing a fixed manual value)
//   "worked" — the manual value was simply applied consistently.
//
//   FIX: explicit environment branching via MQLInfoInteger(MQL_TESTER),
//   resolved once into g_isTester at OnInit:
//
//   1) LIVE (g_isTester == false):
//      TimeGMT() vs TimeCurrent() auto-detection (as before) IS valid here —
//      the platform only forces equality in the Tester. Sanity/fractional
//      checks and the manual-fallback / safe-mode ambiguity handling are
//      unchanged.
//
//   2) TESTER (g_isTester == true):
//      DetectBrokerGMT() is never called — attempting it is worse than not
//      attempting it, since it would report a confident-looking but
//      structurally-guaranteed-wrong offset. InpManualBrokerGMT is used
//      directly as the deterministic, environment-independent source of
//      truth (a broker has exactly one real GMT offset; the same value is
//      correct whether read in the Tester or in Live — the user configures
//      it once, not per-environment). No safe-mode ambiguity check applies
//      here since there is no "auto failed" branch to be ambiguous about.
//
// WIB = UTC + 7, fixed (InpWIBOffsetHours), no DST in Indonesia.
//====================================================================

// Attempts auto GMT detection. Returns true if a plausible offset was found.
// Only meaningful/called in LIVE — see ResolveBrokerGMT().
bool DetectBrokerGMT(int &outOffsetHours)
{
   datetime serverTime = TimeCurrent();
   datetime gmtTime = TimeGMT();

   if(gmtTime <= 0)
      return false;

   double diffHours = (double)(serverTime - gmtTime) / 3600.0;
   int rounded = (int)MathRound(diffHours);

   // Sanity check: plausible broker GMT offsets are roughly -12 to +14.
   if(rounded < -12 || rounded > 14)
      return false;

   // Reject if the fractional part is far from a clean hour/half-hour boundary —
   // real broker offsets are whole or half hours; large fractional drift signals
   // an unreliable TimeGMT() source.
   double fractional = MathAbs(diffHours - rounded);
   if(fractional > 0.05)
      return false;

   outOffsetHours = rounded;
   return true;
}

// Resolves the broker GMT offset to use right now, applying the environment-
// aware policy described above. Called once per new bar (not every tick)
// to keep behavior stable and logs non-spammy.
void ResolveBrokerGMT()
{
   g_timeSafeMode = false;

   if(g_isTester)
   {
      // TESTER: TimeGMT() is guaranteed equal to TimeCurrent() by platform
      // design (see docs cited above) — auto-detection is structurally
      // impossible here, not merely "unreliable". Always use the manual
      // offset directly and deterministically. No safe-mode block: the user
      // is expected to configure InpManualBrokerGMT once (it's a static
      // broker property, not an environment-specific value), and blocking
      // an entire backtest over an unconfigured-looking 0 would be worse
      // than trusting the explicit input the user provided.
      g_detectedBrokerGMT = InpManualBrokerGMT;
      g_gmtDetectionValid = true;

      if(TimeCurrent() - g_lastGMTLogTime > 3600) // throttle to once/hour of simulated time
      {
         DLog("TIME", StringFormat("Environment=TESTER: AutoGMT detection skipped (TimeGMT()==TimeTradeServer() always in Tester, per MQL5 docs). Using ManualBrokerGMT=%+d.%s",
              InpManualBrokerGMT,
              InpManualBrokerGMT == 0 ? " (Verify this matches your broker's real GMT offset — 0 is the input default, not a detected value.)" : ""));
         g_lastGMTLogTime = TimeCurrent();
      }
      return;
   }

   // --- LIVE from here on ---
   if(InpUseAutoGMT)
   {
      int detected;
      if(DetectBrokerGMT(detected))
      {
         g_detectedBrokerGMT = detected;
         g_gmtDetectionValid = true;
         return;
      }

      // Auto detection failed — fall back to manual, but flag ambiguity if manual
      // was left at the untouched default (0), since we cannot distinguish
      // "broker really is GMT+0" from "user never configured this".
      g_gmtDetectionValid = false;
      g_detectedBrokerGMT = InpManualBrokerGMT;

      if(TimeCurrent() - g_lastGMTLogTime > 3600) // throttle to once/hour
      {
         DLog("TIME", "Environment=LIVE: AUTO GMT DETECTION FAILED. Falling back to ManualBrokerGMT="
              + IntegerToString(InpManualBrokerGMT)
              + (InpManualBrokerGMT == 0 ? " (WARNING: default/unconfigured value — verify this is correct for your broker)" : ""));
         g_lastGMTLogTime = TimeCurrent();
      }

      if(InpManualBrokerGMT == 0)
      {
         // Ambiguous: could genuinely be GMT+0, could be unconfigured. Do not
         // silently guess — block new time-dependent entries until the user
         // either fixes auto-detection or explicitly confirms GMT+0 by also
         // disabling InpUseAutoGMT (an explicit action), OR the value is
         // deliberately set to a distinct non-zero broker offset.
         g_timeSafeMode = true;
      }
   }
   else
   {
      // Auto detection explicitly disabled — manual value is trusted as-is
      // since the user made an explicit choice.
      g_detectedBrokerGMT = InpManualBrokerGMT;
      g_gmtDetectionValid = true;
   }
}

// Converts broker server time to WIB (UTC+7).
datetime BrokerTimeToWIB(datetime brokerTime)
{
   int offsetToUTC = -g_detectedBrokerGMT;               // brokerTime -> UTC
   datetime utc = brokerTime + offsetToUTC * 3600;
   datetime wib = utc + InpWIBOffsetHours * 3600;         // UTC -> WIB
   return wib;
}

// Extracts WIB hour/min/day-stamp from current broker time.
void GetWIBNow(int &outHour, int &outMin, datetime &outDayStamp, datetime &outWIBTime)
{
   datetime wibTime = BrokerTimeToWIB(TimeCurrent());
   MqlDateTime dt;
   TimeToStruct(wibTime, dt);
   outHour = dt.hour;
   outMin = dt.min;
   outWIBTime = wibTime;
   // Day stamp = wibTime floored to 00:00 WIB, but represented consistently
   // (we use the WIB-space datetime directly as the day key; this is internally
   // consistent since we only ever compare it against other outputs of this
   // same function).
   outDayStamp = wibTime - (dt.hour*3600 + dt.min*60 + dt.sec);
}

//====================================================================
// RISK MANAGEMENT
//====================================================================

//====================================================================
// V1.2 — GLOBAL VARIABLES OF TERMINAL: generic persistence helpers
//====================================================================
// Naming convention (avoids cross-position / cross-symbol / cross-EA collisions):
//   "LSMS_<Symbol>_<Magic>_<category>_<subkey>"
// Examples:
//   LSMS_XAUUSD_20260801_DAY_STAMP
//   LSMS_XAUUSD_20260801_DAY_EQUITY
//   LSMS_XAUUSD_20260801_POS_123456_INITENTRY
//   LSMS_XAUUSD_20260801_NOTIFY_ENTRY_123456
// Global Variables of Terminal are keyed by name only (no natural namespacing),
// so the Symbol+Magic+(Ticket where relevant) prefix is mandatory on every key
// this EA writes to prevent collisions with other EAs/symbols/instances.
string GVPrefix()
{
   return StringFormat("LSMS_%s_%d_", _Symbol, InpMagicNumber);
}

void GVSetDouble(string key, double value)
{
   GlobalVariableSet(GVPrefix() + key, value);
}

double GVGetDouble(string key, double defaultValue, bool &exists)
{
   string full = GVPrefix() + key;
   if(!GlobalVariableCheck(full)) { exists = false; return defaultValue; }
   exists = true;
   return GlobalVariableGet(full);
}

bool GVExists(string key)
{
   return GlobalVariableCheck(GVPrefix() + key);
}

void GVDelete(string key)
{
   GlobalVariableDel(GVPrefix() + key);
}

//====================================================================
// V1.2 — DAILY STATE (Global Variables, WIB calendar day — replaces V1.1 file-based
// broker-day tracking per instruction #9)
//====================================================================
// WHY GV instead of file: consistent with the unified persistence mechanism used
// for R-state/consecutive-loss/notifications (instruction: "Global Variables of
// Terminal + recovery dari posisi/deal history"). GV survives EA/MT5 restarts;
// it does NOT survive terminal data-folder wipe/reinstall — this is disclosed in
// Known Limitations.
//
// WIB DAY KEY: we store the WIB day-stamp (00:00 WIB expressed as a broker-time
// datetime via GetWIBNow()) rather than the broker's own midnight, so daily
// reset genuinely follows the WIB calendar day regardless of broker server
// timezone, per instruction #9/#15.
void SaveDailyState()
{
   GVSetDouble("DAY_STAMP", (double)g_currentWIBDay);
   GVSetDouble("DAY_EQUITY", g_dayStartEquity);
   GVSetDouble("DAY_TRADES", (double)g_dailyTrades);
   GVSetDouble("DAY_WINS", (double)g_dailyWins);
   GVSetDouble("DAY_LOSSES", (double)g_dailyLosses);
   GVSetDouble("DAY_PL", g_dailyPL);
   GVSetDouble("DAY_OPEN_NOTIFIED", g_dailyOpenNotified ? 1.0 : 0.0);
   GVSetDouble("DAY_CLOSE_NOTIFIED", g_dailyCloseNotified ? 1.0 : 0.0);
   GVSetDouble("CONSEC_LOSSES", (double)g_consecLosses);
   GVSetDouble("COOLDOWN_UNTIL_BAR", (double)g_cooldownUntilBar);
}

bool LoadDailyState()
{
   bool ex;
   double dayStamp = GVGetDouble("DAY_STAMP", 0, ex);
   if(!ex) return false;

   g_currentWIBDay   = (datetime)dayStamp;
   g_dayStartEquity  = GVGetDouble("DAY_EQUITY", 0, ex);
   g_dailyTrades     = (int)GVGetDouble("DAY_TRADES", 0, ex);
   g_dailyWins       = (int)GVGetDouble("DAY_WINS", 0, ex);
   g_dailyLosses     = (int)GVGetDouble("DAY_LOSSES", 0, ex);
   g_dailyPL         = GVGetDouble("DAY_PL", 0, ex);
   g_dailyOpenNotified  = GVGetDouble("DAY_OPEN_NOTIFIED", 0, ex) >= 0.5;
   g_dailyCloseNotified = GVGetDouble("DAY_CLOSE_NOTIFIED", 0, ex) >= 0.5;
   // consecutive loss / cooldown restored separately by RestoreConsecutiveLossState()
   // (kept as a distinct function for clarity per instruction #39)
   return true;
}

// Called once per new bar. Detects WIB calendar-day rollover (00:00 WIB) and
// performs the daily reset — trade counters, daily P/L, daily-loss baseline,
// and the 06:00/22:00 notification flags for the new day.
// CRITICAL: this must NOT fire merely because OnInit() ran (instruction #35) —
// it only fires when the WIB day-stamp actually changes relative to the
// persisted value, which we load first in OnInit() before this is ever called.
void ResetDailyTrackingIfNeeded()
{
   int wibHour, wibMin; datetime wibDay, wibNow;
   GetWIBNow(wibHour, wibMin, wibDay, wibNow);

   if(wibDay != g_currentWIBDay)
   {
      g_currentWIBDay      = wibDay;
      g_dayStartEquity     = AccountInfoDouble(ACCOUNT_EQUITY);
      g_dailyTrades        = 0;
      g_dailyWins          = 0;
      g_dailyLosses        = 0;
      g_dailyPL            = 0;
      g_dailyOpenNotified  = false;
      g_dailyCloseNotified = false;
      // Consecutive loss count resets on genuine WIB day rollover (instruction #39:
      // "Jika sudah masuk hari baru WIB: reset sesuai aturan daily reset").
      g_consecLosses       = 0;
      g_cooldownUntilBar   = -1;

      SaveDailyState();
      GVSetDouble("CONSEC_LOSSES", 0);
      GVSetDouble("COOLDOWN_UNTIL_BAR", -1);

      DLog("RISK", StringFormat("New WIB trading day (00:00 WIB rollover). Start equity=%.2f (persisted to GV)", g_dayStartEquity));
   }
}

bool IsDailyLossLimitHit()
{
   if(g_dayStartEquity <= 0) return false;
   double eq = AccountInfoDouble(ACCOUNT_EQUITY);
   double lossPct = (g_dayStartEquity - eq) / g_dayStartEquity * 100.0;
   return (lossPct >= InpMaxDailyLossPercent);
}

bool IsCooldownActive()
{
   return (g_barIndexCounter < g_cooldownUntilBar);
}

// V1.2: fallback net only (primary path is OnTradeTransaction, instruction #40).
// Scans recent history for closed deals belonging to this EA and routes them
// through the SAME ProcessClosedDeal() function used by OnTradeTransaction.
// Because ProcessClosedDeal() itself guards on the GV-backed dedup key
// ("DEALCLOSE_<ticket>"), any deal already handled via OnTradeTransaction is
// silently skipped here — there is no scenario where a deal is counted twice
// toward consecutive-loss/daily-stats, regardless of which path sees it first.
void UpdateConsecutiveLossTracking()
{
   if(!HistorySelect(TimeCurrent()-86400*3, TimeCurrent())) return;
   int total = HistoryDealsTotal();
   if(total == 0) return;

   // Scan the most recent handful of deals (not the whole 3-day window every
   // bar) — this is a safety net for missed events, not the primary path, so
   // a small recent window is sufficient and keeps this cheap.
   int scanCount = MathMin(total, 20);
   for(int i = total - scanCount; i < total; i++)
   {
      if(i < 0) continue;
      ulong ticket = HistoryDealGetTicket(i);
      if(ticket == 0) continue;
      if(HistoryDealGetInteger(ticket, DEAL_MAGIC) != (long)InpMagicNumber) continue;
      if(HistoryDealGetString(ticket, DEAL_SYMBOL) != _Symbol) continue;
      ENUM_DEAL_ENTRY entryType = (ENUM_DEAL_ENTRY)HistoryDealGetInteger(ticket, DEAL_ENTRY);
      if(entryType != DEAL_ENTRY_OUT && entryType != DEAL_ENTRY_OUT_BY) continue;

      ProcessClosedDeal(ticket); // no-ops internally if already processed
   }
}

// ---------------------------------------------------------------------------
// V1.2.1 ADAPTIVE SMALL-EQUITY RISK ENGINE
// ---------------------------------------------------------------------------
// Priority order (per spec §9):
//   VALID SIGNAL -> VALID ENTRY -> STRUCTURAL SL -> CALCULATE TARGET LOT ->
//   CHECK BROKER MIN/MAX/STEP -> IF LOT < MIN -> MINIMUM LOT FALLBACK ->
//   CALCULATE ACTUAL RISK -> ACTUAL RISK <= MAX? -> ALLOW / BLOCK
//
// This replaces the old CalculateLotSize() body. The old function name is kept
// as a thin wrapper below so existing callers (ExecuteBuy/ExecuteSell) do not
// need to change their call sites at all — only the internals changed.
//
// Structural SL is NEVER modified here. If the SL the caller passed in yields
// too much actual risk even at broker-minimum lot, the trade is BLOCKED, not
// resized by moving SL.
double CalculateLotSizeAdaptive(double slDistance, double &outActualRiskPercent, string &outDecision)
{
   outActualRiskPercent = 0;
   outDecision = "REJECTED";

   if(slDistance <= 0)
   {
      outDecision = "INVALID_SL_DISTANCE";
      return 0;
   }

   g_statValidSignals++;

   // --- STEP 1: target lot from CURRENT equity + target RiskPercent ---
   double equity = AccountInfoDouble(ACCOUNT_EQUITY); // always current equity, never cached/initial
   double targetRiskAmount = equity * (InpRiskPercent / 100.0);

   double tickValue = SymbolInfoDouble(_Symbol, SYMBOL_TRADE_TICK_VALUE);
   double tickSize  = SymbolInfoDouble(_Symbol, SYMBOL_TRADE_TICK_SIZE);
   if(tickSize <= 0 || tickValue <= 0)
   {
      outDecision = "INVALID_SYMBOL_PROPERTIES";
      g_statRiskBlockCount++;
      DLog("RISK", StringFormat("[RISK BLOCK] Equity=%.2f Reason=INVALID_TICK_VALUE_OR_SIZE (tickValue=%.5f tickSize=%.5f)",
           equity, tickValue, tickSize));
      return 0;
   }

   double valuePerPriceUnit = tickValue / tickSize;
   double lossPerLot = slDistance * valuePerPriceUnit;
   if(lossPerLot <= 0)
   {
      outDecision = "INVALID_LOSS_PER_LOT";
      g_statRiskBlockCount++;
      return 0;
   }

   double minLot  = SymbolInfoDouble(_Symbol, SYMBOL_VOLUME_MIN);
   double maxLot  = SymbolInfoDouble(_Symbol, SYMBOL_VOLUME_MAX);
   double lotStep = SymbolInfoDouble(_Symbol, SYMBOL_VOLUME_STEP);
   if(minLot <= 0 || lotStep <= 0)
   {
      outDecision = "INVALID_BROKER_VOLUME_PROPS";
      g_statRiskBlockCount++;
      DLog("RISK", StringFormat("[RISK BLOCK] Equity=%.2f Reason=INVALID_BROKER_VOLUME_PROPS (min=%.5f step=%.5f)",
           equity, minLot, lotStep));
      return 0;
   }

   double targetLots = targetRiskAmount / lossPerLot;
   targetLots = MathFloor(targetLots / lotStep) * lotStep;

   // --- STEP 2: calculated lot meets broker minimum -> use it normally ---
   if(targetLots >= minLot)
   {
      double normalLots = MathMin(targetLots, maxLot);
      double normalLossAmount = normalLots * lossPerLot;
      outActualRiskPercent = (equity > 0) ? (normalLossAmount / equity * 100.0) : 0;
      outDecision = "NORMAL";
      g_statExecutedTradeCount++;

      if(InpEnableRiskDebug)
         DLog("RISK", StringFormat(
            "[RISK OK] Equity=%.2f TargetRisk=%.2f%% CalculatedLot=%.4f ActualRisk=%.2f%% Decision=NORMAL_LOT",
            equity, InpRiskPercent, normalLots, outActualRiskPercent));

      return normalLots;
   }

   // --- STEP 3: calculated lot < broker minimum -> minimum-lot fallback path ---
   if(!InpUseMinimumLotFallback)
   {
      outDecision = "BELOW_MIN_FALLBACK_DISABLED";
      g_statRiskBlockCount++;
      DLog("RISK", StringFormat(
         "[RISK BLOCK] Equity=%.2f TargetRisk=%.2f%% CalculatedLot=%.4f BrokerMinLot=%.4f Reason=BELOW_MIN_FALLBACK_DISABLED",
         equity, InpRiskPercent, targetLots, minLot));
      return 0;
   }

   g_statMinLotFallbackCount++;

   // Simulate the position at broker-minimum lot and compute the REAL monetary
   // loss if SL is hit — this is the actual risk, independent of the target
   // risk amount computed above.
   double fallbackLot = minLot;
   double fallbackLossAmount = fallbackLot * lossPerLot;
   double actualRiskPercent = (equity > 0) ? (fallbackLossAmount / equity * 100.0) : 1e9;
   outActualRiskPercent = actualRiskPercent;

   // --- STEP 4: actual risk at minimum lot vs MaxFallbackRiskPercent ---
   if(actualRiskPercent <= InpMaxFallbackRiskPercent)
   {
      outDecision = "ALLOW_MIN_LOT";
      g_statExecutedTradeCount++;

      DLog("RISK", StringFormat(
         "[RISK FALLBACK] Equity=%.2f TargetRisk=%.2f%% CalculatedLot=%.4f BrokerMinLot=%.4f ActualRisk=%.2f%% MaxAllowedRisk=%.2f%% Decision=ALLOW_MIN_LOT",
         equity, InpRiskPercent, targetLots, minLot, actualRiskPercent, InpMaxFallbackRiskPercent));

      return fallbackLot;
   }

   // Actual risk at minimum lot exceeds the hard cap — BLOCK. Structural SL is
   // NOT touched/moved to try to make this fit.
   outDecision = "ACTUAL_RISK_TOO_HIGH";
   g_statRiskBlockCount++;
   g_statActualRiskBlockCount++;

   DLog("RISK", StringFormat(
      "[RISK BLOCK] Equity=%.2f TargetRisk=%.2f%% CalculatedLot=%.4f BrokerMinLot=%.4f FallbackLot=%.4f ActualRisk=%.2f%% MaxAllowedRisk=%.2f%% Reason=ACTUAL_RISK_TOO_HIGH",
      equity, InpRiskPercent, targetLots, minLot, fallbackLot, actualRiskPercent, InpMaxFallbackRiskPercent));

   return 0;
}

// Backward-compatible wrapper: existing call sites (ExecuteBuy/ExecuteSell) call
// this exact signature unchanged. Internally now routed through the adaptive
// engine above.
double CalculateLotSize(double slDistance)
{
   double actualRisk = 0;
   string decision = "";
   return CalculateLotSizeAdaptive(slDistance, actualRisk, decision);
}

int CountOpenPositions()
{
   int cnt = 0;
   for(int i = 0; i < PositionsTotal(); i++)
   {
      ulong ticket = PositionGetTicket(i);
      if(ticket == 0) continue;
      if(PositionGetString(POSITION_SYMBOL) != _Symbol) continue;
      if(PositionGetInteger(POSITION_MAGIC) != (long)InpMagicNumber) continue;
      cnt++;
   }
   return cnt;
}

//====================================================================
// V1.2.1 — AUTO ADAPTIVE SPREAD FILTER
//====================================================================
// Design goal: ONE codebase that works unmodified in both Strategy Tester
// and Live, without hand-flipping any parameter between environments.
//
// CurrentSpreadPoints is read via SYMBOL_SPREAD (broker-reported, already
// normalized to the current symbol's own point/digit convention — correct
// across 2/3/5-digit symbols and different brokers with no hardcoding).
// This works correctly in BOTH environments already: MT5 itself resolves
// SYMBOL_SPREAD from real historical tick data in the Tester ("Every tick
// based on real ticks" replays actual recorded Bid/Ask, spread included)
// and from the live broker feed in Live. A manual (Ask-Bid)/_Point fallback
// is kept only as a defensive cross-check if SYMBOL_SPREAD is ever 0/stale
// (e.g. right at startup before the first quote arrives).
//
// The environment flag (g_isTester) is used ONLY to label the debug log —
// it does not change which spread source is read, because none is needed:
// the platform already gives the actual environment's real spread either way.
//
// Layered decision, per spec:
//   TypicalSpreadPoints  = rolling MEDIAN of recent samples (robust to a
//                           single spike, unlike mean or a single tick read)
//   AdaptiveLimitPoints  = TypicalSpreadPoints x InpSpreadAdaptiveMult
//   HardMaxSpreadPoints  = absolute ceiling, independent of the adaptive math
//   Startup / insufficient sample (< InpSpreadMinSamples) -> safe fallback
//   (InpMaxSpreadPoints), never a loose/open limit.
//====================================================================

double ReadCurrentSpreadPoints()
{
   long spreadPoints = SymbolInfoInteger(_Symbol, SYMBOL_SPREAD);
   if(spreadPoints > 0)
      return (double)spreadPoints;

   // Defensive fallback only (startup / stale quote edge case): compute
   // directly from Bid/Ask so a transient 0 never masquerades as "no spread".
   double bid = SymbolInfoDouble(_Symbol, SYMBOL_BID);
   double ask = SymbolInfoDouble(_Symbol, SYMBOL_ASK);
   if(bid <= 0 || ask <= 0 || _Point <= 0)
      return 0;
   return (ask - bid) / _Point;
}

// Pushes a new spread sample into the circular rolling buffer. Called once
// per new closed bar (same cadence as ProcessSetupStateMachine) so the
// baseline reflects recent market conditions without being tick-noise-driven
// or growing unbounded.
void PushSpreadSample(double spreadPoints)
{
   if(spreadPoints <= 0) return; // don't pollute the baseline with invalid reads

   // V1.2.1 AUDIT FIX (found via live backtest log review): the rolling
   // buffer was accepting spread samples with NO upper bound, including
   // abnormal spikes (e.g. 500-600 pts on a broker whose HardMax=400).
   // Because TypicalSpread is the median of this buffer, enough spike
   // samples accumulating in the window pushed the median itself up,
   // which in turn pushed AdaptiveLimit (median x multiplier) up past
   // HardMax — observed in practice as AdaptiveLimit=840 while
   // HardMax=400. HardMax still correctly rejected those ticks (it is
   // checked independently), so no bad trade was let through, but this
   // directly violated the stated design principle: "a spread spike must
   // NOT be allowed to raise the limit that is supposed to catch it."
   // Fix: samples above HardMax are excluded from the baseline entirely —
   // they are genuine abnormal-condition ticks, not "typical" market
   // conditions, so they must not shape what "typical" means.
   if(spreadPoints > InpHardMaxSpreadPoints)
      return;

   int cap = MathMax(InpSpreadSampleSize, 1);
   if(ArraySize(g_spreadSamples) != cap)
      ArrayResize(g_spreadSamples, cap);

   g_spreadSamples[g_spreadWriteIdx] = spreadPoints;
   g_spreadWriteIdx = (g_spreadWriteIdx + 1) % cap;
   g_spreadSampleCount++;
}

// Robust median of currently-filled samples (handles the circular buffer not
// yet being full at startup).
double ComputeTypicalSpread()
{
   int cap = ArraySize(g_spreadSamples);
   int n = MathMin(g_spreadSampleCount, cap);
   if(n <= 0) return 0;

   double tmp[];
   ArrayResize(tmp, n);
   for(int i = 0; i < n; i++) tmp[i] = g_spreadSamples[i];
   ArraySort(tmp); // ascending

   if(n % 2 == 1)
      return tmp[n/2];
   return (tmp[n/2 - 1] + tmp[n/2]) / 2.0;
}

bool IsSpreadOK()
{
   double currentSpread = ReadCurrentSpreadPoints();
   string envLabel = g_isTester ? "TESTER" : "LIVE";
   int digits = (int)SymbolInfoInteger(_Symbol, SYMBOL_DIGITS);
   double bid = SymbolInfoDouble(_Symbol, SYMBOL_BID);
   double ask = SymbolInfoDouble(_Symbol, SYMBOL_ASK);

   bool pass;
   string reason;
   double adaptiveLimit;
   double typicalSpread = ComputeTypicalSpread();
   int haveSamples = MathMin(g_spreadSampleCount, ArraySize(g_spreadSamples));

   if(!InpUseAdaptiveSpread)
   {
      // Adaptive engine disabled — behave exactly like the original static
      // filter (kept for backward compatibility / A-B comparison).
      adaptiveLimit = InpMaxSpreadPoints;
      pass = (currentSpread <= InpMaxSpreadPoints) && (currentSpread <= InpHardMaxSpreadPoints);
      reason = pass ? "OK" : (currentSpread > InpHardMaxSpreadPoints ? "SPREAD_ABOVE_HARD_CAP" : "SPREAD_ABOVE_STATIC_LIMIT");
   }
   else if(haveSamples < InpSpreadMinSamples)
   {
      // Startup / insufficient sample -> safe fallback, NOT a loose limit.
      adaptiveLimit = InpMaxSpreadPoints;
      pass = (currentSpread <= InpMaxSpreadPoints) && (currentSpread <= InpHardMaxSpreadPoints);
      reason = pass ? "OK_SAFE_FALLBACK" : "SPREAD_ABOVE_FALLBACK_LIMIT_INSUFFICIENT_SAMPLES";
   }
   else
   {
      adaptiveLimit = typicalSpread * InpSpreadAdaptiveMult;
      // Adaptive limit is still bounded below by nothing looser than the
      // configured safe fallback would allow AND above by the hard cap —
      // i.e. a spike in TypicalSpread itself (sustained wide-spread regime)
      // can raise the limit somewhat, but never past InpHardMaxSpreadPoints,
      // and a single-tick spike does not move TypicalSpread much because it
      // is a median over InpSpreadSampleSize samples.
      if(currentSpread > InpHardMaxSpreadPoints)
      {
         pass = false;
         reason = "SPREAD_ABOVE_HARD_CAP";
      }
      else if(currentSpread > adaptiveLimit)
      {
         pass = false;
         reason = "SPREAD_ABOVE_ADAPTIVE_LIMIT";
      }
      else
      {
         pass = true;
         reason = "OK";
      }
   }

   g_statTypicalSpread = typicalSpread;
   g_statAdaptiveLimit = adaptiveLimit;

   if(InpEnableSpreadDebug)
   {
      DLog("SPREAD AUTO", StringFormat(
         "Environment=%s Symbol=%s Digits=%d Point=%s Bid=%s Ask=%s Current=%.0f Typical=%.1f AdaptiveLimit=%.1f HardMax=%.0f Samples=%d/%d Decision=%s Reason=%s",
         envLabel, _Symbol, digits, DoubleToString(_Point, digits+1),
         DoubleToString(bid, digits), DoubleToString(ask, digits),
         currentSpread, typicalSpread, adaptiveLimit, InpHardMaxSpreadPoints,
         haveSamples, InpSpreadSampleSize,
         pass ? "ALLOW" : "REJECT", reason));
   }

   DLog("SPREAD", StringFormat("Current: %.0f points | AdaptiveLimit: %.1f | HardMax: %.0f | Status: %s",
        currentSpread, adaptiveLimit, InpHardMaxSpreadPoints, pass ? "PASS" : "BLOCKED"));

   return pass;
}

//====================================================================
// SL/TP NORMALIZATION
//====================================================================
double NormalizeSLTP(double price)
{
   int digits = (int)SymbolInfoInteger(_Symbol, SYMBOL_DIGITS);
   return NormalizeDouble(price, digits);
}

bool ValidateStopDistance(double price, double stopPrice)
{
   double minStop = SymbolInfoInteger(_Symbol, SYMBOL_TRADE_STOPS_LEVEL) * _Point;
   double dist = MathAbs(price - stopPrice);
   return (dist >= minStop || minStop == 0);
}

//====================================================================
// ENTRY EXECUTION
//====================================================================
bool ExecuteBuy(double entryLevel, double slPrice, double tpPrice, string reasonLabel)
{
   // V1.2: 22:00 WIB entry lock — existing setups are logged as blocked, not
   // treated as failed trades (instruction #12/#42).
   if(!g_entryAllowed)
   {
      DLog("REJECTED", "VALID SETUP DETECTED / ENTRY BLOCKED. Reason: OUTSIDE TRADING WINDOW (22:00-05:59 WIB entry lock).");
      return false;
   }
   if(g_safeRecoveryMode)
   {
      DLog("REJECTED", "ENTRY BLOCKED. Reason: SAFE RECOVERY MODE active — " + g_safeRecoveryReason);
      return false;
   }

   if(CountOpenPositions() >= InpMaxOpenPositions)
   {
      DLog("REJECTED", "Max open positions reached, BUY skipped");
      return false;
   }

   double ask = SymbolInfoDouble(_Symbol, SYMBOL_ASK);

   slPrice = NormalizeSLTP(slPrice);
   tpPrice = NormalizeSLTP(tpPrice);

   double slDist = MathAbs(ask - slPrice);
   double lots = CalculateLotSize(slDist);

   if(lots <= 0)
   {
      DLog("REJECTED", "Lot size calculation resulted in 0 (see RISK_REJECTED above if applicable), BUY skipped");
      return false;
   }

   if(!ValidateStopDistance(ask, slPrice))
   {
      DLog("REJECTED", "SL too close to current price (broker min stop distance), BUY skipped");
      return false;
   }

   trade.SetExpertMagicNumber(InpMagicNumber);
   trade.SetDeviationInPoints(InpSlippagePoints);

   bool sent = trade.Buy(lots, _Symbol, ask, slPrice, tpPrice, reasonLabel);

   uint retcode = trade.ResultRetcode();
   ulong dealTicket = trade.ResultDeal();
   ulong orderTicket = trade.ResultOrder();
   string retDesc = trade.ResultRetcodeDescription();

   bool success = sent && (retcode == TRADE_RETCODE_DONE || retcode == TRADE_RETCODE_DONE_PARTIAL) && dealTicket != 0;

   if(!success)
   {
      DLog("EXECUTION", StringFormat("BUY FAILED. sent=%s retcode=%u (%s) deal=%I64u order=%I64u",
           sent?"true":"false", retcode, retDesc, dealTicket, orderTicket));
      // Instruction #6/#25: failed order -> no MarkLevelUsed (handled by caller),
      // no ENTRY notification.
      return false;
   }

   DLog("EXECUTION", StringFormat("BUY CONFIRMED. retcode=%u (%s) deal=%I64u order=%I64u lots=%.2f price=%.5f",
        retcode, retDesc, dealTicket, orderTicket, lots, ask));

   // Resolve the actual position ticket opened by this deal (position ticket
   // equals the opening order ticket in MT5 netting/hedging conventions used
   // here since MaxOpenPositions gating prevents ambiguity from pre-existing
   // same-symbol EA positions).
   ulong posTicket = HistoryDealGetInteger(dealTicket, DEAL_POSITION_ID);
   if(posTicket == 0) posTicket = orderTicket; // fallback if position id unavailable at this instant

   double actualEntryPrice = HistoryDealGetDouble(dealTicket, DEAL_PRICE);
   if(actualEntryPrice <= 0) actualEntryPrice = ask;

   RegisterNewPositionRState(posTicket, actualEntryPrice, slPrice);

   if(InpNotifyOnEntry)
   {
      int wibHour, wibMin; datetime wibDay, wibNow;
      GetWIBNow(wibHour, wibMin, wibDay, wibNow);
      string msg = StringFormat("🔵 IKYY LSMS — ENTRY\n%s BUY #%I64u\nEntry: %.2f | SL: %.2f | TP: %.2f\nLot: %.2f | Risk: %.1f%%\nSetup: SWEEP + DISPLACEMENT + MSS\nTime: %02d:%02d WIB\nStatus: OPEN",
         _Symbol, posTicket, actualEntryPrice, slPrice, tpPrice, lots, InpRiskPercent, wibHour, wibMin);
      SendSafeNotificationOnce(StringFormat("ENTRY_%I64u", posTicket), msg);
   }

   return true;
}

bool ExecuteSell(double entryLevel, double slPrice, double tpPrice, string reasonLabel)
{
   if(!g_entryAllowed)
   {
      DLog("REJECTED", "VALID SETUP DETECTED / ENTRY BLOCKED. Reason: OUTSIDE TRADING WINDOW (22:00-05:59 WIB entry lock).");
      return false;
   }
   if(g_safeRecoveryMode)
   {
      DLog("REJECTED", "ENTRY BLOCKED. Reason: SAFE RECOVERY MODE active — " + g_safeRecoveryReason);
      return false;
   }

   if(CountOpenPositions() >= InpMaxOpenPositions)
   {
      DLog("REJECTED", "Max open positions reached, SELL skipped");
      return false;
   }

   double bid = SymbolInfoDouble(_Symbol, SYMBOL_BID);

   slPrice = NormalizeSLTP(slPrice);
   tpPrice = NormalizeSLTP(tpPrice);

   double slDist = MathAbs(slPrice - bid);
   double lots = CalculateLotSize(slDist);

   if(lots <= 0)
   {
      DLog("REJECTED", "Lot size calculation resulted in 0 (see RISK_REJECTED above if applicable), SELL skipped");
      return false;
   }

   if(!ValidateStopDistance(bid, slPrice))
   {
      DLog("REJECTED", "SL too close to current price (broker min stop distance), SELL skipped");
      return false;
   }

   trade.SetExpertMagicNumber(InpMagicNumber);
   trade.SetDeviationInPoints(InpSlippagePoints);

   bool sent = trade.Sell(lots, _Symbol, bid, slPrice, tpPrice, reasonLabel);

   uint retcode = trade.ResultRetcode();
   ulong dealTicket = trade.ResultDeal();
   ulong orderTicket = trade.ResultOrder();
   string retDesc = trade.ResultRetcodeDescription();

   bool success = sent && (retcode == TRADE_RETCODE_DONE || retcode == TRADE_RETCODE_DONE_PARTIAL) && dealTicket != 0;

   if(!success)
   {
      DLog("EXECUTION", StringFormat("SELL FAILED. sent=%s retcode=%u (%s) deal=%I64u order=%I64u",
           sent?"true":"false", retcode, retDesc, dealTicket, orderTicket));
      return false;
   }

   DLog("EXECUTION", StringFormat("SELL CONFIRMED. retcode=%u (%s) deal=%I64u order=%I64u lots=%.2f price=%.5f",
        retcode, retDesc, dealTicket, orderTicket, lots, bid));

   ulong posTicket = HistoryDealGetInteger(dealTicket, DEAL_POSITION_ID);
   if(posTicket == 0) posTicket = orderTicket;

   double actualEntryPrice = HistoryDealGetDouble(dealTicket, DEAL_PRICE);
   if(actualEntryPrice <= 0) actualEntryPrice = bid;

   RegisterNewPositionRState(posTicket, actualEntryPrice, slPrice);

   if(InpNotifyOnEntry)
   {
      int wibHour, wibMin; datetime wibDay, wibNow;
      GetWIBNow(wibHour, wibMin, wibDay, wibNow);
      string msg = StringFormat("🔵 IKYY LSMS — ENTRY\n%s SELL #%I64u\nEntry: %.2f | SL: %.2f | TP: %.2f\nLot: %.2f | Risk: %.1f%%\nSetup: SWEEP + DISPLACEMENT + MSS\nTime: %02d:%02d WIB\nStatus: OPEN",
         _Symbol, posTicket, actualEntryPrice, slPrice, tpPrice, lots, InpRiskPercent, wibHour, wibMin);
      SendSafeNotificationOnce(StringFormat("ENTRY_%I64u", posTicket), msg);
   }

   return true;
}

//====================================================================
// SETUP STATE MACHINE — runs once per new closed bar only
//====================================================================
void ResetSetup()
{
   g_setup.stage = STAGE_NONE;
   g_setup.barsWaited = 0;
}

void ProcessSetupStateMachine()
{
   // Feed the adaptive spread baseline once per closed bar — same cadence as
   // every other decision in this EA (no intrabar/tick-noise sampling).
   PushSpreadSample(ReadCurrentSpreadPoints());

   // --- Global gating checks first ---
   ResetDailyTrackingIfNeeded();

   if(IsDailyLossLimitHit())
   {
      DLog("REJECTED", "Daily loss limit reached. No new setups today.");
      return;
   }
   if(IsCooldownActive())
   {
      DLog("REJECTED", "Cooldown active after consecutive losses.");
      return;
   }
   if(!IsSessionAllowed())
   {
      DLog("REJECTED", "Outside allowed trading session.");
      return;
   }
   if(!IsSpreadOK())
   {
      DLog("REJECTED", StringFormat("Spread too high (Current=%.0f pts, AdaptiveLimit=%.1f, HardMax=%.0f). Setup skipped.",
           ReadCurrentSpreadPoints(), g_statAdaptiveLimit, InpHardMaxSpreadPoints));
      return;
   }

   // V1.2.1 AUDIT FIX: regime is NO LONGER evaluated here. The old placement
   // ran DetectRegime() unconditionally every closed bar and called
   // ResetSetup() whenever it read CHOPPY — which, combined with CHOPPY
   // being an over-broad fallback, wiped in-progress multi-bar setups
   // (STAGE_SWEPT / STAGE_DISPLACED) before they could ever reach MSS
   // confirmation. Regime is now checked once, at the moment a setup is
   // FULLY CONFIRMED (MSS/BOS just broke, immediately before execution) —
   // see the two CheckMSSBullish/CheckMSSBearish blocks below. This matches
   // the requested flow: VALID LSMS SETUP -> REGIME ACCEPT/REJECT -> RISK
   // ACCEPT/REJECT -> EXECUTED. Regime remains a HARD FILTER (Option A) —
   // only the evaluation point moved, not the strictness.

   // --- STAGE_NONE: search for a fresh sweep on the just-closed bar ---
   if(g_setup.stage == STAGE_NONE)
   {
      LiquidityLevel levels[];
      int cnt = CollectLiquidityLevels(levels);

      for(int i = 0; i < cnt; i++)
      {
         if(IsLevelUsed(levels[i].price)) continue;

         if(levels[i].isHighSide)
         {
            double sweepHigh, closeVal;
            if(CheckSweepUpside(levels[i].price, sweepHigh, closeVal))
            {
               g_setup.stage = STAGE_SWEPT;
               g_setup.isBullish = false; // sweep of resistance -> looking for SELL (bearish reversal)
               g_setup.liquidityLevel = levels[i].price;
               g_setup.liquidityTime = levels[i].time;
               g_setup.sweepExtreme = sweepHigh;
               g_setup.sweepTime = iTime(_Symbol, PERIOD_CURRENT, 1);
               g_setup.barsWaited = 0;
               DLog("LIQUIDITY", StringFormat("Level=%s price=%.5f", levels[i].label, levels[i].price));
               DLog("SWEEP", StringFormat("Upside sweep detected. High=%.5f Close=%.5f -> watching for bearish displacement", sweepHigh, closeVal));
               break;
            }
         }
         else
         {
            double sweepLow, closeVal;
            if(CheckSweepDownside(levels[i].price, sweepLow, closeVal))
            {
               g_setup.stage = STAGE_SWEPT;
               g_setup.isBullish = true; // sweep of support -> looking for BUY (bullish reversal)
               g_setup.liquidityLevel = levels[i].price;
               g_setup.liquidityTime = levels[i].time;
               g_setup.sweepExtreme = sweepLow;
               g_setup.sweepTime = iTime(_Symbol, PERIOD_CURRENT, 1);
               g_setup.barsWaited = 0;
               DLog("LIQUIDITY", StringFormat("Level=%s price=%.5f", levels[i].label, levels[i].price));
               DLog("SWEEP", StringFormat("Downside sweep detected. Low=%.5f Close=%.5f -> watching for bullish displacement", sweepLow, closeVal));
               break;
            }
         }
      }
      return; // wait for next bar to look for displacement
   }

   // --- STAGE_SWEPT: waiting for displacement candle ---
   if(g_setup.stage == STAGE_SWEPT)
   {
      g_setup.barsWaited++;
      if(g_setup.barsWaited > InpMaxWaitBars)
      {
         g_statDisplacementTimeoutCount++;
         DLog("REJECTED", "Displacement window expired. Setup discarded.");
         ResetSetup();
         return;
      }

      if(g_setup.isBullish)
      {
         string dispMode, dispReason;
         if(CheckDisplacementBullish(1, dispMode, dispReason))
         {
            g_setup.stage = STAGE_DISPLACED;
            // displacementExtreme is reference/logging only (does not feed
            // SL/TP/RR, which are computed from g_setup.sweepExtreme) — for
            // 2-candle mode, reflect the true combined extreme (max of the
            // two candles' highs) rather than just the closing candle's.
            double extreme1 = iHigh(_Symbol, PERIOD_CURRENT, 1);
            g_setup.displacementExtreme = (dispMode == "2-CANDLE") ? MathMax(extreme1, iHigh(_Symbol, PERIOD_CURRENT, 2)) : extreme1;
            g_setup.barsWaited = 0;
            g_statDisplacementDetected++;
            if(dispMode == "2-CANDLE") g_statDisplacement2CandleCount++; else g_statDisplacement1CandleCount++;
            DLog("DISPLACEMENT", StringFormat("Bullish displacement confirmed [%s] @ %.5f (range=%.5f)", dispMode, iClose(_Symbol,PERIOD_CURRENT,1), CandleRange(1)));
         }
         else if(InpEnableDisplacementDebug)
         {
            DLog("DISPLACEMENT DEBUG", StringFormat("Bullish check failed. Mode=%s Reason=%s BarsWaited=%d/%d", dispMode, dispReason, g_setup.barsWaited, InpMaxWaitBars));
         }
      }
      else
      {
         string dispMode, dispReason;
         if(CheckDisplacementBearish(1, dispMode, dispReason))
         {
            g_setup.stage = STAGE_DISPLACED;
            double extreme1 = iLow(_Symbol, PERIOD_CURRENT, 1);
            g_setup.displacementExtreme = (dispMode == "2-CANDLE") ? MathMin(extreme1, iLow(_Symbol, PERIOD_CURRENT, 2)) : extreme1;
            g_setup.barsWaited = 0;
            g_statDisplacementDetected++;
            if(dispMode == "2-CANDLE") g_statDisplacement2CandleCount++; else g_statDisplacement1CandleCount++;
            DLog("DISPLACEMENT", StringFormat("Bearish displacement confirmed [%s] @ %.5f (range=%.5f)", dispMode, iClose(_Symbol,PERIOD_CURRENT,1), CandleRange(1)));
         }
         else if(InpEnableDisplacementDebug)
         {
            DLog("DISPLACEMENT DEBUG", StringFormat("Bearish check failed. Mode=%s Reason=%s BarsWaited=%d/%d", dispMode, dispReason, g_setup.barsWaited, InpMaxWaitBars));
         }
      }
      return;
   }

   // --- STAGE_DISPLACED: waiting for MSS/BOS confirmation ---
   if(g_setup.stage == STAGE_DISPLACED)
   {
      g_setup.barsWaited++;
      if(g_setup.barsWaited > InpMaxWaitBars)
      {
         g_statMSSTimeoutCount++;
         DLog("REJECTED", "MSS/BOS confirmation window expired. Setup discarded.");
         ResetSetup();
         return;
      }

      double swingBroken;
      int mssCandidatesChecked;
      if(g_setup.isBullish)
      {
         if(CheckMSSBullish(g_setup.sweepTime, swingBroken, mssCandidatesChecked))
         {
            g_statMSSDetectedCount++;
            g_statMSSCandidateChecked += mssCandidatesChecked;
            DLog("MSS_BOS", StringFormat("Bullish MSS confirmed. CandidatesChecked=%d SwingBroken=%.5f Close=%.5f", mssCandidatesChecked, swingBroken, iClose(_Symbol,PERIOD_CURRENT,1)));

            double entryPrice = SymbolInfoDouble(_Symbol, SYMBOL_ASK);
            double slPrice = g_setup.sweepExtreme - InpSLBufferFactor * AverageRange(1, InpAvgRangeN);
            double tpPrice;

            // V1.2.2: risk distance (SL engine, UNCHANGED) must be known
            // BEFORE searching for TP, so every candidate's RR can be
            // computed against the real SL — this is the root-cause fix:
            // RR is now a SELECTION filter, not a post-hoc rejection.
            double risk = entryPrice - slPrice;

            int tpFound, tpValid, tpInvalid; string tpLabel; double tpRR;
            bool tpOk = (risk > 0) && FindTPTarget(true, entryPrice, risk, tpPrice, tpFound, tpValid, tpInvalid, tpLabel, tpRR);
            g_statTPCandidatesFound     += tpFound;
            g_statTPCandidatesRRValid   += tpValid;
            g_statTPCandidatesRRInvalid += tpInvalid;
            if(!tpOk)
            {
               g_statTPRejectedCount++;
               DLog("REJECTED", StringFormat("No RR-valid TP structure target found for BUY (Candidates=%d RRValid=%d RRInvalid=%d). Setup discarded.",
                    tpFound, tpValid, tpInvalid));
               ResetSetup();
               return;
            }
            g_statFinalTPSelectedCount++;

            double reward = tpPrice - entryPrice;
            if(risk <= 0 || reward <= 0 || (reward/risk) < InpMinRR)
            {
               // Should not normally trigger post-fix (FindTPTarget already
               // filtered by InpMinRR) — kept as an explicit safety net in
               // case of floating-point edge cases, not a design gap.
               DLog("REJECTED", StringFormat("RR insufficient (%.2f < %.2f). BUY setup discarded.", (risk>0?reward/risk:0), InpMinRR));
               ResetSetup();
               return;
            }

            DLog("SL", StringFormat("SL=%.5f (sweepExtreme=%.5f - buffer)", slPrice, g_setup.sweepExtreme));
            DLog("TP", StringFormat("TP=%.5f (%s, RR=%.2f, CandidateRR=%.2f)", tpPrice, tpLabel, reward/risk, tpRR));

            if(!EvaluateRegimeGate("BUY"))
            {
               DLog("REJECTED", "Regime=CHOPPY at MSS confirmation. BUY setup discarded.");
               ResetSetup();
               return;
            }

            bool executed = ExecuteBuy(entryPrice, slPrice, tpPrice, "LSMS_BUY");
            if(executed)
               MarkLevelUsed(g_setup.liquidityLevel); // only mark used on CONFIRMED success
            else
               DLog("EXECUTION", "BUY failed - liquidity level NOT marked as used, may retry if setup still valid next bar.");
            ResetSetup();
            return;
         }
      }
      else
      {
         if(CheckMSSBearish(g_setup.sweepTime, swingBroken, mssCandidatesChecked))
         {
            g_statMSSDetectedCount++;
            g_statMSSCandidateChecked += mssCandidatesChecked;
            DLog("MSS_BOS", StringFormat("Bearish MSS confirmed. CandidatesChecked=%d SwingBroken=%.5f Close=%.5f", mssCandidatesChecked, swingBroken, iClose(_Symbol,PERIOD_CURRENT,1)));

            double entryPrice = SymbolInfoDouble(_Symbol, SYMBOL_BID);
            double slPrice = g_setup.sweepExtreme + InpSLBufferFactor * AverageRange(1, InpAvgRangeN);
            double tpPrice;

            double risk = slPrice - entryPrice;

            int tpFound, tpValid, tpInvalid; string tpLabel; double tpRR;
            bool tpOk = (risk > 0) && FindTPTarget(false, entryPrice, risk, tpPrice, tpFound, tpValid, tpInvalid, tpLabel, tpRR);
            g_statTPCandidatesFound     += tpFound;
            g_statTPCandidatesRRValid   += tpValid;
            g_statTPCandidatesRRInvalid += tpInvalid;
            if(!tpOk)
            {
               g_statTPRejectedCount++;
               DLog("REJECTED", StringFormat("No RR-valid TP structure target found for SELL (Candidates=%d RRValid=%d RRInvalid=%d). Setup discarded.",
                    tpFound, tpValid, tpInvalid));
               ResetSetup();
               return;
            }
            g_statFinalTPSelectedCount++;

            double reward = entryPrice - tpPrice;
            if(risk <= 0 || reward <= 0 || (reward/risk) < InpMinRR)
            {
               // Safety net only — see BUY block comment above.
               DLog("REJECTED", StringFormat("RR insufficient (%.2f < %.2f). SELL setup discarded.", (risk>0?reward/risk:0), InpMinRR));
               ResetSetup();
               return;
            }

            DLog("SL", StringFormat("SL=%.5f (sweepExtreme=%.5f + buffer)", slPrice, g_setup.sweepExtreme));
            DLog("TP", StringFormat("TP=%.5f (%s, RR=%.2f, CandidateRR=%.2f)", tpPrice, tpLabel, reward/risk, tpRR));

            if(!EvaluateRegimeGate("SELL"))
            {
               DLog("REJECTED", "Regime=CHOPPY at MSS confirmation. SELL setup discarded.");
               ResetSetup();
               return;
            }

            bool executed = ExecuteSell(entryPrice, slPrice, tpPrice, "LSMS_SELL");
            if(executed)
               MarkLevelUsed(g_setup.liquidityLevel); // only mark used on CONFIRMED success
            else
               DLog("EXECUTION", "SELL failed - liquidity level NOT marked as used, may retry if setup still valid next bar.");
            ResetSetup();
            return;
         }
      }
      return;
   }
}

//====================================================================
// NEW BAR DETECTION
//====================================================================
bool IsNewBar()
{
   datetime t = iTime(_Symbol, PERIOD_CURRENT, 0);
   if(t != g_lastBarTime)
   {
      g_lastBarTime = t;
      return true;
   }
   return false;
}

//====================================================================
// V1.2 — NOTIFICATION WRAPPER
//====================================================================
// SendSafeNotification(): the single choke point for all outbound notifications.
// - Enforces EnableNotifications master switch
// - Enforces <=255 char limit (MT5 SendNotification hard limit)
// - Always logs to Journal via Print(), independent of push success — this is
//   the reliable record in Strategy Tester where push notifications are not
//   delivered/testable.
// - Never lets a notification failure affect trading logic: return value is
//   informational only, callers must not branch trading decisions on it.
void SendSafeNotification(string message)
{
   // Journal log first — this must always happen regardless of anything else,
   // since it is the only reliable record in Strategy Tester.
   Print("[NOTIFY] ", message);

   if(!InpEnableNotifications) return;

   if(StringLen(message) > 255)
   {
      DLog("NOTIFY", StringFormat("Message exceeds 255 chars (%d), truncated before send.", StringLen(message)));
      message = StringSubstr(message, 0, 255);
   }

   bool sent = SendNotification(message);
   if(!sent)
   {
      DLog("NOTIFY", StringFormat("SendNotification() failed. Error=%d (push notification not delivered; Journal log above stands as record)", GetLastError()));
   }
}

// Composite-key duplicate protection: existence of a GV flag = event already sent.
// Works across restarts because GV is checked, not just an in-memory flag.
bool NotificationAlreadySent(string eventKey)
{
   return GVExists("NOTIFY_" + eventKey);
}

void MarkNotificationSent(string eventKey)
{
   GVSetDouble("NOTIFY_" + eventKey, 1.0);
}

// Sends a notification exactly once per unique eventKey, ever (until the GV is
// cleared by the periodic notification-state cleanup, see below). This is the
// mechanism behind instruction #32 (duplicate protection) — e.g. eventKey
// "ENTRY_123456" for a given position ticket, "DAILYOPEN_<wibDayStamp>" for the
// once-per-day 06:00 notification, etc.
void SendSafeNotificationOnce(string eventKey, string message)
{
   if(NotificationAlreadySent(eventKey))
   {
      DLog("NOTIFY", StringFormat("Duplicate suppressed for eventKey=%s", eventKey));
      return;
   }
   SendSafeNotification(message);
   MarkNotificationSent(eventKey);
}

//====================================================================
// V1.2 — R-STATE PERSISTENCE (per-position Initial Entry/SL/1R, trailing, lock)
//====================================================================
// Key scheme: "POS_<ticket>_<field>" under the standard GVPrefix() (which already
// includes Symbol+Magic), so the full uniqueness key is Symbol+Magic+Ticket as
// required by instruction #37.
string PosKey(ulong ticket, string field)
{
   return StringFormat("POS_%I64u_%s", ticket, field);
}

void SavePositionRState(const PositionRState &st)
{
   GVSetDouble(PosKey(st.ticket, "INITENTRY"), st.initialEntry);
   GVSetDouble(PosKey(st.ticket, "INITSL"), st.initialSL);
   GVSetDouble(PosKey(st.ticket, "INITR"), st.initialR);
   GVSetDouble(PosKey(st.ticket, "LOCKACTIVE"), st.profitLockActive ? 1.0 : 0.0);
   GVSetDouble(PosKey(st.ticket, "LOCKEDR"), st.currentLockedR);
   GVSetDouble(PosKey(st.ticket, "TRAILSTEP"), (double)st.lastTrailStepApplied);
   GVSetDouble(PosKey(st.ticket, "VALID"), st.valid ? 1.0 : 0.0);
}

bool LoadPositionRState(ulong ticket, PositionRState &st)
{
   bool ex;
   double initEntry = GVGetDouble(PosKey(ticket, "INITENTRY"), 0, ex);
   if(!ex) return false; // no persisted state at all for this ticket

   st.ticket = ticket;
   st.initialEntry = initEntry;
   st.initialSL = GVGetDouble(PosKey(ticket, "INITSL"), 0, ex);
   st.initialR = GVGetDouble(PosKey(ticket, "INITR"), 0, ex);
   st.profitLockActive = GVGetDouble(PosKey(ticket, "LOCKACTIVE"), 0, ex) >= 0.5;
   st.currentLockedR = GVGetDouble(PosKey(ticket, "LOCKEDR"), 0, ex);
   st.lastTrailStepApplied = (int)GVGetDouble(PosKey(ticket, "TRAILSTEP"), 0, ex);
   st.valid = GVGetDouble(PosKey(ticket, "VALID"), 0, ex) >= 0.5;
   return true;
}

void DeletePositionRState(ulong ticket)
{
   GVDelete(PosKey(ticket, "INITENTRY"));
   GVDelete(PosKey(ticket, "INITSL"));
   GVDelete(PosKey(ticket, "INITR"));
   GVDelete(PosKey(ticket, "LOCKACTIVE"));
   GVDelete(PosKey(ticket, "LOCKEDR"));
   GVDelete(PosKey(ticket, "TRAILSTEP"));
   GVDelete(PosKey(ticket, "VALID"));
}

// Finds cached in-memory state for a ticket, or -1 if not cached yet.
int FindPosStateIndex(ulong ticket)
{
   for(int i = 0; i < ArraySize(g_posState); i++)
      if(g_posState[i].ticket == ticket) return i;
   return -1;
}

// Registers a brand-new position's R-state at the moment of confirmed entry.
// This is the ONLY place InitialEntry/InitialSL/InitialR are ever set — per
// instruction #16/#36, they must never be recalculated later from a trailed SL.
void RegisterNewPositionRState(ulong ticket, double entryPrice, double slPrice)
{
   PositionRState st;
   st.ticket = ticket;
   st.initialEntry = entryPrice;
   st.initialSL = slPrice;
   st.initialR = MathAbs(entryPrice - slPrice);
   st.profitLockActive = false;
   st.currentLockedR = 0;
   st.lastTrailStepApplied = 0;
   st.valid = (st.initialR > 0);

   int idx = ArraySize(g_posState);
   ArrayResize(g_posState, idx+1);
   g_posState[idx] = st;

   SavePositionRState(st);
   DLog("RSTATE", StringFormat("Registered ticket=%I64u InitialEntry=%.5f InitialSL=%.5f InitialR=%.5f",
        ticket, entryPrice, slPrice, st.initialR));
}

//====================================================================
// V1.2 — POSITION RECOVERY / RECONCILIATION (instruction #35-#41)
//====================================================================
// Runs once in OnInit(). For every currently-open position belonging to this
// EA (matched by Symbol+Magic), attempts to restore its R-state from Global
// Variables. If no persisted state exists for an open position, we do NOT
// guess InitialR from the position's CURRENT SL (which may already be
// trailed) — that would silently corrupt the 1R basis (instruction #36/#41).
// Instead the position is marked invalid/unrecoverable and the EA enters
// SAFE RECOVERY MODE: existing position is left alone (never force-closed),
// no new entries are opened, and a clear warning is logged.
void RecoverOpenPositions()
{
   ArrayResize(g_posState, 0);
   bool anyUnrecoverable = false;

   for(int i = 0; i < PositionsTotal(); i++)
   {
      ulong ticket = PositionGetTicket(i);
      if(ticket == 0) continue;
      if(PositionGetString(POSITION_SYMBOL) != _Symbol) continue;
      if(PositionGetInteger(POSITION_MAGIC) != (long)InpMagicNumber) continue;

      PositionRState st;
      if(LoadPositionRState(ticket, st) && st.valid && st.initialR > 0)
      {
         int idx = ArraySize(g_posState);
         ArrayResize(g_posState, idx+1);
         g_posState[idx] = st;
         DLog("RECOVERY", StringFormat("Ticket=%I64u recovered. InitialEntry=%.5f InitialSL=%.5f InitialR=%.5f LockedR=%.2f",
              ticket, st.initialEntry, st.initialSL, st.initialR, st.currentLockedR));
      }
      else
      {
         // Unrecoverable: no persisted state, or state flagged invalid.
         // Do NOT reconstruct InitialR from current (possibly trailed) SL.
         PositionRState badSt;
         badSt.ticket = ticket;
         badSt.initialEntry = PositionGetDouble(POSITION_PRICE_OPEN);
         badSt.initialSL = 0;
         badSt.initialR = 0;
         badSt.profitLockActive = false;
         badSt.currentLockedR = 0;
         badSt.lastTrailStepApplied = 0;
         badSt.valid = false;

         int idx = ArraySize(g_posState);
         ArrayResize(g_posState, idx+1);
         g_posState[idx] = badSt;

         anyUnrecoverable = true;
         DLog("RECOVERY", StringFormat(
            "POSITION RECOVERY WARNING: Ticket=%I64u has no valid persisted Initial-R state. "
            "Refusing to guess 1R from current SL. Position will NOT be force-closed; "
            "trailing/profit-lock for this ticket is suspended until state is otherwise resolved.",
            ticket));
      }
   }

   if(anyUnrecoverable)
   {
      g_safeRecoveryMode = true;
      g_safeRecoveryReason = "One or more open positions have incomplete Initial-Risk state after restart.";
      DLog("RECOVERY", "SAFE RECOVERY MODE ENGAGED: " + g_safeRecoveryReason + " New entries blocked. Existing positions left open and are still governed by their broker-side SL/TP.");
   }
}

//====================================================================
// V1.2 — CONSECUTIVE LOSS STATE RECOVERY (instruction #39)
//====================================================================
void RestoreConsecutiveLossState()
{
   bool ex;
   g_consecLosses = (int)GVGetDouble("CONSEC_LOSSES", 0, ex);
   g_cooldownUntilBar = ex ? (int)GVGetDouble("COOLDOWN_UNTIL_BAR", -1, ex) : -1;
   // Note: g_cooldownUntilBar is compared against g_barIndexCounter, which
   // resets to 0 each EA session (it is a session-local bar counter, not a
   // wall-clock value). A persisted cooldown-until-bar from a PREVIOUS EA
   // session is therefore not meaningfully comparable after restart. To stay
   // safe rather than silently drop the cooldown, we convert any persisted
   // active cooldown into an immediate short cooldown on restart instead of
   // discarding it:
   if(g_cooldownUntilBar > 0)
   {
      g_cooldownUntilBar = InpCooldownBars; // re-apply full cooldown window fresh
      DLog("RISK", StringFormat("Restored consecutive-loss state: count=%d. A cooldown was active before restart; re-applying %d-bar cooldown to stay safe.", g_consecLosses, InpCooldownBars));
   }
   else
   {
      DLog("RISK", StringFormat("Restored consecutive-loss state: count=%d. No cooldown pending.", g_consecLosses));
   }
}

//====================================================================
// V1.2 — PROFIT LOCK & R-STEP TRAILING
//====================================================================
// Core rule set (fixed defaults, NOT auto-optimized, per instruction #17/#18):
//   +0.50R -> lock SL at +0.10R   (Profit Lock)
//   +1.00R -> lock SL at +0.50R   (Trail Step 1)
//   +1.50R -> lock SL at +1.00R   (Trail Step 2)
//   +2.00R -> lock SL at +1.50R   (Trail Step 3)
//   +3.00R -> lock SL at +2.00R   (Trail Step 4)
//
// SAFETY RULES enforced unconditionally (instruction #20):
//   1. SL never widens.
//   2. BUY: new SL must be > current SL, else skipped.
//   3. SELL: new SL must be < current SL, else skipped.
//   4. Uses Bid for BUY current-profit calc, Ask for SELL (matches close price).
//   5. Respects SYMBOL_TRADE_STOPS_LEVEL.
//   6. Price normalized to symbol digits.
//   7. Modification failure is logged with retcode; does not throw/stop EA.
//   8. Only modifies when a NEW step threshold is newly crossed (checked once
//      per new bar, not every tick) — avoids redundant OrderModify spam.
//   9. TP is never touched by this logic.
//  10. InitialEntry/InitialSL/InitialR are read-only here — never recalculated.
//
// This entire module uses only price (current Bid/Ask vs stored InitialEntry/
// InitialSL) — no indicators of any kind.

double CurrentRMultiple(const PositionRState &st, bool isBuy)
{
   if(st.initialR <= 0) return 0;
   double currentPrice = isBuy ? SymbolInfoDouble(_Symbol, SYMBOL_BID)
                                : SymbolInfoDouble(_Symbol, SYMBOL_ASK);
   double profitDist = isBuy ? (currentPrice - st.initialEntry) : (st.initialEntry - currentPrice);
   return profitDist / st.initialR;
}

// Attempts to move SL to lock the given R-multiple. Returns true only if the
// modification was actually sent AND confirmed successful.
bool ApplyRLock(ulong ticket, PositionRState &st, bool isBuy, double lockR, string stepLabel)
{
   double targetSL = isBuy ? (st.initialEntry + lockR * st.initialR)
                            : (st.initialEntry - lockR * st.initialR);
   targetSL = NormalizeSLTP(targetSL);

   double curSL = PositionGetDouble(POSITION_SL);
   double curTP = PositionGetDouble(POSITION_TP);

   // Rule 1-3: SL must only move in the favorable direction, never backward.
   if(isBuy && targetSL <= curSL)
   {
      DLog("TRAILING", StringFormat("Ticket=%I64u skip: target SL %.5f not better than current %.5f (BUY)", ticket, targetSL, curSL));
      return false;
   }
   if(!isBuy && targetSL >= curSL)
   {
      DLog("TRAILING", StringFormat("Ticket=%I64u skip: target SL %.5f not better than current %.5f (SELL)", ticket, targetSL, curSL));
      return false;
   }

   // Rule 5: respect broker min stop distance from current price.
   double refPrice = isBuy ? SymbolInfoDouble(_Symbol, SYMBOL_BID) : SymbolInfoDouble(_Symbol, SYMBOL_ASK);
   if(!ValidateStopDistance(refPrice, targetSL))
   {
      DLog("TRAILING", StringFormat("Ticket=%I64u skip: target SL %.5f violates broker min stop distance from price %.5f", ticket, targetSL, refPrice));
      return false;
   }

   bool sent = trade.PositionModify(ticket, targetSL, curTP); // Rule 9: TP untouched
   uint retcode = trade.ResultRetcode();
   bool success = sent && (retcode == TRADE_RETCODE_DONE);

   if(!success)
   {
      DLog("TRAILING", StringFormat("Ticket=%I64u %s MODIFY FAILED. retcode=%u (%s)", ticket, stepLabel, retcode, trade.ResultRetcodeDescription()));
      return false;
   }

   DLog("TRAILING", StringFormat("Ticket=%I64u %s applied. SL %.5f -> %.5f (locked %.2fR)", ticket, stepLabel, curSL, targetSL, lockR));
   return true;
}

// Evaluated once per new closed bar for every open EA-managed position.
// Determines the highest R-step threshold currently crossed and, if it is
// NEWER than the last step already applied for this ticket, applies it.
void ManageTrailingForPosition(ulong ticket)
{
   int idx = FindPosStateIndex(ticket);
   if(idx < 0) return; // not tracked (shouldn't happen if recovery ran correctly)

   PositionRState st = g_posState[idx];
   if(!st.valid)
   {
      // Incomplete state (see RecoverOpenPositions) — do not trail, do not
      // guess. Position remains governed by its existing broker-side SL/TP.
      return;
   }

   if(!PositionSelectByTicket(ticket)) return;
   bool isBuy = (PositionGetInteger(POSITION_TYPE) == POSITION_TYPE_BUY);

   double rMultiple = CurrentRMultiple(st, isBuy);

   // Determine the highest step whose trigger has been reached, evaluated in
   // descending order so we apply the most advanced applicable step directly
   // (skipping stale intermediate ones is safe since SL-never-widens is
   // enforced inside ApplyRLock regardless).
   int    newStep = st.lastTrailStepApplied;
   double lockR = 0;
   string label = "";

   if(InpEnableRStepTrailing && rMultiple >= InpTrailStep4TriggerR && st.lastTrailStepApplied < 4)
   { newStep = 4; lockR = InpTrailStep4LockR; label = "TRAIL_STEP4"; }
   else if(InpEnableRStepTrailing && rMultiple >= InpTrailStep3TriggerR && st.lastTrailStepApplied < 3)
   { newStep = 3; lockR = InpTrailStep3LockR; label = "TRAIL_STEP3"; }
   else if(InpEnableRStepTrailing && rMultiple >= InpTrailStep2TriggerR && st.lastTrailStepApplied < 2)
   { newStep = 2; lockR = InpTrailStep2LockR; label = "TRAIL_STEP2"; }
   else if(InpEnableRStepTrailing && rMultiple >= InpTrailStep1TriggerR && st.lastTrailStepApplied < 1)
   { newStep = 1; lockR = InpTrailStep1LockR; label = "TRAIL_STEP1"; }
   else if(InpEnableProfitLock && rMultiple >= InpProfitLockTriggerR && !st.profitLockActive)
   { newStep = 0; lockR = InpProfitLockR; label = "PROFIT_LOCK"; } // step 0 = profit lock tier, distinct from st.lastTrailStepApplied semantics below

   bool isProfitLockTier = (label == "PROFIT_LOCK");

   if(label == "") return; // no new threshold crossed

   if(ApplyRLock(ticket, st, isBuy, lockR, label))
   {
      st.currentLockedR = lockR;
      if(isProfitLockTier)
         st.profitLockActive = true;
      else
         st.lastTrailStepApplied = newStep;

      g_posState[idx] = st;
      SavePositionRState(st);

      double entryPrice = PositionGetDouble(POSITION_PRICE_OPEN);
      double curPrice = isBuy ? SymbolInfoDouble(_Symbol, SYMBOL_BID) : SymbolInfoDouble(_Symbol, SYMBOL_ASK);
      double newSL = PositionGetDouble(POSITION_SL);

      if(isProfitLockTier)
      {
         string msg = StringFormat("🟡 IKYY LSMS — PROFIT LOCK\n%s %s #%I64u\nEntry: %.2f | Price: %.2f\nProfit: +%.2fR\nSL: -> %.2f\nLocked: +%.2fR\nStatus: PROTECTED",
            _Symbol, isBuy?"BUY":"SELL", ticket, entryPrice, curPrice, rMultiple, newSL, lockR);
         if(InpNotifyOnTrailing)
            SendSafeNotificationOnce(StringFormat("PROFITLOCK_%I64u", ticket), msg);
      }
      else
      {
         string msg = StringFormat("🟡 IKYY LSMS — TRAIL %s\n%s %s #%I64u\nEntry: %.2f | Price: %.2f\nProfit: +%.2fR\nSL: -> %.2f\nLocked: +%.2fR",
            label, _Symbol, isBuy?"BUY":"SELL", ticket, entryPrice, curPrice, rMultiple, newSL, lockR);
         if(InpNotifyOnTrailing)
            SendSafeNotificationOnce(StringFormat("%s_%I64u", label, ticket), msg);
      }
   }
}

// Iterates all EA-managed open positions and applies trailing/profit-lock.
// Called once per new closed bar — NOT every tick (instruction #20 rule 9 /
// #34), keeping modification frequency and notification frequency sane.
void ManageAllOpenPositions()
{
   for(int i = 0; i < PositionsTotal(); i++)
   {
      ulong ticket = PositionGetTicket(i);
      if(ticket == 0) continue;
      if(PositionGetString(POSITION_SYMBOL) != _Symbol) continue;
      if(PositionGetInteger(POSITION_MAGIC) != (long)InpMagicNumber) continue;

      ManageTrailingForPosition(ticket);
   }
}

//====================================================================
// V1.2 — OnTradeTransaction: event-driven close classification & notification
//====================================================================
// Preferred over pure history-polling (instruction #40) for accuracy: fires
// exactly once per actual trade event, reducing risk of missed/duplicated
// deals. We still keep OnTick-based history reconciliation as a fallback net
// (UpdateConsecutiveLossTracking, unchanged from V1.1) in case a transaction
// event is ever missed (e.g. EA was offline when the deal occurred) — that
// fallback path also has its own duplicate-ticket guard (g_lastCheckedDealTicket)
// so double-processing is not possible from having both paths active.
// Shared close-processing logic. Called from BOTH OnTradeTransaction (primary
// path) and the history-scan fallback (UpdateConsecutiveLossTracking) — both
// paths check the SAME dedup key (GV-backed) before calling this, so a given
// deal is guaranteed to be processed exactly once regardless of which path
// reaches it first (instruction #40: "pastikan tidak terjadi duplicate
// processing").
void ProcessClosedDeal(ulong dealTicket)
{
   if(!HistoryDealSelect(dealTicket)) return;

   if(HistoryDealGetString(dealTicket, DEAL_SYMBOL) != _Symbol) return;
   if(HistoryDealGetInteger(dealTicket, DEAL_MAGIC) != (long)InpMagicNumber) return;

   ENUM_DEAL_ENTRY entry = (ENUM_DEAL_ENTRY)HistoryDealGetInteger(dealTicket, DEAL_ENTRY);
   if(entry != DEAL_ENTRY_OUT && entry != DEAL_ENTRY_OUT_BY) return; // only care about closes here

   string dedupKey = StringFormat("DEALCLOSE_%I64u", dealTicket);
   if(NotificationAlreadySent(dedupKey)) return; // already processed this exact deal — guaranteed single-processing

   ulong ticket = HistoryDealGetInteger(dealTicket, DEAL_POSITION_ID);
   double closePrice = HistoryDealGetDouble(dealTicket, DEAL_PRICE);
   double profit = HistoryDealGetDouble(dealTicket, DEAL_PROFIT) + HistoryDealGetDouble(dealTicket, DEAL_SWAP) + HistoryDealGetDouble(dealTicket, DEAL_COMMISSION);
   ENUM_DEAL_REASON reason = (ENUM_DEAL_REASON)HistoryDealGetInteger(dealTicket, DEAL_REASON);
   ENUM_POSITION_TYPE posType = (HistoryDealGetInteger(dealTicket, DEAL_TYPE) == DEAL_TYPE_SELL) ? POSITION_TYPE_BUY : POSITION_TYPE_SELL;
   bool wasBuy = (posType == POSITION_TYPE_BUY);

   int idx = FindPosStateIndex(ticket);
   double initialEntry = 0, initialR = 0;
   bool haveState = false;
   if(idx >= 0 && g_posState[idx].valid)
   {
      initialEntry = g_posState[idx].initialEntry;
      initialR = g_posState[idx].initialR;
      haveState = true;
   }

   double rResult = (haveState && initialR > 0)
      ? ((wasBuy ? (closePrice - initialEntry) : (initialEntry - closePrice)) / initialR)
      : 0;

   // --- Daily stats update (event-driven, accurate) ---
   g_dailyTrades++;
   if(profit > 0) g_dailyWins++;
   else if(profit < 0) g_dailyLosses++;
   g_dailyPL += profit;
   SaveDailyState();

   // --- Consecutive loss tracking (single source of truth — this function) ---
   if(profit < 0)
   {
      g_consecLosses++;
      if(g_consecLosses >= InpMaxConsecLosses)
         g_cooldownUntilBar = g_barIndexCounter + InpCooldownBars;
   }
   else if(profit > 0)
   {
      g_consecLosses = 0;
   }
   GVSetDouble("CONSEC_LOSSES", (double)g_consecLosses);
   GVSetDouble("COOLDOWN_UNTIL_BAR", (double)g_cooldownUntilBar);

   // --- Classification: TP vs Initial-SL vs Trailing-Exit vs Other ---
   double tp = 0, initSL = 0;
   bool wasTrailed = false;
   if(idx >= 0)
   {
      initSL = g_posState[idx].initialSL;
      wasTrailed = g_posState[idx].profitLockActive || g_posState[idx].lastTrailStepApplied > 0;
   }

   string classification;
   string emoji;
   string label;

   if(reason == DEAL_REASON_TP)
   {
      classification = "TP"; emoji = "🟢"; label = "TAKE PROFIT";
   }
   else if(reason == DEAL_REASON_SL)
   {
      if(wasTrailed)
      {
         classification = "TRAILING_EXIT"; emoji = "🟢"; label = "TRAILING EXIT";
      }
      else
      {
         classification = "INITIAL_SL"; emoji = "🔴"; label = "STOP LOSS";
      }
   }
   else
   {
      classification = "OTHER"; emoji = "⚪"; label = "POSITION CLOSED";
   }

   int wibHour, wibMin; datetime wibDay, wibNow;
   GetWIBNow(wibHour, wibMin, wibDay, wibNow);
   string timeStr = StringFormat("%02d:%02d WIB", wibHour, wibMin);

   string msg;
   if(classification == "TP")
   {
      msg = StringFormat("%s IKYY LSMS — TAKE PROFIT\n%s %s #%I64u\nEntry: %.2f | Exit: %.2f\nProfit: +$%.2f | R: +%.2fR\nTime: %s\nStatus: CLOSED",
         emoji, _Symbol, wasBuy?"BUY":"SELL", ticket, initialEntry, closePrice, profit, rResult, timeStr);
   }
   else if(classification == "TRAILING_EXIT")
   {
      msg = StringFormat("%s IKYY LSMS — TRAILING EXIT\n%s %s #%I64u\nEntry: %.2f | Exit: %.2f\nProfit: +$%.2f | R: +%.2fR\nReason: TRAILING STOP\nTime: %s\nStatus: CLOSED",
         emoji, _Symbol, wasBuy?"BUY":"SELL", ticket, initialEntry, closePrice, profit, rResult, timeStr);
   }
   else if(classification == "INITIAL_SL")
   {
      msg = StringFormat("%s IKYY LSMS — STOP LOSS\n%s %s #%I64u\nEntry: %.2f | Exit: %.2f\nLoss: -$%.2f | R: %.2fR\nTime: %s\nStatus: CLOSED",
         emoji, _Symbol, wasBuy?"BUY":"SELL", ticket, initialEntry, closePrice, MathAbs(profit), rResult, timeStr);
   }
   else
   {
      msg = StringFormat("%s IKYY LSMS — POSITION CLOSED\n%s %s #%I64u\nEntry: %.2f | Exit: %.2f\nP/L: $%.2f | R: %.2fR\nReason: MANUAL/OTHER\nTime: %s",
         emoji, _Symbol, wasBuy?"BUY":"SELL", ticket, initialEntry, closePrice, profit, rResult, timeStr);
   }

   bool shouldNotify = (classification == "TP" && InpNotifyOnTP)
                     || (classification == "TRAILING_EXIT" && InpNotifyOnTrailing)
                     || (classification == "INITIAL_SL" && InpNotifyOnSL)
                     || (classification == "OTHER"); // always notify on unclassified close, safety default

   if(shouldNotify)
      SendSafeNotificationOnce(dedupKey, msg);
   else
   {
      Print("[NOTIFY-SUPPRESSED] ", msg); // still journal-logged even if push disabled for this category
      MarkNotificationSent(dedupKey); // still mark processed so fallback path never reprocesses it
   }

   DeletePositionRState(ticket);
   int posIdx = FindPosStateIndex(ticket);
   if(posIdx >= 0)
   {
      // remove from in-memory cache
      for(int k = posIdx; k < ArraySize(g_posState)-1; k++)
         g_posState[k] = g_posState[k+1];
      ArrayResize(g_posState, ArraySize(g_posState)-1);
   }
}

// Thin event wrapper — primary path (instruction #40). Fires exactly once per
// actual trade event as reported by the terminal.
void OnTradeTransaction(const MqlTradeTransaction &trans,
                         const MqlTradeRequest &request,
                         const MqlTradeResult &result)
{
   if(trans.type != TRADE_TRANSACTION_DEAL_ADD) return;
   ProcessClosedDeal(trans.deal);
}

//====================================================================
// V1.2 — TRADING WINDOW STATE MACHINE (06:00-22:00 WIB)

//====================================================================
// ACTIVE TRADING (06:00-21:59 WIB): scanner+analysis+new entry+management+
//   trailing all active.
// SCAN ONLY / ENTRY LOCK (22:00-05:59 WIB): scanner+analysis+management+
//   trailing remain ACTIVE; only NEW entries are blocked. Existing positions
//   are never force-closed at the boundary unless InpClosePositionsAtCutoff
//   is explicitly enabled (default false, per instruction #14).
//
// This is a pure state-machine driven by WIB clock reads; it does not gate
// or alter any LSMS core detection logic (sweep/displacement/MSS still run
// every bar regardless of window, per instruction #12: "Tetap scan").
void UpdateTradingWindowState()
{
   if(!InpUseTradingWindow)
   {
      g_tradingMode = MODE_ACTIVE_TRADING;
      g_entryAllowed = true;
      return;
   }

   int wibHour, wibMin; datetime wibDay, wibNow;
   GetWIBNow(wibHour, wibMin, wibDay, wibNow);

   bool inActiveWindow = (wibHour >= InpTradingStartHourWIB && wibHour < InpTradingEndHourWIB);

   TradingMode newMode = inActiveWindow ? MODE_ACTIVE_TRADING : MODE_SCAN_ONLY;

   if(newMode != g_tradingMode)
   {
      g_tradingMode = newMode;

      if(newMode == MODE_SCAN_ONLY)
      {
         DLog("WINDOW", StringFormat("22:00 WIB reached (WIB now %02d:%02d). Entering SCAN ONLY / ENTRY LOCK.", wibHour, wibMin));

         if(InpClosePositionsAtCutoff)
         {
            DLog("WINDOW", "InpClosePositionsAtCutoff=true: closing all EA-managed open positions.");
            for(int i = PositionsTotal()-1; i >= 0; i--)
            {
               ulong ticket = PositionGetTicket(i);
               if(ticket == 0) continue;
               if(PositionGetString(POSITION_SYMBOL) != _Symbol) continue;
               if(PositionGetInteger(POSITION_MAGIC) != (long)InpMagicNumber) continue;
               trade.PositionClose(ticket);
            }
         }

         // 22:00 daily-closed notification only if NO positions remain open;
         // otherwise defer until they close naturally (instruction #29/#30).
         if(CountOpenPositions() == 0)
         {
            string msg = StringFormat("🔴 IKYY LSMS — TRADING CLOSED\n22:00 WIB | %02d/%02d/%04d\nTrades: %d | W: %d | L: %d\nP/L: %.2f | WR: %.0f%%\nEntry: DISABLED\nScanner: ACTIVE | Mgmt: ACTIVE\nNext: 06:00 WIB\nStatus: SCAN ONLY",
               0,0,0, g_dailyTrades, g_dailyWins, g_dailyLosses, g_dailyPL,
               g_dailyTrades>0 ? (100.0*g_dailyWins/g_dailyTrades) : 0.0);
            // date fields filled below via WIB struct
            MqlDateTime wdt; TimeToStruct(wibNow, wdt);
            msg = StringFormat("🔴 IKYY LSMS — TRADING CLOSED\n22:00 WIB | %02d/%02d/%04d\nTrades: %d | W: %d | L: %d\nP/L: %.2f | WR: %.0f%%\nEntry: DISABLED\nScanner: ACTIVE | Mgmt: ACTIVE\nNext: 06:00 WIB\nStatus: SCAN ONLY",
               wdt.day, wdt.mon, wdt.year, g_dailyTrades, g_dailyWins, g_dailyLosses, g_dailyPL,
               g_dailyTrades>0 ? (100.0*g_dailyWins/g_dailyTrades) : 0.0);

            if(InpNotifyOnDailyClose && !g_dailyCloseNotified)
            {
               SendSafeNotificationOnce(StringFormat("DAILYCLOSE_%I64u", (ulong)wibDay), msg);
               g_dailyCloseNotified = true;
               SaveDailyState();
            }
         }
         else
         {
            DLog("WINDOW", StringFormat("22:00 WIB reached but %d position(s) still open — deferring daily-close notification until flat.", CountOpenPositions()));
         }
      }
      else
      {
         DLog("WINDOW", StringFormat("06:00 WIB reached (WIB now %02d:%02d). Entering ACTIVE TRADING.", wibHour, wibMin));

         MqlDateTime wdt; TimeToStruct(wibNow, wdt);
         string msg = StringFormat("🟢 IKYY LSMS — TRADING ACTIVE\n%02d:%02d WIB | %02d/%02d/%04d\nSymbol: %s | TF: %s\nEntry: ENABLED\nScanner: ACTIVE | Mgmt: ACTIVE\nTrailing: ACTIVE | TP/SL: ACTIVE\nWindow: %02d:00-%02d:00 WIB\nStatus: READY",
            wibHour, wibMin, wdt.day, wdt.mon, wdt.year, _Symbol, TimeframeToString(PERIOD_CURRENT),
            InpTradingStartHourWIB, InpTradingEndHourWIB);

         if(InpNotifyOnDailyOpen && !g_dailyOpenNotified)
         {
            SendSafeNotificationOnce(StringFormat("DAILYOPEN_%I64u", (ulong)wibDay), msg);
            g_dailyOpenNotified = true;
            SaveDailyState();
         }
      }
   }

   // If we are still in SCAN_ONLY mode and positions just closed to flat, and
   // the daily-close notification hasn't fired yet, fire it now (handles the
   // "trailing exit brought us to flat after 22:00" case from instruction #30).
   if(g_tradingMode == MODE_SCAN_ONLY && CountOpenPositions() == 0 && !g_dailyCloseNotified)
   {
      MqlDateTime wdt; TimeToStruct(wibNow, wdt);
      string msg = StringFormat("🔴 IKYY LSMS — DAILY CLOSED\nDate: %02d/%02d/%04d\nTrades: %d | W: %d | L: %d\nP/L: $%.2f | WR: %.0f%%\nAll Positions: CLOSED\nEntry: LOCKED\nNext Entry: 06:00 WIB",
         wdt.day, wdt.mon, wdt.year, g_dailyTrades, g_dailyWins, g_dailyLosses, g_dailyPL,
         g_dailyTrades>0 ? (100.0*g_dailyWins/g_dailyTrades) : 0.0);

      if(InpNotifyOnDailyClose)
      {
         SendSafeNotificationOnce(StringFormat("DAILYCLOSE_%I64u", (ulong)wibDay), msg);
         g_dailyCloseNotified = true;
         SaveDailyState();
      }
   }

   g_entryAllowed = (g_tradingMode == MODE_ACTIVE_TRADING);
}

//====================================================================
// V1.2 — DEBUG PANEL (instruction #43)
//====================================================================
void UpdateDebugPanel()
{
   if(!InpDebugMode) return;

   int wibHour, wibMin; datetime wibDay, wibNow;
   GetWIBNow(wibHour, wibMin, wibDay, wibNow);

   MqlDateTime sdt; TimeToStruct(TimeCurrent(), sdt);
   datetime utcNow = TimeCurrent() - g_detectedBrokerGMT*3600;
   MqlDateTime udt; TimeToStruct(utcNow, udt);

   string panel = StringFormat(
      "LSMS EA V1.2.1 | %s\n"
      "SERVER TIME: %02d:%02d:%02d\n"
      "UTC: %02d:%02d:%02d\n"
      "WIB: %02d:%02d\n"
      "BROKER GMT: UTC%+d | AUTO GMT: %s\n"
      "TRADING MODE: %s\n"
      "NEW ENTRY: %s\n"
      "SCANNER: ACTIVE\n"
      "POSITION MGMT: ACTIVE\n"
      "TRAILING: %s\n"
      "SAFE RECOVERY MODE: %s\n"
      "NEXT ACTIVATION: %02d:00 WIB\n"
      "--- RISK ENGINE (session) ---\n"
      "Valid Signals: %d\n"
      "Risk Blocks: %d\n"
      "Fallback Entries: %d\n"
      "Actual Risk Blocks: %d\n"
      "Executed Trades: %d\n"
      "--- REGIME FILTER (session) ---\n"
      "Valid LSMS Signals: %d\n"
      "Regime Evaluated: %d\n"
      "Regime Accepted: %d\n"
      "Regime Rejected: %d\n"
      "Choppy Reject: %d\n"
      "Ranging: %d | Trending: %d | Undefined: %d\n"
      "--- SPREAD FILTER (session) ---\n"
      "Typical: %.1f | AdaptiveLimit: %.1f | Env: %s\n"
      "--- TP/RR (V1.2.2) ---\n"
      "TP Candidates Found: %d | RR Valid: %d | RR Invalid: %d\n"
      "TP Rejected: %d | Final TP Selected: %d\n"
      "--- DISPLACEMENT (V1.2.2) ---\n"
      "Detected: %d (1C: %d / 2C: %d) | Timeout: %d\n"
      "--- MSS (V1.2.2) ---\n"
      "Detected: %d | Timeout: %d | Candidates Checked: %d",
      _Symbol,
      sdt.hour, sdt.min, sdt.sec,
      udt.hour, udt.min, udt.sec,
      wibHour, wibMin,
      g_detectedBrokerGMT, (g_gmtDetectionValid ? "ON" : "FALLBACK"),
      (g_tradingMode == MODE_ACTIVE_TRADING ? "ACTIVE TRADING" : "SCAN ONLY / ENTRY LOCK"),
      (g_entryAllowed && !g_safeRecoveryMode ? "ENABLED" : "BLOCKED"),
      (InpEnableRStepTrailing || InpEnableProfitLock ? "ACTIVE" : "DISABLED"),
      (g_safeRecoveryMode ? "YES - " + g_safeRecoveryReason : "NO"),
      (g_tradingMode == MODE_ACTIVE_TRADING ? InpTradingEndHourWIB : InpTradingStartHourWIB),
      g_statValidSignals, g_statRiskBlockCount, g_statMinLotFallbackCount,
      g_statActualRiskBlockCount, g_statExecutedTradeCount,
      g_statValidLSMSSignalCount, g_statRegimeEvaluatedCount, g_statRegimeAcceptedCount,
      g_statRegimeRejectedCount, g_statChoppyRejectCount,
      g_statRangingCount, g_statTrendingCount, g_statUndefinedCount,
      g_statTypicalSpread, g_statAdaptiveLimit, (g_isTester ? "TESTER" : "LIVE")
   );

   Comment(panel);
}

//====================================================================
// EXPERT LIFECYCLE
//====================================================================
int OnInit()
{
   // NOTE (instruction #35): OnInit() running does NOT by itself mean a new
   // trading day, a fresh EA, or that any prior state should be discarded.
   // OnInit() fires on: first attach, EA reload/recompile, chart timeframe
   // change, terminal restart, VPS restart, and manual re-attach. All of
   // these must be safe. The sequence below is deliberately: (1) load
   // whatever persisted state exists, (2) reconcile against actual broker
   // state (open positions, deal history), (3) only THEN decide what — if
   // anything — is genuinely new.

   ArrayResize(g_swingHighs, 0);
   ArrayResize(g_swingLows, 0);
   ArrayResize(g_usedLevels, 0);
   ArrayResize(g_usedLevelTimes, 0);
   ResetSetup();

   g_barIndexCounter = 0;
   g_lastCheckedDealTicket = 0;
   g_lastBarTime = 0;
   g_safeRecoveryMode = false;
   g_safeRecoveryReason = "";

   // --- V1.2.1: resolve environment ONCE, before anything that depends on it
   // (GMT source routing, spread debug labeling). MQL_TESTER is stable for
   // the entire run of the EA, so this is safe to resolve a single time here.
   g_isTester = (bool)MQLInfoInteger(MQL_TESTER);

   // --- Time architecture: resolve broker GMT before anything time-dependent ---
   ResolveBrokerGMT();

   int wibHour, wibMin; datetime wibDay, wibNow;
   GetWIBNow(wibHour, wibMin, wibDay, wibNow);
   g_currentWIBDay = wibDay; // provisional; overwritten below if persisted state exists for a different (older) day, in which case ResetDailyTrackingIfNeeded on the first tick will correctly roll it over

   // --- Restore daily state (WIB-day scoped, GV-based) ---
   if(LoadDailyState() && g_currentWIBDay == wibDay)
   {
      DLog("RISK", StringFormat("Restored daily state (GV) for WIB day. Trades=%d W=%d L=%d P/L=%.2f StartEquity=%.2f",
           g_dailyTrades, g_dailyWins, g_dailyLosses, g_dailyPL, g_dayStartEquity));
   }
   else
   {
      // Either no persisted state, or persisted state belongs to a prior WIB
      // day (EA was off across a day rollover) — start fresh for today.
      g_currentWIBDay = wibDay;
      g_dayStartEquity = AccountInfoDouble(ACCOUNT_EQUITY);
      g_dailyTrades = 0; g_dailyWins = 0; g_dailyLosses = 0; g_dailyPL = 0;
      g_dailyOpenNotified = false; g_dailyCloseNotified = false;
      SaveDailyState();
      DLog("RISK", StringFormat("No valid same-day persisted state. Fresh WIB-day start. Equity=%.2f", g_dayStartEquity));
   }

   // --- Restore consecutive-loss / cooldown state ---
   RestoreConsecutiveLossState();

   // --- Reconcile open positions against persisted R-state (critical safety) ---
   RecoverOpenPositions();

   // --- Trading window initial evaluation (does not send duplicate notifs) ---
   g_tradingMode = (wibHour >= InpTradingStartHourWIB && wibHour < InpTradingEndHourWIB) ? MODE_ACTIVE_TRADING : MODE_SCAN_ONLY;
   g_entryAllowed = (g_tradingMode == MODE_ACTIVE_TRADING) && !g_safeRecoveryMode;

   // --- Warm-up: build initial swing history from available bars (unchanged from V1.1) ---
   int bars = iBars(_Symbol, PERIOD_CURRENT);
   int warmupBars = MathMin(bars - InpSwingK - 2, InpLiquidityLookback * 3);
   if(warmupBars > 0)
   {
      for(int b = warmupBars; b >= 1; b--)
      {
         int centerShift = b + InpSwingK;
         if(centerShift + InpSwingK >= bars) continue;
         datetime centerTime = iTime(_Symbol, PERIOD_CURRENT, centerShift);

         if(IsSwingHigh(centerShift, InpSwingK))
         {
            SwingPoint sp; sp.time=centerTime; sp.price=iHigh(_Symbol,PERIOD_CURRENT,centerShift);
            sp.shift=centerShift; sp.isHigh=true; sp.used=false;
            int sz=ArraySize(g_swingHighs); ArrayResize(g_swingHighs, sz+1); g_swingHighs[sz]=sp;
         }
         if(IsSwingLow(centerShift, InpSwingK))
         {
            SwingPoint sp; sp.time=centerTime; sp.price=iLow(_Symbol,PERIOD_CURRENT,centerShift);
            sp.shift=centerShift; sp.isHigh=false; sp.used=false;
            int sz=ArraySize(g_swingLows); ArrayResize(g_swingLows, sz+1); g_swingLows[sz]=sp;
         }
      }
   }

   DLog("INIT", StringFormat("LSMS EA V1.2.1 initialized. Environment=%s Symbol=%s Period=%s Warmup swings H=%d L=%d BrokerGMT=UTC%+d(%s) WIB=%02d:%02d TradingMode=%s SafeRecovery=%s",
        g_isTester?"TESTER":"LIVE",
        _Symbol, TimeframeToString(PERIOD_CURRENT), ArraySize(g_swingHighs), ArraySize(g_swingLows),
        g_detectedBrokerGMT, g_gmtDetectionValid?(g_isTester?"manual/deterministic":"auto"):"fallback", wibHour, wibMin,
        g_tradingMode==MODE_ACTIVE_TRADING?"ACTIVE":"SCAN_ONLY",
        g_safeRecoveryMode?"YES":"NO"));

   // --- EA ATTACHED notification (instruction #23) — NOT a new trading day ---
   if(InpNotifyOnAttach)
   {
      string broker = AccountInfoString(ACCOUNT_COMPANY);
      MqlDateTime sdt; TimeToStruct(TimeCurrent(), sdt);
      string msg = StringFormat("🟣 IKYY LSMS — EA ATTACHED\nSymbol: %s | TF: %s\nBroker: %s\nServer: %02d:%02d | WIB: %02d:%02d\nGMT: UTC%+d | Auto GMT: %s\nRisk: %.1f%%\nSpread Filter: %s\nStatus: READY",
         _Symbol, TimeframeToString(PERIOD_CURRENT), broker, sdt.hour, sdt.min, wibHour, wibMin,
         g_detectedBrokerGMT, (InpUseAutoGMT?"ON":"OFF"), InpRiskPercent,
         (InpMaxSpreadPoints>0?"ON":"OFF"));
      // Attach notification is intentionally NOT deduped by day — every
      // attach event is informationally distinct (restart, reload, etc.) and
      // the instruction explicitly separates it from daily-open notifications.
      SendSafeNotification(msg);
   }

   if(g_safeRecoveryMode)
      DLog("RECOVERY", "EA started in SAFE RECOVERY MODE — new entries blocked until resolved. See RECOVERY log lines above for detail.");

   return(INIT_SUCCEEDED);
}

void OnDeinit(const int reason)
{
   DLog("DEINIT", StringFormat("EA deinitialized. Reason=%d", reason));
   Comment(""); // clear debug panel
}

void OnTick()
{
   // Debug panel and time-window state are cheap and safe to refresh every
   // tick (display only / state-machine transition detection); all TRADING
   // decisions remain strictly bar-close-gated below.
   UpdateDebugPanel();

   if(!IsNewBar()) return; // ALL trading decisions strictly on closed-candle new-bar events — no intrabar repaint risk

   g_barIndexCounter++;

   ResolveBrokerGMT();          // re-resolve periodically (handles rare mid-session GMT feed changes)
   ResetDailyTrackingIfNeeded(); // WIB 00:00 rollover check
   UpdateTradingWindowState();   // WIB 06:00/22:00 transitions

   UpdateSwingStructure();       // append-only, immutable, no look-ahead (see V1.1 §2 audit)
   UpdateConsecutiveLossTracking(); // fallback net; OnTradeTransaction is primary path (instruction #40)
   ManageAllOpenPositions();     // R-based profit lock / trailing for existing positions — runs in BOTH trading modes

   if(g_safeRecoveryMode)
   {
      DLog("REJECTED", "SAFE RECOVERY MODE active — new entries blocked. " + g_safeRecoveryReason);
      return; // existing positions still managed above; no new setups evaluated
   }

   ProcessSetupStateMachine();
}
//+------------------------------------------------------------------+
