//+------------------------------------------------------------------+ //| COrderBlockScanner.mqh | //| Centaur Quant Architecture — Execution Module | //| Strict SMC Order Block + FVG Zone Scanner | //+------------------------------------------------------------------+ //| PURPOSE | //| Identifies recent Bullish/Bearish Order Blocks that are backed | //| by a clear Fair Value Gap (FVG / imbalance) created by an | //| expanding displacement candle. The zone must be UNMITIGATED | //| (price never retraced into it since formation) and the CURRENT | //| live price must be approaching/tapping it within an ATR-based | //| proximity buffer (via CSymbolNormalizer). | //| The scanner only DETECTS; the caller feeds the returned | //| coordinates (entry/sl/tp) into CSDPEncoder::EncodeSetup. | //| Scan order: newest -> oldest, bounded by depth (default 200). | //+------------------------------------------------------------------+ #property strict #ifndef ORDERBLOCKSCANNER_MQH #define ORDERBLOCKSCANNER_MQH #include "../Core/CSymbolNormalizer.mqh" //--- structured zone output consumed by the main EA --- struct SOrderBlockZone { bool valid; // true when a trigger zone was produced bool is_bullish; // true = buy zone, false = sell zone int ob_index; // bar index of the order block candle (0 = current) datetime ob_time; // open time of the order block candle double zone_low; // lower edge of the OB/FVG zone double zone_high; // upper edge of the OB/FVG zone double entry; // proposed entry (tick-snapped) double sl; // proposed stop-loss (tick-snapped) double tp; // proposed take-profit (tick-snapped) double fvg_size; // size of the fair value gap in price terms }; //+------------------------------------------------------------------+ //| | //+------------------------------------------------------------------+ class COrderBlockScanner { private: string m_symbol; // instrument scanned ENUM_TIMEFRAMES m_period; // timeframe scanned CSymbolNormalizer *m_normalizer; // non-owning ATR/proximity source bool m_ready; // valid symbol + normalizer //--- mitigation: any completed bar AFTER the setup traded into the zone? --- bool IsBullishMitigated(const double &high[], const double &low[], const int ob_index); bool IsBearishMitigated(const double &high[], const double &low[], const int ob_index); public: COrderBlockScanner(CSymbolNormalizer *normalizer, const string symbol = "", const ENUM_TIMEFRAMES period = PERIOD_CURRENT); ~COrderBlockScanner(); //--- scan newest -> oldest for the freshest trigger; false when none --- bool Scan(SOrderBlockZone &out_zone, const int depth = 200, const double expansion_min = 1.2, const double fvg_min_atr_ratio = 0.0, const int atr_period = 14, const double proximity_atr_multiplier = 0.25, const double sl_atr_multiplier = 0.25, const double risk_reward = 2.0, const bool allow_mitigated = false); //--- read access --- bool IsReady() const { return m_ready; } string Symbol() const { return m_symbol; } string Timeframe() const; }; //+------------------------------------------------------------------+ //| Constructor — bind normalizer (non-owning) and resolve symbol. | //+------------------------------------------------------------------+ COrderBlockScanner::COrderBlockScanner(CSymbolNormalizer *normalizer, const string symbol, const ENUM_TIMEFRAMES period) : m_symbol(symbol), m_period(period), m_normalizer(normalizer), m_ready(false) { if(m_normalizer == NULL) { PrintFormat("[COrderBlockScanner] ERROR: null CSymbolNormalizer pointer."); return; } if(StringLen(m_symbol) == 0) m_symbol = m_normalizer.Symbol(); // inherit the normalizer's instrument if(StringLen(m_symbol) == 0) { PrintFormat("[COrderBlockScanner] ERROR: no symbol available (normalizer not bound)."); return; } m_ready = m_normalizer.IsReady(); if(!m_ready) PrintFormat("[COrderBlockScanner] ERROR: underlying normalizer not ready for %s.", m_symbol); else PrintFormat("[COrderBlockScanner] INFO: ready on %s %s.", m_symbol, Timeframe()); } //+------------------------------------------------------------------+ //| Destructor — non-owning pointer; nothing to release. | //+------------------------------------------------------------------+ COrderBlockScanner::~COrderBlockScanner() { } //+------------------------------------------------------------------+ //| Scan — single bounded pass over the OHLC window. | //| Index 0 = current (forming) bar; larger indices = older bars. | //| Pattern (bullish shown; bearish mirrored): | //| i : order block candle (down candle) | //| i-1 : expanding displacement candle (up, closes above high[i])| //| i-2 : confirm candle — low[i-2] > high[i] => FVG opens | //| Zone : [high[i], low[i-2]] (bullish) | //| Trigger: zone unmitigated AND live price within ATR proximity. | //+------------------------------------------------------------------+ bool COrderBlockScanner::Scan(SOrderBlockZone &out_zone, const int depth, const double expansion_min, const double fvg_min_atr_ratio, const int atr_period, const double proximity_atr_multiplier, const double sl_atr_multiplier, const double risk_reward, const bool allow_mitigated) { out_zone.valid = false; //--- rigid input validation --- if(!m_ready || m_normalizer == NULL) { PrintFormat("[COrderBlockScanner] ERROR: scanner not ready."); return false; } if(depth < 5) { PrintFormat("[COrderBlockScanner] ERROR: depth %d < minimum 5.", depth); return false; } if(expansion_min < 1.0) { PrintFormat("[COrderBlockScanner] ERROR: expansion_min must be >= 1.0 (got %G).", expansion_min); return false; } if(fvg_min_atr_ratio < 0.0) { PrintFormat("[COrderBlockScanner] ERROR: fvg_min_atr_ratio must be >= 0 (got %G).", fvg_min_atr_ratio); return false; } if(proximity_atr_multiplier <= 0.0 || sl_atr_multiplier <= 0.0) { PrintFormat("[COrderBlockScanner] ERROR: ATR multipliers must be > 0 (prox=%G sl=%G).", proximity_atr_multiplier, sl_atr_multiplier); return false; } if(risk_reward <= 0.0) { PrintFormat("[COrderBlockScanner] ERROR: risk_reward must be > 0 (got %G).", risk_reward); return false; } int bars = depth; if(bars > 10000) { PrintFormat("[COrderBlockScanner] WARNING: depth %d clamped to 10000.", depth); bars = 10000; } //--- dynamic ATR-based buffers from the normalizer --- const double proximity_buffer = m_normalizer.GetATRBuffer(atr_period, proximity_atr_multiplier); const double sl_buffer = m_normalizer.GetATRBuffer(atr_period, sl_atr_multiplier); if(proximity_buffer <= 0.0 || sl_buffer <= 0.0) { PrintFormat("[COrderBlockScanner] ERROR: ATR buffers unavailable for %s (warm-up?). Scan aborted.", m_symbol); return false; } // raw ATR recovered from the buffered value for the optional gap filter const double raw_atr = proximity_buffer / proximity_atr_multiplier; const double min_fvg = fvg_min_atr_ratio * raw_atr; //--- one window copy; index 0 = most recent bar (series orientation) --- double open[], high[], low[], close[]; datetime time[]; ArraySetAsSeries(open, true); // EXP-003A: perbaikan orientasi seri (bug deteksi 0) ArraySetAsSeries(high, true); ArraySetAsSeries(low, true); ArraySetAsSeries(close, true); ArraySetAsSeries(time, true); const int n_open = CopyOpen(m_symbol, m_period, 0, bars, open); const int n_high = CopyHigh(m_symbol, m_period, 0, bars, high); const int n_low = CopyLow(m_symbol, m_period, 0, bars, low); const int n_close = CopyClose(m_symbol, m_period, 0, bars, close); const int n_time = CopyTime(m_symbol, m_period, 0, bars, time); const int n = MathMin(MathMin(n_open, n_high), MathMin(MathMin(n_low, n_close), n_time)); if(n < 5) { PrintFormat("[COrderBlockScanner] ERROR: insufficient history for %s %s (got %d bars).", m_symbol, Timeframe(), n); return false; } //--- live price for the proximity trigger --- MqlTick tick; if(!SymbolInfoTick(m_symbol, tick) || tick.bid <= 0.0 || tick.ask <= 0.0) { PrintFormat("[COrderBlockScanner] ERROR: no live tick for %s. Scan aborted.", m_symbol); return false; } //--- newest OB (i=3) -> oldest: first trigger wins (freshest zone) --- for(int i = 3; i < n; i++) { const double ob_range = high[i] - low[i]; const double imp_range = high[i - 1] - low[i - 1]; if(ob_range <= 0.0 || imp_range < expansion_min * ob_range) continue; // not an expanding displacement move //================ BULLISH OB + FVG ================ if(close[i] < open[i] && close[i - 1] > open[i - 1] && close[i - 1] > high[i]) { // the expanding up candle opened a gap above the OB high if(low[i - 2] > high[i]) { const double fvg_size = low[i - 2] - high[i]; if(fvg_min_atr_ratio <= 0.0 || fvg_size >= min_fvg) { if(!allow_mitigated && !IsBullishMitigated(high, low, i)) { const double zone_low = high[i]; const double zone_high = low[i - 2]; // price approaching/tapping from above (bid-side) if(tick.bid >= zone_low - proximity_buffer && tick.bid <= zone_high + proximity_buffer) { const double entry = m_normalizer.NormalizePrice(zone_low); const double sl = m_normalizer.NormalizePrice(low[i] - sl_buffer); const double risk = entry - sl; if(entry > 0.0 && sl > 0.0 && risk > 0.0) { const double tp = m_normalizer.NormalizePrice(entry + risk * risk_reward); if(tp > entry) { out_zone.valid = true; out_zone.is_bullish = true; out_zone.ob_index = i; out_zone.ob_time = time[i]; out_zone.zone_low = zone_low; out_zone.zone_high = zone_high; out_zone.entry = entry; out_zone.sl = sl; out_zone.tp = tp; out_zone.fvg_size = fvg_size; PrintFormat("[COrderBlockScanner] INFO: Bullish OB+FVG @ %s bar %d (%.5f..%.5f), entry %.5f sl %.5f tp %.5f.", m_symbol, i, zone_low, zone_high, entry, sl, tp); return true; } } } } } } } //================ BEARISH OB + FVG ================ else if(close[i] > open[i] && close[i - 1] < open[i - 1] && close[i - 1] < low[i]) { // the expanding down candle opened a gap below the OB low if(high[i - 2] < low[i]) { const double fvg_size = low[i] - high[i - 2]; if(fvg_min_atr_ratio <= 0.0 || fvg_size >= min_fvg) { if(!allow_mitigated && !IsBearishMitigated(high, low, i)) { const double zone_low = high[i - 2]; const double zone_high = low[i]; // price approaching/tapping from below (ask-side) if(tick.ask >= zone_low - proximity_buffer && tick.ask <= zone_high + proximity_buffer) { const double entry = m_normalizer.NormalizePrice(zone_high); const double sl = m_normalizer.NormalizePrice(high[i] + sl_buffer); const double risk = sl - entry; if(entry > 0.0 && sl > 0.0 && risk > 0.0) { const double tp = m_normalizer.NormalizePrice(entry - risk * risk_reward); if(tp > 0.0 && tp < entry) { out_zone.valid = true; out_zone.is_bullish = false; out_zone.ob_index = i; out_zone.ob_time = time[i]; out_zone.zone_low = zone_low; out_zone.zone_high = zone_high; out_zone.entry = entry; out_zone.sl = sl; out_zone.tp = tp; out_zone.fvg_size = fvg_size; PrintFormat("[COrderBlockScanner] INFO: Bearish OB+FVG @ %s bar %d (%.5f..%.5f), entry %.5f sl %.5f tp %.5f.", m_symbol, i, zone_low, zone_high, entry, sl, tp); return true; } } } } } } } } //--- no proximity-triggered zone within the window (normal outcome) --- static ulong s_last_no_zone_log_ms = 0; // throttle anti-spam: wall-clock GetTickCount64 (TimeCurrent() melaju cepat di tester) const ulong now_ms = GetTickCount64(); if(now_ms - s_last_no_zone_log_ms >= 10000) { s_last_no_zone_log_ms = now_ms; PrintFormat("[COrderBlockScanner] INFO: no unmitigated OB+FVG in proximity within %d bars on %s %s.", n, m_symbol, Timeframe()); } return false; } //+------------------------------------------------------------------+ //| IsBullishMitigated — true when any COMPLETED bar after the setup | //| traded at/below the zone top (gap filled). Bar 0 (forming) and | //| the impulse/confirm candles are excluded: the trigger bar may | //| legitimately be the one tapping the zone right now. | //+------------------------------------------------------------------+ bool COrderBlockScanner::IsBullishMitigated(const double &high[], const double &low[], const int ob_index) { const double zone_top = high[ob_index]; for(int j = 1; j <= ob_index - 3; j++) if(low[j] <= zone_top) return true; return false; } //+------------------------------------------------------------------+ //| IsBearishMitigated — symmetric: any completed bar traded at/above | //| the zone bottom since the setup formed. | //+------------------------------------------------------------------+ bool COrderBlockScanner::IsBearishMitigated(const double &high[], const double &low[], const int ob_index) { const double zone_bottom = low[ob_index]; for(int j = 1; j <= ob_index - 3; j++) if(high[j] >= zone_bottom) return true; return false; } //+------------------------------------------------------------------+ //| Timeframe — wire name of the scanned period. | //+------------------------------------------------------------------+ string COrderBlockScanner::Timeframe() const { ENUM_TIMEFRAMES p = m_period; if(p == PERIOD_CURRENT) p = (ENUM_TIMEFRAMES)_Period; string name = EnumToString(p); StringReplace(name, "PERIOD_", ""); return name; } #endif // ORDERBLOCKSCANNER_MQH