Building Your Personal Expert Advisor (Part 7): Risk Management VI (Final)—Consolidating the Risk Framework
Contents
- Introduction
- What Is Inside the File
- Three Scopes of Risk
- Section 1: Building the Framework
- Section 2: The Series EA on the Framework
- Section 3: A Second EA on the Same Framework
- Installing the Framework
- What This Part Does Not Do
- Conclusion
Introduction
Over the course of this series, risk checks and safety logic have accumulated until the moving average crossover EA has become a thousand-line monolith in which the actual trading idea is difficult to find and even harder to reuse. The problem is practical: understanding the signal requires navigating dozens of unrelated functions, reusing the same risk controls in another Expert Advisor means copying and adapting large blocks of code, and validation and configuration are scattered across initialization logic rather than being managed by the subsystems they affect.
The five risk-management parts of the series introduced position sizing, free-margin checks, order-type selection and distance validation, basket loss caps, and account-level screening before new trades. These mechanisms were progressively built into one EA file, FixedMACrossover_Part6.mq5, apart from the account-level class, which has had a header of its own since it was introduced. That EA file now exceeds a thousand lines, while only about one hundred lines implement the moving average crossover strategy itself.
The goal of this article is not to introduce new risk controls or to change the crossover strategy. It is to move the risk code out of the strategy file, so that the strategy can be read and a second EA can include that code instead of copying it. We will arrange the risk-management machinery in four header files: one for shared types and conversions, and one for each of three levels of risk management: trade, basket, and account. Three of the files are new, and the account-level file already exists from the previous part. The trade and basket levels each carry their own configuration structure, validation, and defaults. The account level keeps the setter interface it was published with: no configuration structure, no validation, and every limit switched off until the EA sets it. An EA can include only the levels it uses.
By the end of this article, we will have:
- A risk-management framework of four header files under MQL5\Include\Risk Management\: one file of shared types and conversions, and three classes, one for each scope of risk.
- A crossover EA about a quarter of its previous length, with the same signal and the same set of inputs. Its risk checks are carried over, with the differences listed under Scope of This Article.
- A second Expert Advisor with a different strategy that includes two of the four files: the basket class and the shared types and conversions, neither of them modified for it.
What Is Inside the File
This part does not set out to fix a defect in FixedMACrossover_Part6.mq5. It deals with how hard the file has become to read. Above the first input line are four enumerations. Below it are nine input groups, two structures, and thirty-two functions. Twenty-eight of those functions are risk machinery. The other four are the startup, tick, and shutdown event handlers and a spread check. Someone opening the file to read the crossover has to know in advance which twenty-eight to skip.

A second strategy was already represented in the code. The mean-reversion demo published with Part 5 carried basket code of its own, in its own structure. It aggregated its positions and cut the group at an aggregate loss much as the series EA did, and it could not include the series EA's version, because that version was written inside the EA file.
The framework that results is about 1,900 lines in four files, which is more than either EA that includes it. Consolidation did not reduce the amount of code. The two EA files and the account-level header of the previous parts held about 2,400 lines between them, and the six files of this part hold about 2,600. The difference is in where the lines are. The crossover file falls from about 1,370 lines to about 350, the risk code sits in the headers, and two of the four headers are already included by a second EA. The cost is that there are now six files to follow, and a change to a header reaches every EA that includes it.
Three Scopes of Risk
Where the boundaries fall decides whether a refactor helps or just rearranges the same thing. These boundaries came from the articles.
| Scope | The question it answers | Class | Built in |
|---|---|---|---|
| The trade | Can I afford this position, and how big should it be? | CTradeRisk | Risk Management I to III |
| The basket | Can this EA carry it alongside its own other positions? | CBasketRisk | Risk Management IV |
| The account | Can the account carry it alongside everything else? | CPortfolioRisk | Risk Management V |
The decomposition was not invented for the refactor. It was already present in the explanation; the refactor simply made it explicit. Module boundaries often follow how the system was described, long before anyone reads the code.
That also settles most of what belongs in the file at the bottom of the stack. A type or a conversion that more than one file needs goes there, and the steps that belong to one tier stay inside that tier's class. The exception is the configuration of the first two tiers. Their two configuration structures sit in the shared file too, each belonging to one tier, along with the enumerations their fields use. The account tier includes nothing and keeps its own types in its own file.

