Page 2 of 2

Re: Unit Testing Position-Size Functions Before They Touch a Live Account

Posted: Wed Oct 07, 2026 5:28 pm
by PTScalper
Here is the professional-grade implementation:

Code: Select all

//@version=5
strategy("Enterprise Position Sizing Unit", overlay=true, margin_long=100, margin_short=100, initial_capital=10000)

// =============================================================================
// 1. CONFIGURATION & UI PANELS
// =============================================================================
var G_RISK  = "Risk Core"
i_riskMode  = input.string("Equity %", "Risk Model", options=["Equity %", "Balance %", "Fixed Cash"], group=G_RISK)
i_riskValue = input.float(1.0, "Risk Value", step=0.1, group=G_RISK, tooltip="1.0 means 1% or $1 depending on model.")

var G_INSTR = "Instrument Specifications"
i_minQty    = input.float(0.01, "Min Lot Size", group=G_INSTR)
i_maxQty    = input.float(100.0, "Max Lot Size", group=G_INSTR)
i_qtyStep   = input.float(0.01, "Lot Step", group=G_INSTR)
i_leverage  = input.float(30.0, "Max Leverage", group=G_INSTR, tooltip="Used to simulate margin availability checks.")

var G_UI    = "Telemetry"
i_showHUD   = input.bool(true, "Show Risk Dashboard", group=G_UI)

// =============================================================================
// 2. ARCHITECTURE: USER-DEFINED TYPES & METHODS
// =============================================================================
// @type Encapsulates all risk settings into a single configuration object
type RiskManager
    string  mode
    float   value
    float   minLot
    float   maxLot
    float   step
    float   leverage

// @type Holds the result of a sizing calculation, including telemetry and error states
type SizeResult
    float   qty
    float   riskCash
    float   marginReq
    string  status
    bool    isValid

// @function Core calculation method attached to the RiskManager type
method calculate(RiskManager this, float entryPrice, float slPrice) =>
    float cashRisk = 0.0
    
    // 1. Resolve Risk Model
    switch this.mode
        "Equity %"   => cashRisk := strategy.equity * (this.value / 100.0)
        "Balance %"  => cashRisk := strategy.closedtrades.profit > 0 ? strategy.initial_capital + strategy.netprofit : strategy.initial_capital // Approx balance
        "Fixed Cash" => cashRisk := this.value

    // 2. Division-by-Zero & Validity Check
    float slDist = math.abs(entryPrice - slPrice)
    if slDist == 0
        SizeResult.new(0.0, cashRisk, 0.0, "ERR: SL = Entry", false)
    else
        // 3. Base Calculation
        float rawQty = cashRisk / (slDist * syminfo.pointvalue)
        
        // 4. Floor Rounding to Lot Step
        float roundedQty = math.floor(rawQty / this.step) * this.step
        
        // 5. Margin Constraint Check (Approximate Notional / Leverage)
        // Note: Assumes base currency matches account currency for simplicity in this template
        float notionalValue = roundedQty * entryPrice
        float requiredMargin = notionalValue / this.leverage
        bool marginExceeded = requiredMargin > strategy.equity
        
        // 6. Hard Limit Validations
        if marginExceeded
            SizeResult.new(0.0, cashRisk, requiredMargin, "ERR: Margin Exceeded", false)
        else if roundedQty < this.minLot
            SizeResult.new(0.0, cashRisk, requiredMargin, "ERR: Below Min Lot", false)
        else if roundedQty > this.maxLot
            SizeResult.new(this.maxLot, cashRisk, requiredMargin, "WARN: Capped at Max", true)
        else
            SizeResult.new(roundedQty, cashRisk, requiredMargin, "OK", true)

// =============================================================================
// 3. INITIALIZATION
// =============================================================================
// Instantiate the Risk Manager object once (var)
var RiskManager rm = RiskManager.new(i_riskMode, i_riskValue, i_minQty, i_maxQty, i_qtyStep, i_leverage)

// =============================================================================
// 4. MOCK EXECUTION ENGINE
// =============================================================================
// Simple Mean Reversion entry for testing purposes
atr = ta.atr(14)
longTrigger  = ta.crossover(close, ta.sma(close, 20))
shortTrigger = ta.crossunder(close, ta.sma(close, 20))

