Add Phase 2 backtesting pipeline: IS/OOS split, param sweep, generalization scoring

Externalize hardcoded params in S4F (5 params) and S3 (9 params) as class
attributes for sweep compatibility. Add unified backtest runner with IS/OOS
validation and generalization scores, plus parameter grid sweep (90 combos)
with OOS validation. S7/S9/S9_Filtered pass generalization; S4F/S3 confirm
defaults are near-optimal.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Brent Neale
2026-02-20 10:25:08 +10:00
co-authored by Claude Opus 4.6
parent 9d16939eaa
commit 4f911b2072
16 changed files with 3251 additions and 19 deletions
+25 -12
View File
@@ -25,6 +25,17 @@ class S3_KeyLevel_Breakout(BaseStrategy):
strategy_id = 3
name = "S3_Key_Level_Breakout"
# Tunable parameters (defaults match original hardcoded values)
BODY_RATIO_MIN = 0.50
VOLUME_MULT = 1.5
SL_ATR_MULT = 0.5
TP1_ATR_MULT = 1.5
TP2_ATR_MULT = 2.5
TP3_ATR_MULT = 4.0
MIN_ADX = 20
KEY_LEVEL_TOLERANCE = 0.75
KEY_LEVEL_MIN_TOUCHES = 3
def __init__(self):
super().__init__()
self._cached_levels = None
@@ -47,21 +58,21 @@ class S3_KeyLevel_Breakout(BaseStrategy):
# ADX filter: require trending market
adx_val = current.get("adx_14", 0)
if adx_val < 20:
if adx_val < self.MIN_ADX:
return None
# Strong close: candle body > 50% of range
close = current["close"]
body = abs(close - current["open"])
full_range = current["high"] - current["low"]
if full_range <= 0 or body / full_range < 0.50:
if full_range <= 0 or body / full_range < self.BODY_RATIO_MIN:
return None
# Volume spike: current volume > 1.5x 20-bar average
vol = current.get("volume", 0)
if vol > 0 and idx >= 20:
vol_avg = data["volume"].iloc[idx - 20:idx].mean()
if vol_avg > 0 and vol < 1.5 * vol_avg:
if vol_avg > 0 and vol < self.VOLUME_MULT * vol_avg:
return None
prev_close = data.iloc[idx - 1]["close"]
@@ -74,7 +85,9 @@ class S3_KeyLevel_Breakout(BaseStrategy):
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=3
window, lookback=5,
tolerance_atr_mult=self.KEY_LEVEL_TOLERANCE,
min_touches=self.KEY_LEVEL_MIN_TOUCHES,
)
self._cache_idx = idx
@@ -98,10 +111,10 @@ class S3_KeyLevel_Breakout(BaseStrategy):
confluence = self._calc_confluence(current, data, idx,
"LONG", touch_count, vol)
sl = level_price - 0.5 * atr_val
tp1 = close + 1.5 * atr_val
tp2 = close + 2.5 * atr_val
tp3 = close + 4.0 * atr_val
sl = level_price - self.SL_ATR_MULT * atr_val
tp1 = close + self.TP1_ATR_MULT * atr_val
tp2 = close + self.TP2_ATR_MULT * atr_val
tp3 = close + self.TP3_ATR_MULT * atr_val
return {
"direction": "LONG",
@@ -127,10 +140,10 @@ class S3_KeyLevel_Breakout(BaseStrategy):
confluence = self._calc_confluence(current, data, idx,
"SHORT", touch_count, vol)
sl = level_price + 0.5 * atr_val
tp1 = close - 1.5 * atr_val
tp2 = close - 2.5 * atr_val
tp3 = close - 4.0 * atr_val
sl = level_price + self.SL_ATR_MULT * atr_val
tp1 = close - self.TP1_ATR_MULT * atr_val
tp2 = close - self.TP2_ATR_MULT * atr_val
tp3 = close - self.TP3_ATR_MULT * atr_val
return {
"direction": "SHORT",
+14 -7
View File
@@ -21,6 +21,13 @@ class S4F_EMA_Ribbon(BaseStrategy):
strategy_id = 4
name = "S4F_Trend_Context"
# Tunable parameters (defaults match original hardcoded values)
SL_ATR_MULT = 2.0
TP_ATR_MULT = 3.0
VOLUME_MULT = 1.2
COMPRESSION_ATR_MULT = 1.0
HTF_EMA_DIST_ATR = 1.5
def check_signal(self, data: pd.DataFrame, idx: int,
current: pd.Series,
htf_row: Optional[pd.Series] = None) -> Optional[dict]:
@@ -67,7 +74,7 @@ class S4F_EMA_Ribbon(BaseStrategy):
# ------- PRICE WITHIN 1.5 ATR OF 1H 50 EMA -------
price = current["close"]
if abs(price - htf_ema50) > 1.5 * atr_val:
if abs(price - htf_ema50) > self.HTF_EMA_DIST_ATR * atr_val:
return None
# ------- M15 RIBBON COMPRESSION -> EXPANSION -------
@@ -78,7 +85,7 @@ class S4F_EMA_Ribbon(BaseStrategy):
return None
ribbon_width = max(ema_20, ema_50, ema_100) - min(ema_20, ema_50, ema_100)
compression_threshold = 1.0 * atr_val
compression_threshold = self.COMPRESSION_ATR_MULT * atr_val
was_compressed = False
min_compression_width = float('inf')
@@ -119,16 +126,16 @@ class S4F_EMA_Ribbon(BaseStrategy):
return None
vol = current["volume"]
vol_avg = data["volume"].iloc[max(0, idx - 20):idx].mean()
if vol_avg <= 0 or vol <= 1.2 * vol_avg:
if vol_avg <= 0 or vol <= self.VOLUME_MULT * vol_avg:
return None
# ------- EXIT LEVELS -------
if direction == "LONG":
sl = price - 2.0 * atr_val
tp1 = price + 3.0 * atr_val
sl = price - self.SL_ATR_MULT * atr_val
tp1 = price + self.TP_ATR_MULT * atr_val
else:
sl = price + 2.0 * atr_val
tp1 = price - 3.0 * atr_val
sl = price + self.SL_ATR_MULT * atr_val
tp1 = price - self.TP_ATR_MULT * atr_val
return {
"direction": direction,