preview
Rolling Sharpe Ratio with Statistical Significance Bands in MQL5

Rolling Sharpe Ratio with Statistical Significance Bands in MQL5

MetaTrader 5Indicators |
154 0
Ushana Kevin Iorkumbul
Ushana Kevin Iorkumbul

Introduction

Traders frequently treat a short-window Sharpe ratio (eg, 40–60 bars with SR ≈ 1.0–1.5) as evidence of alpha. Statistically, however, such point estimates are highly variable: sample Sharpe has a standard error that scales with 1/√n, so small n produces estimates dominated by noise. The problem is compounded in MetaTrader 5, where the indicator engine can force full recalculation with prev_calculated = 0 and deliver only a partial history; stateful rolling accumulators then produce "plausible" numbers computed from incomplete data. What's needed is an MetaTrader 5-native diagnostic that reports not only the rolling (annualized) Sharpe but also its uncertainty (±z·SE) so users can distinguish "confirmed edge" from "Sharpe inside noise," and an implementation that is resilient to MetaTrader 5's recalculation and viewport behaviors.

This article presents a custom MetaTrader 5 indicator, RollingSharpe.mq5, that plots three lines in a sub-window beneath the price chart: the rolling annualized Sharpe ratio, and an upper and lower confidence band at ±1.96⋅SEann​. Sections 1 through 3 derive the mathematics behind those three lines; Section 4 assembles them into the indicator; Section 5 provides the exact inp_Window and inp_PeriodsPerYear values your instrument and timeframe require so the bands are calibrated correctly from the first bar. Once built, reading the chart reduces to one rule: if the bands straddle zero, the visible Sharpe is statistical noise; if they do not, the Sharpe reading is significant at the chosen confidence level.


Section 1: The Mathematics of Noise vs. Alpha

Why Sample Sharpe Ratios Lie

The Sharpe ratio is the most widely cited risk-adjusted performance metric in systematic trading, yet practitioners apply it naively: a window of 60 bars yielding a Sharpe of 1.2 is treated as evidence of alpha; it is not. It is a point estimate drawn from a distribution whose variance is directly controlled by the sample size. Without quantifying that variance, the number is operationally meaningless.

For a return series {rₜ}ⁿₜ₌₁ drawn from a stationary distribution with mean μ and standard deviation σ, the sample Sharpe ratio is:

The Sample Sharpe ratio

where r̄ = 1/n ∑ⁿₜ₌₁ rₜ and σ̂ = √[ 1/(n-1) ∑ⁿₜ₌₁ (rₜ - r̄)² ].

Lo’s Asymptotic Standard Error Formula

Andrew Lo (2002) derived the asymptotic distribution of SR̂ under independent and identically distributed (IID) return assumptions. For a sample of n observations, the standard error is:

SE(SR̂) = √[ (1 + ½ (SR̂)²) / n ]

This expression encodes two critical facts:

  • Inverse square-root decay: SE ∝ n⁻¹/². Halving the window doubles the standard error. A 20-bar window has √5 ≈ 2.24× more uncertainty than a 100-bar window.
  • Self-referential inflation : High nominal Sharpe estimates themselves widen the confidence interval via the ½ (SR̂)² term, which represents the contribution of kurtosis under IID-Gaussian assumptions.

Annualization and the Statistical Significance Bands

The annualized Sharpe ratio scales by √N, where N is the number of return periods per year:

Sharpe Ratio annual formula

The corresponding standard error of the annualized estimate is:

Standard error of the annualized estimate

The 95% confidence interval — using the asymptotic normality of the estimated Sharpe ratio — is therefore:

Confidence band formula

Any annualized Sharpe ratio whose confidence band includes zero is statistically indistinguishable from noise at the 5% level, regardless of nominal magnitude.

Small-Window Variance Explosion: Numerical Demonstration

Consider a true population Sharpe of 1.0. Table 1 shows the 95% confidence interval half-width as a function of sample size.

Confidence interval half-width as a function of sample size (SR_true = 1.0)

n (bars) SE(SR̂) 95% CI half-width
Min. detectable SR
20 0.2372 ±0.465
1.962
60 0.1374
±0.269
1.135
120 0.0972
±0.190
0.803
252 0.0671
±0.131
0.554
504 0.0475
±0.093
0.392

A 20-bar rolling window cannot distinguish a Sharpe of 1.0 from zero at any conventional significance level. This is not a modeling defect — it is a fundamental limit imposed by Fisher information theory. The indicator developed here makes this limitation visible in the terminal in real time.

Window sizing guidance

The numbers in the table above are not abstract — they determine the practical lower bound on inp_Window used later in this article. Rearranging Lo's standard error formula so that the lower confidence band just touches zero gives the minimum sample size required to confirm a given true Sharpe ratio (SR^*) is statistically distinguishable from noise:

minimum sample size formula

A higher target (SR^*) requires fewer bars, because a larger effect is easier to detect against estimation noise. Applying this formula, confirming a true Sharpe of 0.5 requires at least 18 bars, while confirming a true Sharpe of 1.0 requires at least 6 bars. In practice, FX traders operating on H1 should use inp_window of at least 120 bars regardless, since these theoretical minimums assume IID Gaussian returns and provide no margin against the violations discussed in Section 6.



Section 2: Architecture of the Performance Ingestion Engine

Design Philosophy

The ingestion layer must solve two problems simultaneously: (1) efficiently maintain a rolling buffer of n returns without re-reading the full price history on every tick, and (2) correctly translate raw OHLCV data into period returns that are arithmetically consistent with the statistical derivations above.

The implementation uses a fixed-length circular buffer allocated once in OnInit(). On each call to OnCalculate(), only the new bar’s return is appended; the oldest observation is automatically discarded by advancing the head pointer modulo n.

The CReturnBuffer Class — Full Implementation

