preview
Building a Position Sizing Engine in MQL5 with Multiple Risk Models

Building a Position Sizing Engine in MQL5 with Multiple Risk Models

MetaTrader 5Trading systems |
431 0
Ushana Kevin Iorkumbul
Ushana Kevin Iorkumbul

Introduction

Most EAs decide lot size using either a single hardcoded number or a simple percentage formula embedded in the strategy. This works until you need to compare risk approaches, reuse the logic in another EA, or apply different sizing rules on a prop-firm account versus a personal account running the same signals. Then the code is either copy-pasted with variations that diverge over time, or it becomes a chain of if-statements that selects a formula per case.

This article builds CPositionSizer, an engine that separates two questions: how much should I risk, and how do I turn that risk into a lot size. Four risk models plug into the same interface. Fixed fractional risks a percentage of balance. Fixed monetary risks a flat dollar amount. Volatility-scaled ties the stop distance to the instrument's own ATR. Equity-curve scaling reduces risk automatically during a drawdown. All four models share a single conversion component that measures money-per-point via OrderCalcProfit(). This keeps sizing correct for forex, indices, and CFDs without symbol-specific special cases.


Why Separate Risk Policy from Lot Conversion

Two different questions hide inside "what lot size should I use":

  1. How much am I willing to lose if the stop is hit? This is a policy question. A percentage of balance, a flat dollar figure, a number that shrinks during a drawdown. It has nothing to do with the symbol being traded.
  2. Given that dollar figure and a stop distance, what lot size produces it? This is a conversion question. It depends entirely on the symbol's tick value, tick size, contract size, and volume constraints.

Mixing these two ideas is exactly what makes sizing code fragile. A formula like balance * risk_pct / 100 / stop_points / 10.0 assumes a flat $10 per point value baked directly into the risk logic. That assumption holds for a handful of USD forex pairs and breaks on everything else. Switch the symbol to an index or a metal and the formula is simply wrong, even though the risk policy itself, "risk 1% per trade," never changed.

This engine keeps the two apart. Each risk model computes a risk_amount and hands it, along with a stop distance, to one shared CLotConverter. The converter's only job is turning that pair of numbers into a lot size the broker will accept. Adding a fifth model later means writing a small class that produces a risk_amount. The conversion math never needs touching.

Architectural diagram showing the separation between risk policy and lot conversion

Architectural diagram showing the separation between risk policy and lot conversion across all four models.


RiskTypes.mqh — the Shared Contract

Before any model exists, the engine needs a shared vocabulary. ENUM_RISK_MODEL names the four models. CSizingResult is the structured result every model returns. A bare bool or double tells the caller nothing about why a number was chosen, so this struct carries that context along with it.

//+------------------------------------------------------------------+
//|                                                  RiskTypes.mqh   |
//|                        Shared enum and result struct for the     |
//|                        position sizing engine                    |
//+------------------------------------------------------------------+

#ifndef RISK_TYPES_MQH
#define RISK_TYPES_MQH

//+------------------------------------------------------------------+
//| ENUM_RISK_MODEL                                                  |
//| The set of risk models CPositionSizer can compute a lot size     |
//| under. Each model answers "how much do I risk on this trade" in  |
//| a different way; all of them ultimately convert to a lot size    |
//| through the same normalization path.                             |
//+------------------------------------------------------------------+
enum ENUM_RISK_MODEL
  {
   RISK_MODEL_FIXED_FRACTIONAL,   // risk a fixed percentage of account balance per trade
   RISK_MODEL_FIXED_MONETARY,     // risk a fixed money amount per trade, independent of balance
   RISK_MODEL_VOLATILITY_SCALED,  // risk a fixed percentage, with stop distance driven by ATR
   RISK_MODEL_EQUITY_CURVE        // fixed fractional, scaled down automatically during drawdown
  };

//+------------------------------------------------------------------+
//| CSizingResult                                                    |
//| Everything a caller needs after a sizing calculation, so nothing |
//| downstream has to re-derive the risk amount or reconstruct why   |
//| a particular lot size was chosen.                                |
//+------------------------------------------------------------------+
struct CSizingResult
  {
   bool              success;             // true if a usable lot size was computed
   double            lots;                // final lot size, normalized to the symbol's volume step
   double            risk_amount;         // target risk amount in account currency before normalization
   double            actual_risk_amount;  // actual risk amount at the normalized lot size
   double            stop_points;         // stop distance in points used for the calculation
   double            scaling_factor;      // multiplier applied to the base risk (1.0 unless equity-curve scaled)
   ENUM_RISK_MODEL   model_used;          // which risk model produced this result
   string            reason;              // human-readable description of the outcome or rejection

                     CSizingResult(void)
     {
      success              = false;
      lots                 = 0.0;
      risk_amount          = 0.0;
      actual_risk_amount   = 0.0;
      stop_points          = 0.0;
      scaling_factor       = 1.0;
      model_used           = RISK_MODEL_FIXED_FRACTIONAL;
      reason               = "";
     }

                    ~CSizingResult(void)
     {
     }
  };

#endif // RISK_TYPES_MQH
//+------------------------------------------------------------------+

Two fields deserve a closer look: risk_amount and actual_risk_amount. The first is the target before rounding. The second is what actually happens once the lot size gets rounded to the broker's volume step. Reporting both lets a caller see exactly how far rounding pushed the real risk from the intended figure.


CLotConverter — Broker-Agnostic Money-Per-Point

The naive way to convert risk into lots assumes a flat dollar value per point. "$10 per pip on a standard lot" is the version every beginner tutorial repeats. That only holds for a subset of USD-quoted forex pairs. It breaks on JPY pairs, metals, indices, and any account not denominated in USD.

OrderCalcProfit() is the broker-agnostic fix. Give it an order type, symbol, lot size, and an open and close price, and it returns the real profit or loss in account currency, calculated the same way the trade server itself would. CLotConverter uses this to measure the money value of one point of movement at a small reference lot size, then scales that ratio to hit the target risk.

//+------------------------------------------------------------------+
//|                                              LotConverter.mqh    |
//+------------------------------------------------------------------+

#ifndef LOT_CONVERTER_MQH
#define LOT_CONVERTER_MQH

//+------------------------------------------------------------------+
//| CLotConverter                                                    |
//+------------------------------------------------------------------+
class CLotConverter
  {
private:
   string            m_last_symbol;         // symbol most recently queried
   double            m_cached_step;         // cached SYMBOL_VOLUME_STEP
   double            m_cached_min;          // cached SYMBOL_VOLUME_MIN
   double            m_cached_max;          // cached SYMBOL_VOLUME_MAX
   bool              m_cache_valid;         // whether the cache currently holds valid data

   void              RefreshCache(const string symbol);
   double            NormalizeLots(const double raw_lots);

public:
                     CLotConverter(void);
                    ~CLotConverter(void);

   bool              GetMoneyPerPointPerLot(const string symbol,const ENUM_ORDER_TYPE order_type,double &money_per_point);
   double            LotsForRisk(const string symbol,const ENUM_ORDER_TYPE order_type,const double risk_amount,const double stop_points,double &actual_risk_amount);
   bool              GetConstraints(const string symbol,double &step,double &vol_min,double &vol_max);
  };

RefreshCache() pulls SYMBOL_VOLUME_STEP, SYMBOL_VOLUME_MIN, and SYMBOL_VOLUME_MAX once per symbol and reuses them on later calls. It guards against a broker reporting a zero step, which would otherwise cause a division by zero later.

