preview
Position Management: Deriving a Self-Calibrating Exit Ladder From Historical MFE in MQL5

Position Management: Deriving a Self-Calibrating Exit Ladder From Historical MFE in MQL5

MetaTrader 5 — Examples |
63 0
Tola Moses Hector
Tola Moses Hector

Table of Contents

  1. Introduction
  2. The Pipeline: What Goes In, What Comes Out
  3. Why a Fixed Ladder Is a Guess
  4. Defining the Sample: MFE Expressed in R
  5. CExcursionTracker—Recording Live Excursion
  6. CExitLadderCalibrator—From Samples to Rungs
  7. CLadderExecutor—Firing Rungs Without Double-Counting
  8. SelfCalibratingLadderEA.mq5—Wiring It Together
  9. Verification With TestExcursionLadder.mq5
  10. Backtesting Notes
  11. Known Limitations
  12. Conclusion


Introduction

When building an EA that scales out of positions, the practical question is not what looks nice, but when a trade has earned enough that part of it should be banked. This becomes an engineering problem the moment exits are automated across many trades, instruments, and stop sizes: a fixed rule such as take a third off at 1R, a third at 2R, let the rest run is convenient, but it is essentially a guess dressed up as a rule. On paper it reads as disciplined; in practice a strategy may almost never reach 3R, making the final rung mostly decorative, or it may routinely reach 5R, making a 2R rung a cap on the strategy's own edge rather than a target. ATR-based spacing helps by adapting to volatility, but it still says nothing about how far this specific entry logic, on this specific symbol, actually tends to run.

This article replaces the guess with a measurement-based pipeline suitable for production EAs. Each trade's maximum favorable excursion is measured in units of R—MFE-in-R, where 1R is the price distance between entry and the initial stop—those samples are persisted, and the most recent samples are turned into scale-out rungs computed from the account's own history rather than chosen by hand. "CExcursionTracker" records live per-ticket MFE-in-R and writes persistent samples; "CExitLadderCalibrator" converts the most recent N samples into percentile-based rungs with a configurable lookback, a minimum-sample threshold, and a fallback ladder; "CLadderExecutor" applies those rungs to open positions, handling volume rounding, per-ticket rung state, and an optional breakeven move. The result is a self-calibrating ladder, expressed in R, that can be plugged into any EA: a fallback ladder covers the account until enough history accumulates, after which percentiles over recent trades drive scale-out levels that reflect actual strategy behavior rather than a designer's guess.

Architectural overview of the system

Fig. 1. Architectural overview of the system.

This builds directly on the MAE/MFE tracking introduced in the CTradeJournal EA from an earlier article—where that project recorded excursion data for post-trade review, this one puts the same measurement to work while the account is still trading.

This differs from (1) an MAE/MFE analysis panel and (2) a configurable profit ladder in two ways. First, measurement is live and cumulative. 'CExcursionTracker' monitors each open position tick by tick. The sample set grows and recalibrates as trades close, instead of producing a one-time static suggestion for manual input. Second, the output is a multi-rung ladder, not a single stop/target. Each level is expressed in R rather than points. This lets the same percentile framework work across instruments and stop sizes. A profit ladder that closes at configured R-multiples is only half the system; this article's contribution is where those R-multiples come from in the first place.


The Pipeline: What Goes In, What Comes Out

Before any implementation, it helps to see the whole pipeline in one place—three modules, each with a narrow job, wired together inside an EA rather than merged into one large class.

"CExcursionTracker" owns the live side. "Register()" is called once, right when a position opens, with the entry price, the initial stop, and the direction; from that point "UpdateTick()" is called on every tick to keep a running record of the best MFE-in-R reached so far. When the position closes, "Finalize()" is called with the realized R result—computed by the EA from history deals, since the tracker itself never queries a closed position—and the completed sample is appended to a persistent file.

"CExitLadderCalibrator" owns the offline side. It takes the accumulated samples—loaded back from that same file—along with a small set of target properties fixed in advance: which percentiles of the MFE-in-R distribution become rungs, how many of the most recent trades to consider (the lookback), and the minimum sample count required before a calibrated ladder is trusted at all. Below that minimum, "Calibrate()" returns a fixed fallback ladder instead; above it, the same call returns rungs read directly off the sample distribution. Either way, the output is the same shape—an ordered list of R-levels and close fractions—so nothing downstream needs to know which case produced it.

"CLadderExecutor" owns execution. It is handed whatever ladder the calibrator most recently produced, "Register()" is called once per open position with its entry, stop, and volume, and "OnTick()" checks live progress against each unfired rung, firing a partial close at most once per rung per ticket.

