preview
Building Your Personal Expert Advisor (Part 5): Risk Management IV—Basket Risk and Strategy-Specific Sizing

Building Your Personal Expert Advisor (Part 5): Risk Management IV—Basket Risk and Strategy-Specific Sizing

MetaTrader 5Examples |
286 0
Solomon Anietie Sunday
Solomon Anietie Sunday

Table of Contents

  1. Introduction
  2. Three Correct Trades and One Overexposed Account
  3. Maximum Adverse Excursion
  4. Why This Part Needs a Second EA
  5. One Signal, Several Entries
  6. A Word About Averaging Into a Loser
  7. What This Part Does Not Do
  8. Conclusion

Introduction

Your EA from the previous part already knows how to place a “correct” trade: compute lot size from a risk budget, set a valid stop, and verify free margin. The gap is that every one of those checks answers a question about a single candidate trade — and cannot see what else the account is carrying. Two or three individually valid trades on the same symbol (stacking, averaging, repeated signals, or hedged legs) can quietly turn 1% per trade into 3–6% total exposure, consume disproportionate margin, and leave capital “underwater” for longer than intended.

This part closes that gap by treating everything the EA owns as one basket: it measures total volume, a volume‑weighted average entry, floating P/L including swap, estimated used margin, position count and oldest open time, plus MAE/MFE and peak position count for diagnostics. Those aggregates power two classes of controls: every‑bar protections (aggregate loss cut, time stop, MAE tracking) and pre‑trade admissions (position/ order ceilings, margin ceiling, and implied‑risk caps). Because some sizing models require a price target and therefore make risk an output rather than an input, a small companion mean‑reversion demo EA illustrates target‑based sizing and the additional per‑leg and per‑basket implied‑risk checks the model needs.


Three Correct Trades and One Overexposed Account

Take the EA with hedging enabled, holding two positions and about to open a third. Each was sized to risk one percent of the balance. Each has a valid stop at a legal distance. They also passed the margin check before it opened, and each of those checks confirmed that free margin covered it comfortably. Read the numbers on any single one of them, and it is a textbook trade. Now read the account. It carries three percent of exposure that can all be lost at once, on the same symbol, in a market where the three will mostly move together. It has three positions' worth of margin tied up. Nothing in the code has looked at any of those totals, because nothing was built to.

The per-trade controls are not failing here. They cannot see this, by construction. A function that receives one candidate trade and the account's free margin has been given no way to reason about what everything else adds up to. It answers the question it was asked, and the question was too small. A whole category of trading damage lives in that gap: position sizes creeping upward after a loss, same-direction trades stacked at worsening prices, and exposure growing in exactly the conditions where it should be shrinking.

Measuring Everything the EA Holds

Before anything can be limited, it has to be measured, and the measurement needs somewhere to live. A structure holding one snapshot of the combined position is enough:

//+------------------------------------------------------------------+
//| Every position this EA holds, treated as one unit                |
//+------------------------------------------------------------------+
struct SBasketState
  {
   int               positionCount;
   double            totalVolume;
   double            weightedEntryPrice;  // Volume-weighted average entry
   double            floatingPL;          // Profit + swap
   double            usedMargin;          // Computed, not read - see GetBasketState()
   int               netDirection;        // 1 = net long, -1 = net short, 0 = flat or balanced
   datetime          oldestOpenTime;
  };
