Constant Proportion Portfolio Insurance (CPPI) for MQL5: A Floor-Protected Capital Allocation Strategy
Introduction
Most Expert Advisors are built to answer one question: buy, sell, or wait. Constant Proportion Portfolio Insurance was never trying to answer that question. It asks something quieter and, in a way, more useful: given how much room is left above a floor you've promised yourself you won't cross, how much should actually be at risk right now? The idea comes out of 1980s portfolio insurance, originally built for pension funds and mutual funds that wanted a shot at equity gains without any real chance of ending up below a guaranteed value. The original theory wasn't written with MetaTrader 5, gold, or M5 charts in mind. Porting it into an EA means being explicit about what carries over and what does not.
The mechanics are almost suspiciously simple once you see them. You pick a floor, the lowest your account is ever allowed to be worth. Everything above that floor is the cushion, and a fixed multiplier turns the cushion into how much exposure the risky asset is allowed to have. When the cushion shrinks, exposure comes down with it. When it grows, exposure grows too. That's really the whole rule. No forecast, no indicator, no opinion about where gold is headed next week — just a running answer to "how much room do we have left."
One thing that rule doesn't do, worth naming up front: it's a long-only sizing model. CPPI decides how much of the risky asset to hold, not whether to go long or short — there's no mechanism here for expressing a bearish view. "No directional forecast" means it never bets on where price goes next, not that it's neutral about direction in its exposure.
What follows is that rule built as three files: CPPI_Core.mqh, which holds both the stateless math and the trading engine that acts on it; the EA that wires the engine into an account; and an indicator that shows the same math running on price history alone. I've leaned hard on the code to do the explaining here rather than paragraphs describing what the code does — most functions are shown in full below; a couple of short housekeeping ones aren't, and I say so when they come up.