In the EA, this becomes a short list of call sites: "Register()" on both the tracker and the executor when a position opens, "UpdateTick()" and "OnTick()" on every tick, "Finalize()" and a fresh call to "Calibrate()" when a position closes and enough new samples have accumulated to justify recalibrating. Section 8 wires all of this together; the sections in between build each piece in isolation, in the order they are called.


Why a Fixed Ladder Is a Guess

A 1R/2R/3R ladder assumes every strategy's favorable excursion is shaped the same way and that the shape does not change over time. Neither assumption holds up well in practice. A mean-reversion scalper on a range-bound pair might rarely see a winning trade clear 1.5R before mean-reverting back through breakeven, which makes a 3R final rung mostly decorative. A trend-following breakout system on the same pair might routinely push winners to 4R or 5R, which makes a 2R final rung a cap on the strategy's own edge rather than a target.

ATR-based ladders solve part of this—they at least adapt rung spacing to current volatility—but they still say nothing about how far the strategy itself tends to carry a position once volatility is accounted for. Two strategies with identical ATR-scaled stops can have entirely different MFE distributions, because the distribution is a property of the entry logic and the instrument's behavior around it, not of volatility alone. The only source that captures both is the trade history itself.


Defining the Sample: MFE Expressed in R

Before any code, the unit of measurement needs to be exact. For a given position, R is the price distance between the entry price and the initial stop-loss, fixed when the position opens. Maximum favorable excursion in R, or MFE-in-R, is the best price the position reaches in its favor at any point during its life, expressed as a multiple of that R distance, regardless of where the position eventually closes.

A trade that opens long at 1.1050 with a stop at 1.1030 has an R distance of 0.0020. If the price later touches 1.1090 before the position is closed, its MFE-in-R is 2.0, even if the position is ultimately stopped out at breakeven or closed at a loss. This is deliberate: the ladder is not asking, "How did trades turn out?" It is asking, "How far did favorable excursions typically reach?" which is the question a scale-out level actually needs to be answered.

Each closed trade contributes one sample: the ticket, the symbol, the direction, the R distance, the MFE-in-R reached, the final realized result in R, and the close time. "SExcursionSample" in the code below is exactly that record.

//+------------------------------------------------------------------+
//| One closed-trade excursion sample                                |
//+------------------------------------------------------------------+
struct SExcursionSample
  {
   ulong             ticket;
   string            symbol;
   long              type;          // POSITION_TYPE_BUY or POSITION_TYPE_SELL
   double            r_distance;    // price distance representing 1R at open
   double            mfe_r;         // best favorable excursion reached, in R
   double            result_r;      // final realized result, in R
   datetime          close_time;
  };


CExcursionTracker—Recording Live Excursion

"CExcursionTracker" has one job while a position is open: watch every tick and keep a running record of the best MFE-in-R reached so far. "Register()" is called once, right after a position opens, and stores the entry price, the R distance derived from the initial stop, and the direction.

//+------------------------------------------------------------------+
//| Register                                                         |
//| r_distance is derived from entry and initial stop and must be    |
//| a positive price distance representing one unit of risk (1R).    |
//+------------------------------------------------------------------+
bool CExcursionTracker::Register(ulong ticket, double entry_price, double stop_price, long type)
  {
   double dist = (type == POSITION_TYPE_BUY) ? (entry_price - stop_price) : (stop_price - entry_price);
   if(dist <= 0.0)
      return(false);

   if(FindLive(ticket) >= 0)
      return(true);

   if(m_live_count >= ArraySize(m_live))
      ArrayResize(m_live, ArraySize(m_live) * 2);

   m_live[m_live_count].ticket      = ticket;
   m_live[m_live_count].entry_price = entry_price;
   m_live[m_live_count].r_distance  = dist;
   m_live[m_live_count].type        = type;
   m_live[m_live_count].best_mfe_r  = 0.0;
   m_live_count++;
   return(true);
  }

Rejecting a non-positive R distance up front matters—a stop placed on the wrong side of entry, whether from a bad order or a strategy bug elsewhere in the EA, would otherwise silently corrupt every MFE-in-R reading downstream of it.

"UpdateTick()" runs on every tick for every registered ticket and keeps only the peak—it never lets "best_mfe_r" fall back down when the price retraces, because the ladder needs to know the high-water mark the trade reached, not where it happens to be right now.

