forked from mnbvc188199/Warrior_EA
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>
283 lines
13 KiB
MQL5
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
|