//+------------------------------------------------------------------+
//| RefreshCache                                                     |
//+------------------------------------------------------------------+
void CLotConverter::RefreshCache(const string symbol)
  {
   if(m_cache_valid && m_last_symbol == symbol)
      return;

   m_cached_step = ::SymbolInfoDouble(symbol,SYMBOL_VOLUME_STEP);
   m_cached_min  = ::SymbolInfoDouble(symbol,SYMBOL_VOLUME_MIN);
   m_cached_max  = ::SymbolInfoDouble(symbol,SYMBOL_VOLUME_MAX);

//--- guard against a broker returning zero step, which would cause division by zero below
   if(m_cached_step <= 0.0)
      m_cached_step = 0.01;

   m_last_symbol = symbol;
   m_cache_valid = true;
  }

NormalizeLots() rounds a raw lot value to the nearest step and clamps it inside [min, max]. Every model in this engine eventually calls through here, so all four produce lot sizes the broker actually accepts.

//+------------------------------------------------------------------+
//| NormalizeLots                                                    |
//+------------------------------------------------------------------+
double CLotConverter::NormalizeLots(const double raw_lots)
  {
   double steps_count = ::MathRound(raw_lots / m_cached_step);
   double normalized  = steps_count * m_cached_step;

   if(normalized < m_cached_min)
      normalized = m_cached_min;

   if(normalized > m_cached_max)
      normalized = m_cached_max;

   int digits_after_step = 0;
   double step_check = m_cached_step;

   while(step_check < 1.0 && digits_after_step < 8)
     {
      step_check *= 10.0;
      digits_after_step++;
     }

   normalized = ::NormalizeDouble(normalized,digits_after_step);

   return(normalized);
  }

GetMoneyPerPointPerLot() is the core measurement. It uses the symbol's own minimum lot as a reference size, since that value is always broker-accepted. It probes the profit of moving one point in the favorable direction, then divides by the reference lot size to get a per-lot ratio.

//+-------------------------------------------------------------------+
//| GetMoneyPerPointPerLot                                            |
//+-------------------------------------------------------------------+
bool CLotConverter::GetMoneyPerPointPerLot(const string symbol,const ENUM_ORDER_TYPE order_type,double &money_per_point)
  {
   RefreshCache(symbol);

   double point_size = ::SymbolInfoDouble(symbol,SYMBOL_POINT);

   if(point_size <= 0.0)
     {
      money_per_point = 0.0;
      return(false);
     }

//--- use the symbol's own minimum lot as the reference size for the OrderCalcProfit probe;
//--- this is always a valid, broker-accepted volume to query against
   double reference_lots = m_cached_min;

   if(reference_lots <= 0.0)
      reference_lots = 0.01;

   double reference_price = (order_type == ORDER_TYPE_BUY)
                            ? ::SymbolInfoDouble(symbol,SYMBOL_ASK)
                            : ::SymbolInfoDouble(symbol,SYMBOL_BID);

   if(reference_price <= 0.0)
     {
      money_per_point = 0.0;
      return(false);
     }

//--- probe the profit of moving exactly one point in the favorable direction,
//--- for the reference lot size, and use that as the money-per-point-per-lot ratio
   double close_price = (order_type == ORDER_TYPE_BUY)
                        ? (reference_price + point_size)
                        : (reference_price - point_size);

   double probe_profit = 0.0;
   bool calc_ok = ::OrderCalcProfit(order_type,symbol,reference_lots,reference_price,close_price,probe_profit);

   if(!calc_ok || reference_lots <= 0.0)
     {
      money_per_point = 0.0;
      return(false);
     }

//--- probe_profit is the money value of one point of movement at reference_lots;
//--- scale it down to a per-single-lot basis so callers can multiply by any lot size
   money_per_point = ::MathAbs(probe_profit) / reference_lots;

   return(money_per_point > 0.0);
  }

LotsForRisk() ties it together. Given a risk amount and stop distance, it measures money-per-point, converts to a raw lot size, then normalizes.

//+------------------------------------------------------------------+
//| LotsForRisk                                                      |
//+------------------------------------------------------------------+
double CLotConverter::LotsForRisk(const string symbol,const ENUM_ORDER_TYPE order_type,const double risk_amount,const double stop_points,double &actual_risk_amount)
  {
   RefreshCache(symbol);

   actual_risk_amount = 0.0;

   if(stop_points <= 0.0 || risk_amount <= 0.0)
      return(0.0);

   double money_per_point = 0.0;

   if(!GetMoneyPerPointPerLot(symbol,order_type,money_per_point) || money_per_point <= 0.0)
      return(0.0);

   double raw_lots        = risk_amount / (stop_points * money_per_point);
   double normalized_lots = NormalizeLots(raw_lots);

   actual_risk_amount = normalized_lots * stop_points * money_per_point;

   return(normalized_lots);
  }


CFixedFractionalModel — Risk a Percentage of Balance

The most common risk model. Risk a fixed percentage of the current balance on every trade. The lot size scales up or down with the account while the percentage stays fixed.

//+------------------------------------------------------------------+
//|                                     FixedFractionalModel.mqh     |
//+------------------------------------------------------------------+

#ifndef FIXED_FRACTIONAL_MODEL_MQH
#define FIXED_FRACTIONAL_MODEL_MQH

#include "RiskTypes.mqh"
#include "LotConverter.mqh"

//+------------------------------------------------------------------+
//| CFixedFractionalModel                                            |
//+------------------------------------------------------------------+
class CFixedFractionalModel
  {
private:
   CLotConverter     m_converter;   // shared money-per-point and normalization helper

public:
                     CFixedFractionalModel(void);
                    ~CFixedFractionalModel(void);

   CSizingResult     Calculate(const string symbol,const ENUM_ORDER_TYPE order_type,const double risk_pct,const double stop_points);
  };

//+------------------------------------------------------------------+
//| Constructor                                                      |
//+------------------------------------------------------------------+
CFixedFractionalModel::CFixedFractionalModel(void)
  {
  }

//+------------------------------------------------------------------+
//| Destructor                                                       |
//+------------------------------------------------------------------+
CFixedFractionalModel::~CFixedFractionalModel(void)
  {
  }

Calculate() reads the account balance, computes the target risk, and hands it off to the converter. Every rejection path returns a clear reason rather than silently returning zero lots.

//+------------------------------------------------------------------+
//| Calculate                                                        |
//+------------------------------------------------------------------+
CSizingResult CFixedFractionalModel::Calculate(const string symbol,const ENUM_ORDER_TYPE order_type,const double risk_pct,const double stop_points)
  {
   CSizingResult result;
   result.model_used  = RISK_MODEL_FIXED_FRACTIONAL;
   result.stop_points = stop_points;

   if(risk_pct <= 0.0)
     {
      result.success = false;
      result.reason  = "risk_pct must be positive";
      return(result);
     }

   if(stop_points <= 0.0)
     {
      result.success = false;
      result.reason  = "stop_points must be positive";
      return(result);
     }

   double balance     = ::AccountInfoDouble(ACCOUNT_BALANCE);
   double risk_amount = balance * (risk_pct / 100.0);

   double actual_risk_amount = 0.0;
   double lots               = m_converter.LotsForRisk(symbol,order_type,risk_amount,stop_points,actual_risk_amount);

   if(lots <= 0.0)
     {
      result.success = false;
      result.reason  = "unable to compute a valid lot size for the requested risk";
      return(result);
     }

   result.success             = true;
   result.lots                = lots;
   result.risk_amount         = risk_amount;
   result.actual_risk_amount  = actual_risk_amount;
   result.scaling_factor      = 1.0;
   result.reason              = "fixed fractional sizing at " + DoubleToString(risk_pct,2) + "% of balance";

   return(result);
  }


