Warrior_EA/mql5_patches/VolEstimators.mqh
AnimateDread 9d60289f9f research(vol): meta-label blueprint, and the premise test that sank the 70% forecast
BLUEPRINT.md reviews the feature/label layers and designs fractional
differencing, Garman-Klass / Yang-Zhang targets and a 48-72h expansion
label; mql5_patches/ holds the MQL5 side (FFD safe past the 1024-bar
series ceiling, vol estimators, the label + veto gate, NY-time swap window).

premise_test.py measured the premise on real broker bars: the headline
AUC 0.75 was a day-of-week / path-length artifact (a Friday-clipped path
is shorter, so it touches K*ATR less). Honest residual 0.56-0.62, mostly
within noise once overlap is deflated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 13:24:57 -04:00

180 lines
7.6 KiB
MQL5

//+------------------------------------------------------------------+
//| VolEstimators.mqh |
//| Garman-Klass / Parkinson / Rogers-Satchell / Yang-Zhang |
//+------------------------------------------------------------------+
//| sigma2_GK = 0.5*(ln(H/L))^2 - (2ln2 - 1)*(ln(C/O))^2 |
//| |
//| NON-NEGATIVE BY CONSTRUCTION for any valid bar: H >= max(O,C) and |
//| L <= min(O,C) give ln(H/L) >= |ln(C/O)|, so |
//| GK >= (0.5 - 0.3863)*ln(C/O)^2 = 0.1137*ln(C/O)^2 >= 0. |
//| Do NOT add a clamp and call it a safety net: on a CORRUPT bar |
//| (H < L, or an EMPTY_VALUE reading as DBL_MAX) GK does not go |
//| negative - it returns a plausible POSITIVE variance. The only |
//| defence is validating O/H/L/C, which is what GKValid() does and |
//| what every entry point here calls first. |
//| |
//| GK IS BLIND TO GAPS. It assumes the bar opened where the previous |
//| one closed. Index CFDs gap across the daily maintenance break and |
//| the weekend, so on D1 GK UNDERSTATES the variance a multi-day |
//| hold is actually exposed to. For sizing an intra-week hold use |
//| YangZhangSigma(), which prices the gap explicitly. |
//+------------------------------------------------------------------+
#ifndef VOLESTIMATORS_MQH
#define VOLESTIMATORS_MQH
#define VOL_LN2 0.69314718055994531
//+------------------------------------------------------------------+
//| Bar validity. Rejects the EMPTY_VALUE/DBL_MAX case explicitly - |
//| it passes MathIsValidNumber and would poison every estimator. |
//+------------------------------------------------------------------+
bool GKValid(const double o, const double h, const double l, const double c)
{
if(!MathIsValidNumber(o) || !MathIsValidNumber(h) ||
!MathIsValidNumber(l) || !MathIsValidNumber(c))
return false;
if(o == EMPTY_VALUE || h == EMPTY_VALUE || l == EMPTY_VALUE || c == EMPTY_VALUE)
return false;
if(o <= 0.0 || h <= 0.0 || l <= 0.0 || c <= 0.0)
return false;
//--- the inversion that produces a plausible positive variance
if(h < l)
return false;
if(h < o || h < c || l > o || l > c)
return false;
return true;
}
//--- Per-bar GK variance. Returns false (not 0.0) on a bad bar, so a
//--- caller can drop the bar instead of averaging a fabricated zero in.
bool GarmanKlassVar(const double o, const double h, const double l,
const double c, double &v)
{
if(!GKValid(o, h, l, c))
return false;
double hl = MathLog(h / l);
double co = MathLog(c / o);
v = 0.5 * hl * hl - (2.0 * VOL_LN2 - 1.0) * co * co;
return true;
}
//+------------------------------------------------------------------+
//| Rolling GK sigma over `window` bars ending at `shift`. |
//| |
//| Reads via Copy* directly rather than the stdlib series wrappers: |
//| a 48-72h window on H4 is 12-18 bars so the 1024 ceiling is not in |
//| play here, but keeping one convention across the vol/FFD layer |
//| removes the chance of a later window quietly crossing it. |
//+------------------------------------------------------------------+
bool GarmanKlassSigma(const string symbol, const ENUM_TIMEFRAMES period,
const int shift, const int window, double &sigma)
{
if(window <= 0 || shift < 0)
return false;
double o[], h[], l[], c[];
if(CopyOpen(symbol, period, shift, window, o) != window ||
CopyHigh(symbol, period, shift, window, h) != window ||
CopyLow(symbol, period, shift, window, l) != window ||
CopyClose(symbol, period, shift, window, c) != window)
return false;
double acc = 0.0;
int used = 0;
for(int i = 0; i < window; i++)
{
double v;
if(!GarmanKlassVar(o[i], h[i], l[i], c[i], v))
continue; // drop the bar, do not zero it
acc += v;
used++;
}
//--- Refuse a reading built from a thin remnant: a "volatility" averaged
//--- over 3 of 18 bars is not the same statistic and must not be gated on.
if(used < (window + 1) / 2)
return false;
sigma = MathSqrt(MathMax(acc / used, 0.0));
return true;
}
//--- Parkinson: high-low only, no drift term. Useful as a cross-check when
//--- GK's close-open term behaves oddly on a gappy series.
bool ParkinsonSigma(const string symbol, const ENUM_TIMEFRAMES period,
const int shift, const int window, double &sigma)
{
if(window <= 0 || shift < 0)
return false;
double h[], l[];
if(CopyHigh(symbol, period, shift, window, h) != window ||
CopyLow(symbol, period, shift, window, l) != window)
return false;
double acc = 0.0;
int used = 0;
for(int i = 0; i < window; i++)
{
if(!MathIsValidNumber(h[i]) || !MathIsValidNumber(l[i]) ||
h[i] == EMPTY_VALUE || l[i] == EMPTY_VALUE || h[i] <= 0.0 || l[i] <= 0.0 || h[i] < l[i])
continue;
double hl = MathLog(h[i] / l[i]);
acc += hl * hl / (4.0 * VOL_LN2);
used++;
}
if(used < (window + 1) / 2)
return false;
sigma = MathSqrt(acc / used);
return true;
}
//+------------------------------------------------------------------+
//| Yang-Zhang: overnight gap variance + open-close variance + the |
//| Rogers-Satchell drift-robust term. THE ONE TO GATE ON for a |
//| multi-day CFD hold, because the gap it prices is the risk the |
//| position actually carries overnight. |
//+------------------------------------------------------------------+
bool YangZhangSigma(const string symbol, const ENUM_TIMEFRAMES period,
const int shift, const int window, double &sigma,
const double kAlpha = 1.34)
{
if(window < 2 || shift < 0)
return false;
//--- one extra OLDER bar: the first overnight term needs the prior close
int need = window + 1;
double o[], h[], l[], c[];
if(CopyOpen(symbol, period, shift, need, o) != need ||
CopyHigh(symbol, period, shift, need, h) != need ||
CopyLow(symbol, period, shift, need, l) != need ||
CopyClose(symbol, period, shift, need, c) != need)
return false;
//--- Copy* returns oldest-first; index 0 is the OLDEST of the need bars.
double sumO = 0.0, sumC = 0.0, sumRS = 0.0;
double oc[], co[];
ArrayResize(oc, window);
ArrayResize(co, window);
int used = 0;
for(int i = 1; i < need; i++)
{
if(!GKValid(o[i], h[i], l[i], c[i]) || !GKValid(o[i-1], h[i-1], l[i-1], c[i-1]))
return false; // YZ needs a contiguous run, not a subset
oc[used] = MathLog(o[i] / c[i - 1]); // overnight
co[used] = MathLog(c[i] / o[i]); // open-to-close
sumO += oc[used];
sumC += co[used];
sumRS += MathLog(h[i] / c[i]) * MathLog(h[i] / o[i]) +
MathLog(l[i] / c[i]) * MathLog(l[i] / o[i]);
used++;
}
if(used < 2)
return false;
double mO = sumO / used, mC = sumC / used;
double vO = 0.0, vC = 0.0;
for(int i = 0; i < used; i++)
{
vO += (oc[i] - mO) * (oc[i] - mO);
vC += (co[i] - mC) * (co[i] - mC);
}
vO /= (used - 1);
vC /= (used - 1);
double vRS = sumRS / used;
double k = kAlpha / (kAlpha + (used + 1.0) / (used - 1.0));
sigma = MathSqrt(MathMax(vO + k * vC + (1.0 - k) * vRS, 0.0));
return true;
}
#endif // VOLESTIMATORS_MQH