Add S10 VWAP Mean Reversion and S11 ADX Trend Pullback strategies, drop both from portfolio

Implemented two M15 intraday strategies to diversify the portfolio:
- S10: VWAP mean reversion in ranging markets (ADX<30, RSI(9) extremes)
- S11: ADX trend pullback to 20 EMA in strong trends (ADX>30, rising)

Added rsi_9 and atr_10 to the indicator pipeline for both strategies.

Backtested on IS (2021-2022) and OOS (2023): both strategies produced
insufficient trade counts on M15 and failed generalization. S10 best
result was EUR_GBP at Gen 0.65 (WARN). S11 collapsed to 0% WR OOS
across all param sweep combos. Both dropped from active portfolio —
3-strategy core (S7_Tight, S9_Filtered, S3) remains unchanged.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Brent Neale
2026-02-20 16:23:23 +10:00
parent 410a10acb6
commit bdab78ee48
7 changed files with 1393 additions and 1163 deletions
+116 -20
View File
@@ -95,32 +95,128 @@
"verdict": "PASS"
}
},
"_portfolio": {
"S10_EUR_USD": {
"pair": "EUR_USD",
"timeframe": "M15",
"is_metrics": {
"trades": 149,
"wr": 57.7,
"pf": 1.29,
"sharpe": 1.68,
"pnl_pips": 865.8,
"max_dd_pips": -597.5,
"expectancy": 5.81
"trades": 13,
"wr": 53.8,
"pf": 1.67,
"sharpe": 3.17,
"pnl_pips": 20.1,
"max_dd_pips": -13.1,
"expectancy": 1.55
},
"oos_metrics": {
"trades": 56,
"wr": 64.3,
"pf": 1.55,
"sharpe": 3.03,
"pnl_pips": 553.3,
"max_dd_pips": -182.9,
"expectancy": 9.88
"trades": 6,
"wr": 33.3,
"pf": 1.19,
"sharpe": 0.97,
"pnl_pips": 1.5,
"max_dd_pips": -7.9,
"expectancy": 0.25
},
"generalization": {
"composite": 1.455,
"composite": 0.45,
"detail": {
"wr": 1.114,
"pf": 1.202,
"expectancy": 1.701,
"sharpe": 1.804
"wr": 0.619,
"pf": 0.713,
"expectancy": 0.161,
"sharpe": 0.306
},
"verdict": "FAIL"
}
},
"S10_USD_JPY": {
"pair": "USD_JPY",
"timeframe": "M15",
"is_metrics": {
"trades": 20,
"wr": 50.0,
"pf": 2.08,
"sharpe": 4.18,
"pnl_pips": 51.5,
"max_dd_pips": -19.3,
"expectancy": 2.58
},
"oos_metrics": {
"trades": 9,
"wr": 33.3,
"pf": 0.76,
"sharpe": -1.48,
"pnl_pips": -10.1,
"max_dd_pips": -9.8,
"expectancy": -1.13
},
"generalization": {
"composite": 0.258,
"detail": {
"wr": 0.666,
"pf": 0.365,
"expectancy": 0.0,
"sharpe": 0.0
},
"verdict": "FAIL"
}
},
"S10_EUR_GBP": {
"pair": "EUR_GBP",
"timeframe": "M15",
"is_metrics": {
"trades": 15,
"wr": 33.3,
"pf": 0.98,
"sharpe": -0.14,
"pnl_pips": -0.8,
"max_dd_pips": -29.2,
"expectancy": -0.06
},
"oos_metrics": {
"trades": 7,
"wr": 42.9,
"pf": 1.28,
"sharpe": 1.61,
"pnl_pips": 3.5,
"max_dd_pips": -9.4,
"expectancy": 0.49
},
"generalization": {
"composite": 0.649,
"detail": {
"wr": 1.288,
"pf": 1.306,
"expectancy": 0,
"sharpe": 0
},
"verdict": "WARN"
}
},
"_portfolio": {
"is_metrics": {
"trades": 197,
"wr": 54.8,
"pf": 1.3,
"sharpe": 1.58,
"pnl_pips": 936.6,
"max_dd_pips": -597.5,
"expectancy": 4.75
},
"oos_metrics": {
"trades": 78,
"wr": 56.4,
"pf": 1.51,
"sharpe": 2.52,
"pnl_pips": 548.2,
"max_dd_pips": -208.7,
"expectancy": 7.03
},
"generalization": {
"composite": 1.316,
"detail": {
"wr": 1.029,
"pf": 1.162,
"expectancy": 1.48,
"sharpe": 1.595
},
"verdict": "PASS"
}
File diff suppressed because it is too large Load Diff
+2
View File
@@ -386,9 +386,11 @@ def compute_all_indicators(df: pd.DataFrame) -> pd.DataFrame:
# RSI
df["rsi_14"] = rsi(df["close"], 14)
df["rsi_9"] = rsi(df["close"], 9)
# ATR
df["atr_14"] = atr(df, 14)
df["atr_10"] = atr(df, 10)
# MACD
df["macd"], df["macd_signal"], df["macd_hist"] = macd(df["close"])
+2
View File
@@ -76,6 +76,8 @@ SWEEP_CONFIGS = [
"KEY_LEVEL_TOLERANCE": [0.5, 0.75],
},
},
# S10 dropped: insufficient trade counts on M15, no pair passes generalization
# S11 dropped: OOS collapsed across all param combos (0% WR, Gen=0.00)
]
+2
View File
@@ -48,6 +48,8 @@ CONFIGS = [
"factory": lambda: S9_London_Session(pair="GBP_AUD", filtered=True)},
{"name": "S3", "pair": "GBP_JPY", "tf": "H1", "htf_tf": "H1",
"factory": lambda: S3_KeyLevel_Breakout()},
# S10 dropped: insufficient trade counts on M15, no pair passes generalization
# S11 dropped: OOS collapsed across all param combos (0% WR, Gen=0.00)
]
@@ -0,0 +1,140 @@
"""
Strategy S10: VWAP Mean Reversion (M15).
Concept: Fade extreme deviations from session VWAP in ranging markets.
Price stretched beyond 2 SD from VWAP with RSI(9) at extremes signals
mean reversion back to VWAP.
Entry conditions (ALL must be true):
1. Session filter: 08:00-16:00 UTC
2. Ranging regime: ADX(14) < 25
3. Price >= 2 SD from session VWAP (z-score)
4. RSI(9) at extreme: <25 for longs, >75 for shorts
5. Volume declining (fading momentum)
Exit:
- SL: Tighter of VWAP 3-SD band and 1.5x ATR(10)
- TP: Session VWAP (100% exit at mean)
- Time stop: 8 bars (2 hours on M15)
Target pairs: EUR_USD, USD_JPY, EUR_GBP (range-bound majors)
"""
from typing import Optional
import numpy as np
import pandas as pd
from .base import BaseStrategy
class S10_VWAP_MeanReversion(BaseStrategy):
strategy_id = 10
name = "S10_VWAP_MeanReversion"
# Tunable parameters
VWAP_SD_ENTRY = 2.0 # Min SD from VWAP to enter
VWAP_SD_SL = 3.0 # SL at this SD band
SL_ATR_MULT = 1.5 # Alternative SL: 1.5x ATR(10)
MAX_ADX = 30 # Ranging regime gate
RSI9_LONG_THRESH = 25 # RSI(9) < 25 for longs
RSI9_SHORT_THRESH = 75 # RSI(9) > 75 for shorts
MAX_BARS = 8 # Time stop: 8 bars = 2 hours on M15
def check_signal(self, data: pd.DataFrame, idx: int,
current: pd.Series,
htf_row: Optional[pd.Series] = None) -> Optional[dict]:
# Warmup
if idx < 50:
return None
# Session filter: 08:00-16:00 UTC
hour = current.name.hour if hasattr(current.name, 'hour') else 0
if hour < 8 or hour >= 16:
return None
# Regime gate: ranging market only
adx_val = current.get("adx_14", 50)
if np.isnan(adx_val) or adx_val >= self.MAX_ADX:
return None
# VWAP data required
vwap = current.get("session_vwap", np.nan)
vwap_std = current.get("vwap_std", np.nan)
if np.isnan(vwap) or np.isnan(vwap_std) or vwap_std <= 0:
return None
price = current["close"]
atr_10 = current.get("atr_10", 0)
if atr_10 <= 0 or np.isnan(atr_10):
return None
rsi9 = current.get("rsi_9", 50)
if np.isnan(rsi9):
return None
# Z-score: how many SDs from VWAP
z_score = (price - vwap) / vwap_std
# Volume declining check (fading momentum)
vol = current.get("volume", 0)
vol_window = data["volume"].iloc[max(0, idx - 3):idx]
if len(vol_window) == 0:
return None
vol_avg = vol_window.mean()
if vol_avg <= 0 or vol >= vol_avg:
return None # Volume not declining
# HTF trend for confluence (soft — not a gate)
htf_bullish = False
htf_bearish = False
if htf_row is not None:
htf_close = htf_row.get("close", np.nan)
htf_ema200 = htf_row.get("ema_200", np.nan)
if not np.isnan(htf_close) and not np.isnan(htf_ema200):
htf_bullish = htf_close > htf_ema200
htf_bearish = htf_close < htf_ema200
direction = None
# LONG: price well below VWAP, RSI oversold
if z_score <= -self.VWAP_SD_ENTRY and rsi9 < self.RSI9_LONG_THRESH:
direction = "LONG"
# SHORT: price well above VWAP, RSI overbought
elif z_score >= self.VWAP_SD_ENTRY and rsi9 > self.RSI9_SHORT_THRESH:
direction = "SHORT"
if direction is None:
return None
# Confluence scoring
confluence = 2 # Base: VWAP deviation + RSI extreme
if abs(z_score) >= 2.5:
confluence += 1 # Deeper deviation
if (direction == "LONG" and htf_bullish) or \
(direction == "SHORT" and htf_bearish):
confluence += 1 # HTF aligns with mean reversion direction
if vol_avg > 0 and vol < 0.7 * vol_avg:
confluence += 1 # Strong fade signal
# Exit levels
if direction == "LONG":
sl_vwap = vwap - self.VWAP_SD_SL * vwap_std
sl_atr = price - self.SL_ATR_MULT * atr_10
sl = max(sl_vwap, sl_atr) # Tighter (closer to price) for longs
tp = vwap # Mean reversion target
else:
sl_vwap = vwap + self.VWAP_SD_SL * vwap_std
sl_atr = price + self.SL_ATR_MULT * atr_10
sl = min(sl_vwap, sl_atr) # Tighter (closer to price) for shorts
tp = vwap
return {
"direction": direction,
"sl": sl,
"tp1": tp,
"tp2": tp,
"tp3": tp,
"confluence": confluence,
"entry_pattern": f"vwap_mean_reversion_{direction.lower()}",
"tp_splits": (1.0, 0.0, 0.0),
"trail_atr_mult": 0,
"max_bars": self.MAX_BARS,
}
@@ -0,0 +1,180 @@
"""
Strategy S11: ADX Trend Pullback to 20 EMA (M15).
Concept: In strong trending environments (ADX>30, rising), enter on pullbacks
to the 20 EMA when RSI(9) has reset to a neutral zone, confirmed by a
candle closing in the trend direction.
Entry conditions (ALL must be true):
1. Session filter: 08:00-16:00 UTC
2. Strong trend: ADX(14) > 30 and rising
3. Trend direction: EMA(20) > EMA(50) for uptrend (opposite for downtrend)
4. HTF alignment: close above/below EMA(200)
5. Pullback to EMA(20): price within 0.3x ATR(14)
6. Candle confirmation: close in trend direction
7. RSI(9) reset: 40-55 for longs, 45-60 for shorts
8. Volume declining during pullback
Exit:
- SL: Tighter of EMA(50) and 1.5x ATR(10)
- TP1: 1.0x risk (50%), TP2: 1.8x risk (50%)
- Trail: 1.0x ATR after TP1
- Time stop: 40 bars (~10 hours on M15)
Target pairs: USD_JPY, GBP_USD, GBP_JPY
"""
from typing import Optional
import numpy as np
import pandas as pd
from .base import BaseStrategy
class S11_ADX_TrendPullback(BaseStrategy):
strategy_id = 11
name = "S11_ADX_TrendPullback"
# Tunable parameters
MIN_ADX = 30 # Strong trend gate
EMA_PROXIMITY_ATR = 0.3 # Pullback within 0.3x ATR of EMA(20)
SL_ATR_MULT = 1.5 # SL: 1.5x ATR(10) or beyond EMA(50)
TP_RR_MULT = 1.8 # TP2: 1.8x risk distance
RSI9_LONG_LOW = 40 # RSI(9) reset zone for longs
RSI9_LONG_HIGH = 55
RSI9_SHORT_LOW = 45 # RSI(9) reset zone for shorts
RSI9_SHORT_HIGH = 60
MAX_BARS = 40 # ~10 hours on M15
TRAIL_ATR_MULT = 1.0 # Trail after 1x risk profit
def check_signal(self, data: pd.DataFrame, idx: int,
current: pd.Series,
htf_row: Optional[pd.Series] = None) -> Optional[dict]:
# Warmup
if idx < 50:
return None
# Session filter: 08:00-16:00 UTC
hour = current.name.hour if hasattr(current.name, 'hour') else 0
if hour < 8 or hour >= 16:
return None
# ADX gate: strong and rising
adx_val = current.get("adx_14", 0)
if np.isnan(adx_val) or adx_val <= self.MIN_ADX:
return None
prev_adx = data["adx_14"].iloc[idx - 1] if idx > 0 else 0
if np.isnan(prev_adx) or adx_val <= prev_adx:
return None # ADX must be rising
# EMA values
ema_20 = current.get("ema_20", np.nan)
ema_50 = current.get("ema_50", np.nan)
if np.isnan(ema_20) or np.isnan(ema_50):
return None
# Trend direction from EMA alignment
uptrend = ema_20 > ema_50
downtrend = ema_20 < ema_50
if not uptrend and not downtrend:
return None
# HTF alignment required
if htf_row is None:
return None
htf_close = htf_row.get("close", np.nan)
htf_ema200 = htf_row.get("ema_200", np.nan)
if np.isnan(htf_close) or np.isnan(htf_ema200):
return None
if uptrend and htf_close <= htf_ema200:
return None
if downtrend and htf_close >= htf_ema200:
return None
price = current["close"]
open_price = current["open"]
atr_14 = current.get("atr_14", 0)
atr_10 = current.get("atr_10", 0)
if atr_14 <= 0 or np.isnan(atr_14) or atr_10 <= 0 or np.isnan(atr_10):
return None
# Pullback proximity: price near EMA(20)
if abs(price - ema_20) > self.EMA_PROXIMITY_ATR * atr_14:
return None
# RSI(9) reset check
rsi9 = current.get("rsi_9", 50)
if np.isnan(rsi9):
return None
# Volume declining during pullback
vol = current.get("volume", 0)
vol_window = data["volume"].iloc[max(0, idx - 3):idx]
if len(vol_window) == 0:
return None
vol_avg = vol_window.mean()
if vol_avg <= 0 or vol >= vol_avg:
return None
direction = None
if uptrend:
# Candle closes bullish
if price <= open_price:
return None
# RSI(9) in reset zone for longs
if self.RSI9_LONG_LOW <= rsi9 <= self.RSI9_LONG_HIGH:
direction = "LONG"
elif downtrend:
# Candle closes bearish
if price >= open_price:
return None
# RSI(9) in reset zone for shorts
if self.RSI9_SHORT_LOW <= rsi9 <= self.RSI9_SHORT_HIGH:
direction = "SHORT"
if direction is None:
return None
# Confluence scoring
confluence = 3 # Base: ADX strong + pullback to EMA + RSI reset
# HTF alignment already confirmed above
confluence += 1
if adx_val > 40:
confluence += 1 # Very strong trend
# Volume spike on confirmation candle (relative to longer window)
vol_avg_20 = data["volume"].iloc[max(0, idx - 20):idx].mean()
if vol_avg_20 > 0 and vol > 1.5 * vol_avg_20:
confluence += 1
# Exit levels
if direction == "LONG":
sl_ema = ema_50
sl_atr = price - self.SL_ATR_MULT * atr_10
sl = max(sl_ema, sl_atr) # Tighter: whichever is closer to price
risk = price - sl
if risk <= 0:
return None
tp1 = price + 1.0 * risk # Breakeven target
tp2 = price + self.TP_RR_MULT * risk # Full target
else:
sl_ema = ema_50
sl_atr = price + self.SL_ATR_MULT * atr_10
sl = min(sl_ema, sl_atr) # Tighter: whichever is closer to price
risk = sl - price
if risk <= 0:
return None
tp1 = price - 1.0 * risk
tp2 = price - self.TP_RR_MULT * risk
return {
"direction": direction,
"sl": sl,
"tp1": tp1,
"tp2": tp2,
"tp3": tp2,
"confluence": confluence,
"entry_pattern": f"adx_trend_pullback_{direction.lower()}",
"tp_splits": (0.50, 0.50, 0.0),
"trail_atr_mult": self.TRAIL_ATR_MULT,
"max_bars": self.MAX_BARS,
}