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
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,
|
|
}
|