//+------------------------------------------------------------------+
//| UpdateTick                                                       |
//| Recomputes the running peak MFE in R for every registered ticket.|
//+------------------------------------------------------------------+
void CExcursionTracker::UpdateTick(void)
  {
   for(int i = 0; i < m_live_count; i++)
     {
      ulong ticket = m_live[i].ticket;
      if(!PositionSelectByTicket(ticket))
         continue;

      string symbol = PositionGetString(POSITION_SYMBOL);
      double bid = SymbolInfoDouble(symbol, SYMBOL_BID);
      double ask = SymbolInfoDouble(symbol, SYMBOL_ASK);

      double favorable_price = (m_live[i].type == POSITION_TYPE_BUY) ? bid : ask;
      double excursion = (m_live[i].type == POSITION_TYPE_BUY)
                         ? (favorable_price - m_live[i].entry_price)
                         : (m_live[i].entry_price - favorable_price);

      double mfe_r = excursion / m_live[i].r_distance;
      if(mfe_r > m_live[i].best_mfe_r)
         m_live[i].best_mfe_r = mfe_r;
     }
  }

When a position finally closes, "Finalize()" writes the completed sample and removes the ticket from the live registry. It needs the realized R result as an argument, since the tracker itself has no view of historical deals—that calculation happens in the EA and gets passed in, which is covered in Section 8.

//+------------------------------------------------------------------+
//| Finalize                                                         |
//| Called once a position has fully closed. Writes the sample and   |
//| removes the ticket from the live registry.                       |
//+------------------------------------------------------------------+
bool CExcursionTracker::Finalize(ulong ticket, double result_r)
  {
   int idx = FindLive(ticket);
   if(idx < 0)
      return(false);

   SExcursionSample sample;
   sample.ticket     = ticket;
   sample.symbol     = _Symbol;
   sample.type       = m_live[idx].type;
   sample.r_distance = m_live[idx].r_distance;
   sample.mfe_r      = m_live[idx].best_mfe_r;
   sample.result_r   = result_r;
   sample.close_time = TimeCurrent();

   bool ok = AppendSample(sample);

//--- compact: overwrite the closed slot with the last live entry
   m_live_count--;
   if(idx < m_live_count)
      m_live[idx] = m_live[m_live_count];

   return(ok);
  }

Persistence uses a flat CSV file opened in "FILE READ | FILE WRITE | FILE CSV" mode. Before each write, the file pointer is moved to the end so rows are appended without truncation. This preserves samples across terminal restarts.

//+------------------------------------------------------------------+
//| AppendSample                                                     |
//| Appends one CSV row: ticket,symbol,type,r_distance,mfe_r,        |
//| result_r,close_time                                              |
//+------------------------------------------------------------------+
bool CExcursionTracker::AppendSample(const SExcursionSample &sample)
  {
   int handle = FileOpen(m_filename, FILE_READ | FILE_WRITE | FILE_CSV | FILE_ANSI, ',');
   if(handle == INVALID_HANDLE)
      return(false);

   FileSeek(handle, 0, SEEK_END);
   FileWrite(handle, sample.ticket, sample.symbol, (int)sample.type,
             sample.r_distance, sample.mfe_r, sample.result_r,
             TimeToString(sample.close_time, TIME_DATE | TIME_SECONDS));
   FileClose(handle);
   return(true);
  }

"LoadSamples()" is the mirror image, reading every row back into an "SExcursionSample" array in the order it was written—oldest first—which the calibrator depends on for its lookback window in the next section.


CExitLadderCalibrator—From Samples to Rungs

The calibrator has one responsibility: turn an array of historical "SExcursionSample" records into a ranked list of rungs, each expressed as an R threshold and a fraction of the original position volume to close at that threshold. It never touches a live position—that separation is deliberate since it makes the calibration logic testable with plain data, as Section 8 makes use of.

Configuration specifies which percentiles of the MFE-in-R distribution become rungs, how many of the most recent trades to consider, and the minimum sample count required before calibrated output is trusted at all.

//+------------------------------------------------------------------+
//| Configure                                                        |
//+------------------------------------------------------------------+
void CExitLadderCalibrator::Configure(const double &percentiles[], const double &close_fractions[],
                                      const int min_samples, const int lookback_trades)
  {
   ArrayResize(m_percentiles, ArraySize(percentiles));
   ArrayResize(m_close_fractions, ArraySize(close_fractions));
   ArrayCopy(m_percentiles, percentiles);
   ArrayCopy(m_close_fractions, close_fractions);
   m_min_samples     = (min_samples > 0 ? min_samples : 30);
   m_lookback_trades = (lookback_trades > 0 ? lookback_trades : 200);
  }

The percentile function itself uses linear interpolation between the two nearest ranked values, which is the same method spreadsheet software uses for percentile calculations—it avoids the abrupt jumps a nearest-rank method would produce as new samples arrive.

