preview
Markowitz Mean-Variance Optimization for MQL5: A Native Efficient-Frontier Portfolio Builder

Markowitz Mean-Variance Optimization for MQL5: A Native Efficient-Frontier Portfolio Builder

MetaTrader 5 — Statistics and analysis |
119 0
Adewumi Babatunde Gbadebo
Adewumi Babatunde Gbadebo

Contents

  1. Introduction
  2. CMatrixOps: linear algebra with no external library
  3. CMarkowitzEngine: turning prices into a covariance matrix
  4. The three solvers: GMV, tangency, and a chosen frontier point
  5. The long-only problem, and why clipping is not a real fix
  6. The Expert Advisor: from weights to tickets
  7. The dashboard: watching drift without touching the market
  8. Testing in the Strategy Tester
  9. Conclusion


Introduction

Most Expert Advisors decide when to buy or sell a single instrument. This one decides how much of your equity to put into each instrument in a basket, using the same weight formula Harry Markowitz published in 1952. If you have never built a multi-symbol EA before, the mental shift is bigger than the math: there is no "signal" here, no crossover, no oscillator. There is a covariance matrix, three closed-form solvers, and a rebalance loop that nudges live positions toward whatever the matrix says is optimal this week.

Everything below is native MQL5: no external DLLs, no Python bridge, and no imported matrix library. The Gauss-Jordan inversion, the covariance estimation, and the Lagrangian frontier solve are all hand-written in MarkowitzCore.mqh, and I'll walk through the actual functions instead of talking around them. Wherever I show a formula, the next block is the code that implements it, so you can check one against the other line by line.

A note on scope: the article includes compiling code and one worked numeric example. It also reports four Strategy Tester runs on MetaQuotes-Demo across two symbol baskets and two timeframes. Two real bugs turned up during that testing and are documented where they happened instead of being fixed quietly off-page. The frontier plot further down comes from a synthetic 3-asset covariance matrix, labeled as such — it shows what the solvers produce, not live market data. The actual Strategy Tester numbers live near the end, and they're mixed. Not a clean win.


CMatrixOps: linear algebra with no external library

Every solver in this system needs the inverse of the covariance matrix, so that inversion is written once and reused three times. MQL5 has no built-in matrix inverse for this kind of use, so CMatrixOps::Invert() builds an augmented matrix [A | I] and runs Gauss-Jordan elimination with partial pivoting:

//+------------------------------------------------------------------+
//| Invert an NxN matrix via Gauss-Jordan elimination                |
//| with partial pivoting. Returns false on a near-singular matrix   |
//| instead of silently returning garbage.                           |
//+------------------------------------------------------------------+
bool CMatrixOps::Invert(const double &a[][MZ_MAX_ASSETS],int n,
                         double &out[][MZ_MAX_ASSETS])
  {
   double aug[MZ_MAX_ASSETS][2*MZ_MAX_ASSETS];
//--- build the augmented matrix [ A | I ]
   for(int i=0; i<n; i++)
     {
      for(int j=0; j<n; j++)
         aug[i][j]=a[i][j];
      for(int j=n; j<2*n; j++)
         aug[i][j]=(j-n==i) ? 1.0 : 0.0;
     }
//--- forward elimination with partial pivoting, column by column
   for(int col=0; col<n; col++)
     {
      int    pivot_row=col;
      double pivot_val=MathAbs(aug[col][col]);
      for(int r=col+1; r<n; r++)
        {
         if(MathAbs(aug[r][col])>pivot_val)
           {
            pivot_val=MathAbs(aug[r][col]);
            pivot_row=r;
           }
        }
      if(pivot_val<MZ_EPSILON)
         return(false);
      if(pivot_row!=col)
        {
         for(int k=0; k<2*n; k++)
           {
            double tmp        =aug[col][k];
            aug[col][k]       =aug[pivot_row][k];
            aug[pivot_row][k] =tmp;
           }
        }
      double diag=aug[col][col];
      for(int k=0; k<2*n; k++)
         aug[col][k]/=diag;
      for(int r=0; r<n; r++)
        {
         if(r==col)
            continue;
         double factor=aug[r][col];
         for(int k=0; k<2*n; k++)
            aug[r][k]-=factor*aug[col][k];
        }
     }
//--- lift the right half of the augmented matrix out as the inverse
   for(int i=0; i<n; i++)
      for(int j=0; j<n; j++)
         out[i][j]=aug[i][j+n];
   return(true);
  }