//+------------------------------------------------------------------+
//|                                                CReturnBuffer.mqh |
//| Rolling return series buffer using a circular array template.    |
//+------------------------------------------------------------------+
#ifndef __CRETURNBUFFER_MQH__
#define __CRETURNBUFFER_MQH__

//+------------------------------------------------------------------+
//| Class CReturnBuffer                                              |
//| Purpose: Provides a fast, fixed-size rolling circular cache for  |
//|          storing quantitative returns. Tracks running statistical|
//|          aggregates (mean, variance) using single-pass logic.    |
//+------------------------------------------------------------------+
class CReturnBuffer
  {
private:
   double            m_data[];        // Circular storage array
   int               m_capacity;      // Maximum window index lookback depth
   int               m_head;          // Index coordinate pointer of oldest element
   int               m_count;         // Total active initialized valid records
   double            m_sum;           // Incremental running sum aggregate for mean
   double            m_sumSq;         // Incremental sum-of-squares for variance

public:
                     CReturnBuffer(void);
                    ~CReturnBuffer(void);

   bool              Init(const int capacity);
   void              Push(const double value);
   bool              IsFull(void) const;
   int               Count(void) const;
   int               Capacity(void) const;
   double            Get(const int offset) const;
   double            Mean(void) const;
   double            Variance(void) const;
   double            StdDev(void) const;
   void              Reset(void);
  };

//+------------------------------------------------------------------+
//| Default Class Constructor                                        |
//+------------------------------------------------------------------+
CReturnBuffer::CReturnBuffer(void) : m_capacity(0),
   m_head(0),
   m_count(0),
   m_sum(0.0),
   m_sumSq(0.0)
  {
  }

//+------------------------------------------------------------------+
//| Class Destructor                                                 |
//+------------------------------------------------------------------+
CReturnBuffer::~CReturnBuffer(void)
  {
//--- Reclaim assigned dynamic array memory structures
   ArrayFree(m_data);
  }

//+------------------------------------------------------------------+
//| Init                                                             |
//| Purpose: Allocates initial fixed memory sizing structures.       |
//+------------------------------------------------------------------+
bool CReturnBuffer::Init(const int capacity)
  {
//--- Enforce minimum size threshold validation for variance tracking equations
   if(capacity < 2)
     {
      Print("CReturnBuffer::Init - capacity must be >= 2, received: ", capacity);
      return(false);
     }

//--- Establish base ground variables properties
   m_capacity = capacity;
   m_head     = 0;
   m_count    = 0;
   m_sum      = 0.0;
   m_sumSq    = 0.0;

//--- Perform physical dynamic sizing check vector
   if(ArrayResize(m_data, m_capacity) != m_capacity)
     {
      Print("CReturnBuffer::Init - ArrayResize failed for capacity: ", capacity);
      return(false);
     }

//--- Clean memory pool structures completely
   ArrayInitialize(m_data, 0.0);
   return(true);
  }

//+------------------------------------------------------------------+
//| Push                                                             |
//| Purpose: Writes a value to the current ring buffer position,     |
//|          auto-evicting the oldest element if at capacity.        |
//+------------------------------------------------------------------+
void CReturnBuffer::Push(const double value)
  {
//--- Check if sliding memory ring context is saturated
   if(m_count == m_capacity)
     {
      //--- Evict historical oldest data entry from statistical tracking metrics
      double evicted = m_data[m_head];
      m_sum   -= evicted;
      m_sumSq -= evicted * evicted;
     }
   else
     {
      //--- Expand tracked sample size registry directly
      m_count++;
     }

//--- Overwrite historical ring offset slot with newly arrived data point
   m_data[m_head] = value;
   m_sum          += value;
   m_sumSq        += value * value;

//--- Advance write head pointer wrapping around capacity limits cleanly
   m_head = (m_head + 1) % m_capacity;
  }

//+------------------------------------------------------------------+
//| IsFull                                                           |
//+------------------------------------------------------------------+
bool CReturnBuffer::IsFull(void) const
  {
   return(m_count == m_capacity);
  }

//+------------------------------------------------------------------+
//| Count                                                            |
//+------------------------------------------------------------------+
int CReturnBuffer::Count(void) const
  {
   return(m_count);
  }

//+------------------------------------------------------------------+
//| Capacity                                                         |
//+------------------------------------------------------------------+
int CReturnBuffer::Capacity(void) const
  {
   return(m_capacity);
  }

//+------------------------------------------------------------------+
//| Get                                                              |
//| Purpose: Sequential zero-based index query where 0 is newest.    |
//+------------------------------------------------------------------+
double CReturnBuffer::Get(const int offset) const
  {
//--- Trap invalid or uninitialized out-of-bounds queries.
   if(offset < 0 || offset >= m_count)
      return(0.0);

//--- Map logical historical array request into the physical circular memory ring index
   int physIdx = (m_head - 1 - offset + m_capacity * 2) % m_capacity;
   return(m_data[physIdx]);
  }

//+------------------------------------------------------------------+
//| Mean                                                             |
//+------------------------------------------------------------------+
double CReturnBuffer::Mean(void) const
  {
   if(m_count == 0)
      return(0.0);

   return(m_sum / (double)m_count);
  }

//+------------------------------------------------------------------+
//| Variance                                                         |
//| Purpose: Computes unbiased sample variance (N-1 denominator).    |
//+------------------------------------------------------------------+
double CReturnBuffer::Variance(void) const
  {
//--- Sample statistical models require at least two active item nodes
   if(m_count < 2)
      return(0.0);

   double n    = (double)m_count;
   double mean = m_sum / n;

//--- Apply standard mathematical expansion form equation
   double var  = (m_sumSq - n * mean * mean) / (n - 1.0);

//--- Defensively filter out floating point inaccuracies below absolute zero
   return(MathMax(var, 0.0));
  }

//+------------------------------------------------------------------+
//| StdDev                                                           |
//+------------------------------------------------------------------+
double CReturnBuffer::StdDev(void) const
  {
   return(MathSqrt(Variance()));
  }

