preview
Adaptive Position Sizing in MQL5: A Prototype Risk Engine with Generalized Kelly and Bootstrap Calibration

Adaptive Position Sizing in MQL5: A Prototype Risk Engine with Generalized Kelly and Bootstrap Calibration

MetaTrader 5Trading |
122 0
Stanley Kimathi Kibaara
Stanley Kimathi Kibaara

Introduction

A typical Expert Advisor risks a fixed percentage of the account on every trade. This one-line rule ignores the strategy's statistical edge and current volatility. It also ignores drawdown paths that may not appear in a single historical backtest. As long as the strategy performs normally, a 1% fixed risk may be acceptable. During a deep drawdown, unusually volatile conditions, or a weak historical edge, that fixed percentage can be far too aggressive. Volatility inflates stops, a series of losses eats away at the safety margin, and the strategy's statistical advantage turns out to be weaker than it appeared in a single backtest.

The problem is not that 1% is too much or too little. The problem is that the position size is not based on the distribution of strategy outcomes and is not controlled by the probability of ruin or tail drawdowns. What is needed is a module separate from the signal logic that accepts normalized trade results in R-multiples and produces a risk fraction and lot size that shrink automatically in poor trading regimes and never exceed configured drawdown and ruin limits.

The engine described here replaces that fixed percentage with a layered, adaptive decision process. It works with R-multiples, defined as each trade's profit or loss divided by its initial monetary risk, so that all outcomes become comparable regardless of account size. From those normalized outcomes, the engine:

  • Computes enriched trade statistics, including distribution moments and tail metrics.
  • Estimates a growth-optimal fraction (generalized Kelly) directly from the empirical R distribution.
  • Adjusts the fraction using a volatility regime multiplier and a continuous performance‑degradation model.
  • Runs an iterative bootstrap Monte Carlo calibration that finds the maximum risk fraction satisfying user‑defined limits on ruin probability and drawdown.
  • It applies a final safety layer of absolute exposure limits.
  • Outputs a broker-valid lot size and an optional CSV audit trail.
The project is presented as a research prototype: a complete, executable blueprint that shows how to combine these techniques in MQL5. It is not a turnkey institutional risk engine. Its limitations are documented, and every module can be extended or replaced.


Design Principles

Several principles guided the architecture and can be applied to other risk management projects:

  1. Separation of concerns. The trading strategy decides when to trade; the engine decides how much. The two components are independent.
  2. Normalized inputs. Using R‑multiples makes the analysis portable across accounts, instruments, and timeframes.
  3. Layered safety. An edge estimator, a Monte Carlo calibrator, a continuous risk policy, and an exposure guard each independently constrain the final risk fraction.
  4. Broker awareness. All symbol properties are queried dynamically. No hard‑coded pip values or lot size assumptions.
  5. Transparency. Every adjustment factor is visible in the source code and can be logged. The decision chain is fully traceable.


The Input Data Contract: R-multiples and Initial Risk

Before any analysis can take place, trade outcomes must be converted to R-multiples:

R-multiple = Realized Profit or Loss / Initial Monetary Risk

Trade Initial Risk Realized P&L R-multiples Meaning
A $100 +$150 +1.50R Profit 1.5 times the risk
B $100 -$100 -1.00R Full risk lost
C $200 +$150 +0.50R Profit half the risk
D $150 -$50 -0.33R Loss one-third of risk

Table 1: R-multiple conversion example

MQL5's deal history provides realized profit and loss but not the initial monetary risk. Without that value, a valid R-multiple cannot be reconstructed from history alone. The engine does not extract R-multiples automatically from MQL5 history. The initial monetary risk must be supplied by the developer, either directly via AddTrade(result_r) or through a rich trade record that includes initialRiskMoney. This constraint ensures that no fictitious R-values enter the system and that the statistical analysis is never based on fabricated inputs.


