Rolling Sharpe Ratio with Statistical Significance Bands in MQL5
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:
![]()
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:
![]()
The corresponding standard error of the annualized estimate is:

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

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:

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:
- Primary Chart Window: Displays the raw candlestick price action for structural baseline reference.
- 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 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. |
Warning: All rights to these materials are reserved by MetaQuotes Ltd. Copying or reprinting of these materials in whole or in part is prohibited.
This article was written by a user of the site and reflects their personal views. MetaQuotes Ltd is not responsible for the accuracy of the information presented, nor for any consequences resulting from the use of the solutions, strategies or recommendations described.
Building a Traditional Point and Figure Indicator in MQL5
A Practical Kalman Filter Price Smoother in MQL5: Adaptive Noise Estimation Without External Libraries
Features of Experts Advisors
From Basic to Intermediate: Object Events (I)
- Free trading apps
- Over 8,000 signals for copying
- Economic news for exploring financial markets
You agree to website policy and terms of use