//+------------------------------------------------------------------+
//|                                            AdaptivePSARTrail.mqh |
//|                                                Utility / Library |
//|                                    Copyright 2026, soloharbinger |
//|                      https://www.mql5.com/en/users/soloharbinger |
//+------------------------------------------------------------------+
#property copyright   "Copyright 2026, soloharbinger"
#property link        "https://www.mql5.com/en/users/soloharbinger"
#property description "A drop-in adaptive Parabolic SAR trailing stop."
#property description "It owns its own SAR series and its DivergenceEngine instance, so a host EA adds a trailing stop that reacts to divergence"

#ifndef ADAPTIVE_PSAR_TRAIL_MQH
#define ADAPTIVE_PSAR_TRAIL_MQH

#include <DivergenceEngine.mqh>

#ifndef PSAR_TRAIL_CALC_ONLY
#include <Trade/Trade.mqh>
#endif

//+------------------------------------------------------------------+
//| How an opposing divergence changes the acceleration factor       |
//+------------------------------------------------------------------+
enum ENUM_PSAR_ADAPT_MODE
  {
   PSAR_ADAPT_REDUCE   = 0,   // Reduce step - slower acceleration, looser stop
   PSAR_ADAPT_INCREASE = 1    // Increase step - faster acceleration, tighter stop
  };

//+------------------------------------------------------------------+
//| Why a stop was or was not moved. Every skip has a name.          |
//+------------------------------------------------------------------+
enum ENUM_PSAR_TRAIL_RESULT
  {
   PSAR_TRAIL_OK = 0,           // Stop moved (or a valid stop was produced)
   PSAR_TRAIL_NO_DATA,          // Not refreshed, no SAR value, or no tick
   PSAR_TRAIL_NOT_MINE,         // Symbol or magic number does not match
   PSAR_TRAIL_WRONG_SIDE,       // SAR sits on the wrong side of price for this position
   PSAR_TRAIL_NOT_STARTED,      // Price has not yet moved StartPoints past the entry
   PSAR_TRAIL_BELOW_BREAKEVEN,  // Break-even guard is on and the stop is still worse than entry
   PSAR_TRAIL_STOPS_LEVEL,      // Closer to price than the broker's minimum stop distance
   PSAR_TRAIL_FROZEN,           // Existing stop is inside the broker's freeze level
   PSAR_TRAIL_NO_IMPROVEMENT,   // New stop is not better than the current one
   PSAR_TRAIL_STEP_TOO_SMALL,   // Improvement is smaller than StepPoints
   PSAR_TRAIL_MODIFY_FAILED     // Broker rejected the modification
  };

//+------------------------------------------------------------------+
//| Readable name for a trailing result                              |
//+------------------------------------------------------------------+
string PSARTrailResultToString(const ENUM_PSAR_TRAIL_RESULT result)
  {
   switch(result)
     {
      case PSAR_TRAIL_OK:
         return "OK";
      case PSAR_TRAIL_NO_DATA:
         return "NO_DATA";
      case PSAR_TRAIL_NOT_MINE:
         return "NOT_MINE";
      case PSAR_TRAIL_WRONG_SIDE:
         return "WRONG_SIDE";
      case PSAR_TRAIL_NOT_STARTED:
         return "NOT_STARTED";
      case PSAR_TRAIL_BELOW_BREAKEVEN:
         return "BELOW_BREAKEVEN";
      case PSAR_TRAIL_STOPS_LEVEL:
         return "STOPS_LEVEL";
      case PSAR_TRAIL_FROZEN:
         return "FROZEN";
      case PSAR_TRAIL_NO_IMPROVEMENT:
         return "NO_IMPROVEMENT";
      case PSAR_TRAIL_STEP_TOO_SMALL:
         return "STEP_TOO_SMALL";
      case PSAR_TRAIL_MODIFY_FAILED:
         return "MODIFY_FAILED";
     }
   return "UNKNOWN";
  }