//+------------------------------------------------------------------+
//| Percentile                                                       |
//| Linear-interpolation percentile on an already-sorted array,      |
//| pct expressed 0-100.                                             |
//+------------------------------------------------------------------+
double CExitLadderCalibrator::Percentile(double &sorted_values[], double pct) const
  {
   int n = ArraySize(sorted_values);
   if(n == 0)
      return(0.0);
   if(n == 1)
      return(sorted_values[0]);

   double rank = (pct / 100.0) * (n - 1);
   int lower = (int)MathFloor(rank);
   int upper = (int)MathCeil(rank);
   if(lower < 0)
      lower = 0;
   if(upper > n - 1)
      upper = n - 1;

   if(lower == upper)
      return(sorted_values[lower]);

   double weight = rank - lower;
   return(sorted_values[lower] + weight * (sorted_values[upper] - sorted_values[lower]));
  }

"Calibrate()" ties the pieces together. If the sample count is below the minimum, the function returns the fallback ladder unchanged. Percentiles based on very few trades are not reliable. Above the minimum, it takes the most recent "lookback_trades" samples, sorts their MFE-in-R values, and reads a rung off each configured percentile. A closing bubble pass guarantees the returned rungs are in ascending R order even if the percentiles were configured out of sequence, since "CLadderExecutor" assumes ascending order and has no reason to re-sort what it is handed.

//+------------------------------------------------------------------+
//| Calibrate                                                        |
//| Builds out_ladder[] from percentiles of mfe_r across the most    |
//| recent m_lookback_trades samples. Falls back to the configured   |
//| static ladder when fewer than m_min_samples samples are          |
//| available. Rungs are always returned in ascending R order.       |
//+------------------------------------------------------------------+
int CExitLadderCalibrator::Calibrate(const SExcursionSample &samples[], SLadderRung &out_ladder[]) const
  {
   int total = ArraySize(samples);

   if(total < m_min_samples)
     {
      int fb_count = ArraySize(m_fallback);
      ArrayResize(out_ladder, fb_count);
      for(int i = 0; i < fb_count; i++)
         out_ladder[i] = m_fallback[i];
      return(fb_count);
     }

//--- take the most recent m_lookback_trades samples (samples[] is assumed
//--- ordered oldest to newest, as produced by CExcursionTracker::LoadSamples())
   int start = (total > m_lookback_trades) ? (total - m_lookback_trades) : 0;
   int used  = total - start;

   double mfe_values[];
   ArrayResize(mfe_values, used);
   for(int i = 0; i < used; i++)
      mfe_values[i] = samples[start + i].mfe_r;

   SortAscending(mfe_values);

   int rung_count = ArraySize(m_percentiles);
   ArrayResize(out_ladder, rung_count);

   for(int i = 0; i < rung_count; i++)
     {
      out_ladder[i].r_level        = Percentile(mfe_values, m_percentiles[i]);
      out_ladder[i].close_fraction = m_close_fractions[i];
     }

//--- enforce ascending R order regardless of configured percentile order —
//--- a simple bubble pass since rung counts are always small (2-5 rungs)
   for(int i = 0; i < rung_count - 1; i++)
     {
      for(int j = 0; j < rung_count - i - 1; j++)
        {
         if(out_ladder[j].r_level > out_ladder[j + 1].r_level)
           {
            SLadderRung tmp = out_ladder[j];
            out_ladder[j] = out_ladder[j + 1];
            out_ladder[j + 1] = tmp;
           }
        }
     }

   return(rung_count);
  }

With the default configuration used later in the EA—percentiles of 30, 60, and 85—the first rung sits at the point where 70% of recent trades still had further to run, the second at the point where 40% did, and the third near the edge of what the strategy realistically reaches. That framing is the whole point: each rung is a statement about the probability of continuation, not an arbitrary round number.


CLadderExecutor—Firing Rungs Without Double-Counting

"CLadderExecutor" takes the ladder produced by the calibrator and applies it to open positions. Each managed position tracks which rungs have already fired using a small boolean array, which is what stops a rung from being triggered twice as the price oscillates around its threshold on successive ticks.

//+------------------------------------------------------------------+
//| CLadderExecutor                                                  |
//| Applies a calibrated SLadderRung[] ladder to open positions,     |
//| firing each rung at most once per ticket and optionally moving   |
//| the stop to breakeven after the first partial close.             |
//+------------------------------------------------------------------+
   struct SManaged
     {
      ulong             ticket;
      double            entry_price;
      double            r_distance;
      double            original_volume;
      long              type;
      bool              rung_fired[8];   // supports up to 8 rungs
      bool              breakeven_done;
     };

"OnTick()" walks every managed ticket, computes current progress in R the same way "CExcursionTracker" does, and checks it against each unfired rung. Close volume is always computed as a fraction of the original position volume, not the volume remaining after earlier partial closes—a ladder built on remaining volume would leave a shrinking tail behind on every rung instead of the clean, predictable split the calibration was designed to produce.

