mirror of
https://github.com/xavierchuan/FX-ML-Trading-Engine.git
synced 2026-07-27 18:17:44 +00:00
182 lines
7.7 KiB
Python
182 lines
7.7 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Monte Carlo / stress test for an existing backtest run.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import sys
|
|
from pathlib import Path
|
|
from typing import List, Optional
|
|
|
|
import numpy as np
|
|
import pandas as pd
|
|
from loguru import logger
|
|
|
|
BASE_DIR = Path(__file__).resolve().parents[1]
|
|
if str(BASE_DIR) not in sys.path:
|
|
sys.path.insert(0, str(BASE_DIR))
|
|
|
|
from metrics.perf import compute_metrics
|
|
from scripts.scenario_utils import get_scenario
|
|
|
|
|
|
def parse_args() -> argparse.Namespace:
|
|
parser = argparse.ArgumentParser(description="Run Monte Carlo stress on a backtest result.")
|
|
parser.add_argument("--run", help="Path to results/<run_id> directory.")
|
|
parser.add_argument("--equity", help="Explicit equity CSV (ts,equity). Overrides --run artifacts.")
|
|
parser.add_argument("--iterations", type=int, default=500, help="Number of bootstrap iterations.")
|
|
parser.add_argument("--method", choices=["bootstrap", "block"], default="bootstrap", help="Resampling method.")
|
|
parser.add_argument("--block-size", type=int, default=None, help="Block size for block bootstrap (overrides scenario).")
|
|
parser.add_argument("--return-scale", type=float, default=None, help="Scale factor applied to resampled returns (overrides scenario).")
|
|
parser.add_argument("--ruin-threshold", type=float, default=0.8, help="Final equity / initial equity threshold to count as ruin.")
|
|
parser.add_argument("--seed", type=int, default=None, help="Random seed.")
|
|
parser.add_argument("--scenario", default=None, help="Optional stress scenario label stored in outputs.")
|
|
parser.add_argument(
|
|
"--scenario-file",
|
|
default="config/stress_scenarios.yaml",
|
|
help="Scenario definitions file (default: config/stress_scenarios.yaml).",
|
|
)
|
|
return parser.parse_args()
|
|
|
|
|
|
def load_equity_series(run_path: Path | None, explicit_csv: str | None) -> pd.Series:
|
|
if explicit_csv:
|
|
path = Path(explicit_csv).expanduser()
|
|
else:
|
|
if not run_path:
|
|
raise ValueError("Either --run or --equity must be provided.")
|
|
summary_file = run_path / "summary.json"
|
|
if not summary_file.exists():
|
|
raise FileNotFoundError(f"summary.json not found in {run_path}")
|
|
summary = json.load(summary_file.open("r", encoding="utf-8"))
|
|
artifacts = summary.get("artifacts") or {}
|
|
equity_path = artifacts.get("equity")
|
|
if not equity_path:
|
|
raise FileNotFoundError("Equity artifact missing in summary; rerun backtest after upgrading.")
|
|
path = (BASE_DIR / equity_path).resolve()
|
|
if not path.exists():
|
|
raise FileNotFoundError(f"Equity file not found: {path}")
|
|
df = pd.read_csv(path)
|
|
if "equity" not in df.columns:
|
|
raise ValueError(f"Equity CSV missing 'equity' column: {path}")
|
|
return df["equity"].astype(float)
|
|
|
|
|
|
def block_bootstrap(returns: np.ndarray, size: int, block_size: int, rng: np.random.Generator) -> np.ndarray:
|
|
if returns.size == 0:
|
|
raise ValueError("Cannot run block bootstrap on an empty return series.")
|
|
if block_size <= 0:
|
|
raise ValueError("Block size must be a positive integer.")
|
|
if block_size > len(returns):
|
|
raise ValueError(f"Block size {block_size} exceeds return series length {len(returns)}.")
|
|
out = []
|
|
while len(out) < size:
|
|
start = rng.integers(0, len(returns) - block_size + 1)
|
|
block = returns[start : start + block_size]
|
|
out.extend(block.tolist())
|
|
return np.array(out[:size])
|
|
|
|
|
|
def run_iteration(returns: np.ndarray, args, rng: np.random.Generator) -> dict:
|
|
if returns.size == 0:
|
|
raise ValueError("Return series is empty; cannot run Monte Carlo.")
|
|
if args.method == "bootstrap":
|
|
draw = rng.choice(returns, size=returns.size, replace=True)
|
|
else:
|
|
draw = block_bootstrap(returns, returns.size, args.block_size, rng)
|
|
draw = draw * args.return_scale
|
|
equity = np.cumprod(1.0 + draw)
|
|
equity_series = list(enumerate(equity, start=1))
|
|
metrics = compute_metrics(equity_series)
|
|
return metrics
|
|
|
|
|
|
def summarize(metrics_list: List[dict], ruin_threshold: float, initial_equity: float) -> dict:
|
|
df = pd.DataFrame(metrics_list)
|
|
summary = {}
|
|
for column in ["sharpe", "sortino", "calmar", "ann_return", "max_drawdown"]:
|
|
if column in df.columns:
|
|
summary[column] = {
|
|
"mean": float(df[column].mean()),
|
|
"std": float(df[column].std()),
|
|
"p05": float(df[column].quantile(0.05)),
|
|
"p50": float(df[column].quantile(0.5)),
|
|
"p95": float(df[column].quantile(0.95)),
|
|
}
|
|
ruin = (df["final_equity"] <= initial_equity * ruin_threshold).mean() if "final_equity" in df else None
|
|
summary["p_ruin"] = float(ruin) if ruin is not None else None
|
|
return summary
|
|
|
|
|
|
def _load_scenario(args: argparse.Namespace) -> Optional[dict]:
|
|
if not args.scenario:
|
|
return None
|
|
scenario_path = Path(args.scenario_file).expanduser()
|
|
try:
|
|
scenario = get_scenario(args.scenario, scenario_path)
|
|
except KeyError as exc:
|
|
raise ValueError(f"Scenario '{args.scenario}' not found in {scenario_path}") from exc
|
|
return scenario
|
|
|
|
|
|
def _apply_scenario(args: argparse.Namespace, scenario_cfg: Optional[dict]) -> None:
|
|
if not scenario_cfg:
|
|
args.return_scale = args.return_scale if args.return_scale is not None else 1.0
|
|
args.block_size = args.block_size if args.block_size is not None else 20
|
|
return
|
|
if args.return_scale is None and scenario_cfg.get("return_scale") is not None:
|
|
args.return_scale = float(scenario_cfg["return_scale"])
|
|
if args.block_size is None and scenario_cfg.get("block_size") is not None:
|
|
args.block_size = int(scenario_cfg["block_size"])
|
|
args.return_scale = args.return_scale if args.return_scale is not None else scenario_cfg.get("return_scale", 1.0)
|
|
args.block_size = args.block_size if args.block_size is not None else scenario_cfg.get("block_size", 20)
|
|
|
|
|
|
def main():
|
|
args = parse_args()
|
|
scenario_cfg = _load_scenario(args)
|
|
_apply_scenario(args, scenario_cfg)
|
|
run_path = Path(args.run).expanduser().resolve() if args.run else None
|
|
equity = load_equity_series(run_path, args.equity)
|
|
returns = np.diff(equity.values) / equity.values[:-1]
|
|
if returns.size == 0:
|
|
raise ValueError("Equity series must contain at least two points.")
|
|
initial_equity = float(equity.iloc[0])
|
|
rng = np.random.default_rng(args.seed)
|
|
metrics_list: List[dict] = []
|
|
|
|
logger.info(
|
|
"Running Monte Carlo | method={method} iterations={iterations} scenario={scenario} seed={seed}",
|
|
method=args.method,
|
|
iterations=args.iterations,
|
|
scenario=args.scenario or "default",
|
|
seed=args.seed,
|
|
)
|
|
for _ in range(args.iterations):
|
|
metrics = run_iteration(returns, args, rng)
|
|
metrics_list.append(metrics)
|
|
|
|
summary = summarize(metrics_list, args.ruin_threshold, initial_equity)
|
|
summary["iterations"] = args.iterations
|
|
summary["method"] = args.method
|
|
summary["return_scale"] = args.return_scale
|
|
summary["scenario"] = args.scenario or "default"
|
|
summary["seed"] = args.seed
|
|
summary["scenario_overrides"] = scenario_cfg
|
|
|
|
output_dir = run_path / "stress" if run_path else BASE_DIR / "results" / "stress"
|
|
output_dir.mkdir(parents=True, exist_ok=True)
|
|
iterations_csv = output_dir / "mc_iterations.csv"
|
|
pd.DataFrame(metrics_list).to_csv(iterations_csv, index=False)
|
|
summary_json = output_dir / "mc_summary.json"
|
|
with summary_json.open("w", encoding="utf-8") as fh:
|
|
json.dump(summary, fh, indent=2, ensure_ascii=False)
|
|
logger.info(f"Monte Carlo summary saved to {summary_json}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|