//+------------------------------------------------------------------+
//| Every setting in one place, so the host EA fills one struct      |
//| instead of matching a long argument list                         |
//+------------------------------------------------------------------+
struct PSARTrailConfig
  {
   //--- Market context
   string            Symbol;                // Empty string = current chart symbol
   ENUM_TIMEFRAMES   Timeframe;             // Timeframe the SAR and divergence run on
   int               Lookback;              // Bars to process per refresh

   //--- Parabolic SAR
   double            SARStep;               // Base acceleration step
   double            SARMaximum;            // Acceleration ceiling

   //--- Divergence source (handed straight to DivergenceEngine.mqh)
   ENUM_DIV_SOURCE   DivSource;             // MPO4 or RSI
   int               MPOLength;             // MPO length
   int               MPOSmooth;             // MPO smoothing
   int               RSIPeriod;             // RSI period (used when DivSource is RSI)
   int               PivotLength;           // Bars either side of an oscillator pivot

   //--- Adaptation
   bool              EnableAdaptive;        // Off = a plain Parabolic SAR trailing stop
   ENUM_PSAR_ADAPT_MODE AdaptMode;          // Loosen or tighten on an opposing divergence
   double            AdaptFactor;           // Fraction of the base step to add or remove [0..1]
   int               DivBarsLimit;          // Ignore divergences older than this many bars
   bool              ResetOnTrendChange;    // Clear divergence state when the SAR flips

   //--- Which positions this instance is allowed to touch
   string            MagicList;             // Comma-separated magic numbers, e.g. "23456,999"
   bool              TrailAllMagics;        // Ignore MagicList and trail every position on the symbol

   //--- Trailing behaviour
   int               StartPoints;           // Start trailing once price has moved this many points past the entry (0 = immediately)
   int               StepPoints;            // Minimum improvement before a modify is sent (0 = any)
   int               OffsetPoints;          // Positive moves the stop towards price (tighter); negative gives the trend more room
   int               MinStopBufferPoints;   // Own floor under the broker's stops level
   bool              RequireTrendAgreement; // Skip when the SAR is on the wrong side of price
   bool              RequireBreakEven;      // Never move a stop to worse than the entry price
   bool              VerboseLog;            // Log every skip reason, not just modifications

   //--- Sensible starting point; the host overrides what it cares about
   void              SetDefaults(void)
     {
      Symbol                = "";
      Timeframe             = PERIOD_CURRENT;
      Lookback              = 300;

      SARStep               = 0.02;
      SARMaximum            = 0.20;

      DivSource             = DIV_SRC_MPO;
      MPOLength             = 6;
      MPOSmooth             = 7;
      RSIPeriod             = 14;
      PivotLength           = 2;

      EnableAdaptive        = true;
      AdaptMode             = PSAR_ADAPT_REDUCE;
      AdaptFactor           = 0.3;
      DivBarsLimit          = 200;
      ResetOnTrendChange    = false;

      MagicList             = "";
      TrailAllMagics        = false;

      StartPoints           = 0;
      StepPoints            = 0;
      OffsetPoints          = 0;
      MinStopBufferPoints   = 0;
      RequireTrendAgreement = true;
      RequireBreakEven      = false;
      VerboseLog            = false;
     }

                     PSARTrailConfig(void) { SetDefaults(); }
  };

//+------------------------------------------------------------------+
//| CAdaptivePSARTrail                                               |
//| Holds one symbol/timeframe worth of SAR and divergence state.    |
//+------------------------------------------------------------------+
class CAdaptivePSARTrail
  {
private:
   //--- Configuration and the engine it drives
   PSARTrailConfig   m_cfg;
   DivergenceEngine  m_engine;

   //--- Resolved market context
   string            m_symbol;
   ENUM_TIMEFRAMES   m_tf;
   int               m_digits;
   double            m_point;
   double            m_tickSize;

   //--- Working state
   bool              m_ready;
   int               m_bars;              // Bars actually copied this refresh
   int               m_limit;             // Oldest bar index the loop processes
   int               m_sizedFor;          // Size the buffers were last allocated at
   datetime          m_lastRefreshBar;    // Open time of bar 0 at the last refresh

   //--- Per-bar, per-ticket verbose log throttle
   datetime          m_verboseBar;        // Bar we are currently logging for
   ulong             m_verboseTickets[];  // Tickets already logged on that bar
   int               m_verboseCount;      // Number of entries in the array above

   //--- Price and result series, all series-indexed (0 = current bar)
   double            m_open[];
   double            m_high[];
   double            m_low[];
   double            m_close[];
   double            m_sar[];
   double            m_step[];
   int               m_trend[];

   //--- Magic numbers this instance may modify
   long              m_magics[];

#ifndef PSAR_TRAIL_CALC_ONLY
   CTrade            m_trade;
#endif

   //--- Internals
   bool              ParseMagicList(void);
   bool              ValidateConfig(void);
   bool              CopyPrices(void);
   bool              AllocateBuffers(const int size);
   void              ComputeSeries(void);
   double            AdaptiveStepAt(const int shift, const int trendDir);
   double            AlignStopToTick(const double price, const bool isBuy);
   bool              IndexUsable(const int shift);
   bool              VerboseThisBar(const ulong ticket);

public:
                     CAdaptivePSARTrail(void);
                    ~CAdaptivePSARTrail(void);

   //--- Lifecycle
   bool              Init(const PSARTrailConfig &cfg);
   bool              Refresh(void);
   void              Deinit(void);

   //--- Read-only queries. None of these place or modify an order,
   //--- so they are safe to call from an indicator.
   bool              IsReady(void)            { return m_ready; }
   int               BarCount(void)           { return m_bars;  }
   int               Limit(void)              { return m_limit; }
   string            GetSymbol(void)          { return m_symbol; }
   ENUM_TIMEFRAMES   GetTimeframe(void)       { return m_tf; }

   double            StopPrice(const int shift = 1);
   int               Trend(const int shift = 1);
   double            AppliedStep(const int shift = 1);
   bool              IsFlip(const int shift = 1);
   int               DivergenceType(const int shift = 1);
   int               DivergenceAge(const int shift = 1);
   double            Oscillator(const int shift = 1);
   bool              IsAdapted(const int shift = 1);

   //--- Pure calculation: what stop would this position get, and why not
   ENUM_PSAR_TRAIL_RESULT ProposeStop(const ENUM_POSITION_TYPE posType,
                                      const double entryPrice,
                                      const double currentSL,
                                      double &newSL);

   bool              MatchesMagic(const long magic);

#ifndef PSAR_TRAIL_CALC_ONLY
   //--- Trading surface
   int               ApplyToPositions(void);
   ENUM_PSAR_TRAIL_RESULT ApplyToTicket(const ulong ticket);
#endif
  };