One pass over the position list fills it in. The filter on symbol and magic number is the same one the EA has always used to recognize its own trades:
//+------------------------------------------------------------------+
//| Aggregate every position this EA holds into a single view        |
//+------------------------------------------------------------------+
bool GetBasketState(SBasketState &basket)
  {
   ZeroMemory(basket);

   double weightedEntrySum = 0.0;
   double signedVolume     = 0.0;

   for(int i = PositionsTotal() - 1; i >= 0; i--)
     {
      ulong ticket = PositionGetTicket(i);
      if(!PositionSelectByTicket(ticket))
         continue;
      if(PositionGetString(POSITION_SYMBOL) != _Symbol ||
         PositionGetInteger(POSITION_MAGIC) != MagicNumber)
         continue;

      double volume    = PositionGetDouble(POSITION_VOLUME);
      double openPrice = PositionGetDouble(POSITION_PRICE_OPEN);
      ENUM_POSITION_TYPE posType = (ENUM_POSITION_TYPE)PositionGetInteger(POSITION_TYPE);

      basket.positionCount++;
      basket.totalVolume += volume;

      //--- Floating P/L must include the carrying costs, not just price movement
      basket.floatingPL += PositionGetDouble(POSITION_PROFIT)
                           + PositionGetDouble(POSITION_SWAP);

      weightedEntrySum += openPrice * volume;
      signedVolume     += (posType == POSITION_TYPE_BUY) ? volume : -volume;

      //--- No POSITION_MARGIN exists, so price this leg as if opening it now
      double legMargin = 0.0;
      ENUM_ORDER_TYPE asOrder = (posType == POSITION_TYPE_BUY) ? ORDER_TYPE_BUY : ORDER_TYPE_SELL;
      if(OrderCalcMargin(asOrder, _Symbol, volume, openPrice, legMargin))
         basket.usedMargin += legMargin;

      datetime openTime = (datetime)PositionGetInteger(POSITION_TIME);
      if(basket.oldestOpenTime == 0 || openTime < basket.oldestOpenTime)
         basket.oldestOpenTime = openTime;
     }

   if(basket.positionCount == 0)
      return false;

   if(basket.totalVolume > 0)
      basket.weightedEntryPrice = weightedEntrySum / basket.totalVolume;

//--- Net direction, not "the direction of the last position found".
   if(signedVolume > 0)
      basket.netDirection = 1;
   else
      if(signedVolume < 0)
         basket.netDirection = -1;

   return true;
  }

There is no property for a position's margin. A position publishes its volume, its open price, its profit, its swap, its open time, and a dozen other things. It does not publish the margin it is consuming. Look through the position properties, and the value doesn't exist. So the only way to know what the combined position ties up is to price every leg again, as though you were opening it right now, using the same margin call the EA already uses before a trade.

Swap should be included: it may be negligible over an hour, but it can be material when a position is held across several nights. A limit on what the combined position may lose should be measured against what that position has actually cost, and overnight financing is part of that cost.

Commission is a different case. It is recorded on the deals that open and close a position, so a live position genuinely cannot report it, and this figure is understated by the entry commission for as long as the basket stays open. On most retail accounts, that is small. On some it is not, and it is better to know which one you are on than to assume the total is complete.

Netting and Hedging: What a Basket Can Be

Everything above assumes several positions can exist on one symbol at the same time. On a hedging account they can. On a netting account they cannot, and the difference decides what half of these controls actually do. Buy twice on a netting account and the terminal does not hand you a second position. The second deal increases the volume of the position that already exists. The documentation states the rule plainly: under netting, only one position can exist for a symbol at any moment, and that position is the result of one or more deals. The position list, therefore, never shows more than one entry per symbol, however many times you buy, and the single open price it reports already accounts for every deal that built it.

On a netting account, the controls in this part that count positions have almost nothing to count, and raising the position ceiling above one changes nothing, because the count it compares against cannot exceed one. The controls that measure money keep working exactly as well as they do on a hedging account, because one position built from four deals still has a floating result, a margin cost, and a worst moment. Rather than leave that to be discovered during a tester run where nothing ever stacks, both EAs now report which kind of account they are running on:

      long marginMode = AccountInfoInteger(ACCOUNT_MARGIN_MODE);
      if(marginMode != ACCOUNT_MARGIN_MODE_RETAIL_HEDGING)
         Print("Note: this is not a hedging account. Every deal on this symbol builds one position, so the basket never holds more than one.");

Four Limits on the Combined Position

The controls split cleanly by when they can be applied. Two of them act on positions that already exist, so they run on every bar. The other two decide whether a new trade may join, so they run before it is created.

input group "Basket Risk"
input bool             EnableBasketRisk = true;          // Measure and limit risk across all open positions
input int              MaxBasketPositions = 1;           // Max concurrent positions for this EA [1 = no stacking]
input double           MaxBasketLossPercent = 3.0;       // Close the whole basket if it loses this % [0 = off]
input ENUM_RISK_BASE   BasketLossBase = RISK_BASE_BALANCE; // Basket cut-loss measured against
input double           MaxBasketMarginPercent = 25.0;    // Max % of account equity this basket may tie up [0 = off]
input bool             EnableMAETracking = true;         // Track how far the basket goes against you
input int              MaxBasketHoldBars = 0;            // Close a losing basket after N bars [0 = off]

The two that watch open positions.