Look at where //--- sits: flush against the left margin, right after the opening brace, even though it's describing a loop nested inside the function. That's level one. A comment describing something only inside the for(col...) loop would indent to match that loop's own brace depth instead of staying at column zero. The pivot search returns false the moment a column goes singular, so there's no silent divide-by-near-zero and no NaN weights showing up three functions downstream.

Fixed-size arrays (double a[][MZ_MAX_ASSETS]) are a deliberate choice over dynamic 2D arrays. MQL5's dynamic arrays only resize cleanly in the first dimension, so a true NxN dynamic matrix means manual row-by-row allocation for every solve. At a basket size of 10 or fewer symbols, a fixed 10x10 buffer costs nothing and removes an entire class of allocation bugs.

One simplification is visible in that pivot check: MZ_EPSILON is a single fixed constant compared directly against pivot_val. FX log-return covariances can sit at very different absolute scales depending on the symbol and timeframe, so a fixed threshold either rejects a matrix that's actually fine or lets a genuinely ill-conditioned one through, depending on where that particular covariance happens to land. A scale-aware check — comparing the pivot against the matrix's own trace or largest entry, rather than an absolute constant — would be more robust than what's shown here, and is the kind of thing worth adding before trusting this on a basket with very different volatility profiles across legs.


CMarkowitzEngine: turning prices into a covariance matrix

The engine owns the basket and the statistics. First, the SDoubleRow wrapper struct. MQL5 has no native ragged 2D array (only the leftmost dimension of a multidimensional array can be dynamically sized), so a fixed-size array of per-symbol dynamic rows is how you work around that:

//+------------------------------------------------------------------+
//| Wrapper so a fixed-size array of "rows" can each own their own   |
//| independently-sized dynamic array -- MQL5 has no native ragged   |
//| 2D array, so this struct is the standard way around that.        |
//+------------------------------------------------------------------+
struct SDoubleRow
  {
   double row[];
  };

UpdateStatistics() is the function that turns raw closes into the mean vector μ and covariance matrix Σ that every solver depends on:

//+------------------------------------------------------------------+
//| Pull fresh closes for every symbol, build log-return series,     |
//| then the sample mean vector and covariance matrix, then invert   |
//| the covariance matrix once so every solver below can reuse it.   |
//+------------------------------------------------------------------+
bool CMarkowitzEngine::UpdateStatistics(void)
  {
   SDoubleRow returns[MZ_MAX_ASSETS];
//--- fetch closes and convert to log returns, one symbol at a time
   for(int i=0; i<m_count; i++)
     {
      double closes[];
      ArraySetAsSeries(closes,true);
      int copied=CopyClose(m_symbols[i],m_tf,0,m_lookback+1,closes);
      if(copied<m_lookback+1)
        {
         Print("UpdateStatistics -- insufficient history for ",m_symbols[i]);
         return(false);
        }
      ArrayResize(returns[i].row,m_lookback);
      for(int t=0; t<m_lookback; t++)
         returns[i].row[t]=MathLog(closes[t]/closes[t+1]);
     }
//--- sample mean per asset, needed before the covariance pass
   for(int i=0; i<m_count; i++)
     {
      double sum=0.0;
      for(int t=0; t<m_lookback; t++)
         sum+=returns[i].row[t];
      m_mean[i]=sum/m_lookback;
     }
//--- sample covariance, symmetric so only the upper triangle is computed
   for(int i=0; i<m_count; i++)
     {
      for(int j=i; j<m_count; j++)
        {
         double sum=0.0;
         for(int t=0; t<m_lookback; t++)
            sum+=(returns[i].row[t]-m_mean[i])*(returns[j].row[t]-m_mean[j]);
         double cov_ij=sum/(m_lookback-1);
         m_cov[i][j]=cov_ij;
         m_cov[j][i]=cov_ij;
        }
     }
   m_inv_ready=CMatrixOps::Invert(m_cov,m_count,m_cov_inv);
   return(m_inv_ready);
  }