//+------------------------------------------------------------------+
//| Constructor                                                      |
//+------------------------------------------------------------------+
CAdaptivePSARTrail::CAdaptivePSARTrail(void)
  {
   m_symbol         = "";
   m_tf             = PERIOD_CURRENT;
   m_digits         = 5;
   m_point          = 0.0;
   m_tickSize       = 0.0;
   m_ready          = false;
   m_bars           = 0;
   m_limit          = 0;
   m_sizedFor       = 0;
   m_lastRefreshBar = 0;
   m_engine.rsiHandle = INVALID_HANDLE;
   m_verboseBar     = 0;
   m_verboseCount   = 0;
   ArrayFree(m_verboseTickets);
  }

//+------------------------------------------------------------------+
//| Destructor                                                       |
//+------------------------------------------------------------------+
CAdaptivePSARTrail::~CAdaptivePSARTrail(void)
  {
   Deinit();
  }

//+------------------------------------------------------------------+
//| Validate the configuration before anything is allocated          |
//+------------------------------------------------------------------+
bool CAdaptivePSARTrail::ValidateConfig(void)
  {
   if(m_cfg.SARStep <= 0.0)
     {
      Print("[PSARTrail] SARStep must be greater than 0.");
      return false;
     }
   if(m_cfg.SARMaximum < m_cfg.SARStep)
     {
      Print("[PSARTrail] SARMaximum must be greater than or equal to SARStep.");
      return false;
     }
   if(m_cfg.AdaptFactor < 0.0 || m_cfg.AdaptFactor > 1.0)
     {
      Print("[PSARTrail] AdaptFactor must be within [0..1].");
      return false;
     }
   if(m_cfg.DivBarsLimit <= 0)
     {
      Print("[PSARTrail] DivBarsLimit must be greater than 0.");
      return false;
     }
   if(m_cfg.Lookback < 60)
     {
      Print("[PSARTrail] Lookback below 60 bars leaves no room for pivots. Raise it.");
      return false;
     }

//--- An adaptation of zero is a valid setting but almost certainly a mistake
   if(m_cfg.EnableAdaptive && m_cfg.AdaptFactor == 0.0)
      Print("[PSARTrail] Caution: EnableAdaptive is on but AdaptFactor is 0 - the SAR will not change.");

//--- Nothing to trail is a configuration error, not a runtime surprise
   if(!m_cfg.TrailAllMagics && StringLen(m_cfg.MagicList) == 0)
     {
      Print("[PSARTrail] MagicList is empty and TrailAllMagics is false - no position could ever be trailed.");
      return false;
     }
   return true;
  }

//+------------------------------------------------------------------+
//| Parse the comma-separated magic list into a lookup array         |
//+------------------------------------------------------------------+
bool CAdaptivePSARTrail::ParseMagicList(void)
  {
   ArrayFree(m_magics);

//--- TrailAllMagics makes the list irrelevant
   if(m_cfg.TrailAllMagics)
     {
      Print("[PSARTrail] TrailAllMagics is on - every position on ", m_symbol, " is in scope.");
      return true;
     }

   string raw = m_cfg.MagicList;
   StringTrimLeft(raw);
   StringTrimRight(raw);

   string parts[];
   int count = StringSplit(raw, ',', parts);
   if(count <= 0)
     {
      Print("[PSARTrail] Could not parse MagicList: ", m_cfg.MagicList);
      return false;
     }

   for(int i = 0; i < count; i++)
     {
      string token = parts[i];
      StringTrimLeft(token);
      StringTrimRight(token);
      if(StringLen(token) == 0)
         continue;

      //--- StringToInteger returns 0 for anything unparseable, so check the
      //--- characters ourselves rather than silently adopting magic 0
      bool numeric = true;
      for(int c = 0; c < StringLen(token); c++)
        {
         ushort ch = StringGetCharacter(token, c);
         if(ch < '0' || ch > '9')
           {
            numeric = false;
            break;
           }
        }
      if(!numeric)
        {
         Print("[PSARTrail] Ignoring non-numeric magic entry: '", token, "'");
         continue;
        }

      long magic = StringToInteger(token);

      //--- Skip duplicates so the lookup stays short
      bool exists = false;
      for(int k = 0; k < ArraySize(m_magics); k++)
         if(m_magics[k] == magic)
           {
            exists = true;
            break;
           }
      if(exists)
         continue;

      int size = ArraySize(m_magics);
      ArrayResize(m_magics, size + 1);
      m_magics[size] = magic;
     }

   if(ArraySize(m_magics) == 0)
     {
      Print("[PSARTrail] MagicList produced no usable magic numbers: ", m_cfg.MagicList);
      return false;
     }
   return true;
  }