//+------------------------------------------------------------------+
//| Basket-level protection, evaluated once per bar.                 |
//+------------------------------------------------------------------+
void ManageBasketRisk()
  {
   if(!EnableBasketRisk)
      return;

   SBasketState basket;
   if(!GetBasketState(basket))
     {
      //--- Flat: report what the closed basket did, then start clean
      ResetBasketTracking();
      return;
     }

   UpdateBasketExcursion(basket);

//--- Aggregate loss cut. The individual stops are still in place;
   if(MaxBasketLossPercent > 0)
     {
      double lossBase = (BasketLossBase == RISK_BASE_EQUITY)
                        ? AccountInfoDouble(ACCOUNT_EQUITY)
                        : AccountInfoDouble(ACCOUNT_BALANCE);
      double maxLoss = lossBase * (MaxBasketLossPercent / 100.0);

      if(basket.floatingPL <= -maxLoss)
        {
         CloseBasket(StringFormat("Aggregate loss %.2f breached the %.1f%% limit (%.2f) across %d position(s).",
                                  basket.floatingPL, MaxBasketLossPercent, maxLoss, basket.positionCount));
         ResetBasketTracking();
         return;
        }
     }

//--- Time stop. A basket that has been underwater for a long time
   if(MaxBasketHoldBars > 0 && basket.floatingPL < 0 && basket.oldestOpenTime > 0)
     {
      int barSeconds = PeriodSeconds(_Period);
      if(barSeconds > 0)
        {
         int barsHeld = (int)((TimeCurrent() - basket.oldestOpenTime) / barSeconds);
         if(barsHeld >= MaxBasketHoldBars)
           {
            CloseBasket(StringFormat("Held %d bars while losing %.2f (limit %d bars).",
                                     barsHeld, basket.floatingPL, MaxBasketHoldBars));
            ResetBasketTracking();
            return;
           }
        }
     }
  }

The aggregate loss cut reads at first like a replacement for the individual stops, and it is worth being clear that it is not. Every position still carries its own stop-loss, still placed at a valid distance, and is still doing its job. This is a ceiling on what all of those stops can cost when they are hit together. Two percent per trade is a policy about one trade. Three percent across everything open is a policy about the account, and the second does not follow from the first. Measuring it against balance or against equity is the same choice the risk model already offers, and for the same reasons. Balance is steady and ignores whatever is floating. Equity moves with the open positions, so the limit tightens as the position deteriorates. Neither is more correct than the other.

The time stop is off by default and requires understanding before switching on. A position that has been underwater for two hundred bars may still be well inside its stop, and by the stop's own logic, nothing is wrong. But capital committed to a trade that has gone nowhere is capital that cannot take the next setup, and a strategy with a view about direction usually carries an unspoken view about timing as well. Notice the condition: the time stop only fires on a losing basket. A winner that has been open a long time is left alone.

Closing the basket is a loop over the positions, and it does one thing beyond the obvious. Any pending order the EA still has waiting is deleted along with the positions:

//--- Any pending order belonging to this basket is now orphaned intent
   for(int i = OrdersTotal() - 1; i >= 0; i--)
     {
      ulong ticket = OrderGetTicket(i);
      if(ticket == 0)
         continue;
      if(OrderGetString(ORDER_SYMBOL) != _Symbol ||
         OrderGetInteger(ORDER_MAGIC) != MagicNumber)
         continue;

      if(!trade.OrderDelete(ticket))
         PrintFormat("Failed to delete pending order #%I64u. Retcode: %u (%s)",
                     ticket, trade.ResultRetcode(), trade.ResultRetcodeDescription());
     }

A pending order is an intent that has not become a trade yet. Once a loss limit has overruled the reasoning behind the trade, that intent is stale, and leaving it armed means the basket you just closed for losing too much can quietly rebuild itself an hour later.

The two that guide the door.

