Files
FX-ML-Trading-Engine/Q Research/scripts/run_monte_carlo.py
T
2025-11-14 23:16:51 +00:00

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()