//+------------------------------------------------------------------+
//| Is this position's magic number one we are allowed to touch      |
//+------------------------------------------------------------------+
bool CAdaptivePSARTrail::MatchesMagic(const long magic)
  {
   if(m_cfg.TrailAllMagics)
      return true;
   for(int i = 0; i < ArraySize(m_magics); i++)
      if(m_magics[i] == magic)
         return true;
   return false;
  }

//+------------------------------------------------------------------+
//| Initialization                                                   |
//+------------------------------------------------------------------+
bool CAdaptivePSARTrail::Init(const PSARTrailConfig &cfg)
  {
   m_cfg    = cfg;
   m_symbol = (StringLen(cfg.Symbol) == 0) ? _Symbol : cfg.Symbol;
   m_tf     = cfg.Timeframe;
   m_ready  = false;

   if(!ValidateConfig())
      return false;

//--- Symbol properties. A symbol that is not in Market Watch reports zero for everything.
   if(!SymbolSelect(m_symbol, true))
     {
      Print("[PSARTrail] Could not select symbol ", m_symbol, " in Market Watch.");
      return false;
     }
   m_digits   = (int)SymbolInfoInteger(m_symbol, SYMBOL_DIGITS);
   m_point    = SymbolInfoDouble(m_symbol, SYMBOL_POINT);
   m_tickSize = SymbolInfoDouble(m_symbol, SYMBOL_TRADE_TICK_SIZE);
   if(m_point <= 0.0)
     {
      Print("[PSARTrail] Symbol ", m_symbol, " reported a point size of 0.");
      return false;
     }

   if(!ParseMagicList())
      return false;

//--- The divergence engine is ours; the host never sees it
   if(!InitializeDivergenceEngine(m_engine,
                                  m_cfg.DivSource,
                                  m_cfg.MPOLength,
                                  m_cfg.MPOSmooth,
                                  m_cfg.RSIPeriod,
                                  m_cfg.PivotLength,
                                  m_symbol,
                                  m_tf))
     {
      Print("[PSARTrail] Failed to initialize the divergence engine.");
      return false;
     }

//--- Refuse a lookback that cannot hold a pivot pair plus working room
   int required = GetRequiredLookback(m_engine) + 30;
   if(m_cfg.Lookback < required)
     {
      Print("[PSARTrail] Lookback (", m_cfg.Lookback, ") raised to the engine's requirement (", required, ").");
      m_cfg.Lookback = required;
     }

#ifndef PSAR_TRAIL_CALC_ONLY
   m_trade.SetAsyncMode(false);
   m_trade.LogLevel(LOG_LEVEL_ERRORS);
#endif

//--- Try a first refresh now. Until one succeeds, IsReady() stays false.
   if(!Refresh())
      Print("[PSARTrail] Initialized, but the first refresh found no usable history yet.");
   else
      Print("[PSARTrail] Initialized on ", m_symbol, " with ", m_bars, " bars, adaptive=",
            (m_cfg.EnableAdaptive ? "on" : "off"), ".");

   return true;
  }

//+------------------------------------------------------------------+
//| Release everything the instance owns                             |
//+------------------------------------------------------------------+
void CAdaptivePSARTrail::Deinit(void)
  {
   ReleaseDivergenceEngine(m_engine);

   ArrayFree(m_open);
   ArrayFree(m_high);
   ArrayFree(m_low);
   ArrayFree(m_close);
   ArrayFree(m_sar);
   ArrayFree(m_step);
   ArrayFree(m_trend);
   ArrayFree(m_magics);
   ArrayFree(m_verboseTickets);

   m_ready    = false;
   m_bars     = 0;
   m_limit    = 0;
   m_sizedFor = 0;
   m_verboseCount = 0;
   m_verboseBar   = 0;
  }

