|
|
@@ -0,0 +1,166 @@
|
|
|
+from __future__ import annotations
|
|
|
+
|
|
|
+import argparse
|
|
|
+import json
|
|
|
+from pathlib import Path
|
|
|
+from typing import Any
|
|
|
+
|
|
|
+import pandas as pd
|
|
|
+import yaml
|
|
|
+
|
|
|
+
|
|
|
+def repo_root() -> Path:
|
|
|
+ return Path(__file__).resolve().parents[2]
|
|
|
+
|
|
|
+
|
|
|
+def build_parser() -> argparse.ArgumentParser:
|
|
|
+ parser = argparse.ArgumentParser(description="Build a unified comparison table for backtest outputs.")
|
|
|
+ root = repo_root()
|
|
|
+ parser.add_argument("--backtests-root", type=Path, default=root / "outputs" / "backtests")
|
|
|
+ parser.add_argument("--strategy-config-root", type=Path, default=root / "configs" / "strategy")
|
|
|
+ parser.add_argument("--cost-scenarios", type=Path, default=root / "configs" / "research" / "cost_scenarios.yaml")
|
|
|
+ parser.add_argument("--output-csv", type=Path, default=root / "outputs" / "research" / "strategy_comparison.csv")
|
|
|
+ parser.add_argument("--output-md", type=Path, default=root / "outputs" / "research" / "strategy_comparison.md")
|
|
|
+ return parser
|
|
|
+
|
|
|
+
|
|
|
+def load_yaml(path: Path) -> dict[str, Any]:
|
|
|
+ with path.open("r", encoding="utf-8") as handle:
|
|
|
+ return yaml.safe_load(handle) or {}
|
|
|
+
|
|
|
+
|
|
|
+def load_cost_scenarios(path: Path) -> dict[float, str]:
|
|
|
+ payload = load_yaml(path)
|
|
|
+ mapping: dict[float, str] = {}
|
|
|
+ for label, config in (payload.get("scenarios") or {}).items():
|
|
|
+ total_cost_bps = float(config["total_cost_bps"])
|
|
|
+ mapping[total_cost_bps] = label
|
|
|
+ return mapping
|
|
|
+
|
|
|
+
|
|
|
+def load_strategy_configs(root: Path) -> dict[str, dict[str, Any]]:
|
|
|
+ configs: dict[str, dict[str, Any]] = {}
|
|
|
+ for path in sorted(root.glob("*.yaml")):
|
|
|
+ payload = load_yaml(path)
|
|
|
+ config_name = str(payload.get("name") or path.stem)
|
|
|
+ payload["_path"] = str(path)
|
|
|
+ configs[config_name] = payload
|
|
|
+ return configs
|
|
|
+
|
|
|
+
|
|
|
+def build_rows(
|
|
|
+ *,
|
|
|
+ backtests_root: Path,
|
|
|
+ strategy_configs: dict[str, dict[str, Any]],
|
|
|
+ cost_scenarios: dict[float, str],
|
|
|
+) -> pd.DataFrame:
|
|
|
+ rows: list[dict[str, Any]] = []
|
|
|
+ for summary_path in sorted(backtests_root.glob("*/summary.json")):
|
|
|
+ result_dir = summary_path.parent
|
|
|
+ name = result_dir.name
|
|
|
+ benchmark_path = result_dir / "benchmark_summary.json"
|
|
|
+ if not benchmark_path.exists():
|
|
|
+ continue
|
|
|
+
|
|
|
+ summary = json.loads(summary_path.read_text(encoding="utf-8"))
|
|
|
+ benchmark_summary = json.loads(benchmark_path.read_text(encoding="utf-8"))
|
|
|
+ cfg = strategy_configs.get(name, {})
|
|
|
+ total_cost_bps = float(cfg.get("commission_bps", 0.0)) + float(cfg.get("slippage_bps", 0.0))
|
|
|
+ row = {
|
|
|
+ "name": name,
|
|
|
+ "top_n": cfg.get("top_n"),
|
|
|
+ "rebalance_frequency": cfg.get("rebalance_frequency"),
|
|
|
+ "risk_penalty_multiplier": cfg.get("risk_penalty_multiplier", 0.30),
|
|
|
+ "total_cost_bps": total_cost_bps,
|
|
|
+ "cost_scenario": cost_scenarios.get(total_cost_bps, "custom" if total_cost_bps else "none"),
|
|
|
+ "cumulative_return": summary.get("cumulative_return"),
|
|
|
+ "annual_return": summary.get("annual_return"),
|
|
|
+ "max_drawdown": summary.get("max_drawdown"),
|
|
|
+ "annual_volatility": summary.get("annual_volatility"),
|
|
|
+ "sharpe": summary.get("sharpe"),
|
|
|
+ "calmar": summary.get("calmar"),
|
|
|
+ "turnover": summary.get("turnover"),
|
|
|
+ "rebalance_count": summary.get("rebalance_count"),
|
|
|
+ "cash_days_ratio": summary.get("cash_days_ratio"),
|
|
|
+ "vs_equal_weight_cum": summary.get("cumulative_return", 0.0) - benchmark_summary.get("equal_weight", {}).get("cumulative_return", 0.0),
|
|
|
+ "vs_hs300_cum": summary.get("cumulative_return", 0.0) - benchmark_summary.get("hs300", {}).get("cumulative_return", 0.0),
|
|
|
+ "vs_chinext50_cum": summary.get("cumulative_return", 0.0) - benchmark_summary.get("chinext50", {}).get("cumulative_return", 0.0),
|
|
|
+ }
|
|
|
+ rows.append(row)
|
|
|
+
|
|
|
+ frame = pd.DataFrame(rows)
|
|
|
+ if frame.empty:
|
|
|
+ return frame
|
|
|
+ return frame.sort_values(
|
|
|
+ ["total_cost_bps", "sharpe", "annual_return", "cumulative_return"],
|
|
|
+ ascending=[True, False, False, False],
|
|
|
+ ).reset_index(drop=True)
|
|
|
+
|
|
|
+
|
|
|
+def render_markdown_table(frame: pd.DataFrame) -> str:
|
|
|
+ if frame.empty:
|
|
|
+ return "# Strategy Comparison\n\n_No comparison rows available._\n"
|
|
|
+
|
|
|
+ display = frame[
|
|
|
+ [
|
|
|
+ "name",
|
|
|
+ "cost_scenario",
|
|
|
+ "total_cost_bps",
|
|
|
+ "top_n",
|
|
|
+ "rebalance_frequency",
|
|
|
+ "risk_penalty_multiplier",
|
|
|
+ "cumulative_return",
|
|
|
+ "annual_return",
|
|
|
+ "max_drawdown",
|
|
|
+ "sharpe",
|
|
|
+ "vs_equal_weight_cum",
|
|
|
+ "vs_hs300_cum",
|
|
|
+ "vs_chinext50_cum",
|
|
|
+ ]
|
|
|
+ ].copy()
|
|
|
+ for column in [
|
|
|
+ "cumulative_return",
|
|
|
+ "annual_return",
|
|
|
+ "max_drawdown",
|
|
|
+ "sharpe",
|
|
|
+ "vs_equal_weight_cum",
|
|
|
+ "vs_hs300_cum",
|
|
|
+ "vs_chinext50_cum",
|
|
|
+ ]:
|
|
|
+ display[column] = display[column].map(lambda value: f"{float(value):.4f}")
|
|
|
+ return "# Strategy Comparison\n\n" + display.to_markdown(index=False) + "\n"
|
|
|
+
|
|
|
+
|
|
|
+def main(argv: list[str] | None = None) -> int:
|
|
|
+ parser = build_parser()
|
|
|
+ args = parser.parse_args(argv)
|
|
|
+
|
|
|
+ strategy_configs = load_strategy_configs(args.strategy_config_root)
|
|
|
+ cost_scenarios = load_cost_scenarios(args.cost_scenarios)
|
|
|
+ frame = build_rows(
|
|
|
+ backtests_root=args.backtests_root,
|
|
|
+ strategy_configs=strategy_configs,
|
|
|
+ cost_scenarios=cost_scenarios,
|
|
|
+ )
|
|
|
+
|
|
|
+ args.output_csv.parent.mkdir(parents=True, exist_ok=True)
|
|
|
+ args.output_md.parent.mkdir(parents=True, exist_ok=True)
|
|
|
+ frame.to_csv(args.output_csv, index=False)
|
|
|
+ args.output_md.write_text(render_markdown_table(frame), encoding="utf-8")
|
|
|
+
|
|
|
+ print(
|
|
|
+ json.dumps(
|
|
|
+ {
|
|
|
+ "rows": int(len(frame.index)),
|
|
|
+ "output_csv": str(args.output_csv),
|
|
|
+ "output_md": str(args.output_md),
|
|
|
+ },
|
|
|
+ ensure_ascii=False,
|
|
|
+ indent=2,
|
|
|
+ )
|
|
|
+ )
|
|
|
+ return 0
|
|
|
+
|
|
|
+
|
|
|
+if __name__ == "__main__":
|
|
|
+ raise SystemExit(main())
|