//+------------------------------------------------------------------+ //| 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