The SDoubleRow wrapper isn't a stylistic choice. The natural-looking declaration double returns[MZ_MAX_ASSETS][]; won't compile here — it throws cannot convert parameter 'double' to 'void&[]' on the ArrayResize call, because MQL5 only allows the leftmost dimension of a multidimensional array to be dynamically sized. Wrapping each row in a one-field struct sidesteps that limitation, and it's the version that actually compiles.

Two things in that block are worth a closer look. Returns come from CopyClose at whatever timeframe you set in inputs, not ticks. A basket EA rebalancing every tick on a covariance matrix would be both wasteful and statistically meaningless, since covariance needs a real sample of bar-to-bar moves. And only the upper triangle of Σ gets computed, then mirrored, because covariance matrices are symmetric by construction (cov(i,j) = cov(j,i)). Computing both halves would just duplicate the same n(n-1)/2 arithmetic.

One more thing worth being upfront about: CopyClose(...,0,...) with the series flag set means closes[0] is the current, still-forming bar, not the last fully closed one. UpdateStatistics() only runs when OnTick() detects a new bar has just opened (see the Expert Advisor section below), so at that moment the new bar has barely started, and the most recent return in the sample is built from a close price that's really just "price right now." Shifting the copy to start at index 1 would use only closed bars and is the more defensible choice; this implementation doesn't do that, and the mean and covariance are measurably noisier on that last observation as a result. Separately, Init() (not shown here) is where m_count gets bounds- checked against MZ_MAX_ASSETS and m_lookback against a minimum of 2 — UpdateStatistics() itself assumes those bounds already hold and doesn't re-check them.

Here's how those pieces connect end to end, from price history through to the EA and the dashboard, the two consumers that read the engine's output:

Fig. 1. Data flow through CMarkowitzEngine, from raw closes to the two consumers


The three solvers: GMV, tangency, and a chosen frontier point