Implementation in MQL5

    Shared Data Structures (PositionSizingTypes.mqh)

    All modules communicate through flat, well-commented structures. The most important ones are summarized below; the full definitions are in the source file.

    Structure Purpose
    STradeStatistics Holds enriched statistics: win/loss rates, average R values, median, skewness, kurtosis, downside deviation, ulcer index, standard errors, and more.
    SEdgeEstimate Growth-optimal fraction (optimalFraction) together with 90% bootstrap confidence bounds.
    SMonteCarloResult Aggregated simulation output: drawdown percentiles (90/95/99), conditional drawdown, expected shortfall, ruin probability, time-to-ruin, and seed information.
    SRiskConfig All user-configurable parameters: base risk, Monte Carlo settings, ruin/drawdown limits, sampling strategy, tail-shock parameters, recent window size, and risk tolerance for lot rounding.
    SRiskInput / SRiskDecision Input and output of the risk policy engine.
    SPositionSizeResult Final lot size, risk fraction, monetary risk (allowed and actual), stop distance, and error code.
    SRiskCalibrationResult Recommended fraction from the calibrator, with the observed ruin probability and drawdown at that fraction.

    Trade Statistics (TradeStatistics.mqh)

    CTradeStatistics stores an array of R-multiples and optional full trade records, then computes enriched statistics on demand using a dirty-flag mechanism. Beyond standard metrics, it calculates:

    • Median, skewness, and excess kurtosis from the non-zero R distribution.
    • Downside deviation, computed as the root mean square of negative R values relative to a target of zero. This measures the magnitude of losses only.
    • Ulcer index, a normalized measure of drawdown depth and duration. Since it is computed on a synthetic equity curve that assumes a constant 1% risk per trade, it serves as a proxy for comparing trade sequences rather than predicting actual equity drawdowns.
    • Longest losing streak.
    • Standard errors for expectancy and win rate.

    //+------------------------------------------------------------------+
    //| Class: CTradeStatistics                                          |
    //| Stores trade outcomes (R-multiples) and rich trade records.      |
    //| Provides enriched statistics including tail metrics, confidence  |
    //| intervals, and ulcer index.                                      |
    //+------------------------------------------------------------------+
    class CTradeStatistics
      {
    private:
       double            m_tradeResults[];                                   // array of raw R-multiples (0 for breakeven)
       STradeRecord      m_tradeRecords[];                                   // array of full trade records (optional metadata)
       int               m_count;                                            // number of trades currently stored
       STradeStatistics  m_stats;                                            // cached aggregate statistics
       bool              m_statsDirty;                                       // true when cache needs recalculation
    
       void              InvalidateCache()         { m_statsDirty = true; }  // mark the cache as stale
       void              CalculateStatistics();                              // compute all statistics from raw data
    
    public:
                         CTradeStatistics();                                 // constructor – initializes empty state
       void              AddTrade(const double result_r);                    // add a pre-computed R-multiple
       void              AddTradeRecord(const STradeRecord &record);         // add a trade record (R computed internally)
       void              Clear();                                            // delete all trades and reset cache
    
       int               TotalTrades() const { return m_count; }             // direct access to total count (no cache refresh)
       int               WinningTrades();                                    // count of trades with R > 0
       int               LosingTrades();                                     // count of trades with R < 0
       int               BreakevenTrades();                                  // count of trades where R ≈ 0
       double            WinRate();                                          // fraction of non-zero trades that are winners
       double            LossRate();                                         // fraction of non-zero trades that are losers
       double            AverageWinR();                                      // average R of winning trades
       double            AverageLossR();                                     // average magnitude (positive) of losing trades
       double            MedianR();                                          // median R of all non-zero trades
       double            SkewnessR();                                        // skewness of the R distribution
       double            KurtosisR();                                        // excess kurtosis of the R distribution
       double            MaxWinR();                                          // largest single-trade winning R
       double            MaxLossR();                                         // largest single-trade loss magnitude (positive)
       double            PayoffRatio();                                      // average win divided by average loss
       double            ExpectancyR();                                      // expected value per trade in R
       double            ProfitFactor();                                     // gross profit / gross loss (R terms)
       double            StdDevR();                                          // population standard deviation of R-multiples
       double            DownsideDeviationR();                               // semi-deviation for R < 0 only
       double            UlcerIndex();                                       // normalized measure of drawdown depth and duration
       int               LongestLosingStreak();                              // maximum consecutive losing trades
       double            ExpectancyStdError();                               // standard error of the mean (expectancy)
       double            WinRateStdError();                                  // standard error of the win rate
    
       STradeStatistics  GetStatistics();                                    // return a copy of the full statistics structure
       STradeStatistics  GetRecentStats(int lastNTrades) const;              // statistics for the most recent N trades only
       void              GetTradesArray(double &arr[]) const;                // copy the raw R-multiple array (for Monte Carlo)
      };

    The GetRecentStats() method returns the same metrics for the most recent N trades. This is an essential input for detecting performance deterioration. The window size is configurable via SRiskConfig::recentWindowTrades.

    //--- skewness: third central moment divided by (stdDev^3)
          double m2 = 0.0, m3 = 0.0;
          for(int i = 0; i < nonZero; i++)
            {
             double d = nonZeroArr[i] - m_stats.expectancyR;                  // deviation from mean
             m2 += d * d;
             m3 += d * d * d;
            }
          m2 /= nonZero;
          m3 /= nonZero;
          m_stats.skewnessR = (m2 > 1e-12) ? m3 / MathPow(m2, 1.5) : 0.0;
    
    //--- excess kurtosis: fourth central moment divided by (variance^2), minus 3
          double m4 = 0.0;
          for(int i = 0; i < nonZero; i++)
            {
             double d = nonZeroArr[i] - m_stats.expectancyR;
             m4 += d * d * d * d;
            }
          m4 /= nonZero;
          m_stats.kurtosisR = (m2 > 1e-12) ? (m4 / (m2 * m2)) - 3.0 : 0.0;
         }
    
    //--- downside deviation: standard deviation of negative R values only
       if(losses > 0)
         {
          double sumDsq = 0.0;
          for(int i = 0; i < m_count; i++)
             if(m_tradeResults[i] < -1e-6)
               {
                double d = m_tradeResults[i];                             // the negative R value
                sumDsq += d * d;
               }
          m_stats.downsideDeviationR = MathSqrt(sumDsq / losses);
         }
    

    Deterministic Validation Test

    A deterministic test dataset of four trades [+1.0R, –1.0R, +2.0R, –0.5R] is included in the demo EA to verify the correctness of all deterministic calculations before any random simulation runs.

    Metric Value
    Total trades 4
    Winning trades 2
    Losing trades 2
    Win rate 0.5
    Average win 1.50R
    Average loss 0.75R
    Payoff ratio 2.00
    Expectancy 0.375R

    Table 2: Expected statistics from the test dataset

    Edge Estimator (EdgeEstimator.mqh)

    The classical Kelly formula f* = (b*p - q)/b reduces the entire R distribution to a win rate and an average payoff ratio. To avoid this information loss, CEdgeEstimator finds the fraction *f* that maximizes the expected logarithmic growth E[log(1 + f * R)] directly from the empirical R array, using a golden‑section search on [0, maxFraction].

    //+------------------------------------------------------------------+
    //| Compute average log(1 + f*R) over the given array                |
    //| Returns -1e300 if any (1+f*R) <= 0 (ruin is prohibited)          |
    //+------------------------------------------------------------------+
    double CEdgeEstimator::LogGrowth(const double &rArray[], int count, double f)
      {
       if(count <= 0)                                                           // empty array guard
          return -1e300;                                                        // return a large penalty
    
       double sum = 0.0;                                                        // accumulate log returns
       for(int i = 0; i < count; i++)
         {
          double val = 1.0 + f * rArray[i];                                     // wealth multiplier for this R
          if(val <= 0.0)                                                        // ruin (wealth becomes zero or negative)
             return -1e300;                                                     // disqualify this fraction
          sum += MathLog(val);                                                  // accumulate log of wealth multiplier
         }
       return sum / count;                                                      // average log-growth
      }

    The Estimate() method returns a point estimate together with 90% bootstrap confidence intervals, obtained by re-estimating the optimal fraction on 500 resampled datasets. This bootstrap currently uses the terminal's global MathRand() state and ignores the fixed seed in SRiskConfig. This is a known limitation that can be improved in a future version.

    //+------------------------------------------------------------------+
    //| Estimate optimal fraction with bootstrapped confidence intervals |
    //| Performs a point estimate and then 500 bootstrap replications    |
    //| to obtain a 90% confidence interval (5th to 95th percentile).    |
    //| NOTE: This bootstrap currently uses the terminal's global        |
    //| MathRand() state; it does not respect the fixed seed from        |
    //| SRiskConfig. For reproducibility of the entire risk pipeline,    |
    //| a future improvement would be to pass a local pseudo-random      |
    //| generator seeded from the config.                                |
    //+------------------------------------------------------------------+
    SEdgeEstimate CEdgeEstimator::Estimate(const double &rArray[], int count)
      {
       SEdgeEstimate res;                                                       // result structure, initialized to zeros
       ZeroMemory(res);
    
       if(count < m_minTrades)                                                  // insufficient data
         {
          res.errorMessage = "Insufficient trades";                             // error message
          return res;
         }
    
    //--- point estimate on the original sample
       double fOpt = OptimalFractionFromR(rArray, count);                       // growth-optimal fraction
       if(fOpt < 0.0)                                                           // safety: negative fractions should not occur
          fOpt = 0.0;                                                           // but if they do, clamp to zero
    
    //--- bootstrap confidence intervals (percentile method, 500 resamples)
       int bootRuns = 500;                                                      // number of bootstrap replications
       double bootOpts[];                                                       // array to store each bootstrap estimate
       ArrayResize(bootOpts, bootRuns);
    
       for(int b = 0; b < bootRuns; b++)                                        // for each bootstrap iteration
         {
          double sample[];                                                      // array for a resampled dataset
          ArrayResize(sample, count);
          for(int i = 0; i < count; i++)                                        // draw with replacement
             sample[i] = rArray[MathRand() % count];                            // random index from original array
    
          double f = OptimalFractionFromR(sample, count);                       // optimal fraction for this bootstrap sample
          if(f < 0.0)                                                           // safety clamp
             f = 0.0;
          bootOpts[b] = f;                                                      // store the bootstrap estimate
         }
    
       ArraySort(bootOpts);                                                     // sort for percentile extraction
    
       int lowIdx  = (int)(bootRuns * 0.05);                                    // index for 5th percentile
       int highIdx = (int)(bootRuns * 0.95);                                    // index for 95th percentile
       if(lowIdx < 0)                                                           // boundary guard
          lowIdx = 0;
       if(highIdx >= bootRuns)                                                  // boundary guard
          highIdx = bootRuns - 1;
    
       res.lowerBound      = bootOpts[lowIdx];                                  // 90% confidence lower bound
       res.upperBound      = bootOpts[highIdx];                                 // 90% confidence upper bound
       res.optimalFraction = fOpt;                                              // point estimate
       res.isValid         = true;                                              // estimate is considered valid
    
       return res;
      }

    How the edge estimate fits into the overall decision

    The Kelly estimate serves as an edge-sensitive reference fraction. It tells the engine what the raw data suggests about the strategy's potential. The final risk deployed in live trading is not determined by Kelly alone. The dominance hierarchy is explicit:

    • Hard limiter: The Monte Carlo calibrator enforces the configured ruin probability and DD95 constraints. No other layer overrides these. If no fraction passes, calibration fails and the engine falls back to base risk.
    • Soft adjustments: The volatility regime multiplier and the recent performance degradation factor reduce risk further. They cannot increase risk above the calibrated fraction.
    • Absolute cap: The exposure guard enforces per-trade, total exposure, and minimum equity limits. These are non-negotiable and can block a trade entirely.
    • Broker validation: The lot calculator rejects any trade whose actual monetary risk after rounding exceeds the allowed tolerance.

    This layered approach ensures that a high Kelly estimate from a small sample never drives the actual position size.

    Generalized Kelly Search

    Generalized Kelly search
    Figure 1: Generalized Kelly search


    Bootstrap Monte Carlo Engine (MonteCarloEngine.mqh)

    This is the engine’s workhorse. It simulates thousands of equity paths by resampling historical R-multiples, supporting three modes:

    • IID bootstrap, independent draws with replacement.
    • Block bootstrap, consecutive blocks of trades are drawn to preserve serial correlation.
    • Tail-shock injection, synthetic extreme losses are inserted with a configurable probability, simulating events not present in the historical record.

    Each path tracks the equity curve, maximum drawdown, and time-to-ruin. The aggregated SMonteCarloResult contains drawdown percentiles (90, 95, 99), conditional drawdown (average of worst 5%), expected shortfall, and ruin probability. The Sharpe ratio is recorded for logging only. Per-trade bootstrap returns are not a robust basis for allocation decisions.

    //+------------------------------------------------------------------+
    //| Simulate a single equity path                                    |
    //| Inputs: none (uses member variables)                             |
    //| Outputs: final equity, max drawdown, Sharpe ratio, time of ruin  |
    //+------------------------------------------------------------------+
    void CMonteCarloEngine::RunSinglePath(double &outFinalEquity, double &outMaxDD, double &outSharpe, double &outTimeToRuin)
      {
       double equity = m_initialEquity;                                        // start with initial capital
       double peak   = equity;                                                 // peak equity so far
       double maxDD  = 0.0;                                                    // worst drawdown observed (fraction)
    
       double returns[];                                                       // array to store per-trade returns for Sharpe
       ArrayResize(returns, m_tradesPerSim);
    
       outTimeToRuin = -1;                                                     // assume no ruin occurs
       bool ruined = false;                                                    // ruin flag for this path
    
       int t = 0;                                                              // trade counter
       while(t < m_tradesPerSim)                                               // simulate each trade
         {
          int idx = SampleTradeIndex(t);                                       // get a random R-multiple index
          double rMultiple = m_tradeResults[idx];                              // the sampled R value
    
          //--- tail shock injection: with a small probability, replace the R-multiple by a severe negative shock
          if(m_tailShockProb > 0 && (MathRand() / 32767.0) < m_tailShockProb)  // if shock probability > 0 and random draw < prob
            {
             rMultiple = -m_tailShockMag;                                      // simplified: always the same magnitude (could be a distribution)
            }
    
          double equityBefore = equity;                                        // snapshot equity before this trade
          double riskMoney    = equityBefore * m_riskFraction;                 // monetary value of 1R
          double profit       = riskMoney * rMultiple;                         // actual profit/loss in currency
          equity += profit;                                                    // update equity
    
          //--- track peak and drawdown
          if(equity > peak) peak = equity;                                     // new peak reached
          double dd = (peak > 0) ? (peak - equity) / peak : 0.0;               // current drawdown fraction
          if(dd > maxDD) maxDD = dd;                                           // update max drawdown if worse
    
    
    
          //--- simple per-trade return for Sharpe calculation
          double ret = (equityBefore > 0) ? profit / equityBefore : 0.0;       // return as fraction of equity
          returns[t] = ret;                                                    // store for later Sharpe
    
          //--- ruin check (only record the first time)
          if(!ruined && equity <= m_initialEquity * m_ruinThresholdRatio)      // equity dropped below ruin threshold
            {
             outTimeToRuin = t;                                                // record the trade index
             ruined = true;                                                    // mark as ruined
            }
          t++;
         }
    
       outFinalEquity = equity;                                                // final equity after all trades
       outMaxDD       = maxDD;                                                 // maximum drawdown encountered
    
       //--- compute Sharpe ratio (mean return / std deviation)
       double meanRet = 0.0, stdRet = 0.0;
       if(m_tradesPerSim > 0)
         {
          double sum = 0.0, sumSq = 0.0;
          for(int i = 0; i < m_tradesPerSim; i++)
            {
             sum   += returns[i];                                              // sum of returns
             sumSq += returns[i] * returns[i];                                 // sum of squared returns
            }
          meanRet = sum / m_tradesPerSim;                                      // arithmetic mean
          double var = (sumSq / m_tradesPerSim) - (meanRet * meanRet);         // population variance
          if(var < 0.0) var = 0.0;                                             // correct possible tiny negative due to floating point
          stdRet = MathSqrt(var);                                              // standard deviation
         }
       outSharpe = (stdRet != 0.0) ? (meanRet / stdRet) : 0.0;                 // Sharpe ratio (0 if no variation)
      }

      Bootstrap Resampling (With Replacement)

      Bootstrap resampling illustration

      Figure 2: Bootstrap resampling illustration

      Metric Example Value Interpretation
      Simulations 10,000 Number of equity paths
      Avg max drawdown 12.7% Typical worst drawdown
      Max drawdown 38.4% Most extreme path
      95th percentile DD 26.1% 1-in-20 worst-case
      Ruin probability 8.2% Chance of 50% account loss
      Ruin Threshold 50% Equity level defining ruin

      Table 3: Example Monte Carlo output (results vary with the random seed)


      Risk Calibrator (RiskCalibrator.mqh)

      Instead of a one-shot Kelly to Monte Carlo to adjust pipeline, the engine uses an iterative grid search. The calibrator tries risk fractions from the user's maxRiskPerTrade downward in 0.1% steps, running a quick Monte Carlo simulation at each step, and stops at the first fraction that satisfies both:

      • ruinProbability ≤ config.maxRuinProb
      • drawdown95Percentile ≤ config.maxDD95Percent

      This approach finds the highest risk that still respects the trader's safety constraints. After the calibrator finds a fraction that meets the hard limits, the pipeline applies no additional ruin or drawdown penalties. These constraints are already enforced at calibration.

      //+------------------------------------------------------------------+
      //| Grid-search: try risk fractions from highest to lowest, stopping |
      //| at the first one that passes both ruin and drawdown limits.      |
      //+------------------------------------------------------------------+
      SRiskCalibrationResult CRiskCalibrator::Calibrate(CMonteCarloEngine &engine,
            const double &tradeResults[], int count,
            const SRiskConfig &config)
        {
         SRiskCalibrationResult res;                                            // result structure to be filled
         ZeroMemory(res);                                                       // clear all fields
      
         if(count < 5)                                                          // need a minimum of trades for meaningful simulation
           {
            res.message = "Insufficient trades";                                // descriptive error
            return res;
           }
      
         //--- step downward from the maximum allowed risk
         double bestFrac = 0;                                                   // will hold the best fraction found
         const double step = 0.001;                                             // 0.1% increments (0.001 = 0.1%)
      
         for(double f = config.maxRiskPerTrade; f >= step; f -= step)           // iterate from cap down to 0.001
           {
            //--- configure the engine for the current fraction
            engine.SetHistoricalTrades(tradeResults, count);                    // reload the same trade array
            engine.SetParameters(config.monteCarloQuickRuns,                    // use quick run count for calibration speed
                                 100,                                           // 100 trades per simulation path
                                 10000.0,                                       // initial equity $10k (could be parameterized later)
                                 config.ruinThresholdRatio,                     // ruin threshold from config
                                 f,                                             // the risk fraction to test
                                 config.mcSampling,                             // IID or block
                                 config.blockLength,                            // block length if block bootstrap
                                 config.tailShockProb,                          // tail shock probability
                                 config.tailShockMagnitude,                     // tail shock magnitude
                                 config.randomSeed,                             // fixed seed or 0
                                 config.useFixedSeed);                          // whether to use the fixed seed
      
            if(!engine.Run())                                                   // if the engine fails (e.g., no trades), skip
               continue;
      
            //--- obtain Monte Carlo results (output parameter, not return-by-value)
            SMonteCarloResult mc;
            engine.GetResult(mc);
      
            //--- check if both constraints are satisfied
            if(mc.ruinProbability <= config.maxRuinProb &&                       // ruin probability is acceptable
               mc.drawdown95Percentile <= config.maxDD95Percent)                 // 95th percentile drawdown is acceptable
              {
               bestFrac = f;                                                     // record the passing fraction
               res.ruinProbAtFraction = mc.ruinProbability;                      // snapshot of ruin probability at this fraction
               res.dd95AtFraction     = mc.drawdown95Percentile;                 // snapshot of DD95 at this fraction
               break;                                                            // stop searching (first from top that passes is the highest acceptable)
              }
           }
      
         //--- populate the result
         if(bestFrac > 0)                                                        // a valid fraction was found
           {
            res.recommendedFraction = bestFrac;                                  // the fraction to use
            res.success = true;                                                  // calibration succeeded
            res.message = "Calibration successful";                              // descriptive message
           }
         else                                                                    // no fraction passed both constraints
           {
            res.recommendedFraction = 0;                                         // no fraction
            res.success = false;                                                 // calibration failed
            res.message = "No fraction passed constraints";                      // reason
           }
         return res;
        }

      Iterative Risk Calibration (Grid Search)

      Calibration grid search: testing fractions from high to low until constraints are met

      Figure 3:  Calibration grid search: testing fractions from high to low until constraints are met

      Volatility Regime (VolatilityRegime.mqh)

      Rather than adjusting lot size directly, CVolatilityRegime supplies a volatility multiplier to the risk policy engine. It computes the ratio of the current ATR to a long‑term median ATR and returns a multiplier between 0.2 and 1.0. When volatility doubles, the multiplier drops to 0.5, reducing the risk budget.

      //+------------------------------------------------------------------+
      //| Return the current volatility multiplier (1.0 = normal)          |
      //| The multiplier is computed as the inverse of the ratio of current|
      //| ATR to the historical median, clamped between 0.2 and 1.0.       |
      //+------------------------------------------------------------------+
      double CVolatilityRegime::GetMultiplier()
        {
         if(m_atrHandle == INVALID_HANDLE)                                     // handle not ready
            return 1.0;
      
         double atrArr[1];
         if(CopyBuffer(m_atrHandle, 0, 0, 1, atrArr) != 1)                     // copy latest ATR
            return 1.0;
      
         double currentATR = atrArr[0];
         if(m_atrMedian <= 0)
            return 1.0;
      
         double ratio = currentATR / m_atrMedian;
         double mult  = 1.0 / MathMax(ratio, 0.5);
         if(mult > 1.0) mult = 1.0;
         if(mult < 0.2) mult = 0.2;
      
         m_currentMultiplier = mult;                                           // cache for external query if needed
         return mult;
        }

      Volatility Multiplier Vs ATR Ratio

      Volatility multiplier: current ATR vs. historical median, how multiplier is derived

      Figure 4:  Volatility multiplier: current ATR vs. historical median, how multiplier is derived

      Risk Policy Engine (RiskPolicy.mqh)

      The policy engine takes the calibrated fraction and applies further continuous adjustments:

      • Multiplies by the volatility regime multiplier.
      • Computes a t-statistic from the recent expectancy and its standard error. If the recent performance is significantly negative, risk is smoothly reduced. This heuristic significance signal is used as a smooth degradation indicator, not as a formal statistical hypothesis test.
      • Enforces the hard cap from configuration.

      All adjustments are multiplicative and continuous. There are no hard thresholds that cause abrupt jumps.

      //+------------------------------------------------------------------+
      //| Main decision: adjust the calibrated risk fraction based on      |
      //| volatility, recent performance, and hard cap.                    |
      //| The calibrator already ensures that ruin/drawdown limits are met,|
      //| so those metrics are not used for further penalties here.        |
      //+------------------------------------------------------------------+
      SRiskDecision CRiskPolicy::Decide(const SRiskInput &riskInput)
        {
         SRiskDecision dec;
         ZeroMemory(dec);
      
         //--- start from the calibrated fraction (already constrained by Monte Carlo ruin/drawdown limits)
         double risk = riskInput.mcResult.riskPerTradeFraction;
         if(risk <= 0)                                                                                     // calibration returned no usable fraction
           {
            dec.reason = "Calibration failed";
            return dec;
           }
      
         //--- 1. Volatility regime adjustment
         risk *= riskInput.volatilityMultiplier;                                                           // scale risk by volatility multiplier (<1 in high vol)
      
         //--- 2. Recent performance degradation (t-statistic based)
         double tStat = 0;
         if(riskInput.recentStats.totalTrades >= 10 &&                                                     // need at least 10 recent trades to judge significance
            riskInput.recentStats.expectancyStdError > 0)                                                  // must have a valid standard error
            tStat = MathAbs(riskInput.recentStats.expectancyR) / riskInput.recentStats.expectancyStdError; // how many SEs away from zero
      
         //--- If recent expectancy is negative and tStat > 1.5, reduce risk gradually
         if(riskInput.recentStats.expectancyR < 0 && tStat > 1.5)                                          // heuristic significance signal
           {
            double factor = 1.0 - (m_perfDegradeFactor * MathMin(1.0, (tStat - 1.5) / 2.0));               // linear penalty between t=1.5 and t=3.5
            risk *= factor;                                                                                // apply the penalty factor
           }
      
         //--- 3. Hard cap from configuration
         if(risk > riskInput.config.maxRiskPerTrade)                                                       // never exceed the user-defined ceiling
            risk = riskInput.config.maxRiskPerTrade;
         if(risk < 0.0)                                                                                    // safety: risk cannot be negative
            risk = 0.0;
      
         dec.finalRiskFraction = risk;                                                                     // output the final fraction
         dec.approved          = (risk > 0);                                                               // trade is approved only if risk is positive
         dec.reason            = "Policy applied";                                                         // could be enriched with details
      
         return dec;
        }

      The Exposure Guard (ExposureGuard.mqh)

      CExposureGuard enforces absolute, non-negotiable limits: maximum per‑trade risk fraction, maximum total open exposure, maximum lot volume, and minimum account equity. These limits cannot be overridden by any statistical recommendation.

      //+------------------------------------------------------------------+
      //| Validate the proposed risk fraction against all active limits.   |
      //| Returns false if:                                                |
      //|  - account equity is below the minimum floor                     |
      //|  - proposed risk exceeds the per-trade maximum                   |
      //|  - combined open risk would exceed the total exposure limit      |
      //+------------------------------------------------------------------+
      bool CExposureGuard::ValidateRiskFraction(double proposedRisk, double currentOpenRisk, double equity)
        {
         //--- check minimum equity
         if(equity < m_minAccountEquity)                                       // account too small
            return false;
      
         //--- check per-trade risk cap
         if(proposedRisk > m_maxRiskPerTrade)                                  // risk fraction exceeds single-trade limit
            return false;
      
         //--- check total exposure after adding the new trade
         if((currentOpenRisk + proposedRisk) > m_maxTotalExposure)             // total open risk would exceed allowed maximum
            return false;
      
         return true;                                                          // all checks passed
        }

      Lot Calculator (LotCalculator.mqh)

      CLotCalculator handles the conversion of a monetary risk amount into a broker-valid lot size and validates that the actual risk after rounding does not silently exceed the allowed limit. It queries SYMBOL_TRADE_TICK_SIZE, SYMBOL_TRADE_TICK_VALUE, SYMBOL_VOLUME_MIN, SYMBOL_VOLUME_MAX, and SYMBOL_VOLUME_STEP dynamically.

      The critical safety check is performed by ValidateRiskAfterRounding():

      //+------------------------------------------------------------------+
      //| Verify that the actual risk of the normalized lot does not       |
      //| exceed the allowed risk beyond the configured tolerance          |
      //|                                                                  |
      //| actualRisk = lots * lossPerLot                                   |
      //| returns true if actualRisk <= allowedRisk * (1 + tolerance)      |
      //+------------------------------------------------------------------+
      bool CLotCalculator::ValidateRiskAfterRounding(double lots,                // the normalized lot size
                                                      double lossPerLot,         // monetary loss per lot at the given stop
                                                      double allowedRisk,        // maximum risk the engine permitted
                                                      double tolerance,          // extra allowed fraction (e.g. 0.05 = 5%)
                                                      double &actualRisk)        // [out] the actual monetary risk of the lot
        {
         actualRisk = lots * lossPerLot;                                         // compute the real monetary exposure
         return (actualRisk <= allowedRisk * (1.0 + tolerance));                 // true only if the excess is within tolerance
        }

      If the normalized lot size would cause the actual risk to exceed the tolerance, the sizer rejects the trade, preventing the minimum lot over-risk problem.

      Lot Size Calculation and Risk Validation Pipeline

      Lot size validation pipeline: raw lots, normalization, risk check

      Figure 5: Lot size validation pipeline: raw lots, normalization, risk check

      Position Sizer (PositionSizer.mqh)

      The CPositionSizer class is a facade that wires all the modules together. The public API is minimal, and this is the integration contract:

      • Initialize(config, symbol) sets up all sub-modules.
      • AddTrade(result_r) or AddTradeRecord(record) feeds completed trades.
      • RunCalibration() refreshes the Monte Carlo calibration.
      • GetRiskFraction() returns the adaptive risk fraction (auto-calibrates if needed). If calibration fails, it falls back to the base risk percent. If the policy engine rejects the fraction, it also falls back to the base risk. This is a continuity-focused design choice; a stricter setup could block trading on calibration failure.
      • GetLotSize(equity, stopPoints) returns a broker-compliant lot size and validates rounding risk. The stopLossPoints parameter is expressed in the same units as _Point; the calculation internally converts to monetary loss using the symbol's tick size and tick value.
      • ValidateRisk(...) delegates to the exposure guard.
      The configuration parameters are explicit: maxRiskPerTrade, maxRuinProb, maxDD95Percent, bootstrap mode (IID or block), blockLength, tailShockProb, tailShockMagnitude, recentWindowTrades, riskTolerance, randomSeed, and useFixedSeed. Failure conditions are equally explicit: calibration can fail, the policy can reject the fraction, the exposure guard can block the trade, and the lot calculator can reject on rounding over-risk. Each failure has a defined fallback or rejection behavior.

        The lot size calculation includes the safety check that prevents silent over-risk:

        //+------------------------------------------------------------------+
        //| Compute a broker-valid lot size for the given equity and stop    |
        //| distance (in points). Validates that the actual risk after lot   |
        //| rounding does not exceed the allowed tolerance.                  |
        //| Returns the lot size, and fills m_lastResult with details.       |
        //+------------------------------------------------------------------+
        double CPositionSizer::GetLotSize(double accountEquity, double stopLossPoints, double entryPrice = 0)
          {
           ZeroMemory(m_lastResult);                                           // clear previous result
        
           //--- basic input validation
           if(!m_initialized || accountEquity <= 0 || stopLossPoints <= 0)
             {
              m_lastResult.errorCode = SIZER_INSUFFICIENT_DATA;
              m_lastResult.errorDescription = "Invalid input";
              return 0.0;
             }
        
           double riskFrac = GetRiskFraction();                                // obtain the adaptive risk fraction
           m_lastResult.riskFraction = riskFrac;                               // store for output
        
           //--- step 1: raw lot size from monetary risk and stop distance
           double rawLots = m_lotCalc.CalculateRawLots(accountEquity, riskFrac, stopLossPoints);
           //--- step 2: normalize to broker constraints (clamp, round)
           double lots = m_lotCalc.NormalizeLots(rawLots);
        
           //--- compute the monetary loss per lot for the given stop
           double tickSize   = SymbolInfoDouble(m_symbol, SYMBOL_TRADE_TICK_SIZE);
           double tickValue  = SymbolInfoDouble(m_symbol, SYMBOL_TRADE_TICK_VALUE);
           double lossPerLot = (stopLossPoints / tickSize) * tickValue;        // loss in account currency per lot
        
           double allowedRisk = accountEquity * riskFrac;                      // maximum monetary risk allowed
           double actualRisk;
        
           //--- validate that rounding has not pushed the actual risk beyond the allowed tolerance
           if(!m_lotCalc.ValidateRiskAfterRounding(lots, lossPerLot, allowedRisk, m_config.riskTolerance, actualRisk))
             {
              m_lastResult.errorCode = SIZER_RISK_EXCEEDED;
              m_lastResult.errorDescription = "Actual risk exceeds allowed after rounding";
              m_lastResult.isValid = false;
              return 0.0;
             }
        
           //--- populate the result structure
           m_lastResult.lotSize            = lots;
           m_lastResult.monetaryRisk       = allowedRisk;
           m_lastResult.actualMonetaryRisk = actualRisk;
           m_lastResult.stopDistancePoints = stopLossPoints;
           m_lastResult.isValid            = (lots > 0);
           if(!m_lastResult.isValid)
              m_lastResult.errorCode = SIZER_LOT_TOO_SMALL;
        
           return lots;                                                        // return the broker-valid lot size
          }

        Position Sizing Engine (Simplified Pipeline)

        System architecture

        Figure 6: System architecture

        Persistence (Persistence.mqh)

        An optional CSV logger writes every risk decision to a file in the common folder. Each row records the timestamp, final risk fraction, current equity, and a reason string. This provides a basic audit trail for post‑trade analysis.

        //+------------------------------------------------------------------+
        //| Open the log file (or disable logging if enabled == false)       |
        //| The file is placed in the common file folder so that multiple    |
        //| terminals running the EA can share the same location if desired. |
        //+------------------------------------------------------------------+
        bool CPersistence::Init(string symbol, string baseName, bool enabled)
          {
           m_enabled = enabled;                                                 // store the enabled flag
        
           if(!enabled)                                                         // logging is disabled
              return true;                                                      // nothing to do, success
        
           //--- construct a unique filename using symbol and base name
           m_filename = baseName + "_" + symbol + "_risk_log.csv";              // e.g., "PositionSizer_EURUSD_risk_log.csv"
        
           m_handle = FileOpen(m_filename,                                      // open the file
                               FILE_WRITE | FILE_CSV | FILE_COMMON,             // write mode, CSV, common folder
                               ',');                                            // comma as delimiter
        
           if(m_handle == INVALID_HANDLE)                                       // if opening failed
              return false;                                                     // report failure
        
           //--- write the CSV header line
           FileWrite(m_handle, "Time,RiskFraction,Equity,Reason");              // header with column names
           return true;                                                         // file successfully created
          }

        End‑to‑End Numerical Example

        To make the decision pipeline concrete, consider a strategy that has accumulated 50 trades with a positive expectancy. A complete cycle might look like this:

        1. Statistics: expectancy = 0.18R, Sharpe‑like signal solid.
        2. Edge estimator: growth-optimal fraction = 8% with a wide confidence interval of 3% to 14%.
        3. Calibrator: tries fractions from 5% down. At 3.2%, ruin probability falls to 9% and DD95 to 28%, both within the configured limits. Calibrated fraction = 3.2%.
        4. Volatility regime: current ATR is 1.8× the long‑term median. Multiplier = 0.56.
        5. Risk policy: no recent performance degradation. Final risk = 3.2% × 0.56 = 1.79%.
        6. Exposure guard: checks pass (below 5% cap, total exposure is fine).
        7. Lot calculator: on a $10,000 account and a 120‑point stop, the allowed monetary risk is $179. The raw lot size computes to 0.149; normalized to the broker’s 0.01 step, the final lot size = 0.14 lots.
        This example shows how a seemingly moderate edge can be progressively reduced as safety layers are applied, resulting in a conservative position that respects both statistical and market conditions.


        The Demonstration EA (AdaptivePositionSizer.mq5)

        The demo EA loads the engine, feeds four test trades, runs calibration, and prints the calibrated risk fraction. It also displays the deterministic statistics, the edge estimator’s optimal fraction with confidence intervals, and the Monte Carlo calibration results. On each tick, it calculates a hypothetical stop distance using ATR and displays the resulting lot size. Actual trading is disabled by default (InpEnableTrading = false).

        A typical Expert log output (Monte Carlo numbers will vary):

        === Deterministic Statistics Verification ===
        Total trades: 4  Win: 2  Loss: 2  Breakeven: 0
        WinRate: 0.5000  AvgWin: 1.5000R  AvgLoss: 0.7500R
        Payoff ratio: 2.00  Expectancy: 0.3750R
        Edge estimator optimal fraction: 0.2500 (LB 0.12, UB 0.38)
        Calibrated risk fraction: 4.69% (ruin 9.2%, DD95 26.1%)

        Integrating the Engine into a Trading EA

        The engine is designed to be dropped into any EA with minimal coupling:

        1. Declare CPositionSizer sizer; in the global scope.
        2. In OnInit(), populate an SRiskConfig and call sizer.Initialize(config, _Symbol).
        3. After a trade closes and the initial risk is known, call sizer.AddTrade(profit / initialRisk).
        4. Periodically (e.g., after every N trades or once a day), call sizer.RunCalibration() to refresh the simulation.
        5. On a new signal, compute the stop distance in points and request the lot size:

          double lots = sizer.GetLotSize(equity, stopPoints);
          if(sizer.GetLastResult().isValid)
              // place order with 'lots'

          The strategy decides when to trade; the engine decides how much.

          Decision Chain Summary

          Stage Input Output Can reduce risk? Can block trade?
          Trade Statistics R-multiple Enriched stats No No
          Edge Estimator R-multiple
          Optimal fraction + CI No (only estimates)
          No
          Volatility Regime Current ATR Multiplier(0.2 to 1.0) Yes, indirectly via policy No
          Monte Carlo Engine R-multiple, risk fraction
          Drawdown/ruin metrics No (tests a given fraction)
          No
          Risk Calibrator Engine, config Recommended fraction (hard limits satisfied) Finds max fraction that passes No
          Risk Policy Calibrated fraction, volatility, recent stats Final risk fraction Yes (volatility & performance) No
          Exposure Guard Proposed risk, open risk, equity Approval No
          Yes (if limits breached)
          Lot Calculator Risk fraction, stop distance, symbol props Broker-valid lot size validates rounding risk Yes (if over-risk)

          Table 4: Risk decision pipeline


          Testing and Validation

          Test case Input Expected behavior
          Empty trade history No calls to AddTrade GetRiskFraction returns base risk; calibration fails gracefully
          All losing trades Four -1R trades Kelly invalid; calibration returns 0 or base risk; ruin probability high
          Stop distance below broker minimum 5 points, STOPS_LEVEL = 10 Lot size may still be computed (with minimum stop); risk validation applies
          Raw lot below minimum Minimal fraction NormalizeLots returns SYMBOL_VOLUME_MIN ; risk validation may reject if excess tolerance exceeded
          Excessive edge estimate Win rate 90%, payoff 10 Optimal fraction capped by maxKellyCap ; confidence intervals show wide range
          Minimum lot causes over‑risk Large min lot relative to allowed risk ValidateRiskAfterRounding returns false; trade rejected
          Volatility spike   Current ATR 3× median  Multiplier drops to ~0.33; final risk fraction reduced accordinglyTable 5: Recommended test matrix


          Known Limitations

          The engine is a tool for risk discipline, not a profit‑generating machine. Its boundaries should be clearly understood:

          1. Statistical sensitivity. Generalized Kelly remains sensitive to estimation error; small samples produce wide confidence intervals.
          2. Bootstrap constraints. Bootstrap cannot generate market regimes, flash crashes, or structural breaks not present in the historical record. Tail-shock injection partially mitigates this.
          3. Independence assumption. Resampling assumes trades are exchangeable. Block bootstrap reduces but does not eliminate serial dependence.
          4. Portfolio correlations. The exposure guard does not model portfolio correlations. Total risk may be underestimated for highly correlated positions.
          5. Slippage and market impact. No slippage or market-impact modeling is included.
          6. Reactive volatility. The volatility multiplier is reactive (ATR-based), not predictive.
          7. Small sample reliability. Trade histories with fewer than 30 to 50 trades produce unreliable estimates and calibrations.
          8. Computational cost. The calibration grid search with Monte Carlo runs is computationally expensive for large histories or many simulations. Run calibration only after new closed trades, not on every tick.
          9. Random generator quality. The standard MQL5 random generator is adequate for prototyping but not research-grade. The edge estimator's bootstrap confidence intervals do not yet respect the fixed seed.
          10. Broker data dependency. Lot sizing relies on accurate broker metadata. Incorrect TickValue or VolumeStep will lead to wrong lots.
          11. Numerical edge cases. Edge cases such as zero ATR or all winning trades are guarded but require thorough stress testing.
          12. Extensibility. The API does not currently expose plugin interfaces for custom edge estimators or volatility models.
          13. Overfitting risk. Calibrating the risk fraction on the same sample that produced the edge estimate can overstate confidence. Walk-forward or out-of-sample validation is strongly recommended.
          These limitations define the boundary of a purely historical risk model. They also indicate future enhancements: Bayesian shrinkage, portfolio-aware exposure, adaptive convergence, and persistent state recovery.


              Conclusion

              The prototype demonstrates a reproducible pipeline that turns raw trade outcomes into actionable, safety-constrained position sizes. From the normalized R-multiple input it:

              • Computes enriched statistics and an empirical growth-optimal reference.
              • Runs an iterative bootstrap Monte Carlo calibration to find the largest risk fraction meeting the configured ruinProbability and DD95 limits.
              • Applies continuous adjustments for regime volatility and recent performance deterioration.
              • Enforces absolute exposure and broker rounding constraints before returning a final lot size.
              Practically, the deliverable is a transparent MQL5 facade class (CPositionSizer) with a small contract that can be integrated into any EA: AddTrade(R) or AddTradeRecord(...), RunCalibration(), GetRiskFraction(), and GetLotSize(equity, stopPoints). Configuration exposes the important knobs (maxRiskPerTrade, maxRuinProb, maxDD95Percent, bootstrap mode, tail-shock settings, and recent window), while deterministic test cases, CSV auditing, and per-stage logging make the decision chain verifiable.

              Every component is written in native MQL5 and provided as open source. The architecture is deliberately transparent. Each adjustment can be inspected, logged, and modified. The engine is intentionally a research prototype: its assumptions, limitations (small-sample sensitivity, bootstrap limits, correlation, and slippage omissions), and random-seed handling are documented so the modules can be tested, adapted, and extended to match a specific strategy and operational constraints rather than treated as a turnkey solution.

              Programs used in the article:

              # Name Type Description 
              1 PositionSizingTypes.mqh Include Enriched shared structures and enums
              2 TradeStatistics.mqh Include  CTradeStatistics stores R-multiples, computes full distribution metrics, recent window
              3 EdgeEstimator.mqh Include  CEdgeEstimator generalized Kelly via golden-section search, bootstrap confidence intervals
              4 VolatilityRegime.mqh Include  CVolatilityRegime ATR-based volatility multiplier
              5 LotCalculator.mqh Include  CLotCalculator raw lot computation, broker normalization, rounding risk validation
              6 MonteCarloEngine.mqh Include  CMonteCarloEngine IID/block bootstrap, tail shocks, enriched output percentiles, time‑to‑ruin
              7 RiskPolicy.mqh Include  CRiskPolicy continuous adjustments for volatility and performance, no ruin/drawdown penalties
              8 RiskCalibrator.mqh Include  CRiskCalibrator iterative grid search for max fraction satisfying constraints
              9 ExposureGuard.mqh Include  CExposureGuard hard limits on risk, exposure, volume, minimum equity
              10 PositionSizer.mqh Include  CPositionSizer facade orchestrating all modules, provides GetLotSize() and GetRiskFraction()
              11 Persistence.mqh Include  CPersistence CSV audit log of risk decisions
              12  AdaptivePositionSizer.mq5  Expert  Demo EA showcasing the entire pipeline with deterministic verification

              Attached files |
              MQL5.zip (34.54 KB)
              Neural Networks in Trading: Decomposition Instead of Scaling — Building Modules Neural Networks in Trading: Decomposition Instead of Scaling — Building Modules
              In this article, we continue our hands-on exploration of SSCNN — a next-generation architectural solution capable of processing fragmented time series. Instead of blind scaling — smart modularity, attention to detail, and targeted normalization. Step by step, we are creating computational blocks in the MQL5 environment and laying the foundation for reliable predictive analysis.
              Working with ONNX Models in MQL5 (Part 1): Decoding the Model File with a Protobuf Parser Working with ONNX Models in MQL5 (Part 1): Decoding the Model File with a Protobuf Parser
              We decode ONNX files in pure MQL5 by implementing a Protocol Buffers reader from scratch. We generate a sample network in Python, verify it in Netron, and then parse the same binary to recover graph nodes, connections, weight tensors, and input/output shapes. The result is a MetaTrader 5 program that inspects a trained model's structure before inference, without any external libraries.
              Features of Experts Advisors Features of Experts Advisors
              Creation of expert advisors in the MetaTrader trading system has a number of features.
              Building Your Personal Expert Advisor (Part 4): Risk Management III—Risk Models and Order Execution Building Your Personal Expert Advisor (Part 4): Risk Management III—Risk Models and Order Execution
              The EA now defines risk by percentage, fixed cash, or fixed lot and can measure percentage against balance or equity. It supports market, limit, and stop orders, sizes from the planned entry, and enforces spread‑aware stop minima. Additional safeguards include downward volume rounding, explicit handling when the minimum lot exceeds target risk, and pending‑order distance/expiry checks, organized under a Plan–Validate–Execute structure.