CFixedMonetaryModel — Risk a Flat Amount

Some traders want to risk the same dollar figure on every trade, regardless of balance. This matters on prop-firm accounts with a fixed size, or when testing a strategy against a specific budget. The model is nearly identical to fixed fractional, minus the balance lookup.

//+------------------------------------------------------------------+
//|                                       FixedMonetaryModel.mqh     |
//+------------------------------------------------------------------+

#ifndef FIXED_MONETARY_MODEL_MQH
#define FIXED_MONETARY_MODEL_MQH

#include "RiskTypes.mqh"
#include "LotConverter.mqh"

//+------------------------------------------------------------------+
//| CFixedMonetaryModel                                              |
//+------------------------------------------------------------------+
class CFixedMonetaryModel
  {
private:
   CLotConverter     m_converter;   // shared money-per-point and normalization helper

public:
                     CFixedMonetaryModel(void);
                    ~CFixedMonetaryModel(void);

   CSizingResult     Calculate(const string symbol,const ENUM_ORDER_TYPE order_type,const double risk_amount,const double stop_points);
  };

//+------------------------------------------------------------------+
//| Constructor                                                      |
//+------------------------------------------------------------------+
CFixedMonetaryModel::CFixedMonetaryModel(void)
  {
  }

//+------------------------------------------------------------------+
//| Destructor                                                       |
//+------------------------------------------------------------------+
CFixedMonetaryModel::~CFixedMonetaryModel(void)
  {
  }

Calculate() takes risk_amount directly as a parameter instead of deriving it from balance, then calls the same LotsForRisk() conversion.

//+------------------------------------------------------------------+
//| Calculate                                                        |
//+------------------------------------------------------------------+
CSizingResult CFixedMonetaryModel::Calculate(const string symbol,const ENUM_ORDER_TYPE order_type,const double risk_amount,const double stop_points)
  {
   CSizingResult result;
   result.model_used  = RISK_MODEL_FIXED_MONETARY;
   result.stop_points = stop_points;

   if(risk_amount <= 0.0)
     {
      result.success = false;
      result.reason  = "risk_amount must be positive";
      return(result);
     }

   if(stop_points <= 0.0)
     {
      result.success = false;
      result.reason  = "stop_points must be positive";
      return(result);
     }

   double actual_risk_amount = 0.0;
   double lots               = m_converter.LotsForRisk(symbol,order_type,risk_amount,stop_points,actual_risk_amount);

   if(lots <= 0.0)
     {
      result.success = false;
      result.reason  = "unable to compute a valid lot size for the requested risk";
      return(result);
     }

   result.success             = true;
   result.lots                = lots;
   result.risk_amount         = risk_amount;
   result.actual_risk_amount  = actual_risk_amount;
   result.scaling_factor      = 1.0;
   result.reason              = "fixed monetary sizing at a flat risk of " + DoubleToString(risk_amount,2);

   return(result);
  }

Both models funnel into the same LotsForRisk() call. The only difference is where risk_amount comes from.


CVolatilityScaledModel — Let ATR Set the Stop

A fixed stop distance in points ignores what the instrument is actually doing. A 300-point stop feels wide on a quiet day and tight during a volatile news release, on the same symbol. This model ties the stop distance to the Average True Range instead. A calmer market gets a tighter stop and a larger position. A volatile market gets a wider stop and a smaller position. The dollar risk stays the same either way.

//+------------------------------------------------------------------+
//|                                   VolatilityScaledModel.mqh      |
//+------------------------------------------------------------------+

#ifndef VOLATILITY_SCALED_MODEL_MQH
#define VOLATILITY_SCALED_MODEL_MQH

#include "RiskTypes.mqh"
#include "LotConverter.mqh"

//+------------------------------------------------------------------+
//| CVolatilityScaledModel                                           |
//+------------------------------------------------------------------+
class CVolatilityScaledModel
  {
private:
   CLotConverter     m_converter;     // shared money-per-point and normalization helper
   int               m_atr_handle;    // indicator handle for the ATR used to derive stop distance
   string            m_atr_symbol;    // symbol the current handle was created for
   ENUM_TIMEFRAMES   m_atr_timeframe; // timeframe the current handle was created for

   bool              EnsureHandle(const string symbol,const ENUM_TIMEFRAMES timeframe,const int atr_period);

public:
                     CVolatilityScaledModel(void);
                    ~CVolatilityScaledModel(void);

   CSizingResult     Calculate(const string symbol,
                               const ENUM_ORDER_TYPE order_type,
                               const ENUM_TIMEFRAMES timeframe,
                               const int atr_period,
                               const double atr_multiplier,
                               const double risk_pct);
  };

//+------------------------------------------------------------------+
//| Constructor                                                      |
//+------------------------------------------------------------------+
CVolatilityScaledModel::CVolatilityScaledModel(void)
  {
   m_atr_handle    = INVALID_HANDLE;
   m_atr_symbol    = "";
   m_atr_timeframe = PERIOD_CURRENT;
  }

//+------------------------------------------------------------------+
//| Destructor                                                       |
//+------------------------------------------------------------------+
CVolatilityScaledModel::~CVolatilityScaledModel(void)
  {
   if(m_atr_handle != INVALID_HANDLE)
      ::IndicatorRelease(m_atr_handle);
  }

EnsureHandle() creates the ATR indicator handle once per symbol and timeframe rather than calling iATR() on every request, which would waste terminal resources and could eventually exhaust the indicator handle limit.

//+------------------------------------------------------------------+
//| EnsureHandle                                                     |
//+------------------------------------------------------------------+
bool CVolatilityScaledModel::EnsureHandle(const string symbol,const ENUM_TIMEFRAMES timeframe,const int atr_period)
  {
   if(m_atr_handle != INVALID_HANDLE && m_atr_symbol == symbol && m_atr_timeframe == timeframe)
      return(true);

   if(m_atr_handle != INVALID_HANDLE)
     {
      ::IndicatorRelease(m_atr_handle);
      m_atr_handle = INVALID_HANDLE;
     }

   m_atr_handle = ::iATR(symbol,timeframe,atr_period);

   if(m_atr_handle == INVALID_HANDLE)
      return(false);

   m_atr_symbol    = symbol;
   m_atr_timeframe = timeframe;

   return(true);
  }

Calculate() reads the most recently completed ATR bar, converts it to points, multiplies by the configured multiplier to get the stop distance, then sizes the position the same way the fixed fractional model would.