// Store the latest result for the UI Dashboard
var SizeResult lastCalc = SizeResult.new(0,0,0,"Waiting...", false)

if longTrigger
    float sl = close - (atr * 2)
    lastCalc := rm.calculate(close, sl) // Object-oriented method call
    
    if lastCalc.isValid
        strategy.entry("L", strategy.long, qty=lastCalc.qty)
        strategy.exit("XL", "L", stop=sl)

if shortTrigger
    float sl = close + (atr * 2)
    lastCalc := rm.calculate(close, sl)
    
    if lastCalc.isValid
        strategy.entry("S", strategy.short, qty=lastCalc.qty)
        strategy.exit("XS", "S", stop=sl)

// =============================================================================
// 5. TELEMETRY & DEBUG HUD (The "Pro" touch)
// =============================================================================
var table hud = table.new(position.bottom_right, 2, 5, border_width=1, border_color=color.gray, frame_width=1, frame_color=color.gray)

if barstate.islast and i_showHUD
    // Header
    table.cell(hud, 0, 0, "Risk Analytics", text_color=color.white, bgcolor=color.new(color.blue, 30), text_size=size.small, width=15)
    table.cell(hud, 1, 0, "Value", text_color=color.white, bgcolor=color.new(color.blue, 30), text_size=size.small, width=15)
    
    // Status
    color statusColor = lastCalc.isValid ? color.new(color.green, 70) : color.new(color.red, 70)
    table.cell(hud, 0, 1, "Last Status", text_color=color.gray, bgcolor=color.black, text_size=size.small)
    table.cell(hud, 1, 1, lastCalc.status, text_color=color.white, bgcolor=statusColor, text_size=size.small)
    
    // Target Risk Cash
    table.cell(hud, 0, 2, "Target Risk ($)", text_color=color.gray, bgcolor=color.black, text_size=size.small)
    table.cell(hud, 1, 2, str.tostring(lastCalc.riskCash, "#.##"), text_color=color.white, bgcolor=color.black, text_size=size.small)
    
    // Calculated Qty
    table.cell(hud, 0, 3, "Calculated Size", text_color=color.gray, bgcolor=color.black, text_size=size.small)
    table.cell(hud, 1, 3, str.tostring(lastCalc.qty, "#.##"), text_color=color.white, bgcolor=color.black, text_size=size.small)
    
    // Est. Margin
    table.cell(hud, 0, 4, "Est. Margin Req.", text_color=color.gray, bgcolor=color.black, text_size=size.small)
    table.cell(hud, 1, 4, str.tostring(lastCalc.marginReq, "#.##"), text_color=color.white, bgcolor=color.black, text_size=size.small)

Re: Unit Testing Position-Size Functions Before They Touch a Live Account

Posted: Wed Oct 07, 2026 5:29 pm
by PTScalper
What makes this code architecture superior?

Encapsulation (RiskManager UDT): Instead of passing 6 different input variables into a standard function every time you want to calculate a trade, we instantiate a RiskManager object on the first bar. When an entry condition is met, we simply call rm.calculate(entryPrice, slPrice).

Structured Returns (SizeResult UDT): Standard functions return a single float. This method returns an object containing the size, the required margin, the monetary risk target, and a string-based error message. This allows the execution engine to log why a trade was rejected.

The Margin Check: Before a trade is approved (isValid = true), it checks if the notional value of the trade divided by your broker's leverage exceeds your current equity. This prevents the backtester (or live execution) from attempting to place trades that the broker will instantly reject for insufficient funds.

The Analytics HUD: A real-time data table is drawn in the bottom right corner of the chart. It actively updates with the telemetry of the last attempted calculation. If a trade fails to fire, you don't have to guess if it was a logic error or a sizing error—the HUD will flash red and say ERR: Below Min Lot or ERR: Margin Exceeded.

Re: Unit Testing Position-Size Functions Before They Touch a Live Account