//+------------------------------------------------------------------+
//| Size the local series buffers once per size change               |
//+------------------------------------------------------------------+
bool CAdaptivePSARTrail::AllocateBuffers(const int size)
  {
   ArrayResize(m_open,  size);
   ArrayResize(m_high,  size);
   ArrayResize(m_low,   size);
   ArrayResize(m_close, size);
   ArrayResize(m_sar,   size);
   ArrayResize(m_step,  size);
   ArrayResize(m_trend, size);

   ArraySetAsSeries(m_open,  true);
   ArraySetAsSeries(m_high,  true);
   ArraySetAsSeries(m_low,   true);
   ArraySetAsSeries(m_close, true);
   ArraySetAsSeries(m_sar,   true);
   ArraySetAsSeries(m_step,  true);
   ArraySetAsSeries(m_trend, true);

   if(!ResizeDivergenceBuffers(m_engine, size))
      return false;
   m_sizedFor = size;
   return true;
  }

//+------------------------------------------------------------------+
//| Copy price history and work out how far back we can process      |
//+------------------------------------------------------------------+
bool CAdaptivePSARTrail::CopyPrices(void)
  {
   int available = ::Bars(m_symbol, m_tf);
   if(available <= 0)
     {
      if(m_cfg.VerboseLog)
         Print("[PSARTrail] No bars available for ", m_symbol, " yet.");
      return false;
     }

//--- Never ask for more history than exists
   int want = MathMin(m_cfg.Lookback, available - 1);
   int required = GetRequiredLookback(m_engine) + 30;
   if(want < required)
     {
      if(m_cfg.VerboseLog)
         Print("[PSARTrail] Only ", want, " bars usable, ", required, " needed. Waiting for history.");
      return false;
     }

   if(want != m_sizedFor && !AllocateBuffers(want))
      return false;

//--- CopyX can return fewer bars than asked for while history is still loading
   if(CopyOpen(m_symbol, m_tf, 0, want, m_open)   < want ||
      CopyHigh(m_symbol, m_tf, 0, want, m_high)   < want ||
      CopyLow(m_symbol, m_tf, 0, want, m_low)     < want ||
      CopyClose(m_symbol, m_tf, 0, want, m_close) < want)
     {
      if(m_cfg.VerboseLog)
         Print("[PSARTrail] Price copy incomplete for ", m_symbol, " (", GetLastError(), ").");
      return false;
     }

//--- Re-assert series ordering; a reallocating copy can drop it
   ArraySetAsSeries(m_open,  true);
   ArraySetAsSeries(m_high,  true);
   ArraySetAsSeries(m_low,   true);
   ArraySetAsSeries(m_close, true);

   m_bars = want;

//--- The oldest bar the loop may touch
   m_limit = m_bars - (GetRequiredLookback(m_engine) + 2);
   if(m_limit < 10)
     {
      if(m_cfg.VerboseLog)
         Print("[PSARTrail] Processing window too small (limit=", m_limit, ").");
      return false;
     }
   return true;
  }

//+------------------------------------------------------------------+
//| Recompute the oscillator, divergence state and SAR series        |
//| Call once per new bar.                                           |
//+------------------------------------------------------------------+
bool CAdaptivePSARTrail::Refresh(void)
  {
   m_ready = false;

   if(!CopyPrices())
      return false;

   if(!UpdateDivergenceRSI(m_engine, m_limit))
      return false;

   InitDivergenceBoundaries(m_engine, m_limit);
   ComputeSeries();

   m_lastRefreshBar = iTime(m_symbol, m_tf, 0);
   m_ready = true;
   return true;
  }

//+------------------------------------------------------------------+
//| Step for one bar, adapted only by a divergence that argues       |
//| against the direction the SAR is currently trailing              |
//+------------------------------------------------------------------+
double CAdaptivePSARTrail::AdaptiveStepAt(const int shift, const int trendDir)
  {
   double stepNow = m_cfg.SARStep;
   if(!m_cfg.EnableAdaptive)
      return stepNow;

   int divType   = GetLastDivergenceType(m_engine, shift);
   int barsSince = GetBarsSinceDivergence(m_engine, shift);

   if(divType == 0 || barsSince > m_cfg.DivBarsLimit)
      return stepNow;

//--- A bullish divergence during a downtrend, or a bearish one during an uptrend
   bool opposes = (trendDir == 1 && divType == -1) || (trendDir == -1 && divType == 1);
   if(!opposes)
      return stepNow;

   if(m_cfg.AdaptMode == PSAR_ADAPT_REDUCE)
      stepNow = m_cfg.SARStep * (1.0 - m_cfg.AdaptFactor);
   else
      stepNow = m_cfg.SARStep * (1.0 + m_cfg.AdaptFactor);

//--- Confine to a usable range
   if(stepNow < 0.0001)
      stepNow = 0.0001;
   if(stepNow > m_cfg.SARMaximum)
      stepNow = m_cfg.SARMaximum;

   return stepNow;
  }

