forked from animatedread/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
|