//+------------------------------------------------------------------+
//| Would adding this trade push the basket past its limits?         |
//+------------------------------------------------------------------+
bool IsBasketAcceptingNewTrade(const STradePlan &plan)
  {
   if(!EnableBasketRisk)
      return true;

   SBasketState basket;
   bool hasBasket = GetBasketState(basket);

//--- Position count ceiling, counting pending orders as committed intent
   int committed = (hasBasket ? basket.positionCount : 0) + CountPendingOrders();
   if(committed >= MaxBasketPositions)
     {
      PrintFormat("Basket full: %d position(s)/order(s) already committed, limit is %d. Trade skipped.",
                  committed, MaxBasketPositions);
      return false;
     }

//--- Aggregate margin ceiling for this EA's basket
   if(MaxBasketMarginPercent > 0)
     {
      double newLegMargin = 0.0;
      if(!OrderCalcMargin(MarketOrderTypeOf(plan.orderType), _Symbol,
                          plan.lotSize, plan.entryPrice, newLegMargin))
        {
         PrintFormat("%s: OrderCalcMargin() failed. Error %d", __FUNCTION__, GetLastError());
         return false;
        }

      double projectedMargin = (hasBasket ? basket.usedMargin : 0.0) + newLegMargin;
      double equity          = AccountInfoDouble(ACCOUNT_EQUITY);
      double marginCeiling   = equity * (MaxBasketMarginPercent / 100.0);

      if(equity > 0 && projectedMargin > marginCeiling)
        {
         PrintFormat("Basket margin ceiling: this trade would tie up %.2f of %.2f allowed (%.1f%% of equity). Trade skipped.",
                     projectedMargin, marginCeiling, MaxBasketMarginPercent);
         return false;
        }
     }

   return true;
  }

A Ceiling That Does Not Bite Yet

One of these four limits does almost nothing in the crossover EA. The direction rules that have governed this EA since the early parts run before the basket check. With hedging off, any existing position blocks a new one. With hedging on, an existing position in the same direction blocks a new one in that direction. The most the crossover EA can hold is one position, or one hedged pair, so setting the ceiling to five will not produce five trades. That is deliberate. A crossover fires once and is finished. However, the demo EA in the second half of this article does reach its ceiling, because a mean-reversion signal repeats where a crossover does not. That is where these limits can be watched working.

Why the Basket Check Runs Outside the Signal

Where the basket work sits in the tick handler matters as much as what it does:

//--- Housekeeping runs every new bar, whether or not a signal appears.
   ManagePendingOrders();
   ManageBasketRisk();

That sits immediately after the new-bar check, before the indicator readiness test, before the data copying, before the spread check, and well before the crossover test. Each of those steps can return early, and any one of them returning early on a bad day is a bar on which the basket goes unwatched. Put the basket check inside the signal branch instead, and it becomes a check that only runs when a crossover happens. A losing position most needs supervision when the strategy produces no signals; a signal-gated basket check will not run during that period. The failure would also survive testing comfortably, because on any run with frequent signals, it appears to work.

The pre-trade check goes inside ValidateTradePlan(), alongside the questions already asked before a trade is allowed to exist:

//--- The direction rules above decide whether this trade is allowed to exist
   if(!IsBasketAcceptingNewTrade(plan))
      return false;


Maximum Adverse Excursion

Adverse_excursion

Fig. 1. The closed-trade report shows these as the same win. Excursion tracking is what tells them apart.

Two positions both close two hundred dollars in profit. One drifted upward and never went negative. The other was eight hundred dollars underwater for a day and a half before it recovered. The trade history records them identically: the same symbol, the same direction, same result.

They are not the same trade. The second one was, at its worst moment, a position most traders would have closed by hand. Whether it came back through skill or through luck is a separate argument. What matters is that the account was exposed to four times the eventual profit, and nothing in the standard reporting shows it.

Maximum adverse excursion is the term for that worst moment. Tracking it costs three comparisons per bar:

//+------------------------------------------------------------------+
//| Track how far the basket travels against us and in our favour.   |
//+------------------------------------------------------------------+
void UpdateBasketExcursion(const SBasketState &basket)
  {
   if(!EnableMAETracking)
      return;

   if(basket.positionCount > g_BasketPeakCount)
      g_BasketPeakCount = basket.positionCount;

   if(basket.floatingPL < 0 && MathAbs(basket.floatingPL) > g_BasketMaxAdverse)
      g_BasketMaxAdverse = MathAbs(basket.floatingPL);

   if(basket.floatingPL > g_BasketMaxFavour)
      g_BasketMaxFavour = basket.floatingPL;
  }

The figures are reported and cleared the moment the EA is flat again, which is the only place they ever appear:

//+------------------------------------------------------------------+
//| Reset the running basket statistics once the basket is flat      |
//+------------------------------------------------------------------+
void ResetBasketTracking()
  {
   if(g_BasketPeakCount > 0)
      PrintFormat("Basket closed | Peak positions: %d | Max adverse excursion: %.2f | Max favourable: %.2f",
                  g_BasketPeakCount, g_BasketMaxAdverse, g_BasketMaxFavour);

   g_BasketMaxAdverse = 0.0;
   g_BasketMaxFavour  = 0.0;
   g_BasketPeakCount  = 0;
  }