//+------------------------------------------------------------------+
//| The Parabolic SAR itself, walked from the oldest processed bar   |
//| forward to the current one so each bar's step can differ         |
//+------------------------------------------------------------------+
void CAdaptivePSARTrail::ComputeSeries(void)
  {
   int limit = m_limit;

//--- Seed the oldest bar. Direction of that single candle is an arbitrary
//--- starting guess; the SAR converges away from it within a few flips.
   int    trend = (m_close[limit] > m_open[limit]) ? 1 : -1;
   double sar   = (trend == 1) ? m_low[limit]  : m_high[limit];
   double ep    = (trend == 1) ? m_high[limit] : m_low[limit];
   double af    = m_cfg.SARStep;

   for(int i = limit; i >= 0; i--)
     {
      //--- Divergence state for this bar. The engine owns it entirely.
      CalculateOscillator(m_engine, i, m_open, m_close, m_point);
      DetectDivergence(m_engine, i, m_high, m_low, limit);

      //--- Trend flip: the SAR jumps to the extreme point of the trend
      //--- that just ended, and the acceleration factor starts over
      bool flipped = false;
      if(trend == 1 && m_low[i] < sar)
        {
         trend   = -1;
         sar     = ep;
         ep      = m_low[i];
         af      = m_cfg.SARStep;
         flipped = true;
        }
      else
         if(trend == -1 && m_high[i] > sar)
           {
            trend   = 1;
            sar     = ep;
            ep      = m_high[i];
            af      = m_cfg.SARStep;
            flipped = true;
           }

      if(flipped && m_cfg.ResetOnTrendChange)
         ResetDivergenceState(m_engine, i);

      //--- The step this bar is allowed to add to the acceleration factor
      double stepNow = AdaptiveStepAt(i, trend);

      //--- A new extreme point is what earns an acceleration increase
      if(trend == 1)
        {
         if(m_high[i] > ep)
           {
            ep = m_high[i];
            af = MathMin(af + stepNow, m_cfg.SARMaximum);
           }
        }
      else
        {
         if(m_low[i] < ep)
           {
            ep = m_low[i];
            af = MathMin(af + stepNow, m_cfg.SARMaximum);
           }
        }

      //--- Store what this bar actually used
      m_sar[i]   = sar;
      m_trend[i] = trend;
      m_step[i]  = stepNow;

      //--- Project the SAR for the next (more recent) bar
      sar = sar + af * (ep - sar);
      if(trend == 1)
         sar = MathMin(sar, MathMin(m_low[i], (i < limit) ? m_low[i+1]  : m_low[i]));
      else
         sar = MathMax(sar, MathMax(m_high[i], (i < limit) ? m_high[i+1] : m_high[i]));
     }
  }

//+------------------------------------------------------------------+
//| Bounds check shared by every query                               |
//+------------------------------------------------------------------+
bool CAdaptivePSARTrail::IndexUsable(const int shift)
  {
   return (m_ready && shift >= 0 && shift <= m_limit && shift < ArraySize(m_sar));
  }

//+------------------------------------------------------------------+
//| SAR value at a bar - the raw stop level, before any offset       |
//+------------------------------------------------------------------+
double CAdaptivePSARTrail::StopPrice(const int shift)
  {
   if(!IndexUsable(shift))
      return 0.0;
   return m_sar[shift];
  }

//+------------------------------------------------------------------+
//| SAR direction at a bar: 1 = trailing below price, -1 = above     |
//+------------------------------------------------------------------+
int CAdaptivePSARTrail::Trend(const int shift)
  {
   if(!IndexUsable(shift))
      return 0;
   return m_trend[shift];
  }

//+------------------------------------------------------------------+
//| The acceleration step in effect on this bar.                     |
//+------------------------------------------------------------------+
double CAdaptivePSARTrail::AppliedStep(const int shift)
  {
   if(!IndexUsable(shift))
      return 0.0;
   return m_step[shift];
  }

//+------------------------------------------------------------------+
//| True when the step in effect differs from the configured base,   |
//| i.e. an opposing divergence was live on this bar                 |
//+------------------------------------------------------------------+
bool CAdaptivePSARTrail::IsAdapted(const int shift)
  {
   if(!IndexUsable(shift))
      return false;
   return (MathAbs(m_step[shift] - m_cfg.SARStep) > 1e-12);
  }

//+------------------------------------------------------------------+
//| True when the SAR changed sides on this bar                      |
//+------------------------------------------------------------------+
bool CAdaptivePSARTrail::IsFlip(const int shift)
  {
   if(!IndexUsable(shift) || !IndexUsable(shift + 1))
      return false;
   return (m_trend[shift] != m_trend[shift+1]);
  }