//+------------------------------------------------------------------+
//| Reset                                                            |
//| Purpose: Resets statistical counters and wipes tracking values.  |
//+------------------------------------------------------------------+
void CReturnBuffer::Reset(void)
  {
//--- Reset runtime indices tracking metrics counters
   m_head  = 0;
   m_count = 0;
   m_sum   = 0.0;
   m_sumSq = 0.0;

//--- Return storage elements array fields data to structural baseline definitions
   ArrayInitialize(m_data, 0.0);
  }

#endif // __CRETURNBUFFER_MQH__
//+------------------------------------------------------------------+

Architectural Breakdown of CReturnBuffer

Memory allocation strategy: ArrayResize(m_data, m_capacity) is called exactly once during Init(). No subsequent allocation occurs during live operation. This eliminates heap fragmentation on instruments with high tick frequency. The failure path is explicitly trapped and communicated via Print() before returning false, allowing the indicator’s OnInit() to return INIT_FAILED cleanly.

Circular indexing mechanics: The variable m_head always holds the index of the next write slot, which is simultaneously the index of the oldest element when the buffer is full. After writing m_data[m_head] = value, the head is advanced via m_head = (m_head + 1) % m_capacity. This single modulo operation is the entirety of the circular management overhead per push. There are no ArrayCopy() calls, no ArrayShift() equivalents, and no O(n) memory moves.

Incremental statistics: Rather than recomputing ∑r_t and ∑r_t² over the entire window on each push — an O(n) operation — the implementation maintains `m_sum` and `m_sumSq` as running accumulators. When the buffer is full and an old element is evicted, its contribution is subtracted before the new element is added. This reduces per-push arithmetic to O(1). The sample variance is recovered in O(1) via:

σ̂² = (∑r_t² - n · r̄²) / (n - 1)

Floating-point guard: The MathMax(var, 0.0) clamp in Variance() prevents the case where catastrophic cancellation in the numerator — which can occur when all returns are nearly identical — produces a tiny negative value. Passing a negative argument to MathSqrt() returns NaN in MQL5, which would silently propagate through all downstream calculations.

Indexed access: The Get(offset) method allows external code to inspect any element in the buffer by logical offset from the newest observation. The physical index is computed as (m_head - 1 - offset + 2*m_capacity) % m_capacity. The 2*m_capacity addend prevents the modulo operand from ever being negative, which is required because MQL5’s % operator follows C semantics and returns a negative result for negative dividends.


Section 3: The Statistical Calculation Core

The CSharpeCalculator Class — Full Implementation

//+------------------------------------------------------------------+
//|                                            CSharpeCalculator.mqh |
//| Computes rolling annualized Sharpe ratio with Lo SE bands.       |
//+------------------------------------------------------------------+
#ifndef __CSHARPECALCULATOR_MQH__
#define __CSHARPECALCULATOR_MQH__

#include "CReturnBuffer.mqh"

//+------------------------------------------------------------------+
//| Struct SSharpeResult                                             |
//| Purpose: Structure holding annualized Sharpe evaluation details  |
//|          alongside standard asymptotic error boundary margins.   |
//+------------------------------------------------------------------+
struct SSharpeResult
  {
   double            sharpe;           // Annualized Sharpe ratio metric
   double            upperBand;        // Confidence interval upper limit (Sharpe + zScore * SE)
   double            lowerBand;        // Confidence interval lower limit (Sharpe - zScore * SE)
   double            se;               // Annualized standard error variance estimator
   bool              valid;            // Operational status flag representing dataset maturity
  };

//+------------------------------------------------------------------+
//| Class CSharpeCalculator                                          |
//| Purpose: Processes risk-adjusted metric profiles over sliding    |
//|          lookback window boundaries via single pass iterations.  |
//+------------------------------------------------------------------+
class CSharpeCalculator
  {
private:
   CReturnBuffer     m_buffer;         // Core sliding analytical circular caching array
   int               m_window;         // Minimum lookback item count window depth
   double            m_annFactor;      // Calculated annualization root factor multiplier
   double            m_zScore;         // Standard Normal score confidence coefficient

   //--- Calculates the standard error envelope over raw performance vectors
   double            ComputeSE(const double sr_raw, const int n) const;

public:
                     CSharpeCalculator(void);
                    ~CSharpeCalculator(void);

   //--- Setup tracking dimensions and statistical scaling coefficients
   bool              Init(const int window, const int periodsPerYear, const double zScore = 1.96);

   void              AddReturn(const double ret);
   SSharpeResult     Calculate(void) const;
   bool              IsReady(void) const;
   void              Reset(void);
  };

//+------------------------------------------------------------------+
//| Default Class Constructor                                        |
//+------------------------------------------------------------------+
CSharpeCalculator::CSharpeCalculator(void) : m_window(0),
   m_annFactor(1.0),
   m_zScore(1.96)
  {
  }

//+------------------------------------------------------------------+
//| Class Destructor                                                 |
//+------------------------------------------------------------------+
CSharpeCalculator::~CSharpeCalculator(void)
  {
  }

//+-----------------------------------------------------------------------+
//| Init                                                                  |
//| Purpose: Establishes sizing metrics limits and validation constraints |
//+-----------------------------------------------------------------------+
bool CSharpeCalculator::Init(const int window, const int periodsPerYear, const double zScore = 1.96)
  {
//--- Verify fundamental operational sizing ranges
   if(window < 2)
     {
      Print("CSharpeCalculator::Init - window must be >= 2");
      return(false);
     }
   if(periodsPerYear < 1)
     {
      Print("CSharpeCalculator::Init - periodsPerYear must be >= 1");
      return(false);
     }

//--- Set up global state property definitions
   m_window    = window;
   m_annFactor = MathSqrt((double)periodsPerYear);
   m_zScore    = MathAbs(zScore);

//--- Configure underlying data array storage limits
   return(m_buffer.Init(window));
  }

