Warrior_EA/mql5_patches/VolMetaLabel.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

283 lines
13 KiB
MQL5

//+------------------------------------------------------------------+
//| VolMetaLabel.mqh |
//| 48-72h unsigned expansion label + the live meta-label gate |
//+------------------------------------------------------------------+
//| THE LABEL (training side) |
//| |
//| threshold = K * ATR(t0) |
//| path = bars t0+1 .. deadline |
//| expansion = max( maxHigh(path) - entry , entry - minLow(path) ) |
//| y = 1 if expansion >= threshold else 0 |
//| |
//| UNSIGNED ON PURPOSE. The current head is a 3-class softmax read |
//| by Argmax3(pBuy,pSell,pNeutral) with ties falling to Neutral - |
//| that is the majority-class trap, and it is structural, not a |
//| tuning problem: Neutral is both the modal class AND the tiebreak. |
//| A 2-class expansion/chop head has no third bucket to collapse to. |
//| |
//| WHY THIS IS A SMALLER CHANGE THAN IT LOOKS: LegRideLabel() is |
//| ALREADY a magnitude question ("is there >= LEG_LABEL_MIN_RIDE_ATR |
//| left in this leg"). It merely signs the answer with the leg's |
//| direction at the end. This label asks the same magnitude question |
//| on a fixed clock and drops the sign. |
//| |
//| *** THE WEEKEND CLIP IS LOAD-BEARING *** |
//| A Wednesday trigger + 72h lands on Saturday. With a mandatory |
//| Friday flat, scoring that path labels a position the strategy is |
//| FORBIDDEN to hold. So the path is clipped at the weekly deadline |
//| and a row whose clipped span is under H_min is UNRESOLVED, NOT 0. |
//| Labelling it 0 would invent a chop class that is really "ran out |
//| of week" - and that class correlates with day-of-week, which the |
//| network already sees through its sin/cos time features. It would |
//| learn the calendar and score well doing it. |
//| Measured on H4 with a Friday 16:00 NY flat: ~43% of triggers fall |
//| to this clip. That is a sample-size fact to plan around, not a |
//| bug to code around. |
//| |
//| OVERLAP IS NOT OPTIONAL EITHER. A 72h label on H4 spans 18 bars, |
//| so 18 consecutive rows resolve against overlapping paths. Report |
//| PathBars() into the EXISTING CLabelOverlap/EffectiveSampleSize |
//| machinery in Labels.mqh. A raw N here overstates significance by |
//| roughly sqrt(18) ~ 4.2x. |
//+------------------------------------------------------------------+
#ifndef VOLMETALABEL_MQH
#define VOLMETALABEL_MQH
#include "SwapWindow.mqh"
enum ENUM_VOL_LABEL
{
VOL_UNRESOLVED = -1, // dropped: clipped, or bad data. NEVER a class.
VOL_CHOP = 0,
VOL_EXPANSION = 1
};
//+------------------------------------------------------------------+
//| CVolExpansionLabel |
//+------------------------------------------------------------------+
class CVolExpansionLabel
{
private:
string m_symbol;
ENUM_TIMEFRAMES m_period;
double m_kAtr;
int m_hMinHours;
int m_hMaxHours;
ENUM_DAY_OF_WEEK m_flatDay;
int m_flatMinNy;
//--- diagnostics for the last call
int m_pathBars;
double m_expansionAtr;
int m_barsToHit;
public:
CVolExpansionLabel(void) : m_kAtr(2.0), m_hMinHours(48), m_hMaxHours(72),
m_flatDay(FRIDAY), m_flatMinNy(16 * 60),
m_pathBars(0), m_expansionAtr(0.0), m_barsToHit(-1) { }
void Init(const string symbol, const ENUM_TIMEFRAMES period,
const double kAtr, const int hMinHours, const int hMaxHours,
const ENUM_DAY_OF_WEEK flatDay, const int flatMinNy)
{
m_symbol = symbol;
m_period = period;
m_kAtr = kAtr;
m_hMinHours = hMinHours;
m_hMaxHours = hMaxHours;
m_flatDay = flatDay;
m_flatMinNy = flatMinNy;
}
int PathBars(void) const { return m_pathBars; }
double ExpansionAtr(void) const { return m_expansionAtr; }
int BarsToHit(void) const { return m_barsToHit; }
//+---------------------------------------------------------------+
//| The weekly flat instant (UTC) for the week containing `utc`. |
//+---------------------------------------------------------------+
datetime WeeklyDeadlineUtc(const datetime utc) const
{
datetime ny = SWNewYorkTime(utc);
MqlDateTime n;
TimeToStruct(ny, n);
int daysAhead = ((int)m_flatDay - n.day_of_week + 7) % 7;
int nowMin = n.hour * 60 + n.min;
if(daysAhead == 0 && nowMin >= m_flatMinNy)
daysAhead = 7; // this week's cutoff already passed
datetime flatNy = ny + daysAhead * 86400;
MqlDateTime f;
TimeToStruct(flatNy, f);
f.hour = m_flatMinNy / 60;
f.min = m_flatMinNy % 60;
f.sec = 0;
flatNy = StructToTime(f);
//--- back to UTC using the offset AT THAT INSTANT, not today's
return flatNy - SWNewYorkUtcOffset(flatNy);
}
//+---------------------------------------------------------------+
//| Label the bar at series index `shift` (0 = newest). |
//| |
//| NO LOOKAHEAD BY CONSTRUCTION: the path is bars shift-1 .. 1, |
//| i.e. strictly NEWER closed bars, and the entry/ATR come from |
//| `shift` itself. A path that has not completed inside loaded |
//| history returns VOL_UNRESOLVED and must be revisited, never |
//| cached - the same finality rule AdvanceSwingLabelState() uses. |
//+---------------------------------------------------------------+
ENUM_VOL_LABEL Label(const int shift, const double entry, const double atr)
{
m_pathBars = 0;
m_expansionAtr = 0.0;
m_barsToHit = -1;
if(shift < 1 || !MathIsValidNumber(entry) || entry <= 0.0 || entry == EMPTY_VALUE)
return VOL_UNRESOLVED;
//--- == EMPTY_VALUE explicitly: a cold ATR reads as DBL_MAX, which would
//--- make the threshold unreachable and label the whole sweep CHOP.
if(!MathIsValidNumber(atr) || atr <= 0.0 || atr == EMPTY_VALUE)
return VOL_UNRESOLVED;
datetime t0[];
if(CopyTime(m_symbol, m_period, shift, 1, t0) != 1)
return VOL_UNRESOLVED;
datetime hardStop = t0[0] + m_hMaxHours * 3600;
datetime weekStop = WeeklyDeadlineUtc(t0[0]);
datetime deadline = (weekStop < hardStop) ? weekStop : hardStop;
//--- walk toward NEWER bars (decreasing index) until the deadline
int endShift = -1;
for(int e = shift - 1; e >= 0; e--)
{
datetime te[];
if(CopyTime(m_symbol, m_period, e, 1, te) != 1)
break;
if(te[0] > deadline)
break;
endShift = e;
}
if(endShift < 0 || endShift >= shift)
return VOL_UNRESOLVED; // no path yet - leading edge
datetime tEnd[];
if(CopyTime(m_symbol, m_period, endShift, 1, tEnd) != 1)
return VOL_UNRESOLVED;
//--- HARD DROP, never a CHOP: a path cut short by the flat rule is
//--- absence of evidence, not evidence of chop.
if((int)(tEnd[0] - t0[0]) < m_hMinHours * 3600)
return VOL_UNRESOLVED;
int count = shift - endShift; // bars shift-1 .. endShift
double hi[], lo[];
if(CopyHigh(m_symbol, m_period, endShift, count, hi) != count ||
CopyLow(m_symbol, m_period, endShift, count, lo) != count)
return VOL_UNRESOLVED;
double thr = m_kAtr * atr;
double maxUp = 0.0, maxDn = 0.0;
int firstHit = -1;
//--- Copy* is oldest-first, so walk it backwards to visit the path in
//--- chronological order and capture WHEN the barrier was first touched.
for(int i = count - 1; i >= 0; i--)
{
if(!MathIsValidNumber(hi[i]) || !MathIsValidNumber(lo[i]) ||
hi[i] == EMPTY_VALUE || lo[i] == EMPTY_VALUE || hi[i] <= 0.0 || lo[i] <= 0.0)
return VOL_UNRESOLVED;
if(hi[i] - entry > maxUp)
maxUp = hi[i] - entry;
if(entry - lo[i] > maxDn)
maxDn = entry - lo[i];
if(firstHit < 0 && (maxUp >= thr || maxDn >= thr))
firstHit = count - i;
}
double expansion = MathMax(maxUp, maxDn);
m_pathBars = count;
m_expansionAtr = expansion / atr;
m_barsToHit = firstHit;
return (expansion >= thr) ? VOL_EXPANSION : VOL_CHOP;
}
};
//+------------------------------------------------------------------+
//| CVolMetaGate - the LIVE filter. |
//| |
//| *** WHERE THIS MUST BE CALLED, AND WHERE IT MUST NOT *** |
//| |
//| It must NOT be wired as another voting signal module. Two reasons,|
//| both already paid for in this codebase: |
//| - CExpertSignal has NO per-side veto. A module returning 0 is |
//| an abstention that the ensemble averages away, not a block. |
//| - Returning EMPTY_VALUE to mean "no" silences the WHOLE |
//| ensemble, not one side. |
//| A meta-label is a VETO, so it belongs on the entry path as a |
//| single gate - structurally the same place, and the same shape, as |
//| the TCCanOpen() gate: one call, one bool, one logged reason. |
//| |
//| Hook: CExpertCustom::Processing(), immediately before the |
//| CheckOpenLong()/CheckOpenShort() call (ExpertCustom.mqh ~528). |
//+------------------------------------------------------------------+
class CVolMetaGate
{
private:
double m_minCertainty; // e.g. 0.70
double m_overrideMultiple; // swap-drag override margin
bool m_enabled;
string m_lastReason;
public:
CVolMetaGate(void) : m_minCertainty(0.70), m_overrideMultiple(3.0),
m_enabled(true), m_lastReason("") { }
void Init(const double minCertainty, const double overrideMultiple,
const bool enabled)
{
m_minCertainty = minCertainty;
m_overrideMultiple = overrideMultiple;
m_enabled = enabled;
}
string LastReason(void) const { return m_lastReason; }
//+---------------------------------------------------------------+
//| pExpansion : network P(expansion) for the next 48-72h, 0..1 |
//| Returns true when the primary pattern may proceed. |
//| |
//| A pExpansion outside [0,1] means the head did not produce a |
//| reading. That BLOCKS. An unavailable meta-label must never |
//| degrade to "allow" - that is how a filter silently stops |
//| filtering while the log still says it is on. |
//+---------------------------------------------------------------+
bool Allowed(const string symbol, const bool isLong, const double lots,
const double pExpansion, const double expectedMoveAtr,
const double atrPrice)
{
if(!m_enabled)
{
m_lastReason = "meta-gate disabled";
return true;
}
if(!MathIsValidNumber(pExpansion) || pExpansion < 0.0 || pExpansion > 1.0)
{
m_lastReason = "no meta-label reading - blocked";
return false;
}
if(pExpansion < m_minCertainty)
{
m_lastReason = StringFormat("chop forecast: P(expansion)=%.3f < %.2f",
pExpansion, m_minCertainty);
return false;
}
string why = "";
if(SWBlockedByTripleSwap(symbol, isLong, lots, expectedMoveAtr, atrPrice,
m_overrideMultiple, why))
{
m_lastReason = why;
return false;
}
m_lastReason = StringFormat("allowed: P(expansion)=%.3f%s", pExpansion,
(StringLen(why) > 0 ? " (" + why + ")" : ""));
return true;
}
};
#endif // VOLMETALABEL_MQH