//+------------------------------------------------------------------+
//| OnTick                                                           |
//| For every managed ticket, computes current progress in R and     |
//| fires any rung whose threshold has been reached and has not      |
//| already fired for that ticket.                                   |
//+------------------------------------------------------------------+
void CLadderExecutor::OnTick(void)
  {
   for(int i = m_count - 1; i >= 0; i--)
     {
      ulong ticket = m_managed[i].ticket;

      if(!PositionSelectByTicket(ticket))
        {
         Deregister(ticket);
         continue;
        }

      string symbol         = PositionGetString(POSITION_SYMBOL);
      double current_volume = PositionGetDouble(POSITION_VOLUME);
      double bid             = SymbolInfoDouble(symbol, SYMBOL_BID);
      double ask             = SymbolInfoDouble(symbol, SYMBOL_ASK);

      double favorable_price = (m_managed[i].type == POSITION_TYPE_BUY) ? bid : ask;
      double excursion = (m_managed[i].type == POSITION_TYPE_BUY)
                         ? (favorable_price - m_managed[i].entry_price)
                         : (m_managed[i].entry_price - favorable_price);
      double progress_r = excursion / m_managed[i].r_distance;

      for(int r = 0; r < m_rung_count; r++)
        {
         if(m_managed[i].rung_fired[r])
            continue;
         if(progress_r < m_ladder[r].r_level)
            continue;

         double close_volume = m_managed[i].original_volume * m_ladder[r].close_fraction;
         double vol_step     = SymbolInfoDouble(symbol, SYMBOL_VOLUME_STEP);
         double vol_min      = SymbolInfoDouble(symbol, SYMBOL_VOLUME_MIN);
         close_volume = MathFloor(close_volume / vol_step) * vol_step;

         //--- never close more than what remains open, and skip a rung
         //--- outright if the rounded volume falls below the broker
         //--- minimum rather than risk closing the entire remainder early
         if(close_volume > current_volume)
            close_volume = current_volume;
         if(close_volume < vol_min)
           {
            m_managed[i].rung_fired[r] = true; // mark handled, nothing to close
            continue;
           }

         if(m_trade.PositionClosePartial(ticket, close_volume))
           {
            m_managed[i].rung_fired[r] = true;
            PrintFormat("LadderExecutor: ticket=%I64u rung=%d R=%.2f closed=%.2f lots",
                        ticket, r, m_ladder[r].r_level, close_volume);

            if(m_move_to_breakeven_after_first && !m_managed[i].breakeven_done)
              {
               double be_price = m_managed[i].entry_price;
               double tp       = PositionGetDouble(POSITION_TP);
               if(m_trade.PositionModify(ticket, be_price, tp))
                  m_managed[i].breakeven_done = true;
              }
           }
         else
           {
            PrintFormat("LadderExecutor: partial close failed ticket=%I64u rung=%d retcode=%d",
                        ticket, r, m_trade.ResultRetcode());
           }
        }
     }
  }

Rounding down to the broker's volume step and clamping to what remains open both matter on small accounts, where a calibrated 34% of a 0.02-lot position can round to something the broker will reject outright. Marking an unreachable rung as fired rather than leaving it pending stops the executor from repeatedly retrying a partial close that will never succeed. When a rung does fire successfully, an optional breakeven move follows the first one only, using "CTrade::PositionModify()" to bring the stop up to entry without touching the take-profit level.


SelfCalibratingLadderEA.mq5—Wiring It Together

The EA's job is to own the pieces the classes above intentionally do not: opening trades, detecting when a position has fully closed, and computing the realized R result that "Finalize()" needs. The demo entry logic is a plain EMA crossover with an ATR-derived stop—it exists only to generate trades for the ladder to manage, and any real entry signal can replace "CheckDemoEntry()" without touching the tracker, calibrator, or executor.