With Σ⁻¹ sitting ready in m_cov_inv, all three solvers are a handful of matrix-vector products. The global minimum-variance portfolio is the simplest: w = Σ⁻¹·1 / (1'·Σ⁻¹·1).

//+------------------------------------------------------------------+
//| Global Minimum Variance portfolio:                               |
//|   w = Sigma^-1 * 1 / (1' Sigma^-1 1)                             |
//+------------------------------------------------------------------+
bool CMarkowitzEngine::ComputeGMV(SPortfolioResult &result)
  {
   result.valid=false;
   if(!m_inv_ready)
      return(false);
   double ones[MZ_MAX_ASSETS],temp[MZ_MAX_ASSETS];
//--- 1 = ones vector, temp = Sigma^-1 * 1
   for(int i=0; i<m_count; i++)
      ones[i]=1.0;
   CMatrixOps::MultiplyVec(m_cov_inv,m_count,ones,temp);
   double denom=CMatrixOps::DotProduct(ones,temp,m_count);
   if(MathAbs(denom)<MZ_EPSILON)
      return(false);
   for(int i=0; i<m_count; i++)
      result.weights[i]=temp[i]/denom;
   ClipAndRenormalize(result.weights);
   result.valid=true;
   return(true);
  }

Worth naming precisely: ComputeGMV() computes the analytical global-minimum-variance weights, then immediately runs them through ClipAndRenormalize() before returning. If none of the unconstrained weights came back negative, what you get is the exact GMV. If any did, the function still calls it the GMV portfolio, but what's actually returned is the long-only projection of it — close to the theoretical minimum-variance point, not identical to it. The same caveat carries through to tangency and the target-return solver below, since both call the same clipping step before returning.

Tangency swaps the ones vector for the excess-return vector (μ - rf) and normalizes the same way — same shape, just a different numerator. One detail the code doesn't show: m_mean is the average log-return per bar at whatever timeframe InpTimeframe is set to, so InpRiskFreeRate needs to already be a per-bar figure for the subtraction to mean anything — an annualized rate plugged in directly would be off by orders of magnitude relative to an M5 or H1 log-return. The third solver is the interesting one: it picks an arbitrary point on the frontier at a chosen target return, and that takes two Lagrange multipliers instead of one:

//+------------------------------------------------------------------+
//| Efficient-frontier point at a chosen target return, solved via   |
//| the standard two-multiplier Lagrangian:                          |
//| A = 1'Sigma^-1 1,  B = 1'Sigma^-1 mu,  C = mu'Sigma^-1 mu        |
//| D = A*C - B^2                                                    |
//| lambda = (C - B*target) / D,  gamma = (A*target - B) / D         |
//| w = lambda*(Sigma^-1 1) + gamma*(Sigma^-1 mu)                    |
//+------------------------------------------------------------------+
bool CMarkowitzEngine::ComputeTargetReturn(double target_return,
                                            SPortfolioResult &result)
  {
   result.valid=false;
   if(!m_inv_ready)
      return(false);
   double ones[MZ_MAX_ASSETS],inv_ones[MZ_MAX_ASSETS],inv_mu[MZ_MAX_ASSETS];
//--- Sigma^-1 * 1 and Sigma^-1 * mu are each needed once, reused below
   for(int i=0; i<m_count; i++)
      ones[i]=1.0;
   CMatrixOps::MultiplyVec(m_cov_inv,m_count,ones,inv_ones);
   CMatrixOps::MultiplyVec(m_cov_inv,m_count,m_mean,inv_mu);
   double A=CMatrixOps::DotProduct(ones,inv_ones,m_count);
   double B=CMatrixOps::DotProduct(ones,inv_mu,m_count);
   double C=CMatrixOps::DotProduct(m_mean,inv_mu,m_count);
   double D=A*C-B*B;
   if(MathAbs(D)<MZ_EPSILON)
      return(false);
   double lambda=(C-B*target_return)/D;
   double gamma =(A*target_return-B)/D;
   SolveWithCoeffs(lambda,gamma,result);
   return(result.valid);
  }

A, B, C and D are the textbook two-fund separation coefficients. I kept the variable names one letter each on purpose, so the code reads next to the formula in the header comment instead of forcing you to decode a sigmaInvOnesDotOnes-style name. SolveWithCoeffs() then combines λ·(Σ⁻¹·1) + γ·(Σ⁻¹·μ) into the final weight vector, the same pattern GMV and tangency already used.

To show these solvers actually producing a frontier and not just compiling, here's a worked numeric example from a synthetic 3-asset covariance matrix (fixed correlations of 0.25, 0.40 and 0.10, volatilities scaled to roughly match gold/EUR/JPY). This isn't a live MT5 pull, just the same formulas above run in isolation to check the shape comes out right:

Fig. 2. Efficient frontier, GMV and tangency points from a synthetic 3-asset covariance example

The GMV point sits at the leftmost tip of the curve, as it should for this unconstrained example — lowest possible variance among the combinations shown. The tangency point sits where the capital market line (the straight dashed line from the risk-free rate) just touches the curve, the maximum-Sharpe point for this particular covariance input. Both landed where the closed-form math says they must, which is the whole point of checking a solver against a case you can verify by hand before trusting it on real basket data. None of that guarantee carries over once ClipAndRenormalize() runs on a live basket below — it floors negative weights and rescales the rest, so a GMV or tangency portfolio reported by the running EA is the clipped result, not the pure unconstrained one shown here.


The long-only problem, and why clipping is not a real fix

Every formula above is unconstrained. Nothing stops w from coming back negative for an asset the optimizer wants to short to reduce variance elsewhere. Most retail accounts either cannot short cheaply across a multi-asset basket or the EA is deliberately kept long-only, so ClipAndRenormalize() floors negatives at zero and rescales the rest back to sum to one:

//+------------------------------------------------------------------+
//| Long-only enforcement. The closed-form solutions above are       |
//| UNCONSTRAINED and can return negative weights -- there is no     |
//| simplex or active-set QP here. This is a clip-and-renormalize    |
//| approximation, not an exact long-only optimum, and the article   |
//| is explicit about the tracking error this introduces.            |
//+------------------------------------------------------------------+
void CMarkowitzEngine::ClipAndRenormalize(double &weights[])
  {
   double sum=0.0;
//--- first pass: floor every negative weight at zero
   for(int i=0; i<m_count; i++)
     {
      if(weights[i]<0.0)
         weights[i]=0.0;
      sum+=weights[i];
     }
//--- second pass: rescale so the basket still sums to exactly 1.0
   if(sum>MZ_EPSILON)
     {
      for(int i=0; i<m_count; i++)
         weights[i]/=sum;
     }
   else
     {
      double equal=1.0/m_count;
      for(int i=0; i<m_count; i++)
         weights[i]=equal;
     }
  }

I want to be direct about what this is and isn't. It is not the exact solution to the long-only-constrained quadratic program. That requires an active-set or simplex-style method iterating on which constraints are binding, meaningfully more code than a two-pass clip. What clipping gives you instead is a cheap, deterministic approximation, correct exactly when the unconstrained solution already happens to be non-negative, and increasingly wrong the more assets want negative weight. If your basket has one asset that's mostly a variance-reduction hedge for the others, expect clipping to under-weight it relative to the true constrained optimum. That tracking error is a real limitation of this approach, not a rounding footnote.


The Expert Advisor: from weights to tickets

The EA's job is mechanical once weights exist: convert each target weight into a notional value, convert that into lots, and move the live position toward it without ever opening a short (weights are already non-negative after clipping, so this only ever adds to, trims, or opens a long):

//+------------------------------------------------------------------+
//| Bring one symbol's position to the requested notional value.     |
//| Long-only basket: target_value is always >= 0 after clipping in  |
//| the engine, so this only ever opens/adds/trims a long, never     |
//| flips into a short.                                              |
//+------------------------------------------------------------------+
void AdjustPosition(const string symbol,double target_value)
  {
   double price=SymbolInfoDouble(symbol,SYMBOL_ASK);
   if(price<=0.0)
      return;
   double contract   =SymbolInfoDouble(symbol,SYMBOL_TRADE_CONTRACT_SIZE);
   double target_lots=(contract>0.0) ? target_value/(price*contract) : 0.0;
   double vol_step   =SymbolInfoDouble(symbol,SYMBOL_VOLUME_STEP);
   double vol_min    =SymbolInfoDouble(symbol,SYMBOL_VOLUME_MIN);
   if(vol_step>0.0)
      target_lots=MathRound(target_lots/vol_step)*vol_step;
   double current_lots=0.0;
   if(PositionSelect(symbol))
      current_lots=PositionGetDouble(POSITION_VOLUME);
   double diff=target_lots-current_lots;
   if(MathAbs(diff)<vol_min)
      return;
   if(diff>0.0)
      trade.Buy(diff,symbol);
   else
     {
      //--- shrinking an existing long: reduce it, do not open a fresh short
      double reduce_by=MathMin(MathAbs(diff),current_lots);
      if(reduce_by>=vol_min)
         trade.PositionClosePartial(symbol,reduce_by);
     }
  }

The diff<0 branch uses PositionClosePartial() rather than a bare Sell(). On a hedging-mode account, a plain Sell() opens a brand-new short ticket instead of reducing the existing long — an easy mistake to make on this kind of account, worth guarding against explicitly rather than discovering it live.

Three more limitations worth naming here rather than leaving implicit. First, target_value/(price*contract) assumes the symbol's notional and the account's equity are already in the same currency. That holds for XAUUSD and EURUSD against a USD account, but not in general — USDJPY's contract value is denominated in JPY, so on a USD account this conversion is off by the USDJPY rate unless the broker's own margin handling quietly absorbs it, and this code doesn't add an explicit conversion step. Second, PositionSelect(symbol) reads whatever position it finds without checking POSITION_TYPE first, so on an account carrying a manual short on the same symbol, current_lots would include that short's volume and the sizing arithmetic above would be wrong — the code assumes it's the only thing trading each basket symbol.

Third, and related: PositionSelect() finds one net position per symbol, which is correct under netting but not under hedging mode, where several tickets can coexist on the same symbol; a hedging account needs to aggregate volume across tickets and filter by magic number, which this code doesn't do. All three are the reason the "never opens a short" claim above holds only under this implementation's assumptions — no existing short positions, no other EA sharing the symbol, and a netting account — not as a general guarantee.

Rebalancing triggers in two ways: (1) a periodic timer (InpRebalanceHours), and (2) a drift check. The drift check compares each symbol's live notional share of equity with its last target and triggers early if any leg exceeds InpDriftThresholdPct. That second path matters more than it looks. Prices move between rebalances, so even a portfolio that started at its target weights drifts away from them purely from market movement, no new signal required.


The dashboard: watching drift without touching the market

The indicator runs its own CMarkowitzEngine instance, purely read-only. It never calls OrderSend, trade.Buy, or any other function that modifies a position. Its only job is to solve GMV and tangency on a timer and print a comparison table next to the live weight it computes from open positions:

//+------------------------------------------------------------------+
//| Pull every live weight, solve GMV and tangency, then redraw the  |
//| whole panel from scratch. Read-only -- no trade calls in here.   |
//+------------------------------------------------------------------+
void RefreshPanel(void)
  {
   int row=0;
   DrawLine("title",row++,"Markowitz Dashboard");
   if(!engine.UpdateStatistics())
     {
      DrawLine("status",row++,"Covariance singular / insufficient history");
      return;
     }
   SPortfolioResult gmv,tangency;
   bool gmv_ok      =engine.ComputeGMV(gmv);
   bool tangency_ok =engine.ComputeTangency(tangency);
   double equity=AccountInfoDouble(ACCOUNT_EQUITY);
//--- one row per basket symbol, live weight vs the two solved targets
   for(int i=0; i<g_symbol_count; i++)
     {
      double live_w=(equity>0.0) ? PositionValue(g_symbols[i])/equity : 0.0;
      double gmv_w =gmv_ok      ? gmv.weights[i]      : 0.0;
      double tan_w =tangency_ok ? tangency.weights[i] : 0.0;
      DrawLine("row"+IntegerToString(i),row++,
               StringFormat("%-10s %7.2f%% %7.2f%% %7.2f%%",
               g_symbols[i],live_w*100.0,gmv_w*100.0,tan_w*100.0));
     }
  }

Keeping the dashboard's engine instance completely separate from the EA's is deliberate. Neither one calls into the other, so watching the panel never risks nudging the EA's own rebalance timing, and either file can be attached, removed, or crash-tested independently of the other.


Testing in the Strategy Tester

A basket EA can't be tested the way a single-symbol EA can. Before running anything, every symbol in InpSymbols needs to already be sitting in Market Watch. Set Modelling to "Every tick based on real ticks" so intrabar fills are simulated from genuine historical ticks rather than the Tester's own interpolated ones. That setting changes execution fidelity between bars, not the bar closes themselves — UpdateStatistics() pulls those from CopyClose() at whatever timeframe is configured, and that OHLC history is the same regardless of modelling mode, so "every tick" buys more realistic fills, not a different or more "real" covariance estimate. One catch cost me a slow round-trip: the Symbol/Timeframe dropdown on the Settings tab only anchors the Tester's own clock. It has no say over what timeframe your EA samples internally. If your EA reads a timeframe from its own input parameter (as this one does with InpTimeframe), that value has to be changed on the Inputs tab directly — otherwise you silently re-run the same test twice while thinking you tested two different timeframes.

Two failure modes are worth watching for once a Strategy Tester run starts. A near-singular covariance matrix can show up during unusually quiet stretches, where two symbols moving almost identically for a while pushes Σ toward non-invertible — this is exactly why Invert() returns false instead of a garbage answer. Tracking error against the theoretical frontier shows up too, once ClipAndRenormalize() has to floor a negative weight on a real basket that isn't as clean as the synthetic example shown earlier. Both get documented here as things to expect, not swept under a smoothed backtest number.

A third failure mode showed up in the first Strategy Tester run. The earliest version of OnTick() re-checked drift on every tick. On a modest account, minimum-lot rounding is a large fraction of a leg's target notional, so ordinary tick-level jitter crossed the 5% drift threshold almost continuously. The first M5 backtest came back with 21,985 trades over a two-month window and a 60%+ drawdown — not a market result, an over-trading bug. The fix gates the whole rebalance decision on a genuinely new bar opening, detected via iTime(), instead of firing on every tick, since the weights themselves are bar-level statistics and never needed tick-granularity re-evaluation in the first place — with the caveat on the still-forming current bar noted above.

Even after the fix, trade counts stayed high enough (4,339 and 7,301 trades over roughly two months in Runs 1 and 3 below) that spreads, commissions, and slippage — none of which this test isolates — would meaningfully erode the headline profit figures on a live account; none of the numbers below have those costs backed out.

With that fixed, four Strategy Tester runs were completed on MetaQuotes-Demo: XAUUSD-anchored, "Every tick based on real ticks," 2026.07.01 to 2026.09.04, MODE_TANGENCY, 250-bar lookback, $10,000 deposit. Two symbol baskets crossed with two sampling timeframes — exactly the kind of spread a basket strategy needs to be tested across before anyone should trust it:

Fig. 3. Net profit, max equity drawdown, and Sharpe ratio across the four Strategy Tester runs

Run
Basket
Timeframe
Net Profit
Max Equity DD
Profit Factor
Sharpe
Trades
Win %
1
XAUUSD, EURUSD, USDJPY
M5
+8,928 (+89.3%)
44.29%
1.51
1.70
4,339
48.1%
2
XAUUSD, EURUSD, USDJPY
H1
+8,228 (+82.3%)
52.69%
5.18
1.70
360
69.4%
3
XAUUSD, EURUSD, GBPUSD
M5
+619 (+6.2%)
57.12%
1.02
0.11
7,301
47.3%
4
XAUUSD, EURUSD, GBPUSD
H1
+1,773 (+17.7%)
70.78%
1.32
0.35
754
64.1%

Conclusion on these four runs. Across these four runs, basket composition looks like a bigger driver of the outcome than sampling timeframe — a pattern worth testing on more baskets, not a proven rule from four data points. The USDJPY basket produced a strong result on both timeframes (Sharpe 1.70 either way, profit factor between 1.51 and 5.18), while swapping USDJPY for GBPUSD — same two anchor legs (XAUUSD, EURUSD), same date range, same EA settings — dropped the result to barely breakeven on M5 (profit factor 1.02) and only modestly positive on H1 (Sharpe 0.35). That swap isn't a clean single-variable test, though: the active leg count, trade count, and realized portfolio size differed between the two baskets once clipping ran, so more than one thing changed at a time.

The reason became clear after inspection. In all four runs, ClipAndRenormalize() zeroed out one leg entirely in the USDJPY basket. The Tester summary showed "Symbols: 2", confirmed in Runs 1 and 2. So that basket's strong numbers actually came from a 2-asset XAUUSD/EURUSD tangency portfolio, not genuine 3-asset diversification — the long-only clipping described above visibly reshaping the strategy on real data, a real limitation rather than a hypothetical one, just as flagged there. The GBPUSD basket kept all three legs active and traded far more (7,301 and 754 trades against 360-4,339), for a materially weaker risk-adjusted result.

None of the four runs should be read as a validated trading system. Every one carries an equity drawdown north of 44%, worse than its own balance drawdown, which means intra-trade swings are considerably rougher than the closed trades alone let on. Where this system has an edge, it's narrow and basket-specific, not a general property of Markowitz weighting — worth knowing before extending the basket or sizing up the allocation.


Conclusion

What this article delivers is the machinery: a from-scratch matrix inverse, a covariance estimator built off real log returns, three closed-form solvers that map directly onto the textbook Markowitz formulas, an honest (and honestly limited) long-only approximation, and an EA plus dashboard that consume all of it without duplicating a single line of the math. None of it depends on anything outside the standard MQL5 runtime. Two real bugs turned up during actual testing, not code review — a ragged-array declaration MQL5 doesn't support, and an over-trading loop from checking drift on every tick instead of once per bar. Neither one is smoothed over here; the fixes and the reasoning behind them are laid out earlier, in the CMarkowitzEngine section and the testing section, where they happened.

The four-run test matrix above is the part worth remembering longest. The same solver, the same code, the same date range produced a strong result on one basket and a mediocre one on a basket that only differed by a single swapped currency pair. That's not a flaw unique to this implementation; it's what unconstrained mean-variance optimization does whenever a basket's estimated covariance and expected returns are unstable over the sample window, and clipping a negative weight to zero can silently turn a "3-asset portfolio" into a 2-asset one without saying so anywhere except the Tester's own symbol count. Anyone extending this basket should test more than one composition before trusting a single backtest's headline number — that's the bar any basket strategy should have to clear, and it's what this article tried to give it.

File
Type
Description
MarkowitzCore.mqh
Library
CMatrixOps (Gauss-Jordan inversion, matrix-vector product, dot product) and CMarkowitzEngine (covariance estimation, GMV/tangency/target-return solvers, clip-and-renormalize).
MarkowitzPortfolio_EA.mq5
Expert Advisor
Solves the chosen portfolio mode on a periodic timer and on weight drift, then rebalances each symbol's live position toward its target notional via CTrade.
MarkowitzDashboard.mq5
Indicator
Read-only panel comparing live weights against freshly solved GMV and tangency targets, with portfolio variance and Sharpe. Never sends a trade.
Attached files |
MQL5.zip (10 KB)
Self-Optimizing Expert Advisors in MQL5 (Part 20): Causality Regularization With Time-Lagged Independent Components Analysis (3) Self-Optimizing Expert Advisors in MQL5 (Part 20): Causality Regularization With Time-Lagged Independent Components Analysis (3)
The article presents a piecewise workflow that forecasts growth in ICA components as surrogate targets and then reconstructs smoothed returns from these forecasts. We analyze dominant-component effects and apply Shapley Additive Explanations to assess feature importance. The approach is implemented end to end, from data preparation to ONNX export and MQL5 integration, providing a practical pipeline for noise reduction and model interpretability.
Neural Networks in Trading: Recurrent Modeling of Market Micromovements (Encoder) Neural Networks in Trading: Recurrent Modeling of Market Micromovements (Encoder)
The article takes readers deep into the heart of the EV-MGRFlowNet framework, showing how its architecture is applied in a practical implementation for financial forecasting tasks. Step by step, we build a carefully designed combination of modules capable of detecting subtle temporal patterns and translating them into meaningful market signals.
RSI Divergence Grid Expert Advisor with Risk Manager in MQL5 RSI Divergence Grid Expert Advisor with Risk Manager in MQL5
A simple grid trading strategy becomes a resilient system thanks to three components: an entry filter based on RSI divergence, a strict limit on the number of grid levels, and an independent risk manager that monitors equity on every tick and blocks trading when the daily limit or maximum drawdown is reached. The article examines the mathematics behind the classic martingale trap, the implementation of pivot point and RSI divergence detection in MQL5, the complete code for the CRiskManager class with protection against floating losses, and the architecture of the grid itself, with TP based on the weighted average entry price.
From Basic to Intermediate: Subwindows (II) From Basic to Intermediate: Subwindows (II)
This article examines the use of subwindows in MetaTrader 5 in detail and shows how the calculation direction in OnCalculate affects indicator buffers and the plotting of averages. Practical examples explain how indicators are linked using FIRST INDICATOR’S DATA and PREVIOUS INDICATOR’S DATA, the effect of cascading removal of dependent indicators, and how indicator_separate_window behaves. Readers will learn how to diagnose display issues and create indicators that work correctly in subwindows.