This measures and does not enforce, deliberately. Adding a rule that closes anything reaching a given excursion is easy. Knowing what number to put in it is not. A sensible aggregate loss limit depends on the strategy's typical drawdown on the given symbol, which you can only determine by measuring it first. Run the tracking for a while, look at what the winners went through on their way to winning, then choose the limit. Choosing it first is guessing with extra steps.


Why This Part Needs a Second EA

Three items on this section of the roadmap have not appeared yet: sizing from a target price, sizing from an expected move, and sizing from a profit target. All three need a price the strategy is aiming at, and a moving average crossover does not have one. Its take-profit is a multiple of its stop distance, which is a risk-reward convention rather than a forecast about where price will stop.

Mean reversion does have one. The whole thesis is that price has traveled too far from a mean and will come back to it, and that mean is a real price level, known before the trade opens.

This series holds to a rule that the example strategy does not change to suit the message, and grafting a mean onto a crossover would break it. So the three sizing models live in a separate EA written only for this purpose, MeanReversionSizing_Demo.mq5. It is deliberately small. Bollinger Bands supply the mean through the middle band; a close below the lower band is a buy toward it, and a close above the upper band is a sell. That is the entire signal. It is a teaching aid rather than a strategy recommendation, and this article would be doing you a disservice if it implied otherwise.

Sizing Towards a Target Instead of Away From a Stop

Position_sizing_inversion

Fig. 2. Two ways to arrive at a position size.

The whole idea is this:

   series EA:   risk budget   -> stop distance  -> volume
   demo EA:     profit target -> expected move  -> volume

Three links in both chains, travelling in opposite directions. In the series EA, the stop distance is an input to the sizing arithmetic. In the demo it plays no part in that arithmetic at all. The expected move is the distance from the entry to the mean, and it has to clear two separate minimums before the trade is worth attempting:

//--- Expected move: how far price must travel to reach the mean.
   double expectedMove = MathAbs(meanPrice - entryPrice);

//--- The target has to clear the broker's minimum distance as well
   double brokerMinimum = ((double)SymbolInfoInteger(_Symbol, SYMBOL_TRADE_STOPS_LEVEL) * _Point)
                          + (tick.ask - tick.bid);
   double minTargetDistance = MathMax(MinExpectedMovePoints * _Point, brokerMinimum);

   if(expectedMove < minTargetDistance)
     {
      PrintFormat("Mean is %.1f points away, %.1f required (strategy minimum %d, broker minimum %.1f). Signal skipped.",
                  expectedMove / _Point, minTargetDistance / _Point,
                  MinExpectedMovePoints, brokerMinimum / _Point);
      return;
     }

Both minimums earn their place for unrelated reasons. When price sits almost on the mean, the expected move approaches zero, and dividing a fixed profit target by a value approaching zero produces a volume approaching infinity. That is the sanity check. The broker's minimum is more mundane and just as necessary: the target is sent as a take-profit level, and a take-profit closer to the market than the symbol's stops level is rejected outright. Taking the larger of the two satisfies both rules with one comparison.

The stop is placed afterward and for structural reasons only. It exists in case the mean is never reached, and nothing downstream of it feeds the volume calculation:

//--- Structural stop. It protects against the mean never being reached and doesn't define position size
   double stopDistance = atrValue * StopATRMultiplier;
   if(stopDistance < brokerMinimum)
      stopDistance = brokerMinimum;

   double stopLoss = (direction == 1)
                     ? NormalizeDouble(entryPrice - stopDistance, digits)
                     : NormalizeDouble(entryPrice + stopDistance, digits);

Then the volume falls out of the target and the distance to it:

//--- Target-profit sizing
   double sizeBaseValue = (SizeBase == SIZE_BASE_EQUITY)
                          ? AccountInfoDouble(ACCOUNT_EQUITY)
                          : AccountInfoDouble(ACCOUNT_BALANCE);
   double targetProfit  = sizeBaseValue * (TargetProfitPercent / 100.0);

   double profitPerLot = ValuePerLot(expectedMove);
   if(profitPerLot <= 0)
     {
      Print("Cannot value the expected move. Trade skipped.");
      return;
     }

   double rawLot = targetProfit / profitPerLot;

One property of this model comes for free. When volatility rises, the bands widen, the mean sits further from price, the expected move grows, and the same profit target, therefore, produces a smaller position. The model is volatility-scaled without any volatility input at all. The series EA achieves the same effect deliberately, through an ATR multiplier feeding the stop distance. Here it falls out of what the strategy was already measuring.