After a position is closed, it cannot be queried via position functions, so the realized result must be reconstructed from deal history. "ComputeResultR()" selects the position's history by ticket, sums the volume-weighted average price across its closing deals, and expresses the difference from entry as a multiple of R.
//+------------------------------------------------------------------+
//| Computes the realized result of a fully closed position, in R    |
//+------------------------------------------------------------------+
double ComputeResultR(ulong ticket, double entry_price, double r_distance, long type)
  {
   if(!HistorySelectByPosition(ticket))
      return(0.0);

   double sum_volume_price = 0.0;
   double sum_volume       = 0.0;
   int total = HistoryDealsTotal();

   for(int i = 0; i < total; i++)
     {
      ulong deal = HistoryDealGetTicket(i);
      if(deal == 0)
         continue;
      long entry_flag = HistoryDealGetInteger(deal, DEAL_ENTRY);
      if(entry_flag != DEAL_ENTRY_OUT && entry_flag != DEAL_ENTRY_OUT_BY)
         continue;

      double vol   = HistoryDealGetDouble(deal, DEAL_VOLUME);
      double price = HistoryDealGetDouble(deal, DEAL_PRICE);
      sum_volume_price += vol * price;
      sum_volume       += vol;
     }

   if(sum_volume <= 0.0)
      return(0.0);

   double avg_close = sum_volume_price / sum_volume;
   double move = (type == POSITION_TYPE_BUY) ? (avg_close - entry_price) : (entry_price - avg_close);
   return(move / r_distance);
  }

Summing across every "DEAL_ENTRY_OUT" deal rather than reading a single closing price matters here specifically because the ladder itself produces multiple partial closes—a position scaled out at three rungs generates three separate closing deals, and only the volume-weighted average across all of them gives the correct realized result.

To detect closures, the EA compares the set of open positions between ticks. The native event-based option is "OnTradeTransaction()." "SweepClosedPositions()" keeps a static array of previously known tickets and finalizes any tracked ticket that has dropped out of the current set.

//+------------------------------------------------------------------+
//| Sweeps tracker-registered tickets for closures. When a tracked   |
//| ticket is no longer an open position, its result is computed     |
//| from history and the sample is finalized to the persistent file. |
//+------------------------------------------------------------------+
void SweepClosedPositions(void)
  {
   static ulong known_tickets[];
   ulong current_tickets[];
   int total = PositionsTotal();
   ArrayResize(current_tickets, total);
   for(int i = 0; i < total; i++)
      current_tickets[i] = PositionGetTicket(i);

   for(int i = 0; i < ArraySize(known_tickets); i++)
     {
      ulong ticket = known_tickets[i];
      bool still_open = false;
      for(int j = 0; j < ArraySize(current_tickets); j++)
         if(current_tickets[j] == ticket)
           {
            still_open = true;
            break;
           }

      if(!still_open && g_tracker.IsRegistered(ticket))
        {
         double entry = 0.0, r_distance = 0.0;
         long   type  = 0;
         if(g_tracker.GetLiveInfo(ticket, entry, r_distance, type))
           {
            double result_r = ComputeResultR(ticket, entry, r_distance, type);
            g_tracker.Finalize(ticket, result_r);
            g_samples_since_recalibration++;

            if(g_samples_since_recalibration >= InpRecalibrateEvery)
              {
               Recalibrate();
               g_samples_since_recalibration = 0;
              }
           }
        }
     }

   ArrayResize(known_tickets, ArraySize(current_tickets));
   ArrayCopy(known_tickets, current_tickets);
  }

"Recalibrate()" reloads the full sample file, hands it to the calibrator, and installs the resulting ladder into the executor. Running it every "InpRecalibrateEvery" closed trade—five by default—rather than on every single close keeps the ladder from reshaping itself so often that scale-out behavior becomes inconsistent from one trade to the next.


Verification With TestExcursionLadder.mq5

Because "CExitLadderCalibrator::Calibrate()" takes a plain "SExcursionSample" array and touches no live position, it can be exercised directly from a script with synthetic data—no chart, no open trades, and no broker connection required for the calibration logic itself. "TestExcursionLadder.mq5" builds six checks around this.

The percentile test uses a deliberately hand-computable data set—MFE-in-R values running from 1.0 to 41.0 in whole-number steps—so that the 0th, 50th, and 100th percentiles land on values that can be verified with arithmetic rather than trusted on faith: minimum, median, and maximum, respectively.

