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.

177 lines
6.5 KiB
Python

"""Quarter-robustness research CLI for leader_intraday_momentum strategies."""
from __future__ import annotations
import argparse
import asyncio
import json
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_quarterly_candidate,
)
from apps.intraday_bt.momentum_wfv import DEFAULT_CONFIG
from apps.intraday_bt.oracle import make_intraday_oracle_client
from apps.intraday_bt.run import load_config
def _quarterly_candidate_overrides() -> list[tuple[str, dict[str, Any]]]:
"""Curated nearby candidates around the current leader champion."""
return [
("control", {}),
("top18", {"top_n": 18}),
("top22", {"top_n": 22}),
("gain15", {"min_morning_gain_pct": 0.015}),
("gain17", {"min_morning_gain_pct": 0.017}),
("trail70", {"trailing_stop_pct": -0.07}),
("trail80", {"trailing_stop_pct": -0.08}),
("exit0", {"exit_minutes_before_close": 0}),
("exit10", {"exit_minutes_before_close": 10}),
("vol75k", {"min_entry_volume": 75000}),
("vol125k", {"min_entry_volume": 125000}),
("vix28", {"max_vix": 28.0}),
("vix32", {"max_vix": 32.0}),
("top22_gain17", {"top_n": 22, "min_morning_gain_pct": 0.017}),
("top22_vol125k", {"top_n": 22, "min_entry_volume": 125000}),
]
def _rank_results(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
rows.sort(
key=lambda row: (
row["score"]["quarterly_selection_score"],
row["score"]["holdout_return_pct"] or float("-inf"),
row["score"]["quarter_worst_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"]
return {
"label": label,
"strategy": payload["strategy"],
"score": score,
"quarter_metrics": payload["quarter_metrics"],
"walk_forward_summary": payload["walk_forward_summary"],
"holdout_metrics": payload["holdout_metrics"],
}
async def main_async() -> None:
parser = argparse.ArgumentParser(description="Quarter robustness 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_quarterly only supports momentum configs")
settings = get_settings()
async with make_intraday_oracle_client(settings) as client:
print("[1/3] Building 2025 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 quarter-robust candidates...")
rows: list[dict[str, Any]] = []
candidates = _quarterly_candidate_overrides()
for idx, (label, overrides) in enumerate(candidates, start=1):
print(f"\n Candidate {idx}/{len(candidates)}: {label}")
strategy = build_momentum_strategy(config, overrides)
payload = evaluate_momentum_quarterly_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"Quarter score {row['score']['quarterly_selection_score'] or 0:.2f} | "
f"Qmean {row['score']['quarter_mean_return_pct'] or 0:.2f}% | "
f"Qworst {row['score']['quarter_worst_return_pct'] or 0:.2f}% | "
f"WF {row['score']['mean_test_return_pct'] or 0:.2f}% | "
f"Q1 {row['score']['holdout_return_pct'] or 0:.2f}%"
)
ranked = _rank_results(rows)
print("\n=== 2025 Quarterly Robustness Ranking ===")
for row in ranked:
score = row["score"]
print(
f"{row['rank']:>2}. {row['label']:<16} "
f"qscore {score['quarterly_selection_score']:>7.2f} | "
f"qmean {score['quarter_mean_return_pct'] or 0:>6.2f}% | "
f"qworst {score['quarter_worst_return_pct'] or 0:>6.2f}% | "
f"q+ {score['quarter_positive_rate_pct'] or 0:>5.1f}% | "
f"qstd {score['quarter_return_stdev_pct'] or 0:>5.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_quarterly_{ts}.json"
out_path.write_text(
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 quarterly report to: {out_path}")
def main() -> None:
asyncio.run(main_async())
if __name__ == "__main__":
main()