//+------------------------------------------------------------------+
//| Calculate                                                        |
//+------------------------------------------------------------------+
CSizingResult CVolatilityScaledModel::Calculate(const string symbol,
      const ENUM_ORDER_TYPE order_type,
      const ENUM_TIMEFRAMES timeframe,
      const int atr_period,
      const double atr_multiplier,
      const double risk_pct)
  {
   CSizingResult result;
   result.model_used = RISK_MODEL_VOLATILITY_SCALED;

   if(risk_pct <= 0.0)
     {
      result.success = false;
      result.reason  = "risk_pct must be positive";
      return(result);
     }

   if(atr_multiplier <= 0.0)
     {
      result.success = false;
      result.reason  = "atr_multiplier must be positive";
      return(result);
     }

   if(!EnsureHandle(symbol,timeframe,atr_period))
     {
      result.success = false;
      result.reason  = "unable to create ATR indicator handle";
      return(result);
     }

   double atr_buffer[];
   ::ArraySetAsSeries(atr_buffer,true);

//--- read index 1, the most recently completed bar's ATR value, rather than index 0,
//--- which represents the still-forming current bar and would change on every tick
   if(::CopyBuffer(m_atr_handle,0,1,1,atr_buffer) <= 0)
     {
      result.success = false;
      result.reason  = "unable to read ATR buffer, indicator may still be calculating";
      return(result);
     }

   double atr_price_distance = atr_buffer[0];

   if(atr_price_distance <= 0.0)
     {
      result.success = false;
      result.reason  = "ATR returned a non-positive value";
      return(result);
     }

   double point_size = ::SymbolInfoDouble(symbol,SYMBOL_POINT);

   if(point_size <= 0.0)
     {
      result.success = false;
      result.reason  = "unable to read a valid point size for " + symbol;
      return(result);
     }

   double atr_points   = atr_price_distance / point_size;
   double stop_points  = atr_points * atr_multiplier;

   result.stop_points  = stop_points;

   double balance      = ::AccountInfoDouble(ACCOUNT_BALANCE);
   double risk_amount  = balance * (risk_pct / 100.0);

   double actual_risk_amount = 0.0;
   double lots = m_converter.LotsForRisk(symbol,order_type,risk_amount,stop_points,actual_risk_amount);

   if(lots <= 0.0)
     {
      result.success = false;
      result.reason  = "unable to compute a valid lot size for the requested risk";
      return(result);
     }

   result.success             = true;
   result.lots                = lots;
   result.risk_amount         = risk_amount;
   result.actual_risk_amount  = actual_risk_amount;
   result.scaling_factor      = 1.0;
   result.reason              = "volatility-scaled sizing, ATR=" + DoubleToString(atr_points,1) +
                                "pts x" + DoubleToString(atr_multiplier,2) +
                                " -> stop=" + DoubleToString(stop_points,1) + "pts";

   return(result);
  }

Buffer index 1 matters here, not 0. Index 0 is the current, still-forming bar. Its ATR value keeps shifting tick by tick, which would make the stop distance inconsistent between the moment sizing runs and the moment the order actually reaches the broker. Index 1 is the last fully closed bar. It stays fixed until the next bar closes.


CEquityCurveModel — Automatic Drawdown Scaling

A flat risk percentage sounds steady, but it doesn't behave that way during a losing streak. Risking 1% of a shrinking balance still compounds losses at the same rate that caused the drawdown in the first place. This model tracks a peak balance watermark and reduces the effective risk percentage as the balance drops further below that peak, flooring at a configurable minimum so the account keeps trading at a smaller size rather than stopping outright.

//+------------------------------------------------------------------+
//|                                       EquityCurveModel.mqh       |
//+------------------------------------------------------------------+

#ifndef EQUITY_CURVE_MODEL_MQH
#define EQUITY_CURVE_MODEL_MQH

#include "RiskTypes.mqh"
#include "LotConverter.mqh"

//+------------------------------------------------------------------+
//| CEquityCurveModel                                                |
//+------------------------------------------------------------------+
class CEquityCurveModel
  {
private:
   CLotConverter     m_converter;              // shared money-per-point and normalization helper
   double            m_peak_balance;           // highest balance observed since this instance was created
   double            m_drawdown_threshold_pct; // drawdown % at which scaling reaches min_scaling_factor
   double            m_min_scaling_factor;     // floor on the scaling factor, applied at or beyond the threshold

   double            ComputeScalingFactor(const double current_balance);

public:
                     CEquityCurveModel(void);
                    ~CEquityCurveModel(void);

   void              Configure(const double drawdown_threshold_pct,const double min_scaling_factor);
   void              UpdatePeakBalance(const double current_balance);
   double            GetPeakBalance(void) const { return(m_peak_balance); }

   CSizingResult     Calculate(const string symbol,const ENUM_ORDER_TYPE order_type,const double base_risk_pct,const double stop_points);
  };

//+------------------------------------------------------------------+
//| Constructor                                                      |
//+------------------------------------------------------------------+
CEquityCurveModel::CEquityCurveModel(void)
  {
   m_peak_balance            = 0.0;
   m_drawdown_threshold_pct  = 10.0;
   m_min_scaling_factor      = 0.25;
  }

//+------------------------------------------------------------------+
//| Destructor                                                       |
//+------------------------------------------------------------------+
CEquityCurveModel::~CEquityCurveModel(void)
  {
  }

//+------------------------------------------------------------------+
//| Configure                                                        |
//+------------------------------------------------------------------+
void CEquityCurveModel::Configure(const double drawdown_threshold_pct,const double min_scaling_factor)
  {
   m_drawdown_threshold_pct = (drawdown_threshold_pct > 0.0 ? drawdown_threshold_pct : 10.0);
   m_min_scaling_factor     = (min_scaling_factor >= 0.0 && min_scaling_factor <= 1.0 ? min_scaling_factor : 0.25);
  }

//+------------------------------------------------------------------+
//| UpdatePeakBalance                                                |
//+------------------------------------------------------------------+
void CEquityCurveModel::UpdatePeakBalance(const double current_balance)
  {
   if(current_balance > m_peak_balance)
      m_peak_balance = current_balance;
  }

ComputeScalingFactor() returns 1.0 at or above the peak, interpolates linearly down to the floor as drawdown approaches the configured threshold, and locks at the floor beyond it.

//+------------------------------------------------------------------+
//| ComputeScalingFactor                                             |
//+------------------------------------------------------------------+
double CEquityCurveModel::ComputeScalingFactor(const double current_balance)
  {
   if(m_peak_balance <= 0.0)
      return(1.0);

   double drawdown_pct = (m_peak_balance - current_balance) / m_peak_balance * 100.0;

   if(drawdown_pct <= 0.0)
      return(1.0);

   if(drawdown_pct >= m_drawdown_threshold_pct)
      return(m_min_scaling_factor);

   double progress       = drawdown_pct / m_drawdown_threshold_pct;
   double scaling_factor = 1.0 - progress * (1.0 - m_min_scaling_factor);

   return(scaling_factor);
  }

Calculate() updates the peak balance, computes the scaling factor, multiplies it into the base risk percentage, and sizes the position from there. Calling UpdatePeakBalance() inside Calculate() itself means the watermark is always current, so there's no ordering mistake for a caller to make.

//+------------------------------------------------------------------+
//| Calculate                                                        |
//+------------------------------------------------------------------+
CSizingResult CEquityCurveModel::Calculate(const string symbol,const ENUM_ORDER_TYPE order_type,const double base_risk_pct,const double stop_points)
  {
   CSizingResult result;
   result.model_used  = RISK_MODEL_EQUITY_CURVE;
   result.stop_points = stop_points;

   if(base_risk_pct <= 0.0)
     {
      result.success = false;
      result.reason  = "base_risk_pct must be positive";
      return(result);
     }

   if(stop_points <= 0.0)
     {
      result.success = false;
      result.reason  = "stop_points must be positive";
      return(result);
     }

   double current_balance = ::AccountInfoDouble(ACCOUNT_BALANCE);

   UpdatePeakBalance(current_balance);

   double scaling_factor     = ComputeScalingFactor(current_balance);
   double effective_risk_pct = base_risk_pct * scaling_factor;
   double risk_amount        = current_balance * (effective_risk_pct / 100.0);

   double actual_risk_amount = 0.0;
   double lots               = m_converter.LotsForRisk(symbol,order_type,risk_amount,stop_points,actual_risk_amount);

   if(lots <= 0.0)
     {
      result.success = false;
      result.reason  = "unable to compute a valid lot size for the requested risk";
      return(result);
     }

   result.success             = true;
   result.lots                = lots;
   result.risk_amount         = risk_amount;
   result.actual_risk_amount  = actual_risk_amount;
   result.scaling_factor      = scaling_factor;
   result.reason              = "equity-curve scaled sizing, factor=" + DoubleToString(scaling_factor,3) +
                                " (peak=" + DoubleToString(m_peak_balance,2) +
                                ", balance=" + DoubleToString(current_balance,2) + ")";

   return(result);
  }