//+------------------------------------------------------------------+
//| TestPercentileCalibration                                        |
//| Uses a hand-computable data set: mfe_r = 1..41 (41 values), so   |
//| percentiles land on values that are easy to verify by hand.      |
//+------------------------------------------------------------------+
void TestPercentileCalibration(void)
  {
   Print("--- Percentile calibration tests ---");

   CExitLadderCalibrator calib;
   double pct[]  = {0.0, 50.0, 100.0};
   double frac[] = {0.34, 0.33, 0.33};
   calib.Configure(pct, frac, 30, 200);

   SLadderRung fallback[3];
   fallback[0].r_level = 1.0;
   fallback[0].close_fraction = 0.34;
   fallback[1].r_level = 2.0;
   fallback[1].close_fraction = 0.33;
   fallback[2].r_level = 3.0;
   fallback[2].close_fraction = 0.33;
   calib.SetFallback(fallback);

   double mfe_values[41];
   for(int i = 0; i < 41; i++)
      mfe_values[i] = 1.0 + i; // 1.0, 2.0 ... 41.0 -- above the 30 minimum

   SExcursionSample samples[];
   BuildSamples(mfe_values, samples);

   SLadderRung out_ladder[];
   int rungs = calib.Calibrate(samples, out_ladder);

   ASSERT(rungs == 3, "percentile: returns 3 rungs when at/above min_samples");
//--- 0th percentile = minimum = 1.0; 100th percentile = maximum = 41.0;
//--- 50th percentile of 41 sorted values = the 21st value = 21.0
   ASSERT_CLOSE(out_ladder[0].r_level, 1.0,  0.0001, "percentile: 0th percentile equals sample minimum (1.0R)");
   ASSERT_CLOSE(out_ladder[1].r_level, 21.0, 0.0001, "percentile: 50th percentile equals the median (21.0R)");
   ASSERT_CLOSE(out_ladder[2].r_level, 41.0, 0.0001, "percentile: 100th percentile equals sample maximum (41.0R)");
  }
//+------------------------------------------------------------------+
//| TestLookbackWindow                                               |
//| Confirms only the most recent m_lookback_trades samples are used |
//+------------------------------------------------------------------+
void TestLookbackWindow(void)
  {
   Print("--- Lookback window tests ---");

   CExitLadderCalibrator calib;
   double pct[]  = {50.0};
   double frac[] = {1.0};
   calib.Configure(pct, frac, 30, 20); // lookback = 20 most recent samples only

   SLadderRung fallback[1];
   fallback[0].r_level = 1.0;
   fallback[0].close_fraction = 1.0;
   calib.SetFallback(fallback);

//--- first 30 samples are all 100.0R (a stale, old regime), the
//--- last 20 samples are all 1.0R (the current regime)
   double mfe_values[50];
   for(int i = 0; i < 30; i++)
      mfe_values[i] = 100.0;
   for(int i = 30; i < 50; i++)
      mfe_values[i] = 1.0;

   SExcursionSample samples[];
   BuildSamples(mfe_values, samples);

   SLadderRung out_ladder[];
   calib.Calibrate(samples, out_ladder);

   ASSERT_CLOSE(out_ladder[0].r_level, 1.0, 0.0001,
                "lookback: median of the most recent 20 samples ignores the stale 100R regime");
  }
The lookback test uses two regimes: 100R for the first 30 samples and 1R for the last 20. With a lookback of 20, the calibrated rung must reflect only the recent regime. This catches bugs where the lookback is ignored and the full history is used. The round-trip test writes through "CExcursionTracker::AppendSample()" and reads back through "LoadSamples()" using the class's real file handling rather than a hand-rolled substitute, which is what actually proves the persistence layer works rather than just the arithmetic. Every test in the file follows the same PASS/FAIL logging pattern, printing a final count so a moderator or reader can run the script and see the result without stepping through the code.


Backtesting Notes

Run "SelfCalibratingLadderEA.mq5" in the Strategy Tester against a currency pair with a reasonably long history—a full year at minimum—since the calibrated ladder only becomes active once "InpMinSamples" trades have closed, and a short test window may never leave the fallback ladder at all. "Every tick based on real ticks" mode is worth the extra run time here specifically because "CExcursionTracker::UpdateTick()" depends on intrabar price movement to find the true peak MFE, and OHLC-only modes would understate excursion on any bar where the best price occurred mid-bar rather than at its close.

A useful way to see the calibration working is to run two back-to-back tests on the same range: a short one that never reaches "InpMinSamples" closed trades and a longer one that does. The journal log from "Recalibrate()" prints the rung levels on every recalibration, and comparing the fallback rungs printed early eventually against the calibrated rungs printed later makes the shift from assumed to measured levels visible directly in the log rather than requiring a separate report.

Testing on a netting account is the simplest setup, since a hedging account can hold more than one position per symbol, and the current EA's 'CheckDemoEntry()' guard (against 'PositionsTotal() > 0') assumes a single position.


Known Limitations

The sample file is written per symbol by filename, but nothing in "CExcursionTracker" enforces that—running the same EA instance across multiple symbols with the default filename will blend their MFE-in-R distributions into one file, which defeats the purpose of a strategy-specific calibration. Give each symbol its own "InpSampleFile" value if running multi-symbol.

Close detection through "SweepClosedPositions()" polls the open-position set once per tick rather than reacting to "OnTradeTransaction()" directly. It works reliably for a single EA managing its own trades, but a busy multi-EA terminal, or a position closed by manual intervention between ticks, is better served by moving that detection into a transaction handler—a natural next step for anyone adapting this for production use.