Posted: Wed Oct 07, 2026 5:29 pm
by PTScalper
Here is the professional-grade implementation. I have built it to be cross-compatible; it will compile flawlessly on both MT4 and MT5 by using preprocessor directives (#ifdef __MQL5__) for the specific API differences, like margin calculation.

1. The Risk Manager Class (RiskManager.mqh)

Save this as an include file. It encapsulates the risk models, handles the Strategy Tester division-by-zero bug natively, strictly floors lot steps, and normalizes floating-point precision for the broker API.

Code: Select all

//+------------------------------------------------------------------+
//|                                                  RiskManager.mqh |
//|                                     Enterprise Position Sizing   |
//+------------------------------------------------------------------+
#property strict

// --- 1. Enumerations ---
enum ENUM_RISK_MODEL {
    RISK_MODEL_EQUITY_PCT,  // % of Current Equity
    RISK_MODEL_BALANCE_PCT, // % of Account Balance
    RISK_MODEL_FIXED_CASH   // Fixed Account Currency Amount
};

// --- 2. Data Structures ---
// Mirrors a typical C# Data Transfer Object (DTO) for structured returns
struct SizeResult {
    double volume;      // The sanitized, ready-to-execute lot size
    double riskCash;    // The monetary risk calculated
    double marginReq;   // The estimated margin required
    string status;      // Error or success message
    bool   isValid;     // True if safe to execute, False to block execution
};

// --- 3. Core Class ---
class CRiskManager {
private:
    ENUM_RISK_MODEL m_mode;
    double          m_value;
    
    // MQL-specific utility to calculate floating point precision for volumes
    int GetVolumeDigits(double volumeStep) {
        return (int)MathMax(0, -MathLog10(volumeStep));
    }

    // Cross-platform margin calculator
    double CalculateMargin(string symbol, ENUM_ORDER_TYPE type, double volume, double price) {
        #ifdef __MQL5__
            double margin = 0.0;
            OrderCalcMargin(type, symbol, volume, price, margin);
            return margin;
        #else
            // MT4 approximation: MODE_MARGINREQUIRED returns margin for 1.0 standard lot
            return volume * MarketInfo(symbol, MODE_MARGINREQUIRED);
        #endif
    }

public:
    // Constructor
    CRiskManager(ENUM_RISK_MODEL mode, double value) {
        m_mode = mode;
        m_value = value;
    }

    // Main calculation method
    SizeResult Calculate(string symbol, ENUM_ORDER_TYPE orderType, double entryPrice, double slPrice) {
        SizeResult result;
        result.isValid = false;
        result.volume = 0.0;
        
        // 1. Calculate Target Monetary Risk
        double accountEquity  = AccountInfoDouble(ACCOUNT_EQUITY);
        double accountBalance = AccountInfoDouble(ACCOUNT_BALANCE);
        
        switch(m_mode) {
            case RISK_MODEL_EQUITY_PCT:  result.riskCash = accountEquity * (m_value / 100.0); break;
            case RISK_MODEL_BALANCE_PCT: result.riskCash = accountBalance * (m_value / 100.0); break;
            case RISK_MODEL_FIXED_CASH:  result.riskCash = m_value; break;
        }

        // 2. Division-by-Zero Protection (Distance)
        double slDist = MathAbs(entryPrice - slPrice);
        if(slDist == 0) {
            result.status = "ERR: Entry and SL are identical";
            return result;
        }

        // 3. Broker Specifications & Tester Bug Protection
        double tickSize  = SymbolInfoDouble(symbol, SYMBOL_TRADE_TICK_SIZE);
        double tickValue = SymbolInfoDouble(symbol, SYMBOL_TRADE_TICK_VALUE);
        
        // CRITICAL MQL FIX: Tester returns 0 tick value on first tick for cross pairs
        if(tickValue == 0 || tickSize == 0) {
            result.status = "ERR: Tick Value/Size is 0 (Market closed or Tester bug)";
            return result;
        }

        // 4. Raw Volume Calculation
        double ticksAtRisk = slDist / tickSize;
        double rawVolume   = result.riskCash / (ticksAtRisk * tickValue);

        // 5. Apply Broker Volume Restrictions & Floor Rounding
        double volMin  = SymbolInfoDouble(symbol, SYMBOL_VOLUME_MIN);
        double volMax  = SymbolInfoDouble(symbol, SYMBOL_VOLUME_MAX);
        double volStep = SymbolInfoDouble(symbol, SYMBOL_VOLUME_STEP);

        if (volStep <= 0) volStep = 0.01; // Fallback safety

        // C#-style Math.Floor equivalent to guarantee we never round UP and exceed risk
        double flooredVolume = MathFloor(rawVolume / volStep) * volStep;
        
        // NormalizeDouble is strictly required in MQL before sending to OrderSend()
        // Otherwise 0.01 becomes 0.010000000000002 and the trade server rejects it
        int volDigits = GetVolumeDigits(volStep);
        double finalVolume = NormalizeDouble(flooredVolume, volDigits);

        // 6. Margin Constraint Verification
        result.marginReq = CalculateMargin(symbol, orderType, finalVolume, entryPrice);
        double freeMargin = AccountInfoDouble(ACCOUNT_MARGIN_FREE);

        // 7. Final Sanity Checks
        if(result.marginReq > freeMargin) {
            result.status = "ERR: Insufficient Free Margin";
        }
        else if(finalVolume < volMin) {
            result.status = "ERR: Below Broker Min Volume";
        }
        else if(finalVolume > volMax) {
            result.volume = volMax;
            result.isValid = true;
            result.status = "WARN: Volume Capped at Max Lot";
        }
        else {
            result.volume = finalVolume;
            result.isValid = true;
            result.status = "OK";
        }

        return result;
    }
};

Re: Unit Testing Position-Size Functions Before They Touch a Live Account

Posted: Wed Oct 07, 2026 5:30 pm
by PTScalper
2. Implementation in the EA (ExpertAdvisor.mq5 / .mq4)

Here is how elegantly this reads in your main EA file. Because we are using an object-oriented approach, your OnTick() logic is no longer cluttered with math and broker symbol requests.

Code: Select all

#include "RiskManager.mqh"

// --- Inputs ---
input ENUM_RISK_MODEL InpRiskModel = RISK_MODEL_EQUITY_PCT; // Risk Model
input double          InpRiskValue = 0.5;                   // Risk Value (e.g. 0.5%)

// --- Globals ---
CRiskManager *RiskMgr;

//+------------------------------------------------------------------+
//| Expert initialization function                                   |
//+------------------------------------------------------------------+
int OnInit() {
    // Instantiate the Risk Manager on EA load
    RiskMgr = new CRiskManager(InpRiskModel, InpRiskValue);
    return(INIT_SUCCEEDED);
}

//+------------------------------------------------------------------+
//| Expert deinitialization function                                 |
//+------------------------------------------------------------------+
void OnDeinit(const int reason) {
    // C++ style memory cleanup
    delete RiskMgr;
}

//+------------------------------------------------------------------+
//| Expert tick function                                             |
//+------------------------------------------------------------------+
void OnTick() {
    // ... [Your Price Action Entry Logic Here] ...
    
    // Example: We have a long signal
    bool longSignal = true; // Placeholder
    
    if(longSignal && PositionsTotal() == 0) {
        
        double askPrice = SymbolInfoDouble(_Symbol, SYMBOL_ASK);
        
        // Example: 15-pip SL
        double point = SymbolInfoDouble(_Symbol, SYMBOL_POINT);
        double slPrice = askPrice - (150 * point); 
        
        // 1. Request position size calculation
        SizeResult calc = RiskMgr.Calculate(_Symbol, ORDER_TYPE_BUY, askPrice, slPrice);
        
        // 2. Validate and Execute
        if(calc.isValid) {
            Print("Sizing OK. Volume: ", calc.volume, " | Risking: $", calc.riskCash);
            
            // Execute trade here using standard OrderSend (MT4) or CTrade (MT5)
            // e.g., trade.Buy(calc.volume, _Symbol, askPrice, slPrice, tpPrice);
        } 
        else {
            // Log exactly why the trade was rejected
            Print("Trade Rejected by Risk Manager: ", calc.status);
        }
    }
}

Re: Unit Testing Position-Size Functions Before They Touch a Live Account

Posted: Wed Oct 07, 2026 5:30 pm
by PTScalper
The "Pro" MQL Traps Avoided in this Code:

The NormalizeDouble Trap: MQL is infamous for ERR_INVALID_VOLUME rejections. If the broker step is 0.01 and your math calculates 0.12, memory floating-point artifacts often represent this as 0.11999999999999. The broker rejects it. GetVolumeDigits() combined with NormalizeDouble forces it into a strict binary representation the broker API accepts.

The MarketInfo() vs OrderCalcMargin() gap: MT4 and MT5 calculate margin differently. The CalculateMargin wrapper uses standard C preprocessor directives so the exact same logic tree is used regardless of which MetaEditor you compile it in.

Pointers & Memory: In MT4/MT5, if you declare an object globally via new, it lives in the heap. The OnDeinit cleanup (delete RiskMgr) ensures you don't create memory leaks when switching timeframes or changing inputs, which is critical for long-running server instances.

Re: Unit Testing Position-Size Functions Before They Touch a Live Account

Posted: Wed Oct 07, 2026 5:31 pm
by PTScalper
Since your background is in enterprise C# and .NET development, writing for cTrader (cAlgo) will feel like coming home.

cTrader uses standard C# (.NET 6 in modern versions) and its API is beautifully engineered compared to MQL. It natively eliminates the two biggest headaches we faced in the MQL version: floating-point lot-step rounding and cross-platform margin calculations.

Here is the professional-grade C# implementation. Notice how much cleaner the logic becomes when we can leverage C# record types, switch expressions, and cTrader's native RoundingMode.Down.

Re: Unit Testing Position-Size Functions Before They Touch a Live Account

Posted: Wed Oct 07, 2026 5:31 pm
by PTScalper
The Core Risk Manager & DTO

Code: Select all

using System;
using cAlgo.API;
using cAlgo.API.Internals;

namespace cAlgo.Robots
{
    public enum RiskModel
    {
        EquityPercent,
        BalancePercent,
        FixedCash
    }

    // Modern C# record for an immutable Data Transfer Object (DTO)
    public record SizeResult(
        double VolumeInUnits,
        double RiskCash,
        double MarginReq,
        string Status,
        bool IsValid
    );

    public class RiskManager
    {
        private readonly RiskModel _mode;
        private readonly double _value;
        private readonly IAccount _account;

        // Inject the IAccount interface so the class can read live equity/balance
        public RiskManager(RiskModel mode, double value, IAccount account)
        {
            _mode = mode;
            _value = value;
            _account = account;
        }

        public SizeResult Calculate(Symbol symbol, TradeType tradeType, double entryPrice, double slPrice)
        {
            // 1. Calculate Target Monetary Risk using C# 8+ switch expressions
            double riskCash = _mode switch
            {
                RiskModel.EquityPercent  => _account.Equity * (_value / 100.0),
                RiskModel.BalancePercent => _account.Balance * (_value / 100.0),
                RiskModel.FixedCash      => _value,
                _ => throw new ArgumentOutOfRangeException()
            };

            // 2. SL Distance (cTrader handles this best in Pips, not raw price distance)
            double slDistPips = Math.Abs(entryPrice - slPrice) / symbol.PipSize;

            if (slDistPips == 0)
                return new SizeResult(0, riskCash, 0, "ERR: Entry and SL are identical", false);

            if (symbol.PipValue == 0)
                return new SizeResult(0, riskCash, 0, "ERR: PipValue is 0 (Unmapped cross or zero liquidity)", false);

            // 3. Raw Volume Calculation
            // cTrader's PipValue is the monetary value of 1 pip for exactly 1 Unit of volume.
            // This natively calculates the size in Units (not Lots).
            double rawUnits = riskCash / (slDistPips * symbol.PipValue);

            // 4. Broker Volume Restrictions & Native Rounding
            // cTrader API handles the lot-step flooring internally—no Math.Floor hacks needed.
            double flooredUnits = symbol.NormalizeVolumeInUnits(rawUnits, RoundingMode.Down);

            // 5. Margin Constraint Verification
            // Natively calculates required margin in account currency for the specific trade type
            double marginReq = symbol.GetEstimatedMargin(tradeType, flooredUnits);

            // 6. Final Sanity Checks
            if (marginReq > _account.FreeMargin)
            {
                return new SizeResult(0, riskCash, marginReq, "ERR: Insufficient Free Margin", false);
            }
            if (flooredUnits < symbol.VolumeInUnitsMin)
            {
                return new SizeResult(0, riskCash, marginReq, "ERR: Below Broker Min Volume", false);
            }
            if (flooredUnits > symbol.VolumeInUnitsMax)
            {
                // Floor the max volume to be safe and recalculate margin
                double maxUnits = symbol.NormalizeVolumeInUnits(symbol.VolumeInUnitsMax, RoundingMode.Down);
                marginReq = symbol.GetEstimatedMargin(tradeType, maxUnits);
                
                return new SizeResult(maxUnits, riskCash, marginReq, "WARN: Volume Capped at Max Lot", true);
            }

            return new SizeResult(flooredUnits, riskCash, marginReq, "OK", true);
        }
    }
}

Re: Unit Testing Position-Size Functions Before They Touch a Live Account

Posted: Wed Oct 07, 2026 5:32 pm
by PTScalper
Implementation in the cBot

Here is how you inject and consume the RiskManager inside a standard cTrader Robot.

Code: Select all

using cAlgo.API;

namespace cAlgo.Robots
{
    [Robot(TimeZone = TimeZones.UTC, AccessRights = AccessRights.None)]
    public class EnterpriseRiskBot : Robot
    {
        [Parameter("Risk Model", Group = "Risk Management", DefaultValue = RiskModel.EquityPercent)]
        public RiskModel Mode { get; set; }

        [Parameter("Risk Value", Group = "Risk Management", DefaultValue = 1.0)]
        public double RiskValue { get; set; }

        private RiskManager _riskManager;

        protected override void OnStart()
        {
            // Initialize the Risk Manager, passing in the cTrader Account interface
            _riskManager = new RiskManager(Mode, RiskValue, Account);
        }

        protected override void OnTick()
        {
            // Example trigger: Price crosses above a 20-period SMA
            bool longSignal = true; // Placeholder for actual logic

            if (longSignal && Positions.Count == 0)
            {
                double entryPrice = Symbol.Ask;
                
                // Example: 15-pip SL
                double slPrice = entryPrice - (15 * Symbol.PipSize); 

                // 1. Request calculation
                SizeResult calc = _riskManager.Calculate(Symbol, TradeType.Buy, entryPrice, slPrice);

                // 2. Validate and Execute
                if (calc.IsValid)
                {
                    Print($"Sizing OK. Units: {calc.VolumeInUnits} | Margin: {calc.MarginReq:C} | Risk: {calc.RiskCash:C}");
                    
                    // Execute using cTrader's native Units representation
                    ExecuteMarketOrder(TradeType.Buy, SymbolName, calc.VolumeInUnits, "MainStrategy", 15, null);
                }
                else
                {
                    Print($"Trade Rejected by Risk Manager: {calc.Status}");
                }
            }
        }
    }
}

Re: Unit Testing Position-Size Functions Before They Touch a Live Account

Posted: Wed Oct 07, 2026 5:32 pm
by PTScalper
The "Pro" cTrader Traps Avoided Here:

Units vs. Lots: In MQL, execution functions take "1.0" for a standard lot. In cTrader, ExecuteMarketOrder expects Units (e.g., 100,000). Developers migrating from MQL frequently calculate 1.5 lots and try to pass 1.5 to the execution function, causing an instant InvalidVolume exception. The math in this class natively resolves directly to Units because Symbol.PipValue in cTrader evaluates to the value of 1 Unit, not 1 Lot.

RoundingMode.Down: MQL requires a complex equation to normalize steps: MathFloor(raw / step) * step. cTrader abstracts this beautifully via symbol.NormalizeVolumeInUnits(). Forcing RoundingMode.Down guarantees that a risk calculation resulting in 105,432 units rounds safely to 105,000 rather than rounding up to 106,000 and blowing past your hard 1.0% risk limit.

Margin Abstraction: Instead of querying the base currency, determining leverage, and writing custom C# math to calculate cross-margin, we just call symbol.GetEstimatedMargin(tradeType, flooredUnits). The cTrader API natively references the backend server to pull the exact margin required before you attempt execution.