mirror of
https://github.com/BrentNeale1/fx-quant.git
synced 2026-08-03 05:47:43 +00:00
Add 5 new strategies (S12-S16) — all tested, none show edge on M15
Implemented and backtested 5 new strategies adapted to M15 timeframe: - S12 Asian Range Sweep (best OOS PF 0.66) - S13 Bollinger-Keltner Squeeze (best OOS PF 0.79) - S14 London Fix post-fix reversal (best OOS PF 0.52) - S15 Momentum Continuation (best OOS PF 0.87) - S16 London ORB (best OOS PF 0.63) Added Bollinger Bands and Keltner Channels to indicator pipeline. None passed generalization — these strategies need M5/M30 data. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,622 @@
|
||||
{
|
||||
"S12_GBP_JPY": {
|
||||
"pair": "GBP_JPY",
|
||||
"timeframe": "M15",
|
||||
"is_metrics": {
|
||||
"trades": 35,
|
||||
"wr": 42.9,
|
||||
"pf": 0.74,
|
||||
"sharpe": -2.1,
|
||||
"pnl_pips": -93.2,
|
||||
"max_dd_pips": -103.2,
|
||||
"expectancy": -2.66
|
||||
},
|
||||
"oos_metrics": {
|
||||
"trades": 11,
|
||||
"wr": 45.5,
|
||||
"pf": 0.53,
|
||||
"sharpe": -4.41,
|
||||
"pnl_pips": -58.4,
|
||||
"max_dd_pips": -71.1,
|
||||
"expectancy": -5.31
|
||||
},
|
||||
"generalization": {
|
||||
"composite": 0.444,
|
||||
"detail": {
|
||||
"wr": 1.061,
|
||||
"pf": 0.716,
|
||||
"expectancy": 0,
|
||||
"sharpe": 0
|
||||
},
|
||||
"verdict": "FAIL"
|
||||
}
|
||||
},
|
||||
"S12_GBP_USD": {
|
||||
"pair": "GBP_USD",
|
||||
"timeframe": "M15",
|
||||
"is_metrics": {
|
||||
"trades": 63,
|
||||
"wr": 33.3,
|
||||
"pf": 0.43,
|
||||
"sharpe": -5.67,
|
||||
"pnl_pips": -323.5,
|
||||
"max_dd_pips": -339.6,
|
||||
"expectancy": -5.14
|
||||
},
|
||||
"oos_metrics": {
|
||||
"trades": 25,
|
||||
"wr": 48.0,
|
||||
"pf": 0.66,
|
||||
"sharpe": -2.94,
|
||||
"pnl_pips": -61.3,
|
||||
"max_dd_pips": -122.6,
|
||||
"expectancy": -2.45
|
||||
},
|
||||
"generalization": {
|
||||
"composite": 0.744,
|
||||
"detail": {
|
||||
"wr": 1.441,
|
||||
"pf": 1.535,
|
||||
"expectancy": 0,
|
||||
"sharpe": 0
|
||||
},
|
||||
"verdict": "WARN"
|
||||
}
|
||||
},
|
||||
"S12_EUR_USD": {
|
||||
"pair": "EUR_USD",
|
||||
"timeframe": "M15",
|
||||
"is_metrics": {
|
||||
"trades": 52,
|
||||
"wr": 38.5,
|
||||
"pf": 0.53,
|
||||
"sharpe": -4.52,
|
||||
"pnl_pips": -174.9,
|
||||
"max_dd_pips": -210.6,
|
||||
"expectancy": -3.36
|
||||
},
|
||||
"oos_metrics": {
|
||||
"trades": 20,
|
||||
"wr": 30.0,
|
||||
"pf": 0.46,
|
||||
"sharpe": -5.78,
|
||||
"pnl_pips": -72.6,
|
||||
"max_dd_pips": -80.9,
|
||||
"expectancy": -3.63
|
||||
},
|
||||
"generalization": {
|
||||
"composite": 0.412,
|
||||
"detail": {
|
||||
"wr": 0.779,
|
||||
"pf": 0.868,
|
||||
"expectancy": 0,
|
||||
"sharpe": 0
|
||||
},
|
||||
"verdict": "FAIL"
|
||||
}
|
||||
},
|
||||
"S12_USD_JPY": {
|
||||
"pair": "USD_JPY",
|
||||
"timeframe": "M15",
|
||||
"is_metrics": {
|
||||
"trades": 18,
|
||||
"wr": 33.3,
|
||||
"pf": 0.3,
|
||||
"sharpe": -8.57,
|
||||
"pnl_pips": -118.5,
|
||||
"max_dd_pips": -132.1,
|
||||
"expectancy": -6.58
|
||||
},
|
||||
"oos_metrics": {
|
||||
"trades": 8,
|
||||
"wr": 37.5,
|
||||
"pf": 0.57,
|
||||
"sharpe": -3.81,
|
||||
"pnl_pips": -31.8,
|
||||
"max_dd_pips": -47.4,
|
||||
"expectancy": -3.98
|
||||
},
|
||||
"generalization": {
|
||||
"composite": 0.757,
|
||||
"detail": {
|
||||
"wr": 1.126,
|
||||
"pf": 1.9,
|
||||
"expectancy": 0,
|
||||
"sharpe": 0
|
||||
},
|
||||
"verdict": "WARN"
|
||||
}
|
||||
},
|
||||
"S13_GBP_JPY": {
|
||||
"pair": "GBP_JPY",
|
||||
"timeframe": "M15",
|
||||
"is_metrics": {
|
||||
"trades": 294,
|
||||
"wr": 46.9,
|
||||
"pf": 0.75,
|
||||
"sharpe": -2.03,
|
||||
"pnl_pips": -934.9,
|
||||
"max_dd_pips": -1184.4,
|
||||
"expectancy": -3.18
|
||||
},
|
||||
"oos_metrics": {
|
||||
"trades": 94,
|
||||
"wr": 42.6,
|
||||
"pf": 0.79,
|
||||
"sharpe": -1.76,
|
||||
"pnl_pips": -291.3,
|
||||
"max_dd_pips": -352.9,
|
||||
"expectancy": -3.1
|
||||
},
|
||||
"generalization": {
|
||||
"composite": 0.49,
|
||||
"detail": {
|
||||
"wr": 0.908,
|
||||
"pf": 1.053,
|
||||
"expectancy": 0,
|
||||
"sharpe": 0
|
||||
},
|
||||
"verdict": "FAIL"
|
||||
}
|
||||
},
|
||||
"S13_EUR_USD": {
|
||||
"pair": "EUR_USD",
|
||||
"timeframe": "M15",
|
||||
"is_metrics": {
|
||||
"trades": 232,
|
||||
"wr": 46.1,
|
||||
"pf": 0.83,
|
||||
"sharpe": -1.29,
|
||||
"pnl_pips": -276.5,
|
||||
"max_dd_pips": -433.4,
|
||||
"expectancy": -1.19
|
||||
},
|
||||
"oos_metrics": {
|
||||
"trades": 76,
|
||||
"wr": 43.4,
|
||||
"pf": 0.72,
|
||||
"sharpe": -2.4,
|
||||
"pnl_pips": -174.5,
|
||||
"max_dd_pips": -269.4,
|
||||
"expectancy": -2.3
|
||||
},
|
||||
"generalization": {
|
||||
"composite": 0.452,
|
||||
"detail": {
|
||||
"wr": 0.941,
|
||||
"pf": 0.867,
|
||||
"expectancy": 0,
|
||||
"sharpe": 0
|
||||
},
|
||||
"verdict": "FAIL"
|
||||
}
|
||||
},
|
||||
"S13_GBP_USD": {
|
||||
"pair": "GBP_USD",
|
||||
"timeframe": "M15",
|
||||
"is_metrics": {
|
||||
"trades": 207,
|
||||
"wr": 41.1,
|
||||
"pf": 0.69,
|
||||
"sharpe": -2.62,
|
||||
"pnl_pips": -680.7,
|
||||
"max_dd_pips": -809.6,
|
||||
"expectancy": -3.29
|
||||
},
|
||||
"oos_metrics": {
|
||||
"trades": 70,
|
||||
"wr": 42.9,
|
||||
"pf": 0.72,
|
||||
"sharpe": -2.34,
|
||||
"pnl_pips": -191.8,
|
||||
"max_dd_pips": -268.5,
|
||||
"expectancy": -2.74
|
||||
},
|
||||
"generalization": {
|
||||
"composite": 0.522,
|
||||
"detail": {
|
||||
"wr": 1.044,
|
||||
"pf": 1.043,
|
||||
"expectancy": 0,
|
||||
"sharpe": 0
|
||||
},
|
||||
"verdict": "WARN"
|
||||
}
|
||||
},
|
||||
"S13_USD_JPY": {
|
||||
"pair": "USD_JPY",
|
||||
"timeframe": "M15",
|
||||
"is_metrics": {
|
||||
"trades": 268,
|
||||
"wr": 41.4,
|
||||
"pf": 0.77,
|
||||
"sharpe": -1.79,
|
||||
"pnl_pips": -503.6,
|
||||
"max_dd_pips": -720.3,
|
||||
"expectancy": -1.88
|
||||
},
|
||||
"oos_metrics": {
|
||||
"trades": 76,
|
||||
"wr": 39.5,
|
||||
"pf": 0.66,
|
||||
"sharpe": -2.99,
|
||||
"pnl_pips": -322.6,
|
||||
"max_dd_pips": -326.6,
|
||||
"expectancy": -4.25
|
||||
},
|
||||
"generalization": {
|
||||
"composite": 0.453,
|
||||
"detail": {
|
||||
"wr": 0.954,
|
||||
"pf": 0.857,
|
||||
"expectancy": 0,
|
||||
"sharpe": 0
|
||||
},
|
||||
"verdict": "FAIL"
|
||||
}
|
||||
},
|
||||
"S14_GBP_USD": {
|
||||
"pair": "GBP_USD",
|
||||
"timeframe": "M15",
|
||||
"is_metrics": {
|
||||
"trades": 23,
|
||||
"wr": 34.8,
|
||||
"pf": 0.55,
|
||||
"sharpe": -4.43,
|
||||
"pnl_pips": -89.1,
|
||||
"max_dd_pips": -127.5,
|
||||
"expectancy": -3.87
|
||||
},
|
||||
"oos_metrics": {
|
||||
"trades": 2,
|
||||
"wr": 0.0,
|
||||
"pf": 0.0,
|
||||
"sharpe": -11.22,
|
||||
"pnl_pips": -10.6,
|
||||
"max_dd_pips": -10.6,
|
||||
"expectancy": -5.29
|
||||
},
|
||||
"generalization": {
|
||||
"composite": 0.0,
|
||||
"detail": {
|
||||
"wr": 0.0,
|
||||
"pf": 0.0,
|
||||
"expectancy": 0,
|
||||
"sharpe": 0
|
||||
},
|
||||
"verdict": "FAIL"
|
||||
}
|
||||
},
|
||||
"S14_EUR_USD": {
|
||||
"pair": "EUR_USD",
|
||||
"timeframe": "M15",
|
||||
"is_metrics": {
|
||||
"trades": 20,
|
||||
"wr": 30.0,
|
||||
"pf": 0.45,
|
||||
"sharpe": -5.31,
|
||||
"pnl_pips": -80.9,
|
||||
"max_dd_pips": -103.0,
|
||||
"expectancy": -4.04
|
||||
},
|
||||
"oos_metrics": {
|
||||
"trades": 3,
|
||||
"wr": 0.0,
|
||||
"pf": 0.0,
|
||||
"sharpe": -50.87,
|
||||
"pnl_pips": -35.9,
|
||||
"max_dd_pips": -25.0,
|
||||
"expectancy": -11.96
|
||||
},
|
||||
"generalization": {
|
||||
"composite": 0.0,
|
||||
"detail": {
|
||||
"wr": 0.0,
|
||||
"pf": 0.0,
|
||||
"expectancy": 0,
|
||||
"sharpe": 0
|
||||
},
|
||||
"verdict": "FAIL"
|
||||
}
|
||||
},
|
||||
"S14_EUR_GBP": {
|
||||
"pair": "EUR_GBP",
|
||||
"timeframe": "M15",
|
||||
"is_metrics": {
|
||||
"trades": 16,
|
||||
"wr": 43.8,
|
||||
"pf": 0.45,
|
||||
"sharpe": -5.64,
|
||||
"pnl_pips": -42.1,
|
||||
"max_dd_pips": -48.6,
|
||||
"expectancy": -2.63
|
||||
},
|
||||
"oos_metrics": {
|
||||
"trades": 4,
|
||||
"wr": 50.0,
|
||||
"pf": 0.4,
|
||||
"sharpe": -5.02,
|
||||
"pnl_pips": -8.5,
|
||||
"max_dd_pips": -2.8,
|
||||
"expectancy": -2.12
|
||||
},
|
||||
"generalization": {
|
||||
"composite": 0.508,
|
||||
"detail": {
|
||||
"wr": 1.142,
|
||||
"pf": 0.889,
|
||||
"expectancy": 0,
|
||||
"sharpe": 0
|
||||
},
|
||||
"verdict": "WARN"
|
||||
}
|
||||
},
|
||||
"S14_GBP_JPY": {
|
||||
"pair": "GBP_JPY",
|
||||
"timeframe": "M15",
|
||||
"is_metrics": {
|
||||
"trades": 24,
|
||||
"wr": 37.5,
|
||||
"pf": 0.51,
|
||||
"sharpe": -4.52,
|
||||
"pnl_pips": -124.1,
|
||||
"max_dd_pips": -147.8,
|
||||
"expectancy": -5.17
|
||||
},
|
||||
"oos_metrics": {
|
||||
"trades": 8,
|
||||
"wr": 37.5,
|
||||
"pf": 0.52,
|
||||
"sharpe": -4.07,
|
||||
"pnl_pips": -47.9,
|
||||
"max_dd_pips": -49.1,
|
||||
"expectancy": -5.98
|
||||
},
|
||||
"generalization": {
|
||||
"composite": 0.505,
|
||||
"detail": {
|
||||
"wr": 1.0,
|
||||
"pf": 1.02,
|
||||
"expectancy": 0,
|
||||
"sharpe": 0
|
||||
},
|
||||
"verdict": "WARN"
|
||||
}
|
||||
},
|
||||
"S15_GBP_JPY": {
|
||||
"pair": "GBP_JPY",
|
||||
"timeframe": "M15",
|
||||
"is_metrics": {
|
||||
"trades": 473,
|
||||
"wr": 38.9,
|
||||
"pf": 0.8,
|
||||
"sharpe": -1.45,
|
||||
"pnl_pips": -1392.7,
|
||||
"max_dd_pips": -1549.2,
|
||||
"expectancy": -2.94
|
||||
},
|
||||
"oos_metrics": {
|
||||
"trades": 166,
|
||||
"wr": 33.7,
|
||||
"pf": 0.57,
|
||||
"sharpe": -3.71,
|
||||
"pnl_pips": -1277.8,
|
||||
"max_dd_pips": -1460.8,
|
||||
"expectancy": -7.7
|
||||
},
|
||||
"generalization": {
|
||||
"composite": 0.395,
|
||||
"detail": {
|
||||
"wr": 0.866,
|
||||
"pf": 0.712,
|
||||
"expectancy": 0,
|
||||
"sharpe": 0
|
||||
},
|
||||
"verdict": "FAIL"
|
||||
}
|
||||
},
|
||||
"S15_USD_JPY": {
|
||||
"pair": "USD_JPY",
|
||||
"timeframe": "M15",
|
||||
"is_metrics": {
|
||||
"trades": 505,
|
||||
"wr": 44.0,
|
||||
"pf": 0.94,
|
||||
"sharpe": -0.39,
|
||||
"pnl_pips": -236.4,
|
||||
"max_dd_pips": -497.2,
|
||||
"expectancy": -0.47
|
||||
},
|
||||
"oos_metrics": {
|
||||
"trades": 161,
|
||||
"wr": 41.6,
|
||||
"pf": 0.79,
|
||||
"sharpe": -1.65,
|
||||
"pnl_pips": -398.2,
|
||||
"max_dd_pips": -596.8,
|
||||
"expectancy": -2.47
|
||||
},
|
||||
"generalization": {
|
||||
"composite": 0.446,
|
||||
"detail": {
|
||||
"wr": 0.945,
|
||||
"pf": 0.84,
|
||||
"expectancy": 0,
|
||||
"sharpe": 0
|
||||
},
|
||||
"verdict": "FAIL"
|
||||
}
|
||||
},
|
||||
"S15_GBP_USD": {
|
||||
"pair": "GBP_USD",
|
||||
"timeframe": "M15",
|
||||
"is_metrics": {
|
||||
"trades": 529,
|
||||
"wr": 37.4,
|
||||
"pf": 0.72,
|
||||
"sharpe": -2.18,
|
||||
"pnl_pips": -1787.4,
|
||||
"max_dd_pips": -1822.5,
|
||||
"expectancy": -3.38
|
||||
},
|
||||
"oos_metrics": {
|
||||
"trades": 171,
|
||||
"wr": 42.1,
|
||||
"pf": 0.87,
|
||||
"sharpe": -0.99,
|
||||
"pnl_pips": -237.1,
|
||||
"max_dd_pips": -393.5,
|
||||
"expectancy": -1.39
|
||||
},
|
||||
"generalization": {
|
||||
"composite": 0.584,
|
||||
"detail": {
|
||||
"wr": 1.126,
|
||||
"pf": 1.208,
|
||||
"expectancy": 0,
|
||||
"sharpe": 0
|
||||
},
|
||||
"verdict": "WARN"
|
||||
}
|
||||
},
|
||||
"S15_EUR_USD": {
|
||||
"pair": "EUR_USD",
|
||||
"timeframe": "M15",
|
||||
"is_metrics": {
|
||||
"trades": 531,
|
||||
"wr": 41.1,
|
||||
"pf": 0.74,
|
||||
"sharpe": -2.09,
|
||||
"pnl_pips": -1144.3,
|
||||
"max_dd_pips": -1321.8,
|
||||
"expectancy": -2.16
|
||||
},
|
||||
"oos_metrics": {
|
||||
"trades": 180,
|
||||
"wr": 37.8,
|
||||
"pf": 0.59,
|
||||
"sharpe": -3.78,
|
||||
"pnl_pips": -605.0,
|
||||
"max_dd_pips": -633.6,
|
||||
"expectancy": -3.36
|
||||
},
|
||||
"generalization": {
|
||||
"composite": 0.429,
|
||||
"detail": {
|
||||
"wr": 0.92,
|
||||
"pf": 0.797,
|
||||
"expectancy": 0,
|
||||
"sharpe": 0
|
||||
},
|
||||
"verdict": "FAIL"
|
||||
}
|
||||
},
|
||||
"S16_GBP_JPY": {
|
||||
"pair": "GBP_JPY",
|
||||
"timeframe": "M15",
|
||||
"is_metrics": {
|
||||
"trades": 0,
|
||||
"wr": 0,
|
||||
"pf": 0,
|
||||
"sharpe": 0,
|
||||
"pnl_pips": 0,
|
||||
"max_dd_pips": 0,
|
||||
"expectancy": 0
|
||||
},
|
||||
"oos_metrics": {
|
||||
"trades": 0,
|
||||
"wr": 0,
|
||||
"pf": 0,
|
||||
"sharpe": 0,
|
||||
"pnl_pips": 0,
|
||||
"max_dd_pips": 0,
|
||||
"expectancy": 0
|
||||
},
|
||||
"generalization": {
|
||||
"composite": 0,
|
||||
"detail": {},
|
||||
"verdict": "FAIL"
|
||||
}
|
||||
},
|
||||
"S16_GBP_USD": {
|
||||
"pair": "GBP_USD",
|
||||
"timeframe": "M15",
|
||||
"is_metrics": {
|
||||
"trades": 0,
|
||||
"wr": 0,
|
||||
"pf": 0,
|
||||
"sharpe": 0,
|
||||
"pnl_pips": 0,
|
||||
"max_dd_pips": 0,
|
||||
"expectancy": 0
|
||||
},
|
||||
"oos_metrics": {
|
||||
"trades": 0,
|
||||
"wr": 0,
|
||||
"pf": 0,
|
||||
"sharpe": 0,
|
||||
"pnl_pips": 0,
|
||||
"max_dd_pips": 0,
|
||||
"expectancy": 0
|
||||
},
|
||||
"generalization": {
|
||||
"composite": 0,
|
||||
"detail": {},
|
||||
"verdict": "FAIL"
|
||||
}
|
||||
},
|
||||
"S16_EUR_USD": {
|
||||
"pair": "EUR_USD",
|
||||
"timeframe": "M15",
|
||||
"is_metrics": {
|
||||
"trades": 0,
|
||||
"wr": 0,
|
||||
"pf": 0,
|
||||
"sharpe": 0,
|
||||
"pnl_pips": 0,
|
||||
"max_dd_pips": 0,
|
||||
"expectancy": 0
|
||||
},
|
||||
"oos_metrics": {
|
||||
"trades": 0,
|
||||
"wr": 0,
|
||||
"pf": 0,
|
||||
"sharpe": 0,
|
||||
"pnl_pips": 0,
|
||||
"max_dd_pips": 0,
|
||||
"expectancy": 0
|
||||
},
|
||||
"generalization": {
|
||||
"composite": 0,
|
||||
"detail": {},
|
||||
"verdict": "FAIL"
|
||||
}
|
||||
},
|
||||
"S16_USD_JPY": {
|
||||
"pair": "USD_JPY",
|
||||
"timeframe": "M15",
|
||||
"is_metrics": {
|
||||
"trades": 0,
|
||||
"wr": 0,
|
||||
"pf": 0,
|
||||
"sharpe": 0,
|
||||
"pnl_pips": 0,
|
||||
"max_dd_pips": 0,
|
||||
"expectancy": 0
|
||||
},
|
||||
"oos_metrics": {
|
||||
"trades": 0,
|
||||
"wr": 0,
|
||||
"pf": 0,
|
||||
"sharpe": 0,
|
||||
"pnl_pips": 0,
|
||||
"max_dd_pips": 0,
|
||||
"expectancy": 0
|
||||
},
|
||||
"generalization": {
|
||||
"composite": 0,
|
||||
"detail": {},
|
||||
"verdict": "FAIL"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -142,6 +142,32 @@ def session_vwap_bands(df: pd.DataFrame, session_start_hour: int = 8):
|
||||
}, index=df.index)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Bollinger Bands
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def bollinger_bands(series: pd.Series, period: int = 20, num_std: float = 2.0):
|
||||
"""Bollinger Bands: middle (SMA), upper, lower."""
|
||||
mid = series.rolling(window=period, min_periods=period).mean()
|
||||
std = series.rolling(window=period, min_periods=period).std()
|
||||
upper = mid + num_std * std
|
||||
lower = mid - num_std * std
|
||||
return mid, upper, lower
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Keltner Channels
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def keltner_channels(df: pd.DataFrame, period: int = 20, atr_mult: float = 1.5):
|
||||
"""Keltner Channels: EMA-based middle with ATR-based bands."""
|
||||
mid = ema(df["close"], period)
|
||||
atr_val = atr(df, period)
|
||||
upper = mid + atr_mult * atr_val
|
||||
lower = mid - atr_mult * atr_val
|
||||
return mid, upper, lower
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# On-Balance Volume (OBV)
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -419,4 +445,10 @@ def compute_all_indicators(df: pd.DataFrame) -> pd.DataFrame:
|
||||
# On-Balance Volume
|
||||
df["obv"] = obv(df)
|
||||
|
||||
# Bollinger Bands (20, 2)
|
||||
df["bb_mid"], df["bb_upper"], df["bb_lower"] = bollinger_bands(df["close"], 20, 2.0)
|
||||
|
||||
# Keltner Channels (20, 1.5)
|
||||
df["kc_mid"], df["kc_upper"], df["kc_lower"] = keltner_channels(df, 20, 1.5)
|
||||
|
||||
return df
|
||||
|
||||
@@ -0,0 +1,366 @@
|
||||
"""
|
||||
Test New Strategies (S12-S16) — IS/OOS Backtest.
|
||||
|
||||
Strategies:
|
||||
S12 - Asian Range Sweep (M15 + H1 HTF) — mean reversion after sweep
|
||||
S13 - Bollinger-Keltner Squeeze (M15 + H1 HTF) — volatility breakout
|
||||
S14 - London Fix (M15 + H1 HTF) — post-fix mean reversion
|
||||
S15 - Momentum Continuation (M15 + H1 HTF) — impulse pullback entry
|
||||
S16 - London ORB (M15 + H1 HTF) — opening range breakout
|
||||
|
||||
Each tested across multiple pairs.
|
||||
"""
|
||||
import os, sys, io, json, time
|
||||
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace')
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
|
||||
|
||||
import pandas as pd
|
||||
import numpy as np
|
||||
from src.indicators.technical import compute_all_indicators
|
||||
from src.backtester.engine import Backtester
|
||||
|
||||
# Strategy imports
|
||||
from src.strategies_pkg.s12_asian_range_sweep import S12_AsianRangeSweep
|
||||
from src.strategies_pkg.s13_bollinger_keltner_squeeze import S13_BollingerKeltnerSqueeze
|
||||
from src.strategies_pkg.s14_london_fix import S14_LondonFix
|
||||
from src.strategies_pkg.s15_momentum_continuation import S15_MomentumContinuation
|
||||
from src.strategies_pkg.s16_london_orb import S16_LondonORB
|
||||
|
||||
PROCESSED_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "data", "processed")
|
||||
RESULTS_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "results", "phase2")
|
||||
os.makedirs(RESULTS_DIR, exist_ok=True)
|
||||
|
||||
# IS/OOS period definitions (same as Phase 2)
|
||||
IS_START = "2021-01-01"
|
||||
IS_END = "2022-12-31"
|
||||
OOS_START = "2023-01-01"
|
||||
OOS_END = "2023-08-31"
|
||||
WARMUP_DAYS = 60
|
||||
|
||||
# All M15 strategies with H1 as HTF
|
||||
CONFIGS = [
|
||||
# S12: Asian Range Sweep — best on liquid London pairs
|
||||
{"name": "S12_GBP_JPY", "pair": "GBP_JPY", "tf": "M15", "htf_tf": "H1",
|
||||
"factory": lambda: S12_AsianRangeSweep()},
|
||||
{"name": "S12_GBP_USD", "pair": "GBP_USD", "tf": "M15", "htf_tf": "H1",
|
||||
"factory": lambda: S12_AsianRangeSweep()},
|
||||
{"name": "S12_EUR_USD", "pair": "EUR_USD", "tf": "M15", "htf_tf": "H1",
|
||||
"factory": lambda: S12_AsianRangeSweep()},
|
||||
{"name": "S12_USD_JPY", "pair": "USD_JPY", "tf": "M15", "htf_tf": "H1",
|
||||
"factory": lambda: S12_AsianRangeSweep()},
|
||||
|
||||
# S13: Bollinger-Keltner Squeeze — works across many pairs
|
||||
{"name": "S13_GBP_JPY", "pair": "GBP_JPY", "tf": "M15", "htf_tf": "H1",
|
||||
"factory": lambda: S13_BollingerKeltnerSqueeze()},
|
||||
{"name": "S13_EUR_USD", "pair": "EUR_USD", "tf": "M15", "htf_tf": "H1",
|
||||
"factory": lambda: S13_BollingerKeltnerSqueeze()},
|
||||
{"name": "S13_GBP_USD", "pair": "GBP_USD", "tf": "M15", "htf_tf": "H1",
|
||||
"factory": lambda: S13_BollingerKeltnerSqueeze()},
|
||||
{"name": "S13_USD_JPY", "pair": "USD_JPY", "tf": "M15", "htf_tf": "H1",
|
||||
"factory": lambda: S13_BollingerKeltnerSqueeze()},
|
||||
|
||||
# S14: London Fix — best on GBP and EUR pairs
|
||||
{"name": "S14_GBP_USD", "pair": "GBP_USD", "tf": "M15", "htf_tf": "H1",
|
||||
"factory": lambda: S14_LondonFix()},
|
||||
{"name": "S14_EUR_USD", "pair": "EUR_USD", "tf": "M15", "htf_tf": "H1",
|
||||
"factory": lambda: S14_LondonFix()},
|
||||
{"name": "S14_EUR_GBP", "pair": "EUR_GBP", "tf": "M15", "htf_tf": "H1",
|
||||
"factory": lambda: S14_LondonFix()},
|
||||
{"name": "S14_GBP_JPY", "pair": "GBP_JPY", "tf": "M15", "htf_tf": "H1",
|
||||
"factory": lambda: S14_LondonFix()},
|
||||
|
||||
# S15: Momentum Continuation — trending pairs
|
||||
{"name": "S15_GBP_JPY", "pair": "GBP_JPY", "tf": "M15", "htf_tf": "H1",
|
||||
"factory": lambda: S15_MomentumContinuation()},
|
||||
{"name": "S15_USD_JPY", "pair": "USD_JPY", "tf": "M15", "htf_tf": "H1",
|
||||
"factory": lambda: S15_MomentumContinuation()},
|
||||
{"name": "S15_GBP_USD", "pair": "GBP_USD", "tf": "M15", "htf_tf": "H1",
|
||||
"factory": lambda: S15_MomentumContinuation()},
|
||||
{"name": "S15_EUR_USD", "pair": "EUR_USD", "tf": "M15", "htf_tf": "H1",
|
||||
"factory": lambda: S15_MomentumContinuation()},
|
||||
|
||||
# S16: London ORB — best on London-active pairs
|
||||
{"name": "S16_GBP_JPY", "pair": "GBP_JPY", "tf": "M15", "htf_tf": "H1",
|
||||
"factory": lambda: S16_LondonORB()},
|
||||
{"name": "S16_GBP_USD", "pair": "GBP_USD", "tf": "M15", "htf_tf": "H1",
|
||||
"factory": lambda: S16_LondonORB()},
|
||||
{"name": "S16_EUR_USD", "pair": "EUR_USD", "tf": "M15", "htf_tf": "H1",
|
||||
"factory": lambda: S16_LondonORB()},
|
||||
{"name": "S16_USD_JPY", "pair": "USD_JPY", "tf": "M15", "htf_tf": "H1",
|
||||
"factory": lambda: S16_LondonORB()},
|
||||
]
|
||||
|
||||
|
||||
def load_data(pair, tf):
|
||||
"""Load price data with indicators."""
|
||||
fp = os.path.join(PROCESSED_DIR, f"{pair}_{tf}.csv")
|
||||
if not os.path.exists(fp):
|
||||
print(f" WARNING: {fp} not found")
|
||||
return None
|
||||
df = pd.read_csv(fp, index_col=0, parse_dates=True)
|
||||
df.index.name = "timestamp"
|
||||
return compute_all_indicators(df)
|
||||
|
||||
|
||||
def slice_period(df, start, end, warmup_days=WARMUP_DAYS):
|
||||
"""Slice dataframe to a date range, with warmup prepended."""
|
||||
if df.index.tz is not None:
|
||||
start_ts = pd.Timestamp(start, tz=df.index.tz)
|
||||
end_ts = pd.Timestamp(f"{end} 23:59:59", tz=df.index.tz)
|
||||
else:
|
||||
start_ts = pd.Timestamp(start)
|
||||
end_ts = pd.Timestamp(f"{end} 23:59:59")
|
||||
|
||||
warmup_start = start_ts - pd.DateOffset(days=warmup_days)
|
||||
sliced = df[(df.index >= warmup_start) & (df.index <= end_ts)].copy()
|
||||
return sliced, start_ts
|
||||
|
||||
|
||||
def run_backtest_period(cfg, data, htf_data, start, end):
|
||||
"""Run backtester on a period, return filtered trade log."""
|
||||
sliced, start_ts = slice_period(data, start, end)
|
||||
if len(sliced) < 250:
|
||||
print(f" Insufficient data ({len(sliced)} bars)")
|
||||
return pd.DataFrame()
|
||||
|
||||
htf_sliced = slice_period(htf_data, start, end)[0] if htf_data is not None else None
|
||||
|
||||
strategy = cfg["factory"]()
|
||||
bt = Backtester(data=sliced, strategy=strategy, pair=cfg["pair"],
|
||||
starting_equity=100_000.0, htf_data=htf_sliced)
|
||||
bt.run()
|
||||
trade_log = bt.get_trade_log_df()
|
||||
|
||||
# Filter trades to exclude warmup period
|
||||
if not trade_log.empty:
|
||||
ts = pd.to_datetime(trade_log["timestamp"])
|
||||
filter_ts = pd.Timestamp(start_ts)
|
||||
if ts.dt.tz is not None and filter_ts.tz is None:
|
||||
filter_ts = filter_ts.tz_localize(ts.dt.tz)
|
||||
elif ts.dt.tz is None and filter_ts.tz is not None:
|
||||
filter_ts = filter_ts.tz_localize(None)
|
||||
trade_log = trade_log[ts >= filter_ts]
|
||||
|
||||
return trade_log
|
||||
|
||||
|
||||
def compute_metrics(trade_log):
|
||||
"""Compute metrics from a trade log DataFrame."""
|
||||
if trade_log.empty or len(trade_log) == 0:
|
||||
return {
|
||||
"trades": 0, "wr": 0, "pf": 0, "sharpe": 0,
|
||||
"pnl_pips": 0, "max_dd_pips": 0, "expectancy": 0,
|
||||
}
|
||||
|
||||
n = len(trade_log)
|
||||
wins = trade_log[trade_log["win"] == True]
|
||||
losses = trade_log[trade_log["win"] == False]
|
||||
|
||||
wr = len(wins) / n * 100 if n > 0 else 0
|
||||
gross_profit = wins["pnl_pips"].sum() if len(wins) > 0 else 0
|
||||
gross_loss = abs(losses["pnl_pips"].sum()) if len(losses) > 0 else 0
|
||||
pf = gross_profit / gross_loss if gross_loss > 0 else float("inf")
|
||||
total_pnl = trade_log["pnl_pips"].sum()
|
||||
expectancy = total_pnl / n if n > 0 else 0
|
||||
|
||||
if n > 1:
|
||||
pnl_series = trade_log["pnl_pips"]
|
||||
sharpe = (pnl_series.mean() / pnl_series.std()) * np.sqrt(252) \
|
||||
if pnl_series.std() > 0 else 0
|
||||
else:
|
||||
sharpe = 0
|
||||
|
||||
cum_pnl = trade_log["pnl_pips"].cumsum()
|
||||
peak = cum_pnl.cummax()
|
||||
dd = cum_pnl - peak
|
||||
max_dd = dd.min() if len(dd) > 0 else 0
|
||||
|
||||
return {
|
||||
"trades": n,
|
||||
"wr": round(wr, 1),
|
||||
"pf": round(pf, 2),
|
||||
"sharpe": round(sharpe, 2),
|
||||
"pnl_pips": round(total_pnl, 1),
|
||||
"max_dd_pips": round(max_dd, 1),
|
||||
"expectancy": round(expectancy, 2),
|
||||
}
|
||||
|
||||
|
||||
def compute_generalization_scores(is_metrics, oos_metrics):
|
||||
"""Compute OOS/IS ratio per metric + composite generalization score."""
|
||||
if is_metrics["trades"] == 0 or oos_metrics["trades"] == 0:
|
||||
return {"composite": 0, "detail": {}, "verdict": "FAIL"}
|
||||
|
||||
ratios = {}
|
||||
if is_metrics["wr"] > 0:
|
||||
ratios["wr"] = oos_metrics["wr"] / is_metrics["wr"]
|
||||
else:
|
||||
ratios["wr"] = 0
|
||||
|
||||
if is_metrics["pf"] > 0 and is_metrics["pf"] != float("inf"):
|
||||
if oos_metrics["pf"] == float("inf"):
|
||||
ratios["pf"] = 2.0
|
||||
else:
|
||||
ratios["pf"] = oos_metrics["pf"] / is_metrics["pf"]
|
||||
else:
|
||||
ratios["pf"] = 0
|
||||
|
||||
if is_metrics["expectancy"] > 0:
|
||||
ratios["expectancy"] = oos_metrics["expectancy"] / is_metrics["expectancy"]
|
||||
elif is_metrics["expectancy"] < 0 and oos_metrics["expectancy"] < 0:
|
||||
ratios["expectancy"] = 0
|
||||
else:
|
||||
ratios["expectancy"] = 0
|
||||
|
||||
if is_metrics["sharpe"] > 0:
|
||||
ratios["sharpe"] = oos_metrics["sharpe"] / is_metrics["sharpe"]
|
||||
else:
|
||||
ratios["sharpe"] = 0
|
||||
|
||||
for k in ratios:
|
||||
ratios[k] = min(ratios[k], 2.0)
|
||||
ratios[k] = max(ratios[k], 0.0)
|
||||
|
||||
composite = np.mean(list(ratios.values())) if ratios else 0
|
||||
|
||||
if composite >= 0.80:
|
||||
verdict = "PASS"
|
||||
elif composite >= 0.50:
|
||||
verdict = "WARN"
|
||||
else:
|
||||
verdict = "FAIL"
|
||||
|
||||
return {
|
||||
"composite": round(composite, 3),
|
||||
"detail": {k: round(v, 3) for k, v in ratios.items()},
|
||||
"verdict": verdict,
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
t0 = time.time()
|
||||
all_results = {}
|
||||
|
||||
print(f"{'='*105}")
|
||||
print("TEST NEW STRATEGIES S12-S16 (IS/OOS Split)")
|
||||
print(f" IS period: {IS_START} to {IS_END}")
|
||||
print(f" OOS period: {OOS_START} to {OOS_END}")
|
||||
print(f" Configs: {len(CONFIGS)} strategy-pair combos")
|
||||
print(f"{'='*105}")
|
||||
|
||||
data_cache = {}
|
||||
|
||||
for cfg in CONFIGS:
|
||||
name = cfg["name"]
|
||||
pair = cfg["pair"]
|
||||
tf = cfg["tf"]
|
||||
htf_tf = cfg["htf_tf"]
|
||||
|
||||
print(f"\n {name} / {pair} ({tf} + {htf_tf})...")
|
||||
|
||||
# Load primary data (cached)
|
||||
cache_key = f"{pair}_{tf}"
|
||||
if cache_key not in data_cache:
|
||||
data_cache[cache_key] = load_data(pair, tf)
|
||||
data = data_cache[cache_key]
|
||||
if data is None:
|
||||
continue
|
||||
|
||||
# Load HTF data (cached)
|
||||
htf_cache_key = f"{pair}_{htf_tf}"
|
||||
if htf_cache_key not in data_cache:
|
||||
data_cache[htf_cache_key] = load_data(pair, htf_tf)
|
||||
htf_data = data_cache[htf_cache_key]
|
||||
if htf_data is None:
|
||||
continue
|
||||
|
||||
# Run IS
|
||||
is_log = run_backtest_period(cfg, data, htf_data, IS_START, IS_END)
|
||||
is_metrics = compute_metrics(is_log)
|
||||
|
||||
# Run OOS
|
||||
oos_log = run_backtest_period(cfg, data, htf_data, OOS_START, OOS_END)
|
||||
oos_metrics = compute_metrics(oos_log)
|
||||
|
||||
# Generalization score
|
||||
gen = compute_generalization_scores(is_metrics, oos_metrics)
|
||||
|
||||
# Print
|
||||
print(f" IS: {is_metrics['trades']:>4}t WR={is_metrics['wr']:>5.1f}% "
|
||||
f"PF={is_metrics['pf']:>5.2f} Sharpe={is_metrics['sharpe']:>6.2f} "
|
||||
f"PnL={is_metrics['pnl_pips']:>+8.1f}p DD={is_metrics['max_dd_pips']:>+7.1f}p")
|
||||
print(f" OOS: {oos_metrics['trades']:>4}t WR={oos_metrics['wr']:>5.1f}% "
|
||||
f"PF={oos_metrics['pf']:>5.2f} Sharpe={oos_metrics['sharpe']:>6.2f} "
|
||||
f"PnL={oos_metrics['pnl_pips']:>+8.1f}p DD={oos_metrics['max_dd_pips']:>+7.1f}p "
|
||||
f"Gen={gen['composite']:>5.3f} {gen['verdict']}")
|
||||
|
||||
all_results[name] = {
|
||||
"pair": pair, "timeframe": tf,
|
||||
"is_metrics": is_metrics, "oos_metrics": oos_metrics,
|
||||
"generalization": gen,
|
||||
}
|
||||
|
||||
# Summary table
|
||||
print(f"\n{'='*105}")
|
||||
print("SUMMARY — SORTED BY OOS PROFIT FACTOR")
|
||||
print(f"{'='*105}")
|
||||
print(f" {'Strategy':<16} {'Pair':<10} {'IS-t':>5} {'IS PF':>6} "
|
||||
f"{'OOS-t':>6} {'OOS PF':>7} {'OOS WR%':>8} "
|
||||
f"{'Gen':>6} {'Verdict':>8}")
|
||||
print(f" {'-'*85}")
|
||||
|
||||
sorted_results = sorted(all_results.items(),
|
||||
key=lambda x: x[1]["oos_metrics"]["pf"]
|
||||
if x[1]["oos_metrics"]["pf"] != float("inf") else 99,
|
||||
reverse=True)
|
||||
|
||||
for name, res in sorted_results:
|
||||
is_m = res["is_metrics"]
|
||||
oos_m = res["oos_metrics"]
|
||||
gen = res["generalization"]
|
||||
pf_str = f"{oos_m['pf']:.2f}" if oos_m['pf'] != float('inf') else "inf"
|
||||
print(f" {name:<16} {res['pair']:<10} {is_m['trades']:>5} {is_m['pf']:>6.2f} "
|
||||
f"{oos_m['trades']:>6} {pf_str:>7} {oos_m['wr']:>7.1f}% "
|
||||
f"{gen['composite']:>5.3f} {gen['verdict']:>8}")
|
||||
|
||||
# Highlight promising strategies
|
||||
print(f"\n{'='*105}")
|
||||
print("PROMISING (OOS PF > 1.0, OOS trades >= 5, Gen >= 0.50)")
|
||||
print(f"{'='*105}")
|
||||
promising = [(n, r) for n, r in sorted_results
|
||||
if r["oos_metrics"]["pf"] > 1.0
|
||||
and r["oos_metrics"]["trades"] >= 5
|
||||
and r["generalization"]["composite"] >= 0.50]
|
||||
if promising:
|
||||
for name, res in promising:
|
||||
is_m = res["is_metrics"]
|
||||
oos_m = res["oos_metrics"]
|
||||
gen = res["generalization"]
|
||||
print(f" {name:<16} IS: {is_m['trades']}t PF={is_m['pf']:.2f} WR={is_m['wr']:.1f}% "
|
||||
f"OOS: {oos_m['trades']}t PF={oos_m['pf']:.2f} WR={oos_m['wr']:.1f}% "
|
||||
f"Gen={gen['composite']:.3f} {gen['verdict']}")
|
||||
else:
|
||||
print(" None found.")
|
||||
|
||||
# Save JSON report
|
||||
out_path = os.path.join(RESULTS_DIR, "test_new_strategies.json")
|
||||
|
||||
def json_default(obj):
|
||||
if isinstance(obj, (np.integer,)):
|
||||
return int(obj)
|
||||
if isinstance(obj, (np.floating,)):
|
||||
return float(obj)
|
||||
if isinstance(obj, (np.bool_,)):
|
||||
return bool(obj)
|
||||
return str(obj)
|
||||
|
||||
with open(out_path, "w") as f:
|
||||
json.dump(all_results, f, indent=2, default=json_default)
|
||||
print(f"\nResults saved: {out_path}")
|
||||
|
||||
elapsed = time.time() - t0
|
||||
print(f"Total runtime: {elapsed:.1f}s")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,234 @@
|
||||
"""
|
||||
Strategy S12: Asian Range Sweep (Mean Reversion).
|
||||
|
||||
Concept: Price sweeps past the Asian session (00:00-07:00 UTC) high or low
|
||||
at London open, triggering clustered stop-loss orders, then reverses back
|
||||
into the range. This is a classic liquidity sweep pattern on the session level.
|
||||
|
||||
Adapted to M15 from original 5-minute specification. The Asian range is well-
|
||||
defined on M15 (28 bars = 7 hours), giving clean levels.
|
||||
|
||||
Entry conditions (ALL must be true):
|
||||
1. Asian range established (00:00-07:00 UTC, min 3 bars)
|
||||
2. Asian range not too wide (< 2.0 ATR H1)
|
||||
3. Price sweeps past Asian high/low by 0.3-1.5 ATR
|
||||
4. Price closes back inside the Asian range (reversal candle)
|
||||
5. Session: 07:00-10:00 UTC (London kill zone)
|
||||
6. HTF trend alignment (soft confluence)
|
||||
|
||||
Exit:
|
||||
- SL: Beyond sweep extreme + 0.5 ATR buffer
|
||||
- TP1: Asian range midpoint (close 50%)
|
||||
- TP2: Opposite side of Asian range (close 50%)
|
||||
- Max hold: 40 bars (~10 hours on M15)
|
||||
"""
|
||||
from typing import Optional
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from .base import BaseStrategy
|
||||
|
||||
|
||||
class S12_AsianRangeSweep(BaseStrategy):
|
||||
strategy_id = 12
|
||||
name = "S12_AsianRangeSweep"
|
||||
|
||||
# Asian session window (UTC)
|
||||
ASIAN_START_HOUR = 0
|
||||
ASIAN_END_HOUR = 7
|
||||
|
||||
# Entry window (UTC)
|
||||
ENTRY_START_HOUR = 7
|
||||
ENTRY_END_HOUR = 10
|
||||
|
||||
# Sweep parameters
|
||||
SWEEP_MIN_ATR = 0.3 # Min penetration past Asian range
|
||||
SWEEP_MAX_ATR = 1.5 # Max penetration (beyond = genuine breakout)
|
||||
|
||||
# Risk management
|
||||
SL_ATR_BUFFER = 0.5 # Buffer beyond sweep extreme
|
||||
TP_RR_MULT = 1.5 # Minimum RR filter
|
||||
|
||||
MAX_BARS = 40
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._asian_range_cache = {}
|
||||
|
||||
def _compute_asian_range(self, data, idx):
|
||||
"""Compute Asian session range for the current day."""
|
||||
current_time = data.index[idx]
|
||||
current_date = current_time.date()
|
||||
|
||||
if current_date in self._asian_range_cache:
|
||||
return self._asian_range_cache[current_date]
|
||||
|
||||
asian_bars = []
|
||||
for i in range(max(0, idx - 60), idx + 1):
|
||||
bar_time = data.index[i]
|
||||
if bar_time.date() != current_date:
|
||||
continue
|
||||
if self.ASIAN_START_HOUR <= bar_time.hour < self.ASIAN_END_HOUR:
|
||||
asian_bars.append(i)
|
||||
|
||||
if len(asian_bars) < 3:
|
||||
return None
|
||||
|
||||
asian_data = data.iloc[asian_bars]
|
||||
asian_high = asian_data["high"].max()
|
||||
asian_low = asian_data["low"].min()
|
||||
asian_mid = (asian_high + asian_low) / 2
|
||||
asian_avg_vol = asian_data["volume"].mean()
|
||||
|
||||
result = (asian_high, asian_low, asian_mid, asian_avg_vol)
|
||||
self._asian_range_cache[current_date] = result
|
||||
return result
|
||||
|
||||
def check_signal(self, data: pd.DataFrame, idx: int,
|
||||
current: pd.Series,
|
||||
htf_row: Optional[pd.Series] = None) -> Optional[dict]:
|
||||
if idx < 200:
|
||||
return None
|
||||
|
||||
# Entry window: 07:00-10:00 UTC
|
||||
hour = current.name.hour if hasattr(current.name, 'hour') else 0
|
||||
if hour < self.ENTRY_START_HOUR or hour >= self.ENTRY_END_HOUR:
|
||||
return None
|
||||
|
||||
atr_val = current.get("atr_14", 0)
|
||||
if atr_val <= 0 or np.isnan(atr_val):
|
||||
return None
|
||||
|
||||
# Get Asian range
|
||||
asian = self._compute_asian_range(data, idx)
|
||||
if asian is None:
|
||||
return None
|
||||
|
||||
asian_high, asian_low, asian_mid, asian_avg_vol = asian
|
||||
asian_range = asian_high - asian_low
|
||||
|
||||
if asian_range <= 0:
|
||||
return None
|
||||
|
||||
# Skip if Asian range is too wide (> 2.0 ATR)
|
||||
htf_atr = atr_val
|
||||
if htf_row is not None:
|
||||
htf_atr_val = htf_row.get("atr_14", np.nan)
|
||||
if not np.isnan(htf_atr_val) and htf_atr_val > 0:
|
||||
htf_atr = htf_atr_val
|
||||
|
||||
if asian_range > 2.0 * htf_atr:
|
||||
return None
|
||||
|
||||
price = current["close"]
|
||||
candle_high = current["high"]
|
||||
candle_low = current["low"]
|
||||
candle_range = candle_high - candle_low
|
||||
if candle_range <= 0:
|
||||
return None
|
||||
|
||||
# HTF trend (soft confluence)
|
||||
htf_aligned_long = False
|
||||
htf_aligned_short = False
|
||||
if htf_row is not None:
|
||||
htf_ema200 = htf_row.get("ema_200", np.nan)
|
||||
htf_close = htf_row.get("close", np.nan)
|
||||
if not np.isnan(htf_ema200) and not np.isnan(htf_close):
|
||||
htf_aligned_long = htf_close > htf_ema200
|
||||
htf_aligned_short = htf_close < htf_ema200
|
||||
|
||||
# RSI
|
||||
rsi_val = current.get("rsi_14", 50)
|
||||
if np.isnan(rsi_val):
|
||||
rsi_val = 50
|
||||
|
||||
# ---- BULLISH SWEEP: price swept below Asian low, closed back inside ----
|
||||
sweep_below = candle_low < asian_low
|
||||
penetration_below = asian_low - candle_low
|
||||
closed_inside_from_below = price > asian_low
|
||||
|
||||
if (sweep_below and closed_inside_from_below
|
||||
and self.SWEEP_MIN_ATR * atr_val <= penetration_below <= self.SWEEP_MAX_ATR * atr_val):
|
||||
# Strong reversal candle: close in upper 40% of range
|
||||
if (price - candle_low) / candle_range < 0.4:
|
||||
return None
|
||||
|
||||
sweep_extreme = candle_low
|
||||
sl = sweep_extreme - self.SL_ATR_BUFFER * atr_val
|
||||
sl_dist = price - sl
|
||||
|
||||
# TP1: Asian midpoint, TP2: Asian high
|
||||
tp1 = asian_mid
|
||||
tp2 = asian_high
|
||||
|
||||
# Min RR check
|
||||
tp1_dist = tp1 - price
|
||||
if sl_dist > 0 and tp1_dist / sl_dist < 1.0:
|
||||
return None
|
||||
|
||||
confluence = 3
|
||||
if htf_aligned_long:
|
||||
confluence += 1
|
||||
if rsi_val < 35:
|
||||
confluence += 1
|
||||
vol = current.get("volume", 0)
|
||||
if asian_avg_vol > 0 and vol > 1.5 * asian_avg_vol:
|
||||
confluence += 1
|
||||
|
||||
return {
|
||||
"direction": "LONG",
|
||||
"sl": sl,
|
||||
"tp1": tp1,
|
||||
"tp2": tp2,
|
||||
"tp3": tp2,
|
||||
"confluence": confluence,
|
||||
"entry_pattern": "asian_sweep_bullish",
|
||||
"tp_splits": (0.50, 0.50, 0.0),
|
||||
"trail_atr_mult": 1.0,
|
||||
"max_bars": self.MAX_BARS,
|
||||
}
|
||||
|
||||
# ---- BEARISH SWEEP: price swept above Asian high, closed back inside ----
|
||||
sweep_above = candle_high > asian_high
|
||||
penetration_above = candle_high - asian_high
|
||||
closed_inside_from_above = price < asian_high
|
||||
|
||||
if (sweep_above and closed_inside_from_above
|
||||
and self.SWEEP_MIN_ATR * atr_val <= penetration_above <= self.SWEEP_MAX_ATR * atr_val):
|
||||
# Strong reversal candle: close in lower 40% of range
|
||||
if (candle_high - price) / candle_range < 0.4:
|
||||
return None
|
||||
|
||||
sweep_extreme = candle_high
|
||||
sl = sweep_extreme + self.SL_ATR_BUFFER * atr_val
|
||||
sl_dist = sl - price
|
||||
|
||||
tp1 = asian_mid
|
||||
tp2 = asian_low
|
||||
|
||||
tp1_dist = price - tp1
|
||||
if sl_dist > 0 and tp1_dist / sl_dist < 1.0:
|
||||
return None
|
||||
|
||||
confluence = 3
|
||||
if htf_aligned_short:
|
||||
confluence += 1
|
||||
if rsi_val > 65:
|
||||
confluence += 1
|
||||
vol = current.get("volume", 0)
|
||||
if asian_avg_vol > 0 and vol > 1.5 * asian_avg_vol:
|
||||
confluence += 1
|
||||
|
||||
return {
|
||||
"direction": "SHORT",
|
||||
"sl": sl,
|
||||
"tp1": tp1,
|
||||
"tp2": tp2,
|
||||
"tp3": tp2,
|
||||
"confluence": confluence,
|
||||
"entry_pattern": "asian_sweep_bearish",
|
||||
"tp_splits": (0.50, 0.50, 0.0),
|
||||
"trail_atr_mult": 1.0,
|
||||
"max_bars": self.MAX_BARS,
|
||||
}
|
||||
|
||||
return None
|
||||
@@ -0,0 +1,175 @@
|
||||
"""
|
||||
Strategy S13: Bollinger-Keltner Squeeze.
|
||||
|
||||
Concept: When Bollinger Bands (20, 2) compress inside Keltner Channels (20, 1.5),
|
||||
volatility is contracting. When BBs expand back outside KC, a directional move
|
||||
is starting. Also known as "TTM Squeeze" (John Carter).
|
||||
|
||||
Adapted to M15 from original 30-minute specification.
|
||||
|
||||
Entry conditions (ALL must be true):
|
||||
1. Squeeze detected: BB was inside KC for at least 3 bars (squeeze on)
|
||||
2. Squeeze releases: BB expands outside KC (squeeze off)
|
||||
3. Momentum direction determines trade direction (MACD histogram)
|
||||
4. ADX rising (trend developing)
|
||||
5. Session: 07:00-17:00 UTC (London + NY)
|
||||
6. HTF trend alignment (confluence)
|
||||
|
||||
Exit:
|
||||
- SL: 1.5x ATR from entry
|
||||
- TP1: 2.0x ATR from entry (close 50%)
|
||||
- TP2: 3.0x ATR from entry (close 50%)
|
||||
- Max hold: 40 bars (~10 hours on M15)
|
||||
"""
|
||||
from typing import Optional
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from .base import BaseStrategy
|
||||
|
||||
|
||||
class S13_BollingerKeltnerSqueeze(BaseStrategy):
|
||||
strategy_id = 13
|
||||
name = "S13_BollingerKeltnerSqueeze"
|
||||
|
||||
# Squeeze detection
|
||||
MIN_SQUEEZE_BARS = 3 # Min bars in squeeze before release
|
||||
MAX_SQUEEZE_BARS = 30 # Max bars in squeeze (too long = no energy)
|
||||
|
||||
# Risk management
|
||||
SL_ATR_MULT = 1.5
|
||||
TP1_ATR_MULT = 2.0
|
||||
TP2_ATR_MULT = 3.0
|
||||
TRAIL_ATR_MULT = 1.0
|
||||
|
||||
# Filters
|
||||
MIN_ADX = 15 # Minimum ADX for directional move
|
||||
SESSION_START = 7
|
||||
SESSION_END = 17
|
||||
|
||||
MAX_BARS = 40
|
||||
|
||||
def _is_squeeze_on(self, row):
|
||||
"""Check if BB is inside KC (squeeze is on)."""
|
||||
bb_upper = row.get("bb_upper", np.nan)
|
||||
bb_lower = row.get("bb_lower", np.nan)
|
||||
kc_upper = row.get("kc_upper", np.nan)
|
||||
kc_lower = row.get("kc_lower", np.nan)
|
||||
|
||||
if any(np.isnan(v) for v in [bb_upper, bb_lower, kc_upper, kc_lower]):
|
||||
return False
|
||||
|
||||
return bb_upper < kc_upper and bb_lower > kc_lower
|
||||
|
||||
def check_signal(self, data: pd.DataFrame, idx: int,
|
||||
current: pd.Series,
|
||||
htf_row: Optional[pd.Series] = None) -> Optional[dict]:
|
||||
if idx < 200:
|
||||
return None
|
||||
|
||||
# Session filter
|
||||
hour = current.name.hour if hasattr(current.name, 'hour') else 0
|
||||
if hour < self.SESSION_START or hour >= self.SESSION_END:
|
||||
return None
|
||||
|
||||
atr_val = current.get("atr_14", 0)
|
||||
if atr_val <= 0 or np.isnan(atr_val):
|
||||
return None
|
||||
|
||||
# Check if squeeze just released (was on, now off)
|
||||
current_squeeze = self._is_squeeze_on(current)
|
||||
if current_squeeze:
|
||||
return None # Still in squeeze
|
||||
|
||||
# Count how many prior bars were in squeeze
|
||||
squeeze_count = 0
|
||||
for i in range(idx - 1, max(0, idx - self.MAX_SQUEEZE_BARS - 1), -1):
|
||||
if self._is_squeeze_on(data.iloc[i]):
|
||||
squeeze_count += 1
|
||||
else:
|
||||
break
|
||||
|
||||
if squeeze_count < self.MIN_SQUEEZE_BARS:
|
||||
return None
|
||||
|
||||
# Also verify the bar before the squeeze run was NOT in squeeze
|
||||
# (ensures we detect the release, not a mid-squeeze fluctuation)
|
||||
pre_squeeze_idx = idx - 1 - squeeze_count
|
||||
if pre_squeeze_idx >= 0 and self._is_squeeze_on(data.iloc[pre_squeeze_idx]):
|
||||
return None # Squeeze was already going before our count window
|
||||
|
||||
# Momentum direction: use MACD histogram
|
||||
macd_hist = current.get("macd_hist", 0)
|
||||
if np.isnan(macd_hist) or macd_hist == 0:
|
||||
return None
|
||||
|
||||
# Also check momentum is accelerating (current > previous)
|
||||
prev_hist = data.iloc[idx - 1].get("macd_hist", 0)
|
||||
if np.isnan(prev_hist):
|
||||
prev_hist = 0
|
||||
|
||||
direction = None
|
||||
if macd_hist > 0 and macd_hist > prev_hist:
|
||||
direction = "LONG"
|
||||
elif macd_hist < 0 and macd_hist < prev_hist:
|
||||
direction = "SHORT"
|
||||
|
||||
if direction is None:
|
||||
return None
|
||||
|
||||
# ADX filter: trend developing
|
||||
adx_val = current.get("adx_14", 0)
|
||||
if np.isnan(adx_val):
|
||||
adx_val = 0
|
||||
if adx_val < self.MIN_ADX:
|
||||
return None
|
||||
|
||||
# ADX rising check
|
||||
prev_adx = data.iloc[idx - 1].get("adx_14", 0)
|
||||
if np.isnan(prev_adx):
|
||||
prev_adx = 0
|
||||
adx_rising = adx_val > prev_adx
|
||||
|
||||
price = current["close"]
|
||||
|
||||
# HTF alignment
|
||||
htf_aligned = False
|
||||
if htf_row is not None:
|
||||
htf_ema200 = htf_row.get("ema_200", np.nan)
|
||||
htf_close = htf_row.get("close", np.nan)
|
||||
if not np.isnan(htf_ema200) and not np.isnan(htf_close):
|
||||
if direction == "LONG" and htf_close > htf_ema200:
|
||||
htf_aligned = True
|
||||
elif direction == "SHORT" and htf_close < htf_ema200:
|
||||
htf_aligned = True
|
||||
|
||||
# Confluence scoring
|
||||
confluence = 3 # Base: squeeze release + momentum + ADX
|
||||
if htf_aligned:
|
||||
confluence += 1
|
||||
if adx_rising:
|
||||
confluence += 1
|
||||
if squeeze_count >= 6:
|
||||
confluence += 1 # Longer squeeze = more stored energy
|
||||
|
||||
# SL / TP
|
||||
if direction == "LONG":
|
||||
sl = price - self.SL_ATR_MULT * atr_val
|
||||
tp1 = price + self.TP1_ATR_MULT * atr_val
|
||||
tp2 = price + self.TP2_ATR_MULT * atr_val
|
||||
else:
|
||||
sl = price + self.SL_ATR_MULT * atr_val
|
||||
tp1 = price - self.TP1_ATR_MULT * atr_val
|
||||
tp2 = price - self.TP2_ATR_MULT * atr_val
|
||||
|
||||
return {
|
||||
"direction": direction,
|
||||
"sl": sl,
|
||||
"tp1": tp1,
|
||||
"tp2": tp2,
|
||||
"tp3": tp2,
|
||||
"confluence": confluence,
|
||||
"entry_pattern": f"squeeze_release_{direction.lower()}",
|
||||
"tp_splits": (0.50, 0.50, 0.0),
|
||||
"trail_atr_mult": self.TRAIL_ATR_MULT,
|
||||
"max_bars": self.MAX_BARS,
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
"""
|
||||
Strategy S14: London Fix Trade.
|
||||
|
||||
Concept: The WM/Reuters 4 PM London Fix creates predictable institutional
|
||||
order flow. Large FX transactions cluster around 16:00 UTC, causing
|
||||
directional moves in the 30-60 minutes before the fix, which often
|
||||
reverse after completion.
|
||||
|
||||
This strategy trades the post-fix mean reversion: if price moved strongly
|
||||
in one direction before the fix (15:00-16:00 UTC), enter the opposite
|
||||
direction expecting reversion.
|
||||
|
||||
Adapted to M15 timeframe (1 trade per day target).
|
||||
|
||||
Entry conditions (ALL must be true):
|
||||
1. Time is 16:00-16:15 UTC (immediately after fix window)
|
||||
2. Pre-fix move: price moved > 0.5 ATR in one direction during 15:00-16:00
|
||||
3. Reversal candle: fix candle closes against the pre-fix direction
|
||||
4. Not Friday after 16:00 (weekend gap risk)
|
||||
|
||||
Exit:
|
||||
- SL: Beyond the pre-fix extreme + 0.5 ATR buffer
|
||||
- TP1: 50% retracement of pre-fix move
|
||||
- TP2: Full retracement to pre-fix level
|
||||
- Time exit: 18:00 UTC (2 hours max)
|
||||
- Max hold: 8 bars (2 hours on M15)
|
||||
"""
|
||||
from typing import Optional
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from .base import BaseStrategy
|
||||
|
||||
|
||||
class S14_LondonFix(BaseStrategy):
|
||||
strategy_id = 14
|
||||
name = "S14_LondonFix"
|
||||
|
||||
# Fix timing
|
||||
PRE_FIX_START_HOUR = 15 # Monitor pre-fix move from 15:00
|
||||
FIX_HOUR = 16 # Fix at 16:00 UTC
|
||||
FIX_ENTRY_MINUTE = 0 # Enter at 16:00
|
||||
|
||||
# Pre-fix move threshold
|
||||
MIN_MOVE_ATR = 0.5 # Minimum pre-fix move to qualify
|
||||
|
||||
# Risk management
|
||||
SL_ATR_BUFFER = 0.5 # Buffer beyond pre-fix extreme
|
||||
MAX_BARS = 8 # 2 hours on M15
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._daily_trade_cache = {}
|
||||
|
||||
def _get_pre_fix_move(self, data, idx):
|
||||
"""Calculate the pre-fix price move (15:00-16:00 UTC)."""
|
||||
current_time = data.index[idx]
|
||||
current_date = current_time.date()
|
||||
|
||||
# Find bars between 15:00-16:00 UTC today
|
||||
pre_fix_bars = []
|
||||
for i in range(max(0, idx - 20), idx + 1):
|
||||
bar_time = data.index[i]
|
||||
if bar_time.date() != current_date:
|
||||
continue
|
||||
if self.PRE_FIX_START_HOUR <= bar_time.hour < self.FIX_HOUR:
|
||||
pre_fix_bars.append(i)
|
||||
|
||||
if len(pre_fix_bars) < 2:
|
||||
return None
|
||||
|
||||
pre_fix_data = data.iloc[pre_fix_bars]
|
||||
pre_fix_open = data.iloc[pre_fix_bars[0]]["open"]
|
||||
pre_fix_high = pre_fix_data["high"].max()
|
||||
pre_fix_low = pre_fix_data["low"].min()
|
||||
pre_fix_close = data.iloc[pre_fix_bars[-1]]["close"]
|
||||
|
||||
move = pre_fix_close - pre_fix_open
|
||||
|
||||
return {
|
||||
"open": pre_fix_open,
|
||||
"close": pre_fix_close,
|
||||
"high": pre_fix_high,
|
||||
"low": pre_fix_low,
|
||||
"move": move,
|
||||
}
|
||||
|
||||
def check_signal(self, data: pd.DataFrame, idx: int,
|
||||
current: pd.Series,
|
||||
htf_row: Optional[pd.Series] = None) -> Optional[dict]:
|
||||
if idx < 200:
|
||||
return None
|
||||
|
||||
current_time = current.name
|
||||
hour = current_time.hour if hasattr(current_time, 'hour') else 0
|
||||
minute = current_time.minute if hasattr(current_time, 'minute') else 0
|
||||
|
||||
# Only enter at 16:00 UTC
|
||||
if hour != self.FIX_HOUR or minute != self.FIX_ENTRY_MINUTE:
|
||||
return None
|
||||
|
||||
# No Friday trades (weekend gap risk)
|
||||
dow = current_time.dayofweek if hasattr(current_time, 'dayofweek') else 0
|
||||
if dow == 4:
|
||||
return None
|
||||
|
||||
# One trade per day
|
||||
current_date = current_time.date()
|
||||
if current_date in self._daily_trade_cache:
|
||||
return None
|
||||
|
||||
atr_val = current.get("atr_14", 0)
|
||||
if atr_val <= 0 or np.isnan(atr_val):
|
||||
return None
|
||||
|
||||
# Get pre-fix move
|
||||
pre_fix = self._get_pre_fix_move(data, idx)
|
||||
if pre_fix is None:
|
||||
return None
|
||||
|
||||
move = pre_fix["move"]
|
||||
abs_move = abs(move)
|
||||
|
||||
# Check minimum move threshold
|
||||
if abs_move < self.MIN_MOVE_ATR * atr_val:
|
||||
return None
|
||||
|
||||
price = current["close"]
|
||||
|
||||
# Trade the reversal of the pre-fix move
|
||||
if move > 0:
|
||||
# Pre-fix was bullish -> trade SHORT (reversal)
|
||||
direction = "SHORT"
|
||||
sl = pre_fix["high"] + self.SL_ATR_BUFFER * atr_val
|
||||
# TP1: 50% retracement, TP2: full retracement
|
||||
tp1 = price - 0.5 * abs_move
|
||||
tp2 = pre_fix["open"]
|
||||
else:
|
||||
# Pre-fix was bearish -> trade LONG (reversal)
|
||||
direction = "LONG"
|
||||
sl = pre_fix["low"] - self.SL_ATR_BUFFER * atr_val
|
||||
tp1 = price + 0.5 * abs_move
|
||||
tp2 = pre_fix["open"]
|
||||
|
||||
# Min RR check
|
||||
sl_dist = abs(price - sl)
|
||||
tp1_dist = abs(tp1 - price)
|
||||
if sl_dist <= 0 or tp1_dist / sl_dist < 1.0:
|
||||
return None
|
||||
|
||||
# Reversal candle confirmation: current candle closes against pre-fix direction
|
||||
candle_body = current["close"] - current["open"]
|
||||
if move > 0 and candle_body >= 0:
|
||||
return None # No bearish reversal candle
|
||||
if move < 0 and candle_body <= 0:
|
||||
return None # No bullish reversal candle
|
||||
|
||||
self._daily_trade_cache[current_date] = True
|
||||
|
||||
# Confluence
|
||||
confluence = 3 # Base: fix timing + pre-fix move + reversal candle
|
||||
|
||||
# HTF alignment
|
||||
if htf_row is not None:
|
||||
htf_ema200 = htf_row.get("ema_200", np.nan)
|
||||
htf_close = htf_row.get("close", np.nan)
|
||||
if not np.isnan(htf_ema200) and not np.isnan(htf_close):
|
||||
if direction == "LONG" and htf_close > htf_ema200:
|
||||
confluence += 1
|
||||
elif direction == "SHORT" and htf_close < htf_ema200:
|
||||
confluence += 1
|
||||
|
||||
# Large pre-fix move (> 1.0 ATR = stronger reversal expected)
|
||||
if abs_move > 1.0 * atr_val:
|
||||
confluence += 1
|
||||
|
||||
# RSI extreme
|
||||
rsi_val = current.get("rsi_14", 50)
|
||||
if not np.isnan(rsi_val):
|
||||
if direction == "LONG" and rsi_val < 35:
|
||||
confluence += 1
|
||||
elif direction == "SHORT" and rsi_val > 65:
|
||||
confluence += 1
|
||||
|
||||
return {
|
||||
"direction": direction,
|
||||
"sl": sl,
|
||||
"tp1": tp1,
|
||||
"tp2": tp2,
|
||||
"tp3": tp2,
|
||||
"confluence": confluence,
|
||||
"entry_pattern": f"london_fix_{direction.lower()}",
|
||||
"tp_splits": (0.50, 0.50, 0.0),
|
||||
"trail_atr_mult": 0, # No trailing for mean reversion
|
||||
"max_bars": self.MAX_BARS,
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
"""
|
||||
Strategy S15: Momentum Continuation.
|
||||
|
||||
Concept: After a strong impulse move (>2 ATR in N bars), enter on the first
|
||||
pullback in the direction of the impulse. The impulse signals institutional
|
||||
commitment; the pullback offers a low-risk entry before continuation.
|
||||
|
||||
Adapted to M15 from original 5-minute specification (2-4 trades/day target).
|
||||
|
||||
Entry conditions (ALL must be true):
|
||||
1. Impulse detected: price moved > IMPULSE_ATR_MULT * ATR in last IMPULSE_BARS
|
||||
2. Pullback: price retraces to within PULLBACK_EMA_PROXIMITY * ATR of EMA(20)
|
||||
3. Pullback candle closes in impulse direction (continuation confirmation)
|
||||
4. ADX > 25 (trending environment)
|
||||
5. Session: 07:00-17:00 UTC
|
||||
6. HTF trend alignment
|
||||
|
||||
Exit:
|
||||
- SL: Beyond pullback extreme + 0.5 ATR
|
||||
- TP1: 1.5x risk distance (close 50%)
|
||||
- TP2: Extension to impulse length from pullback low/high (close 50%)
|
||||
- Trailing stop: 1.0 ATR after TP1
|
||||
- Max hold: 40 bars (~10 hours on M15)
|
||||
"""
|
||||
from typing import Optional
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from .base import BaseStrategy
|
||||
|
||||
|
||||
class S15_MomentumContinuation(BaseStrategy):
|
||||
strategy_id = 15
|
||||
name = "S15_MomentumContinuation"
|
||||
|
||||
# Impulse detection
|
||||
IMPULSE_ATR_MULT = 2.0 # Min impulse size in ATR units
|
||||
IMPULSE_BARS = 8 # Window to detect impulse (8 bars = 2 hours on M15)
|
||||
|
||||
# Pullback parameters
|
||||
PULLBACK_EMA_PROXIMITY = 0.5 # Must be within 0.5 ATR of EMA(20)
|
||||
PULLBACK_MIN_BARS = 2 # Min bars of pullback
|
||||
PULLBACK_MAX_BARS = 12 # Max bars before pullback is stale
|
||||
|
||||
# Risk management
|
||||
SL_ATR_BUFFER = 0.5
|
||||
TP1_RR_MULT = 1.5 # TP1 at 1.5x risk
|
||||
TP2_RR_MULT = 2.5 # TP2 at 2.5x risk
|
||||
TRAIL_ATR_MULT = 1.0
|
||||
|
||||
# Filters
|
||||
MIN_ADX = 25
|
||||
SESSION_START = 7
|
||||
SESSION_END = 17
|
||||
|
||||
MAX_BARS = 40
|
||||
|
||||
def _detect_impulse(self, data, idx, atr_val):
|
||||
"""Detect a recent impulse move."""
|
||||
if idx < self.IMPULSE_BARS + self.PULLBACK_MAX_BARS:
|
||||
return None
|
||||
|
||||
# Look for impulse in the window before the current pullback zone
|
||||
# The impulse should have occurred IMPULSE_BARS bars ago, then pullback since
|
||||
for pb_end in range(idx - self.PULLBACK_MIN_BARS,
|
||||
idx - self.PULLBACK_MAX_BARS, -1):
|
||||
imp_start = pb_end - self.IMPULSE_BARS
|
||||
if imp_start < 0:
|
||||
continue
|
||||
|
||||
imp_open = data.iloc[imp_start]["open"]
|
||||
imp_close = data.iloc[pb_end]["close"]
|
||||
imp_move = imp_close - imp_open
|
||||
|
||||
if abs(imp_move) >= self.IMPULSE_ATR_MULT * atr_val:
|
||||
# Found an impulse
|
||||
imp_high = data["high"].iloc[imp_start:pb_end + 1].max()
|
||||
imp_low = data["low"].iloc[imp_start:pb_end + 1].min()
|
||||
|
||||
return {
|
||||
"direction": "LONG" if imp_move > 0 else "SHORT",
|
||||
"move": imp_move,
|
||||
"high": imp_high,
|
||||
"low": imp_low,
|
||||
"end_idx": pb_end,
|
||||
}
|
||||
|
||||
return None
|
||||
|
||||
def check_signal(self, data: pd.DataFrame, idx: int,
|
||||
current: pd.Series,
|
||||
htf_row: Optional[pd.Series] = None) -> Optional[dict]:
|
||||
if idx < 200:
|
||||
return None
|
||||
|
||||
# Session filter
|
||||
hour = current.name.hour if hasattr(current.name, 'hour') else 0
|
||||
if hour < self.SESSION_START or hour >= self.SESSION_END:
|
||||
return None
|
||||
|
||||
atr_val = current.get("atr_14", 0)
|
||||
if atr_val <= 0 or np.isnan(atr_val):
|
||||
return None
|
||||
|
||||
# ADX filter
|
||||
adx_val = current.get("adx_14", 0)
|
||||
if np.isnan(adx_val):
|
||||
adx_val = 0
|
||||
if adx_val < self.MIN_ADX:
|
||||
return None
|
||||
|
||||
# EMA(20) proximity check
|
||||
ema_20 = current.get("ema_20", np.nan)
|
||||
if np.isnan(ema_20):
|
||||
return None
|
||||
|
||||
price = current["close"]
|
||||
ema_dist = abs(price - ema_20)
|
||||
|
||||
if ema_dist > self.PULLBACK_EMA_PROXIMITY * atr_val:
|
||||
return None
|
||||
|
||||
# Detect impulse
|
||||
impulse = self._detect_impulse(data, idx, atr_val)
|
||||
if impulse is None:
|
||||
return None
|
||||
|
||||
direction = impulse["direction"]
|
||||
|
||||
# Candle confirmation: closes in impulse direction
|
||||
candle_body = current["close"] - current["open"]
|
||||
if direction == "LONG" and candle_body <= 0:
|
||||
return None
|
||||
if direction == "SHORT" and candle_body >= 0:
|
||||
return None
|
||||
|
||||
# Pullback confirmation: price has actually pulled back from impulse
|
||||
if direction == "LONG":
|
||||
# After bullish impulse, pullback should have brought price down
|
||||
pullback_low = data["low"].iloc[impulse["end_idx"]:idx + 1].min()
|
||||
pullback_depth = impulse["high"] - pullback_low
|
||||
if pullback_depth < 0.3 * abs(impulse["move"]):
|
||||
return None # Not enough pullback
|
||||
else:
|
||||
pullback_high = data["high"].iloc[impulse["end_idx"]:idx + 1].max()
|
||||
pullback_depth = pullback_high - impulse["low"]
|
||||
if pullback_depth < 0.3 * abs(impulse["move"]):
|
||||
return None
|
||||
|
||||
# HTF trend alignment
|
||||
htf_aligned = False
|
||||
if htf_row is not None:
|
||||
htf_ema200 = htf_row.get("ema_200", np.nan)
|
||||
htf_close = htf_row.get("close", np.nan)
|
||||
if not np.isnan(htf_ema200) and not np.isnan(htf_close):
|
||||
if direction == "LONG" and htf_close > htf_ema200:
|
||||
htf_aligned = True
|
||||
elif direction == "SHORT" and htf_close < htf_ema200:
|
||||
htf_aligned = True
|
||||
|
||||
# SL and TP
|
||||
if direction == "LONG":
|
||||
pullback_extreme = data["low"].iloc[impulse["end_idx"]:idx + 1].min()
|
||||
sl = pullback_extreme - self.SL_ATR_BUFFER * atr_val
|
||||
risk = price - sl
|
||||
tp1 = price + self.TP1_RR_MULT * risk
|
||||
tp2 = price + self.TP2_RR_MULT * risk
|
||||
else:
|
||||
pullback_extreme = data["high"].iloc[impulse["end_idx"]:idx + 1].max()
|
||||
sl = pullback_extreme + self.SL_ATR_BUFFER * atr_val
|
||||
risk = sl - price
|
||||
tp1 = price - self.TP1_RR_MULT * risk
|
||||
tp2 = price - self.TP2_RR_MULT * risk
|
||||
|
||||
if risk <= 0:
|
||||
return None
|
||||
|
||||
# Confluence
|
||||
confluence = 3 # Base: impulse + pullback to EMA + continuation candle
|
||||
if htf_aligned:
|
||||
confluence += 1
|
||||
if adx_val > 35:
|
||||
confluence += 1 # Very strong trend
|
||||
|
||||
# Volume confirmation
|
||||
vol = current.get("volume", 0)
|
||||
vol_avg = data["volume"].iloc[max(0, idx - 20):idx].mean()
|
||||
if vol_avg > 0 and vol > 1.3 * vol_avg:
|
||||
confluence += 1
|
||||
|
||||
return {
|
||||
"direction": direction,
|
||||
"sl": sl,
|
||||
"tp1": tp1,
|
||||
"tp2": tp2,
|
||||
"tp3": tp2,
|
||||
"confluence": confluence,
|
||||
"entry_pattern": f"momentum_continuation_{direction.lower()}",
|
||||
"tp_splits": (0.50, 0.50, 0.0),
|
||||
"trail_atr_mult": self.TRAIL_ATR_MULT,
|
||||
"max_bars": self.MAX_BARS,
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
"""
|
||||
Strategy S16: London Open Range Breakout (ORB).
|
||||
|
||||
Concept: Define the range of the first 30 minutes of the London session
|
||||
(07:00-07:30 UTC = 2 M15 bars), then trade the breakout with volume
|
||||
confirmation. Well-established pattern based on institutional order flow
|
||||
at London open.
|
||||
|
||||
Adapted to M15 from original 5-minute specification (0.3-0.7 trades/day target).
|
||||
|
||||
Entry conditions (ALL must be true):
|
||||
1. Opening range defined: 07:00-07:30 UTC high/low (2 M15 bars)
|
||||
2. Range not too wide (< 1.5 ATR H1) and not too narrow (> 0.3 ATR)
|
||||
3. Price breaks and closes above/below opening range
|
||||
4. Volume > 1.3x opening range average
|
||||
5. Entry window: 07:30-12:00 UTC
|
||||
6. ADX > 15 (some directional context)
|
||||
7. HTF trend alignment (soft)
|
||||
|
||||
Exit:
|
||||
- SL: Opposite side of opening range + 0.3 ATR buffer
|
||||
- TP1: 1x opening range extension (close 50%)
|
||||
- TP2: 2x opening range extension (close 50%)
|
||||
- Time exit: 17:00 UTC
|
||||
- Max hold: 40 bars
|
||||
"""
|
||||
from typing import Optional
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from .base import BaseStrategy
|
||||
|
||||
|
||||
class S16_LondonORB(BaseStrategy):
|
||||
strategy_id = 16
|
||||
name = "S16_LondonORB"
|
||||
|
||||
# Opening range definition
|
||||
ORB_START_HOUR = 7
|
||||
ORB_START_MINUTE = 0
|
||||
ORB_BARS = 1 # 1 M15 bar = 15 min range (tighter on M15)
|
||||
|
||||
# Entry window
|
||||
ENTRY_END_HOUR = 12 # Can enter until noon UTC
|
||||
|
||||
# Range filters
|
||||
MIN_RANGE_ATR = 0.1 # Min range (too narrow = noise)
|
||||
MAX_RANGE_ATR = 2.0 # Max range (relaxed for M15 adaptation)
|
||||
|
||||
# Volume
|
||||
VOLUME_MULT = 1.0 # Relaxed (M15 volume patterns differ from M5)
|
||||
|
||||
# Risk management
|
||||
SL_ATR_MULT = 1.5 # SL: 1.5x ATR from entry (tighter than opposite side)
|
||||
TP1_MULT = 1.0 # TP1 = 1x range extension from breakout level
|
||||
TP2_MULT = 2.0 # TP2 = 2x range extension
|
||||
|
||||
# Filters
|
||||
MIN_ADX = 15
|
||||
|
||||
MAX_BARS = 40
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._orb_cache = {}
|
||||
self._daily_trade_cache = {}
|
||||
|
||||
def _compute_opening_range(self, data, idx):
|
||||
"""Compute London opening range (first 2 M15 bars at 07:00)."""
|
||||
current_time = data.index[idx]
|
||||
current_date = current_time.date()
|
||||
|
||||
if current_date in self._orb_cache:
|
||||
return self._orb_cache[current_date]
|
||||
|
||||
# Find the first ORB_BARS bars starting at 07:00
|
||||
orb_bars = []
|
||||
for i in range(max(0, idx - 40), idx + 1):
|
||||
bar_time = data.index[i]
|
||||
if bar_time.date() != current_date:
|
||||
continue
|
||||
if bar_time.hour == self.ORB_START_HOUR:
|
||||
orb_bars.append(i)
|
||||
if len(orb_bars) >= self.ORB_BARS:
|
||||
break
|
||||
|
||||
if len(orb_bars) < self.ORB_BARS:
|
||||
return None
|
||||
|
||||
orb_data = data.iloc[orb_bars]
|
||||
orb_high = orb_data["high"].max()
|
||||
orb_low = orb_data["low"].min()
|
||||
orb_avg_vol = orb_data["volume"].mean()
|
||||
|
||||
result = (orb_high, orb_low, orb_avg_vol)
|
||||
self._orb_cache[current_date] = result
|
||||
return result
|
||||
|
||||
def check_signal(self, data: pd.DataFrame, idx: int,
|
||||
current: pd.Series,
|
||||
htf_row: Optional[pd.Series] = None) -> Optional[dict]:
|
||||
if idx < 200:
|
||||
return None
|
||||
|
||||
current_time = current.name
|
||||
hour = current_time.hour if hasattr(current_time, 'hour') else 0
|
||||
minute = current_time.minute if hasattr(current_time, 'minute') else 0
|
||||
|
||||
# Entry window: after ORB defined (07:15) until noon
|
||||
# The first valid entry is the 07:15 bar (2nd bar of the session)
|
||||
if hour < self.ORB_START_HOUR:
|
||||
return None
|
||||
if hour == self.ORB_START_HOUR and minute < 15:
|
||||
return None # ORB still forming
|
||||
if hour >= self.ENTRY_END_HOUR:
|
||||
return None
|
||||
|
||||
# One trade per day
|
||||
current_date = current_time.date()
|
||||
if current_date in self._daily_trade_cache:
|
||||
return None
|
||||
|
||||
atr_val = current.get("atr_14", 0)
|
||||
if atr_val <= 0 or np.isnan(atr_val):
|
||||
return None
|
||||
|
||||
# Get opening range
|
||||
orb = self._compute_opening_range(data, idx)
|
||||
if orb is None:
|
||||
return None
|
||||
|
||||
orb_high, orb_low, orb_avg_vol = orb
|
||||
orb_range = orb_high - orb_low
|
||||
|
||||
if orb_range <= 0:
|
||||
return None
|
||||
|
||||
# Range size filter
|
||||
htf_atr = atr_val
|
||||
if htf_row is not None:
|
||||
htf_atr_val = htf_row.get("atr_14", np.nan)
|
||||
if not np.isnan(htf_atr_val) and htf_atr_val > 0:
|
||||
htf_atr = htf_atr_val
|
||||
|
||||
if orb_range < self.MIN_RANGE_ATR * htf_atr:
|
||||
return None
|
||||
if orb_range > self.MAX_RANGE_ATR * htf_atr:
|
||||
return None
|
||||
|
||||
price = current["close"]
|
||||
|
||||
# Volume confirmation
|
||||
vol = current.get("volume", 0)
|
||||
if orb_avg_vol <= 0 or vol < self.VOLUME_MULT * orb_avg_vol:
|
||||
return None
|
||||
|
||||
# ADX filter
|
||||
adx_val = current.get("adx_14", 0)
|
||||
if np.isnan(adx_val):
|
||||
adx_val = 0
|
||||
if adx_val < self.MIN_ADX:
|
||||
return None
|
||||
|
||||
# Direction: breakout above or below ORB
|
||||
direction = None
|
||||
if price > orb_high:
|
||||
direction = "LONG"
|
||||
elif price < orb_low:
|
||||
direction = "SHORT"
|
||||
|
||||
if direction is None:
|
||||
return None
|
||||
|
||||
# HTF alignment (soft)
|
||||
htf_aligned = False
|
||||
if htf_row is not None:
|
||||
htf_ema200 = htf_row.get("ema_200", np.nan)
|
||||
htf_close = htf_row.get("close", np.nan)
|
||||
if not np.isnan(htf_ema200) and not np.isnan(htf_close):
|
||||
if direction == "LONG" and htf_close > htf_ema200:
|
||||
htf_aligned = True
|
||||
elif direction == "SHORT" and htf_close < htf_ema200:
|
||||
htf_aligned = True
|
||||
|
||||
self._daily_trade_cache[current_date] = True
|
||||
|
||||
# SL / TP — ATR-based SL (tighter than opposite side of range)
|
||||
if direction == "LONG":
|
||||
sl = price - self.SL_ATR_MULT * atr_val
|
||||
tp1 = price + self.TP1_MULT * orb_range
|
||||
tp2 = price + self.TP2_MULT * orb_range
|
||||
else:
|
||||
sl = price + self.SL_ATR_MULT * atr_val
|
||||
tp1 = price - self.TP1_MULT * orb_range
|
||||
tp2 = price - self.TP2_MULT * orb_range
|
||||
|
||||
# Min RR check
|
||||
sl_dist = abs(price - sl)
|
||||
tp1_dist = abs(tp1 - price)
|
||||
if sl_dist <= 0 or tp1_dist / sl_dist < 0.8:
|
||||
return None
|
||||
|
||||
# Confluence
|
||||
confluence = 3 # Base: ORB breakout + volume + ADX
|
||||
if htf_aligned:
|
||||
confluence += 1
|
||||
if adx_val > 25:
|
||||
confluence += 1
|
||||
|
||||
# MACD confirmation
|
||||
macd_hist = current.get("macd_hist", 0)
|
||||
if not np.isnan(macd_hist):
|
||||
if direction == "LONG" and macd_hist > 0:
|
||||
confluence += 1
|
||||
elif direction == "SHORT" and macd_hist < 0:
|
||||
confluence += 1
|
||||
|
||||
return {
|
||||
"direction": direction,
|
||||
"sl": sl,
|
||||
"tp1": tp1,
|
||||
"tp2": tp2,
|
||||
"tp3": tp2,
|
||||
"confluence": confluence,
|
||||
"entry_pattern": f"london_orb_{direction.lower()}",
|
||||
"tp_splits": (0.50, 0.50, 0.0),
|
||||
"trail_atr_mult": 1.5,
|
||||
"max_bars": self.MAX_BARS,
|
||||
}
|
||||
Reference in New Issue
Block a user