Equity curve scaling chart

Equity curve scaling factor decreasing linearly from 1.0 at zero drawdown to a floor of 0.25 at the 10% threshold, then holding flat beyond it.


CPositionSizer — the Unified Interface

CPositionSizer is what the strategy code actually talks to. It owns one instance of each model, keeps each model's configuration separate, and dispatches CalculateLots() to whichever model SetModel() has selected. Switching models is a one-line change. The calling code never changes.

//+------------------------------------------------------------------+
//|                                              PositionSizer.mqh   |
//|                       The single point of contact for lot size   |
//|                       calculation across every risk model        |
//+------------------------------------------------------------------+

#ifndef POSITION_SIZER_MQH
#define POSITION_SIZER_MQH

#include "RiskTypes.mqh"
#include "FixedFractionalModel.mqh"
#include "FixedMonetaryModel.mqh"
#include "VolatilityScaledModel.mqh"
#include "EquityCurveModel.mqh"

//+------------------------------------------------------------------+
//| CPositionSizer                                                   |
//+------------------------------------------------------------------+
class CPositionSizer
  {
private:
   ENUM_RISK_MODEL          m_active_model;          // which model CalculateLots() currently dispatches to
   CFixedFractionalModel    m_fixed_fractional;      // sub-component: Model 1
   CFixedMonetaryModel      m_fixed_monetary;        // sub-component: Model 2
   CVolatilityScaledModel   m_volatility_scaled;     // sub-component: Model 3
   CEquityCurveModel        m_equity_curve;          // sub-component: Model 4

   double                   m_fixed_fractional_pct;  // configured risk % for Model 1
   double                   m_fixed_monetary_amount; // configured flat risk amount for Model 2
   ENUM_TIMEFRAMES          m_atr_timeframe;         // configured ATR timeframe for Model 3
   int                      m_atr_period;            // configured ATR period for Model 3
   double                   m_atr_multiplier;        // configured ATR multiplier for Model 3
   double                   m_volatility_risk_pct;   // configured risk % for Model 3
   double                   m_equity_curve_base_pct; // configured base risk % for Model 4

public:
                     CPositionSizer(void);
                    ~CPositionSizer(void);

   void              SetModel(const ENUM_RISK_MODEL model);
   ENUM_RISK_MODEL   GetModel(void) const { return(m_active_model); }

   void              ConfigureFixedFractional(const double risk_pct);
   void              ConfigureFixedMonetary(const double risk_amount);
   void              ConfigureVolatilityScaled(const ENUM_TIMEFRAMES timeframe,const int atr_period,const double atr_multiplier,const double risk_pct);
   void              ConfigureEquityCurve(const double base_risk_pct,const double drawdown_threshold_pct,const double min_scaling_factor);

   CSizingResult     CalculateLots(const string symbol,const ENUM_ORDER_TYPE order_type,const double stop_points);
   double            GetPeakBalance(void) const { return(m_equity_curve.GetPeakBalance()); }
  };

//+------------------------------------------------------------------+
//| Constructor                                                      |
//+------------------------------------------------------------------+
CPositionSizer::CPositionSizer(void)
  {
   m_active_model          = RISK_MODEL_FIXED_FRACTIONAL;
   m_fixed_fractional_pct  = 1.0;
   m_fixed_monetary_amount = 100.0;
   m_atr_timeframe         = PERIOD_H1;
   m_atr_period            = 14;
   m_atr_multiplier        = 2.0;
   m_volatility_risk_pct   = 1.0;
   m_equity_curve_base_pct = 1.0;
  }

//+------------------------------------------------------------------+
//| Destructor                                                       |
//+------------------------------------------------------------------+
CPositionSizer::~CPositionSizer(void)
  {
  }

//+------------------------------------------------------------------+
//| SetModel                                                         |
//| Switches which model CalculateLots() dispatches to. Each model's |
//| own configuration is retained independently, so switching back   |
//| and forth does not lose any settings.                            |
//+------------------------------------------------------------------+
void CPositionSizer::SetModel(const ENUM_RISK_MODEL model)
  {
   m_active_model = model;
  }

//+------------------------------------------------------------------+
//| ConfigureFixedFractional                                         |
//+------------------------------------------------------------------+
void CPositionSizer::ConfigureFixedFractional(const double risk_pct)
  {
   m_fixed_fractional_pct = risk_pct;
  }

//+------------------------------------------------------------------+
//| ConfigureFixedMonetary                                           |
//+------------------------------------------------------------------+
void CPositionSizer::ConfigureFixedMonetary(const double risk_amount)
  {
   m_fixed_monetary_amount = risk_amount;
  }

//+------------------------------------------------------------------+
//| ConfigureVolatilityScaled                                        |
//+------------------------------------------------------------------+
void CPositionSizer::ConfigureVolatilityScaled(const ENUM_TIMEFRAMES timeframe,const int atr_period,const double atr_multiplier,const double risk_pct)
  {
   m_atr_timeframe       = timeframe;
   m_atr_period          = atr_period;
   m_atr_multiplier      = atr_multiplier;
   m_volatility_risk_pct = risk_pct;
  }

//+------------------------------------------------------------------+
//| ConfigureEquityCurve                                             |
//+------------------------------------------------------------------+
void CPositionSizer::ConfigureEquityCurve(const double base_risk_pct,const double drawdown_threshold_pct,const double min_scaling_factor)
  {
   m_equity_curve_base_pct = base_risk_pct;
   m_equity_curve.Configure(drawdown_threshold_pct,min_scaling_factor);
  }

CalculateLots() is the single entry point strategy code calls. It switches on the active model and forwards to that model's own Calculate() method with its own configured parameters.

//+-------------------------------------------------------------------+
//| CalculateLots                                                     |
//+-------------------------------------------------------------------+
CSizingResult CPositionSizer::CalculateLots(const string symbol,const ENUM_ORDER_TYPE order_type,const double stop_points)
  {
   switch(m_active_model)
     {
      case RISK_MODEL_FIXED_FRACTIONAL:
         return(m_fixed_fractional.Calculate(symbol,order_type,m_fixed_fractional_pct,stop_points));

      case RISK_MODEL_FIXED_MONETARY:
         return(m_fixed_monetary.Calculate(symbol,order_type,m_fixed_monetary_amount,stop_points));

      case RISK_MODEL_VOLATILITY_SCALED:
         return(m_volatility_scaled.Calculate(symbol,order_type,m_atr_timeframe,m_atr_period,m_atr_multiplier,m_volatility_risk_pct));

      case RISK_MODEL_EQUITY_CURVE:
         return(m_equity_curve.Calculate(symbol,order_type,m_equity_curve_base_pct,stop_points));

      default:
        {
         CSizingResult fallback_result;
         fallback_result.success = false;
         fallback_result.reason  = "unrecognized risk model";
         return(fallback_result);
        }
     }
  }