//+------------------------------------------------------------------+
//| ComputeSE                                                        |
//| Purpose: Estimates the standard error of the Sharpe Ratio        |
//|          utilizing standard Lo (2002) asymptotic equations.      |
//+------------------------------------------------------------------+
double CSharpeCalculator::ComputeSE(const double sr_raw, const int n) const
  {
//--- Guard against out of bounds division operations on low sample distributions
   if(n < 2)
      return(0.0);

//--- Compute standard error base tracking metrics
   double se_raw = MathSqrt((1.0 + 0.5 * sr_raw * sr_raw) / (double)n);
   return(m_annFactor * se_raw);
  }

//+------------------------------------------------------------------+
//| AddReturn                                                        |
//+------------------------------------------------------------------+
void CSharpeCalculator::AddReturn(const double ret)
  {
//--- Write newly arrived data element onto tracking data arrays
   m_buffer.Push(ret);
  }

//+------------------------------------------------------------------+
//| Calculate                                                        |
//| Purpose: Transforms runtime rolling sums into an annualized      |
//|          Sharpe profile structure report payload cleanly.        |
//+------------------------------------------------------------------+
SSharpeResult CSharpeCalculator::Calculate(void) const
  {
   SSharpeResult res;
   res.valid     = false;
   res.sharpe    = 0.0;
   res.upperBand = 0.0;
   res.lowerBand = 0.0;
   res.se        = 0.0;

//--- Extract current item counts stored within the active matrix buffer
   int n = m_buffer.Count();
   if(n < 2)
      return(res);

//--- Prevent divide-by-zero actions under zero-variance market conditions
   double stdDev = m_buffer.StdDev();
   if(stdDev < 1e-12)
      return(res);

//--- Execute mathematical tracking conversions
   double mean   = m_buffer.Mean();
   double sr_raw = mean / stdDev;
   double sr_ann = sr_raw * m_annFactor;
   double se_ann = ComputeSE(sr_raw, n);

//--- Finalize calculation result matrix mapping assignments
   res.sharpe    = sr_ann;
   res.se        = se_ann;
   res.upperBand = sr_ann + m_zScore * se_ann;
   res.lowerBand = sr_ann - m_zScore * se_ann;
   res.valid     = true;

   return(res);
  }

//+------------------------------------------------------------------+
//| IsReady                                                          |
//+------------------------------------------------------------------+
bool CSharpeCalculator::IsReady(void) const
  {
//--- Asserts whether historical sampling has fully saturated the designated window
   return(m_buffer.IsFull());
  }

//+------------------------------------------------------------------+
//| Reset                                                            |
//+------------------------------------------------------------------+
void CSharpeCalculator::Reset(void)
  {
//--- Flush underlying analytical ring buffers completely
   m_buffer.Reset();
  }

#endif // __CSHARPECALCULATOR_MQH__
//+------------------------------------------------------------------+

Architectural Breakdown of CSharpeCalculator

Separation of raw and annualized quantities: The calculation chain maintains two distinct Sharpe estimates: sr_raw (per-period) and sr_ann (annualized). The Lo standard error formula operates on sr_raw because the derivation assumes the underlying observations are the period returns themselves. Applying the formula directly to the annualized value would introduce a factor-of-N error in the variance term. Annualization is applied multiplicatively to both sr_raw and SE_raw after the SE computation, preserving mathematical consistency.

Degeneracy guard (stdDev < 1e-12): When all returns in the window are identical — a condition arising during market halts, weekend gaps, or aggressive stop-filling — the standard deviation collapses toward zero. Dividing by a near-zero value produces Sharpe estimates on the order of 10¹², which corrupt indicator buffers and generate erroneous signals. The threshold 1e-12 is set well below the smallest meaningful pip-level return on any liquid instrument while remaining far above the machine epsilon for double (≈ 2.22 × 10⁻¹⁶).

The SSharpeResult struct: Packing all outputs into a plain struct allows the Calculate() method to remain const, signaling to the compiler that it introduces no side effects. The valid boolean member allows downstream code to branch cleanly without checking each output field individually.

m_zScore parameterization: The critical value is stored as a class member rather than hardcoded to 1.96. This allows callers to instantiate at 1.645 (90% CI), 2.576 (99% CI), or any arbitrary quantile without modifying core logic. The Init() call applies MathAbs() to the supplied value, eliminating the possibility of an accidentally negative critical value inverting the band direction.

Two valid consumers of this calculation core: CReturnBuffer and CSharpeCalculator are a complete, reusable O(1) rolling-statistics engine. They are the correct choice for an Expert Advisor, where OnTick() is called sequentially and bar history is never replayed out of order — a stateful accumulator works safely there. The custom indicator built in Section 4, however, runs inside MetaTrader's chart engine, which can replay history non-sequentially (see below). For that environment specifically, a stateless design is required, so the indicator does not instantiate either class at runtime. Both files remain in the project as the EA-ready calculation core; Section 4 explains why the indicator itself takes a different path.


Section 4: The Custom Indicator Binding Layer

Architecture: Stateless Per-Bar Computation

The indicator binding layer connects the mathematical derivations in Sections 1 through 3 to MetaTrader 5’s rendering engine. The core architectural decision is the use of a stateless per-bar computation model: rather than maintaining a rolling accumulator object across OnCalculate() calls, a standalone function ComputeBar() accepts the full close[] array by const reference and performs a complete two-pass calculation on each invocation. No state is preserved between calls.

This design is a direct response to a structural characteristic of the MetaTrader 5 indicator engine. The engine may call OnCalculate() with prev_calculated = 0 (full recalculation) not only after a reload or parameter change, but also after viewport scroll events or new-bar arrivals on backgrounded chart windows. When triggered by these events, rates_total may not reflect the full loaded history; it can be limited to the bars visible in the chart viewport. If an implementation resets the rolling accumulator when prev_calculated = 0 and rebuilds it using only rates total bars, it will be wrong whenever rates_total contains only partial history. The stateless design avoids this failure mode: ComputeBar() uses only close[] and does not rely on persisted state.

