diff --git a/results/phase2/test_new_strategies.json b/results/phase2/test_new_strategies.json new file mode 100644 index 0000000..8ef85e4 --- /dev/null +++ b/results/phase2/test_new_strategies.json @@ -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" + } + } +} \ No newline at end of file diff --git a/src/indicators/technical.py b/src/indicators/technical.py index 8a5c5e4..1baaa8c 100644 --- a/src/indicators/technical.py +++ b/src/indicators/technical.py @@ -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 diff --git a/src/run_test_new_strategies.py b/src/run_test_new_strategies.py new file mode 100644 index 0000000..c91fab1 --- /dev/null +++ b/src/run_test_new_strategies.py @@ -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() diff --git a/src/strategies_pkg/s12_asian_range_sweep.py b/src/strategies_pkg/s12_asian_range_sweep.py new file mode 100644 index 0000000..3a695b4 --- /dev/null +++ b/src/strategies_pkg/s12_asian_range_sweep.py @@ -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 diff --git a/src/strategies_pkg/s13_bollinger_keltner_squeeze.py b/src/strategies_pkg/s13_bollinger_keltner_squeeze.py new file mode 100644 index 0000000..dcd0c5b --- /dev/null +++ b/src/strategies_pkg/s13_bollinger_keltner_squeeze.py @@ -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, + } diff --git a/src/strategies_pkg/s14_london_fix.py b/src/strategies_pkg/s14_london_fix.py new file mode 100644 index 0000000..eae7388 --- /dev/null +++ b/src/strategies_pkg/s14_london_fix.py @@ -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, + } diff --git a/src/strategies_pkg/s15_momentum_continuation.py b/src/strategies_pkg/s15_momentum_continuation.py new file mode 100644 index 0000000..bea8e38 --- /dev/null +++ b/src/strategies_pkg/s15_momentum_continuation.py @@ -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, + } diff --git a/src/strategies_pkg/s16_london_orb.py b/src/strategies_pkg/s16_london_orb.py new file mode 100644 index 0000000..8370c53 --- /dev/null +++ b/src/strategies_pkg/s16_london_orb.py @@ -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, + }