When Risk Is an Output

This is the consequence that makes the demo belong in an article about basket risk rather than one about position sizing. When volume comes from a risk budget, the maximum loss is settled before the trade exists. It is the number you typed into the inputs. When volume comes from a profit target, nothing in the calculation holds an opinion about the loss. The stop is placed for structural reasons, the volume is decided by the target, and whatever those two multiply out to is what the trade risks. Risk per trade stops being an input and becomes an output.

An output can still be capped, but it has to be measured first, and the cap has to sit outside the model that produced it:

//--- The risk this position implies is an output of the model
   double riskPerLot = ValuePerLot(stopDistance);
   if(riskPerLot > 0 && MaxImpliedRiskPercent > 0)
     {
      double maxRiskAmount = sizeBaseValue * (MaxImpliedRiskPercent / 100.0);
      double affordableLot = FloorToVolumeStep(maxRiskAmount / riskPerLot, lotStep);

      if(lotSize > affordableLot)
        {
         PrintFormat("Implied risk cap: target-profit volume %.2f would risk %.2f (%.2f%% of account). Reducing to %.2f.",
                     lotSize, lotSize * riskPerLot,
                     (lotSize * riskPerLot) / sizeBaseValue * 100.0, affordableLot);
         lotSize = affordableLot;
        }
     }

That block is a bridge between two philosophies rather than a choice between them. The sizing model is free to think in terms of profit, and a separate check makes sure the loss it implies is one the account can live with. Neither has to be abandoned for the other to work.


One Signal, Several Entries

Basket_averaging_trades

Fig. 3. Three legs, one exit.

The demo is where a basket actually forms, and the reason sits in the strategy rather than in anything added to it. A mean-reversion signal repeats. Price closes below the lower band, and if it keeps falling, it closes below the band again on the next bar, and once more on the one after. A crossover fires once and is finished. This one continues to fire until the market stops disagreeing with it. Left alone, that produces a pile of trades opened at almost the same price. Three rules shape it into something that can be measured instead, and each rule exists because of a specific failure.

An opposing signal is refused rather than hedged. A basket holding both directions has no meaningful average entry, and the shared exit described below depends on there being one. Rather than build something it cannot then manage, the EA declines.

An add has to actually move. Without a spacing rule, three consecutive bars outside the band produce three legs at nearly identical prices, which is all the added risk and none of the improvement in the average entry that averaging is supposed to buy:

//+------------------------------------------------------------------+
//| Should this signal be allowed to add a leg to the basket?        |
//+------------------------------------------------------------------+
bool IsAddAllowed(int direction, double entryPrice, double atrValue, const SDemoBasket &basket)
  {
   if(basket.positionCount == 0)
      return true;

//--- An opposing signal is refused rather than hedged
   if(basket.direction != 0 && basket.direction != direction)
     {
      PrintFormat("Opposing signal while %d %s leg(s) are open. Signal skipped.",
                  basket.positionCount, (basket.direction == 1) ? "long" : "short");
      return false;
     }

   if(basket.positionCount >= MaxOpenPositions)
     {
      PrintFormat("Basket full: %d of %d legs already open. Signal skipped.",
                  basket.positionCount, MaxOpenPositions);
      return false;
     }

//--- Spacing. Price closes outside a Bollinger band for several bars in a row
   double spacing = atrValue * MinAddSpacingATR;
   if(spacing > 0 && basket.extremeEntry > 0)
     {
      double advance = (direction == 1)
                       ? (basket.extremeEntry - entryPrice)
                       : (entryPrice - basket.extremeEntry);

      if(advance < spacing)
        {
         PrintFormat("Add rejected: price is %.1f points beyond the last entry, %.1f required.",
                     advance / _Point, spacing / _Point);
         return false;
        }
     }

   return true;
  }

The spacing is measured in ATR rather than points, so it widens with volatility for the same reason the expected move does.

The basket exits as one. Each leg opens with its own take-profit at the mean, and the moment there are two legs, those targets are the wrong exit. The later leg reaches the mean well before the first one does, and the first is still deep underwater when it gets there. A basket has a single break-even price, which is the volume-weighted average of what it paid, and a small offset beyond that price takes the whole thing off altogether:

//+------------------------------------------------------------------+
//| Exit the whole basket at its volume-weighted average entry.      |
//+------------------------------------------------------------------+
bool ManageBasketExit(const SDemoBasket &basket)
  {
   if(!UseBasketExit || basket.positionCount < 2 || basket.direction == 0)
      return false;

   MqlTick tick;
   if(!SymbolInfoTick(_Symbol, tick))
      return false;

   double offset = BasketExitOffsetPips * _Point * 10.0;
   double target = (basket.direction == 1)
                   ? basket.weightedEntry + offset
                   : basket.weightedEntry - offset;

   bool reached = (basket.direction == 1) ? (tick.bid >= target) : (tick.ask <= target);
   if(!reached)
      return false;

   int digits = (int)SymbolInfoInteger(_Symbol, SYMBOL_DIGITS);
   CloseBasket(StringFormat("Weighted average entry %.*f reached with offset (exit %.*f) across %d legs. Floating: %.2f",
                            digits, basket.weightedEntry, digits, target,
                            basket.positionCount, basket.floatingPL));
   return true;
  }

That is where the volume-weighted average entry stops being a number in a structure and becomes the price the strategy exits on. Note what the routine does not do. It closes positions rather than modifying them, so nothing here has to contend with the freeze level that governs changes to an order already on the server.

Three Legs, Three Times the Risk

Here is where the two halves of this article meet. A model whose risk is an output has that risk multiplied by however many legs it opens. Three legs at up to two percent implied risk each are six percent, and the per-trade cap holds no opinion whatsoever about that, because it was only ever asked about one trade.

So the same check is applied one level up, against what the basket has already committed:

   if(riskPerLot > 0 && MaxBasketImpliedRiskPercent > 0)
     {
      double basketCeiling = sizeBaseValue * (MaxBasketImpliedRiskPercent / 100.0);
      double headroom      = basketCeiling - basket.impliedRisk;

      if(headroom <= 0)
        {
         PrintFormat("Basket implied risk %.2f is already at the %.1f%% ceiling (%.2f). Trade skipped.",
                     basket.impliedRisk, MaxBasketImpliedRiskPercent, basketCeiling);
         return;
        }

      double affordableLot = FloorToVolumeStep(headroom / riskPerLot, lotStep);
      if(lotSize > affordableLot)
        {
         PrintFormat("Basket implied risk cap: %.2f already committed of %.2f allowed. Reducing %.2f to %.2f.",
                     basket.impliedRisk, basketCeiling, lotSize, affordableLot);
         lotSize = affordableLot;
        }
     }

The committed figure is not remembered from when the trades were placed. It is read back out of the positions themselves, one leg at a time, from each position's own stop-loss level:

      //--- What this leg's own stop would cost if it were hit
      if(stopPrice > 0)
         basket.impliedRisk += ValuePerLot(MathAbs(openPrice - stopPrice)) * volume;

That is a habit worth carrying into your own EAs. A state you store can go stale, can be lost on a restart, and can drift out of agreement with the account it describes. The state you can re-derive from the account cannot do any of those things. Where the derivation is cheap, prefer it.

The controls across both EAs, and when each one is answered:

Control (inputs) What it limits When it is checked Which EA
MaxBasketLossPercent What every open position may lose together.
Every bar Both
MaxBasketHoldBars How long a losing basket may be held.
Every bar Series
MaxBasketPositions/MaxOpenPositions How many legs the basket may contain.
Before a trade Both
MaxBasketMarginPercent Margin the basket may tie up.
Before a trade Series
MinAddSpacingATR How far price must move before an add.
Before a trade Demo
MaxImpliedRiskPercent The loss one leg's stop implies. Before a trade Demo
MaxBasketImpliedRiskPercent The loss all legs' stops imply together.
Before a trade Demo
EnableMAETracking Nothing. Measures only. Every bar Series


A Word About Averaging Into a Loser

The demo EA adds to a position that is moving against it, and that deserves a plain warning because it is the mechanism behind a large share of blown retail accounts. Averaging into a loser improves your average entry and increases your exposure at the same time. Both of those are real. The improvement is what makes the technique attractive, and the increase is what makes it dangerous because it arrives exactly when the market is disagreeing with you and exactly when you have the least evidence that you are right. A mean-reversion thesis says price will return to its mean. The market is under no obligation to do that before your margin runs out.