Fig. 1. The CPPI decision loop
Runs on every tick: read equity, recompute the floor and cushion, size the target notional, then either rebalance the risky position or flatten and lock.
Contents
- What CPPI promises, and what changes inside MetaTrader 5
- The floor, the cushion and the multiplier: four formulas
- CCppiMath and the CPPI state contract, in full
- CCppiEngine: the fields that carry the whole strategy
- Init() and Start(): wiring configuration and capturing V0
- OnNewTick(): the decision made on every single tick
- From a target notional to a real order
- Cash lock, gap risk, and reading out a snapshot
- The Expert Advisor: OnInit, OnTick, and the CSV trail
- The indicator: an honest, price-only reference simulation
- Edge cases and pitfalls
- Testing the mechanism
- Testing on other instruments
- Conclusion
What CPPI promises, and what changes inside MetaTrader 5
Textbook CPPI splits money between a risky asset and a risk-free bond, and rebalances continuously between them. That bond leg is doing real work: money sitting in it grows at the risk-free rate and, by definition, is never exposed to loss. MetaTrader doesn't give you a bond leg. An account either holds a position or it holds uninvested equity, and uninvested equity just sits there earning nothing unless your broker happens to pay swap on it. So "safe" in this article means one thing only: not being in the market. That's a real simplification and I'd rather say so here than let it hide quietly inside the code. It means the floor holds up as long as flat equity doesn't lose value between rebalances, which is a fair assumption, but it also means nothing is compounding the floor upward the way a real bond leg would over a multi-year horizon.
Continuous rebalancing doesn't survive the trip either. The academic version assumes you can trade instantly, all the time. This EA evaluates once per tick and rebalances only when the gap between target exposure and the open position exceeds a band. On M5 gold, reacting to every tick would mostly pay spread without improving exposure control. I'm not hiding either of these compromises — they're right there in the code: in the m_rebalance_band_pct field and in the fact that CCppiMath simply has no term for bond yield at all.
The floor, the cushion and the multiplier: four formulas
Four formulas do all the work. The floor F_t starts life as a fraction of your starting capital, and can optionally ratchet upward with a fraction of the highest equity the account has ever reached, so a good run actually gets locked in instead of being handed back later. The cushion, C_t = max(V_t - F_t, 0), is the only money the strategy is allowed to risk at all. Multiply the cushion by m and cap it against a maximum leverage figure, and you get the target notional — the ceiling exists specifically so an ambitious multiplier can't quietly turn into unbounded leverage. And 1/m tells you the single-step drop the portfolio can absorb before the floor itself gets breached between rebalances. At m=3 that's 33%. Which is really the point of choosing m in the first place: it's not a knob you tune for a nicer-looking backtest, it's you deciding in advance how much of a gap you can live with.
CCppiMath and the CPPI state contract, in full
Everything from the previous section gets written down exactly once, as plain static functions: numbers go in, a number comes out, and that's the whole contract. CCppiMath doesn't know an account exists, doesn't know a symbol exists, and doesn't send a single order. That's deliberate. This separation is what allows the live EA and the price-only indicator to use identical math without sharing account state. Right next to it sit the lifecycle enum and the logging struct, since both are just different views of the same three numbers:
//+------------------------------------------------------------------+ //| CPPI engine lifecycle state | //+------------------------------------------------------------------+ enum ENUM_CPPI_STATE { CPPI_STATE_ACTIVE, // cushion positive, exposure tracked and rebalanced normally CPPI_STATE_CASH_LOCKED, // cushion hit zero once; flattened and permanently de-risked CPPI_STATE_ERROR // last rebalance or flatten attempt failed (trade/market error) }; //+------------------------------------------------------------------+ //| Snapshot of one CPPI decision point, used for logging/plotting | //+------------------------------------------------------------------+ struct SCppiSnapshot { datetime time; // timestamp this snapshot was taken double equity; // account equity V_t at snapshot time double floor_value; // floor F_t double cushion; // cushion C_t = max(equity - floor_value, 0) double target_notional; // m * cushion, clipped to max leverage double target_lots; // target_notional converted to a broker-legal lot size double actual_lots; // current open volume on the risky symbol ENUM_CPPI_STATE state; // engine state at snapshot time };
And here are those four formulas, exactly as described above, just in the form the compiler actually cares about:
//+------------------------------------------------------------------+ //| CCppiMath -- pure, stateless floor/cushion/exposure formulas | //| Every method here takes plain numbers in and returns a plain | //| number out. Nothing here touches the account, the trade server, | //| or symbol properties, which is what lets CCppiEngine (live | //| trading) and CPPI_Ind.mq5 (price-only visualization) share the | //| exact same math without sharing any account state. | //+------------------------------------------------------------------+ class CCppiMath { public: static double Floor(const double v0,const double floor_pct, const double peak_equity,const double ratchet_pct); static double Cushion(const double equity,const double floor_value); static double TargetNotional(const double cushion,const double multiplier, const double equity,const double max_leverage); static double MaxSingleStepDrawdown(const double multiplier); }; //+------------------------------------------------------------------+ //| CCppiMath::Floor | //+------------------------------------------------------------------+ double CCppiMath::Floor(const double v0,const double floor_pct, const double peak_equity,const double ratchet_pct) { //--- the base floor is a fixed fraction of the capital the strategy started with double base_floor = v0*floor_pct; //--- ratchet_pct<=0 means the ratchet feature is disabled -- floor never moves if(ratchet_pct <= 0.0) return(base_floor); //--- with the ratchet enabled the floor also tracks a fraction of the highest //--- equity ever seen, so realized gains get locked in and the floor can only rise double ratchet_floor = peak_equity*ratchet_pct; return(MathMax(base_floor,ratchet_floor)); } //+------------------------------------------------------------------+ //| CCppiMath::Cushion | //+------------------------------------------------------------------+ double CCppiMath::Cushion(const double equity,const double floor_value) { //--- the cushion is the only capital CPPI is allowed to put at risk; it can //--- never be negative, even on a tick where equity sits below the floor return(MathMax(equity - floor_value,0.0)); } //+------------------------------------------------------------------+ //| CCppiMath::TargetNotional | //+------------------------------------------------------------------+ double CCppiMath::TargetNotional(const double cushion,const double multiplier, const double equity,const double max_leverage) { //--- raw target is the textbook CPPI rule: multiplier times the cushion double raw = multiplier*cushion; //--- a hard ceiling on risky exposure regardless of what the multiplier wants, //--- so an aggressive multiplier can never silently turn into unlimited leverage double cap = equity*max_leverage; return(MathMin(MathMax(raw,0.0),cap)); } //+------------------------------------------------------------------+ //| CCppiMath::MaxSingleStepDrawdown | //+------------------------------------------------------------------+ double CCppiMath::MaxSingleStepDrawdown(const double multiplier) { //--- 1/m is the largest single-step drop the risky asset can take before the //--- cushion is wiped out between rebalances; CheckGapRisk() below uses it //--- as the threshold of its early-warning tier if(multiplier <= 0.0) return(0.0); return(1.0/multiplier); }
CCppiEngine: the fields that carry the whole strategy
This is where CPPI stops being a formula and starts being a trading engine. CCppiEngine owns a CTrade instance, the symbol it's trading, every configured parameter, and the running state — initial equity, peak equity, the current floor, and what things looked like on the previous tick, so CheckGapRisk() further down actually has a "before" to compare "now" against. I'd rather just show the member list than describe it: read it top to bottom and you've got the whole answer to "what does this thing need to remember."
//+------------------------------------------------------------------+ //| CCppiEngine -- live account-driven CPPI allocation engine | //| CCppiEngine drives one live CPPI allocation on a single symbol. | //| Start() records the account's initial equity as V0. OnNewTick() | //| reads live equity, updates the floor and cushion, and rebalances | //| the risky position toward multiplier*cushion whenever the drift | //| exceeds the configured band. Once the cushion hits zero the | //| engine flattens and locks -- it will not re-lever until Start() | //| is called again. | //+------------------------------------------------------------------+ class CCppiEngine { private: CTrade m_trade; // order-sending wrapper string m_symbol; // risky asset traded by this engine double m_multiplier; // CPPI multiplier m double m_floor_pct; // base floor as a fraction of initial equity V0 double m_ratchet_pct; // 0 disables ratchet; else floor tracks this fraction of peak equity double m_max_leverage; // hard cap on risky exposure as a multiple of equity double m_rebalance_band_pct; // minimum drift, as a fraction of equity, needed to trigger a rebalance ulong m_magic; // magic number stamped on every order this engine sends uint m_deviation_pts; // maximum acceptable slippage per order, in points string m_comment; // order comment stamped on every order double m_initial_equity; // V0, captured once by Start() double m_peak_equity; // highest equity observed since Start(), feeds the ratchet double m_floor; // current floor F_t double m_prev_equity; // equity as of the previous OnNewTick() call double m_prev_cushion; // cushion as of the previous OnNewTick() call ENUM_CPPI_STATE m_state; // current lifecycle state string m_last_error; // human-readable reason for the most recent failure double CurrentExposureLots(void); double NotionalToLots(const double notional,const double price,const double contract_size); bool Rebalance(const double target_lots); bool ReduceLongPosition(const double lots_to_close); bool FlattenAll(const string reason); bool CheckCashLock(const double cushion); void CheckGapRisk(const double equity,const double cushion); public: void Init(const string symbol,const double multiplier,const double floor_pct, const double ratchet_pct,const double max_leverage, const double rebalance_band_pct,const ulong magic, const uint deviation_pts,const string comment); void Start(const double v0); bool OnNewTick(void); void GetSnapshot(SCppiSnapshot &snap); ENUM_CPPI_STATE State(void) const { return(m_state); } double FloorValue(void) const { return(m_floor); } double PeakEquity(void) const { return(m_peak_equity); } string LastError(void) const { return(m_last_error); } };
Init() and Start(): wiring configuration and capturing V0
Init() doesn't do much, on purpose. It copies inputs into members and sets up CTrade, and skips validation entirely because that's the EA's job at OnInit(), not the engine's. Start() is the function that actually matters. It takes whatever equity the caller hands it as V0 — the EA reads that from AccountInfoDouble(ACCOUNT_EQUITY) in its own OnInit() and passes it in; Start() itself never touches the account — and keeps it as the number the base floor gets measured against for the rest of the run. It's also, and this is worth sitting with for a second, the only place in the entire engine where a previous CPPI_STATE_CASH_LOCKED ever gets cleared. In practice that usually means restarting the EA, but technically any later call to Start() — not only a restart — resets the lock.
//+------------------------------------------------------------------+ //| CCppiEngine::Init | //+------------------------------------------------------------------+ void CCppiEngine::Init(const string symbol,const double multiplier,const double floor_pct, const double ratchet_pct,const double max_leverage, const double rebalance_band_pct,const ulong magic, const uint deviation_pts,const string comment) { //--- copy every configuration input straight into member state; no validation //--- happens here on purpose -- the EA's OnInit() validates inputs before this //--- is ever called, which keeps this constructor-style method simple m_symbol = symbol; m_multiplier = multiplier; m_floor_pct = floor_pct; m_ratchet_pct = ratchet_pct; m_max_leverage = max_leverage; m_rebalance_band_pct = rebalance_band_pct; m_magic = magic; m_deviation_pts = deviation_pts; m_comment = comment; //--- runtime state starts empty until Start() is called m_initial_equity = 0.0; m_peak_equity = 0.0; m_floor = 0.0; m_prev_equity = 0.0; m_prev_cushion = 0.0; m_state = CPPI_STATE_ACTIVE; m_last_error = ""; //--- every order this engine ever sends carries the same magic number, //--- slippage tolerance and filling policy, configured once here m_trade.SetExpertMagicNumber(m_magic); m_trade.SetDeviationInPoints(m_deviation_pts); m_trade.SetTypeFillingBySymbol(m_symbol); } //+------------------------------------------------------------------+ //| CCppiEngine::Start | //+------------------------------------------------------------------+ void CCppiEngine::Start(const double v0) { //--- V0 is the capital base the floor percentage is measured against; the //--- ratchet also starts from here since nothing has run yet m_initial_equity = v0; m_peak_equity = v0; m_floor = CCppiMath::Floor(m_initial_equity,m_floor_pct,m_peak_equity,m_ratchet_pct); m_prev_equity = v0; m_prev_cushion = CCppiMath::Cushion(v0,m_floor); m_state = CPPI_STATE_ACTIVE; m_last_error = ""; //--- a restart always clears any previous lock -- this is the only place in //--- the engine where CPPI_STATE_CASH_LOCKED is ever reversed PrintFormat("CPPI engine started on %s: V0=%.2f floor=%.2f (floor_pct=%.2f, ratchet_pct=%.2f)", m_symbol,v0,m_floor,m_floor_pct,m_ratchet_pct); }
OnNewTick(): the decision made on every single tick
This is the function that actually runs the strategy, tick after tick. It pulls live equity, updates the peak and the floor, works out the cushion, checks for gap risk, checks the cash lock, and only after all that bothers computing a target lot size to compare against what's really open. Worth pointing out while you're reading it: notice how the comments sitting directly in the function body start at column zero, while the ones inside the two nested if blocks are indented to match their code. That's not sloppy formatting — it's the comment convention this article follows throughout, and it's easiest to see in a function with this much branching. One simplification worth flagging here: both the target-lot sizing and the drift check use the symbol's Ask price, even though a long position actually exits at Bid. On a tight spread like gold's that's a small bias, not a correctness bug, but it isn't zero either.
//+------------------------------------------------------------------+ //| CCppiEngine::OnNewTick | //+------------------------------------------------------------------+ bool CCppiEngine::OnNewTick(void) { //--- pull the account's live equity; this IS the portfolio value V_t in CPPI terms double equity = AccountInfoDouble(ACCOUNT_EQUITY); if(equity > m_peak_equity) m_peak_equity = equity; //--- recompute the floor first -- with the ratchet enabled it can only move up m_floor = CCppiMath::Floor(m_initial_equity,m_floor_pct,m_peak_equity,m_ratchet_pct); double cushion = CCppiMath::Cushion(equity,m_floor); //--- a gap that already breached the modelled tolerance is worth logging //--- before anything else happens this tick, win or lose CheckGapRisk(equity,cushion); //--- cash lock is checked before any rebalancing logic runs at all if(CheckCashLock(cushion)) { m_prev_equity = equity; m_prev_cushion = cushion; //--- true only if the lock actually took hold; a flatten that failed //--- reports false so the caller can see this step did not complete return(m_state == CPPI_STATE_CASH_LOCKED); } //--- once locked this engine stays locked until Start() is called again, //--- even if the cushion recovers on paper due to floating profit while flat if(m_state == CPPI_STATE_CASH_LOCKED) { m_prev_equity = equity; m_prev_cushion = cushion; return(true); } double target_notional = CCppiMath::TargetNotional(cushion,m_multiplier,equity,m_max_leverage); double price = SymbolInfoDouble(m_symbol,SYMBOL_ASK); double contract_size = SymbolInfoDouble(m_symbol,SYMBOL_TRADE_CONTRACT_SIZE); double target_lots = NotionalToLots(target_notional,price,contract_size); double current_lots = CurrentExposureLots(); //--- convert the lot drift back into a fraction of equity so the rebalance //--- band means the same thing regardless of account size or symbol price double drift_notional = MathAbs(target_lots - current_lots)*contract_size*price; double drift_pct = (equity > 0.0) ? drift_notional/equity : 0.0; bool rebalanced = true; if(drift_pct >= m_rebalance_band_pct) rebalanced = Rebalance(target_lots); m_prev_equity = equity; m_prev_cushion = cushion; return(rebalanced); }
From a target notional to a real order
Converting a notional amount into an actual position takes four functions. CurrentExposureLots() adds up open volume under this engine's magic number, but only POSITION_TYPE_BUY volume. NotionalToLots() always rounds down to the broker's legal step, since CPPI's whole safety case rests on never being more exposed than the formula asked for, and anything under the broker's minimum size just gets treated as flat. Rebalance() calls Buy() to grow the position, but shrinking it goes through ReduceLongPosition() instead of a bare Sell(). ReduceLongPosition() iterates through open positions and partially closes the relevant long tickets by the required volume. A plain Sell() nets correctly against the long on a netting account, but on a hedging account it opens a brand-new short sitting next to the long instead of reducing anything — exactly what this function is built to avoid, since the engine can't assume which account type it's running on.
//+------------------------------------------------------------------+ //| CCppiEngine::CurrentExposureLots | //+------------------------------------------------------------------+ double CCppiEngine::CurrentExposureLots(void) { double total_lots = 0.0; int total = PositionsTotal(); //--- long-only design: this loop sums only BUY positions under our magic //--- number on our symbol. Counting SELL volume here too would be wrong on //--- a hedging account, where a stray short can sit alongside our long -- //--- and this engine should never mistake that short for reduced exposure for(int i = 0; i < total; i++) { ulong ticket = PositionGetTicket(i); if(ticket == 0) continue; if(PositionGetString(POSITION_SYMBOL) != m_symbol) continue; if(PositionGetInteger(POSITION_MAGIC) != (long)m_magic) continue; if((ENUM_POSITION_TYPE)PositionGetInteger(POSITION_TYPE) != POSITION_TYPE_BUY) continue; total_lots += PositionGetDouble(POSITION_VOLUME); } return(total_lots); } //+------------------------------------------------------------------+ //| CCppiEngine::NotionalToLots | //+------------------------------------------------------------------+ double CCppiEngine::NotionalToLots(const double notional,const double price,const double contract_size) { if(price <= 0.0 || contract_size <= 0.0) return(0.0); double min_lot = SymbolInfoDouble(m_symbol,SYMBOL_VOLUME_MIN); double max_lot = SymbolInfoDouble(m_symbol,SYMBOL_VOLUME_MAX); double step = SymbolInfoDouble(m_symbol,SYMBOL_VOLUME_STEP); //--- round DOWN to the nearest legal step -- CPPI's whole safety argument //--- rests on never being MORE exposed than the formula actually calls for double raw_lots = notional/(price*contract_size); double lots = MathFloor(raw_lots/step)*step; lots = MathMax(0.0,MathMin(lots,max_lot)); //--- a target under the broker's minimum tradable size is not a small //--- position, it's an untradeable one -- treat it as flat rather than //--- silently rounding up past what the formula actually asked for if(lots > 0.0 && lots < min_lot) lots = 0.0; return(lots); } //+------------------------------------------------------------------+ //| CCppiEngine::Rebalance | //+------------------------------------------------------------------+ bool CCppiEngine::Rebalance(const double target_lots) { double current_lots = CurrentExposureLots(); double delta = target_lots - current_lots; double min_step = SymbolInfoDouble(m_symbol,SYMBOL_VOLUME_STEP); //--- a delta smaller than half a volume step is noise, not a real drift -- //--- acting on it would just generate broker commission for nothing if(MathAbs(delta) < min_step*0.5) return(true); bool ok = false; if(delta > 0.0) { //--- growing the risky position: Buy() adds straight on top of //--- whatever is already open, correctly on both a netting account //--- and a hedging account. The broker's own margin calculation is //--- consulted first, so an order the account cannot fund is refused //--- here with a clear reason instead of being sent and rejected double margin_needed = 0.0; double free_margin = AccountInfoDouble(ACCOUNT_MARGIN_FREE); double ask = SymbolInfoDouble(m_symbol,SYMBOL_ASK); if(!OrderCalcMargin(ORDER_TYPE_BUY,m_symbol,delta,ask,margin_needed) || margin_needed > free_margin) { m_last_error = "Rebalance skipped: margin check failed (required "+ DoubleToString(margin_needed,2)+", free "+DoubleToString(free_margin,2)+")"; m_state = CPPI_STATE_ERROR; Print(m_last_error); return(false); } ok = m_trade.Buy(delta,m_symbol,0.0,0.0,0.0,m_comment); } else { //--- shrinking it: close the requested volume directly off our own //--- known long ticket(s) rather than sending a bare Sell(). A bare //--- Sell() nets correctly on a netting account but on a hedging //--- account it opens a brand-new short next to the long instead of //--- reducing anything, which this engine can never allow ok = ReduceLongPosition(-delta); } if(!ok) { m_last_error = "Rebalance order failed: "+IntegerToString(m_trade.ResultRetcode())+ " "+m_trade.ResultRetcodeDescription(); m_state = CPPI_STATE_ERROR; Print(m_last_error); } else { if(m_state == CPPI_STATE_ERROR) m_state = CPPI_STATE_ACTIVE; // a clean fill clears a previous error state } return(ok); } //+------------------------------------------------------------------+ //| CCppiEngine::ReduceLongPosition | //+------------------------------------------------------------------+ bool CCppiEngine::ReduceLongPosition(const double lots_to_close) { double remaining = lots_to_close; int total = PositionsTotal(); //--- walk backwards for the same reason FlattenAll() does: closing a //--- ticket shifts every later index down by one for(int i = total - 1; i >= 0 && remaining > 0.0; i--) { ulong ticket = PositionGetTicket(i); if(ticket == 0) continue; if(PositionGetString(POSITION_SYMBOL) != m_symbol) continue; if(PositionGetInteger(POSITION_MAGIC) != (long)m_magic) continue; if((ENUM_POSITION_TYPE)PositionGetInteger(POSITION_TYPE) != POSITION_TYPE_BUY) continue; double vol = PositionGetDouble(POSITION_VOLUME); double close_vol = MathMin(vol,remaining); if(!m_trade.PositionClosePartial(ticket,close_vol)) { m_last_error = "ReduceLongPosition could not partial-close ticket "+IntegerToString(ticket)+ ": "+m_trade.ResultRetcodeDescription(); Print(m_last_error); return(false); } remaining -= close_vol; } //--- a tiny leftover here is floating-point noise, not a real shortfall return(remaining < 0.0000001); }
Cash lock, gap risk, and reading out a snapshot
CheckCashLock() only ever goes one direction. Once the cushion is depleted, everything gets flattened and the state flips to CPPI_STATE_CASH_LOCKED, and from then on OnNewTick() refuses to re-lever even if the cushion technically recovers. That isn't an oversight being patched over — it's the textbook CPPI weakness, and this engine lets it happen instead of quietly working around it. Two details matter in practice. First, "depleted" is 0.01% of V0 rather than a fixed dollar figure, so the rule means the same thing on a $500 account and on a $500,000 one. Second, the lock is declared only after FlattenAll() reports that every position under this engine's magic number is really closed. FlattenAll() isn't reproduced here; it walks the open positions backwards, keeps going past a stuck close, and returns false if any ticket survived. In that case the engine drops to CPPI_STATE_ERROR, skips rebalancing for the tick, returns false to the caller, and tries the flatten again on the next tick, so a failed close can never leave live exposure sitting behind a state that says it is gone.
CheckGapRisk(), by contrast, doesn't act on anything at all — it only logs, and it logs in two tiers. The breach tier is the literal model failure: at exposure = m×cushion, a fall of more than 1/m in the risky asset costs more than the whole previous cushion, so a tick whose equity drop exceeds that cushion is reported as CPPI GAP BREACH. The warning tier fires earlier, once the drop passes just 1/m of the previous cushion, and is reported as CPPI GAP RISK. That earlier trigger is a deliberate safety margin, not an exact measurement. Neither tier is exact: both read whole-account equity rather than this symbol's own price, and a capped or lot-rounded exposure moves the true breach point away from exactly 1/m. GetSnapshot() is the boring one: every field, floor included, is recomputed from live account and symbol data on each call, taking the larger of the stored peak and current equity without writing anything back into the engine. A snapshot therefore never depends on OnNewTick() having run first.
//+------------------------------------------------------------------+ //| CCppiEngine::CheckCashLock | //+------------------------------------------------------------------+ bool CCppiEngine::CheckCashLock(const double cushion) { //--- "depleted" is a fraction of V0 rather than a fixed dollar amount, so the //--- same rule means the same thing on a small account and on a large one const double eps = m_initial_equity*0.0001; // cushion at or below 0.01% of V0 counts as depleted if(cushion > eps) return(false); if(m_state == CPPI_STATE_CASH_LOCKED) return(true); // already locked from an earlier tick, nothing new to do //--- first time the cushion has been seen at zero: try to flatten everything. //--- The lock is declared only once every position is really closed. If any //--- close fails the engine goes to ERROR instead, skips rebalancing this //--- tick, and tries the flatten again on the next one if(!FlattenAll("cushion depleted - entering cash lock")) { m_state = CPPI_STATE_ERROR; m_last_error = "Cash lock flatten incomplete ("+m_last_error+"), retrying on the next tick"; Print(m_last_error); return(true); } m_state = CPPI_STATE_CASH_LOCKED; PrintFormat("CPPI engine on %s entered CASH_LOCKED (cushion=%.2f)",m_symbol,cushion); return(true); } //+------------------------------------------------------------------+ //| CCppiEngine::CheckGapRisk | //+------------------------------------------------------------------+ void CCppiEngine::CheckGapRisk(const double equity,const double cushion) { //--- nothing to compare against on the very first tick after Start(), or //--- when the previous tick had no cushion left to lose if(m_prev_equity <= 0.0 || m_prev_cushion <= 0.0) return; double tolerance = CCppiMath::MaxSingleStepDrawdown(m_multiplier); double drop = m_prev_equity - equity; //--- two tiers, both log-only. The breach tier is the literal model failure: //--- at exposure = m*cushion a risky-asset fall of more than 1/m costs more //--- than the whole previous cushion, i.e. drop > m_prev_cushion. The warning //--- tier fires earlier, at just 1/m of that cushion, as a conservative margin. //--- Both read whole-account equity rather than this symbol's own price, and //--- a capped or lot-rounded exposure moves the true breach point away from //--- exactly 1/m, so treat them as indicators, not exact measurements if(drop > tolerance*m_prev_cushion) { PrintFormat("CPPI GAP RISK on %s: equity dropped %.2f in one tick, exceeding the "+ "%.0f%% single-step tolerance on a %.2f cushion", m_symbol,drop,tolerance*100.0,m_prev_cushion); } if(drop > m_prev_cushion) { PrintFormat("CPPI GAP BREACH on %s: equity dropped %.2f in one tick, more than "+ "the entire previous cushion of %.2f",m_symbol,drop,m_prev_cushion); } } //+------------------------------------------------------------------+ //| CCppiEngine::GetSnapshot | //+------------------------------------------------------------------+ void CCppiEngine::GetSnapshot(SCppiSnapshot &snap) { //--- every field is recomputed from live account and symbol data, floor //--- included, so a snapshot never depends on OnNewTick() having run first. //--- The peak is taken as the larger of the stored peak and current equity, //--- without writing anything back into the engine's own state snap.time = TimeCurrent(); snap.equity = AccountInfoDouble(ACCOUNT_EQUITY); double peak = MathMax(m_peak_equity,snap.equity); snap.floor_value = CCppiMath::Floor(m_initial_equity,m_floor_pct,peak,m_ratchet_pct); snap.cushion = CCppiMath::Cushion(snap.equity,snap.floor_value); snap.target_notional = CCppiMath::TargetNotional(snap.cushion,m_multiplier,snap.equity,m_max_leverage); double price = SymbolInfoDouble(m_symbol,SYMBOL_ASK); double contract_size = SymbolInfoDouble(m_symbol,SYMBOL_TRADE_CONTRACT_SIZE); snap.target_lots = NotionalToLots(snap.target_notional,price,contract_size); snap.actual_lots = CurrentExposureLots(); snap.state = m_state; }
The Expert Advisor: OnInit, OnTick, and the CSV trail
CPPI_EA.mq5 stays thin on purpose. Every rule already lives in the include file, so this file only has to validate inputs, start the engine once, and call it on every tick. ValidateInputs() is nothing more than a wall of guards, but each one is there for a reason: it's stopping a bad value from ever reaching a formula in CCppiMath that was never built to handle it.
//+------------------------------------------------------------------+ //| Validate inputs before the engine is allowed to start | //+------------------------------------------------------------------+ bool ValidateInputs() { //--- every one of these guards protects a division or a formula elsewhere //--- in CCppiMath from being handed a value it was never designed for if(InpMultiplier <= 0.0) { Print("CPPI_EA: InpMultiplier must be > 0"); return(false); } if(InpFloorPct <= 0.0 || InpFloorPct > 1.0) { Print("CPPI_EA: InpFloorPct must be in (0, 1]"); return(false); } if(InpRatchetPct < 0.0 || InpRatchetPct > 1.0) { Print("CPPI_EA: InpRatchetPct must be in [0, 1]"); return(false); } if(InpMaxLeveragePct <= 0.0) { Print("CPPI_EA: InpMaxLeveragePct must be > 0"); return(false); } if(InpRebalanceBandPct <= 0.0) { Print("CPPI_EA: InpRebalanceBandPct must be > 0"); return(false); } if(!SymbolSelect(g_symbol,true)) { PrintFormat("CPPI_EA: could not select symbol %s in Market Watch",g_symbol); return(false); } return(true); }
From there, OnInit() resolves the symbol, runs validation, wires the engine, starts it from whatever equity the account currently has, and opens a CSV log through FILE_COMMON — that particular flag matters because it keeps the log reachable in the same spot whether this is running on a live terminal or inside a Strategy Tester agent:
//+------------------------------------------------------------------+ //| Expert initialization function | //+------------------------------------------------------------------+ int OnInit() { //--- an empty InpSymbol means "trade whatever chart this EA is attached to" g_symbol = (InpSymbol == "") ? _Symbol : InpSymbol; if(!ValidateInputs()) return(INIT_PARAMETERS_INCORRECT); //--- wire every validated input into the engine, then start it from the //--- account's CURRENT equity -- that becomes V0 for this run g_engine.Init(g_symbol,InpMultiplier,InpFloorPct,InpRatchetPct,InpMaxLeveragePct, InpRebalanceBandPct,InpMagic,InpDeviationPoints,"CPPI_FloorGuard"); double v0 = AccountInfoDouble(ACCOUNT_EQUITY); g_engine.Start(v0); g_last_bar_time = 0; g_last_warn_bar = 0; g_csv_handle = INVALID_HANDLE; if(InpEnableCsvLog) { //--- FILE_COMMON keeps the log reachable in the same place whether this //--- runs on a live terminal or inside a Strategy Tester agent g_csv_handle = FileOpen(InpCsvFileName,FILE_WRITE|FILE_CSV|FILE_COMMON|FILE_ANSI,","); if(g_csv_handle == INVALID_HANDLE) PrintFormat("CPPI_EA: could not open CSV log %s (error %d)",InpCsvFileName,GetLastError()); else FileWrite(g_csv_handle,"time","equity","floor","cushion", "target_notional","target_lots","actual_lots","state"); } return(INIT_SUCCEEDED); }
OnDeinit() doesn't do anything dramatic, just closes the file handle and prints where the engine landed. LogSnapshot() writes a single CSV row: time, equity, floor, cushion, target lots, actual lots, state. That row is what turns the testing section further down from a screenshot you have to trust into something you can actually go and reconstruct:
//+------------------------------------------------------------------+ //| Expert deinitialization function | //+------------------------------------------------------------------+ void OnDeinit(const int reason) { if(g_csv_handle != INVALID_HANDLE) FileClose(g_csv_handle); PrintFormat("CPPI_EA: stopped (reason=%d). Final state=%d, floor=%.2f, peak_equity=%.2f", reason,(int)g_engine.State(),g_engine.FloorValue(),g_engine.PeakEquity()); } //+------------------------------------------------------------------+ //| Write one CSV row for the current CPPI snapshot | //+------------------------------------------------------------------+ void LogSnapshot() { if(g_csv_handle == INVALID_HANDLE) return; SCppiSnapshot snap; g_engine.GetSnapshot(snap); FileWrite(g_csv_handle,TimeToString(snap.time,TIME_DATE|TIME_SECONDS), DoubleToString(snap.equity,2),DoubleToString(snap.floor_value,2), DoubleToString(snap.cushion,2),DoubleToString(snap.target_notional,2), DoubleToString(snap.target_lots,2),DoubleToString(snap.actual_lots,2), (int)snap.state); }
And then there's OnTick() itself, which stays thin because everything that matters happens inside the engine. The one thing it adds is visibility: OnNewTick() returns false when a step did not complete (a failed order, a failed flatten, a refused margin check), and the EA turns that into an alert, at most once per bar. It doesn't try to recover on its own, because the engine already retries on the next tick:
//+------------------------------------------------------------------+ //| Expert tick function | //+------------------------------------------------------------------+ void OnTick() { //--- every tick goes through the engine -- floor/cushion/rebalance logic //--- all lives in CPPI_Core.mqh, this function only wires it to the chart. //--- A false return means this step did not complete (a failed order, a //--- failed flatten or a refused margin check). The engine retries on its //--- own next tick; this function adds a visible alert, at most once a bar bool engine_ok = g_engine.OnNewTick(); datetime bar_time = iTime(g_symbol,PERIOD_CURRENT,0); if(!engine_ok && bar_time != g_last_warn_bar) { g_last_warn_bar = bar_time; Alert("CPPI_EA: engine step failed (state=",(int)g_engine.State(),"): ",g_engine.LastError()); } SCppiSnapshot snap; g_engine.GetSnapshot(snap); Comment(StringFormat("CPPI_FloorGuard %s\nEquity: %.2f Floor: %.2f Cushion: %.2f\n"+ "Target lots: %.2f Actual lots: %.2f State: %d", g_symbol,snap.equity,snap.floor_value,snap.cushion, snap.target_lots,snap.actual_lots,(int)snap.state)); //--- the CSV log is written once per new bar rather than every tick, which //--- keeps the file a manageable size while still giving a bar-by-bar //--- equity/floor/cushion trail -- rebalancing itself still happens every //--- tick, so this reconstructs the path at bar resolution, not tick by tick if(bar_time != g_last_bar_time) { g_last_bar_time = bar_time; LogSnapshot(); } }
The indicator: an honest, price-only reference simulation
There's a real limitation here and I'd rather name it than quietly work around it. An indicator can see price history. It cannot see the EA's actual account equity history, because MetaTrader simply doesn't store equity as a per-bar timeseries the way it stores OHLC. So CPPI_Ind.mq5 makes no attempt to redraw the live EA's real curve — that would be pretending to know something it can't. Instead it runs its own hypothetical simulation: starting from InpInitialCapital, growing a risky sleeve using the chart symbol's own bar-to-bar returns, and rebalancing with the exact same CCppiMath functions the EA uses. Its own file header says this outright, which felt more honest than leaving it implied. And because it's a simulation rather than a replay of real trading, it also skips everything that makes the EA's own numbers messier: spread, commission, slippage, the broker's lot step and minimum lot, any trade that could fail, margin, and the tick-by-tick path within a bar. None of that is modeled here, on purpose — this indicator answers "what would this allocation rule have done to this price series," nothing more. That makes it fine for illustrating the mathematics and limited as a check on Tester or live behavior:
#property copyright "Copyright 2026, Adewumi Gbadebo Babatunde" #property link "https://www.mql5.com" #property version "1.00" #property description "Visualizes a hypothetical CPPI allocation on the chart symbol's" #property description "own price history, reusing CCppiMath unmodified from CPPI_Core.mqh." #property description "It never touches the account or sends orders -- it is a reference" #property description "simulation, not the live EA's actual equity curve." #property strict #property indicator_separate_window #property indicator_buffers 4 #property indicator_plots 4 #property indicator_label1 "Simulated Equity" #property indicator_type1 DRAW_LINE #property indicator_color1 clrDodgerBlue #property indicator_width1 2 #property indicator_label2 "Floor" #property indicator_type2 DRAW_LINE #property indicator_color2 clrCrimson #property indicator_style2 STYLE_DASH #property indicator_label3 "Cushion" #property indicator_type3 DRAW_LINE #property indicator_color3 clrLimeGreen #property indicator_label4 "Cash-Locked" #property indicator_type4 DRAW_HISTOGRAM #property indicator_color4 clrOrange #property indicator_width4 3 //+------------------------------------------------------------------+ //| Includes | //+------------------------------------------------------------------+ #include <CPPI_FloorGuard/CPPI_Core.mqh> // CCppiMath -- shared, unmodified floor/cushion/exposure math //+------------------------------------------------------------------+ //| Input parameters -- mirrors the EA's CPPI configuration | //+------------------------------------------------------------------+ input double InpInitialCapital = 10000.0; // Hypothetical starting capital for this simulation input double InpMultiplier = 3.0; // CPPI multiplier m (should match the EA for comparison) input double InpFloorPct = 0.90; // Floor as a fraction of InpInitialCapital input double InpRatchetPct = 0.0; // Ratchet floor fraction of peak equity (0 = off) input double InpMaxLeveragePct = 1.0; // Max risky exposure as a multiple of equity //+------------------------------------------------------------------+ //| Indicator buffers | //+------------------------------------------------------------------+ double EquityBuffer[]; // simulated portfolio value V_t double FloorBuffer[]; // simulated floor F_t double CushionBuffer[]; // simulated cushion C_t double LockBuffer[]; // equals EquityBuffer on cash-locked bars, else EMPTY_VALUE
Not much to say about OnInit() here — it binds the four buffers and marks them as non-timeseries, since OnCalculate() fills them oldest-to-newest rather than the other way round:
//+------------------------------------------------------------------+ //| Custom indicator initialization function | //+------------------------------------------------------------------+ int OnInit() { SetIndexBuffer(0,EquityBuffer,INDICATOR_DATA); SetIndexBuffer(1,FloorBuffer,INDICATOR_DATA); SetIndexBuffer(2,CushionBuffer,INDICATOR_DATA); SetIndexBuffer(3,LockBuffer,INDICATOR_DATA); //--- all four buffers are built oldest-to-newest inside OnCalculate, //--- so none of them should be treated as a MQL5 timeseries array ArraySetAsSeries(EquityBuffer,false); ArraySetAsSeries(FloorBuffer,false); ArraySetAsSeries(CushionBuffer,false); ArraySetAsSeries(LockBuffer,false); PlotIndexSetDouble(3,PLOT_EMPTY_VALUE,EMPTY_VALUE); IndicatorSetString(INDICATOR_SHORTNAME, StringFormat("CPPI Sim (m=%.1f, floor=%.0f%%, ratchet=%.0f%%)", InpMultiplier,InpFloorPct*100.0,InpRatchetPct*100.0)); return(INIT_SUCCEEDED); }
OnCalculate() is a different story. Bar 500's allocation depends on every single bar before it, so there's no way to resume partway through — it resets and replays the whole visible history on every call instead:
//+------------------------------------------------------------------+ //| Custom indicator iteration function | //+------------------------------------------------------------------+ int OnCalculate(const int rates_total,const int prev_calculated,const datetime &time[], const double &open[],const double &high[],const double &low[], const double &close[],const long &tick_volume[],const long &volume[], const int &spread[]) { if(rates_total < 2) return(0); //--- this is a pure price-return simulation, not the EA's real account //--- equity, so it is fully path-dependent on CLOSE[] alone: it cannot be //--- resumed partway through, only recomputed -- every call replays the //--- whole visible history fresh double risky_alloc = 0.0; double safe_alloc = InpInitialCapital; double peak_equity = InpInitialCapital; bool locked = false; EquityBuffer[0] = InpInitialCapital; FloorBuffer[0] = CCppiMath::Floor(InpInitialCapital,InpFloorPct,peak_equity,InpRatchetPct); CushionBuffer[0] = CCppiMath::Cushion(InpInitialCapital,FloorBuffer[0]); LockBuffer[0] = EMPTY_VALUE; for(int i = 1; i < rates_total; i++) { //--- grow (or shrink) only the risky sleeve by this bar's return; the //--- safe sleeve is modelled at a flat 0% yield, a simplification the //--- article states plainly rather than leaving implicit in the code double bar_return = (close[i-1] > 0.0) ? (close[i]/close[i-1] - 1.0) : 0.0; risky_alloc *= (1.0 + bar_return); double equity = risky_alloc + safe_alloc; if(equity > peak_equity) peak_equity = equity; double floor_value = CCppiMath::Floor(InpInitialCapital,InpFloorPct,peak_equity,InpRatchetPct); double cushion = CCppiMath::Cushion(equity,floor_value); if(cushion <= 1.0) { //--- once locked in this replay it stays locked for the rest of the //--- series, mirroring CCppiEngine's real refuse-to-re-lever rule locked = true; risky_alloc = 0.0; safe_alloc = equity; } else if(!locked) { //--- frictionless, instantaneous rebalance every bar: no spread, //--- commission or slippage is modelled here, unlike the live EA double target = CCppiMath::TargetNotional(cushion,InpMultiplier,equity,InpMaxLeveragePct); risky_alloc = target; safe_alloc = equity - target; } EquityBuffer[i] = equity; FloorBuffer[i] = floor_value; CushionBuffer[i] = cushion; LockBuffer[i] = locked ? equity : EMPTY_VALUE; } return(rates_total); }
Edge cases and pitfalls
Don't fit the multiplier to a backtest. It's tempting to run five values of m and keep whichever equity curve looks nicest, but that defeats the whole point of the number. m encodes how much single-step drawdown you're willing to let a gap inflict on your cushion. Pick it from that answer, not from whichever curve happened to look best in hindsight.
This works on hedging accounts, not just netting ones. A plain Sell() to shrink exposure nets correctly against an existing long on a netting account, but on a hedging account it opens a second, offsetting position instead of actually reducing anything — a real risk on any hedging account, MetaQuotes-Demo included. ReduceLongPosition() closes the known long ticket directly instead, which is correct on both account types.
The minimum tradable lot sets a floor on how small this account can be. Clearing gold's minimum 0.01 lot at a 100 oz contract size takes roughly one dollar of notional per dollar of gold's price — near $4,700/oz that's about $4,700 of target notional just to place a single trade. With m=3, the cushion alone needs to be north of $1,500, which a $10,000 account with a 90% floor doesn't clear (its cushion caps at $1,000). This isn't a code bug; NotionalToLots() is doing exactly what it's supposed to do by refusing to round up. It does mean the floor and account size need to be sized together, not chosen independently.
There's no universally right rebalance band. A tight band tracks the target notional closely but pays the spread more often. A wide one saves on transaction cost but lets real exposure drift further from wherever the formula currently thinks it should be. That's genuinely a symbol-by-symbol call, not something with a correct default.
The cash lock stays permanent on purpose. It would be easy to add a quiet re-lever rule once the cushion recovers, and I thought about it. But that's a different strategy wearing CPPI's formulas, not CPPI. Leaving the lock in place until someone deliberately calls Start() again is this article's way of not smoothing over the strategy's best-known weakness.
Equity is read for the whole account, not just this symbol. ACCOUNT_EQUITY includes every open position, commission, swap and manual trade on the account — not just XAUUSD. Run this EA alongside anything else trading the same account and its floor, cushion, target exposure and gap checks will react to profit and loss that has nothing to do with the symbol it's actually managing. This engine assumes it owns the whole account. The practical fix is to give it a dedicated account; isolating a strategy "sleeve" inside a shared one would need position-level P&L tracking that this version doesn't have.
Max leverage caps notional, not margin. m_max_leverage limits how large the target notional is allowed to get relative to equity. Before every purchase, Rebalance() asks the broker's own OrderCalcMargin() what the added volume requires and compares it with free margin; if the account can't fund it, the order isn't sent, the engine drops to CPPI_STATE_ERROR, and the reason is logged. That catches an order the account cannot pay for. It doesn't model the stop-out level, the margin level after the fill, or margin changes caused by price moves later, so an account running close to its margin still needs its own safeguards.
Testing the mechanism
Two things about sizing this run are worth knowing before the numbers below. The account size and floor need to be chosen together, not independently: a $10,000 account with a 90% floor would be too small to clear gold's minimum lot at current prices, since the cushion it produces caps out below what a single 0.01-lot trade actually costs. And the engine needs to handle both netting and hedging accounts correctly, which is exactly what ReduceLongPosition() is for. Both points are covered in the edge cases above; the run below is sized and configured to actually trade.
The first run is on gold, the instrument the sizing discussion above is built around: XAUUSD M5, 2026.04.01 through 2026.09.04, MetaQuotes-Demo, every tick based on real ticks, $50,000 deposit, 1:100 leverage, m=3 and floor at 90% (a $45,000 floor). 29,217 bars, just under 60 million ticks, and zero errors in the journal across the whole run — which confirms the engine never hit a failed order or a blocked trade, nothing more. It isn't proof that the model, the sizing, or the floor protection itself is correct; those are separate questions, and the numbers below speak to them more directly than the journal does.
The results below are taken directly from the per-bar CSV log produced by LogSnapshot(). Equity ranged from $48,313 to $50,637 and finished at $49,250 — a small net loss over five months, and never once closer than about $3,300 to the $45,000 floor. The cushion never hit zero, so the engine never cash-locked; it stayed in CPPI_STATE_ACTIVE for all 29,217 bars, so the cash-lock and failed-flatten paths described above never ran. It rebalanced 23 times, always in 0.01-lot steps between 0.02 and 0.03 lots, which is exactly the granularity you'd expect from a $50,000 account trading gold's minimum tradable increment. I've also plotted buy-and-hold on the same starting capital below, reconstructed from the same real price action the EA traded against, but the two lines are not risk-equivalent. CPPI held 0.02–0.03 lots; the buy-and-hold line puts the entire $50,000 into gold, which at roughly $4,670 per ounce and a 100-ounce contract is about 0.11 lots. Buy-and-hold fell as low as $42,077 on June 30th, below CPPI's own floor, while CPPI's equity sat at $48,313 at that moment. A rough scaling shows how much of that gap is just position size: shrink buy-and-hold's $7,923 drop to 0.02–0.03 lots and it becomes about $1,500–$2,200, in the same range as CPPI's own $1,687 dip. That is back-of-envelope arithmetic, not a re-plotted curve, but it means this chart cannot show that the floor mechanism, rather than the smaller position, is what separated the two lines. What it does show is that the floor was never approached in this window, on a real Strategy Tester run against real historical tick data, not a live account. Comparing two allocation methods fairly would need both sized to the same risk budget, which this run doesn't do.

