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.

850 lines
32 KiB
Python

"""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_candidate_stage_catalyst,
_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 = 7
@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):
if _momentum_strategy_uses_candidate_stage_catalyst(config.strategy):
event_tickers = sorted(daily_bars.keys())
else:
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")
ticker_sectors = await _load_ticker_sectors_with_oracle(tickers, client)
if _momentum_uses_historical_intraday_first(config.strategy):
candidates = momentum_intraday_first_candidates(
all_intraday,
trading_days,
config.strategy,
daily_enrichment=daily_enrichment,
ticker_sectors=ticker_sectors,
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=ticker_sectors,
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,
ticker_sectors=context.ticker_sectors,
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,
}