//+------------------------------------------------------------------+
//| Last divergence type at a bar: 1 = bullish, -1 = bearish, 0 none |
//+------------------------------------------------------------------+
int CAdaptivePSARTrail::DivergenceType(const int shift)
  {
   if(!IndexUsable(shift))
      return 0;
   return GetLastDivergenceType(m_engine, shift);
  }

//+------------------------------------------------------------------+
//| Bars since the last divergence, or -1 when there has been none   |
//+------------------------------------------------------------------+
int CAdaptivePSARTrail::DivergenceAge(const int shift)
  {
   if(!IndexUsable(shift))
      return -1;
   int age = GetBarsSinceDivergence(m_engine, shift);

   if(age >= DIV_NO_BARS)
      return -1;
   return age;
  }

//+------------------------------------------------------------------+
//| Smoothed oscillator value at a bar                               |
//+------------------------------------------------------------------+
double CAdaptivePSARTrail::Oscillator(const int shift)
  {
   if(!IndexUsable(shift))
      return 0.0;
   return GetOscillatorValue(m_engine, shift);
  }

//+------------------------------------------------------------------+
//| Round a stop to the symbol's tick grid, always away from price   |
//+------------------------------------------------------------------+
double CAdaptivePSARTrail::AlignStopToTick(const double price, const bool isBuy)
  {
   if(m_tickSize <= 0.0)
      return NormalizeDouble(price, m_digits);

   double ticks = price / m_tickSize;
   double aligned = (isBuy ? MathFloor(ticks) : MathCeil(ticks)) * m_tickSize;
   return NormalizeDouble(aligned, m_digits);
  }

//+------------------------------------------------------------------+
//| True at most once per bar per ticket. Keeps the verbose skip log |
//| readable even though ApplyToPositions() runs on every tick.      |
//+------------------------------------------------------------------+
bool CAdaptivePSARTrail::VerboseThisBar(const ulong ticket)
  {
   if(!m_cfg.VerboseLog)
      return false;

   datetime barTime = iTime(m_symbol, m_tf, 0);
   if(barTime == 0)
      return false;

//--- New bar: forget every ticket we logged on the previous one
   if(barTime != m_verboseBar)
     {
      m_verboseBar   = barTime;
      m_verboseCount = 0;
      ArrayResize(m_verboseTickets, 0);
     }

//--- Already logged this ticket on this bar
   for(int i = 0; i < m_verboseCount; i++)
      if(m_verboseTickets[i] == ticket)
         return false;

//--- First time we see this ticket on this bar
   ArrayResize(m_verboseTickets, m_verboseCount + 1);
   m_verboseTickets[m_verboseCount] = ticket;
   m_verboseCount++;
   return true;
  }

//+------------------------------------------------------------------+
//| What stop would this position get, and if none, why not.         |
//+------------------------------------------------------------------+
ENUM_PSAR_TRAIL_RESULT CAdaptivePSARTrail::ProposeStop(const ENUM_POSITION_TYPE posType,
      const double entryPrice,
      const double currentSL,
      double &newSL)
  {
   newSL = 0.0;

   double sar = StopPrice(1);
   if(sar <= 0.0)
      return PSAR_TRAIL_NO_DATA;

   MqlTick tick;
   if(!SymbolInfoTick(m_symbol, tick))
      return PSAR_TRAIL_NO_DATA;
   if(tick.bid <= 0.0 || tick.ask <= 0.0)
      return PSAR_TRAIL_NO_DATA;

   bool isBuy = (posType == POSITION_TYPE_BUY);

//--- The side check. The SAR flips; a position does not
   if(m_cfg.RequireTrendAgreement)
     {
      if(isBuy  && sar >= tick.bid)
         return PSAR_TRAIL_WRONG_SIDE;
      if(!isBuy && sar <= tick.ask)
         return PSAR_TRAIL_WRONG_SIDE;
     }

//--- Distance gate: how far price has moved past the entry
   if(m_cfg.StartPoints > 0)
     {
      double movedPoints = isBuy ? (tick.bid - entryPrice) / m_point
                           : (entryPrice - tick.ask) / m_point;
      if(movedPoints < (double)m_cfg.StartPoints)
         return PSAR_TRAIL_NOT_STARTED;
     }

//--- Candidate stop: the SAR, shifted by the configured offset. A positive
//--- offset moves it toward price (tighter), negative gives the trend room.
   double candidate = sar;
   if(m_cfg.OffsetPoints != 0)
      candidate += (isBuy ? 1.0 : -1.0) * m_cfg.OffsetPoints * m_point;
   candidate = AlignStopToTick(candidate, isBuy);

//--- Break-even guard
   if(m_cfg.RequireBreakEven)
     {
      if(isBuy  && candidate < entryPrice)
         return PSAR_TRAIL_BELOW_BREAKEVEN;
      if(!isBuy && candidate > entryPrice)
         return PSAR_TRAIL_BELOW_BREAKEVEN;
     }

//--- Broker distance rules. A level at or beyond the price fails even when the minimum is 0
   int    stopsPts  = (int)SymbolInfoInteger(m_symbol, SYMBOL_TRADE_STOPS_LEVEL);
   int    freezePts = (int)SymbolInfoInteger(m_symbol, SYMBOL_TRADE_FREEZE_LEVEL);
   double minDist   = MathMax(stopsPts, m_cfg.MinStopBufferPoints) * m_point;

   if(isBuy)
     {
      if(candidate >= tick.bid || (tick.bid - candidate) < minDist)
         return PSAR_TRAIL_STOPS_LEVEL;
     }
   else
     {
      if(candidate <= tick.ask || (candidate - tick.ask) < minDist)
         return PSAR_TRAIL_STOPS_LEVEL;
     }

//--- Freeze level
   if(freezePts > 0 && currentSL > 0.0)
     {
      double freezeDist = freezePts * m_point;
      if(isBuy  && (tick.bid - currentSL) < freezeDist)
         return PSAR_TRAIL_FROZEN;
      if(!isBuy && (currentSL - tick.ask) < freezeDist)
         return PSAR_TRAIL_FROZEN;
     }

//--- A trailing stop only ever moves toward profit
   if(currentSL > 0.0)
     {
      if(isBuy  && candidate <= currentSL)
         return PSAR_TRAIL_NO_IMPROVEMENT;
      if(!isBuy && candidate >= currentSL)
         return PSAR_TRAIL_NO_IMPROVEMENT;

      if(m_cfg.StepPoints > 0)
        {
         double moved = isBuy ? (candidate - currentSL) : (currentSL - candidate);
         if(moved < m_cfg.StepPoints * m_point)
            return PSAR_TRAIL_STEP_TOO_SMALL;
        }
     }

   newSL = candidate;
   return PSAR_TRAIL_OK;
  }