Fig. 2. CPPI equity against buy-and-hold, XAUUSD M5
Real MetaTrader 5 Strategy Tester run, 2026.04.01–2026.09.04, MetaQuotes-Demo, $50,000 deposit, m=3, floor at 90% of starting capital. Buy-and-hold is reconstructed from the same real price action.
Fig. 3 checks the other half of the theory: how close any single bar came to tripping CheckGapRisk()'s warning threshold. Across 14,702 downside bars, the worst one used up 13.7% of that threshold. At m=3 that is about 4.6% of the whole previous cushion, so the breach tier never came close either. Put together, that means across five months of real M5 gold data, ordinary volatility never came near the kind of move this design structurally can't protect against. That's a real result, not a reassurance: it says this particular five-month stretch of gold, on this one broker, didn't contain the gap that would have tested the strategy's actual weak point — not that the weak point isn't real, or that a different period, instrument, or venue couldn't still find it. The next section runs the same configuration on four other instruments.

Fig. 3. Per-bar equity drops against the gap-risk threshold
Real per-bar equity drops from the same Strategy Tester run, each expressed as a fraction of that bar’s CheckGapRisk() warning threshold (1/m of the previous cushion). The dashed line at 1.0 is the point that would trigger a gap-risk warning.
Testing on other instruments
Gold has a character of its own: wide ranges, busy sessions, a 100-ounce contract. One instrument like that is a narrow basis for judging a method, so the same run is repeated on four more: EURUSD, GBPUSD, AUDUSD and the S&P 500 index CFD, which this broker lists as US500. Only the symbol changed between runs. Every run used M5, 2026.04.01 through 2026.09.04, MetaQuotes-Demo, every tick based on real ticks, a $50,000 deposit, 1:100 leverage, m=3, a 90% floor, the default 5% rebalance band and its own CSV file. All four instruments are quoted in US dollars, which the lot-sizing code relies on (more on that below). Gold is the first row of the table for reference.
| Symbol | Lots held | Rebalances | Equity low / high ($) | Final balance ($) | Min cushion ($) | Worst gap ratio |
|---|---|---|---|---|---|---|
| XAUUSD | 0.02 - 0.03 | 23 | 48,313 / 50,637 | 49,250 (-1.5%) | 3,313 | 0.137 |
| US500 | 2.3 - 3.1 | 2 | 49,866 / 53,146 | 52,890 (+5.8%) | 4,866 | 0.089 |
| AUDUSD | 0.21 | 0 | 49,913 / 50,843 | 50,828 (+1.7%) | 4,913 | 0.103 |
| GBPUSD | 0.11 | 0 | 49,894 / 50,478 | 50,312 (+0.6%) | 4,894 | 0.053 |
| EURUSD | 0.12 | 0 | 49,719 / 50,351 | 50,073 (+0.1%) | 4,719 | 0.041 |
Equity low and high come from the per-bar CSV, so a spike inside a bar can go slightly past them. Final balances come from the tester's Journal after the forced close at the end of the test, and they include whatever overnight swap the tester charged or credited. The worst gap ratio is the largest single-bar equity drop as a fraction of the warning threshold in CheckGapRisk(), the same measure as Fig. 3, so a value of 1.0 would have triggered a warning. Bar counts differ with trading hours: 32,256 M5 bars for each currency pair, 30,426 for the index and 29,217 for gold.
The first thing the table says is that nothing came close to the floor. The smallest cushion on any instrument was $3,313, on gold, out of the $5,000 the run started with. No run cash-locked, and every logged bar on every instrument was in CPPI_STATE_ACTIVE, so none of them ever entered the error state either. The worst bar anywhere used 14% of the gap threshold. Gold was also the only instrument that finished below its starting equity. The other four ended between +0.1% and +5.8%.
The column that tells you more is Rebalances. Gold rebalanced 23 times and US500 twice. EURUSD, GBPUSD and AUDUSD never rebalanced at all: each bought once on the first bar and held that size to the end of the test. With m=3 and a 90% floor, the target is about 30% of equity, and the 5% band means the target has to drift by roughly $2,500 of notional away from what is held before the engine acts. Currency pairs didn't move enough in five months to do that. AUDUSD came closest, with a target of 0.24 lots against 0.21 held, a gap of about 4% of equity. On those three pairs the test says very little about the rebalancing logic. It does confirm the entry, the sizing against a USD quote, the CSV trail and the distance to the floor, and it shows that on a quiet pair this configuration behaves like a fixed long position of about 30% of equity, with the band, not m, deciding that. A tighter band would have made the engine trade on currency pairs. I kept the configuration identical so the symbol is the only variable, and I haven't tested a tighter band here.
US500 is the closest thing in this set to a second test of the mechanism. The index climbed from about 6,521 to 7,739 over the window. CPPI opened at 2.3 lots, added 0.4 lots on April 17 and another 0.4 on August 5 as the cushion grew, and finished at +5.8%, taking part of the move at an exposure of roughly 30% of equity. That is the formulas doing what they describe: exposure rises after gains because the cushion rose. An index is also where a real gap is most likely, since it closes overnight and at weekends. The worst bar in the whole run was the first one after a weekend close, on Monday April 13, and it used 9% of the warning threshold. Gold's worst was 14%.

