"""WFV-oriented research CLI for leader_intraday_momentum style strategies.""" from __future__ import annotations import argparse import asyncio from datetime import datetime from pathlib import Path from typing import Any from libs.common.config import get_settings from apps.intraday_bt.momentum_research import ( build_momentum_research_context, build_momentum_strategy, evaluate_momentum_wfv_candidate, ) from apps.intraday_bt.oracle import make_intraday_oracle_client from apps.intraday_bt.run import load_config DEFAULT_CONFIG = "configs/intraday/strategies/leader_intraday_momentum.yaml" def _leader_candidate_overrides() -> list[tuple[str, dict[str, Any]]]: """Curated nearby candidates around the current leader champion.""" return [ ("control", {}), ("top6", {"top_n": 6}), ("top6_trail_loose", {"top_n": 6, "trailing_stop_pct": -0.07}), ( "top6_trail_loose_volume_ratio", {"top_n": 6, "trailing_stop_pct": -0.07, "min_volume_ratio_14d": 0.05}, ), ("entropy_tight", {"max_entropy_20d": 0.88}), ("entropy_loose", {"max_entropy_20d": 0.92}), ("vix_tight", {"max_vix": 28.0}), ("vix_loose", {"max_vix": 32.0}), ("top4", {"top_n": 4}), ("trail_tight", {"trailing_stop_pct": -0.05}), ("trail_loose", {"trailing_stop_pct": -0.07}), ( "volume_ratio_gate", { "min_volume_ratio_14d": 0.05, }, ), ( "spy_regime_guard", { "market_regime_spy_threshold": -0.005, }, ), ( "quality_defensive", { "top_n": 4, "max_entropy_20d": 0.88, "max_vix": 28.0, "trailing_stop_pct": -0.05, }, ), ] def _rank_results(rows: list[dict[str, Any]]) -> list[dict[str, Any]]: rows.sort( key=lambda row: ( row["score"]["selection_score"], row["score"]["holdout_return_pct"] or float("-inf"), row["score"]["mean_test_return_pct"] or float("-inf"), ), reverse=True, ) for idx, row in enumerate(rows, start=1): row["rank"] = idx return rows def _condensed_row(label: str, payload: dict[str, Any]) -> dict[str, Any]: score = payload["score"] summary = payload["walk_forward_summary"] holdout = payload["holdout_metrics"] or {} return { "label": label, "strategy": payload["strategy"], "score": score, "fold_count": summary["fold_count"], "holdout_trades": holdout.get("total_trades"), "holdout_max_dd_pct": ( None if holdout.get("max_drawdown_pct") is None else round(abs(holdout["max_drawdown_pct"]) * 100.0, 2) ), } async def main_async() -> None: parser = argparse.ArgumentParser(description="WFV research for leader intraday momentum") parser.add_argument("--config", default=DEFAULT_CONFIG) parser.add_argument("--wfv-start", default="2025-01-02") parser.add_argument("--wfv-end", default="2025-12-31") parser.add_argument("--holdout-start", default="2026-01-02") parser.add_argument("--holdout-end", default="2026-03-31") parser.add_argument("--train-days", type=int, default=84) parser.add_argument("--test-days", type=int, default=21) parser.add_argument("--step-days", type=int, default=21) parser.add_argument("--output-dir", default="runs/intraday/research") args = parser.parse_args() config = load_config(args.config) if config.strategy_mode != "momentum": raise ValueError("momentum_wfv only supports momentum configs") settings = get_settings() async with make_intraday_oracle_client(settings) as client: print("[1/3] Building 2025 WFV context...") context_2025 = await build_momentum_research_context( config, args.wfv_start, args.wfv_end, client, print_progress=True, ) print("[2/3] Building 2026 Q1 holdout context...") holdout_context = await build_momentum_research_context( config, args.holdout_start, args.holdout_end, client, print_progress=True, ) print("[3/3] Evaluating WFV candidates...") rows: list[dict[str, Any]] = [] for idx, (label, overrides) in enumerate(_leader_candidate_overrides(), start=1): print(f"\n Candidate {idx}/{len(_leader_candidate_overrides())}: {label}") strategy = build_momentum_strategy(config, overrides) payload = evaluate_momentum_wfv_candidate( context_2025, strategy, train_days=args.train_days, test_days=args.test_days, step_days=args.step_days, holdout_context=holdout_context, ) row = _condensed_row(label, payload) rows.append(row) print( " " f"WF mean {row['score']['mean_test_return_pct'] or 0:.2f}% | " f"WF+ {row['score']['positive_fold_rate_pct'] or 0:.0f}% | " f"WFQS {row['score']['wfqs_v2'] or 0:.1f} | " f"Q1 {(row['score']['holdout_return_pct'] or 0):.2f}%" ) ranked = _rank_results(rows) print("\n=== 2025 WFV Ranking ===") for row in ranked: score = row["score"] print( f"{row['rank']:>2}. {row['label']:<18} " f"score {score['selection_score']:>6.2f} | " f"WF mean {score['mean_test_return_pct'] or 0:>6.2f}% | " f"WF+ {score['positive_fold_rate_pct'] or 0:>5.1f}% | " f"worst {score['worst_fold_return_pct'] or 0:>6.2f}% | " f"Q1 {score['holdout_return_pct'] or 0:>6.2f}%" ) out_dir = Path(args.output_dir) out_dir.mkdir(parents=True, exist_ok=True) ts = datetime.now().strftime("%Y%m%d_%H%M%S") out_path = out_dir / f"leader_momentum_wfv_{ts}.json" out_path.write_text( __import__("json").dumps( { "config": args.config, "wfv_period": [args.wfv_start, args.wfv_end], "holdout_period": [args.holdout_start, args.holdout_end], "train_days": args.train_days, "test_days": args.test_days, "step_days": args.step_days, "rows": ranked, "winner_label": ranked[0]["label"] if ranked else None, "winner_strategy": ranked[0]["strategy"] if ranked else None, }, indent=2, ensure_ascii=True, default=str, ) ) print(f"\nSaved WFV report to: {out_path}") def main() -> None: asyncio.run(main_async()) if __name__ == "__main__": main()