Add Phase 2 backtesting pipeline: IS/OOS split, param sweep, generalization scoring

Externalize hardcoded params in S4F (5 params) and S3 (9 params) as class
attributes for sweep compatibility. Add unified backtest runner with IS/OOS
validation and generalization scores, plus parameter grid sweep (90 combos)
with OOS validation. S7/S9/S9_Filtered pass generalization; S4F/S3 confirm
defaults are near-optimal.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Brent Neale
2026-02-19 18:58:35 +10:00
parent 9d16939eaa
commit 4f911b2072
16 changed files with 3251 additions and 19 deletions
+402
View File
@@ -0,0 +1,402 @@
"""
Phase 2 — Parameter Grid Sweep with OOS Validation.
For each strategy, defines a small param grid (~18 combos), runs all on IS,
ranks by profit factor, validates the top-1 param set on OOS.
Output: results/phase2/param_sweep.json
"""
import os, sys, io, json, time
from itertools import product
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.s7_liquidity_sweep import S7_Liquidity_Sweep
from src.strategies_pkg.s9_london_session import S9_London_Session
from src.strategies_pkg.s4f_ema_ribbon import S4F_EMA_Ribbon
from src.strategies_pkg.s3_key_level_breakout import S3_KeyLevel_Breakout
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
IS_START = "2021-01-01"
IS_END = "2022-12-31"
OOS_START = "2023-01-01"
OOS_END = "2023-08-31"
WARMUP_DAYS = 60
# ─── Strategy configs + param grids ───────────────────────────────────────
SWEEP_CONFIGS = [
{
"name": "S7_Tight",
"pair": "GBP_JPY",
"tf": "H1",
"htf_tf": "H1",
"factory": lambda: S7_Liquidity_Sweep(),
"param_mode": "setattr",
"grid": {
"SWEEP_MIN_ATR": [0.5, 0.7, 1.0],
"SL_ATR_MULT": [0.8, 1.0, 1.5],
"TP2_ATR_MULT": [2.5, 3.0],
},
},
{
"name": "S9",
"pair": "GBP_USD",
"tf": "H1",
"htf_tf": "H1",
"factory": lambda: S9_London_Session(),
"param_mode": "setattr",
"grid": {
"VOLUME_MULT": [1.2, 1.5, 2.0],
"SL_ATR_CAP": [2.0, 2.5, 3.0],
"ENTRY_END_HOUR": [10, 11],
},
},
{
"name": "S9_Filtered",
"pair": "GBP_AUD",
"tf": "H1",
"htf_tf": "H1",
"factory": lambda: S9_London_Session(pair="GBP_AUD", filtered=True),
"param_mode": "pair_cfg",
"grid": {
"min_adx": [20, 25, 30],
"min_ema50_dist_pips": [30, 40, 50],
"skip_friday": [True, False],
},
},
{
"name": "S4F",
"pair": "EUR_AUD",
"tf": "M15",
"htf_tf": "H1",
"factory": lambda: S4F_EMA_Ribbon(),
"param_mode": "setattr",
"grid": {
"SL_ATR_MULT": [1.5, 2.0, 2.5],
"TP_ATR_MULT": [2.5, 3.0, 4.0],
"COMPRESSION_ATR_MULT": [0.8, 1.0],
},
},
{
"name": "S3",
"pair": "GBP_JPY",
"tf": "H1",
"htf_tf": "H1",
"factory": lambda: S3_KeyLevel_Breakout(),
"param_mode": "setattr",
"grid": {
"SL_ATR_MULT": [0.3, 0.5, 0.75],
"TP1_ATR_MULT": [1.0, 1.5, 2.0],
"KEY_LEVEL_TOLERANCE": [0.5, 0.75],
},
},
]
# ─── Helpers ──────────────────────────────────────────────────────────────
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 apply_params(strategy, params, mode):
"""Apply parameter dict to a strategy instance."""
if mode == "setattr":
for key, value in params.items():
setattr(strategy, key, value)
elif mode == "pair_cfg":
for key, value in params.items():
strategy._pair_cfg[key] = value
def run_single(cfg, data, htf_data, start, end, params):
"""Run a single backtest with given params, return metrics."""
sliced, start_ts = slice_period(data, start, end)
if len(sliced) < 250:
return {"trades": 0, "wr": 0, "pf": 0, "sharpe": 0,
"pnl_pips": 0, "expectancy": 0}
htf_sliced = sliced.copy() if cfg["htf_tf"] == cfg["tf"] else \
slice_period(htf_data, start, end)[0]
strategy = cfg["factory"]()
apply_params(strategy, params, cfg["param_mode"])
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 warmup trades
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 compute_metrics(trade_log)
def compute_metrics(trade_log):
"""Compute metrics from trade log."""
if trade_log.empty or len(trade_log) == 0:
return {"trades": 0, "wr": 0, "pf": 0, "sharpe": 0,
"pnl_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
return {
"trades": n,
"wr": round(wr, 1),
"pf": round(pf, 2),
"sharpe": round(sharpe, 2),
"pnl_pips": round(total_pnl, 1),
"expectancy": round(expectancy, 2),
}
def compute_generalization_score(is_m, oos_m):
"""Quick generalization composite from IS/OOS metrics."""
if is_m["trades"] == 0 or oos_m["trades"] == 0:
return 0.0
ratios = {}
if is_m["wr"] > 0:
ratios["wr"] = min(oos_m["wr"] / is_m["wr"], 2.0)
if is_m["pf"] > 0 and is_m["pf"] != float("inf"):
oos_pf = min(oos_m["pf"], 2 * is_m["pf"]) if oos_m["pf"] == float("inf") else oos_m["pf"]
ratios["pf"] = min(oos_pf / is_m["pf"], 2.0)
if is_m["expectancy"] > 0:
ratios["expectancy"] = min(oos_m["expectancy"] / is_m["expectancy"], 2.0)
for k in ratios:
ratios[k] = max(ratios[k], 0.0)
return round(np.mean(list(ratios.values())), 3) if ratios else 0.0
def expand_grid(grid):
"""Expand a param grid dict into a list of param dicts."""
keys = list(grid.keys())
values = list(grid.values())
combos = []
for combo in product(*values):
combos.append(dict(zip(keys, combo)))
return combos
# ─── Main ─────────────────────────────────────────────────────────────────
def main():
t0 = time.time()
all_sweep_results = {}
print(f"{'='*100}")
print("PHASE 2 — PARAMETER SWEEP (IS Grid Search + OOS Validation)")
print(f" IS period: {IS_START} to {IS_END}")
print(f" OOS period: {OOS_START} to {OOS_END}")
print(f"{'='*100}")
# Data cache to avoid reloading
data_cache = {}
for cfg in SWEEP_CONFIGS:
name = cfg["name"]
pair = cfg["pair"]
tf = cfg["tf"]
htf_tf = cfg["htf_tf"]
print(f"\n{'#'*70}")
print(f"# {name} / {pair} ({tf})")
print(f"{'#'*70}")
# Load 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
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_tf != tf else data
if htf_data is None:
continue
# Expand parameter grid
combos = expand_grid(cfg["grid"])
print(f" Grid: {len(combos)} combinations")
# Run IS sweep
is_results = []
for i, params in enumerate(combos, 1):
param_str = ", ".join(f"{k}={v}" for k, v in params.items())
metrics = run_single(cfg, data, htf_data, IS_START, IS_END, params)
is_results.append({
"params": params,
"metrics": metrics,
})
# Progress indicator
status = f" [{i:>2}/{len(combos)}] {param_str}"
status += f" -> {metrics['trades']}t PF={metrics['pf']:.2f} WR={metrics['wr']:.1f}%"
print(status)
# Rank by profit factor (filter out 0-trade combos)
valid = [r for r in is_results if r["metrics"]["trades"] >= 5]
if not valid:
print(" No valid IS results (all combos had <5 trades)")
all_sweep_results[name] = {
"pair": pair, "combos_tested": len(combos),
"best_params": None, "is_results_ranked": [],
}
continue
# Sort by PF descending, then by trade count descending as tiebreak
valid.sort(key=lambda r: (r["metrics"]["pf"], r["metrics"]["trades"]),
reverse=True)
print(f"\n --- IS Rankings (top 5) ---")
print(f" {'Rank':>4} {'PF':>6} {'WR%':>6} {'Trades':>6} {'Sharpe':>7} {'Exp':>7} Params")
for rank, r in enumerate(valid[:5], 1):
m = r["metrics"]
p_str = ", ".join(f"{k}={v}" for k, v in r["params"].items())
print(f" {rank:>4} {m['pf']:>6.2f} {m['wr']:>5.1f}% {m['trades']:>6} "
f"{m['sharpe']:>7.2f} {m['expectancy']:>+7.2f} {p_str}")
# Validate top-1 on OOS
best = valid[0]
best_params = best["params"]
is_metrics = best["metrics"]
print(f"\n Validating best params on OOS...")
oos_metrics = run_single(cfg, data, htf_data, OOS_START, OOS_END, best_params)
gen_score = compute_generalization_score(is_metrics, oos_metrics)
if gen_score >= 0.80:
verdict = "PASS"
elif gen_score >= 0.50:
verdict = "WARN"
else:
verdict = "FAIL"
param_str = ", ".join(f"{k}={v}" for k, v in best_params.items())
print(f"\n BEST: {param_str}")
print(f" IS: {is_metrics['trades']}t PF={is_metrics['pf']:.2f} "
f"WR={is_metrics['wr']:.1f}% Sharpe={is_metrics['sharpe']:.2f} "
f"Exp={is_metrics['expectancy']:+.2f}")
print(f" OOS: {oos_metrics['trades']}t PF={oos_metrics['pf']:.2f} "
f"WR={oos_metrics['wr']:.1f}% Sharpe={oos_metrics['sharpe']:.2f} "
f"Exp={oos_metrics['expectancy']:+.2f}")
print(f" Gen: {gen_score:.3f} -> {verdict}")
# Store
all_sweep_results[name] = {
"pair": pair,
"combos_tested": len(combos),
"best_params": best_params,
"is_best_metrics": is_metrics,
"oos_validation": oos_metrics,
"generalization_score": gen_score,
"verdict": verdict,
"is_results_ranked": [
{"rank": i + 1, "params": r["params"], "metrics": r["metrics"]}
for i, r in enumerate(valid)
],
}
# Summary table
print(f"\n{'='*100}")
print("SWEEP SUMMARY")
print(f"{'='*100}")
print(f" {'Strategy':<14} {'Combos':>6} {'Best IS PF':>10} {'OOS PF':>8} "
f"{'Gen':>6} {'Verdict':>8} Best Params")
print(f" {'-'*95}")
for name, res in all_sweep_results.items():
if res["best_params"] is None:
print(f" {name:<14} {res['combos_tested']:>6} {'N/A':>10} {'N/A':>8} "
f"{'N/A':>6} {'SKIP':>8}")
continue
is_pf = res["is_best_metrics"]["pf"]
oos_pf = res["oos_validation"]["pf"]
gen = res["generalization_score"]
verdict = res["verdict"]
p_str = ", ".join(f"{k}={v}" for k, v in res["best_params"].items())
print(f" {name:<14} {res['combos_tested']:>6} {is_pf:>10.2f} {oos_pf:>8.2f} "
f"{gen:>6.3f} {verdict:>8} {p_str}")
# Save JSON
out_path = os.path.join(RESULTS_DIR, "param_sweep.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_, bool)):
return bool(obj)
return str(obj)
with open(out_path, "w") as f:
json.dump(all_sweep_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()
+359
View File
@@ -0,0 +1,359 @@
"""
Phase 2 — Unified Backtest Runner with IS/OOS Split.
Runs all 5 Phase 2 strategies on:
- In-Sample (IS): 2021-01-01 to 2022-12-31
- Out-of-Sample (OOS): 2023-01-01 to 2023-08-31
Computes per-strategy and portfolio-level metrics, generalization scores,
and exports structured results.
Output:
- Console summary table
- results/phase2/backtest_report.json
- results/phase2/trades_{name}_IS.csv / trades_{name}_OOS.csv
"""
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.s7_liquidity_sweep import S7_Liquidity_Sweep
from src.strategies_pkg.s9_london_session import S9_London_Session
from src.strategies_pkg.s4f_ema_ribbon import S4F_EMA_Ribbon
from src.strategies_pkg.s3_key_level_breakout import S3_KeyLevel_Breakout
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
IS_START = "2021-01-01"
IS_END = "2022-12-31"
OOS_START = "2023-01-01"
OOS_END = "2023-08-31"
WARMUP_DAYS = 60
# Phase 2 strategy-pair configurations
CONFIGS = [
{"name": "S7_Tight", "pair": "GBP_JPY", "tf": "H1", "htf_tf": "H1",
"factory": lambda: S7_Liquidity_Sweep()},
{"name": "S9", "pair": "GBP_USD", "tf": "H1", "htf_tf": "H1",
"factory": lambda: S9_London_Session()},
{"name": "S9_Filtered", "pair": "GBP_AUD", "tf": "H1", "htf_tf": "H1",
"factory": lambda: S9_London_Session(pair="GBP_AUD", filtered=True)},
{"name": "S4F", "pair": "EUR_AUD", "tf": "M15", "htf_tf": "H1",
"factory": lambda: S4F_EMA_Ribbon()},
{"name": "S3", "pair": "GBP_JPY", "tf": "H1", "htf_tf": "H1",
"factory": lambda: S3_KeyLevel_Breakout()},
]
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 for indicators."""
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 = sliced.copy() if cfg["htf_tf"] == cfg["tf"] else \
slice_period(htf_data, start, end)[0]
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, including Sharpe ratio."""
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
# Sharpe ratio (annualized, from per-trade PnL)
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
# Max drawdown in pips
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 = {}
# Win rate ratio
if is_metrics["wr"] > 0:
ratios["wr"] = oos_metrics["wr"] / is_metrics["wr"]
else:
ratios["wr"] = 0
# Profit factor ratio
if is_metrics["pf"] > 0 and is_metrics["pf"] != float("inf"):
if oos_metrics["pf"] == float("inf"):
ratios["pf"] = 2.0 # Cap at 2x
else:
ratios["pf"] = oos_metrics["pf"] / is_metrics["pf"]
else:
ratios["pf"] = 0
# Expectancy ratio
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 # Both negative
else:
ratios["expectancy"] = 0
# Sharpe ratio (same-sign comparison)
if is_metrics["sharpe"] > 0:
ratios["sharpe"] = oos_metrics["sharpe"] / is_metrics["sharpe"]
else:
ratios["sharpe"] = 0
# Cap individual ratios at 2.0 (OOS can't be "too much better")
for k in ratios:
ratios[k] = min(ratios[k], 2.0)
ratios[k] = max(ratios[k], 0.0)
# Composite: equal-weight average of capped ratios
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 compute_portfolio_aggregate(all_trade_logs):
"""Concatenate all strategy trades, compute portfolio-level metrics."""
combined = pd.concat(all_trade_logs, ignore_index=True) if all_trade_logs else pd.DataFrame()
return compute_metrics(combined), combined
def main():
t0 = time.time()
all_results = {}
is_trade_logs = []
oos_trade_logs = []
print(f"{'='*100}")
print("PHASE 2 — UNIFIED BACKTEST (IS/OOS Split)")
print(f" IS period: {IS_START} to {IS_END}")
print(f" OOS period: {OOS_START} to {OOS_END}")
print(f"{'='*100}")
header = (f"{'Strategy':<14} {'Period':<5} {'Trades':>6} {'WR%':>6} "
f"{'PF':>6} {'Sharpe':>7} {'Exp':>7} {'PnL':>9} {'Gen':>6}")
separator = "-" * 100
print(f"\n{header}")
print(separator)
for cfg in CONFIGS:
name = cfg["name"]
pair = cfg["pair"]
tf = cfg["tf"]
htf_tf = cfg["htf_tf"]
print(f"\n Loading {name} / {pair} ({tf})...")
# Load data
data = load_data(pair, tf)
if data is None:
continue
htf_data = data.copy() if htf_tf == tf else load_data(pair, htf_tf)
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 rows
print(f" {name:<14} {'IS':<5} {is_metrics['trades']:>6} "
f"{is_metrics['wr']:>5.1f}% {is_metrics['pf']:>6.2f} "
f"{is_metrics['sharpe']:>7.2f} {is_metrics['expectancy']:>+7.2f} "
f"{is_metrics['pnl_pips']:>+9.1f}")
print(f" {'':<14} {'OOS':<5} {oos_metrics['trades']:>6} "
f"{oos_metrics['wr']:>5.1f}% {oos_metrics['pf']:>6.2f} "
f"{oos_metrics['sharpe']:>7.2f} {oos_metrics['expectancy']:>+7.2f} "
f"{oos_metrics['pnl_pips']:>+9.1f} "
f"{gen['composite']:>5.2f} {gen['verdict']}")
# Save trade CSVs
if not is_log.empty:
is_log.to_csv(os.path.join(RESULTS_DIR, f"trades_{name}_IS.csv"), index=False)
is_trade_logs.append(is_log)
if not oos_log.empty:
oos_log.to_csv(os.path.join(RESULTS_DIR, f"trades_{name}_OOS.csv"), index=False)
oos_trade_logs.append(oos_log)
# Store results
all_results[name] = {
"pair": pair, "timeframe": tf,
"is_metrics": is_metrics, "oos_metrics": oos_metrics,
"generalization": gen,
}
# Portfolio aggregate
print(f"\n{separator}")
print("PORTFOLIO AGGREGATE")
print(separator)
port_is_metrics, _ = compute_portfolio_aggregate(is_trade_logs)
port_oos_metrics, _ = compute_portfolio_aggregate(oos_trade_logs)
port_gen = compute_generalization_scores(port_is_metrics, port_oos_metrics)
print(f" {'PORTFOLIO':<14} {'IS':<5} {port_is_metrics['trades']:>6} "
f"{port_is_metrics['wr']:>5.1f}% {port_is_metrics['pf']:>6.2f} "
f"{port_is_metrics['sharpe']:>7.2f} {port_is_metrics['expectancy']:>+7.2f} "
f"{port_is_metrics['pnl_pips']:>+9.1f}")
print(f" {'':<14} {'OOS':<5} {port_oos_metrics['trades']:>6} "
f"{port_oos_metrics['wr']:>5.1f}% {port_oos_metrics['pf']:>6.2f} "
f"{port_oos_metrics['sharpe']:>7.2f} {port_oos_metrics['expectancy']:>+7.2f} "
f"{port_oos_metrics['pnl_pips']:>+9.1f} "
f"{port_gen['composite']:>5.2f} {port_gen['verdict']}")
all_results["_portfolio"] = {
"is_metrics": port_is_metrics,
"oos_metrics": port_oos_metrics,
"generalization": port_gen,
}
# Generalization summary
print(f"\n{separator}")
print("GENERALIZATION SUMMARY")
print(separator)
print(f" {'Strategy':<14} {'Composite':>9} {'WR':>6} {'PF':>6} {'Exp':>6} {'Sharpe':>7} {'Verdict':>8}")
for name, res in all_results.items():
if name.startswith("_"):
continue
g = res["generalization"]
d = g["detail"]
print(f" {name:<14} {g['composite']:>9.3f} "
f"{d.get('wr', 0):>6.3f} {d.get('pf', 0):>6.3f} "
f"{d.get('expectancy', 0):>6.3f} {d.get('sharpe', 0):>7.3f} "
f"{g['verdict']:>8}")
g = all_results["_portfolio"]["generalization"]
d = g["detail"]
print(f" {'PORTFOLIO':<14} {g['composite']:>9.3f} "
f"{d.get('wr', 0):>6.3f} {d.get('pf', 0):>6.3f} "
f"{d.get('expectancy', 0):>6.3f} {d.get('sharpe', 0):>7.3f} "
f"{g['verdict']:>8}")
# Save JSON report
out_path = os.path.join(RESULTS_DIR, "backtest_report.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()
+25 -12
View File
@@ -25,6 +25,17 @@ class S3_KeyLevel_Breakout(BaseStrategy):
strategy_id = 3
name = "S3_Key_Level_Breakout"
# Tunable parameters (defaults match original hardcoded values)
BODY_RATIO_MIN = 0.50
VOLUME_MULT = 1.5
SL_ATR_MULT = 0.5
TP1_ATR_MULT = 1.5
TP2_ATR_MULT = 2.5
TP3_ATR_MULT = 4.0
MIN_ADX = 20
KEY_LEVEL_TOLERANCE = 0.75
KEY_LEVEL_MIN_TOUCHES = 3
def __init__(self):
super().__init__()
self._cached_levels = None
@@ -47,21 +58,21 @@ class S3_KeyLevel_Breakout(BaseStrategy):
# ADX filter: require trending market
adx_val = current.get("adx_14", 0)
if adx_val < 20:
if adx_val < self.MIN_ADX:
return None
# Strong close: candle body > 50% of range
close = current["close"]
body = abs(close - current["open"])
full_range = current["high"] - current["low"]
if full_range <= 0 or body / full_range < 0.50:
if full_range <= 0 or body / full_range < self.BODY_RATIO_MIN:
return None
# Volume spike: current volume > 1.5x 20-bar average
vol = current.get("volume", 0)
if vol > 0 and idx >= 20:
vol_avg = data["volume"].iloc[idx - 20:idx].mean()
if vol_avg > 0 and vol < 1.5 * vol_avg:
if vol_avg > 0 and vol < self.VOLUME_MULT * vol_avg:
return None
prev_close = data.iloc[idx - 1]["close"]
@@ -74,7 +85,9 @@ class S3_KeyLevel_Breakout(BaseStrategy):
start = max(0, idx - 1000)
window = data.iloc[start:idx] # exclude current bar
self._cached_levels = identify_key_levels(
window, lookback=5, tolerance_atr_mult=0.75, min_touches=3
window, lookback=5,
tolerance_atr_mult=self.KEY_LEVEL_TOLERANCE,
min_touches=self.KEY_LEVEL_MIN_TOUCHES,
)
self._cache_idx = idx
@@ -98,10 +111,10 @@ class S3_KeyLevel_Breakout(BaseStrategy):
confluence = self._calc_confluence(current, data, idx,
"LONG", touch_count, vol)
sl = level_price - 0.5 * atr_val
tp1 = close + 1.5 * atr_val
tp2 = close + 2.5 * atr_val
tp3 = close + 4.0 * atr_val
sl = level_price - self.SL_ATR_MULT * atr_val
tp1 = close + self.TP1_ATR_MULT * atr_val
tp2 = close + self.TP2_ATR_MULT * atr_val
tp3 = close + self.TP3_ATR_MULT * atr_val
return {
"direction": "LONG",
@@ -127,10 +140,10 @@ class S3_KeyLevel_Breakout(BaseStrategy):
confluence = self._calc_confluence(current, data, idx,
"SHORT", touch_count, vol)
sl = level_price + 0.5 * atr_val
tp1 = close - 1.5 * atr_val
tp2 = close - 2.5 * atr_val
tp3 = close - 4.0 * atr_val
sl = level_price + self.SL_ATR_MULT * atr_val
tp1 = close - self.TP1_ATR_MULT * atr_val
tp2 = close - self.TP2_ATR_MULT * atr_val
tp3 = close - self.TP3_ATR_MULT * atr_val
return {
"direction": "SHORT",
+14 -7
View File
@@ -21,6 +21,13 @@ class S4F_EMA_Ribbon(BaseStrategy):
strategy_id = 4
name = "S4F_Trend_Context"
# Tunable parameters (defaults match original hardcoded values)
SL_ATR_MULT = 2.0
TP_ATR_MULT = 3.0
VOLUME_MULT = 1.2
COMPRESSION_ATR_MULT = 1.0
HTF_EMA_DIST_ATR = 1.5
def check_signal(self, data: pd.DataFrame, idx: int,
current: pd.Series,
htf_row: Optional[pd.Series] = None) -> Optional[dict]:
@@ -67,7 +74,7 @@ class S4F_EMA_Ribbon(BaseStrategy):
# ------- PRICE WITHIN 1.5 ATR OF 1H 50 EMA -------
price = current["close"]
if abs(price - htf_ema50) > 1.5 * atr_val:
if abs(price - htf_ema50) > self.HTF_EMA_DIST_ATR * atr_val:
return None
# ------- M15 RIBBON COMPRESSION -> EXPANSION -------
@@ -78,7 +85,7 @@ class S4F_EMA_Ribbon(BaseStrategy):
return None
ribbon_width = max(ema_20, ema_50, ema_100) - min(ema_20, ema_50, ema_100)
compression_threshold = 1.0 * atr_val
compression_threshold = self.COMPRESSION_ATR_MULT * atr_val
was_compressed = False
min_compression_width = float('inf')
@@ -119,16 +126,16 @@ class S4F_EMA_Ribbon(BaseStrategy):
return None
vol = current["volume"]
vol_avg = data["volume"].iloc[max(0, idx - 20):idx].mean()
if vol_avg <= 0 or vol <= 1.2 * vol_avg:
if vol_avg <= 0 or vol <= self.VOLUME_MULT * vol_avg:
return None
# ------- EXIT LEVELS -------
if direction == "LONG":
sl = price - 2.0 * atr_val
tp1 = price + 3.0 * atr_val
sl = price - self.SL_ATR_MULT * atr_val
tp1 = price + self.TP_ATR_MULT * atr_val
else:
sl = price + 2.0 * atr_val
tp1 = price - 3.0 * atr_val
sl = price + self.SL_ATR_MULT * atr_val
tp1 = price - self.TP_ATR_MULT * atr_val
return {
"direction": direction,