The demo entry logic opens one position at a time by design, purely to keep the article's focus on exit management. Adapting the tracker and executor to a multi-position strategy needs no changes to either class—both already key everything off ticket number—but the EA's own position-count guard and "SweepClosedPositions()" bookkeeping would need to track multiple simultaneous tickets rather than assuming at most one.

Finally, a strategy that changes character over time—a parameter update, a shift in the traded instrument's volatility regime, or a change in market conditions—will carry stale excursion samples in its lookback window until enough new trades close to dilute them. The lookback window limits this exposure but does not eliminate it; a large regime shift is still felt gradually rather than immediately.


Conclusion

You should come away with a working, testable pattern for replacing hardcoded scale-out guesses with statistics derived from the account's own trades. The three core components—"CExcursionTracker," "CExitLadderCalibrator," and "CLadderExecutor"—implement a clear pipeline: measure MFE-in-R for each trade, persist samples, calibrate percentiles over a configurable lookback once a minimum sample count is met (otherwise use a fallback ladder), and execute partial closes against the calibrated rungs while preventing double-counting and handling broker volume constraints. The distribution-based rungs are expressed in R so the same framework works across symbols and stop sizes.

Practically, the article provides a demo EA that wires those pieces together and a test script that verifies percentile math, lookback behavior, and persistence. Integration notes and limitations are intentional: keep sample files per symbol, expect gradual adaptation after regime shifts (lookback limits but does not eliminate staleness), and prefer tick-accurate backtests to capture true intrabar MFE. In short, this approach turns an arbitrary scale-out rule into a measurable, self-adjusting exit system that is modular, verifiable, and ready to be adapted into production EAs.

Programs used in the article:

# Name Type Description
1 SelfCalibratingLadderEA.mq5 Demo EA Integration EA combining the demo entry logic with the tracker, calibrator, and executor.
2 ExcursionTracker.mqh Include File Live MFE-in-R tracking and CSV-based sample persistence.
3 ExitLadderCalibrator.mqh Include File Percentile-based ladder derivation with fallback handling.
4 LadderExecutor.mqh Include File Guarded partial-close execution against a calibrated ladder.
5 TestExcursionLadder.mq5 Script Verification script covering calibration math and sample persistence.
6 SelfCalibratingLadder.zip Zip Archive Archive with all files that can be unpacked into the terminal installation directory, and all files will be located in the required places.
Wasserstein Distance for Live Feature-Drift Detection in MQL5: Monitoring ONNX Model Inputs with Optimal Transport Wasserstein Distance for Live Feature-Drift Detection in MQL5: Monitoring ONNX Model Inputs with Optimal Transport
This article implements a lightweight feature-space drift guard in MQL5 using 1D Wasserstein‑1: sort-and-pair scoring on equal windows, IQR normalization per feature, and gating via both a weighted composite and a max-statistic. Configuration comes from a JSON manifest (window sizes, weights, warn/critical thresholds, actions). It runs next to an ONNX classifier and is validated with Strategy Tester results and explicit caveats.
Rough Volatility: Building a Roughness Index Feature from the RFSV Model for ML Trade Filtering Rough Volatility: Building a Roughness Index Feature from the RFSV Model for ML Trade Filtering
This article builds a usable roughness index from rough volatility theory by fitting local H via a structure-function regression on blocked returns, all in native MQL5. We combine it with vol-of-vol features, train a gradient-boosted classifier in Python, export to ONNX, and call it from an EA. You will be able to compute H on every bar and use the model as a regime-aware entry filter.
Working with ONNX Models in MQL5 (Part 2): Drawing the Model Graph on an Interactive Chart Panel Working with ONNX Models in MQL5 (Part 2): Drawing the Model Graph on an Interactive Chart Panel
We turn the ONNX reader into an interactive graph viewer on a MetaTrader 5 chart. The parser is extended to carry tensor shapes and node attributes, and a canvas with anti-aliased drawing renders nodes and labeled connections. The layout arranges layers by depth, while zoom, pan, and clicks let you inspect operators and the tensors they read. This makes a model's structure and data flow visible directly in MQL5.
Hidden Semi-Markov Models for Duration-Aware Regime Detection in MQL5 Hidden Semi-Markov Models for Duration-Aware Regime Detection in MQL5
Standard HMMs assume geometric, memoryless state durations, which poorly match real market phases. This piece implements a duration‑explicit Hidden Semi‑Markov Model natively in MQL5, with per‑state sojourn distributions and a residual‑time forward filter. Parameters are fit offline via EM and loaded through a compact JSON manifest. The EA for XAUUSD M5 uses expected remaining duration to gate entries and exits, helping hold trends while avoiding late entries near regime exhaustion.