Adaptive Position Sizing in MQL5: A Prototype Risk Engine with Generalized Kelly and Bootstrap Calibration
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.
Design Principles
Several principles guided the architecture and can be applied to other risk management projects:
- Separation of concerns. The trading strategy decides when to trade; the engine decides how much. The two components are independent.
- Normalized inputs. Using R‑multiples makes the analysis portable across accounts, instruments, and timeframes.
- Layered safety. An edge estimator, a Monte Carlo calibrator, a continuous risk policy, and an exposure guard each independently constrain the final risk fraction.
- Broker awareness. All symbol properties are queried dynamically. No hard‑coded pip values or lot size assumptions.
- 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

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)

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)

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

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

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 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)

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:
- Statistics: expectancy = 0.18R, Sharpe‑like signal solid.
- Edge estimator: growth-optimal fraction = 8% with a wide confidence interval of 3% to 14%.
- 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%.
- Volatility regime: current ATR is 1.8× the long‑term median. Multiplier = 0.56.
- Risk policy: no recent performance degradation. Final risk = 3.2% × 0.56 = 1.79%.
- Exposure guard: checks pass (below 5% cap, total exposure is fine).
- 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.
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:
- Declare CPositionSizer sizer; in the global scope.
- In OnInit(), populate an SRiskConfig and call sizer.Initialize(config, _Symbol).
- After a trade closes and the initial risk is known, call sizer.AddTrade(profit / initialRisk).
- Periodically (e.g., after every N trades or once a day), call sizer.RunCalibration() to refresh the simulation.
- 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:
- Statistical sensitivity. Generalized Kelly remains sensitive to estimation error; small samples produce wide confidence intervals.
- Bootstrap constraints. Bootstrap cannot generate market regimes, flash crashes, or structural breaks not present in the historical record. Tail-shock injection partially mitigates this.
- Independence assumption. Resampling assumes trades are exchangeable. Block bootstrap reduces but does not eliminate serial dependence.
- Portfolio correlations. The exposure guard does not model portfolio correlations. Total risk may be underestimated for highly correlated positions.
- Slippage and market impact. No slippage or market-impact modeling is included.
- Reactive volatility. The volatility multiplier is reactive (ATR-based), not predictive.
- Small sample reliability. Trade histories with fewer than 30 to 50 trades produce unreliable estimates and calibrations.
- 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.
- 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.
- Broker data dependency. Lot sizing relies on accurate broker metadata. Incorrect TickValue or VolumeStep will lead to wrong lots.
- Numerical edge cases. Edge cases such as zero ATR or all winning trades are guarded but require thorough stress testing.
- Extensibility. The API does not currently expose plugin interfaces for custom edge estimators or volatility models.
- 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.
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.
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 |
Warning: All rights to these materials are reserved by MetaQuotes Ltd. Copying or reprinting of these materials in whole or in part is prohibited.
This article was written by a user of the site and reflects their personal views. MetaQuotes Ltd is not responsible for the accuracy of the information presented, nor for any consequences resulting from the use of the solutions, strategies or recommendations described.
Neural Networks in Trading: Decomposition Instead of Scaling — Building Modules
Working with ONNX Models in MQL5 (Part 1): Decoding the Model File with a Protobuf Parser
Features of Experts Advisors
Building Your Personal Expert Advisor (Part 4): Risk Management III—Risk Models and Order Execution
- Free trading apps
- Over 8,000 signals for copying
- Economic news for exploring financial markets
You agree to website policy and terms of use