#ifndef PSAR_TRAIL_CALC_ONLY

//+------------------------------------------------------------------+
//| Trail one position by ticket.                                    |
//+------------------------------------------------------------------+
ENUM_PSAR_TRAIL_RESULT CAdaptivePSARTrail::ApplyToTicket(const ulong ticket)
  {
   if(!PositionSelectByTicket(ticket))
      return PSAR_TRAIL_NO_DATA;

//--- Never modify a position this instance was not configured to manage
   if(PositionGetString(POSITION_SYMBOL) != m_symbol)
      return PSAR_TRAIL_NOT_MINE;
   if(!MatchesMagic(PositionGetInteger(POSITION_MAGIC)))
      return PSAR_TRAIL_NOT_MINE;

   ENUM_POSITION_TYPE posType = (ENUM_POSITION_TYPE)PositionGetInteger(POSITION_TYPE);
   double entry = PositionGetDouble(POSITION_PRICE_OPEN);
   double curSL = PositionGetDouble(POSITION_SL);
   double curTP = PositionGetDouble(POSITION_TP);

   double newSL = 0.0;
   ENUM_PSAR_TRAIL_RESULT result = ProposeStop(posType, entry, curSL, newSL);

   if(result != PSAR_TRAIL_OK)
     {
      //--- NOT_MINE is excluded on purpose
      if(result != PSAR_TRAIL_NOT_MINE && VerboseThisBar(ticket))
         Print("[PSARTrail] #", IntegerToString((long)ticket), " skipped: ",
               PSARTrailResultToString(result));
      return result;
     }

//--- Carry the take-profit through unchanged; PositionModify replaces both
   if(!m_trade.PositionModify(ticket, newSL, curTP))
     {
      Print("[PSARTrail] #", IntegerToString((long)ticket), " modify failed: ",
            m_trade.ResultRetcode(), " (", m_trade.ResultRetcodeDescription(), ")");
      return PSAR_TRAIL_MODIFY_FAILED;
     }

   Print("[PSARTrail] #", IntegerToString((long)ticket), " stop -> ",
         DoubleToString(newSL, m_digits),
         (IsAdapted(1) ? "  [adapted step " + DoubleToString(AppliedStep(1), 4) + "]" : ""));
   return PSAR_TRAIL_OK;
  }

//+------------------------------------------------------------------+
//| Trail every position in scope. Returns how many were modified.   |
//+------------------------------------------------------------------+
int CAdaptivePSARTrail::ApplyToPositions(void)
  {
   if(!m_ready)
      return 0;

   int modified = 0;
   for(int i = PositionsTotal() - 1; i >= 0; i--)
     {
      ulong ticket = PositionGetTicket(i);
      if(ticket == 0)
         continue;
      if(ApplyToTicket(ticket) == PSAR_TRAIL_OK)
         modified++;
     }
   return modified;
  }
#endif // PSAR_TRAIL_CALC_ONLY

#endif // ADAPTIVE_PSAR_TRAIL_MQH
//+------------------------------------------------------------------+