What makes it survivable in the demo is not the averaging logic. It is the three things sitting around it: a hard cap on how many legs may exist, a cap on what all their stops imply together, and an aggregate loss limit that closes everything at a level chosen in advance. Remove any one of those, and what remains is a martingale with better manners. So take the accounting from this EA rather than the entry rule. Measuring a combined position, capping what it may lose, and knowing what your winners went through before they won are useful in any strategy. Deciding to add to a loser is a strategy decision, and it should be made deliberately, with a limit written down first.


What This Part Does Not Do

The measurement does not survive a restart. Excursion figures and the peak position count live in memory, so closing the terminal resets them while the positions carry on. The trades are safe; the record of what they have been through is not. Every basket check also runs once per bar, which means that between bars the combined position is supervised only by the stops and targets already sitting on the broker's server, and a fast move can carry a basket well past its aggregate limit before the next evaluation. That is a deliberate trade-off rather than an oversight, since checking on every tick means re-reading every position on every tick, but it argues for setting the aggregate limit with some room rather than at the exact figure you can tolerate.

The legs do not share a stop. Each position keeps its own, so a basket can be taken apart from the outside in, with the earliest and furthest-underwater leg stopping out first and leaving the rest behind. Basket margin is approximate for the same reason it has been approximate throughout this series: each position is priced as though it were the only thing on the account, so a hedged pair shows a computed total higher than the account has really committed.

Everything here is scoped to one symbol and one EA. The loops filter by the current symbol and this EA's magic number; therefore, manual trades, trades from other EAs, and trades on correlated symbols are ignored. The demo's averaging also needs a hedging account, since on a netting account its adds build a single position and the shared exit never engages.

Two smaller things to also note. With hedging on and the position ceiling left at one, the hedging rule allows an opposing trade, and the ceiling then refuses it, so no hedged pair can ever open. The EA prints a note at startup rather than refusing to run, because it is a legitimate configuration that simply may not be what was intended. And the two EAs re-implement the same measurement separately, which is honest for a teaching file and wrong for anything else, since the same logic maintained twice drifts apart the first time one copy is corrected.


Conclusion

We moved the unit of control from the single trade to the account-level basket. The EA can now describe its holdings as one combined position (count, total volume, VWAP entry, floating P/L including swap, used margin, oldest time), measure maximum adverse excursion and peak leg count, and act on those readings. Protections include an aggregate loss cut (measured against balance or equity), a configurable time stop that fires only on losing baskets, and pre‑trade gates that refuse new legs when they would breach position, margin, or implied‑risk ceilings. Importantly, the basket checks run outside the signal path (every bar rather than only on new signals), so losing positions are supervised even when the strategy is quiet.

The companion mean‑reversion demo demonstrates the other side: when profit targets determine size, risk becomes an output and must be measured and capped both per leg and across the basket. Use MAE/MFE and peak counts as diagnostic inputs — run the tracking first, then choose basket limits on real data rather than by guesswork.

Limitations remain: the diagnostics are per symbol and per magic number (they do not aggregate manual trades, other EAs, or correlated symbols), MAE and peak statistics live only in memory and reset on restart, basket checks run once per bar (not every tick) and margin is estimated per leg. These are deliberate trade‑offs, but they define the next boundary: true cross‑EA, cross‑symbol exposure accounting, which must be addressed at a higher coordination layer.

The source files for both EAs are attached below.

Filename Description
FixedMACrossover_Part5.mq5 This part's EA containing basket aggregation, aggregate loss cut, time stop, position and margin ceilings, and adverse-excursion tracking.
MeanReversionSizing_Demo.mq5 A companion demo containing target-price, expected-move, and target-profit sizing; lightweight averaging; and the two implied-risk caps.

Features of Custom Indicators Creation Features of Custom Indicators Creation
Creation of Custom Indicators in the MetaTrader trading system has a number of features.
Eco-inspired Evolutionary Algorithm (ECO) Eco-inspired Evolutionary Algorithm (ECO)
The article discusses the ECO optimization algorithm, which is based on ecological concepts: populations are grouped into habitats based on territorial proximity, exchange genetic material within habitats, and migrate between them. Despite its wide range of operators and elegant biological metaphor, the algorithm produced a certain result discussed below.
Features of Experts Advisors Features of Experts Advisors
Creation of expert advisors in the MetaTrader trading system has a number of features.
From Basic to Intermediate: Classes (II) From Basic to Intermediate: Classes (II)
This article is intended to be as educational as possible, since the topic we will be discussing often causes considerable confusion in itself. Therefore, dear reader, please try to put what is explained here into practice. If you have any questions, be sure to leave a comment—after all, understanding destructors is no easy task.