The CReturnBuffer.mqh and CSharpeCalculator.mqh headers are included in the project and remain available for integration into Expert Advisors, but neither class is instantiated by the indicator at runtime.

Visualizing Statistical Significance on the Chart

The indicator output splits into a dual-window analytical workspace:

  1. Primary Chart Window: Displays the raw candlestick price action for structural baseline reference.
  2. Sub-Indicator Window: Houses the custom statistical overlay containing three primary plot structures:
    • Solid Cyan Line (DRAW_LINE): Tracks the running annualized Sharpe ratio (SR_ann), oscillating dynamically between approximately -2.5 and +3.0.
    • Dashed Red Lines (DRAW_DASH): Represents the upper significance threshold (SR_ann + 1.96 * SE_ann) and the lower significance threshold (SR_ann - 1.96 * SE_ann).
    • Solid Gray Level: Establishes the static 0.0 zero baseline marker.
    • Translucent Shaded Ribbon (DRAW_FILLING): Highlights the entire spatial region bounded between the upper and lower dashed red bands. Any points where the cyan line resides inside this shaded zone indicate that the strategy's edge is statistically indistinguishable from random noise at the 5% level.

Full Indicator Source File

//+------------------------------------------------------------------+
//|                                                RollingSharpe.mq5 |
//| Rolling Annualized Sharpe Ratio with Lo Significance Bands       |
//| Requires: CReturnBuffer.mqh, CSharpeCalculator.mqh               |
//+------------------------------------------------------------------+

#property description "Rolling Sharpe Ratio with +/-z*SE significance bands"
#property strict

//--- Indicator window configurations
#property indicator_separate_window
#property indicator_buffers 4
#property indicator_plots   4

//--- Plot 1: Sharpe Ratio line
#property indicator_label1  "Sharpe Ratio"
#property indicator_type1   DRAW_LINE
#property indicator_color1  clrCyan
#property indicator_style1  STYLE_SOLID
#property indicator_width1  2

//--- Plot 2: Upper Standard Error Band
#property indicator_label2  "Upper Band (+1.96*SE)"
#property indicator_type2   DRAW_LINE
#property indicator_color2  clrCrimson
#property indicator_style2  STYLE_DASH
#property indicator_width2  1

//--- Plot 3: Lower Standard Error Band
#property indicator_label3  "Lower Band (-1.96*SE)"
#property indicator_type3   DRAW_LINE
#property indicator_color3  clrCrimson
#property indicator_style3  STYLE_DASH
#property indicator_width3  1

//--- Plot 4: Static Baseline Anchor
#property indicator_label4  "Zero Line"
#property indicator_type4   DRAW_LINE
#property indicator_color4  clrDimGray
#property indicator_style4  STYLE_SOLID
#property indicator_width4  1

//--- Include dependencies
#include <RollingSharpe/CReturnBuffer.mqh>
#include <RollingSharpe/CSharpeCalculator.mqh>

//--- Input parameters
input int    inp_Window         = 60;  // Lookback Rolling Window Size
input int    inp_PeriodsPerYear = 252; // Annualization Periodicity (e.g. Daily = 252)
input double inp_ZScore         = 1.96;// Statistical Confidence Interval z-score
input bool   inp_UseLogReturns  = true;// Calculate Logarithmic Returns instead of Simple

//--- Global indicator data arrays
double g_BufSharpe[];
double g_BufUpper[];
double g_BufLower[];
double g_BufZero[];

//--- Shared analytical cached values
double g_AnnFactor = 1.0;

//+------------------------------------------------------------------+
//| ComputeBar                                                       |
//| Purpose: Evaluates rolling Sharpe statistics and standard errors |
//|          for an isolated bar position using multi-pass analysis. |
//+------------------------------------------------------------------+
void ComputeBar(const int      i,
                const double   &close[],
                const int      window,
                const double   annFactor,
                const double   zScore,
                const bool     useLog,
                double         &outSharpe,
                double         &outUpper,
                double         &outLower)
  {
   outSharpe = EMPTY_VALUE;
   outUpper  = EMPTY_VALUE;
   outLower  = EMPTY_VALUE;

//--- Verify that enough historical data points exist prior to the index
   if(i < window)
      return;

   double sum = 0.0;

//--- Pass 1: Compute empirical mean of returns over the sliding window
   for(int k = i - window + 1; k <= i; k++)
     {
      double prevClose = close[k - 1];
      if(prevClose < 1e-10)
         return; // Abort structural processing if an invalid price point is hit

      double ret = useLog ? MathLog(close[k] / prevClose) : (close[k] - prevClose) / prevClose;
      sum += ret;
     }
   double mean = sum / (double)window;

   double ssq = 0.0;

//--- Pass 2: Compute unbiased sample variance across the window
   for(int k = i - window + 1; k <= i; k++)
     {
      double prevClose = close[k - 1];
      double ret = useLog ? MathLog(close[k] / prevClose) : (close[k] - prevClose) / prevClose;
      double dev = ret - mean;
      ssq += dev * dev;
     }
   double variance = ssq / (double)(window - 1);

//--- Handle edge cases with zero/degenerate market variance
   if(variance < 1e-24)
      return;

   double stdDev = MathSqrt(variance);
   double sr_raw = mean / stdDev;
   double sr_ann = sr_raw * annFactor;

//--- Apply asymptotic standard error framework using Lo (2002) model equations
   double se_raw = MathSqrt((1.0 + 0.5 * sr_raw * sr_raw) / (double)window);
   double se_ann = annFactor * se_raw;

//--- Map analytical values onto output destination reference parameters
   outSharpe = sr_ann;
   outUpper  = sr_ann + zScore * se_ann;
   outLower  = sr_ann - zScore * se_ann;
  }