Fig. 4. Equity of five instruments against the floor
Equity of the five runs as a percentage change from the $50,000 starting equity, built from each run's per-bar CSV log. The dashed line marks the floor at 90% of starting equity.
Fig. 4 puts the five curves on one axis, with the floor drawn at -10%. The gap between any curve and that dashed line is what the design is meant to protect, and in this sample it never narrowed below about 6.6 points, which was gold's low of -3.4%.
One limitation of the code shows up as soon as you leave USD-quoted symbols. NotionalToLots() turns a target notional into lots by dividing it by price times contract size, and that gives the right lot count only when the instrument's quote currency is the account currency, USD in these runs. On USDJPY the same division works in yen and returns a tiny fraction of the intended size. On USDCHF or USDCAD it is off by the exchange rate, and a cross such as EURGBP has the same problem. The EA neither converts nor warns, so keep it on instruments quoted in the account currency, or add a conversion rate before using it anywhere else. US500 is a useful check that the arithmetic holds on an index: 2.3 lots at about 6,521 works out to roughly $15,000 of notional for a contract size of 1, which is what m=3 on a $5,000 cushion asks for.
None of this is a claim that the strategy is profitable, or that it will look like this again next quarter. It's one five-month window against one broker's tick data, at one configuration, on five instruments that are all quoted in US dollars, and on none of them did the cushion come close to running out. That means the cash-lock path still hasn't been exercised by a real run, and a market with a genuine gap would be the way to test it. The results are shown here exactly as the Strategy Tester produced them.
Conclusion
What makes CPPI worth building at all is that it never has to be right about direction. It just needs an honest floor and an honest cushion, and everything past that is arithmetic. Getting it into MQL5 meant losing the parts that simply don't exist in a trading account — a bond leg, true continuous rebalancing — and I'd rather say so plainly than pretend the gap isn't there. What's left, once you account for both, is a small engine that's easy to actually read: four static formulas, one class that turns them into real orders, a thin EA, and an indicator that's honest about what it can and can't see.
If you take this further, start with the multiplier and nothing else. Decide the maximum single-step drawdown you're genuinely willing to let a gap cause, run CPPI_EA.mq5 on a demo account long enough for the CSV log to fill up, then actually read that log before you touch the multiplier again. The formulas can't tell you whether the answer you picked is good. All they can tell you is whether the engine did precisely what they said it would.
| File | Type | Description |
|---|---|---|
| CPPI_Core.mqh | Library | Shared include: ENUM_CPPI_STATE, SCppiSnapshot, CCppiMath (stateless floor/cushion/exposure formulas), and CCppiEngine (live account-driven rebalancing, cash-lock and gap-risk logic). |
| CPPI_EA.mq5 | Expert Advisor | Validates inputs, starts CCppiEngine from current account equity, calls it every tick, shows a live chart comment, and writes a per-bar CSV log of the full floor/cushion/exposure path. |
| CPPI_Ind.mq5 | Indicator | Price-only reference simulation of the same CPPI formulas, reusing CCppiMath unmodified, clearly separated from the live EA's real account state. |
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.
Creating Custom Indicators in MQL5 (Part 12): Mapping Liquidity with a Volume Profile Heatmap
Neural Additive Models for Fully Interpretable Signal Attribution in MQL5
Crypto Exchange Integration with MQL5 (Part 1): Connecting to MEXC Futures
Neural Networks in Trading: Temporal Motion Aggregation (Key Components)
- Free trading apps
- Over 8,000 signals for copying
- Economic news for exploring financial markets
You agree to website policy and terms of use