The second EA in that picture includes the basket tier and the shared file, and leaves the trade tier and the account tier alone. Section 3 covers what it reuses and what it does not.
Scope of This Article
Three header files are added, the account-level file from the previous part moves in beside them, and two EAs are rebuilt on the result. The two EAs are affected differently. The series EA keeps its crossover, its inputs, and its sizing arithmetic, and its risk checks are carried over. What changes is where the code lives, which class owns each setting, and the points below, found by reading the two files side by side:
- The order of the checks. In the previous EA, the basket gate and the account gate ran before the pending-distance check and the margin cap, so they judged the volume before the margin cap reduced it. The trade tier now finishes first, and the two gates judge the volume that will be sent. The outcome can differ only when the margin cap reduces a volume. The usual effect is that a trade one of the gates would have refused at its uncapped size is accepted at the reduced size.
- One condition in the volume check. A symbol whose reported maximum volume is below its minimum volume is treated as an invalid specification, and no volume is produced for it.
- Log lines, the order comment, and Initialization. Several log lines are worded differently, and a trade that more than one check would refuse is reported by whichever check now comes first. Orders are sent with the comment SoloEA Buy or SoloEA Sell, where the previous EA wrote MA Cross Buy or MA Cross Sell. Initialization checks the strategy inputs before it creates the indicator handles, where the previous EA created the handles first.
The second EA does not keep its previous behavior. It is rebuilt on the basket tier, and Section 3 lists what its earlier version did that this one does not.
The article is in three sections. The first builds the framework. The second shows the series EA including it, configuring it, and calling it. The third shows a second EA reusing part of it.
Section 1: Building the Framework
The Shared Vocabulary in RiskTypes.mqh
The bottom file holds what more than one file needs, together with the configuration of the first two tiers. It has two enumerations for the risk model and two for order execution, the two configuration structures, the two structures that pass between the tiers and the EA, and a few conversions that would otherwise be written in more than one file. The trade plan is the structure that travels furthest, and its nine fields are the ones Risk Management III gave it.
//+------------------------------------------------------------------+ //| A fully-specified trade, before anything reaches the broker | //+------------------------------------------------------------------+ struct STradePlan { int direction; // 1 = Buy, -1 = Sell ENUM_ORDER_TYPE orderType; // Concrete order type being sent bool isPending; // True for limit/stop entries double entryPrice; // Intended entry, not necessarily current price double stopLoss; double takeProfit; double stopDistance; // Derived from entryPrice and stopLoss double lotSize; double riskAmount; // Intended monetary risk (0 in fixed-lot mode) };
It sits between deciding what a trade should be and deciding whether it may exist. The trade tier fills in all nine fields and reads them back when it validates and sends the plan. The basket gate reads three of them: the order type, the entry price, and the volume. The account gate does not take the structure at all, and the EA hands it the symbol with values taken from those three fields. Neither gate knows how the plan was filled. Nothing in the structure marks which fields a given reader needs, so this list is a contract kept by the code as it stands, and an EA that fills a plan by hand has to fill at least those three.
One Conversion, Two Meanings
Extraction turned up something that had been true since Part 5 of the series. Both EAs in this series compute a volume, and the two calculations had looked unrelated.
| EA | Money amount | Price distance |
|---|---|---|
| Series Crossover EA | risk budget | the stop distance |
| Mean reversion demo | profit target | the expected move to the mean |
They are the same division. A money amount divided by what a price distance is worth per lot gives a volume. That division is what the two calculations share. The meaning of the result differs. For a risk budget, it is the volume whose loss over the stop distance is close to the budget, and for a profit target, it is the volume whose gain over the expected move is close to the target. In both cases the figure is a price move valued at the symbol's current tick value. Both distances are measured from the entry price, so the spread is already inside them: a buy enters at Ask, and its stop and target are tested on Bid. Commission, swap, and slippage are not inside them, so the money lost at a stop or made at a target can differ from the amount that was asked for.