//+------------------------------------------------------------------+
//| Custom Indicator Initialization Function                         |
//+------------------------------------------------------------------+
int OnInit(void)
  {
//--- Perform input verification checks
   if(inp_Window < 2)
     {
      Alert("RollingSharpe: inp_Window must be >= 2. Aborting.");
      return(INIT_PARAMETERS_INCORRECT);
     }
   if(inp_PeriodsPerYear < 1)
     {
      Alert("RollingSharpe: inp_PeriodsPerYear must be >= 1. Aborting.");
      return(INIT_PARAMETERS_INCORRECT);
     }
   if(inp_ZScore <= 0.0)
     {
      Alert("RollingSharpe: inp_ZScore must be > 0. Aborting.");
      return(INIT_PARAMETERS_INCORRECT);
     }

//--- Derive annualization multiplier coefficients
   g_AnnFactor = MathSqrt((double)inp_PeriodsPerYear);

//--- Bind dynamic global array vectors to structural indicator tracks
   SetIndexBuffer(0, g_BufSharpe, INDICATOR_DATA);
   SetIndexBuffer(1, g_BufUpper,  INDICATOR_DATA);
   SetIndexBuffer(2, g_BufLower,  INDICATOR_DATA);
   SetIndexBuffer(3, g_BufZero,   INDICATOR_DATA);

//--- Explicitly assign systemic out-of-bounds rendering targets
   PlotIndexSetDouble(0, PLOT_EMPTY_VALUE, EMPTY_VALUE);
   PlotIndexSetDouble(1, PLOT_EMPTY_VALUE, EMPTY_VALUE);
   PlotIndexSetDouble(2, PLOT_EMPTY_VALUE, EMPTY_VALUE);
   PlotIndexSetDouble(3, PLOT_EMPTY_VALUE, EMPTY_VALUE);

//--- Offset rendering boundaries to match lookback window data constraints
   PlotIndexSetInteger(0, PLOT_DRAW_BEGIN, inp_Window);
   PlotIndexSetInteger(1, PLOT_DRAW_BEGIN, inp_Window);
   PlotIndexSetInteger(2, PLOT_DRAW_BEGIN, inp_Window);
   PlotIndexSetInteger(3, PLOT_DRAW_BEGIN, 0);

//--- Set visual tracking float precision limits
   IndicatorSetInteger(INDICATOR_DIGITS, 4);

   return(INIT_SUCCEEDED);
  }

//+------------------------------------------------------------------+
//| Custom Indicator Deinitialization Function                       |
//+------------------------------------------------------------------+
void OnDeinit(const int reason)
  {
  }

//+------------------------------------------------------------------+
//| 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[])
  {
//--- Ensure dataset sizes exceed minimum parsing window depths
   if(rates_total < inp_Window + 1)
      return(0);

//--- Assert system security against reverse index array formatting flags
   if(ArrayGetAsSeries(close))
     {
      Print("RollingSharpe: time-series array ordering detected. Aborting.");
      return(0);
     }

//--- Establish processing ranges depending on complete or incremental calculations
   int startBar = (prev_calculated == 0) ? 1 : prev_calculated - 1;

//--- Flush baseline data elements if handling a complete data recalculation pass
   if(prev_calculated == 0)
     {
      for(int i = 0; i < MathMin(inp_Window, rates_total); i++)
        {
         g_BufSharpe[i] = EMPTY_VALUE;
         g_BufUpper[i]  = EMPTY_VALUE;
         g_BufLower[i]  = EMPTY_VALUE;
         g_BufZero[i]   = 0.0;
        }
      //--- Advance initialization pointer past uncomputable warmup bar frames
      startBar = inp_Window;
     }

//--- Primary analytical calculation execution loop
   for(int i = startBar; i < rates_total; i++)
     {
      g_BufZero[i] = 0.0;

      double sharpe, upper, lower;
      ComputeBar(i, close, inp_Window, g_AnnFactor, inp_ZScore, inp_UseLogReturns, sharpe, upper, lower);

      g_BufSharpe[i] = sharpe;
      g_BufUpper[i]  = upper;
      g_BufLower[i]  = lower;
     }

   return(rates_total);
  }
//+------------------------------------------------------------------+

Architectural Breakdown of the Indicator Binding Layer

ComputeBar() — the stateless computation unit: The function accepts the full close[] array by const reference, the target bar index i, and the four configuration parameters as scalars. It returns the annualized Sharpe ratio and both significance band values through output reference parameters, writing EMPTY_VALUE to all three if any validity condition is not met. The function has no side effects and holds no state between calls.

Pass 1 iterates over the index range [i - window + 1, i], computing the period return at each step and accumulating the sum. The mean is then sum / window. Pass 2 repeats the range, accumulates squared deviations from the mean, and stores the result in ssq. The sample variance is ssq / (window - 1). This is the classical two-pass algorithm for sample variance. It is numerically stable: it uses deviations from the sample mean instead of the algebraic identity sum(r_t^2) - n * (r_bar^2), which is prone to catastrophic cancellation during low-volatility consolidation.

g_AnnFactor — single pre-computation in OnInit(): The annualization multiplier √N is computed once via MathSqrt((double)inp_PeriodsPerYear) and stored in the module-level double g_AnnFactor. Every call to ComputeBar() uses this pre-computed value directly. On a full recalculation pass over 8,000 bars, this avoids 8,000 redundant MathSqrt() calls with an identical argument.

PLOT_EMPTY_VALUE and PLOT_DRAW_BEGIN — dual rendering suppression: Two independent mechanisms prevent warm-up zone rendering artifacts. First, ComputeBar() returns EMPTY_VALUE for any i < window, so the buffer slots for the warm-up bars receive EMPTY_VALUE unconditionally. Second, PlotIndexSetInteger(plot, PLOT_DRAW_BEGIN, inp_Window) instructs the renderer to begin connecting data points only from bar index inp_Window onward, preventing the renderer from drawing a line from an EMPTY_VALUE slot to the first valid value.