Notice CalculateLots() takes a stop_points parameter, but the volatility-scaled model ignores it and derives its own stop internally from ATR. That is intentional. A caller comparing all four models side by side should expect the reported stop_points to differ from what it passed in for that one model only.


PositionSizerEA.mq5 — Comparing All Four Models

The demo EA does not place trades. It wires up CPositionSizer, exposes every model's settings as inputs, and logs the resulting CSizingResult once a day so you can compare all four models on the same account without risking capital.

//+------------------------------------------------------------------+
//|                                          PositionSizerEA.mq5     |
//|                        Thin demo EA showing CPositionSizer with  |
//|                        all four risk models, selectable by input |
//+------------------------------------------------------------------+

#property strict

#include <PositionSizingEngine/PositionSizer.mqh>

//--- Input parameters
input ENUM_RISK_MODEL  InpRiskModel            = RISK_MODEL_FIXED_FRACTIONAL; // active risk model
input double           InpFixedFractionalPct   = 1.0;                         // Model 1: risk % of balance per trade
input double           InpFixedMonetaryAmount  = 100.0;                       // Model 2: flat risk amount per trade
input ENUM_TIMEFRAMES  InpAtrTimeframe         = PERIOD_H1;                   // Model 3: ATR timeframe
input int              InpAtrPeriod            = 14;                          // Model 3: ATR period
input double           InpAtrMultiplier        = 2.0;                         // Model 3: stop = ATR x this multiplier
input double           InpVolatilityRiskPct    = 1.0;                         // Model 3: risk % of balance per trade
input double           InpEquityCurveBasePct   = 1.0;                         // Model 4: base risk % with no drawdown
input double           InpDrawdownThresholdPct = 10.0;                        // Model 4: drawdown % at which scaling floors
input double           InpMinScalingFactor     = 0.25;                        // Model 4: floor on the scaling factor
input int              InpFixedStopPoints      = 300;                         // stop distance in points, used by Models 1, 2, and 4
input int              InpTakeProfitPoints     = 600;                         // take profit distance in points from entry
input ulong            InpMagicNumber          = 990022;                      // magic number stamped on every request

//--- Module-level state
CPositionSizer g_sizer;
datetime       g_last_open_day = 0;

OnInit() configures every model up front, regardless of which one is active. That is what makes it possible to switch InpRiskModel and reattach without touching anything else.

//+------------------------------------------------------------------+
//| Expert initialization function                                   |
//+------------------------------------------------------------------+
int OnInit(void)
  {
   g_sizer.SetModel(InpRiskModel);
   g_sizer.ConfigureFixedFractional(InpFixedFractionalPct);
   g_sizer.ConfigureFixedMonetary(InpFixedMonetaryAmount);
   g_sizer.ConfigureVolatilityScaled(InpAtrTimeframe,InpAtrPeriod,InpAtrMultiplier,InpVolatilityRiskPct);
   g_sizer.ConfigureEquityCurve(InpEquityCurveBasePct,InpDrawdownThresholdPct,InpMinScalingFactor);

   ::PrintFormat("PositionSizerEA: initialized with risk model %s",::EnumToString(InpRiskModel));

   return(INIT_SUCCEEDED);
  }

OnTick() runs the demonstration once per calendar day rather than on every tick.

//+------------------------------------------------------------------+
//| Expert tick function                                             |
//+------------------------------------------------------------------+
void OnTick(void)
  {
   MqlDateTime now;
   ::TimeToStruct(::TimeCurrent(),now);

   datetime today_start = ::TimeCurrent() - (now.hour * 3600 + now.min * 60 + now.sec);

   if(g_last_open_day == today_start)
      return;

   g_last_open_day = today_start;

   DemonstrateSizing();
  }

DemonstrateSizing() calls the sizer and logs the result. Models 1, 2, and 4 use InpFixedStopPoints. Model 3 ignores it and derives its own stop internally.

//+------------------------------------------------------------------+
//| DemonstrateSizing                                                |
//+------------------------------------------------------------------+
void DemonstrateSizing(void)
  {
   string symbol = ::Symbol();

   CSizingResult result = g_sizer.CalculateLots(symbol,ORDER_TYPE_BUY,(double)InpFixedStopPoints);

   if(result.success)
     {
      ::PrintFormat("PositionSizerEA: model=%s lots=%.2f target_risk=%.2f actual_risk=%.2f stop_points=%.1f scaling=%.3f reason=%s",
                    ::EnumToString(result.model_used),result.lots,result.risk_amount,
                    result.actual_risk_amount,result.stop_points,result.scaling_factor,result.reason);
     }
   else
     {
      ::PrintFormat("PositionSizerEA: sizing failed for model=%s reason=%s",
                    ::EnumToString(result.model_used),result.reason);
     }
  }


Verification — TestPositionSizer.mq5

A sizing calculation depends on live account balance and live broker prices. None of these tests compare against a single hardcoded number. Instead, each test checks a relationship that has to hold no matter what account or symbol the script runs against.

//+------------------------------------------------------------------+
//|                                        TestPositionSizer.mq5     |
//|                        Verification script: synthetic unit tests |
//|                        for the lot converter and all four risk   |
//|                        models                                    |
//+------------------------------------------------------------------+
#property script_show_inputs

#include <PositionSizingEngine/PositionSizer.mqh>

//--- test bookkeeping
int g_tests_run    = 0;
int g_tests_passed = 0;

ASSERT() and ASSERT_DOUBLE_CLOSE() record pass and fail lines without halting the script, so every test runs regardless of earlier failures. The double comparison uses a relative tolerance instead of exact equality, since results depend on live prices.

//+------------------------------------------------------------------+
//| ASSERT                                                           |
//+------------------------------------------------------------------+
void ASSERT(const bool condition,const string test_name)
  {
   g_tests_run++;

   if(condition)
     {
      g_tests_passed++;
      ::PrintFormat("PASS: %s",test_name);
     }
   else
     {
      ::PrintFormat("FAIL: %s",test_name);
     }
  }

//+------------------------------------------------------------------+
//| ASSERT_DOUBLE_CLOSE                                              |
//+------------------------------------------------------------------+
void ASSERT_DOUBLE_CLOSE(const double actual,const double expected,const double tolerance_pct,const string test_name)
  {
   double tolerance = ::MathAbs(expected) * (tolerance_pct / 100.0);

   if(tolerance <= 0.0)
      tolerance = 0.0001;

   bool ok = (::MathAbs(actual - expected) <= tolerance);
   g_tests_run++;

   if(ok)
     {
      g_tests_passed++;
      ::PrintFormat("PASS: %s (expected ~%.4f, got %.4f)",test_name,expected,actual);
     }
   else
     {
      ::PrintFormat("FAIL: %s (expected ~%.4f, got %.4f)",test_name,expected,actual);
     }
  }

TestLotConverter() confirms the money-per-point measurement is positive, and that doubling the stop distance for the same risk roughly halves the resulting lot size.

