You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
201 lines
6.8 KiB
Python
201 lines
6.8 KiB
Python
"""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()
|