Phase 1: Event-driven backtester, 5 strategies, and baseline results

- Built event-driven backtesting engine with spread/slippage modeling,
  3-TP partial closes, trailing stops, and rich trade logging (20+ features)
- Implemented 5 strategy signal generators (MA Breakout, VWAP Reversal,
  Key Level Breakout, EMA Ribbon Scalp, Momentum Exhaustion)
- Full indicator library (EMA, SMA, RSI, ATR, MACD, ADX, Stochastic,
  Session VWAP bands, swing points, key levels, RSI divergence)
- Data pipeline: Dukascopy download, validation, 70/30 train/test split
- Baseline results: all 5 strategies generate 200+ trades on training data
  (Jan 2021 - Aug 2023), best profit factors 0.82-0.96 on select pairs
- Trade logs and reports saved for Phase 3 ML feature engineering

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Brent Neale
2026-02-18 06:04:40 +10:00
co-authored by Claude Opus 4.6
parent 5d7f6c60a9
commit dce54845c2
103 changed files with 26083 additions and 123 deletions
+31
View File
@@ -0,0 +1,31 @@
from .s1_ma_breakout import S1_MA_Breakout
from .s2_vwap_reversal import S2_VWAP_Reversal
from .s3_key_level_breakout import S3_KeyLevel_Breakout
from .s4_ema_ribbon import S4_EMA_Ribbon
from .s5_momentum_exhaustion import S5_Momentum_Exhaustion
STRATEGIES = {
1: S1_MA_Breakout,
2: S2_VWAP_Reversal,
3: S3_KeyLevel_Breakout,
4: S4_EMA_Ribbon,
5: S5_Momentum_Exhaustion,
}
# Which pairs each strategy trades
STRATEGY_PAIRS = {
1: ["GBP_AUD", "EUR_AUD", "EUR_CAD", "EUR_NZD"],
2: ["GBP_USD", "EUR_USD", "GBP_JPY", "USD_JPY"],
3: ["GBP_JPY", "USD_JPY", "GBP_USD", "EUR_GBP"],
4: ["GBP_AUD", "EUR_AUD", "EUR_GBP"],
5: ["GBP_AUD", "EUR_AUD", "EUR_GBP", "GBP_CAD", "EUR_CAD"],
}
# Primary and filter timeframes
STRATEGY_TIMEFRAMES = {
1: {"primary": "M15", "filter": "H1"},
2: {"primary": "M15", "filter": None},
3: {"primary": "H1", "filter": None}, # Uses internal key level detection
4: {"primary": "M15", "filter": "H1"},
5: {"primary": "M15", "filter": "H1"},
}
+27
View File
@@ -0,0 +1,27 @@
"""Base strategy interface."""
from typing import Optional
import pandas as pd
class BaseStrategy:
strategy_id: int = 0
name: str = "BaseStrategy"
def check_signal(self, data: pd.DataFrame, idx: int,
current: pd.Series,
htf_row: Optional[pd.Series] = None) -> Optional[dict]:
"""
Check for a trade signal at the current candle.
Args:
data: Full precomputed dataframe (must only access [:idx+1]).
idx: Current bar index into data.
current: The current candle (data.iloc[idx]).
htf_row: Most recent fully-closed higher timeframe candle (or None).
Returns:
dict with keys: direction, sl, tp1, tp2, tp3, confluence,
tp_splits, trail_atr_mult, max_bars
or None if no signal.
"""
raise NotImplementedError
+177
View File
@@ -0,0 +1,177 @@
"""
Strategy 1: MA Breakout-Retest (Option 3 - no trendlines).
Uses MA structure + key level breaks + candle confirmation.
Entry TF: M15, Filter TF: H1 (200 SMA directional filter).
Entry conditions (LONG):
- EMA 50 > EMA 100 > EMA 200 (trend alignment)
- Price pulls back to EMA 50 zone (within 1.0x ATR)
- Bullish confirmation candle (close > open, close > prev close, body > 30% range)
- H1 close > H1 200 SMA (HTF filter)
- Session filter: London/NY hours only (08:00-17:00 UTC)
- Confluence >= 2
Boosters (confluence 0-5):
- Volume above 20-period average
- RSI between 40-60 (not overextended)
- MACD histogram positive and rising
- ADX > 20 (trending)
- Price above session VWAP
"""
from typing import Optional
import numpy as np
import pandas as pd
from .base import BaseStrategy
class S1_MA_Breakout(BaseStrategy):
strategy_id = 1
name = "S1_MA_Breakout_Retest"
def check_signal(self, data: pd.DataFrame, idx: int,
current: pd.Series,
htf_row: Optional[pd.Series] = None) -> Optional[dict]:
if idx < 50:
return None
# Session filter: only London + NY (08:00-17:00 UTC)
hour = current.name.hour if hasattr(current.name, 'hour') else 0
if hour < 8 or hour >= 17:
return None
atr_val = current.get("atr_14", 0)
if atr_val <= 0 or np.isnan(atr_val):
return None
ema_50 = current.get("ema_50", np.nan)
ema_100 = current.get("ema_100", np.nan)
ema_200 = current.get("ema_200", np.nan)
if any(np.isnan(v) for v in [ema_50, ema_100, ema_200]):
return None
close = current["close"]
open_p = current["open"]
prev = data.iloc[idx - 1]
prev_close = prev["close"]
# Candle body filter: body must be > 30% of range (no dojis)
body = abs(close - open_p)
full_range = current["high"] - current["low"]
if full_range <= 0 or body / full_range < 0.3:
return None
# LONG setup
if ema_50 > ema_100 > ema_200:
# HTF filter
if htf_row is not None:
htf_sma200 = htf_row.get("sma_200", np.nan)
if not np.isnan(htf_sma200) and htf_row.get("close", 0) <= htf_sma200:
return None
# Pullback to EMA 50 zone (within 1.0x ATR - tightened from 1.5x)
dist_to_ema50 = close - ema_50
if dist_to_ema50 < 0 or dist_to_ema50 > 1.0 * atr_val:
return None
# Bullish confirmation candle
if not (close > open_p and close > prev_close):
return None
# Not too far from EMAs (avoid chasing)
if close - ema_200 > 5 * atr_val:
return None
confluence = self._calc_confluence(data, idx, current, "LONG")
# Require minimum confluence of 2
if confluence < 2:
return None
sl = current["low"] - 0.5 * atr_val
tp1 = close + 1.5 * atr_val
tp2 = close + 2.5 * atr_val
tp3 = close + 4.0 * atr_val
return {
"direction": "LONG",
"sl": sl, "tp1": tp1, "tp2": tp2, "tp3": tp3,
"confluence": confluence,
"tp_splits": (0.50, 0.30, 0.20),
"trail_atr_mult": 1.5,
"max_bars": 200,
}
# SHORT setup
if ema_50 < ema_100 < ema_200:
if htf_row is not None:
htf_sma200 = htf_row.get("sma_200", np.nan)
if not np.isnan(htf_sma200) and htf_row.get("close", 0) >= htf_sma200:
return None
dist_to_ema50 = ema_50 - close
if dist_to_ema50 < 0 or dist_to_ema50 > 1.0 * atr_val:
return None
if not (close < open_p and close < prev_close):
return None
if ema_200 - close > 5 * atr_val:
return None
confluence = self._calc_confluence(data, idx, current, "SHORT")
if confluence < 2:
return None
sl = current["high"] + 0.5 * atr_val
tp1 = close - 1.5 * atr_val
tp2 = close - 2.5 * atr_val
tp3 = close - 4.0 * atr_val
return {
"direction": "SHORT",
"sl": sl, "tp1": tp1, "tp2": tp2, "tp3": tp3,
"confluence": confluence,
"tp_splits": (0.50, 0.30, 0.20),
"trail_atr_mult": 1.5,
"max_bars": 200,
}
return None
def _calc_confluence(self, data, idx, current, direction):
confluence = 0
# Volume above average
if "volume" in current.index:
vol_avg = data["volume"].iloc[max(0, idx - 20):idx].mean()
if vol_avg > 0 and current["volume"] > vol_avg:
confluence += 1
# RSI between 40-60
rsi = current.get("rsi_14", 50)
if 40 <= rsi <= 60:
confluence += 1
# MACD histogram confirmation
macd_h = current.get("macd_hist", 0)
prev_macd_h = data.iloc[idx - 1].get("macd_hist", 0)
if direction == "LONG" and macd_h > 0 and macd_h > prev_macd_h:
confluence += 1
elif direction == "SHORT" and macd_h < 0 and macd_h < prev_macd_h:
confluence += 1
# ADX > 20
if current.get("adx_14", 0) > 20:
confluence += 1
# VWAP alignment
vwap = current.get("session_vwap", 0)
if vwap:
if direction == "LONG" and current["close"] > vwap:
confluence += 1
elif direction == "SHORT" and current["close"] < vwap:
confluence += 1
return min(confluence, 5)
+140
View File
@@ -0,0 +1,140 @@
"""
Strategy 2: Session VWAP Reversal.
Entry TF: M15, No HTF filter needed.
Entry conditions (LONG):
- Price crosses below VWAP -2 sigma band
- RSI < 30 (oversold confirmation)
- Session filter: London/NY hours only (08:00-17:00 UTC)
- Minimum band width: vwap_std > 0.3 * ATR
Entry conditions (SHORT):
- Price crosses above VWAP +2 sigma band
- RSI > 70 (overbought confirmation)
TP1: Return to VWAP, TP2: Opposite 0.5 sigma band
SL: Beyond session high/low OR 1x ATR (whichever tighter)
"""
from typing import Optional
import numpy as np
import pandas as pd
from .base import BaseStrategy
class S2_VWAP_Reversal(BaseStrategy):
strategy_id = 2
name = "S2_Session_VWAP_Reversal"
def check_signal(self, data: pd.DataFrame, idx: int,
current: pd.Series,
htf_row: Optional[pd.Series] = None) -> Optional[dict]:
if idx < 30:
return None
# Session filter: only London + NY (08:00-17:00 UTC)
hour = current.name.hour if hasattr(current.name, 'hour') else 0
if hour < 8 or hour >= 17:
return None
vwap = current.get("session_vwap", None)
upper_2 = current.get("vwap_upper_2", None)
lower_2 = current.get("vwap_lower_2", None)
if vwap is None or upper_2 is None or lower_2 is None:
return None
if any(np.isnan(v) for v in [vwap, upper_2, lower_2]):
return None
# Skip if bands are too tight (early in session, no deviation yet)
vwap_std = current.get("vwap_std", 0)
if vwap_std is None or np.isnan(vwap_std) or vwap_std <= 0:
return None
atr_val = current.get("atr_14", 0)
if atr_val <= 0 or np.isnan(atr_val):
return None
# Minimum band width: std must be meaningful relative to ATR
if vwap_std < 0.3 * atr_val:
return None
rsi = current.get("rsi_14", 50)
if np.isnan(rsi):
return None
close = current["close"]
prev = data.iloc[idx - 1]
# Session high/low for SL
start = max(0, idx - 80)
session_high = data["high"].iloc[start:idx + 1].max()
session_low = data["low"].iloc[start:idx + 1].min()
# LONG: price below -2 sigma + RSI < 30 + price just crossed below
if close < lower_2 and rsi < 30:
prev_lower_2 = prev.get("vwap_lower_2", None)
if prev_lower_2 is not None and not np.isnan(prev_lower_2):
if prev["close"] >= prev_lower_2:
return self._build_long(close, vwap, atr_val, vwap_std,
session_low, current)
# SHORT: price above +2 sigma + RSI > 70 + price just crossed above
if close > upper_2 and rsi > 70:
prev_upper_2 = prev.get("vwap_upper_2", None)
if prev_upper_2 is not None and not np.isnan(prev_upper_2):
if prev["close"] <= prev_upper_2:
return self._build_short(close, vwap, atr_val, vwap_std,
session_high, current)
return None
def _build_long(self, close, vwap, atr_val, vwap_std, session_low, current):
sl_session = session_low - 0.5 * atr_val
sl_atr = close - 1.5 * atr_val # Wider SL for mean reversion
sl = max(sl_session, sl_atr)
tp1 = vwap
tp2 = vwap + 0.5 * vwap_std
tp3 = vwap + 1.5 * vwap_std
confluence = 2
if current.get("adx_14", 30) < 25:
confluence += 1
macd_h = current.get("macd_hist", 0)
if macd_h > 0:
confluence += 1
return {
"direction": "LONG",
"sl": sl, "tp1": tp1, "tp2": tp2, "tp3": tp3,
"confluence": confluence,
"tp_splits": (0.50, 0.35, 0.15),
"trail_atr_mult": 1.0,
"max_bars": 80,
}
def _build_short(self, close, vwap, atr_val, vwap_std, session_high, current):
sl_session = session_high + 0.5 * atr_val
sl_atr = close + 1.5 * atr_val # Wider SL for mean reversion
sl = min(sl_session, sl_atr)
tp1 = vwap
tp2 = vwap - 0.5 * vwap_std
tp3 = vwap - 1.5 * vwap_std
confluence = 2
if current.get("adx_14", 30) < 25:
confluence += 1
macd_h = current.get("macd_hist", 0)
if macd_h < 0:
confluence += 1
return {
"direction": "SHORT",
"sl": sl, "tp1": tp1, "tp2": tp2, "tp3": tp3,
"confluence": confluence,
"tp_splits": (0.50, 0.35, 0.15),
"trail_atr_mult": 1.0,
"max_bars": 80,
}
+156
View File
@@ -0,0 +1,156 @@
"""
Strategy 3: Key Level Momentum Breakout.
Entry TF: H1. Key levels identified from swing point clusters.
Entry conditions (LONG):
- H1 candle closes above a key level (horizontal S/R with 2+ touches)
- MACD histogram same sign as direction
- ADX > 15
- Candle body > 30% of range (conviction candle)
SL: Back inside key level + 1x ATR buffer
TP1: 1.5x ATR, TP2: 2.5x ATR, TP3: 4x ATR
"""
from typing import Optional
import numpy as np
import pandas as pd
from .base import BaseStrategy
from ..indicators.technical import identify_key_levels
class S3_KeyLevel_Breakout(BaseStrategy):
strategy_id = 3
name = "S3_Key_Level_Breakout"
def __init__(self):
self._cached_levels = None
self._cache_idx = -1
def check_signal(self, data: pd.DataFrame, idx: int,
current: pd.Series,
htf_row: Optional[pd.Series] = None) -> Optional[dict]:
if idx < 100:
return None
# Session filter: London/NY only (08:00-17:00 UTC)
hour = current.name.hour if hasattr(current.name, 'hour') else 0
if hour < 8 or hour >= 17:
return None
atr_val = current.get("atr_14", 0)
if atr_val <= 0 or np.isnan(atr_val):
return None
# Recalculate key levels every 20 bars using larger lookback
if self._cached_levels is None or idx - self._cache_idx >= 20:
start = max(0, idx - 1000)
window = data.iloc[start:idx] # exclude current bar
self._cached_levels = identify_key_levels(
window, lookback=5, tolerance_atr_mult=0.75, min_touches=2
)
self._cache_idx = idx
if not self._cached_levels:
return None
close = current["close"]
prev_close = data.iloc[idx - 1]["close"]
# Candle body filter
body = abs(close - current["open"])
full_range = current["high"] - current["low"]
if full_range <= 0 or body / full_range < 0.3:
return None
# MACD
macd_h = current.get("macd_hist", 0)
# ADX
adx_val = current.get("adx_14", 0)
if adx_val < 15:
return None
for level_price, touch_count in self._cached_levels:
tolerance = 0.3 * atr_val
# LONG breakout: close above level, prev close was at or below
if close > level_price + tolerance and prev_close <= level_price + tolerance:
if macd_h <= 0:
continue
# EMA alignment: 50 > 200 for LONG
ema_50 = current.get("ema_50", 0)
ema_200 = current.get("ema_200", 0)
if ema_50 and ema_200 and ema_50 <= ema_200:
continue
confluence = self._calc_confluence(current, data, idx, "LONG", touch_count)
sl = level_price - 0.3 * atr_val # Tight SL just inside key level
tp1 = close + 1.5 * atr_val
tp2 = close + 2.5 * atr_val
tp3 = close + 4.0 * atr_val
return {
"direction": "LONG",
"sl": sl, "tp1": tp1, "tp2": tp2, "tp3": tp3,
"confluence": confluence,
"tp_splits": (0.40, 0.40, 0.20),
"trail_atr_mult": 2.0,
"max_bars": 150,
}
# SHORT breakout: close below level, prev close was at or above
if close < level_price - tolerance and prev_close >= level_price - tolerance:
if macd_h >= 0:
continue
# EMA alignment: 50 < 200 for SHORT
ema_50 = current.get("ema_50", 0)
ema_200 = current.get("ema_200", 0)
if ema_50 and ema_200 and ema_50 >= ema_200:
continue
confluence = self._calc_confluence(current, data, idx, "SHORT", touch_count)
sl = level_price + 0.3 * atr_val # Tight SL just inside key level
tp1 = close - 1.5 * atr_val
tp2 = close - 2.5 * atr_val
tp3 = close - 4.0 * atr_val
return {
"direction": "SHORT",
"sl": sl, "tp1": tp1, "tp2": tp2, "tp3": tp3,
"confluence": confluence,
"tp_splits": (0.40, 0.40, 0.20),
"trail_atr_mult": 2.0,
"max_bars": 150,
}
return None
def _calc_confluence(self, current, data, idx, direction, touch_count):
confluence = 1 # breakout confirmed
# More touches = stronger level
if touch_count >= 3:
confluence += 1
if touch_count >= 5:
confluence += 1
rsi = current.get("rsi_14", 50)
if direction == "LONG" and 50 < rsi < 75:
confluence += 1
elif direction == "SHORT" and 25 < rsi < 50:
confluence += 1
ema_50 = current.get("ema_50", 0)
ema_200 = current.get("ema_200", 0)
if ema_50 and ema_200:
if direction == "LONG" and ema_50 > ema_200:
confluence += 1
elif direction == "SHORT" and ema_50 < ema_200:
confluence += 1
return min(confluence, 5)
+173
View File
@@ -0,0 +1,173 @@
"""
Strategy 4: EMA Ribbon Momentum Scalp.
Entry TF: M15, Filter TF: H1.
H1 Filter: EMA 20 > 50 > 100 > 200 (LONG) or reversed (SHORT)
M15 Entry (LONG):
- EMA ribbon compressed (EMAs within 1.0x ATR)
- Ribbon re-expanding (current width > prev width)
- Stochastic turning from oversold
- Session filter: London/NY (08:00-17:00 UTC)
SL: Below compression low - 0.5x ATR
TP1: 1x ATR, TP2: 1.5x ATR, TP3: 2.5x ATR
"""
from typing import Optional
import numpy as np
import pandas as pd
from .base import BaseStrategy
class S4_EMA_Ribbon(BaseStrategy):
strategy_id = 4
name = "S4_EMA_Ribbon_Scalp"
def check_signal(self, data: pd.DataFrame, idx: int,
current: pd.Series,
htf_row: Optional[pd.Series] = None) -> Optional[dict]:
if idx < 50:
return None
# Session filter
hour = current.name.hour if hasattr(current.name, 'hour') else 0
if hour < 8 or hour >= 17:
return None
atr_val = current.get("atr_14", 0)
if atr_val <= 0 or np.isnan(atr_val):
return None
if htf_row is None:
return None
# H1 EMA stack check
htf_ema20 = htf_row.get("ema_20", np.nan)
htf_ema50 = htf_row.get("ema_50", np.nan)
htf_ema100 = htf_row.get("ema_100", np.nan)
htf_ema200 = htf_row.get("ema_200", np.nan)
if any(np.isnan(v) for v in [htf_ema20, htf_ema50, htf_ema100, htf_ema200]):
return None
long_stack = htf_ema20 > htf_ema50 > htf_ema100 > htf_ema200
short_stack = htf_ema20 < htf_ema50 < htf_ema100 < htf_ema200
if not long_stack and not short_stack:
return None
# M15 ribbon EMAs
ema_20 = current.get("ema_20", np.nan)
ema_50 = current.get("ema_50", np.nan)
ema_100 = current.get("ema_100", np.nan)
if any(np.isnan(v) for v in [ema_20, ema_50, ema_100]):
return None
ribbon_width = max(ema_20, ema_50, ema_100) - min(ema_20, ema_50, ema_100)
compression_threshold = 1.0 * atr_val # relaxed from 0.5x
# Check for recent compression (look back 5-20 bars)
was_compressed = False
compression_low = current["low"]
compression_high = current["high"]
min_compression_width = float('inf')
for j in range(max(0, idx - 20), idx):
bar = data.iloc[j]
e20 = bar.get("ema_20", np.nan)
e50 = bar.get("ema_50", np.nan)
e100 = bar.get("ema_100", np.nan)
if any(np.isnan(v) for v in [e20, e50, e100]):
continue
w = max(e20, e50, e100) - min(e20, e50, e100)
if w <= compression_threshold:
was_compressed = True
min_compression_width = min(min_compression_width, w)
compression_low = min(compression_low, bar["low"])
compression_high = max(compression_high, bar["high"])
if not was_compressed:
return None
# Current bar: ribbon must be expanding (wider than min compression)
if ribbon_width <= min_compression_width * 1.2:
return None
# Stochastic
stoch_k = current.get("stoch_k", 50)
stoch_d = current.get("stoch_d", 50)
if long_stack:
# Stochastic turning up or in bullish zone
if not (stoch_k > stoch_d or stoch_k < 50):
return None
if not (ema_20 >= ema_50): # Ribbon expanding upward
return None
confluence = self._calc_confluence(data, idx, current, atr_val, "LONG")
# Use tighter SL: max of (compression_low, close - 0.8*ATR)
sl_compression = compression_low - 0.3 * atr_val
sl_atr = current["close"] - 0.8 * atr_val
sl = max(sl_compression, sl_atr)
tp1 = current["close"] + 1.0 * atr_val
tp2 = current["close"] + 1.5 * atr_val
tp3 = current["close"] + 2.5 * atr_val
return {
"direction": "LONG",
"sl": sl, "tp1": tp1, "tp2": tp2, "tp3": tp3,
"confluence": confluence,
"tp_splits": (0.50, 0.30, 0.20),
"trail_atr_mult": 1.0,
"max_bars": 60,
}
elif short_stack:
if not (stoch_k < stoch_d or stoch_k > 50):
return None
if not (ema_20 <= ema_50):
return None
confluence = self._calc_confluence(data, idx, current, atr_val, "SHORT")
sl_compression = compression_high + 0.3 * atr_val
sl_atr = current["close"] + 0.8 * atr_val
sl = min(sl_compression, sl_atr)
tp1 = current["close"] - 1.0 * atr_val
tp2 = current["close"] - 1.5 * atr_val
tp3 = current["close"] - 2.5 * atr_val
return {
"direction": "SHORT",
"sl": sl, "tp1": tp1, "tp2": tp2, "tp3": tp3,
"confluence": confluence,
"tp_splits": (0.50, 0.30, 0.20),
"trail_atr_mult": 1.0,
"max_bars": 60,
}
return None
def _calc_confluence(self, data, idx, current, atr_val, direction):
confluence = 2 # HTF alignment + compression/expansion
# ATR contracting
atr_avg = data["atr_14"].iloc[max(0, idx - 50):idx].mean()
if atr_val < atr_avg:
confluence += 1
# Volume rising
if "volume" in current.index:
vol_avg = data["volume"].iloc[max(0, idx - 20):idx].mean()
if vol_avg > 0 and current["volume"] > vol_avg:
confluence += 1
# RSI not extreme
rsi = current.get("rsi_14", 50)
if direction == "LONG" and 40 < rsi < 65:
confluence += 1
elif direction == "SHORT" and 35 < rsi < 60:
confluence += 1
return min(confluence, 5)
@@ -0,0 +1,193 @@
"""
Strategy 5: Momentum Exhaustion Reversal.
Entry TF: M15, Filter TF: H1 (structure/key levels).
Mandatory conditions (all 4 required):
1. RSI divergence (price new extreme, RSI doesn't confirm)
2. MACD histogram shrinking (2+ consecutive smaller bars)
3. Near key level (within 0.75x ATR)
4. Price overextended: moved 1.0x+ ATR from nearest EMA
- Session filter: London/NY (08:00-17:00 UTC)
Mutual exclusion: Cannot fire if S4 ribbon is compressed.
"""
from typing import Optional
import numpy as np
import pandas as pd
from .base import BaseStrategy
from ..indicators.technical import identify_key_levels
class S5_Momentum_Exhaustion(BaseStrategy):
strategy_id = 5
name = "S5_Momentum_Exhaustion"
def __init__(self):
self._cached_levels = None
self._cache_idx = -1
def check_signal(self, data: pd.DataFrame, idx: int,
current: pd.Series,
htf_row: Optional[pd.Series] = None) -> Optional[dict]:
if idx < 50:
return None
# Session filter
hour = current.name.hour if hasattr(current.name, 'hour') else 0
if hour < 8 or hour >= 17:
return None
atr_val = current.get("atr_14", 0)
if atr_val <= 0 or np.isnan(atr_val):
return None
# Condition 1: RSI divergence
divergence = self._detect_divergence(data, idx)
if divergence is None:
return None
# Condition 2: MACD histogram shrinking
if not self._macd_shrinking(data, idx):
return None
# Condition 3: Near key level
if not self._near_key_level(data, idx, current, atr_val):
return None
# Condition 4: Overextension from EMA
if not self._overextended(current, atr_val):
return None
# Mutual exclusion with S4 ribbon
if self._s4_active(current, atr_val):
return None
confluence = 4 # All mandatory met
# Booster: declining volume (required for entry - reduces false signals)
if not self._volume_declining(data, idx):
return None
confluence = 5
close = current["close"]
if divergence == "bullish":
sl = current["low"] - 0.5 * atr_val # Tight SL for reversal
tp1 = close + 1.5 * atr_val
tp2 = close + 2.5 * atr_val
tp3 = close + 4.0 * atr_val
direction = "LONG"
else:
sl = current["high"] + 0.5 * atr_val # Tight SL for reversal
tp1 = close - 1.5 * atr_val
tp2 = close - 2.5 * atr_val
tp3 = close - 4.0 * atr_val
direction = "SHORT"
return {
"direction": direction,
"sl": sl, "tp1": tp1, "tp2": tp2, "tp3": tp3,
"confluence": confluence,
"tp_splits": (0.40, 0.40, 0.20),
"trail_atr_mult": 1.5,
"max_bars": 120,
}
def _detect_divergence(self, data, idx):
"""Detect RSI divergence over lookback window."""
lookback = 20
if idx < lookback + 3:
return None
rsi_col = "rsi_14"
if rsi_col not in data.columns:
return None
window = data.iloc[idx - lookback:idx + 1]
rsi_vals = window[rsi_col]
lows = window["low"]
highs = window["high"]
# Bullish: current low is near the lowest in window, but RSI is higher
current_low = data.iloc[idx]["low"]
min_low_idx = lows.iloc[:-3].idxmin() # exclude last 3 bars
if min_low_idx is not None:
prev_low = lows.loc[min_low_idx]
if current_low <= prev_low * 1.001: # current near or below prev low
if rsi_vals.iloc[-1] > rsi_vals.loc[min_low_idx]:
return "bullish"
# Bearish: current high is near highest, but RSI is lower
current_high = data.iloc[idx]["high"]
max_high_idx = highs.iloc[:-3].idxmax()
if max_high_idx is not None:
prev_high = highs.loc[max_high_idx]
if current_high >= prev_high * 0.999:
if rsi_vals.iloc[-1] < rsi_vals.loc[max_high_idx]:
return "bearish"
return None
def _macd_shrinking(self, data, idx):
if idx < 3:
return False
h0 = abs(data.iloc[idx].get("macd_hist", 0))
h1 = abs(data.iloc[idx - 1].get("macd_hist", 0))
h2 = abs(data.iloc[idx - 2].get("macd_hist", 0))
return h0 < h1 and h1 < h2 # 2 consecutive shrinks
def _near_key_level(self, data, idx, current, atr_val):
# Recache every 50 bars
if self._cached_levels is None or idx - self._cache_idx >= 50:
start = max(0, idx - 500)
window = data.iloc[start:idx]
self._cached_levels = identify_key_levels(
window, lookback=5, tolerance_atr_mult=0.75, min_touches=2
)
self._cache_idx = idx
if not self._cached_levels:
return False
close = current["close"]
tolerance = 0.75 * atr_val # relaxed from 0.5x
for level_price, _ in self._cached_levels:
if abs(close - level_price) <= tolerance:
return True
return False
def _overextended(self, current, atr_val):
close = current["close"]
ema_50 = current.get("ema_50", np.nan)
ema_100 = current.get("ema_100", np.nan)
distances = []
if not np.isnan(ema_50):
distances.append(abs(close - ema_50))
if not np.isnan(ema_100):
distances.append(abs(close - ema_100))
if not distances:
return False
# Relaxed from 1.5x to 1.0x ATR
return min(distances) >= 1.0 * atr_val
def _s4_active(self, current, atr_val):
ema_20 = current.get("ema_20", np.nan)
ema_50 = current.get("ema_50", np.nan)
ema_100 = current.get("ema_100", np.nan)
if any(np.isnan(v) for v in [ema_20, ema_50, ema_100]):
return False
ribbon_width = max(ema_20, ema_50, ema_100) - min(ema_20, ema_50, ema_100)
return ribbon_width <= 1.0 * atr_val # match S4's relaxed threshold
def _volume_declining(self, data, idx):
if idx < 5 or "volume" not in data.columns:
return False
vols = data["volume"].iloc[idx - 4:idx + 1].values
if np.any(np.isnan(vols)):
return False
diffs = np.diff(vols)
return np.sum(diffs < 0) >= 3