//+------------------------------------------------------------------+
//| TestLotConverter                                                 |
//+------------------------------------------------------------------+
void TestLotConverter(void)
  {
   ::Print("--- CLotConverter tests ---");

   CLotConverter converter;
   string symbol = ::Symbol();

   double money_per_point = 0.0;
   bool measured = converter.GetMoneyPerPointPerLot(symbol,ORDER_TYPE_BUY,money_per_point);

   ASSERT(measured,"money-per-point measurement succeeds for the current symbol");
   ASSERT(money_per_point > 0.0,"measured money-per-point-per-lot is positive");

   ::PrintFormat("Using symbol %s money-per-point-per-lot: %.6f",symbol,money_per_point);

   double actual_risk_a = 0.0;
   double lots_a        = converter.LotsForRisk(symbol,ORDER_TYPE_BUY,100.0,300.0,actual_risk_a);

   double actual_risk_b = 0.0;
   double lots_b        = converter.LotsForRisk(symbol,ORDER_TYPE_BUY,100.0,600.0,actual_risk_b);

   ASSERT(lots_a > 0.0 && lots_b > 0.0,"both conversions produce a positive lot size");

//--- doubling the stop distance for the same risk should roughly halve the lot size,
//--- within a loose tolerance since normalization to the volume step introduces rounding
   double ratio = lots_a / lots_b;
   ASSERT(ratio > 1.5 && ratio < 2.5,"doubling stop distance roughly halves lot size for equal risk");
  }

TestFixedFractionalModel() and TestFixedMonetaryModel() confirm each model derives its risk amount correctly and rejects bad input outright.

//+------------------------------------------------------------------+
//| TestFixedFractionalModel                                         |
//+------------------------------------------------------------------+
void TestFixedFractionalModel(void)
  {
   ::Print("--- CFixedFractionalModel tests ---");

   CFixedFractionalModel model;
   string symbol  = ::Symbol();

   double balance = ::AccountInfoDouble(ACCOUNT_BALANCE);

   CSizingResult result = model.Calculate(symbol,ORDER_TYPE_BUY,1.0,300.0);

   ASSERT(result.success,"fixed fractional calculation succeeds");
   ASSERT(result.model_used == RISK_MODEL_FIXED_FRACTIONAL,"result reports the correct model");

   double expected_risk_amount = balance * 0.01;
   ASSERT_DOUBLE_CLOSE(result.risk_amount,expected_risk_amount,0.01,"risk_amount equals 1% of current balance");

//--- actual risk at the normalized lot size should stay within one volume step's
//--- worth of the target - a loose 30% tolerance comfortably covers this on any
//--- symbol without asserting on the exact step size directly
   ASSERT_DOUBLE_CLOSE(result.actual_risk_amount,result.risk_amount,30.0,
                       "actual risk at normalized lot size stays close to target risk");

//--- an invalid risk percentage must be rejected rather than silently producing a lot size
   CSizingResult invalid_result = model.Calculate(symbol,ORDER_TYPE_BUY,0.0,300.0);
   ASSERT(invalid_result.success == false,"zero risk_pct is rejected rather than sized");
  }

//+------------------------------------------------------------------+
//| TestFixedMonetaryModel                                           |
//+------------------------------------------------------------------+
void TestFixedMonetaryModel(void)
  {
   ::Print("--- CFixedMonetaryModel tests ---");

   CFixedMonetaryModel model;
   string symbol = ::Symbol();

   CSizingResult result = model.Calculate(symbol,ORDER_TYPE_BUY,100.0,300.0);

   ASSERT(result.success,"fixed monetary calculation succeeds");
   ASSERT(result.model_used == RISK_MODEL_FIXED_MONETARY,"result reports the correct model");
   ASSERT_DOUBLE_CLOSE(result.risk_amount,100.0,0.01,"risk_amount equals the supplied flat amount");

//--- a negative risk amount must be rejected
   CSizingResult invalid_result = model.Calculate(symbol,ORDER_TYPE_BUY,-50.0,300.0);
   ASSERT(invalid_result.success == false,"negative risk_amount is rejected rather than sized");
  }

TestVolatilityScaledModel() confirms the ATR-derived stop is positive, and that doubling the multiplier roughly doubles the stop. It skips gracefully if ATR isn't ready yet on a freshly opened chart.

//+------------------------------------------------------------------+
//| TestVolatilityScaledModel                                        |
//+------------------------------------------------------------------+
void TestVolatilityScaledModel(void)
  {
   ::Print("--- CVolatilityScaledModel tests ---");

   CVolatilityScaledModel model_a;
   CVolatilityScaledModel model_b;
   string symbol = ::Symbol();

   CSizingResult result_a = model_a.Calculate(symbol,ORDER_TYPE_BUY,PERIOD_H1,14,2.0,1.0);
   CSizingResult result_b = model_b.Calculate(symbol,ORDER_TYPE_BUY,PERIOD_H1,14,4.0,1.0);

//--- these can legitimately fail on a freshly opened chart where the ATR indicator
//--- has not finished calculating yet; report clearly rather than asserting blindly
   if(!result_a.success || !result_b.success)
     {
      ::PrintFormat("SKIP: volatility-scaled tests skipped, ATR not yet available (reason: %s)",
                    (!result_a.success ? result_a.reason : result_b.reason));
      return;
     }

   ASSERT(result_a.stop_points > 0.0,"ATR-derived stop distance is positive");

//--- doubling the ATR multiplier should roughly double the stop distance
   double stop_ratio = result_b.stop_points / result_a.stop_points;
   ASSERT(stop_ratio > 1.8 && stop_ratio < 2.2,"doubling ATR multiplier roughly doubles stop distance");

//--- the wider stop (model_b) should produce a smaller or equal lot size than the
//--- tighter stop (model_a) for the same risk percentage
   ASSERT(result_b.lots <= result_a.lots,"wider ATR-derived stop produces a smaller or equal lot size");
  }

TestEquityCurveModel() confirms the scaling factor sits at 1.0 at the peak balance and that the watermark tracks correctly.

//+------------------------------------------------------------------+
//| TestEquityCurveModel                                             |
//+------------------------------------------------------------------+
void TestEquityCurveModel(void)
  {
   ::Print("--- CEquityCurveModel tests ---");

   CEquityCurveModel model;
   model.Configure(10.0,0.25);   // 10% drawdown threshold, floor at 25% scaling
   string symbol = ::Symbol();

//--- establish a peak balance explicitly rather than relying on the live account,
//--- so the drawdown percentages below are deterministic and test-controlled
   model.UpdatePeakBalance(10000.0);

   CSizingResult result_no_dd = model.Calculate(symbol,ORDER_TYPE_BUY,1.0,300.0);
   ASSERT(result_no_dd.success,"equity curve calculation succeeds with no drawdown");
   ASSERT_DOUBLE_CLOSE(result_no_dd.scaling_factor,1.0,0.1,"scaling factor is 1.0 at the peak balance");

//--- simulate a deep drawdown by updating the peak higher, then computing against
//--- a lower live balance is not directly controllable since Calculate() reads the
//--- real account balance internally - this test instead confirms the floor logic
//--- using GetPeakBalance() and documents that live-balance-dependent behavior is
//--- exercised qualitatively via the demo EA rather than asserted on exactly here
   ASSERT(model.GetPeakBalance() >= 10000.0,"peak balance watermark reflects the configured peak");
  }

TestCrossModelNormalizationBoundaries() confirms the shared clamping logic works correctly at both extremes.