The conversion moved into the shared file (RiskTypes.mqh) in two steps. First, what one lot is worth over a distance:
//+------------------------------------------------------------------+ //| What one lot gains or loses over a given price distance | //+------------------------------------------------------------------+ double ValuePerLot(const string symbol, const double priceDistance) { if(priceDistance <= 0) return 0.0; double tickSize = SymbolInfoDouble(symbol, SYMBOL_TRADE_TICK_SIZE); double tickValue = SymbolInfoDouble(symbol, SYMBOL_TRADE_TICK_VALUE); if(tickSize <= 0 || tickValue <= 0) return 0.0; return (priceDistance / tickSize) * tickValue; }
The function reads the tick size and tick value the terminal reports for the symbol and does not work from the contract size. That does not make the figure exact on every symbol. SYMBOL_TRADE_TICK_VALUE is documented as the value of SYMBOL_TRADE_TICK_VALUE_PROFIT, the calculated tick price for a profitable position. The terminal reports a separate SYMBOL_TRADE_TICK_VALUE_LOSS for a losing position, and this function does not read it, so where the two differ, a stop distance is valued with the profit-side figure.
The value is also the one in force when the function is called, and on a symbol whose tick value moves with price, it can be different at the far end of the distance. The function also treats every tick over the distance as worth the same amount, which holds only for a symbol whose profit is proportional to the price move.
Then the conversion itself, wrapping that value in the broker's volume rules:
//+------------------------------------------------------------------+ //| Turn a money amount and a price distance into a tradable volume | //+------------------------------------------------------------------+ double VolumeForMoneyAndDistance(const string symbol, const double moneyAmount, const double priceDistance, const bool allowMinimumOverrun) { double minLot = SymbolInfoDouble(symbol, SYMBOL_VOLUME_MIN); double maxLot = SymbolInfoDouble(symbol, SYMBOL_VOLUME_MAX); double lotStep = SymbolInfoDouble(symbol, SYMBOL_VOLUME_STEP); if(minLot <= 0 || lotStep <= 0 || maxLot < minLot) { Print("Invalid broker volume specification. Cannot size a position."); return 0.0; } if(priceDistance <= 0 || moneyAmount <= 0) return 0.0; double valuePerLot = ValuePerLot(symbol, priceDistance); if(valuePerLot <= 0) { Print("Cannot value the price distance. Cannot size a position."); return 0.0; } double rawLot = moneyAmount / valuePerLot; double lotSize = FloorToVolumeStep(rawLot, lotStep); if(lotSize > maxLot) lotSize = FloorToVolumeStep(maxLot, lotStep); if(lotSize < minLot) { if(!allowMinimumOverrun) { PrintFormat("Correct volume (%.4f) is below the minimum lot (%.2f). Trade skipped to avoid overshooting the amount.", rawLot, minLot); return 0.0; } PrintFormat("Caution: correct volume (%.4f) is below the minimum lot (%.2f). Trading %.2f - the actual amount will EXCEED %.2f.", rawLot, minLot, minLot, moneyAmount); lotSize = minLot; } return lotSize; }
The parameters are named moneyAmount and priceDistance. Inside the crossover EA, the same two values were called riskAmount and stopDistance. A function whose parameters are named for a stop reads wrongly in an EA that sizes from a target, so the shared version names them for what they are: an amount of money and a price distance.
The first test refuses a symbol whose volume specification cannot be used: a minimum or a step that is not above zero, or a maximum below the minimum. The volume is then rounded down to the volume step, and cut to the maximum when it is above it. Rounding down means the money at stake comes out at or below the amount asked for: by less than one step's worth in the usual case, and by more when the maximum cuts the volume. For a risk budget, that leaves the trade at or under its budget. For a profit target, it leaves the trade at or short of its target at the target price. The last parameter covers the opposite case. When the calculated volume falls below the broker's minimum lot, trading the minimum overshoots whatever amount was asked for. The caller decides whether that is acceptable, and a line is printed either way.
Configuration Is a Structure With Results
Each of the first two tiers takes one configuration structure, filled once and handed over.
//+------------------------------------------------------------------+ //| Trade-level configuration, filled once by the EA | //+------------------------------------------------------------------+ struct SRiskConfig { //--- Identity long magic; //--- Risk model ENUM_RISK_MODE riskMode; ENUM_RISK_BASE riskBase; double riskPercent; double fixedCashRisk; double fixedLotSize; bool allowMinLotOverRisk; //--- Drawdown scaling bool enableDrawdownScaling; double drawdownTrigger; // % from peak equity double drawdownFactor; // Multiplier once triggered bool applyDrawdownToFixedLot; //--- Stops and targets double stopATRMultiplier; double tpRatio; //--- Order execution ENUM_ENTRY_MODE entryMode; ENUM_OFFSET_MODE offsetMode; double pendingOffsetATR; int pendingOffsetPoints; int pendingExpiryBars; //--- Broker and margin safety double maxMarginUsagePercent; bool enableAdaptiveMarginCap; double marginLevelSafe; double marginLevelDanger; //--- Position rules bool allowHedging; void SetDefaults(void); };
The alternative was a setter call per setting, which is how the account tier is configured. A structure does not shorten the EA's initialization: the EA still assigns the fields one line at a time, and this structure has twenty-three. The structure was chosen for what comes with it. A tier's settings are listed in one definition, apart from the symbol, which is passed to Init(). The defaults are set by one call. The tier checks the settings once, together, when the finished structure is handed to Init(), so a check that involves two fields sees both. The cost is that a setting changed afterward has no effect until Init() is called again.
One convention covers the limits that can be switched off: zero switches them off. In the basket structure, those are the loss percentage, the margin percentage, and the hold-bars limit, each marked "0 disables" in the listing below. In the trade structure, it is the pending-order expiry, and the account tier's six limits follow the same convention. It does not reach every number. The position ceiling must be at least 1. The trade tier refuses a zero for the risk setting of the selected mode, the stop multiplier, the target ratio, and the margin cap, and for the pending offset when a pending entry is selected. Features have switches of their own as well, such as the basket's enabled field and the two enable fields in the trade structure.
The convention is one reason both configuration structures carry defaults. The MQL5 documentation says that a variable that is not initialized explicitly can hold any value, so a field the EA never set could be zero, and a zero limit is a switched-off check.
//+------------------------------------------------------------------+ //| Basket-level configuration | //+------------------------------------------------------------------+ struct SBasketConfig { long magic; bool enabled; int maxPositions; double maxLossPercent; // 0 disables ENUM_RISK_BASE lossBase; double maxMarginPercent; // 0 disables bool trackExcursion; int maxHoldBars; // 0 disables void SetDefaults(void); }; //+------------------------------------------------------------------+ //| Basket defaults | //+------------------------------------------------------------------+ void SBasketConfig::SetDefaults(void) { magic = 0; enabled = true; maxPositions = 1; maxLossPercent = 3.0; lossBase = RISK_BASE_BALANCE; maxMarginPercent = 25.0; trackExcursion = true; maxHoldBars = 0; }
An EA calls SetDefaults() first and then overrides the settings it cares about. Anything it did not set keeps the default, which is the value the series EA's input ships with: one position, the loss cut at 3%, the margin ceiling at 25%, and the time stop off. That protection exists only if the call is made. A structure that is declared and filled without it can hold any value in the fields the EA skipped, and Validate() catches some of those values and not others. The magic number has no safe default. Zero is what a trade request carries when no magic number was set. A tier left on zero treats every such position on its symbol as its own, and the basket tier closes them with the rest when its loss limit is reached.
Tier One: The Trade
What Risk Management I through III built now sits behind one class, apart from the shared conversions and types shown above.
//+------------------------------------------------------------------+ //| CTradeRisk | //+------------------------------------------------------------------+ class CTradeRisk { private: SRiskConfig m_cfg; string m_symbol; CTrade m_trade; double m_peakEquity; bool m_ready; //--- Sizing internals double DrawdownFactor(void); double RiskAmount(void); double CalculateLotSize(const double stopDistance, const double riskAmount); //--- Broker constraint internals double MarginUsageCap(void); double CapLotToMargin(const ENUM_ORDER_TYPE orderType, const double lotSize, const double price); //--- Order construction internals ENUM_ORDER_TYPE ResolveOrderType(const int direction) const; double PendingOffset(const double atrValue) const; double ResolveEntryPrice(const ENUM_ORDER_TYPE orderType, const double atrValue, const MqlTick &tick) const; double MinStopDistance(const MqlTick &tick) const; bool IsPendingDistanceValid(const STradePlan &plan, const MqlTick &tick) const; public: CTradeRisk(void); //--- Setup bool Init(const SRiskConfig &config, const string symbol = NULL); bool Validate(string &error) const; bool IsReady(void) const { return m_ready; } //--- Per-bar housekeeping void ManagePendingOrders(void); //--- Exposure counting, shared with the higher tiers int CountPositions(void) const; int CountPositionsByDirection(const int direction) const; int CountPendingOrders(void) const; int CountPendingOrdersByDirection(const int direction) const; //--- The pipeline bool BuildPlan(const int direction, const double atrValue, const MqlTick &tick, STradePlan &plan); bool ValidatePlan(STradePlan &plan, const MqlTick &tick); bool ExecutePlan(const STradePlan &plan); //--- Diagnostics double CurrentRiskAmount(void) { return RiskAmount(); } CTrade *Trade(void) { return GetPointer(m_trade); } };
Ten of the class's functions are private. What an EA can call is setup and readiness, per-bar housekeeping, four counters, the three-stage pipeline, and two diagnostics. Drawdown factors, margin caps, and pending offsets became implementation details, and an EA cannot call them in the wrong order because it cannot call them at all.
The startup takes the configuration, checks it, and leaves the class marked as not ready if the settings are wrong.
//+------------------------------------------------------------------+ //| Adopt a configuration | //+------------------------------------------------------------------+ bool CTradeRisk::Init(const SRiskConfig &config, const string symbol = NULL) { m_cfg = config; m_symbol = (symbol == NULL || symbol == "") ? _Symbol : symbol; m_trade.SetExpertMagicNumber(m_cfg.magic); string error = ""; if(!Validate(error)) { Print("CTradeRisk: ", error); m_ready = false; return false; } m_ready = true; return true; }
The plan builder tests that ready flag before it fills in the plan, so a class that failed its own validation builds no plan.
Inside the class, sizing is where the tier's own logic meets the shared arithmetic.
//+------------------------------------------------------------------+ //| Convert a risk amount and stop distance into a tradable volume | //+------------------------------------------------------------------+ double CTradeRisk::CalculateLotSize(const double stopDistance, const double riskAmount) { double minLot = SymbolInfoDouble(m_symbol, SYMBOL_VOLUME_MIN); double maxLot = SymbolInfoDouble(m_symbol, SYMBOL_VOLUME_MAX); double lotStep = SymbolInfoDouble(m_symbol, SYMBOL_VOLUME_STEP); if(minLot <= 0 || lotStep <= 0 || maxLot < minLot) { Print("Invalid broker volume specification. Cannot calculate lot size."); return 0.0; } //--- Fixed-lot mode never consults the risk budget if(m_cfg.riskMode == RISK_FIXED_LOT) { double rawLot = m_cfg.fixedLotSize; if(m_cfg.enableDrawdownScaling && m_cfg.applyDrawdownToFixedLot) { double factor = DrawdownFactor(); if(factor < 1.0) { PrintFormat("Fixed lot %.2f scaled by drawdown factor %.2f.", rawLot, factor); rawLot *= factor; } } double fixedLot = FloorToVolumeStep(rawLot, lotStep); if(fixedLot > maxLot) fixedLot = FloorToVolumeStep(maxLot, lotStep); //--- Below the minimum lot, the same switch decides as in the other modes if(fixedLot < minLot) { if(!m_cfg.allowMinLotOverRisk) { PrintFormat("Fixed lot (%.4f) is below the minimum lot (%.2f). Trade skipped.", rawLot, minLot); return 0.0; } PrintFormat("Caution: fixed lot (%.4f) is below the minimum lot (%.2f). Trading %.2f.", rawLot, minLot, minLot); fixedLot = minLot; } return fixedLot; } //--- Every other mode is the same conversion if(stopDistance <= 0) { Print("Stop distance is zero. Cannot calculate lot size."); return 0.0; } return VolumeForMoneyAndDistance(m_symbol, riskAmount, stopDistance, m_cfg.allowMinLotOverRisk); }
A fixed lot still passes through the broker's volume rules. It can fall below the minimum lot, for example after drawdown scaling has reduced it, and the same switch that decides for a risk-based volume decides here. With allowMinLotOverRisk on, the minimum is traded and the log says so, which means the trade is larger than the reduced lot. With it off, the trade is skipped.
Tier Two: The Basket
The basket class is the series EA's Risk Management IV code, moved into a class. Its measurements and its limits are computed as they were. The pending-order count is now passed in by the caller, where the EA's function counted the orders itself. The class also works on the symbol and magic number it was initialized with, where the EA's functions read the chart symbol and the input. It treats as one unit every position on that symbol that carries that magic number.
That is every position the EA holds there, and any other position opened with the same number. When the aggregate loss reaches its limit, or the group is losing once its oldest position has been open for the set number of bars, it sends a close for each position in the group and a delete for each pending order with that number on the symbol. It refuses a trade that would take the group past its position ceiling or its margin ceiling. With excursion tracking on, it records the worst and the best floating result it saw at its once-per-bar checks.
The parameter checks moved as well. The startup used to carry a long run of range checks, almost a hundred lines of code, for the risk settings of several subsystems in one place. The trade class and the basket class now each check their own, and no check the previous EA made was dropped. A few fields still have no range, among them the drawdown trigger and factor and the hold-bars limit. The account tier validates none of its settings.//+------------------------------------------------------------------+ //| Configuration sanity | //+------------------------------------------------------------------+ bool CBasketRisk::Validate(string &error) const { error = ""; if(!m_cfg.enabled) return true; if(m_cfg.maxPositions < 1) { error = "max basket positions must be at least 1."; return false; } if(m_cfg.maxLossPercent < 0 || m_cfg.maxLossPercent > 100) { error = "max basket loss percent must be between 0 and 100."; return false; } if(m_cfg.maxMarginPercent < 0 || m_cfg.maxMarginPercent > 100) { error = "max basket margin percent must be between 0 and 100."; return false; } return true; }
A disabled basket returns success at once, since settings its checks will not read should not stop an EA from starting. The position ceiling is the one basket limit that zero does not switch off: a basket has to be allowed at least one position, and a basket that should limit nothing is switched off with enabled.
Validate() hands the reason back as a string, and Init() prints it with the class name in front. Init() then returns false and does nothing more. The basket class has no ready flag, so an EA that ignored the return value would go on running with the refused settings. Refusing to start is what the EA in Section 2 does with that answer. The trade tier goes one step further with its three risk settings. It validates the one the selected risk mode uses, so a nonsensical risk percentage cannot block an EA running in fixed-lot mode.
The Account Tier Kept Its Old Interface
The account tier is where the framework is deliberately not uniform. CPortfolioRisk arrived in Part 6 as a standalone shared library, with a scope setter, a magic-number register, and four limit setters. It has no configuration structure and no initialization call, and moving it into the framework folder changed none of that.
Anyone who took that library and wired it into their own EA has those setters sitting in their initialization function. Replacing them with a structure would stop that code from compiling against the new file, for the sake of a tidier article. A structure could have been added beside the setters, and this part does not add one. The file moved, and an EA that follows it changes one include line and nothing else. If a later part gives that class a configuration structure, the setters stay alongside it. The difference has a cost in use. The two configured tiers start from the series EA's defaults and report settings that fail their checks. The account tier does neither.
Its constructor starts every limit at zero, which is off, so a setter the EA never calls leaves that limit off without a line in the log. The setters store what they are given, and zero or a negative number switches a limit off. An EA that uses all three tiers therefore configures them in two ways: a structure that is checked, and setters that are not.
Include Guards and How the Files Find Each Other
Two of the four files include a third. The trade and basket files both include RiskTypes.mqh, so an EA that includes both reaches the shared file twice. Every framework file begins with a guard and ends with the matching #endif, so its body is read once however many files include it.
#ifndef RISKMANAGEMENT_BASKETRISK_MQH #define RISKMANAGEMENT_BASKETRISK_MQH #include <Trade/Trade.mqh> #include "RiskTypes.mqh"
The first time the compiler reads the file, the symbol is undefined, so it defines it and reads the body. Every later time the symbol already exists and the body is skipped. The second include uses quotes rather than angle brackets. Angle brackets search the terminal's include directory, which is how an EA reaches the framework from outside it. A quoted name is not searched for there. The framework files sit in one folder and name each other by file name alone, the way the standard library's Trade.mqh names the files beside it. The folder's name is therefore needed in the EA's include lines and in no include line inside the framework.
Section 2: The Series EA on the Framework
Including the Framework
The EA names the tiers it wants. The shared types file comes in through the first two, which both include it.
#include <Risk Management/TradeRisk.mqh> #include <Risk Management/BasketRisk.mqh> #include <Risk Management/PortfolioRisk.mqh>
Then one instance of each is declared with the EA's globals.
//--- The three tiers of risk: the trade, the basket, the account
CTradeRisk risk;
CBasketRisk basket;
CPortfolioRisk portfolio; Every input a user could reach before is still here, with the same name, type, and default. Two of them moved into the signal group and one group was renamed. Inputs stay in the EA because they are what the terminal's parameter dialog shows.
What a Startup Still Checks for Itself
//--- Strategy inputs are checked here if(FastMA <= 0 || SlowMA <= 0) { Print("Moving Average periods must be greater than zero."); return INIT_PARAMETERS_INCORRECT; } if(FastMA >= SlowMA) { Print("FastMA should be smaller than SlowMA."); return INIT_PARAMETERS_INCORRECT; } if(ATRPeriod <= 0) { Print("ATR period must be greater than zero."); return INIT_PARAMETERS_INCORRECT; }
Three checks, all about moving averages and volatility. A fast period at or above the slow period is a strategy error, and no risk class could know it was wrong.
Configuring the Three Tiers
The trade and basket tiers each get their defaults, then their overrides, then an initialization call. The first is the longest because the trade tier carries the most settings.//--- Tier one: the trade. SRiskConfig riskCfg; riskCfg.SetDefaults(); riskCfg.magic = MagicNumber; riskCfg.riskMode = RiskMode; riskCfg.riskBase = RiskBase; riskCfg.riskPercent = RiskPercent; riskCfg.fixedCashRisk = FixedCashRisk; riskCfg.fixedLotSize = FixedLotSize; riskCfg.allowMinLotOverRisk = AllowMinLotOverRisk; riskCfg.enableDrawdownScaling = EnableAdaptiveRisk; riskCfg.drawdownTrigger = DDLevel; riskCfg.drawdownFactor = RiskReductionFactor; riskCfg.applyDrawdownToFixedLot = ApplyDrawdownToFixedLot; riskCfg.stopATRMultiplier = ATRMultiplier; riskCfg.tpRatio = TPRatio; riskCfg.entryMode = EntryMode; riskCfg.offsetMode = PendingOffsetMode; riskCfg.pendingOffsetATR = PendingOffsetATR; riskCfg.pendingOffsetPoints = PendingOffsetPoints; riskCfg.pendingExpiryBars = PendingExpiryBars; riskCfg.maxMarginUsagePercent = MaxMarginUsagePercent; riskCfg.enableAdaptiveMarginCap = EnableAdaptiveMarginCap; riskCfg.marginLevelSafe = MarginLevelSafe; riskCfg.marginLevelDanger = MarginLevelDanger; riskCfg.allowHedging = AllowHedging; if(!risk.Init(riskCfg)) return INIT_PARAMETERS_INCORRECT;It is one list of assignments with no logic mixed into it, and the last two lines are where the class decides whether those settings make sense. The list assigns every field, so SetDefaults() changes nothing here today. It is kept for a field added later, which would otherwise reach Init() unset. The basket tier follows the same shape and ends with a note about two settings that are each legal and contradict each other.
//--- Tier two: the basket SBasketConfig basketCfg; basketCfg.SetDefaults(); basketCfg.magic = MagicNumber; basketCfg.enabled = EnableBasketRisk; basketCfg.maxPositions = MaxBasketPositions; basketCfg.maxLossPercent = MaxBasketLossPercent; basketCfg.lossBase = BasketLossBase; basketCfg.maxMarginPercent = MaxBasketMarginPercent; basketCfg.trackExcursion = EnableMAETracking; basketCfg.maxHoldBars = MaxBasketHoldBars; if(!basket.Init(basketCfg)) return INIT_PARAMETERS_INCORRECT; //--- Not an error, but the two settings contradict each other if(EnableBasketRisk && AllowHedging && MaxBasketPositions < 2) Print("Note: AllowHedging is on but MaxBasketPositions is 1, so an opposing position can never be opened.");
Hedging permission with a ceiling of one position means the opposing trade is allowed by one rule and refused by another. Neither setting is invalid on its own, so the EA starts and says what will happen.
The account tier keeps the calls it always had.
//--- Tier three: the account if(EnablePortfolioRisk) { portfolio.SetScope(PortfolioScope); portfolio.RegisterMagic(MagicNumber); RegisterExtraMagics(); portfolio.SetPositionLimits(MaxPortfolioPositions, MaxPositionsPerSymbol, MaxPortfolioSymbols); portfolio.SetMarginLimit(MaxPortfolioMarginPercent); portfolio.SetLossLimit(MaxPortfolioLossPercent); portfolio.SetCurrencyLimit(MaxNetCurrencyLots); if(PortfolioScope == PORTFOLIO_MAGIC_FILTERED && ExtraPortfolioMagics == "") Print("Note: portfolio scope is magic-filtered but no extra magic numbers were listed, so only this EA is counted."); }
Three Scopes, Consulted in Widening Order
The tick handler is where the three tiers meet.
//+------------------------------------------------------------------+ //| Expert tick function | //+------------------------------------------------------------------+ void OnTick() { //--- One decision per bar datetime currentBarTime = iTime(_Symbol, _Period, 0); if(currentBarTime == 0) return; static datetime lastProcessedBarTime = 0; if(currentBarTime == lastProcessedBarTime) return; lastProcessedBarTime = currentBarTime; //--- Housekeeping runs every bar, before and regardless of any signal. risk.ManagePendingOrders(); basket.Manage(); if(EnablePortfolioRisk && LogPortfolioSnapshot) portfolio.PrintSnapshot(); if(!IsSpreadAcceptable()) return; //--- The strategy: the only part of this file that is about trading double atrValue = 0.0; int direction = ReadSignal(atrValue); if(direction == 0) return; MqlTick tick; if(!SymbolInfoTick(_Symbol, tick)) return; //--- Plan the trade, then pass it through all three scopes of risk in widening order. STradePlan plan; if(!risk.BuildPlan(direction, atrValue, tick, plan)) return; if(!risk.ValidatePlan(plan, tick)) return; if(!basket.AcceptsNewTrade(plan, risk.CountPendingOrders())) return; if(EnablePortfolioRisk) { string blockReason = ""; if(!portfolio.CanOpenPosition(_Symbol, MarketOrderTypeOf(plan.orderType), plan.lotSize, plan.entryPrice, blockReason)) { PrintFormat("Portfolio limit: %s. Trade skipped.", blockReason); return; } } risk.ExecutePlan(plan); }
Four questions in sequence. What should this trade be? Can the trade itself run? Can this EA's basket carry it? May the account carry it? They stayed inline instead of going behind a single permission call, which would be shorter and would hide the sequence.
The sequence is the EA's to keep. ExecutePlan() does not check that the plan it is handed went through the gates, and nothing else in the classes does. The order is not the previous EA's. There, the basket and account questions were asked before the pending-distance check and the margin cap. Here, the trade tier answers its own questions first, including the margin cap, so the two wider gates are asked about the volume that will be sent.
Both gates are asked about the position the order would become. That is why the account gate receives MarketOrderTypeOf(plan.orderType) and the plan's entry price: a pending buy is checked as a buy of that volume at its intended entry. The check is made once, when the order is placed, against the account as it stands at that moment. Nothing checks again when a pending order fills, and the account can look different by then.
Pending orders that already exist are counted in some of these answers and not in others. The trade tier refuses a new trade while one of its own pending orders is waiting, in the same direction when hedging is allowed and in either direction when it is not. The basket gate adds the pending count it is given to its position count. The account gate adds pending orders to its total-positions limit only. Its other five limits are computed from open positions and the account's margin figure, and the class adds nothing to them for an order that is still waiting. The housekeeping above them runs before the signal check. A losing basket is checked on the bars when the strategy has nothing to say, and pending orders expire on their own schedule whether a new signal arrives or not. All of it runs on the first tick of a bar and not again until the next bar.
A basket loss that passes its limit in the middle of a bar is not acted on until the next bar's first tick. If the loss is still past the limit then, the group is closed at whatever the loss has become. In between, the only protection this EA has in place is each position's own stop-loss. A close request that returns false is logged with the ticket and the return code, and it is not retried inside the bar. The next bar's check finds the position still open and tries again if the loss is still past the limit. One decision per bar also means one attempt per bar. The bar is marked as processed before the spread check, the signal read, and the price request. A first tick with a wide spread, or indicator data that is not ready, costs that bar its signal, and nothing retries inside the bar.
The crossover is read from the two bars before the current one, so the next bar no longer sees it. The EA has worked this way since the first part of the series. Marking the bar only after a successful attempt would let a signal wait for the spread to settle, and it would change when the EA trades, which this part does not do.
What Is Left in the File
Four functions remain, plus the startup and shutdown: reading the crossover, checking the spread, parsing the magic number list, and handling the tick event. The crossover test is unchanged, and it now has a function of its own where it used to sit inside the tick handler. The file is about a quarter of its previous length.
Section 3: A Second EA on the Same Framework
The Mean-Reversion Demo Rebuilt
Part 5's mean-reversion demo has been rebuilt on the framework as well. It trades reversion toward a Bollinger middle band and sizes positions from a profit target, so it approaches the framework from a direction the crossover EA never does.
It is a rebuild and not a port, and it does not trade as its earlier version did:
- It no longer closes a basket of two or more legs at their volume-weighted average entry. Each leg is left to its own target and stop.
- It no longer caps the risk that all open legs imply together. The cap on a single leg's implied risk remains.
- The spacing between adds is measured from the basket's average entry, which the framework's basket state provides. The earlier version measured from the basket's lowest entry for buys and its highest for sells.
- The basket class brings a margin ceiling, an optional time stop, and excursion tracking, which the earlier version did not have.
- The defaults for the profit target, the stop multiple, and the basket loss limit differ.
Results from the two versions should not be expected to match. The rebuilt demo includes two of the four files.
#include <Trade/Trade.mqh> #include <Risk Management/RiskTypes.mqh> #include <Risk Management/BasketRisk.mqh>
The Basket Tier Transfers Unmodified
The demo configures the basket the way the series EA configures it, with its own numbers.
//--- The basket tier is configured the way the series EA configures it SBasketConfig basketCfg; basketCfg.SetDefaults(); basketCfg.magic = MagicNumber; basketCfg.enabled = true; basketCfg.maxPositions = MaxOpenPositions; basketCfg.maxLossPercent = MaxBasketLossPercent; basketCfg.lossBase = RISK_BASE_BALANCE; basketCfg.maxMarginPercent = MaxBasketMarginPercent; basketCfg.trackExcursion = true; basketCfg.maxHoldBars = MaxBasketHoldBars; if(!basket.Init(basketCfg)) return INIT_PARAMETERS_INCORRECT;
The class is the same file the series EA includes, with nothing changed for this EA. That works because the basket tier measures and limits positions and does not choose entries. Its gate limits how many positions the group holds and how much margin it ties up, and it has no rule about price, direction, or timing. Deciding to add a position is strategy, so it stayed in the EA: the demo refuses a signal against its open legs and keeps its own spacing rule.
Sizing From a Target Instead of a Stop
The shared conversion is called here by an EA with no stop-based risk budget at all:
//--- Target-profit sizing, using the framework's shared conversion double sizeBaseValue = AccountValueByBase(SizeBase); double targetProfit = sizeBaseValue * (TargetProfitPercent / 100.0); double lotSize = VolumeForMoneyAndDistance(_Symbol, targetProfit, expectedMove, false); if(lotSize <= 0) return;
The money amount is a profit target, and the distance is how far price has to travel to reach the mean. Neither is a risk budget nor a stop distance. The conversion is the same one the crossover EA calls. The last argument is false here and configurable in the series EA. This strategy would skip a signal before it traded a minimum lot that overshot its target.
Sizing from a target also inverts the relationship the rest of the series relies on. The trader chooses a profit target, and what stands to be lost falls out of that choice, so it has to be measured afterward and capped when it comes out too large.
double riskPerLot = ValuePerLot(_Symbol, stopDistance); if(riskPerLot > 0 && MaxImpliedRiskPercent > 0) { double maxRiskAmount = sizeBaseValue * (MaxImpliedRiskPercent / 100.0); double affordable = FloorToVolumeStep(maxRiskAmount / riskPerLot, lotStep); if(lotSize > affordable) { PrintFormat("Implied risk cap: volume %.2f would risk %.2f (%.2f%% of account). Reducing to %.2f.", lotSize, lotSize * riskPerLot, (lotSize * riskPerLot) / sizeBaseValue * 100.0, affordable); lotSize = affordable; } }
Both helper functions come from the framework. The cap itself is strategy logic and stays in the EA, since the crossover EA starts from its risk and has nothing implied to cap.
The Basket Gate in the Demo
Once a volume exists, the basket gate is asked the same question. No pending count is passed, since this EA places no pending orders.//--- Basket tier decides whether this leg can join the others STradePlan plan; ZeroMemory(plan); plan.direction = direction; plan.orderType = (direction == 1) ? ORDER_TYPE_BUY : ORDER_TYPE_SELL; plan.entryPrice = entryPrice; plan.lotSize = lotSize; if(!basket.AcceptsNewTrade(plan)) return;
The plan structure is filled by hand, and only four of its nine fields are set. The basket gate reads three of them: order type, entry price, and volume. Direction is filled in, and the gate does not read it. The other five are left at zero by ZeroMemory(). This works because of what the gate reads today, and it is a dependency on that. If a later version of the gate read another field, it would find a zero here, with no compile error to point at it. The demo file is about three fifths of its previous length, and part of that reduction is the two features the rebuild left out.
What the Demo Does Not Reuse
The demo does not use CTradeRisk. The trade tier encodes one model of risk: a stop distance derived from volatility, a money budget set by the trader, and a volume derived from dividing the budget by the value of that distance. The mean-reversion EA does not size that way. It has a volatility-based stop, but the stop is structural insurance against the mean never arriving, and it plays no part in sizing. Making the trade tier fit would have meant a target-profit mode, then a switch, then settings that apply in one mode and are ignored in the other. That makes CTradeRisk the tier that is tied to a strategy's risk model, by design. Neither the basket tier nor the account tier reads how a trade was sized, and in that sense they are strategy-neutral.
The demo does not include the account tier either. Nothing in CPortfolioRisk depends on how a trade was sized, since its gate takes a symbol, an order type, a volume, and a price. The demo does not call it, so this part shows the account tier used by one EA only.
Installing the Framework
Copy the shared Risk Management folder into your terminal's MQL5\Include\ directory. The include lines in both EAs then resolve without any project settings, which is why they read <Risk Management/TradeRisk.mqh> and not a relative path. The series EA includes three framework files, two of which include the shared types file, and it compiles without a duplicate-declaration error. A clean compile shows that the files find each other and that nothing is declared twice. It says nothing about whether the tiers are configured sensibly or called in the right order, which is the EA's job and the subject of Section 2.
Where a limit can be switched off, zero is what switches it off. That covers the basket's loss, margin, and hold-bars limits, the trade tier's pending-order expiry, and the six account limits. It does not cover the position ceiling, which must be at least 1, or the trade tier's risk, stop, target, margin-cap, and pending-offset settings, which must be above zero where they are used. A whole tier or feature has its own switch, such as EnableBasketRisk and EnablePortfolioRisk in the EA, and the two enable inputs for drawdown scaling and the adaptive margin cap.What This Part Does Not Do
The series EA is meant to trade as FixedMACrossover_Part6.mq5 does on the same symbol, period, and settings, apart from the differences listed under Scope of This Article. That rests on reading the two files side by side, function by function. Running both in the Strategy Tester on the same settings and comparing the deals is the check a reader can make. The demo EA offers no such comparison. It was rebuilt without two features of its earlier version, as Section 3 lists, so the two versions should not be expected to produce matching results. The account tier does not follow the framework's configuration pattern, as described above, and the framework carries that inconsistency deliberately.
The limitations of the earlier controls carry over. On netting accounts, only one position per symbol can exist, so a count of positions on one symbol never passes one. Raising the basket's position ceiling above one has no effect there, and neither does a per-symbol limit above one. The limits that count across symbols and the money-based controls still work.
Per-position margin is computed with OrderCalcMargin() because MetaTrader provides no such position-level property. That calculation does not account for other open positions, so any per-leg total is an approximation. A live position cannot report the commission paid to open it, so a floating result looks better than it is by that amount for as long as the position is open. Currency decomposition is shaped for foreign exchange and says little about indices or metals. None of the in-memory measurement survives a terminal restart. The crossover EA still does not stack positions. Its ceiling remains one, and the add-on logic that would make a higher ceiling meaningful is still ahead.
The limits stated earlier in this article are collected here:
- The sequence of gates is kept by the EA and is not enforced by the classes, and a plan filled by hand has to carry the three fields the gates read.
- A pending order is checked against the basket and the account when it is placed and not again when it fills, and the account tier adds a waiting order to one of its six limits only.
- Basket protection and pending-order expiry run once per bar. A close that returns false is tried again at the next bar's check if the limit is still breached, and a bar whose first tick fails the spread check or finds indicator data not ready loses its signal.
- The basket tier acts on every position and pending order on its symbol that carries its magic number, whichever program opened it.
- The sizing conversion values a price move at the profit-side tick value in force when it is called, rounds the volume down, and leaves out commission, swap, and slippage.
- The defaults in a configuration structure protect only an EA that calls SetDefaults(), and the magic number has no safe default.
- Init() reports a refused configuration, and what happens next is the EA's decision. The basket class has no ready flag, and the account tier validates none of its settings and starts with every limit off.
What Comes Next
A trailing stop can become another class alongside the three tiers, with its own configuration structure and one more call in the tick handler. The EA grows by that class's inputs and its configuration block, and the trailing logic itself stays out of the file.
Conclusion
This part moves the risk code out of the crossover EA into three header files and puts the account-level header beside them. The strategy file is about a quarter of its previous length and holds the inputs, the signal, the configuration, and the call sequence. The series EA's risk controls were carried over and separated according to the scope they protect: CTradeRisk handles the individual trade, CBasketRisk handles the positions on one symbol under one magic number, and CPortfolioRisk handles the wider account. Shared types and calculations sit underneath them, while strategy-specific decisions remain in the EA.
The whole is not smaller or simpler than before. There is more code in more files, and the classes depend on contracts that the EA has to keep. The split gives a crossover file that can be read for what it trades, and risk controls that another EA can include. The framework has also been used by a second strategy, in part. The mean-reversion demo includes the basket class and the shared types and conversions unmodified, and it sizes from a profit target without adopting the trade tier's stop-based model. It does not use the trade tier or the account tier, so those two have one consumer each.
One further EA that reuses half of the files shows that the boundary around the basket tier and the shared conversions holds for one strategy that sizes differently. It does not show that the framework suits strategies in general. The demo does show the intent behind the boundaries: an EA takes the tiers that apply to it and leaves the rest.
Source Files
| Filename | Description |
|---|---|
| FixedMACrossover_Part7.mq5 | The main series EA. It contains the moving-average crossover strategy, inputs, configuration, and the sequence that consults the three risk tiers. |
| RiskTypes.mqh | Shared vocabulary for the framework, including risk and order enumerations, STradePlan, configuration structures, and common conversion utilities. |
| TradeRisk.mqh | Implements CTradeRisk, handling position sizing, drawdown scaling, margin constraints, order construction, validation, and execution at the individual-trade level. |
| BasketRisk.mqh | Implements CBasketRisk, limiting the loss, margin, position count, and holding time of the positions on one symbol under one magic number, and recording their excursion. |
| PortfolioRisk.mqh | Implements CPortfolioRisk, applying limits at the broader account/portfolio level across positions and, where configured, multiple magic numbers. |
| MeanReversionSizing_Part7.mq5 | The second EA rebuilt on the framework. It uses RiskTypes.mqh and BasketRisk.mqh while retaining its own strategy-specific sizing and implied-risk logic. |
| MQL5.zip | Archive containing all source files, arranged in the required MQL5 terminal directory structure. |
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.
Building a Prop Firm Compliance Monitor in MQL5 (Part 2): Daily Sessions and Restart-Safe Starting Balances
Beyond Clone and Commit: The Advanced Side of MQL5 Algo Forge
Neural Networks in Trading: A Spiking-Semantic Approach to Spatiotemporal Identification (Key Components)
Neural Networks in Trading: A Spiking-Semantic Approach to Spatiotemporal Identification (S3CE-Net)
- Free trading apps
- Over 8,000 signals for copying
- Economic news for exploring financial markets
You agree to website policy and terms of use