605faf5310
Add cluster audit pipeline, united EA updates, brochure generators, and publication hygiene (gitignore, MT5 path desensitization, pre-upload scan). Remove tracked reports, models, and binary artifacts from the repo. Co-authored-by: Cursor <cursoragent@cursor.com>
255 lines
8.6 KiB
Python
255 lines
8.6 KiB
Python
"""
|
|
RSIScalpingAdaptive XAUUSD — monthly walk-forward validation (Python).
|
|
|
|
Mirrors the in-EA optimizer: each calendar month, grid-search the prior month,
|
|
pick the best score, then forward-test that month with the selected params.
|
|
|
|
Usage:
|
|
python run_walk_forward.py
|
|
python run_walk_forward.py --symbol XAUUSD --start 2023-01-01 --end 2026-01-01
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import sys
|
|
from dataclasses import asdict, dataclass
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
|
|
import MetaTrader5 as mt5
|
|
import pandas as pd
|
|
|
|
ROOT = Path(__file__).resolve().parents[3]
|
|
sys.path.insert(0, str(ROOT / "backtesting" / "MT5"))
|
|
|
|
from cluster_audit.backtest_core import CostModel, load_bars, resolve_symbol # noqa: E402
|
|
from run_backtest import StrategyParams, run_backtest # noqa: E402
|
|
|
|
STRATEGY_ID = "RSIScalpingAdaptiveXAUUSD"
|
|
|
|
|
|
@dataclass
|
|
class SearchGrid:
|
|
rsi_period: tuple[int, int, int] = (12, 18, 2)
|
|
rsi_overbought: tuple[float, float, float] = (65.0, 77.0, 3.0)
|
|
rsi_oversold: tuple[float, float, float] = (50.0, 63.0, 3.0)
|
|
rsi_target_buy: tuple[float, float, float] = (75.0, 86.0, 3.0)
|
|
rsi_target_sell: tuple[float, float, float] = (50.0, 63.0, 3.0)
|
|
bars_to_wait: tuple[int, int, int] = (1, 4, 1)
|
|
min_trades: int = 8
|
|
max_combos: int = 600
|
|
weight_sharpe: float = 0.35
|
|
weight_net: float = 0.25
|
|
weight_pf: float = 0.15
|
|
weight_dd: float = 0.10
|
|
|
|
|
|
def _frange(start: float, stop: float, step: float) -> list[float]:
|
|
out: list[float] = []
|
|
v = start
|
|
while v <= stop + 1e-9:
|
|
out.append(round(v, 6))
|
|
v += step
|
|
return out
|
|
|
|
|
|
def _irange(start: int, stop: int, step: int) -> list[int]:
|
|
return list(range(start, stop + 1, step))
|
|
|
|
|
|
def score_report(report, min_trades: int, grid: SearchGrid) -> float:
|
|
if report.total_trades < min_trades or report.net_profit <= 0 or report.profit_factor < 1.05:
|
|
return float("-inf")
|
|
pf = min(report.profit_factor, 4.0) / 4.0
|
|
return (
|
|
report.sharpe * grid.weight_sharpe
|
|
+ (report.net_profit / 2000.0) * grid.weight_net
|
|
+ pf * grid.weight_pf
|
|
- report.max_drawdown_pct * grid.weight_dd
|
|
)
|
|
|
|
|
|
def is_valid(p: StrategyParams) -> bool:
|
|
return p.rsi_target_buy > p.rsi_oversold and p.rsi_target_sell < p.rsi_overbought
|
|
|
|
|
|
def iter_params(fallback: StrategyParams, grid: SearchGrid):
|
|
yield fallback
|
|
tested = 0
|
|
for rp in _irange(*grid.rsi_period):
|
|
for ob in _frange(*grid.rsi_overbought):
|
|
for os in _frange(*grid.rsi_oversold):
|
|
for tb in _frange(*grid.rsi_target_buy):
|
|
for ts in _frange(*grid.rsi_target_sell):
|
|
for bw in _irange(*grid.bars_to_wait):
|
|
if tested >= grid.max_combos:
|
|
return
|
|
p = StrategyParams(
|
|
rsi_period=rp,
|
|
rsi_overbought=ob,
|
|
rsi_oversold=os,
|
|
rsi_target_buy=tb,
|
|
rsi_target_sell=ts,
|
|
bars_to_wait=bw,
|
|
lot_size=fallback.lot_size,
|
|
initial_balance=fallback.initial_balance,
|
|
)
|
|
if is_valid(p):
|
|
tested += 1
|
|
yield p
|
|
|
|
|
|
def month_starts(start: datetime, end: datetime) -> list[pd.Timestamp]:
|
|
idx = pd.date_range(start=start, end=end, freq="MS")
|
|
return list(idx)
|
|
|
|
|
|
def previous_month_bounds(ts: pd.Timestamp) -> tuple[datetime, datetime]:
|
|
prev_end = ts - pd.Timedelta(seconds=1)
|
|
prev_start = prev_end.replace(day=1)
|
|
return prev_start.to_pydatetime(), prev_end.to_pydatetime()
|
|
|
|
|
|
def month_bounds(ts: pd.Timestamp) -> tuple[datetime, datetime]:
|
|
start = ts.to_pydatetime()
|
|
end = (ts + pd.offsets.MonthBegin(1) - pd.Timedelta(seconds=1)).to_pydatetime()
|
|
return start, end
|
|
|
|
|
|
def optimize_month(
|
|
df_all: pd.DataFrame,
|
|
symbol: str,
|
|
costs: CostModel,
|
|
opt_start: datetime,
|
|
opt_end: datetime,
|
|
fallback: StrategyParams,
|
|
grid: SearchGrid,
|
|
):
|
|
df = df_all.loc[(df_all.index >= opt_start) & (df_all.index <= opt_end)]
|
|
if len(df) < 80:
|
|
return fallback, None, 0
|
|
|
|
best_p = fallback
|
|
best_r = None
|
|
best_score = float("-inf")
|
|
combos = 0
|
|
|
|
for p in iter_params(fallback, grid):
|
|
report = run_backtest(df, symbol, p, costs, f"{opt_start.date()}_{opt_end.date()}", "H1")
|
|
combos += 1
|
|
sc = score_report(report, grid.min_trades, grid)
|
|
if sc > best_score:
|
|
best_score = sc
|
|
best_p = p
|
|
best_r = report
|
|
|
|
return best_p, best_r, combos
|
|
|
|
|
|
def forward_month(
|
|
df_all: pd.DataFrame,
|
|
symbol: str,
|
|
costs: CostModel,
|
|
fwd_start: datetime,
|
|
fwd_end: datetime,
|
|
params: StrategyParams,
|
|
):
|
|
df = df_all.loc[(df_all.index >= fwd_start) & (df_all.index <= fwd_end)]
|
|
if len(df) < 20:
|
|
return None
|
|
return run_backtest(df, symbol, params, costs, f"{fwd_start.date()}_{fwd_end.date()}", "H1")
|
|
|
|
|
|
def parse_args() -> argparse.Namespace:
|
|
p = argparse.ArgumentParser(description=f"{STRATEGY_ID} walk-forward")
|
|
p.add_argument("--symbol", default="XAUUSD")
|
|
p.add_argument("--start", default="2023-01-01")
|
|
p.add_argument("--end", default="2026-01-01")
|
|
p.add_argument("--balance", type=float, default=10_000.0)
|
|
p.add_argument("--lot", type=float, default=0.1)
|
|
return p.parse_args()
|
|
|
|
|
|
def main() -> None:
|
|
args = parse_args()
|
|
out_dir = Path(__file__).resolve().parent
|
|
fallback = StrategyParams(lot_size=args.lot, initial_balance=args.balance)
|
|
grid = SearchGrid()
|
|
|
|
if not mt5.initialize():
|
|
raise SystemExit("MetaTrader5 initialize() failed")
|
|
|
|
try:
|
|
symbol = resolve_symbol(args.symbol)
|
|
start = datetime.fromisoformat(args.start)
|
|
end = datetime.fromisoformat(args.end)
|
|
warmup = start - pd.Timedelta(days=45)
|
|
print(f"Loading {symbol} H1 bars from {warmup.date()} to {end.date()} ...")
|
|
df_all = load_bars(symbol, mt5.TIMEFRAME_H1, warmup.to_pydatetime(), end)
|
|
costs = CostModel.for_symbol(symbol)
|
|
|
|
rows = []
|
|
cumulative = 0.0
|
|
for month_ts in month_starts(start, end):
|
|
if month_ts.to_pydatetime() >= end:
|
|
break
|
|
opt_start, opt_end = previous_month_bounds(month_ts)
|
|
fwd_start, fwd_end = month_bounds(month_ts)
|
|
if fwd_start >= end:
|
|
continue
|
|
|
|
best_p, opt_report, combos = optimize_month(
|
|
df_all, symbol, costs, opt_start, opt_end, fallback, grid
|
|
)
|
|
fwd_report = forward_month(df_all, symbol, costs, fwd_start, fwd_end, best_p)
|
|
if fwd_report is None:
|
|
continue
|
|
|
|
cumulative += fwd_report.net_profit
|
|
rows.append(
|
|
{
|
|
"month": str(month_ts.date())[:7],
|
|
"opt_window": f"{opt_start.date()}..{opt_end.date()}",
|
|
"combos_tested": combos,
|
|
"selected": asdict(best_p),
|
|
"opt_net": opt_report.net_profit if opt_report else 0.0,
|
|
"opt_sharpe": opt_report.sharpe if opt_report else 0.0,
|
|
"fwd_net": fwd_report.net_profit,
|
|
"fwd_trades": fwd_report.total_trades,
|
|
"fwd_sharpe": fwd_report.sharpe,
|
|
"fwd_pf": fwd_report.profit_factor,
|
|
"fwd_dd_pct": fwd_report.max_drawdown_pct,
|
|
"cumulative_net": cumulative,
|
|
}
|
|
)
|
|
print(
|
|
f"{rows[-1]['month']} | opt ${rows[-1]['opt_net']:,.0f} "
|
|
f"-> fwd ${rows[-1]['fwd_net']:,.0f} | cum ${cumulative:,.0f} | "
|
|
f"RSI={best_p.rsi_period} OB={best_p.rsi_overbought} OS={best_p.rsi_oversold}"
|
|
)
|
|
|
|
summary = {
|
|
"strategy": STRATEGY_ID,
|
|
"symbol": symbol,
|
|
"start": args.start,
|
|
"end": args.end,
|
|
"months": len(rows),
|
|
"cumulative_net": cumulative,
|
|
"rows": rows,
|
|
}
|
|
out_path = out_dir / "walk_forward_report.json"
|
|
with open(out_path, "w", encoding="utf-8") as f:
|
|
json.dump(summary, f, indent=2, ensure_ascii=False)
|
|
|
|
pd.DataFrame(rows).to_csv(out_dir / "walk_forward_monthly.csv", index=False)
|
|
print(f"\nWalk-forward cumulative net: ${cumulative:,.2f} over {len(rows)} months")
|
|
print(f"Saved {out_path}")
|
|
finally:
|
|
mt5.shutdown()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|