prev_calculated state machine and the warm-up pre-fill: On a full recalculation (prev_calculated == 0), a for loop explicitly writes EMPTY_VALUE into every buffer slot from index 0 to inp_Window - 1 before the main loop begins. This guarantees that no stale values from any previous calculation pass survive in the warm-up zone. The main loop then starts at startBar = inp_Window, the first index for which ComputeBar() can return valid output. On incremental updates, startBar = prev_calculated - 1 re-processes the last confirmed bar to absorb any tick-level change in its close price.

Immunity to partial-history recalculation triggers: MetaTrader 5 fires prev_calculated = 0 under conditions that include chart viewport scroll events and background chart updates, and does not guarantee that rates_total reflects the full loaded history in these cases. Because ComputeBar() reads its input window entirely from close[] on every call with no dependency on any prior call’s state, the output is arithmetically correct for whatever bars are present in close[] regardless of what triggered the recalculation.

OnDeinit() is empty: The indicator holds no heap-allocated state — no dynamic arrays beyond the four index buffers managed by the indicator engine, no object instances, and no open handles. OnDeinit() is present solely to satisfy the MQL5 indicator interface contract.

EURUSD H4 Rolling Sharpe indicator showing a declining Sharpe line contained within significance bands during the June 2026 selloff.

EURUSD H4 with inp_Window = 60, inp_PeriodsPerYear = 252. The cyan Sharpe line trends downward from approximately −0.5 toward −1.35 as the sharp June 5 selloff accumulates within the rolling window, while the upper band remains positive throughout. The Sharpe line remains within the significance bands across the visible range. This indicates a negative-momentum regime rather than confirmed directional alpha, because the lower band has not crossed zero at a 60-bar window.


Section 5: Deployment Notes and inp_PeriodsPerYear Calibration

inp_PeriodsPerYear recommended values by timeframe and instrument type:

Timeframe Instrument Type Recommended Value
M1 FX (24/5) 525,600
H1 FX (24/5)
8,736
H4 FX (24/5)
2,184
D1 FX 260
D1 Equities 252
D1 Cryptocurrency (24/7) 365
H1 Cryptocurrency (24/7)  8,760 
W1 Any 52

File placement

The two include files CReturnBuffer.mqh and CSharpeCalculator.mqh must be placed in a folder named RollingSharpe inside the MQL5/Include/ directory, giving the paths MQL5/Include/RollingSharpe/CReturnBuffer.mqh and MQL5/Include/RollingSharpe/CSharpeCalculator.mqh. The indicator file RollingSharpe.mq5 must be placed in a folder named RollingSharpe inside the MQL5/Indicators/ directory, giving the path MQL5/Indicators/RollingSharpe/RollingSharpe.mq5. The two directories are separate; mixing the files or placing them in the wrong root will cause the compiler to fail with an unresolved include error.

Log vs. arithmetic returns

The inp_UseLogReturns = true default is preferred for time-series consistency. Log returns are additive across time, making their sum a coherent measure of cumulative log-performance. For instruments with daily moves exceeding ±3% — common in cryptocurrency markets — the difference between log and arithmetic Sharpe becomes non-negligible and log returns are the correct choice.

Practical Window Sizing by Timeframe

At H1 with inp_PeriodsPerYear = 8,736, a window of 60 bars represents approximately 2.5 trading days — too narrow for statistically reliable inference, per the theoretical minimums in Section 1. A more defensible configuration for H1 FX is inp_Window = 120 (approximately 5 trading days). For cryptocurrency on H1, inp_Window = 168 covers exactly one calendar week. At D1, inp_Window = 60 represents approximately 3 calendar months and is the practical minimum for producing significance bands not dominated by sampling noise.


Section 6: Technical Wrap-up: Operational Constraints and Limitations

Non-Normal Return Distributions and IID Violations

Lo’s formula was derived under the assumption that returns are independent and identically distributed (IID) and drawn from a Gaussian distribution. In practice, financial returns exhibit four systematic violations of this assumption.

1. Fat Tails (Excess Kurtosis)
Equity and FX returns have empirical kurtosis of 4–8 at daily frequency, versus the Gaussian value of 3. Under fat-tailed distributions, the true variance of SR_hat is larger than Lo’s formula predicts. Christie (2005) showed that the bias in the standard error is approximately:

Bias ≈ ((k - 3) / 4n) * (SR_hat)^2

where k is the excess kurtosis. For k = 6, n = 60, and SR_hat = 1.0, this adds roughly 0.8% to the true SE. The practical implication is that the significance bands produced by this indicator are optimistic under fat-tailed conditions; a real-world 95% band is slightly wider than displayed.

2. Serial Autocorrelation

Trending strategies by construction generate positive autocorrelation in their return series. Lo’s autocorrelation-adjusted Sharpe variance replaces SE(SR_hat) with:

SE_AC(SR_hat) = sqrt((1 / n) * (1 + 2 * sum(rho_hat_k)) * (1 + (SR_hat^2 / 2)))

where rho_hat_k is the sample autocorrelation at lag k from k = 1 to q. Positive rho_hat_k inflates the SE; strategies with persistent momentum will have their significance bands systematically understated by the IID version implemented here.

3. Non-Stationarity

The rolling window assumes return stationarity within the lookback period. Structural breaks — central bank announcements, geopolitical events, volatility regime transitions — violate this assumption. The indicator responds to regime shifts with a lag equal to the window length: it takes inp_window for the old regime’s returns to fully exit the computation window.

4. Integer Annualization

The inp_PeriodsPerYear parameter is necessarily approximate. For H1 charts, the true value depends on the instrument’s trading hours: FX is 24/5 giving approximately 8,736 bars per year; cryptocurrency instruments trade 24/7 giving 8,760; equity indices on H1 may give approximately 1,950. Misconfiguring this parameter scales all Sharpe output by sqrt(N_wrong / N_correct) without affecting the shape of the Sharpe curve, but produces numerically incorrect annualized values that mislead cross-strategy comparisons.

Structural Lag and Window Length Trade-offs