//+------------------------------------------------------------------+
//| TestCrossModelNormalizationBoundaries                            |
//+------------------------------------------------------------------+
void TestCrossModelNormalizationBoundaries(void)
  {
   ::Print("--- Cross-model normalization boundary tests ---");

   CLotConverter converter;
   string symbol = ::Symbol();

   double step = 0.0, vol_min = 0.0, vol_max = 0.0;
   converter.GetConstraints(symbol,step,vol_min,vol_max);

   ::PrintFormat("Using symbol %s volume constraints: step=%.5f min=%.5f max=%.5f",symbol,step,vol_min,vol_max);

//--- an absurdly small risk amount against a huge stop distance should clamp to vol_min
   double actual_risk_tiny = 0.0;
   double tiny_lots = converter.LotsForRisk(symbol,ORDER_TYPE_BUY,0.01,100000.0,actual_risk_tiny);
   ASSERT_DOUBLE_CLOSE(tiny_lots,vol_min,1.0,"tiny risk request clamps to SYMBOL_VOLUME_MIN");

//--- an absurdly large risk amount against a tiny stop distance should clamp to vol_max
   double actual_risk_huge = 0.0;
   double huge_lots = converter.LotsForRisk(symbol,ORDER_TYPE_BUY,1000000.0,1.0,actual_risk_huge);
   ASSERT_DOUBLE_CLOSE(huge_lots,vol_max,1.0,"huge risk request clamps to SYMBOL_VOLUME_MAX");
  }


Extending the Engine

Two extensions come up often enough to mention, though neither is built into this version.

A margin-aware ceiling: None of the four models check whether the computed lot size fits the account's free margin. A natural addition is a check inside CalculateLots() that calls OrderCalcMargin() and caps the lot size downward if the required margin exceeds what's available.

A hard risk cap independent of the active model: Nothing currently stops a caller from requesting a risk_pct of 50.0 and getting exactly that. A sensible production safeguard is a cap enforced once inside CPositionSizer, after dispatch, rather than duplicated across all four models.


Limitations

No margin check is performed. A computed lot size can be perfectly valid with respect to the symbol's volume constraints while still exceeding what the account's free margin can actually support. This engine reports a lot size based purely on risk and volume constraints; a pre-trade margin check belongs in the execution layer that ultimately calls OrderSend(), or in the extension described above.

When the EA restarts, the equity-curve model resets its peak-balance watermark to the current balance instead of the true historical peak. CEquityCurveModel::m_peak_balance lives in memory for the lifetime of the class instance. A production deployment that needs the watermark to survive restarts should persist it — to a file, or to global variables via GlobalVariableSet() — and restore it in OnInit().

OrderCalcProfit() reflects current conditions, not the conditions at the moment a trade eventually fills. The money-per-point measurement in CLotConverter is accurate for the current bid or ask at the moment it runs. Because this changes tick to tick, a lot size computed a few seconds before an order is actually submitted can be very slightly off from what it would compute if measured again immediately before submission — a small effect in practice, but not literally zero.

The volatility-scaled model reads a single ATR value on a single timeframe. It does not account for volatility disagreeing meaningfully across timeframes, and it does not smooth or average multiple ATR readings. A strategy trading across widely different volatility regimes on the same symbol may want a more sophisticated volatility estimate.

None of the four models account for correlated risk across multiple open positions. Each model sizes a single trade in isolation. An EA running several strategies simultaneously, or trading several correlated symbols, can still end up risking far more than any individual model's percentage suggests if several positions move against the account at once. This engine intentionally does not attempt portfolio-level risk aggregation — that is a materially different problem from sizing one trade.


Conclusion

We separate two distinct responsibilities: policy (how much money to risk) and conversion (how to turn that risk and a stop distance into a broker-accepted lot). CPositionSizer lets four different answers to the first question share one correct answer to the second, while CLotConverter measures the real money-per-point value using OrderCalcProfit() instead of assuming a fixed per-pip figure. This makes the sizing engine work consistently across forex, JPY pairs, metals, indices, and different account currencies, without hardcoded instrument-specific assumptions.

What it doesn't cover is just as important. The current implementation provides no built-in margin awareness, no portfolio-level risk aggregation across open positions, and a peak-balance watermark that requires persistence if continuity across terminal restarts is needed. OrderCalcProfit() also reflects current market conditions, so small differences may appear if sizing is calculated several ticks before order submission. Each of these limitations maps cleanly into the existing architecture: adding margin-aware sizing, global risk limits, persistent equity tracking, or portfolio-level controls can be done without changing the four risk models or the underlying lot-conversion logic.


Programs used in the article:

# Name Type Description
1 RiskTypes.mqh Include File ENUM_RISK_MODEL and CSizingResult struct shared across the engine.
2 LotConverter.mqh Include File CLotConverter class, converts risk and stop distance into a normalized lot size using OrderCalcProfit().
3 FixedFractionalModel.mqh Include File CFixedFractionalModel class, risks a fixed percentage of balance.
4 FixedMonetaryModel.mqh Include File CFixedMonetaryModel class, risks a fixed flat amount independent of balance.
5 VolatilityScaledModel.mqh Include File CVolatilityScaledModel class, derives stop distance from ATR.
6 EquityCurveModel.mqh Include File CEquityCurveModel class, scales risk down automatically during drawdown.
7 PositionSizer.mqh Include File CPositionSizer class, the unified dispatch interface across all four models.
8 PositionSizerEA.mq5 Demo EA Demo EA that logs a CSizingResult once a day for whichever model is selected.
9 TestPositionSizer.mq5 Script Verification script with synthetic tests for the converter and all four models.
10 Position_Sizer.zip Zip Archive  Zip archive containing all the attached files and their paths relative to the terminal's root folder. 

Machine Learning Without the Black Box: The Tsetlin Machine for Trading Machine Learning Without the Black Box: The Tsetlin Machine for Trading
This article builds a white-box classifier in MQL5 using the Tsetlin Machine. It learns human-readable AND-rules instead of weights, trains with integer state updates, and requires no external dependencies. You will assemble the automaton, clause, and multi-class voter, verify on XOR and other boolean tasks, booleanize indicators, label by forward ATR-scaled return, save the model to CSV, and view active rules on a live chart.
From Novice to Expert: Weekend Gap Size Effect Research Using MQL5 and Python From Novice to Expert: Weekend Gap Size Effect Research Using MQL5 and Python
The article provides a practical research setup for weekend gap analysis: MQL5 extracts precise pip‑based gaps and tracks fills, while Python performs statistical testing and visualization. You will compute fill rates by gap buckets, model fill probability with logistic regression, and assess time-to-fill via Kaplan–Meier curves. All steps are configurable and reproducible for EURUSD, GBPUSD, USDJPY and beyond.
How to Detect and Normalize Chart Objects in MQL5 (Part 5): Fibonacci in Focus How to Detect and Normalize Chart Objects in MQL5 (Part 5): Fibonacci in Focus
The article bridges automated placement with manual analysis for the Fibonacci family in MQL5. It scans charts, identifies user Fibonacci objects, and normalizes their level arrays, interaction flags, and visuals per object type while preserving coordinates. With manual-priority enforcement, Expert Advisors can evaluate both human and code-generated tools reliably, without duplicates or runtime indexing issues.
Building a Future Swing Projection Indicator in MQL5 Building a Future Swing Projection Indicator in MQL5
We implement a Future Swing Projection indicator in MQL5 that analyzes historical swing structure and estimates the next move from recent price behavior. It locates six alternating swing points, measures five completed legs, and uses their average distance to project a target five bars ahead. The indicator draws swing legs, a projection line, ATR‑based support and resistance zones, and a label with the projected price to keep the process rule‑based and reproducible.