mirror of
https://github.com/BrentNeale1/fx-quant.git
synced 2026-07-28 19:07:44 +00:00
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:
@@ -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"
|
||||
}
|
||||
|
||||
+951
-1143
File diff suppressed because it is too large
Load Diff
@@ -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"])
|
||||
|
||||
@@ -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)
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
Reference in New Issue
Block a user