"""Shared momentum research helpers for WFV and holdout evaluation.""" from __future__ import annotations import gzip import hashlib import json import pickle import statistics import sys import uuid from dataclasses import dataclass from datetime import date, timedelta from pathlib import Path from typing import Any from libs.backtest.domain import SplitResult, WalkForwardSummary from libs.backtest.tracker import compute_wfqs_v2 from libs.common.config import get_settings from libs.intraday.cache import DailyBarCache, IntradayCache from libs.intraday.domain import IntradayConfig, IntradayMetrics, StrategyParams from libs.intraday.metrics import compute_metrics from libs.intraday.catalyst import ( AttentionEventCache, FilingEventCache, fetch_attention_features_bulk, fetch_filing_event_features_bulk, ) from libs.intraday.screener import ( fetch_daily_bars_bulk, fetch_intraday_bulk, momentum_intraday_first_candidates, momentum_pre_screen_candidates, pre_screen_candidates, resolve_universe, ) from libs.intraday.simulator import run_simulation from libs.oracle_client import OracleClient from apps.intraday_bt.orb_research import ( build_walk_forward_summary, generate_walk_forward_windows, ) from apps.intraday_bt.run import ( _fetch_vix_by_day, _load_ticker_sectors_with_oracle, _make_progress_bar, _augment_momentum_seed_candidates_with_liquid_overlay, _merge_momentum_attention_features, _merge_momentum_event_features, _momentum_candidate_event_pairs, _momentum_candidate_event_tickers, _momentum_intraday_seed_candidates, _momentum_preliminary_candidates, _momentum_strategy_uses_attention, _momentum_strategy_uses_catalyst, _momentum_enrichment_for_days, _momentum_uses_historical_intraday_first, _momentum_strategy_uses_daily_enrichment, _momentum_strategy_uses_vix, get_trading_days, ) _MOMENTUM_RESEARCH_SNAPSHOT_VERSION = 6 @dataclass class MomentumResearchContext: config: IntradayConfig tickers: list[str] ticker_sectors: dict[str, str] trading_days: list[str] daily_bars: dict[str, list[dict]] all_intraday: dict[str, dict[str, list[dict]]] daily_enrichment: dict[str, dict[str, dict]] | None vix_by_day: dict[str, float] | None candidates: dict[str, list[str]] candidate_pairs: int research_snapshot_key: str | None = None class MomentumResearchSnapshotStore: """Disk snapshot for expensive momentum research context preparation.""" def __init__(self, root: str | Path) -> None: self.root = Path(root) def _path(self, key: str) -> Path: return self.root / key[:2] / f"{key}.pkl.gz" @classmethod def build_key( cls, config: IntradayConfig, *, start_date: str, end_date: str, tickers: list[str], trading_days: list[str], ) -> str: strategy = config.strategy payload = { "version": _MOMENTUM_RESEARCH_SNAPSHOT_VERSION, "strategy_mode": config.strategy_mode, "start_date": start_date, "end_date": end_date, "universe": config.universe.model_dump(mode="json"), "pre_screen_threshold": config.backtest.pre_screen_threshold, # Research snapshots cache the fetched intraday seed set and any # candidate-scoped enrichment, so the full strategy is the safest # invalidation boundary. This prevents stale snapshots when new # seed-overlay / liquid-largecap controls are introduced. "strategy": config.strategy.model_dump(mode="json"), "uses_daily_enrichment": _momentum_strategy_uses_daily_enrichment(strategy), "uses_catalyst": _momentum_strategy_uses_catalyst(strategy), "uses_attention": _momentum_strategy_uses_attention(strategy), "uses_vix": _momentum_strategy_uses_vix(strategy), "tickers": tickers, "trading_days": trading_days, } blob = json.dumps(payload, sort_keys=True, separators=(",", ":"), default=str) return hashlib.sha1(blob.encode("utf-8")).hexdigest() def load(self, key: str) -> dict[str, Any] | None: path = self._path(key) if not path.exists(): return None try: with gzip.open(path, "rb") as fh: payload = pickle.load(fh) except Exception: path.unlink(missing_ok=True) return None if not isinstance(payload, dict): path.unlink(missing_ok=True) return None if payload.get("version") != _MOMENTUM_RESEARCH_SNAPSHOT_VERSION: path.unlink(missing_ok=True) return None if payload.get("key") != key: path.unlink(missing_ok=True) return None required = { "tickers", "trading_days", "daily_bars", "all_intraday", "daily_enrichment", "vix_by_day", "candidates", "candidate_pairs", } if not required.issubset(set(payload)): path.unlink(missing_ok=True) return None return payload def save(self, key: str, payload: dict[str, Any]) -> Path: path = self._path(key) path.parent.mkdir(parents=True, exist_ok=True) tmp = path.with_suffix(".tmp") record = dict(payload) record["version"] = _MOMENTUM_RESEARCH_SNAPSHOT_VERSION record["key"] = key with gzip.open(tmp, "wb", compresslevel=3) as fh: pickle.dump(record, fh, protocol=pickle.HIGHEST_PROTOCOL) tmp.replace(path) return path def intraday_metrics_to_momentum_split_result( metrics: IntradayMetrics, strategy: StrategyParams, ) -> SplitResult: """Adapt momentum metrics into the generic split schema used by WFV scorers.""" days_in_market_pct = None if metrics.trading_days > 0: days_in_market_pct = round(metrics.days_with_trades / metrics.trading_days * 100.0, 1) total_return_pct = metrics.total_return_pct * 100.0 if metrics.total_return_pct is not None else None annualized_return_pct = ( metrics.annualized_return_pct * 100.0 if metrics.annualized_return_pct is not None else None ) max_drawdown_pct = ( abs(metrics.max_drawdown_pct) * 100.0 if metrics.max_drawdown_pct is not None else None ) # The momentum engine deploys the day's basket across the full book using # equal-weight slots, so exposure is best approximated as fully invested on # active days instead of reusing ORB's position-cap heuristic. gross_proxy = 100.0 if metrics.days_with_trades > 0 and strategy.top_n > 0 else 0.0 return SplitResult( run_id=metrics.run_id, trade_count=metrics.total_trades, profit_factor=metrics.profit_factor, total_return_pct=total_return_pct, annualized_return_pct=annualized_return_pct, win_rate=metrics.win_rate, max_drawdown_pct=max_drawdown_pct, sharpe_ratio=metrics.sharpe_ratio, monthly_win_rate=None, equity_curve_r_squared=None, avg_gross_exposure_pct=round(gross_proxy, 1), avg_net_exposure_pct=round(gross_proxy, 1), days_in_market_pct=days_in_market_pct, ) def _wfv_score_payload( summary: WalkForwardSummary, holdout_metrics: IntradayMetrics, ) -> dict[str, float | None]: wfqs_score, _ = compute_wfqs_v2(summary) mean_test_return = summary.test_aggregate.mean_return_pct positive_fold_rate = summary.test_aggregate.positive_fold_rate_pct worst_fold_return = summary.test_aggregate.worst_return_pct holdout_return = ( (holdout_metrics.total_return_pct or 0.0) * 100.0 if holdout_metrics.total_return_pct is not None else None ) holdout_sharpe = holdout_metrics.sharpe_ratio holdout_loss_containment = holdout_metrics.loss_containment_score holdout_avg_loss_day = ( (holdout_metrics.avg_loss_day_pct or 0.0) * 100.0 if holdout_metrics.avg_loss_day_pct is not None else None ) holdout_tail_loss = ( (holdout_metrics.tail_loss_20_pct or 0.0) * 100.0 if holdout_metrics.tail_loss_20_pct is not None else None ) # Conservative ranking: prefer stable WFV first, then holdout confirmation. selection_score = ( (wfqs_score or 0.0) * 0.55 + (mean_test_return or 0.0) * 1.80 + (positive_fold_rate or 0.0) * 0.18 + max(worst_fold_return or 0.0, -25.0) * 0.35 + (holdout_return or 0.0) * 1.25 + (holdout_sharpe or 0.0) * 3.0 + (holdout_loss_containment or 0.0) * 0.12 ) return { "selection_score": round(selection_score, 2), "wfqs_v2": None if wfqs_score is None else round(wfqs_score, 2), "mean_test_return_pct": None if mean_test_return is None else round(mean_test_return, 2), "positive_fold_rate_pct": None if positive_fold_rate is None else round(positive_fold_rate, 1), "worst_fold_return_pct": None if worst_fold_return is None else round(worst_fold_return, 2), "holdout_return_pct": None if holdout_return is None else round(holdout_return, 2), "holdout_sharpe": None if holdout_sharpe is None else round(holdout_sharpe, 2), "holdout_avg_loss_day_pct": None if holdout_avg_loss_day is None else round(holdout_avg_loss_day, 2), "holdout_tail_loss_20_pct": None if holdout_tail_loss is None else round(holdout_tail_loss, 2), "holdout_loss_containment_score": ( None if holdout_loss_containment is None else round(holdout_loss_containment, 2) ), } def group_trading_days_by_quarter(trading_days: list[str]) -> list[tuple[str, list[str]]]: """Group prepared trading days into calendar quarters preserving order.""" grouped: dict[str, list[str]] = {} labels: list[str] = [] for day in trading_days: day_obj = date.fromisoformat(day) quarter = ((day_obj.month - 1) // 3) + 1 label = f"{day_obj.year}Q{quarter}" if label not in grouped: grouped[label] = [] labels.append(label) grouped[label].append(day) return [(label, grouped[label]) for label in labels] def _quarterly_score_payload( wfv_score: dict[str, float | None], quarter_metrics: list[tuple[str, IntradayMetrics]], ) -> tuple[dict[str, float | None], list[dict[str, Any]]]: """Blend walk-forward quality with 2025 quarterly stability.""" quarter_rows: list[dict[str, Any]] = [] quarter_returns: list[float] = [] quarter_sharpes: list[float] = [] quarter_drawdowns: list[float] = [] quarter_loss_scores: list[float] = [] quarter_avg_loss_days: list[float] = [] quarter_tail_losses: list[float] = [] for label, metrics in quarter_metrics: return_pct = ( None if metrics.total_return_pct is None else round(metrics.total_return_pct * 100.0, 2) ) sharpe = None if metrics.sharpe_ratio is None else round(metrics.sharpe_ratio, 2) drawdown_pct = ( None if metrics.max_drawdown_pct is None else round(abs(metrics.max_drawdown_pct) * 100.0, 2) ) avg_loss_day_pct = ( None if metrics.avg_loss_day_pct is None else round(metrics.avg_loss_day_pct * 100.0, 2) ) tail_loss_20_pct = ( None if metrics.tail_loss_20_pct is None else round(metrics.tail_loss_20_pct * 100.0, 2) ) loss_containment_score = ( None if metrics.loss_containment_score is None else round(metrics.loss_containment_score, 2) ) quarter_rows.append( { "quarter": label, "total_return_pct": return_pct, "sharpe_ratio": sharpe, "max_drawdown_pct": drawdown_pct, "avg_loss_day_pct": avg_loss_day_pct, "tail_loss_20_pct": tail_loss_20_pct, "loss_containment_score": loss_containment_score, "trades": metrics.total_trades, } ) if return_pct is not None: quarter_returns.append(return_pct) if sharpe is not None: quarter_sharpes.append(sharpe) if drawdown_pct is not None: quarter_drawdowns.append(drawdown_pct) if loss_containment_score is not None: quarter_loss_scores.append(loss_containment_score) if avg_loss_day_pct is not None: quarter_avg_loss_days.append(avg_loss_day_pct) if tail_loss_20_pct is not None: quarter_tail_losses.append(tail_loss_20_pct) mean_return = statistics.mean(quarter_returns) if quarter_returns else None positive_rate = ( sum(1 for value in quarter_returns if value > 0) / len(quarter_returns) * 100.0 if quarter_returns else None ) worst_return = min(quarter_returns) if quarter_returns else None return_stdev = statistics.pstdev(quarter_returns) if len(quarter_returns) > 1 else 0.0 mean_sharpe = statistics.mean(quarter_sharpes) if quarter_sharpes else None mean_drawdown = statistics.mean(quarter_drawdowns) if quarter_drawdowns else None mean_loss_containment = statistics.mean(quarter_loss_scores) if quarter_loss_scores else None mean_avg_loss_day = statistics.mean(quarter_avg_loss_days) if quarter_avg_loss_days else None worst_tail_loss = min(quarter_tail_losses) if quarter_tail_losses else None quarterly_selection_score = ( (wfv_score.get("selection_score") or 0.0) * 0.35 + (mean_return or 0.0) * 2.60 + (positive_rate or 0.0) * 0.45 + max(worst_return or 0.0, -25.0) * 1.60 - return_stdev * 1.10 + (wfv_score.get("holdout_return_pct") or 0.0) * 0.45 + (wfv_score.get("holdout_sharpe") or 0.0) * 1.80 + (mean_loss_containment or 0.0) * 0.12 ) score = dict(wfv_score) score.update( { "quarter_mean_return_pct": None if mean_return is None else round(mean_return, 2), "quarter_positive_rate_pct": None if positive_rate is None else round(positive_rate, 1), "quarter_worst_return_pct": None if worst_return is None else round(worst_return, 2), "quarter_return_stdev_pct": None if mean_return is None else round(return_stdev, 2), "quarter_mean_sharpe": None if mean_sharpe is None else round(mean_sharpe, 2), "quarter_mean_max_drawdown_pct": ( None if mean_drawdown is None else round(mean_drawdown, 2) ), "quarter_mean_avg_loss_day_pct": ( None if mean_avg_loss_day is None else round(mean_avg_loss_day, 2) ), "quarter_worst_tail_loss_20_pct": ( None if worst_tail_loss is None else round(worst_tail_loss, 2) ), "quarter_mean_loss_containment_score": ( None if mean_loss_containment is None else round(mean_loss_containment, 2) ), "quarterly_selection_score": round(quarterly_selection_score, 2), } ) return score, quarter_rows async def build_momentum_research_context( config: IntradayConfig, start_date: str, end_date: str, client: OracleClient, *, print_progress: bool = False, daily_concurrency: int = 12, intraday_concurrency: int = 8, ) -> MomentumResearchContext: """Prepare one full momentum research context for repeated parameter evaluation.""" if config.strategy_mode != "momentum": raise ValueError("Momentum research context requires strategy_mode='momentum'") snapshot_store = ( MomentumResearchSnapshotStore(Path(config.cache.dir).with_name("momentum_research")) if config.cache.enabled else None ) cache = IntradayCache(config.cache.dir) if config.cache.enabled else None daily_cache = ( DailyBarCache(str(Path(config.cache.dir).with_name("daily"))) if config.cache.enabled else None ) event_cache = ( FilingEventCache(str(Path(config.cache.dir).with_name("momentum_catalyst"))) if config.cache.enabled else None ) attention_cache = ( AttentionEventCache(str(Path(config.cache.dir).with_name("momentum_attention"))) if config.cache.enabled else None ) tickers = await resolve_universe(config.universe, client) trading_days = await get_trading_days(client, start_date, end_date, lookback=0) if not trading_days: raise ValueError(f"No trading days resolved for {start_date} → {end_date}") snapshot_key = None if snapshot_store is not None: snapshot_key = snapshot_store.build_key( config, start_date=start_date, end_date=end_date, tickers=tickers, trading_days=trading_days, ) snapshot = snapshot_store.load(snapshot_key) if snapshot is not None: if print_progress: print( " Momentum research snapshot hit: " f"{len(snapshot['tickers'])} tickers, {len(snapshot['trading_days'])} days" ) return MomentumResearchContext( config=config, tickers=list(snapshot["tickers"]), ticker_sectors=await _load_ticker_sectors_with_oracle( list(snapshot["tickers"]), client, ), trading_days=list(snapshot["trading_days"]), daily_bars=dict(snapshot["daily_bars"]), all_intraday=dict(snapshot["all_intraday"]), daily_enrichment=snapshot["daily_enrichment"], vix_by_day=snapshot["vix_by_day"], candidates=dict(snapshot["candidates"]), candidate_pairs=int(snapshot["candidate_pairs"]), research_snapshot_key=snapshot_key, ) if _momentum_strategy_uses_daily_enrichment(config.strategy): daily_fetch_start = (date.fromisoformat(trading_days[0]) - timedelta(days=90)).isoformat() else: daily_fetch_start = trading_days[0] def _daily_progress(done: int, total: int) -> None: if not print_progress: return sys.stdout.write(f"\r Daily: {_make_progress_bar(done, total)}") sys.stdout.flush() daily_bars = await fetch_daily_bars_bulk( tickers, daily_fetch_start, trading_days[-1], client, cache=daily_cache, intraday_cache_fallback=cache, prefer_intraday_fallback=True, skip_oracle_when_unhealthy=True, concurrency=daily_concurrency, progress_callback=_daily_progress if print_progress else None, ) if print_progress: print(f"\n Daily loaded: {len(daily_bars)}/{len(tickers)}") daily_enrichment = None if _momentum_strategy_uses_daily_enrichment(config.strategy): if print_progress: print(" Computing momentum enrichment...") daily_enrichment = _momentum_enrichment_for_days(daily_bars, trading_days) vix_by_day = None if _momentum_strategy_uses_vix(config.strategy): if print_progress: print(" Fetching VIX regime series...") vix_by_day = await _fetch_vix_by_day(client, trading_days) if ( daily_enrichment is not None and (_momentum_strategy_uses_catalyst(config.strategy) or _momentum_strategy_uses_attention(config.strategy)) ): preliminary_candidates = _momentum_intraday_seed_candidates( daily_bars, trading_days, daily_enrichment, config.strategy, default_threshold=config.backtest.pre_screen_threshold, use_signal_features=False, ) if _momentum_strategy_uses_catalyst(config.strategy): event_tickers = _momentum_candidate_event_tickers(preliminary_candidates) if print_progress: print(f" Fetching momentum catalysts for {len(event_tickers)} tickers...") event_features = await fetch_filing_event_features_bulk( event_tickers, trading_days[0], trading_days[-1], client, cache=event_cache, concurrency=8, ) _merge_momentum_event_features(daily_enrichment, event_features) if _momentum_strategy_uses_attention(config.strategy): attention_pairs = _momentum_candidate_event_pairs( preliminary_candidates, daily_enrichment, config.strategy, ) if print_progress: print(f" Fetching momentum attention for {len(attention_pairs)} ticker-days...") attention_features = await fetch_attention_features_bulk( attention_pairs, client, cache=attention_cache, concurrency=8, ) _merge_momentum_attention_features(daily_enrichment, attention_features) def _intraday_progress(done: int, total: int, hits: int, calls: int) -> None: if not print_progress: return if completed := (done == 0 and calls == 0 and total > 0): _ = completed sys.stdout.write("\n") sys.stdout.flush() sys.stdout.write( f"\r Intraday: {_make_progress_bar(done, total)} cache:{hits} api:{calls}" ) sys.stdout.flush() seed_candidates = _momentum_intraday_seed_candidates( daily_bars, trading_days, daily_enrichment or {}, config.strategy, default_threshold=config.backtest.pre_screen_threshold, use_signal_features=True, ) seed_candidates = _augment_momentum_seed_candidates_with_liquid_overlay( seed_candidates, daily_bars, trading_days, daily_enrichment or {}, config.strategy, ) seed_pairs = sum(len(v) for v in seed_candidates.values()) if print_progress: label = "seed shortlist" if _momentum_uses_historical_intraday_first(config.strategy) else "pre-screened" print(f" {label.capitalize()}: {seed_pairs} ticker-day pairs across {len(seed_candidates)} days") all_intraday = await fetch_intraday_bulk( seed_candidates, client, cache, skip_oracle_when_unhealthy=True, concurrency=intraday_concurrency, progress_callback=_intraday_progress if print_progress else None, ) if print_progress: print(f"\n Intraday loaded: {len(all_intraday)} days") if _momentum_uses_historical_intraday_first(config.strategy): candidates = momentum_intraday_first_candidates( all_intraday, trading_days, config.strategy, daily_enrichment=daily_enrichment, max_per_day=config.strategy.candidate_final_max_per_day, ) else: candidates = momentum_pre_screen_candidates( daily_bars, trading_days, daily_enrichment or {}, threshold=config.backtest.pre_screen_threshold, max_per_day=config.strategy.candidate_final_max_per_day, strategy=config.strategy, ) candidate_pairs = sum(len(v) for v in candidates.values()) if print_progress: print(f" Final candidates: {candidate_pairs} ticker-day pairs across {len(candidates)} days") context = MomentumResearchContext( config=config, tickers=tickers, ticker_sectors=await _load_ticker_sectors_with_oracle(tickers, client), trading_days=trading_days, daily_bars=daily_bars, all_intraday=all_intraday, daily_enrichment=daily_enrichment, vix_by_day=vix_by_day, candidates=candidates, candidate_pairs=candidate_pairs, research_snapshot_key=snapshot_key, ) if snapshot_store is not None and snapshot_key is not None: snapshot_path = snapshot_store.save( snapshot_key, { "tickers": tickers, "trading_days": trading_days, "daily_bars": daily_bars, "all_intraday": all_intraday, "daily_enrichment": daily_enrichment, "vix_by_day": vix_by_day, "candidates": candidates, "candidate_pairs": candidate_pairs, }, ) if print_progress: print(f" Momentum research snapshot saved: {snapshot_path}") return context def build_momentum_strategy( base_config: IntradayConfig, overrides: dict[str, Any] | None = None, ) -> StrategyParams: if not overrides: return base_config.strategy base = base_config.strategy.model_dump(mode="json") base.update(overrides) return StrategyParams.model_validate(base) def _normalize_momentum_research_strategy(strategy: StrategyParams) -> StrategyParams: """Research runs use daily-reset simple sizing by default. This keeps 2025 WFV / quarter comparisons path-independent and prevents late-period equity changes from dominating candidate selection. """ return strategy.model_copy( update={ "compound_returns": False, "daily_budget_reset": True, } ) def simulate_momentum_params( context: MomentumResearchContext, strategy: StrategyParams, trading_days: list[str], *, run_id: str = "", ) -> tuple[list[Any], IntradayMetrics]: """Evaluate one momentum parameter set on a subset of prepared trading days.""" if _momentum_uses_historical_intraday_first(strategy): strategy_candidates = momentum_intraday_first_candidates( { day: context.all_intraday.get(day, {}) for day in trading_days }, trading_days, strategy, daily_enrichment=context.daily_enrichment, max_per_day=strategy.candidate_final_max_per_day, ) else: strategy_candidates = momentum_pre_screen_candidates( context.daily_bars, trading_days, context.daily_enrichment or {}, threshold=context.config.backtest.pre_screen_threshold, max_per_day=strategy.candidate_final_max_per_day, strategy=strategy, ) subset_intraday = { day: { ticker: context.all_intraday.get(day, {}).get(ticker) for ticker in strategy_candidates.get(day, []) if context.all_intraday.get(day, {}).get(ticker) } for day in trading_days if strategy_candidates.get(day) } run_config = context.config.model_copy(update={"strategy": strategy}) day_results = run_simulation( subset_intraday, trading_days, strategy, daily_enrichment=context.daily_enrichment, vix_by_day=context.vix_by_day, ticker_sectors=context.ticker_sectors, ) metrics = compute_metrics(day_results, run_config, run_id=run_id or str(uuid.uuid4())[:8]) return day_results, metrics def evaluate_momentum_wfv_candidate( context_2025: MomentumResearchContext, strategy: StrategyParams, *, train_days: int, test_days: int, step_days: int, holdout_context: MomentumResearchContext | None = None, ) -> dict[str, Any]: """Evaluate one strategy over rolling 2025 windows and optional holdout.""" strategy = _normalize_momentum_research_strategy(strategy) windows = generate_walk_forward_windows( context_2025.trading_days, train_days=train_days, test_days=test_days, step_days=step_days, ) folds: list[dict[str, Any]] = [] for idx, (train_window, test_window) in enumerate(windows, start=1): _, train_metrics = simulate_momentum_params( context_2025, strategy, train_window, run_id=f"mwf_tr_{idx:02d}", ) _, test_metrics = simulate_momentum_params( context_2025, strategy, test_window, run_id=f"mwf_te_{idx:02d}", ) folds.append( { "train_start": train_window[0], "train_end": train_window[-1], "test_start": test_window[0], "test_end": test_window[-1], "train_result": intraday_metrics_to_momentum_split_result(train_metrics, strategy), "test_result": intraday_metrics_to_momentum_split_result(test_metrics, strategy), } ) summary = build_walk_forward_summary( folds, train_days=train_days, test_days=test_days, step_days=step_days, ) holdout_metrics = None holdout_result = None if holdout_context is not None: _, holdout_metrics = simulate_momentum_params( holdout_context, strategy, holdout_context.trading_days, run_id="mwf_holdout", ) holdout_result = intraday_metrics_to_momentum_split_result(holdout_metrics, strategy) score = _wfv_score_payload( summary, holdout_metrics or IntradayMetrics(run_id="holdout"), ) return { "strategy": strategy.model_dump(mode="json"), "walk_forward_summary": summary.model_dump(mode="json"), "holdout_metrics": None if holdout_metrics is None else holdout_metrics.model_dump(mode="json"), "holdout_result": None if holdout_result is None else holdout_result.model_dump(mode="json"), "score": score, } def evaluate_momentum_quarterly_candidate( context_2025: MomentumResearchContext, strategy: StrategyParams, *, train_days: int, test_days: int, step_days: int, holdout_context: MomentumResearchContext | None = None, ) -> dict[str, Any]: """Evaluate one candidate using WFV plus 2025 quarter-by-quarter robustness.""" strategy = _normalize_momentum_research_strategy(strategy) payload = evaluate_momentum_wfv_candidate( context_2025, strategy, train_days=train_days, test_days=test_days, step_days=step_days, holdout_context=holdout_context, ) quarter_metrics: list[tuple[str, IntradayMetrics]] = [] for label, quarter_days in group_trading_days_by_quarter(context_2025.trading_days): _, metrics = simulate_momentum_params( context_2025, strategy, quarter_days, run_id=f"mq_{label.lower()}", ) quarter_metrics.append((label, metrics)) score, quarter_rows = _quarterly_score_payload( payload["score"], quarter_metrics, ) payload["score"] = score payload["quarter_metrics"] = quarter_rows return payload def summarize_fold_returns(summary: WalkForwardSummary) -> dict[str, float | None]: test_returns = [ fold.test_metrics.total_return_pct for fold in summary.folds if fold.test_metrics.total_return_pct is not None ] test_sharpes = [ fold.test_metrics.sharpe_ratio for fold in summary.folds if fold.test_metrics.sharpe_ratio is not None ] return { "mean_test_return_pct": None if not test_returns else round(statistics.mean(test_returns), 2), "median_test_return_pct": None if not test_returns else round(statistics.median(test_returns), 2), "mean_test_sharpe": None if not test_sharpes else round(statistics.mean(test_sharpes), 2), "positive_fold_rate_pct": summary.test_aggregate.positive_fold_rate_pct, "worst_fold_return_pct": summary.test_aggregate.worst_return_pct, }