mirror of
https://github.com/BrentNeale1/fx-quant.git
synced 2026-08-22 14:38:08 +00:00
Add M5 data pipeline and test S12/S15/S16 on 5-minute data
Downloaded M5 data via Dukascopy for GBP_JPY, GBP_USD, EUR_USD, USD_JPY (~200k bars each, 2021-2024). Added M5 to validate_and_split pipeline. Tested S12 (Asian Range Sweep), S15 (Momentum Continuation), S16 (London ORB) with M5-scaled parameters. No viable edge found — best result was S16_GBP_JPY OOS PF=1.03 but IS was negative (PF=0.71). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
7657d4b1b3
commit
a4ec5fb0a5
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -49,6 +49,7 @@ class S12_AsianRangeSweep(BaseStrategy):
|
|||||||
TP_RR_MULT = 1.5 # Minimum RR filter
|
TP_RR_MULT = 1.5 # Minimum RR filter
|
||||||
|
|
||||||
MAX_BARS = 40
|
MAX_BARS = 40
|
||||||
|
LOOKBACK_BARS = 60 # How far back to search for Asian session bars
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
@@ -63,7 +64,7 @@ class S12_AsianRangeSweep(BaseStrategy):
|
|||||||
return self._asian_range_cache[current_date]
|
return self._asian_range_cache[current_date]
|
||||||
|
|
||||||
asian_bars = []
|
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]
|
bar_time = data.index[i]
|
||||||
if bar_time.date() != current_date:
|
if bar_time.date() != current_date:
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ SPLIT_DATE = "2023-09-01"
|
|||||||
|
|
||||||
# Expected timeframe intervals
|
# Expected timeframe intervals
|
||||||
EXPECTED_INTERVALS = {
|
EXPECTED_INTERVALS = {
|
||||||
|
"M5": pd.Timedelta(minutes=5),
|
||||||
"M15": pd.Timedelta(minutes=15),
|
"M15": pd.Timedelta(minutes=15),
|
||||||
"H1": pd.Timedelta(hours=1),
|
"H1": pd.Timedelta(hours=1),
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user