//+------------------------------------------------------------------+ //| SignalDipBuy.mqh | //| AnimateDread | //| | //| THE ONE EDGE THE DATA HAS: buy the high-volatility low on a US | //| equity index at the daily close, hold about a week. | //| | //| MEASURED, not authored. The honest daily state scan (states x | //| hold, picked in-sample <2019, pooled out-of-sample against the | //| instrument's own unconditional drift, cost and swap charged) was | //| run on every instrument with data - ten Dukascopy series and the | //| broker's own index history. One cell family survived, and it is | //| the same on all three US indices: | //| SP500 posxvol=0x3 L5 +103 bp OOS (t 3.1) +17..+24 over drift| //| NAS100 r20xrsi=0x0 L5 +109 bp OOS (t 3.7) +11 over drift | //| US30 r20xrsi=0x0 L5 +102 bp OOS (t 3.1) +18 over drift | //| 20-day range LOW x high volatility, or bottom-quintile 20-day | //| return x lowest RSI(2): long, hold five days. The named form is | //| Connors' RSI(2)<10 above the 200-day, which the 176-cell swing | //| screen found flat across its whole neighbourhood (thresholds | //| 5-25, every exit, with or without the stop: all positive). | //| | //| WHERE IT DOES NOT WORK, so nobody points it there: DAX40 and | //| UK100 (picks LOSE out of sample), every forex pair (null on all | //| five), gold (drift, not selection). US indices, long only. A long | //| bias on an index is a property of the instrument, not a flaw. | //| | //| HOW IT SHOULD BE RUN. This is not a vote member. At thirteen | //| voters one filter at 100 scores 7.7%, below any threshold, so it | //| must be the ONLY registered module (UseClassics, UseWyckoff, | //| UseNeural, UseRegime all off) with Direction = DIR_LONG. Its | //| ShortCondition is the EXIT ("close above the short average"), | //| reached through ThresholdClose - it never opens a short. | //| | //| THE STOP MUST BE WIDE. Measured: a 0.5-ATR stop destroyed the | //| edge (+2.8 bp, 101 of 152 stopped) - the dip runs THROUGH a tight | //| stop before it reverts. 2-3 ATR kept it. StopMode SL_ATR_x3, | //| TargetMode TP_NONE: the exit is the signal, not a price. | //| | //| REPLICATED ON THIS EA (2026-09-13, module alone, long only, 3-ATR | //| stop, exit = signal, costs in), per trade in bp of notional: | //| 2012-2026 SP500 +34.7 (n 84, 76% won) NAS100 +37.2 (145, 62%) | //| US30 +22.8 (n 84, 68%) pooled ~+33 on 313, 3/3 | //| 2019-2026 SP500 +46.2 (n 73, PF 2.32) NAS100 +41.4 (67, 1.56) | //| US30 +25.0 (n 72, PF 1.37) - out of sample only | //| The broker's SP500/US30 history is daily-only before 2017 (one | //| tick per day, stamped in the shut hour), so those years cannot | //| fill in the tester; the effective sample there starts 2018. | //| | //| CEILING, stated so it is not oversold: ~9 trades/yr/index, ~4%/yr | //| on notional unlevered; the three-index basket at 1% risk is | //| CAGR +2.6% at 10.6% max drawdown, and the losing years (2018, | //| 2020, 2022) are the bear years on ALL THREE at once. Real, small, | //| correlated. Size for the 5% account rule accordingly. | //| | //| THE SECOND FORM OF THE SAME DIP (2026-09-13, research/style_scan | //| .py): seven strategy styles x 13 instruments x D1 and H4, honest | //| protocol. Only MEAN REVERSION, LONG, on equity indices survived - | //| and its deepest form is a z-score, not RSI(2): close more than z | //| standard deviations below its 20-bar mean, exit at that mean or | //| after 10 bars. On H4 SP500, 2019-2026, z<-1.5 made +22.7 bp per | //| trade net (n 1,428, t 5.4) on Dukascopy and +18.5 (n 1,415, t 4.4)| //| on the broker's own bars - the same variant on two data sources - | //| at ~15 trades a month, which the RSI(2) daily form (8-14 a YEAR) | //| never could. In-sample (<2019) it was positive but weak (+8.8), | //| so it is an era-strengthened claim, stated as such. The z-score | //| needs no trend filter in the scan (TrendPeriod 0 switches it off).| //| WARRIOR_DIP_ENTRY lives in Enumerations\WarriorEnums.mqh. | //| | //| THE META-LABEL (System\DipMeta.mqh). With MetaCut above 0.50 the | //| module asks an ALGLIB forest + MLP whether THIS dip pays before | //| voting. The models are fitted here, in the terminal, on every | //| past dip this same rule found and on what this same exit made of | //| it - a simulation on closed bars, so the label is exactly the | //| trade the EA would have taken. Refitted every MetaRefit bars from | //| history-so-far: walk-forward, never saved. A skipped dip is still | //| NAMED (Pattern_0) so the journal records its outcome too. | //+------------------------------------------------------------------+ #ifndef WARRIOR_SIGNALDIPBUY_MQH #define WARRIOR_SIGNALDIPBUY_MQH #include "..\Expert\WarriorSignal.mqh" #include "..\Enumerations\WarriorEnums.mqh" #include "..\System\DipMeta.mqh" class CSignalDipBuy : public CWarriorSignal { protected: CiRSI m_rsi; // RSI(2) on close int m_rsiPeriod; double m_rsiEntry; // RSI(2) at or below this = a dip int m_trendPeriod; // the long-term filter: close above this SMA (0 = off) int m_exitPeriod; // exit when close crosses above this SMA int m_maxBars; // time stop, in bars held WARRIOR_DIP_ENTRY m_entry; // which dip double m_zEntry; // z-score depth for DIP_ZSCORE int m_zPeriod; // the mean and deviation window for the z-score double m_stopAtr; // the stop the EA places, for the label simulation int m_pattern_0; // the dip entry int m_pattern_1; // the exit (voted as a "short" so ThresholdClose can act) //--- THE META-LABEL CDipMeta m_meta; double m_metaCut; // <= 0.5 = off int m_metaRefit; // refit every N bars int m_metaMinBars; // history before the first attempt int m_metaTrainedAtBars; datetime m_metaLastTry; int m_metaFits; int m_metaAsked, m_metaSkipped; double m_metaLastP; double Sma(const int shift, const int period) const; double ZScore(const int shift, const int period) const; double Atr(const int shift, const int period) const; int BarsHeld(void) const; bool DipAt(const int shift) const; bool BuildFeatures(double &x[], const int shift) const; bool LabelAt(const int shift, double &label) const; void MetaTrainIfDue(void); bool MetaTrainFromHistory(void); public: CSignalDipBuy(void); ~CSignalDipBuy(void); void RsiEntry(const double v) { m_rsiEntry = v; } void ExitPeriod(const int v) { m_exitPeriod = v; } void MaxBars(const int v) { m_maxBars = v; } void TrendPeriod(const int v) { m_trendPeriod = v; } void Entry(const WARRIOR_DIP_ENTRY e) { m_entry = e; } void ZEntry(const double v) { m_zEntry = v; } void StopAtr(const double v) { m_stopAtr = v; } void MetaCut(const double v) { m_metaCut = v; } void MetaRefit(const int v) { m_metaRefit = v; } void Pattern_0(const int v) { m_pattern_0 = v; } void Pattern_1(const int v) { m_pattern_1 = v; } virtual void ApplyPatternWeight(int pattern, int weight) { if(pattern == 0) m_pattern_0 = weight; if(pattern == 1) m_pattern_1 = weight; } virtual bool ValidationSettings(void) override; virtual bool InitIndicators(CIndicators *indicators) override; virtual int LongCondition(void) override; virtual int ShortCondition(void) override; }; //+------------------------------------------------------------------+ CSignalDipBuy::CSignalDipBuy(void) : m_rsiPeriod(2), m_rsiEntry(10.0), m_trendPeriod(200), m_exitPeriod(5), m_maxBars(10), m_entry(DIP_RSI2), m_zEntry(1.5), m_zPeriod(20), m_stopAtr(3.0), m_pattern_0(100), m_pattern_1(100), m_metaCut(0.0), m_metaRefit(500), m_metaMinBars(400), m_metaTrainedAtBars(0), m_metaLastTry(0), m_metaFits(0), m_metaAsked(0), m_metaSkipped(0), m_metaLastP(-1.0) { m_id = "DIPBUY"; m_pattern_count = 2; m_used_series = USE_SERIES_OPEN + USE_SERIES_HIGH + USE_SERIES_LOW + USE_SERIES_CLOSE + USE_SERIES_TIME; } //+------------------------------------------------------------------+ CSignalDipBuy::~CSignalDipBuy(void) { if(m_metaCut > 0.5) PrintFormat("CSignalDipBuy: META - %d fit(s); asked %d time(s), skipped %d (%.0f%%); last forest AUC %.3f, MLP AUC %.3f.", m_metaFits, m_metaAsked, m_metaSkipped, (m_metaAsked > 0 ? 100.0 * m_metaSkipped / m_metaAsked : 0.0), m_meta.ForestAUC(), m_meta.MlpAUC()); } //+------------------------------------------------------------------+ bool CSignalDipBuy::ValidationSettings(void) { if(!CWarriorSignal::ValidationSettings()) return false; if(m_rsiEntry <= 0.0 || m_rsiEntry >= 50.0 || m_exitPeriod < 2 || (m_trendPeriod != 0 && m_trendPeriod < 50) || m_zEntry < 0.5 || m_zEntry > 4.0 || m_maxBars < 1) { Print("CSignalDipBuy: settings outside the measured neighbourhood (RSI 5-25, z 1.5-2.5, exit sma5..sma20, trend 0 or >=50)"); return false; } return true; } //+------------------------------------------------------------------+ bool CSignalDipBuy::InitIndicators(CIndicators *indicators) { if(indicators == NULL || !CWarriorSignal::InitIndicators(indicators)) return false; if(!indicators.Add(GetPointer(m_rsi)) || !m_rsi.Create(m_symbol.Name(), m_period, m_rsiPeriod, PRICE_CLOSE)) { Print("CSignalDipBuy: could not create RSI"); return false; } return true; } //+------------------------------------------------------------------+ double CSignalDipBuy::Sma(const int shift, const int period) const { double s = 0.0; for(int i = 0; i < period; i++) s += Close(shift + i); return s / period; } //+------------------------------------------------------------------+ //| (close - mean) / standard deviation over `period` closed bars, | //| the population deviation as the scan computed it. | //+------------------------------------------------------------------+ double CSignalDipBuy::ZScore(const int shift, const int period) const { double s = 0.0, s2 = 0.0; for(int i = 0; i < period; i++) { const double c = Close(shift + i); if(c <= 0.0) return 0.0; // past the series buffer: no dip, not a deep one s += c; s2 += c * c; } const double mean = s / period; const double var = s2 / period - mean * mean; if(var <= 0.0) return 0.0; return (Close(shift) - mean) / MathSqrt(var); } //+------------------------------------------------------------------+ //| Average true range over `period` bars from `shift` - the same | //| arithmetic as iATR, so the simulated stop is the one the vote | //| places. | //+------------------------------------------------------------------+ double CSignalDipBuy::Atr(const int shift, const int period) const { double s = 0.0; for(int i = 0; i < period; i++) { const int b = shift + i; const double pc = Close(b + 1); double tr = High(b) - Low(b); tr = MathMax(tr, MathAbs(High(b) - pc)); tr = MathMax(tr, MathAbs(Low(b) - pc)); s += tr; } return s / period; } //+------------------------------------------------------------------+ int CSignalDipBuy::BarsHeld(void) const { if(!PositionSelect(m_symbol.Name())) return 0; return iBarShift(m_symbol.Name(), m_period, (datetime)PositionGetInteger(POSITION_TIME)); } //+------------------------------------------------------------------+ //| The dip rule at any closed bar, so history is judged by exactly | //| the rule that fires live. RSI is read from the indicator buffer, | //| which is deepened with the prices. | //+------------------------------------------------------------------+ bool CSignalDipBuy::DipAt(const int shift) const { bool dip = false; if(m_entry == DIP_ZSCORE) dip = (ZScore(shift, m_zPeriod) <= -m_zEntry); else { const double rsi = m_rsi.Main(shift); if(rsi == EMPTY_VALUE || !MathIsValidNumber(rsi)) return false; dip = (rsi <= m_rsiEntry); } //--- A trend average that reaches past the buffer averages in zeros and reads as "above". const bool trendOk = (m_trendPeriod <= 0) || (Close(shift + m_trendPeriod) > 0.0 && Close(shift) > Sma(shift, m_trendPeriod)); return dip && trendOk; } //+------------------------------------------------------------------+ //| The state at the signal bar - nothing after it is read. | //+------------------------------------------------------------------+ bool CSignalDipBuy::BuildFeatures(double &x[], const int shift) const { ArrayResize(x, DIPMETA_FEATURES); const double atr = Atr(shift, 14); if(atr <= 0.0 || !MathIsValidNumber(atr)) return false; const double atr100 = Atr(shift, 100); const double c = Close(shift), o = Open(shift), h = High(shift), l = Low(shift); double hi20 = h, lo20 = l; for(int i = 1; i < 20; i++) { hi20 = MathMax(hi20, High(shift + i)); lo20 = MathMin(lo20, Low(shift + i)); } int streak = 0; for(int i = 0; i < 10; i++) { if(Close(shift + i) < Close(shift + i + 1)) streak++; else break; } MqlDateTime t; TimeToStruct(Time(shift), t); x[0] = ZScore(shift, m_zPeriod); x[1] = (c - Sma(shift, 200)) / atr; x[2] = (c - Close(shift + 1)) / atr; x[3] = (c - Close(shift + 5)) / atr; x[4] = streak; x[5] = (hi20 > lo20) ? (c - lo20) / (hi20 - lo20) : 0.5; x[6] = (h - l) / atr; x[7] = (h > l) ? (c - l) / (h - l) : 0.5; x[8] = (o - Close(shift + 1)) / atr; x[9] = (atr100 > 0.0) ? atr / atr100 : 1.0; x[10] = EfficiencyRatio(shift, 20); x[11] = VarianceRatio(shift, 60, 5); x[12] = RegimeCode(shift); x[13] = t.day_of_week / 6.0; x[14] = t.hour / 24.0; for(int f = 0; f < DIPMETA_FEATURES; f++) if(!MathIsValidNumber(x[f])) return false; return true; } //+------------------------------------------------------------------+ //| WHAT THIS MODULE'S OWN EXIT MADE OF THE DIP AT `shift`: fill at | //| the next bar's open; a stop m_stopAtr ATR below it, filled at the | //| stop; else the first close above the exit average, filled at the | //| open after it (the vote reads the closed bar and acts on the next | //| tick); else the time stop, filled the same way. 1 = it paid. | //+------------------------------------------------------------------+ bool CSignalDipBuy::LabelAt(const int shift, double &label) const { if(shift < m_maxBars + 2) return false; // the outcome is not fully on closed bars yet const double atr = Atr(shift, 14); if(atr <= 0.0) return false; const double fill = Open(shift - 1); const double stop = fill - m_stopAtr * atr; double exitPx = 0.0; for(int k = 1; k <= m_maxBars; k++) { const int b = shift - k; if(m_stopAtr > 0.0 && Low(b) <= stop) { exitPx = stop; break; } if(Close(b) > Sma(b, m_exitPeriod) || k == m_maxBars) { exitPx = Open(b - 1); break; } } if(exitPx <= 0.0) return false; label = (exitPx > fill) ? 1.0 : 0.0; return true; } //+------------------------------------------------------------------+ //| Rebuild the rows from history-so-far and refit. Bars() in the | //| tester is history accrued to the bar being decided, so the fit | //| can only ever have seen the past. | //+------------------------------------------------------------------+ bool CSignalDipBuy::MetaTrainFromHistory(void) { const int bars = Bars(m_symbol.Name(), m_period); const int first = 310; // ATR100 + SMA200 + the label's own window, with margin const int last = bars - 2; if(last - first < 100) return false; const int want = MathMin(bars, WARRIOR_NET_HISTORY); const bool deepRsi = (want <= m_rsi.BufferSize()) || m_rsi.BufferResize(want); if(!DeepenPrices(want) || !deepRsi) { PrintFormat("CSignalDipBuy: could not deepen series to %d bar(s) - meta not trained.", want); return false; } CMatrixDouble xy(last - first + 1, DIPMETA_FEATURES + 1); int rows = 0, dips = 0; double x[], label; for(int s = last; s >= first; s--) // oldest to newest: the split is chronological { if(!DipAt(s)) continue; dips++; if(!BuildFeatures(x, s) || !LabelAt(s, label)) continue; for(int f = 0; f < DIPMETA_FEATURES; f++) xy.Set(rows, f, x[f]); xy.Set(rows, DIPMETA_FEATURES, label); rows++; } PrintFormat("CSignalDipBuy: META fit #%d at %d bars - %d dip(s) in history, %d labelled row(s).", m_metaFits + 1, bars, dips, rows); //--- Embargo = the label's window: the last training dips share their outcome bars with the //--- first validation dips. Dips cluster, so two horizons rather than one. return m_meta.Train(xy, rows, 2 * (m_maxBars + 2)); } //+------------------------------------------------------------------+ void CSignalDipBuy::MetaTrainIfDue(void) { if(m_metaCut <= 0.5) return; const datetime bar = iTime(m_symbol.Name(), m_period, 0); if(bar == m_metaLastTry) return; const int bars = Bars(m_symbol.Name(), m_period); if(bars < m_metaMinBars) return; if(m_metaTrainedAtBars > 0 && (m_metaRefit <= 0 || bars < m_metaTrainedAtBars + m_metaRefit)) return; m_metaLastTry = bar; m_metaTrainedAtBars = bars; // back off whether it succeeds or fails if(MetaTrainFromHistory()) m_metaFits++; } //+------------------------------------------------------------------+ //| THE DIP: evaluated on the closed bar, acted on at the next open - | //| exactly the "fill at next open" the screen priced. With the meta | //| on, a dip the model rates below the cut is named but not voted. | //+------------------------------------------------------------------+ int CSignalDipBuy::LongCondition(void) { MetaTrainIfDue(); const int idx = StartIndex(); if(!DipAt(idx)) return 0; m_active_pattern = "Pattern_0"; m_active_direction = "Buy"; if(m_metaCut > 0.5 && m_meta.Ready()) { double x[]; if(BuildFeatures(x, idx)) { m_metaLastP = m_meta.Score(x); if(m_metaLastP >= 0.0) { m_metaAsked++; if(m_metaLastP < m_metaCut) { m_metaSkipped++; return 0; } } } } return m_pattern_0; } //+------------------------------------------------------------------+ //| THE EXIT, expressed as a short vote so the standard library's | //| CheckCloseLong() (-direction >= ThresholdClose) can act on it. | //| With Direction = DIR_LONG it can never OPEN a short. Two ways | //| out: the close crosses back above the short average - the dip | //| has reverted - or the time stop, because a dip that has not | //| reverted in ten bars is not the setup that was measured. | //+------------------------------------------------------------------+ int CSignalDipBuy::ShortCondition(void) { const int idx = StartIndex(); const bool reverted = (Close(idx) > Sma(idx, m_exitPeriod)); const bool timedOut = (m_maxBars > 0 && BarsHeld() >= m_maxBars); if(reverted || timedOut) { m_active_pattern = "Pattern_1"; m_active_direction = "Sell"; return m_pattern_1; } return 0; } #endif // WARRIOR_SIGNALDIPBUY_MQH