The fixed-window rolling estimator imposes a lag of n/2 bars in its response to a genuine regime change in the underlying Sharpe ratio. This is the unavoidable cost of statistical stability. Narrower windows respond faster but produce variance-inflated estimates that generate false significance signals, as quantified in the table in Section 1.

The minimum-sample-size formula and its practical implications for inp_Window selection are covered in Section 1. Practitioners operating on H1 FX should use inp_Window of at least 120 bars to balance detection sensitivity against false-signal suppression during consolidation phases.

Computational Complexity Profile

The indicator uses a two-pass mean-and-variance algorithm inside ComputeBar(). Each pass iterates over inp_Window, making the per-bar cost O(n) where n= inp_Window. The full operation count per bar is:

Per-bar arithmetic cost breakdown for ComputeBar()

Operation Count Complexity
Pass 1: return accumulation inp_Window iterations, 1 add/step O(n)
Mean division 1 O(1)
Pass 2: squared deviation inp_Window iterations, 2 mul+1 add/step O(n)
Variance, stddev, sr_raw, sr_ann 4 arithmetic ops O(1)
Lo (2002) SE computation 1 sqrt, 2 mul, 1 div O(1)
Band computation 2 mul, 2 add O(1)
Total per bar 2 * inp_Window iterations O(n)

For a full recalculation pass over rates_total bars, the total floating-point operation count is O(n×rates_total). At inp_Window = 60 with 8,000 bars loaded, this is approximately 960,000 operations, completing in under 5 milliseconds on any modern processor. At inp_Window = 252 with 20,000 bars, the count reaches approximately 10 million operations, still completing in well under 50 milliseconds. Per-tick incremental updates execute 2 * inp_Window iterations for a single bar — 120 operations at inp_Window = 60 — which is negligible even when the indicator runs simultaneously across 20 symbols. The O(n) cost is the deliberate trade-off for eliminating state-dependent rendering artifacts; it becomes a practical concern only at inp_Window values above 5,000, which no practitioner configuration requires.


Conclusion

This article ties the statistical theory of sample Sharpe and Lo's standard-error formula to a practical, MetaTrader 5-ready implementation that makes uncertainty visible in real time. Deliverables include the RollingSharpe.mq5 indicator and two include modules (CReturnBuffer.mqh, CSharpeCalculator.mqh). The indicator itself uses a stateless per-bar ComputeBar() routine to avoid MetaTrader 5 full-recalc artifacts; the include classes are provided for EA integration or alternate stateful usage where persistent buffers are appropriate. The indicator plots the annualized Sharpe line, upper and lower ±z·SE bands, and a zero baseline. Operational rule of thumb: whenever the bands include zero, the observed Sharpe is statistically indistinguishable from noise at the chosen confidence level; only when the bands exclude zero should you consider the window's SR evidence of an edge. Use inp_Window and inp_PeriodsPerYear carefully—longer windows reduce sampling noise but introduce lag—so choose settings consistent with your detection horizon and the minimum detectable Sharpe you require.


Programs used in the article:

# Name Type Description
1 CReturnBuffer.mqh Include File Fixed-capacity circular buffer implementing O(1) push with incremental running sum and sum-of-squares accumulators. Exposes Init(), Push(), Mean(), Variance(), StdDev(), IsFull(), Get(), and Reset(). Used by CSharpeCalculator.mqh and available for standalone EA integration.
2 CSharpeCalculator.mqh Include File
Rolling annualized Sharpe ratio engine built on CReturnBuffer. Implements Lo (2002) asymptotic standard error computation and returns a fully populated SSharpeResult struct containing the annualized Sharpe, upper band, lower band, SE, and validity flag on each Calculate() call.
3 RollingSharpe.mq5 Custom Indicator Custom MQL5 indicator that renders the rolling annualized Sharpe ratio as a solid cyan line with ±z⋅SE_ann significance bands in a separate sub-window. Includes CReturnBuffer.mqh and CSharpeCalculator.mqh. Computes all values stateless per bar via ComputeBar(), reading directly from close[]. Configurable via inp_Window, inp_PeriodsPerYear, inp_ZScore, and inp_UseLogReturns.
 4 Rolling_Sharpe.zip  Zip Archive  Zip archive containing all the attached files and their paths relative to the terminal's root folder.
Attached files |
CReturnBuffer.mqh (8.12 KB)
RollingSharpe.mq5 (8.68 KB)
Rolling_Sharpe.zip (7.88 KB)
Building a Traditional Point and Figure Indicator in MQL5 Building a Traditional Point and Figure Indicator in MQL5
This article implements a custom Point and Figure indicator in MQL5 that maps price movement into X/O columns using a fixed box size and three-box reversal logic. We define the base price, convert prices into box intervals, manage trends and reversals, auto-scale the indicator window, and render symbols with objects, providing a clean, time-independent view of trends, breakouts, and support/resistance.
A Practical Kalman Filter Price Smoother in MQL5: Adaptive Noise Estimation Without External Libraries A Practical Kalman Filter Price Smoother in MQL5: Adaptive Noise Estimation Without External Libraries
Fixed-weight moving averages introduce regime-insensitive lag. This work presents an adaptive scalar Kalman filter indicator in native MQL5 that estimates process noise Q from rolling return variance and measurement noise R from rolling price variance, with floor clamps for stability, and recomputes the Kalman Gain on every bar. The chart-overlay output is benchmarked against a 20-period EMA using MAE, RMSE, lag, and smoothness metrics to quantify tracking and noise suppression.
Features of Experts Advisors Features of Experts Advisors
Creation of expert advisors in the MetaTrader trading system has a number of features.
From Basic to Intermediate: Object Events (I) From Basic to Intermediate: Object Events (I)
In this article, we will look at three of the six events that MetaTrader 5 can generate when some change occurs to an object on the chart. These events are very useful from the standpoint of user interaction. This is because, without understanding these events, we would have to put in much more effort to maintain a specific chart configuration when trying to manage objects for particular purposes.