diff --git a/results/phase2/test_m5_strategies.json b/results/phase2/test_m5_strategies.json new file mode 100644 index 0000000..7c16d00 --- /dev/null +++ b/results/phase2/test_m5_strategies.json @@ -0,0 +1,386 @@ +{ + "S12_GBP_JPY_M5": { + "pair": "GBP_JPY", + "timeframe": "M5", + "is_metrics": { + "trades": 138, + "wr": 37.0, + "pf": 0.67, + "sharpe": -2.74, + "pnl_pips": -425.4, + "max_dd_pips": -543.8, + "expectancy": -3.08 + }, + "oos_metrics": { + "trades": 42, + "wr": 35.7, + "pf": 0.69, + "sharpe": -2.63, + "pnl_pips": -143.2, + "max_dd_pips": -230.1, + "expectancy": -3.41 + }, + "generalization": { + "composite": 0.499, + "detail": { + "wr": 0.965, + "pf": 1.03, + "expectancy": 0, + "sharpe": 0 + }, + "verdict": "FAIL" + } + }, + "S12_GBP_USD_M5": { + "pair": "GBP_USD", + "timeframe": "M5", + "is_metrics": { + "trades": 230, + "wr": 43.0, + "pf": 0.85, + "sharpe": -1.11, + "pnl_pips": -221.0, + "max_dd_pips": -311.7, + "expectancy": -0.96 + }, + "oos_metrics": { + "trades": 70, + "wr": 35.7, + "pf": 0.6, + "sharpe": -3.6, + "pnl_pips": -190.1, + "max_dd_pips": -243.5, + "expectancy": -2.72 + }, + "generalization": { + "composite": 0.384, + "detail": { + "wr": 0.83, + "pf": 0.706, + "expectancy": 0, + "sharpe": 0 + }, + "verdict": "FAIL" + } + }, + "S12_EUR_USD_M5": { + "pair": "EUR_USD", + "timeframe": "M5", + "is_metrics": { + "trades": 206, + "wr": 31.6, + "pf": 0.47, + "sharpe": -5.49, + "pnl_pips": -658.9, + "max_dd_pips": -675.5, + "expectancy": -3.2 + }, + "oos_metrics": { + "trades": 74, + "wr": 36.5, + "pf": 0.53, + "sharpe": -4.69, + "pnl_pips": -188.1, + "max_dd_pips": -198.1, + "expectancy": -2.54 + }, + "generalization": { + "composite": 0.571, + "detail": { + "wr": 1.155, + "pf": 1.128, + "expectancy": 0, + "sharpe": 0 + }, + "verdict": "WARN" + } + }, + "S12_USD_JPY_M5": { + "pair": "USD_JPY", + "timeframe": "M5", + "is_metrics": { + "trades": 70, + "wr": 35.7, + "pf": 0.55, + "sharpe": -3.91, + "pnl_pips": -186.0, + "max_dd_pips": -189.5, + "expectancy": -2.66 + }, + "oos_metrics": { + "trades": 26, + "wr": 34.6, + "pf": 0.77, + "sharpe": -1.85, + "pnl_pips": -40.0, + "max_dd_pips": -85.2, + "expectancy": -1.54 + }, + "generalization": { + "composite": 0.592, + "detail": { + "wr": 0.969, + "pf": 1.4, + "expectancy": 0, + "sharpe": 0 + }, + "verdict": "WARN" + } + }, + "S15_GBP_JPY_M5": { + "pair": "GBP_JPY", + "timeframe": "M5", + "is_metrics": { + "trades": 1692, + "wr": 41.1, + "pf": 0.77, + "sharpe": -1.73, + "pnl_pips": -4612.5, + "max_dd_pips": -4697.3, + "expectancy": -2.73 + }, + "oos_metrics": { + "trades": 519, + "wr": 42.2, + "pf": 0.87, + "sharpe": -0.97, + "pnl_pips": -844.3, + "max_dd_pips": -1292.3, + "expectancy": -1.63 + }, + "generalization": { + "composite": 0.539, + "detail": { + "wr": 1.027, + "pf": 1.13, + "expectancy": 0, + "sharpe": 0 + }, + "verdict": "WARN" + } + }, + "S15_GBP_USD_M5": { + "pair": "GBP_USD", + "timeframe": "M5", + "is_metrics": { + "trades": 1771, + "wr": 38.8, + "pf": 0.74, + "sharpe": -2.0, + "pnl_pips": -4256.1, + "max_dd_pips": -4292.4, + "expectancy": -2.4 + }, + "oos_metrics": { + "trades": 534, + "wr": 39.9, + "pf": 0.79, + "sharpe": -1.58, + "pnl_pips": -969.8, + "max_dd_pips": -1129.9, + "expectancy": -1.82 + }, + "generalization": { + "composite": 0.524, + "detail": { + "wr": 1.028, + "pf": 1.068, + "expectancy": 0, + "sharpe": 0 + }, + "verdict": "WARN" + } + }, + "S15_EUR_USD_M5": { + "pair": "EUR_USD", + "timeframe": "M5", + "is_metrics": { + "trades": 1743, + "wr": 38.5, + "pf": 0.65, + "sharpe": -2.99, + "pnl_pips": -4400.2, + "max_dd_pips": -4408.4, + "expectancy": -2.52 + }, + "oos_metrics": { + "trades": 545, + "wr": 39.4, + "pf": 0.69, + "sharpe": -2.57, + "pnl_pips": -1120.2, + "max_dd_pips": -1206.2, + "expectancy": -2.06 + }, + "generalization": { + "composite": 0.521, + "detail": { + "wr": 1.023, + "pf": 1.062, + "expectancy": 0, + "sharpe": 0 + }, + "verdict": "WARN" + } + }, + "S15_USD_JPY_M5": { + "pair": "USD_JPY", + "timeframe": "M5", + "is_metrics": { + "trades": 1660, + "wr": 39.9, + "pf": 0.79, + "sharpe": -1.44, + "pnl_pips": -2511.8, + "max_dd_pips": -2819.3, + "expectancy": -1.51 + }, + "oos_metrics": { + "trades": 586, + "wr": 35.5, + "pf": 0.73, + "sharpe": -2.01, + "pnl_pips": -1520.9, + "max_dd_pips": -1579.0, + "expectancy": -2.6 + }, + "generalization": { + "composite": 0.453, + "detail": { + "wr": 0.89, + "pf": 0.924, + "expectancy": 0, + "sharpe": 0 + }, + "verdict": "FAIL" + } + }, + "S16_GBP_JPY_M5": { + "pair": "GBP_JPY", + "timeframe": "M5", + "is_metrics": { + "trades": 299, + "wr": 36.5, + "pf": 0.71, + "sharpe": -2.3, + "pnl_pips": -898.0, + "max_dd_pips": -1161.0, + "expectancy": -3.0 + }, + "oos_metrics": { + "trades": 98, + "wr": 39.8, + "pf": 1.03, + "sharpe": 0.2, + "pnl_pips": 33.3, + "max_dd_pips": -339.8, + "expectancy": 0.34 + }, + "generalization": { + "composite": 0.635, + "detail": { + "wr": 1.09, + "pf": 1.451, + "expectancy": 0, + "sharpe": 0 + }, + "verdict": "WARN" + } + }, + "S16_GBP_USD_M5": { + "pair": "GBP_USD", + "timeframe": "M5", + "is_metrics": { + "trades": 354, + "wr": 28.8, + "pf": 0.53, + "sharpe": -4.42, + "pnl_pips": -1470.2, + "max_dd_pips": -1479.1, + "expectancy": -4.15 + }, + "oos_metrics": { + "trades": 108, + "wr": 36.1, + "pf": 0.7, + "sharpe": -2.55, + "pnl_pips": -248.1, + "max_dd_pips": -300.2, + "expectancy": -2.3 + }, + "generalization": { + "composite": 0.644, + "detail": { + "wr": 1.253, + "pf": 1.321, + "expectancy": 0, + "sharpe": 0 + }, + "verdict": "WARN" + } + }, + "S16_EUR_USD_M5": { + "pair": "EUR_USD", + "timeframe": "M5", + "is_metrics": { + "trades": 369, + "wr": 36.6, + "pf": 0.64, + "sharpe": -3.12, + "pnl_pips": -795.4, + "max_dd_pips": -838.1, + "expectancy": -2.16 + }, + "oos_metrics": { + "trades": 108, + "wr": 32.4, + "pf": 0.48, + "sharpe": -5.41, + "pnl_pips": -356.9, + "max_dd_pips": -346.1, + "expectancy": -3.3 + }, + "generalization": { + "composite": 0.409, + "detail": { + "wr": 0.885, + "pf": 0.75, + "expectancy": 0, + "sharpe": 0 + }, + "verdict": "FAIL" + } + }, + "S16_USD_JPY_M5": { + "pair": "USD_JPY", + "timeframe": "M5", + "is_metrics": { + "trades": 306, + "wr": 42.2, + "pf": 0.79, + "sharpe": -1.45, + "pnl_pips": -367.6, + "max_dd_pips": -425.6, + "expectancy": -1.2 + }, + "oos_metrics": { + "trades": 95, + "wr": 31.6, + "pf": 0.64, + "sharpe": -2.89, + "pnl_pips": -318.0, + "max_dd_pips": -493.6, + "expectancy": -3.35 + }, + "generalization": { + "composite": 0.39, + "detail": { + "wr": 0.749, + "pf": 0.81, + "expectancy": 0, + "sharpe": 0 + }, + "verdict": "FAIL" + } + } +} \ No newline at end of file diff --git a/src/download_m5_dukascopy.py b/src/download_m5_dukascopy.py new file mode 100644 index 0000000..58f719f --- /dev/null +++ b/src/download_m5_dukascopy.py @@ -0,0 +1,144 @@ +""" +Download M5 (5-minute) historical data from Dukascopy. + +Pairs needed for 5-minute strategies (S12, S15, S16 on M5): + GBP_JPY, GBP_USD, EUR_USD, USD_JPY + +Date range: Jan 1, 2021 - Dec 31, 2024 (matches existing M15/H1 data) +Saves to data/raw/ as CSV, then split via validate_and_split_data.py. + +M5 generates ~290k bars per pair over 4 years, so we use 2-month chunks +to stay well under Dukascopy's row limit. +""" +import os +import sys +import time +from datetime import datetime, timedelta + +import pandas as pd +from dukascopy_python import fetch, INTERVAL_MIN_5, OFFER_SIDE_BID + +# Pairs needed for 5-minute strategies +PAIRS = [ + "GBP/JPY", "GBP/USD", "EUR/USD", "USD/JPY", +] + +INTERVAL_NAME = "M5" +INTERVAL_CODE = INTERVAL_MIN_5 + +START = datetime(2021, 1, 1) +END = datetime(2024, 12, 31, 23, 59) + +# Chunk by 2 months — M5 produces ~17k bars/month, so 2 months = ~34k +CHUNK_MONTHS = 2 + +RAW_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "data", "raw") + + +def chunk_date_range(start, end, months): + """Split date range into chunks of N months.""" + chunks = [] + current = start + while current < end: + chunk_end = current + timedelta(days=months * 30) + if chunk_end > end: + chunk_end = end + chunks.append((current, chunk_end)) + current = chunk_end + timedelta(hours=1) + return chunks + + +def download_pair(pair): + """Download M5 data for one pair.""" + pair_file = pair.replace("/", "_") + filename = f"{pair_file}_{INTERVAL_NAME}.csv" + filepath = os.path.join(RAW_DIR, filename) + + if os.path.exists(filepath): + existing = pd.read_csv(filepath) + print(f" Already exists: {filename} ({len(existing)} rows) - skipping") + return True + + chunks = chunk_date_range(START, END, CHUNK_MONTHS) + all_dfs = [] + + for i, (chunk_start, chunk_end) in enumerate(chunks): + print(f" Chunk {i+1}/{len(chunks)}: {chunk_start.date()} to {chunk_end.date()}...", + end=" ", flush=True) + try: + df = fetch( + instrument=pair, + interval=INTERVAL_CODE, + offer_side=OFFER_SIDE_BID, + start=chunk_start, + end=chunk_end, + max_retries=5, + ) + print(f"{len(df)} rows") + if len(df) > 0: + all_dfs.append(df) + time.sleep(1) # Rate limiting + except Exception as e: + print(f"ERROR: {e}") + time.sleep(3) + # Retry once + try: + df = fetch( + instrument=pair, + interval=INTERVAL_CODE, + offer_side=OFFER_SIDE_BID, + start=chunk_start, + end=chunk_end, + max_retries=5, + ) + print(f" Retry OK: {len(df)} rows") + if len(df) > 0: + all_dfs.append(df) + except Exception as e2: + print(f" Retry failed: {e2}") + return False + + if not all_dfs: + print(f" No data retrieved for {pair}") + return False + + combined = pd.concat(all_dfs) + combined = combined[~combined.index.duplicated(keep="first")] + combined = combined.sort_index() + + # Save + os.makedirs(RAW_DIR, exist_ok=True) + combined.to_csv(filepath) + print(f" Saved: {filename} ({len(combined)} rows)") + print(f" Range: {combined.index[0]} to {combined.index[-1]}") + return True + + +def main(): + os.makedirs(RAW_DIR, exist_ok=True) + + print(f"Downloading M5 data from Dukascopy") + print(f"Pairs: {', '.join(PAIRS)}") + print(f"Date range: {START.date()} to {END.date()}") + print(f"Output: {RAW_DIR}") + print("=" * 60) + + failed = [] + for i, pair in enumerate(PAIRS): + print(f"\n[{i+1}/{len(PAIRS)}] {pair} M5") + success = download_pair(pair) + if not success: + failed.append(pair) + + print("\n" + "=" * 60) + print(f"Complete: {len(PAIRS) - len(failed)}/{len(PAIRS)}") + if failed: + print(f"Failed: {', '.join(failed)}") + else: + print("All M5 downloads successful!") + print(f"\nNext step: Run validate_and_split to process the data:") + print(f" python src/validate_and_split_data.py") + + +if __name__ == "__main__": + main() diff --git a/src/run_test_m5_strategies.py b/src/run_test_m5_strategies.py new file mode 100644 index 0000000..274b04e --- /dev/null +++ b/src/run_test_m5_strategies.py @@ -0,0 +1,380 @@ +""" +Test 5-Minute Strategies on M5 Data — IS/OOS Backtest. + +Strategies originally designed for 5-minute charts, now tested on actual M5 data: + S12 - Asian Range Sweep (M5 + H1 HTF) + S15 - Momentum Continuation (M5 + H1 HTF) + S16 - London ORB (M5 + H1 HTF) + +Parameters are scaled from M15 defaults to M5 granularity (3x more bars per hour). +""" +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.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 + + +def make_s12_m5(): + """S12 Asian Range Sweep adapted for M5.""" + s = S12_AsianRangeSweep() + # M5 has 84 bars in Asian session (7h * 12 bars/h) vs 28 on M15 + s.LOOKBACK_BARS = 180 # Enough to find all Asian bars + s.MAX_BARS = 120 # Same ~10h hold time (120 * 5min = 10h) + return s + + +def make_s15_m5(): + """S15 Momentum Continuation adapted for M5.""" + s = S15_MomentumContinuation() + # Scale bar counts by 3x (M5 has 3x more bars per hour than M15) + s.IMPULSE_BARS = 24 # 2 hours = 24 M5 bars (was 8 on M15) + s.PULLBACK_MIN_BARS = 6 # 30 min minimum pullback (was 2) + s.PULLBACK_MAX_BARS = 36 # 3 hours max pullback (was 12) + s.MAX_BARS = 120 # ~10h hold (was 40) + return s + + +def make_s16_m5(): + """S16 London ORB adapted for M5.""" + s = S16_LondonORB() + # M5 gives proper 30-min opening range (6 bars) + s.ORB_BARS = 6 # 30 min = 6 M5 bars (was 1 on M15) + s.VOLUME_MULT = 1.3 # Restore original volume filter (M5 volume is granular) + s.MAX_BARS = 120 # ~10h hold (was 40) + s.MIN_RANGE_ATR = 0.2 # Slightly tighter for M5 + s.MAX_RANGE_ATR = 1.8 + return s + + +# Test configs: 3 strategies x 4 pairs = 12 combos +CONFIGS = [ + # S12: Asian Range Sweep on M5 + {"name": "S12_GBP_JPY_M5", "pair": "GBP_JPY", "tf": "M5", "htf_tf": "H1", + "factory": make_s12_m5}, + {"name": "S12_GBP_USD_M5", "pair": "GBP_USD", "tf": "M5", "htf_tf": "H1", + "factory": make_s12_m5}, + {"name": "S12_EUR_USD_M5", "pair": "EUR_USD", "tf": "M5", "htf_tf": "H1", + "factory": make_s12_m5}, + {"name": "S12_USD_JPY_M5", "pair": "USD_JPY", "tf": "M5", "htf_tf": "H1", + "factory": make_s12_m5}, + + # S15: Momentum Continuation on M5 + {"name": "S15_GBP_JPY_M5", "pair": "GBP_JPY", "tf": "M5", "htf_tf": "H1", + "factory": make_s15_m5}, + {"name": "S15_GBP_USD_M5", "pair": "GBP_USD", "tf": "M5", "htf_tf": "H1", + "factory": make_s15_m5}, + {"name": "S15_EUR_USD_M5", "pair": "EUR_USD", "tf": "M5", "htf_tf": "H1", + "factory": make_s15_m5}, + {"name": "S15_USD_JPY_M5", "pair": "USD_JPY", "tf": "M5", "htf_tf": "H1", + "factory": make_s15_m5}, + + # S16: London ORB on M5 + {"name": "S16_GBP_JPY_M5", "pair": "GBP_JPY", "tf": "M5", "htf_tf": "H1", + "factory": make_s16_m5}, + {"name": "S16_GBP_USD_M5", "pair": "GBP_USD", "tf": "M5", "htf_tf": "H1", + "factory": make_s16_m5}, + {"name": "S16_EUR_USD_M5", "pair": "EUR_USD", "tf": "M5", "htf_tf": "H1", + "factory": make_s16_m5}, + {"name": "S16_USD_JPY_M5", "pair": "USD_JPY", "tf": "M5", "htf_tf": "H1", + "factory": make_s16_m5}, +] + + +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 5-MINUTE STRATEGIES ON M5 DATA (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 M5 data (cached) + cache_key = f"{pair}_{tf}" + if cache_key not in data_cache: + print(f" Loading {pair} {tf}...", end=" ", flush=True) + data_cache[cache_key] = load_data(pair, tf) + if data_cache[cache_key] is not None: + print(f"{len(data_cache[cache_key])} bars") + else: + print("MISSING") + data = data_cache[cache_key] + if data is None: + continue + + # Load HTF H1 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':<20} {'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:<20} {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:<20} 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_m5_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 index 3a695b4..93b6361 100644 --- a/src/strategies_pkg/s12_asian_range_sweep.py +++ b/src/strategies_pkg/s12_asian_range_sweep.py @@ -49,6 +49,7 @@ class S12_AsianRangeSweep(BaseStrategy): TP_RR_MULT = 1.5 # Minimum RR filter MAX_BARS = 40 + LOOKBACK_BARS = 60 # How far back to search for Asian session bars def __init__(self): super().__init__() @@ -63,7 +64,7 @@ class S12_AsianRangeSweep(BaseStrategy): return self._asian_range_cache[current_date] asian_bars = [] - for i in range(max(0, idx - 60), idx + 1): + for i in range(max(0, idx - self.LOOKBACK_BARS), idx + 1): bar_time = data.index[i] if bar_time.date() != current_date: continue diff --git a/src/validate_and_split_data.py b/src/validate_and_split_data.py index 550a0b9..4ea51b4 100644 --- a/src/validate_and_split_data.py +++ b/src/validate_and_split_data.py @@ -23,6 +23,7 @@ SPLIT_DATE = "2023-09-01" # Expected timeframe intervals EXPECTED_INTERVALS = { + "M5": pd.Timedelta(minutes=5), "M15": pd.Timedelta(minutes=15), "H1": pd.Timedelta(hours=1), }