diff --git a/apps/intraday_bt/__init__.py b/apps/intraday_bt/__init__.py new file mode 100644 index 0000000..ba0e2e6 --- /dev/null +++ b/apps/intraday_bt/__init__.py @@ -0,0 +1 @@ +"""Morning Momentum Intraday Backtester app.""" diff --git a/apps/intraday_bt/composite.py b/apps/intraday_bt/composite.py new file mode 100644 index 0000000..725bec8 --- /dev/null +++ b/apps/intraday_bt/composite.py @@ -0,0 +1,323 @@ +"""Multi-sleeve composite intraday backtester. + +Runs N independent strategy sleeves, each with proportional capital allocation, +then combines results into a unified portfolio report. Directly ports the PEAD +composed_gld multi-sleeve approach to intraday ORB strategies. + +Usage: + python -m apps.intraday_bt.composite \\ + --sleeve configs/intraday/strategies/orb_gainers_v16.yaml:0.60 \\ + --sleeve configs/intraday/strategies/orb_compression.yaml:0.25 \\ + --sleeve configs/intraday/strategies/orb_stocks_in_play_v17a.yaml:0.15 \\ + --total-capital 10000 \\ + --days 200 +""" +from __future__ import annotations + +import argparse +import asyncio +import json +import math +import statistics +import uuid +from collections import defaultdict +from datetime import datetime +from pathlib import Path + +from apps.intraday_bt.run import load_config, run as run_sleeve +from libs.intraday.domain import BacktestParams, IntradayConfig, ORBStrategyParams + + +# ── Merging ──────────────────────────────────────────────────────────────── + + +def _merge_day_results( + sleeve_results: list[tuple[list, str, float]], +) -> tuple[dict[str, float], dict[str, list], list[str]]: + """Combine day-level PnL and trades from all sleeves. + + Returns: + daily_pnl: {date: combined dollar PnL} + daily_trades: {date: list of all sleeve trades} + sorted dates + """ + combined_pnl: dict[str, float] = defaultdict(float) + combined_trades: dict[str, list] = defaultdict(list) + all_dates: set[str] = set() + + for day_results, _name, _capital in sleeve_results: + for dr in day_results: + all_dates.add(dr.date) + combined_pnl[dr.date] += dr.daily_pnl + combined_trades[dr.date].extend(dr.trades) + + return dict(combined_pnl), dict(combined_trades), sorted(all_dates) + + +# ── Metrics ──────────────────────────────────────────────────────────────── + + +def _compute_composite_metrics( + daily_pnl: dict[str, float], + daily_trades: dict[str, list], + dates: list[str], + total_capital: float, + run_id: str = "", +) -> dict: + """Compute portfolio-level metrics from merged sleeve results.""" + if not dates: + return {} + + # Equity curve (each sleeve already compounds internally) + equity = total_capital + equity_curve: list[float] = [equity] + daily_returns: list[float] = [] + + for date in dates: + pnl = daily_pnl.get(date, 0.0) + daily_ret = pnl / equity if equity > 0 else 0.0 + daily_returns.append(daily_ret) + equity += pnl + equity_curve.append(equity) + + final_equity = equity + total_return = (final_equity - total_capital) / total_capital + + # Annualized return (252 trading days per year) + n_days = len(dates) + annualized = (1 + total_return) ** (252 / n_days) - 1 if n_days > 0 else 0.0 + + # Sharpe (annualized daily Sharpe) + if len(daily_returns) > 1: + mean_ret = statistics.mean(daily_returns) + std_ret = statistics.stdev(daily_returns) + sharpe = (mean_ret / std_ret * math.sqrt(252)) if std_ret > 0 else 0.0 + else: + sharpe = 0.0 + + # Sortino (downside deviation) + downside = [r for r in daily_returns if r < 0] + if downside: + downside_std = math.sqrt(sum(r ** 2 for r in downside) / len(daily_returns)) + mean_ret_s = statistics.mean(daily_returns) + sortino = (mean_ret_s / downside_std * math.sqrt(252)) if downside_std > 0 else 0.0 + else: + sortino = float("inf") + + # Max drawdown + peak = total_capital + max_dd = 0.0 + for eq in equity_curve: + if eq > peak: + peak = eq + dd = (eq - peak) / peak + if dd < max_dd: + max_dd = dd + + # Calmar + calmar = (annualized / abs(max_dd)) if max_dd != 0 else float("inf") + + # Trade stats (combined) + all_trades = [t for date in dates for t in daily_trades.get(date, [])] + wins = [t for t in all_trades if t.pnl > 0] + losses = [t for t in all_trades if t.pnl <= 0] + win_rate = len(wins) / len(all_trades) if all_trades else 0.0 + gross_profit = sum(t.pnl for t in wins) + gross_loss = abs(sum(t.pnl for t in losses)) + profit_factor = gross_profit / gross_loss if gross_loss > 0 else float("inf") + days_with_trades = sum(1 for date in dates if daily_trades.get(date)) + + return { + "run_id": run_id or str(uuid.uuid4())[:8], + "start_date": dates[0], + "end_date": dates[-1], + "trading_days": n_days, + "days_with_trades": days_with_trades, + "total_return_pct": total_return, + "annualized_return_pct": annualized, + "sharpe_ratio": sharpe, + "sortino_ratio": sortino, + "calmar_ratio": calmar, + "max_drawdown_pct": max_dd, + "total_trades": len(all_trades), + "win_rate": win_rate, + "profit_factor": profit_factor, + "initial_capital": total_capital, + "final_equity": final_equity, + } + + +# ── Sleeve runner ────────────────────────────────────────────────────────── + + +def _build_sleeve_config( + base_config: IntradayConfig, + capital: float, + days: int | None, +) -> IntradayConfig: + """Clone config with adjusted initial_capital and optional lookback override.""" + orb = base_config.orb_strategy + if orb is None: + raise ValueError("Composite only supports orb strategy_mode sleeves.") + + orb_dict = orb.model_dump() + orb_dict["initial_capital"] = capital + new_orb = ORBStrategyParams(**orb_dict) + + bt_dict = base_config.backtest.model_dump() + if days is not None: + bt_dict["lookback_trading_days"] = days + + return IntradayConfig( + strategy_mode=base_config.strategy_mode, + strategy=base_config.strategy, + orb_strategy=new_orb, + universe=base_config.universe, + backtest=BacktestParams(**bt_dict), + cache=base_config.cache, + output=base_config.output, + ) + + +# ── Reporting ────────────────────────────────────────────────────────────── + + +def _format_composite_summary( + metrics: dict, + sleeve_summaries: list[dict], + total_capital: float, +) -> str: + lines = [] + lines.append("\n" + "=" * 60) + lines.append(" COMPOSITE PORTFOLIO RESULTS") + lines.append("=" * 60) + lines.append(f" Period: {metrics['start_date']} → {metrics['end_date']}") + lines.append(f" Capital: ${total_capital:,.0f}") + lines.append(f" Final equity: ${metrics['final_equity']:,.2f}") + lines.append("") + lines.append(f" Total return: {metrics['total_return_pct']*100:+.2f}%") + lines.append(f" Ann. return: {metrics['annualized_return_pct']*100:+.2f}%") + lines.append(f" Sharpe: {metrics['sharpe_ratio']:.3f}") + lines.append(f" Sortino: {metrics['sortino_ratio']:.3f}") + lines.append(f" Max DD: {metrics['max_drawdown_pct']*100:.2f}%") + lines.append(f" Calmar: {metrics['calmar_ratio']:.2f}") + lines.append(f" Trades: {metrics['total_trades']}") + lines.append(f" Win rate: {metrics['win_rate']*100:.1f}%") + lines.append(f" Profit factor: {metrics['profit_factor']:.3f}") + lines.append("") + lines.append(" Sleeve breakdown:") + for s in sleeve_summaries: + lines.append( + f" {s['name']:40s} {s['weight']:4.0%} | " + f"{s['return_pct']*100:+6.2f}% | Sharpe {s['sharpe']:.2f} | " + f"DD {s['max_dd']*100:.2f}%" + ) + lines.append("=" * 60) + return "\n".join(lines) + + +# ── Main ─────────────────────────────────────────────────────────────────── + + +async def run_composite( + sleeve_specs: list[tuple[str, float]], + total_capital: float = 10_000.0, + days: int | None = None, +) -> dict: + """Run all sleeves sequentially and return composite metrics.""" + # Normalize weights + total_w = sum(w for _, w in sleeve_specs) + sleeve_specs = [(p, w / total_w) for p, w in sleeve_specs] + + sleeve_results: list[tuple[list, str, float]] = [] + sleeve_summaries: list[dict] = [] + + for config_path, weight in sleeve_specs: + capital = total_capital * weight + name = Path(config_path).stem + print(f"\n{'='*60}") + print(f" Sleeve: {name} ({weight:.0%} — ${capital:,.0f})") + print("=" * 60) + + base_config = load_config(config_path) + sleeve_config = _build_sleeve_config(base_config, capital, days) + + day_results, metrics, _, _ = await run_sleeve(sleeve_config) + sleeve_results.append((day_results, name, capital)) + + sleeve_summaries.append({ + "name": name, + "weight": weight, + "return_pct": metrics.total_return_pct or 0.0, + "sharpe": metrics.sharpe_ratio or 0.0, + "max_dd": metrics.max_drawdown_pct or 0.0, + "trades": metrics.total_trades, + }) + print(f" → {name}: {(metrics.total_return_pct or 0)*100:+.2f}% | " + f"Sharpe {metrics.sharpe_ratio:.2f} | DD {(metrics.max_drawdown_pct or 0)*100:.2f}%") + + # Merge and compute + daily_pnl, daily_trades, dates = _merge_day_results(sleeve_results) + run_id = str(uuid.uuid4())[:8] + composite_metrics = _compute_composite_metrics( + daily_pnl, daily_trades, dates, total_capital, run_id=run_id + ) + + print(_format_composite_summary(composite_metrics, sleeve_summaries, total_capital)) + + # Save results + out_dir = Path("runs/intraday_orb") + out_dir.mkdir(parents=True, exist_ok=True) + ts = datetime.now().strftime("%Y%m%d_%H%M%S") + out_file = out_dir / f"composite_{ts}_{run_id}.json" + out_file.write_text(json.dumps({ + "run_id": run_id, + "generated_at": datetime.now().isoformat(), + "total_capital": total_capital, + "sleeves": sleeve_summaries, + "metrics": composite_metrics, + }, indent=2, default=str)) + print(f"\nResults saved to: {out_file}") + + return composite_metrics + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Composite multi-sleeve intraday backtester") + parser.add_argument( + "--sleeve", + dest="sleeves", + action="append", + default=[], + metavar="CONFIG:WEIGHT", + help="Sleeve config path and weight, e.g. orb_v16.yaml:0.60 (can repeat)", + ) + parser.add_argument("--total-capital", type=float, default=10_000.0, dest="total_capital") + parser.add_argument("--days", type=int, default=None) + return parser.parse_args() + + +async def main_async() -> None: + args = parse_args() + + if not args.sleeves: + print("Error: at least one --sleeve CONFIG:WEIGHT is required.") + return + + sleeve_specs: list[tuple[str, float]] = [] + for spec in args.sleeves: + if ":" not in spec: + print(f"Error: sleeve spec must be 'path:weight', got: {spec!r}") + return + path, weight_str = spec.rsplit(":", 1) + sleeve_specs.append((path, float(weight_str))) + + await run_composite(sleeve_specs, total_capital=args.total_capital, days=args.days) + + +def main() -> None: + asyncio.run(main_async()) + + +if __name__ == "__main__": + main() diff --git a/apps/intraday_bt/evaluate.py b/apps/intraday_bt/evaluate.py new file mode 100644 index 0000000..e16da13 --- /dev/null +++ b/apps/intraday_bt/evaluate.py @@ -0,0 +1,492 @@ +"""ORB strategy evaluation with IS/OOS validation and streaming intraday fetch.""" +from __future__ import annotations + +import argparse +import asyncio +import json +import statistics +import sys +import uuid +from datetime import datetime +from pathlib import Path +from typing import Any + +import yaml + +from apps.intraday_bt.oracle import make_intraday_oracle_client +from libs.common.config import get_settings +from libs.intraday.domain import ( + BacktestParams, + CacheParams, + IntradayConfig, + IntradayMetrics, + ORBStrategyParams, + OutputParams, + SweepResult, + UniverseParams, +) +from libs.oracle_client import OracleClient + +from apps.intraday_bt.orb_research import ( + build_orb_research_context, + force_simple_returns, + generate_walk_forward_windows, + simulate_orb_overrides, + split_trading_days, +) +from apps.intraday_bt.sweep import ( + SweepConfig, + apply_overrides, + generate_combinations, + load_sweep_config, +) +from apps.intraday_bt.run import get_trading_days + + +def compute_degradation(is_sharpe: float | None, oos_sharpe: float | None) -> float: + if is_sharpe is None or is_sharpe <= 0: + return 0.0 + if oos_sharpe is None: + return 0.0 + return oos_sharpe / is_sharpe + + +async def run_sweep_on_period( + sweep: SweepConfig, + context, + client: OracleClient, + trading_days: list[str], + progress_prefix: str = "", +) -> list[SweepResult]: + combos = generate_combinations(sweep) + results: list[SweepResult] = [] + + for i, overrides in enumerate(combos): + _, metrics = await simulate_orb_overrides( + context, + client, + overrides, + trading_days, + run_id=f"sw{i:04d}", + ) + results.append(SweepResult(params=overrides, metrics=metrics)) + sys.stdout.write( + f"\r {progress_prefix}[{i+1}/{len(combos)}] " + f"Sharpe={metrics.sharpe_ratio or 0:.2f} " + f"Ret={(metrics.total_return_pct or 0)*100:.0f}%" + ) + sys.stdout.flush() + print() + + results.sort( + key=lambda r: ( + r.metrics.sharpe_ratio or float("-inf"), + r.metrics.total_return_pct or -999, + ), + reverse=True, + ) + return results + + +async def run_single_config_on_period( + base_config: IntradayConfig, + overrides: dict[str, Any], + context, + client: OracleClient, + trading_days: list[str], + *, + run_id: str = "", +) -> IntradayMetrics: + _, metrics = await simulate_orb_overrides( + context, + client, + overrides, + trading_days, + run_id=run_id or str(uuid.uuid4())[:8], + ) + return metrics + + +async def _run_split_eval( + sweep: SweepConfig, + context, + client: OracleClient, + train_days: list[str], + test_days: list[str], + top_n: int, + *, + oos_sort: bool = False, +) -> dict[str, Any]: + print(f"\n ── Phase 1: Sweep on Train ({train_days[0]}→{train_days[-1]}, {len(train_days)}d) ──") + is_results = await run_sweep_on_period( + sweep, context, client, train_days, progress_prefix="IS " + ) + + print(f"\n Top {top_n} IS configs (by Sharpe):") + print(f" {'#':<3} {'Sharpe':>7} {'Return':>8} {'WR':>6} {'PF':>6} {'Trades':>7} {'DD':>7} Params") + for i, result in enumerate(is_results[:top_n]): + metrics = result.metrics + print( + f" {i+1:<3} {metrics.sharpe_ratio or 0:>7.2f} " + f"{(metrics.total_return_pct or 0)*100:>7.0f}% " + f"{(metrics.win_rate or 0)*100:>5.1f}% " + f"{metrics.profit_factor or 0:>6.2f} " + f"{metrics.total_trades or 0:>7} " + f"{abs((metrics.max_drawdown_pct or 0)*100):>6.2f}% " + f"{_format_params(result.params)}" + ) + + print(f"\n ── Phase 2: Validate on Test ({test_days[0]}→{test_days[-1]}, {len(test_days)}d) ──") + oos_results: list[tuple[SweepResult, IntradayMetrics]] = [] + for i, is_result in enumerate(is_results[:top_n]): + oos_metrics = await run_single_config_on_period( + sweep.base_config, + is_result.params, + context, + client, + test_days, + run_id=f"oos{i:02d}", + ) + oos_results.append((is_result, oos_metrics)) + sys.stdout.write( + f"\r OOS [{i+1}/{top_n}] " + f"Sharpe={oos_metrics.sharpe_ratio or 0:.2f} " + f"Ret={(oos_metrics.total_return_pct or 0)*100:.0f}%" + ) + sys.stdout.flush() + print() + + print(f"\n {'='*80}") + print(" IS/OOS Comparison (Target: OOS Sharpe ≥ 60% of IS Sharpe)") + print(f" {'='*80}") + print( + f" {'#':<3} {'IS Sharpe':>10} {'OOS Sharpe':>11} {'Retain':>8} " + f"{'IS Ret':>8} {'OOS Ret':>8} {'OOS DD':>7} {'Verdict':>8} Params" + ) + + report_rows: list[dict[str, Any]] = [] + for i, (is_result, oos_metrics) in enumerate(oos_results): + is_sharpe = is_result.metrics.sharpe_ratio or 0.0 + oos_sharpe = oos_metrics.sharpe_ratio or 0.0 + retain = compute_degradation(is_sharpe, oos_sharpe) + verdict = "PASS" if retain >= 0.60 else "WARN" if retain >= 0.40 else "FAIL" + print( + f" {i+1:<3} {is_sharpe:>10.2f} {oos_sharpe:>11.2f} {retain*100:>7.0f}% " + f"{(is_result.metrics.total_return_pct or 0)*100:>7.0f}% " + f"{(oos_metrics.total_return_pct or 0)*100:>7.0f}% " + f"{abs((oos_metrics.max_drawdown_pct or 0)*100):>6.2f}% " + f"{verdict:>6} {_format_params(is_result.params)}" + ) + report_rows.append({ + "rank": i + 1, + "params": is_result.params, + "is_sharpe": is_sharpe, + "oos_sharpe": oos_sharpe, + "retention_pct": round(retain * 100, 1), + "is_return_pct": round((is_result.metrics.total_return_pct or 0) * 100, 1), + "oos_return_pct": round((oos_metrics.total_return_pct or 0) * 100, 1), + "oos_max_dd_pct": round(abs((oos_metrics.max_drawdown_pct or 0) * 100), 2), + "oos_trades": oos_metrics.total_trades, + "oos_win_rate": round((oos_metrics.win_rate or 0) * 100, 1), + "verdict": verdict, + }) + + if oos_sort: + report_rows.sort(key=lambda row: row["oos_sharpe"], reverse=True) + for i, row in enumerate(report_rows, start=1): + row["rank"] = i + print("\n [Re-ranked by OOS Sharpe]") + + retentions = [row["retention_pct"] for row in report_rows] + avg_retain = statistics.mean(retentions) if retentions else 0.0 + median_retain = statistics.median(retentions) if retentions else 0.0 + pass_count = sum(1 for row in report_rows if row["verdict"] == "PASS") + + print("\n Summary:") + print(f" Avg retention: {avg_retain:.0f}%") + print(f" Median retention: {median_retain:.0f}%") + print(f" Passed (≥60%): {pass_count}/{len(report_rows)}") + + if avg_retain >= 60: + print("\n Overall: ROBUST — strategy generalizes well to unseen data") + elif avg_retain >= 40: + print("\n Overall: FRAGILE — moderate overfitting detected, caution advised") + else: + print("\n Overall: OVERFIT — strategy does not generalize, parameter re-tuning needed") + + return { + "mode": "split", + "train_period": f"{train_days[0]} → {train_days[-1]}", + "train_days": len(train_days), + "test_period": f"{test_days[0]} → {test_days[-1]}", + "test_days": len(test_days), + "total_sweep_combos": sweep.total_combinations, + "top_n": len(report_rows), + "results": report_rows, + "avg_retention_pct": round(avg_retain, 1), + "median_retention_pct": round(median_retain, 1), + "pass_count": pass_count, + } + + +async def _run_walk_forward_eval( + sweep: SweepConfig, + context, + client: OracleClient, + windows: list[tuple[list[str], list[str]]], +) -> dict[str, Any]: + wf_results: list[dict[str, Any]] = [] + + for window_idx, (train_days, test_days) in enumerate(windows, start=1): + print( + f"\n ── Window {window_idx}/{len(windows)}: " + f"Train {train_days[0]}→{train_days[-1]} | " + f"Test {test_days[0]}→{test_days[-1]} ──" + ) + is_results = await run_sweep_on_period( + sweep, context, client, train_days, progress_prefix=f"W{window_idx} IS " + ) + if not is_results: + continue + + best_is = is_results[0] + is_sharpe = best_is.metrics.sharpe_ratio or 0.0 + oos_metrics = await run_single_config_on_period( + sweep.base_config, + best_is.params, + context, + client, + test_days, + run_id=f"wf{window_idx:02d}", + ) + oos_sharpe = oos_metrics.sharpe_ratio or 0.0 + retain = compute_degradation(is_sharpe, oos_sharpe) + print( + f" Best: IS Sharpe={is_sharpe:.2f} → OOS Sharpe={oos_sharpe:.2f} " + f"(retain {retain*100:.0f}%) {_format_params(best_is.params)}" + ) + wf_results.append({ + "window": window_idx, + "train_period": f"{train_days[0]} → {train_days[-1]}", + "test_period": f"{test_days[0]} → {test_days[-1]}", + "best_params": best_is.params, + "is_sharpe": is_sharpe, + "oos_sharpe": oos_sharpe, + "retention_pct": round(retain * 100, 1), + "oos_return_pct": round((oos_metrics.total_return_pct or 0) * 100, 1), + "oos_trades": oos_metrics.total_trades, + "oos_win_rate": round((oos_metrics.win_rate or 0) * 100, 1), + }) + + retentions = [row["retention_pct"] for row in wf_results] + oos_sharpes = [row["oos_sharpe"] for row in wf_results] + avg_retain = statistics.mean(retentions) if retentions else 0.0 + median_oos = statistics.median(oos_sharpes) if oos_sharpes else 0.0 + positive_oos = sum(1 for sharpe in oos_sharpes if sharpe > 0) + + print(f"\n {'='*70}") + print(f" Walk-Forward Summary ({len(wf_results)} windows)") + print(f" {'='*70}") + print(f" {'Window':<8} {'Train':>26} {'Test':>26} {'IS Sh':>6} {'OOS Sh':>7} {'Retain':>7}") + for row in wf_results: + print( + f" {row['window']:<8} {row['train_period']:>26} {row['test_period']:>26} " + f"{row['is_sharpe']:>6.2f} {row['oos_sharpe']:>7.2f} {row['retention_pct']:>6.0f}%" + ) + + print(f"\n Avg OOS Sharpe: {statistics.mean(oos_sharpes) if oos_sharpes else 0:.2f}") + print(f" Median OOS Sharpe: {median_oos:.2f}") + print(f" Avg retention: {avg_retain:.0f}%") + print(f" Positive OOS: {positive_oos}/{len(wf_results)}") + + if wf_results: + serialized = [json.dumps(row["best_params"], sort_keys=True) for row in wf_results] + unique_params = len(set(serialized)) + print(f" Unique best configs: {unique_params}/{len(wf_results)}") + if unique_params <= len(wf_results) * 0.5: + print(" → Parameter stability: GOOD (same params often selected)") + else: + print(" → Parameter stability: POOR (different params per window)") + + return { + "mode": "walk_forward", + "n_windows": len(wf_results), + "windows": wf_results, + "avg_oos_sharpe": round(statistics.mean(oos_sharpes), 2) if oos_sharpes else 0, + "median_oos_sharpe": round(median_oos, 2), + "avg_retention_pct": round(avg_retain, 1), + "positive_oos_count": positive_oos, + } + + +async def evaluate( + config: IntradayConfig, + sweep_path: str, + split_date: str | None = None, + train_ratio: float | None = None, + top_n: int = 5, + walk_forward: bool = False, + wf_train_days: int = 252, + wf_test_days: int = 63, + oos_sort: bool = False, +) -> dict[str, Any]: + settings = get_settings() + + config = force_simple_returns(config) + sweep = load_sweep_config(sweep_path, config) + print(f"\n{'='*65}") + print(" ORB Strategy Evaluation — IS/OOS Validation") + print(f"{'='*65}") + print(f"\n Sweep: {sweep.total_combinations} parameter combinations") + print(f" Sweep params: {list(sweep.sweep_params.keys())}") + + async with make_intraday_oracle_client(settings) as client: + print("\n[1/4] Building shared ORB research context...") + resolved_days = await get_trading_days( + client, + config.backtest.start_date, + config.backtest.end_date, + config.backtest.lookback_trading_days, + ) + if not resolved_days: + raise ValueError("No trading days resolved for evaluation window") + start_date = resolved_days[0] + end_date = resolved_days[-1] + + context = await build_orb_research_context( + config, + start_date, + end_date, + client, + print_progress=True, + ) + print( + f" Shared context: {len(context.trading_days)} days, " + f"{context.total_pairs} ticker-day pairs" + ) + + if walk_forward: + windows = generate_walk_forward_windows( + context.trading_days, + train_days=wf_train_days, + test_days=wf_test_days, + ) + print( + f"\n[2/4] Walk-forward: {len(windows)} windows " + f"(train={wf_train_days}d, test={wf_test_days}d)" + ) + print("\n[3/4] Running streaming evaluation...") + report = await _run_walk_forward_eval(sweep, context, client, windows) + else: + train_days, test_days = split_trading_days( + context.trading_days, + split_date=split_date, + train_ratio=train_ratio, + ) + print("\n[2/4] Train/Test split:") + print(f" Train (IS): {train_days[0]} → {train_days[-1]} ({len(train_days)} days)") + print(f" Test (OOS): {test_days[0]} → {test_days[-1]} ({len(test_days)} days)") + print("\n[3/4] Running streaming evaluation...") + report = await _run_split_eval( + sweep, + context, + client, + train_days, + test_days, + top_n, + oos_sort=oos_sort, + ) + + return report + + +def _format_params(params: dict[str, Any]) -> str: + parts = [] + for key, value in sorted(params.items()): + short_key = key.replace("_multiplier", "").replace("_pct", "%").replace("_at_r", "R") + if isinstance(value, float): + parts.append(f"{short_key}={value:.2g}") + elif value is None: + parts.append(f"{short_key}=off") + else: + parts.append(f"{short_key}={value}") + return " ".join(parts) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="ORB strategy IS/OOS evaluation", + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + parser.add_argument("--config", required=True, help="Base strategy config YAML") + parser.add_argument("--sweep", required=True, help="Sweep parameter grid YAML") + parser.add_argument("--start", help="Start date (YYYY-MM-DD)") + parser.add_argument("--end", help="End date (YYYY-MM-DD)") + parser.add_argument("--days", type=int, help="Lookback trading days (alternative to --start)") + parser.add_argument("--split-date", help="Train/test split date (YYYY-MM-DD)") + parser.add_argument("--train-ratio", type=float, help="Fraction of data for training (0.0-1.0)") + parser.add_argument("--top-n", type=int, default=5, help="Top N configs to validate OOS") + parser.add_argument("--walk-forward", action="store_true", help="Enable walk-forward evaluation") + parser.add_argument("--wf-train-days", type=int, default=252, help="WF train window (trading days)") + parser.add_argument("--wf-test-days", type=int, default=63, help="WF test window (trading days)") + parser.add_argument("--output", help="Save report JSON to path") + parser.add_argument("--oos-sort", action="store_true", help="Re-rank final results by OOS Sharpe") + return parser.parse_args() + + +def main() -> None: + args = parse_args() + with open(args.config) as handle: + raw = yaml.safe_load(handle) or {} + + orb_params = ORBStrategyParams(**(raw.get("orb_strategy", {}))) + backtest_params = BacktestParams(**(raw.get("backtest", {}))) + universe_params = UniverseParams(**(raw.get("universe", {}))) + cache_params = CacheParams(**(raw.get("cache", {}))) + output_params = OutputParams(**(raw.get("output", {}))) + + if args.start: + backtest_params.start_date = args.start + if args.end: + backtest_params.end_date = args.end + if args.days: + backtest_params.lookback_trading_days = args.days + + config = IntradayConfig( + strategy_mode=raw.get("strategy_mode", "orb"), + orb_strategy=orb_params, + backtest=backtest_params, + universe=universe_params, + cache=cache_params, + output=output_params, + ) + + report = asyncio.run( + evaluate( + config=config, + sweep_path=args.sweep, + split_date=args.split_date, + train_ratio=args.train_ratio, + top_n=args.top_n, + walk_forward=args.walk_forward, + wf_train_days=args.wf_train_days, + wf_test_days=args.wf_test_days, + oos_sort=args.oos_sort, + ) + ) + + if args.output: + out_path = Path(args.output) + else: + out_dir = Path(config.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"eval_{ts}.json" + + out_path.parent.mkdir(parents=True, exist_ok=True) + out_path.write_text(json.dumps(report, indent=2)) + print(f"\n Report saved: {out_path}") + + +if __name__ == "__main__": + main() diff --git a/apps/intraday_bt/lab.py b/apps/intraday_bt/lab.py new file mode 100644 index 0000000..d214979 --- /dev/null +++ b/apps/intraday_bt/lab.py @@ -0,0 +1,1477 @@ +"""ORB development lab orchestration CLI.""" +from __future__ import annotations + +import argparse +import asyncio +import hashlib +import json +from datetime import datetime +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +import yaml + +from libs.backtest.domain import WalkForwardSummary +from libs.common.config import get_settings +from libs.intraday.domain import IntradayMetrics, ORBStrategyParams +from libs.oracle_client import OracleClient + +from apps.intraday_bt.orb_research import ( + DEFAULT_ORB_RESEARCH_PERIODS, + ORBResearchPeriods, + build_orb_params, + build_orb_research_context, + build_walk_forward_summary, + compute_orbqs, + force_simple_returns, + intraday_metrics_to_split_result, + resolve_lab_splits, + resolve_orb_config, + simulate_orb_overrides, + write_json, +) +from apps.intraday_bt.oracle import make_intraday_oracle_client +from apps.intraday_bt.overfit_check import ( + run_param_plateau_test, + run_permutation_test, + summarize_is_oos_from_results, + summarize_walk_forward_test_from_summary, +) +from apps.intraday_bt.orb_research import generate_walk_forward_windows + + +DEFAULT_CONFIG = "configs/intraday/strategies/orb_default.yaml" + + +@dataclass(frozen=True) +class EngineSpec: + family: str + thesis: str + live_readiness: str + base_overrides: dict[str, Any] + hypotheses: list[dict[str, Any]] + + +def _read_json(path: Path, default: Any = None) -> Any: + if not path.exists(): + return default + return json.loads(path.read_text()) + + +def _serialize_finalist_eval_row(row: dict[str, Any]) -> dict[str, Any]: + def _dump(value: Any) -> Any: + if hasattr(value, "model_dump"): + return value.model_dump() + return value + + return { + **row, + "params": _dump(row["params"]), + "train_metrics_obj": _dump(row["train_metrics_obj"]), + "valid_metrics_obj": _dump(row["valid_metrics_obj"]), + "test_metrics_obj": _dump(row["test_metrics_obj"]), + "train_result": _dump(row.get("train_result")), + "valid_result": _dump(row.get("valid_result")), + "test_result": _dump(row.get("test_result")), + } + + +def _deserialize_finalist_eval_row(row: dict[str, Any]) -> dict[str, Any]: + restored = dict(row) + restored["params"] = ORBStrategyParams.model_validate(row["params"]) + restored["train_metrics_obj"] = IntradayMetrics.model_validate(row["train_metrics_obj"]) + restored["valid_metrics_obj"] = IntradayMetrics.model_validate(row["valid_metrics_obj"]) + restored["test_metrics_obj"] = IntradayMetrics.model_validate(row["test_metrics_obj"]) + restored["train_result"] = intraday_metrics_to_split_result( + restored["train_metrics_obj"], + restored["params"], + ) + restored["valid_result"] = intraday_metrics_to_split_result( + restored["valid_metrics_obj"], + restored["params"], + ) + restored["test_result"] = intraday_metrics_to_split_result( + restored["test_metrics_obj"], + restored["params"], + ) + return restored + + +def _sample_representative_days(days: list[str], target_count: int) -> list[str]: + """Pick an ordered, regime-spread subset of trading days for coarse search.""" + if target_count <= 0 or len(days) <= target_count: + return list(days) + last_idx = len(days) - 1 + chosen: list[str] = [] + seen: set[str] = set() + for i in range(target_count): + idx = round(i * last_idx / max(target_count - 1, 1)) + day = days[idx] + if day not in seen: + chosen.append(day) + seen.add(day) + return chosen + + +def _pre_robustness_rank_key(entry: dict[str, Any]) -> tuple[float, float, float, int]: + return ( + entry.get("test_sharpe") or float("-inf"), + entry.get("valid_sharpe") or float("-inf"), + entry.get("train_sharpe") or float("-inf"), + entry.get("test_trade_count") or 0, + ) + + +def _merge(base: dict[str, Any], extra: dict[str, Any]) -> dict[str, Any]: + merged = dict(base) + merged.update(extra) + return merged + + +def _candidate_id(overrides: dict[str, Any]) -> str: + payload = json.dumps(overrides, sort_keys=True, default=str) + return hashlib.sha1(payload.encode("utf-8")).hexdigest()[:12] + + +def _rank_key(metrics) -> tuple[float, float, float, int]: + return ( + metrics.sharpe_ratio or float("-inf"), + metrics.total_return_pct or float("-inf"), + -(abs(metrics.max_drawdown_pct) if metrics.max_drawdown_pct is not None else 999.0), + metrics.total_trades or 0, + ) + + +def _rank_key_from_payload(payload: dict[str, Any]) -> tuple[float, float, float, int]: + return ( + payload.get("sharpe_ratio") or float("-inf"), + payload.get("total_return_pct") or float("-inf"), + -(abs(payload.get("max_drawdown_pct")) if payload.get("max_drawdown_pct") is not None else 999.0), + payload.get("total_trades") or 0, + ) + + +def _orbqs_rank_key(entry: dict[str, Any]) -> tuple[float, float, float, int]: + return ( + entry.get("orbqs_score") or float("-inf"), + entry.get("test_sharpe") or float("-inf"), + entry.get("valid_sharpe") or float("-inf"), + entry.get("test_trade_count") or 0, + ) + + +def _engine_specs(quick: bool) -> list[EngineSpec]: + classic_hypotheses = [ + { + "entry_direction": "long_only", + "orb_minutes": 5, + "sim_bar_minutes": 5, + "order_timeout_minutes": 20, + "atr_stop_multiplier": 1.25, + "breakeven_at_r": 1.0, + "trailing_at_r": 3.0, + "trailing_stop_atr_multiplier": 0.3, + "min_rvol": 1.0, + "max_gap_pct": 0.04, + "max_candidates": 20, + "ticker_cooldown_days": 0, + }, + { + "entry_direction": "long_only", + "orb_minutes": 10, + "sim_bar_minutes": 5, + "order_timeout_minutes": 20, + "atr_stop_multiplier": 1.0, + "breakeven_at_r": 1.0, + "trailing_at_r": 3.0, + "trailing_stop_atr_multiplier": 0.3, + "min_rvol": 1.2, + "max_gap_pct": 0.03, + "max_candidates": 10, + "ticker_cooldown_days": 1, + }, + { + "entry_direction": "both", + "orb_minutes": 5, + "sim_bar_minutes": 15, + "order_timeout_minutes": 30, + "atr_stop_multiplier": 1.5, + "breakeven_at_r": 0.0, + "trailing_at_r": 2.0, + "trailing_stop_atr_multiplier": 0.0, + "min_rvol": 0.8, + "max_gap_pct": 0.06, + "max_candidates": 20, + "ticker_cooldown_days": 0, + }, + { + "entry_direction": "long_only", + "orb_minutes": 10, + "sim_bar_minutes": 15, + "order_timeout_minutes": 30, + "atr_stop_multiplier": 1.25, + "breakeven_at_r": 0.0, + "trailing_at_r": 2.0, + "trailing_stop_atr_multiplier": 0.3, + "min_rvol": 0.8, + "max_gap_pct": 0.04, + "max_candidates": 10, + "ticker_cooldown_days": 1, + }, + ] + if not quick: + classic_hypotheses.extend( + [ + { + "entry_direction": "both", + "orb_minutes": 15, + "sim_bar_minutes": 15, + "order_timeout_minutes": 30, + "atr_stop_multiplier": 1.25, + "breakeven_at_r": 1.0, + "trailing_at_r": 3.0, + "trailing_stop_atr_multiplier": 0.3, + "min_rvol": 1.0, + "max_gap_pct": 0.04, + "max_candidates": 20, + "ticker_cooldown_days": 1, + }, + { + "entry_direction": "long_only", + "orb_minutes": 5, + "sim_bar_minutes": 30, + "order_timeout_minutes": 45, + "atr_stop_multiplier": 1.5, + "breakeven_at_r": 1.0, + "trailing_at_r": 5.0, + "trailing_stop_atr_multiplier": 0.6, + "min_rvol": 1.2, + "max_gap_pct": 0.03, + "max_candidates": 10, + "ticker_cooldown_days": 2, + }, + ] + ) + + quality_hypotheses = [ + { + "entry_direction": "long_only", + "orb_minutes": 5, + "sim_bar_minutes": 5, + "order_timeout_minutes": 20, + "atr_stop_multiplier": 1.25, + "breakeven_at_r": 1.0, + "trailing_at_r": 3.0, + "trailing_stop_atr_multiplier": 0.3, + "min_rvol": 1.2, + "max_gap_pct": 0.03, + "max_candidates": 10, + "ticker_cooldown_days": 1, + "min_body_ratio": 0.4, + "weight_momentum": 0.1, + "min_candidate_breadth": 0.2, + "market_regime_spy_threshold": -0.005, + }, + { + "entry_direction": "long_only", + "orb_minutes": 5, + "sim_bar_minutes": 5, + "order_timeout_minutes": 20, + "atr_stop_multiplier": 1.25, + "breakeven_at_r": 1.0, + "trailing_at_r": 2.0, + "trailing_stop_atr_multiplier": 0.3, + "min_rvol": 1.2, + "max_gap_pct": 0.03, + "max_candidates": 8, + "ticker_cooldown_days": 2, + "min_body_ratio": 0.4, + "weight_momentum": 0.2, + "min_candidate_breadth": 0.3, + "market_regime_spy_threshold": -0.005, + }, + { + "entry_direction": "long_only", + "orb_minutes": 10, + "sim_bar_minutes": 5, + "order_timeout_minutes": 20, + "atr_stop_multiplier": 1.0, + "breakeven_at_r": 1.0, + "trailing_at_r": 3.0, + "trailing_stop_atr_multiplier": 0.3, + "min_rvol": 1.2, + "max_gap_pct": 0.03, + "max_candidates": 10, + "ticker_cooldown_days": 1, + "min_body_ratio": 0.4, + "weight_momentum": 0.1, + "min_candidate_breadth": 0.2, + "market_regime_spy_threshold": -0.005, + }, + { + "entry_direction": "long_only", + "orb_minutes": 5, + "sim_bar_minutes": 15, + "order_timeout_minutes": 30, + "atr_stop_multiplier": 1.25, + "breakeven_at_r": 0.0, + "trailing_at_r": 2.0, + "trailing_stop_atr_multiplier": 0.3, + "min_rvol": 1.0, + "max_gap_pct": 0.03, + "max_candidates": 10, + "ticker_cooldown_days": 1, + "min_body_ratio": 0.2, + "weight_momentum": 0.1, + "min_candidate_breadth": 0.2, + "market_regime_spy_threshold": -0.005, + }, + ] + if not quick: + quality_hypotheses.extend( + [ + { + "entry_direction": "long_only", + "orb_minutes": 15, + "sim_bar_minutes": 15, + "order_timeout_minutes": 30, + "atr_stop_multiplier": 1.0, + "breakeven_at_r": 1.0, + "trailing_at_r": 3.0, + "trailing_stop_atr_multiplier": 0.3, + "min_rvol": 1.2, + "max_gap_pct": 0.03, + "max_candidates": 10, + "ticker_cooldown_days": 1, + "min_body_ratio": 0.4, + "weight_momentum": 0.2, + "min_candidate_breadth": 0.3, + "market_regime_spy_threshold": -0.005, + }, + { + "entry_direction": "both", + "orb_minutes": 5, + "sim_bar_minutes": 5, + "order_timeout_minutes": 20, + "atr_stop_multiplier": 1.5, + "breakeven_at_r": 0.0, + "trailing_at_r": 2.0, + "trailing_stop_atr_multiplier": 0.3, + "min_rvol": 0.8, + "max_gap_pct": 0.06, + "max_candidates": 20, + "ticker_cooldown_days": 2, + "min_body_ratio": 0.0, + "weight_momentum": 0.2, + "min_candidate_breadth": 0.2, + "market_regime_spy_threshold": None, + }, + ] + ) + + compression_hypotheses = [ + { + "orb_minutes": 10, + "sim_bar_minutes": 5, + "order_timeout_minutes": 45, + "atr_stop_multiplier": 1.0, + "breakeven_at_r": 1.0, + "trailing_at_r": 3.0, + "trailing_stop_atr_multiplier": 0.3, + "min_rvol": 1.2, + "max_gap_pct": 0.03, + "max_candidates": 12, + "max_candidates_per_sector": 2, + "ticker_cooldown_days": 1, + "weight_entropy": -0.15, + "weight_atr_ratio": 0.0, + "weight_gap_zscore": 0.2, + "weight_premarket_dollar_vol": 0.25, + "weight_gap": 0.25, + "compression_ratio_max": 0.60, + "min_candidate_breadth": 0.2, + "market_regime_spy_threshold": -0.005, + "min_candidates_to_trade": 3, + }, + { + "orb_minutes": 10, + "sim_bar_minutes": 5, + "order_timeout_minutes": 45, + "atr_stop_multiplier": 1.0, + "breakeven_at_r": 1.0, + "trailing_at_r": 3.0, + "trailing_stop_atr_multiplier": 0.3, + "min_rvol": 1.2, + "max_gap_pct": 0.03, + "max_candidates": 15, + "max_candidates_per_sector": 2, + "ticker_cooldown_days": 1, + "weight_entropy": -0.15, + "weight_atr_ratio": 0.0, + "weight_gap_zscore": 0.2, + "weight_premarket_dollar_vol": 0.25, + "weight_gap": 0.25, + "compression_ratio_max": 0.60, + "min_candidate_breadth": 0.2, + "market_regime_spy_threshold": -0.005, + "min_candidates_to_trade": 3, + }, + { + "orb_minutes": 10, + "sim_bar_minutes": 5, + "order_timeout_minutes": 45, + "atr_stop_multiplier": 1.0, + "breakeven_at_r": 1.0, + "trailing_at_r": 3.0, + "trailing_stop_atr_multiplier": 0.3, + "min_rvol": 1.2, + "max_gap_pct": 0.03, + "max_candidates": 12, + "max_candidates_per_sector": 3, + "ticker_cooldown_days": 1, + "weight_entropy": -0.15, + "weight_atr_ratio": 0.0, + "weight_gap_zscore": 0.2, + "weight_premarket_dollar_vol": 0.25, + "weight_gap": 0.25, + "compression_ratio_max": 0.60, + "min_candidate_breadth": 0.15, + "market_regime_spy_threshold": -0.005, + "min_candidates_to_trade": 3, + "min_rvol": 1.1, + }, + { + "orb_minutes": 10, + "sim_bar_minutes": 5, + "order_timeout_minutes": 45, + "atr_stop_multiplier": 1.0, + "breakeven_at_r": 1.0, + "trailing_at_r": 3.0, + "trailing_stop_atr_multiplier": 0.3, + "min_rvol": 1.1, + "max_gap_pct": 0.03, + "max_candidates": 15, + "max_candidates_per_sector": 2, + "ticker_cooldown_days": 1, + "weight_entropy": -0.15, + "weight_atr_ratio": 0.0, + "weight_gap_zscore": 0.2, + "weight_premarket_dollar_vol": 0.25, + "weight_gap": 0.25, + "compression_ratio_max": 0.60, + "min_candidate_breadth": 0.15, + "market_regime_spy_threshold": -0.005, + "min_candidates_to_trade": 3, + }, + ] + if not quick: + compression_hypotheses.extend( + [ + { + "orb_minutes": 15, + "sim_bar_minutes": 15, + "order_timeout_minutes": 30, + "atr_stop_multiplier": 1.0, + "breakeven_at_r": 1.0, + "trailing_at_r": 3.0, + "trailing_stop_atr_multiplier": 0.3, + "min_rvol": 1.0, + "max_gap_pct": 0.03, + "max_candidates": 10, + "ticker_cooldown_days": 1, + "weight_entropy": -0.15, + "weight_atr_ratio": 0.0, + "weight_gap_zscore": 0.2, + "compression_ratio_max": 0.60, + }, + { + "orb_minutes": 5, + "sim_bar_minutes": 30, + "order_timeout_minutes": 45, + "atr_stop_multiplier": 1.5, + "breakeven_at_r": 1.0, + "trailing_at_r": 5.0, + "trailing_stop_atr_multiplier": 0.6, + "min_rvol": 1.2, + "max_gap_pct": 0.03, + "max_candidates": 10, + "ticker_cooldown_days": 2, + "weight_entropy": 0.0, + "weight_atr_ratio": -0.15, + "weight_gap_zscore": 0.2, + "compression_ratio_max": 0.60, + }, + ] + ) + + # Gainers hypotheses reflect the improved parameter space after strategy analysis. + # Key improvements: early trailing activation, wider trail, R2G/doji allowance, + # regime filter, simultaneous-entry cap. + _gainers_base = { + "entry_direction": "long_only", + "orb_minutes": 5, + "sim_bar_minutes": 5, + "order_timeout_minutes": 45, + "breakeven_at_r": 1.0, + "min_rvol": 1.0, + "max_gap_pct": None, + "min_abs_gap_pct": 0.02, + "min_premarket_dollar_vol": 1500000.0, + "max_candidates": 20, + "min_candidates_to_trade": 1, + "ticker_cooldown_days": 0, + "weight_rvol": 0.40, + "weight_gap": 0.20, + "weight_dollar_vol": 0.05, + "weight_premarket_dollar_vol": 0.35, + # New: allow leader followthrough patterns + "allow_doji_breakout": True, + "allow_red_to_green_breakout": True, + # New: simultaneous entry cap to prevent correlated burst risk + "max_simultaneous_entries": 5, + } + gainers_hypotheses = [ + # H1: Baseline improved — early trail (1.5R), loose trail (0.8 ATR), two-stage tighten + { + **_gainers_base, + "atr_stop_multiplier": 0.75, + "trailing_at_r": 1.5, + "trailing_stop_atr_multiplier": 0.8, + "trailing_tighten_at_r": 3.0, + "trailing_stop_atr_multiplier_tight": 0.4, + "max_candidates_per_sector": 3, + "min_candidate_breadth": 0.30, + "market_regime_spy_threshold": -0.005, + }, + # H2: More aggressive trail (activate at 1R), wider stop + { + **_gainers_base, + "atr_stop_multiplier": 0.75, + "trailing_at_r": 1.0, + "trailing_stop_atr_multiplier": 1.0, + "trailing_tighten_at_r": 2.5, + "trailing_stop_atr_multiplier_tight": 0.5, + "max_candidates_per_sector": 3, + "min_premarket_dollar_vol": 2000000.0, + "min_candidate_breadth": 0.30, + "market_regime_spy_threshold": -0.005, + }, + # H3: Tighter regime filter, higher premarket bar, no sector cap + { + **_gainers_base, + "atr_stop_multiplier": 1.0, + "trailing_at_r": 1.5, + "trailing_stop_atr_multiplier": 0.8, + "trailing_tighten_at_r": 3.0, + "trailing_stop_atr_multiplier_tight": 0.4, + "max_candidates_per_sector": None, + "min_premarket_dollar_vol": 3000000.0, + "min_rvol": 1.5, + "min_candidate_breadth": 0.40, + "market_regime_spy_threshold": -0.008, + }, + # H4: Original baseline (conservative) for comparison/regression + { + **_gainers_base, + "atr_stop_multiplier": 0.50, + "trailing_at_r": 3.0, + "trailing_stop_atr_multiplier": 0.3, + "trailing_tighten_at_r": None, + "trailing_stop_atr_multiplier_tight": 0.0, + "max_candidates_per_sector": 2, + "min_rvol": 1.2, + "max_candidates": 12, + "max_simultaneous_entries": None, + "allow_doji_breakout": False, + "allow_red_to_green_breakout": False, + "min_candidate_breadth": None, + "market_regime_spy_threshold": None, + }, + ] + + return [ + EngineSpec( + family="classic_breakout", + thesis="Pure breakout timing and risk discipline over broad ORB participation.", + live_readiness="live_ready", + base_overrides={ + "engine_family": "classic_breakout", + "live_readiness": "live_ready", + "weight_body_ratio": 0.0, + "weight_momentum": 0.0, + "weight_entropy": 0.0, + "weight_atr_ratio": 0.0, + "weight_gap_zscore": 0.0, + "min_body_ratio": 0.0, + }, + hypotheses=classic_hypotheses, + ), + EngineSpec( + family="quality_breakout", + thesis="Higher-quality ORB candles with body strength, momentum, and breadth filters.", + live_readiness="live_ready", + base_overrides={ + "engine_family": "quality_breakout", + "live_readiness": "live_ready", + "weight_body_ratio": 0.15, + }, + hypotheses=quality_hypotheses, + ), + EngineSpec( + family="gainers_leader", + thesis="Top-gainers style ORB that emphasizes abnormal 09:35 attention, gap, and premarket participation.", + live_readiness="research_only", + base_overrides={ + "engine_family": "gainers_leader", + "live_readiness": "research_only", + "entry_direction": "long_only", + "weight_body_ratio": 0.0, + "weight_momentum": 0.0, + }, + hypotheses=gainers_hypotheses, + ), + EngineSpec( + family="compression_breakout", + thesis="Compressed prior-day regime followed by expansion through the opening range.", + live_readiness="research_only", + base_overrides={ + "engine_family": "compression_breakout", + "live_readiness": "research_only", + "entry_direction": "long_only", + "weight_body_ratio": 0.10, + "weight_momentum": 0.10, + "max_entropy": 0.95, + }, + hypotheses=compression_hypotheses, + ), + ] + + +async def _evaluate_hypotheses_family( + spec: EngineSpec, + context, + client: OracleClient, + train_days: list[str], + *, + keep_top: int, +) -> list[dict[str, Any]]: + print(f"\n[coarse] {spec.family}") + print(f" thesis: {spec.thesis}") + evaluated: list[dict[str, Any]] = [] + for idx, hypothesis in enumerate(spec.hypotheses, start=1): + overrides = _merge(spec.base_overrides, hypothesis) + cid = _candidate_id(overrides) + params, metrics = await simulate_orb_overrides( + context, + client, + overrides, + train_days, + run_id=f"{spec.family[:4]}_{idx:03d}", + ) + row = { + "candidate_id": cid, + "engine_family": spec.family, + "live_readiness": spec.live_readiness, + "thesis": spec.thesis, + "hypothesis_index": idx, + "overrides": overrides, + "params": params.model_dump(), + "train_metrics": metrics.model_dump(), + } + evaluated.append(row) + print( + f" hypothesis {idx}/{len(spec.hypotheses)} " + f"Sharpe={metrics.sharpe_ratio or 0:.2f} " + f"Ret={(metrics.total_return_pct or 0)*100:.1f}%" + ) + + survivors = sorted( + evaluated, + key=lambda row: _rank_key_from_payload(row["train_metrics"]), + reverse=True, + )[:keep_top] + return survivors + + +async def _rerank_family( + spec: EngineSpec, + survivors: list[dict[str, Any]], + context, + client: OracleClient, + evaluation_days: list[str], + *, + keep_top: int, + metric_field: str, + existing_rows: list[dict[str, Any]] | None = None, + on_update: Any | None = None, +) -> list[dict[str, Any]]: + print(f"\n[rerank] {spec.family}") + reranked: list[dict[str, Any]] = list(existing_rows or []) + completed_ids = {row["candidate_id"] for row in reranked} + for idx, survivor in enumerate(survivors, start=1): + if survivor["candidate_id"] in completed_ids: + print(f" {idx}/{len(survivors)} resume hit") + continue + _, metrics = await simulate_orb_overrides( + context, + client, + survivor["overrides"], + evaluation_days, + run_id=f"{spec.family[:4]}_rv_{idx:03d}", + progress_prefix=f" [rerank {spec.family} {idx}/{len(survivors)}] ", + intraday_concurrency=2, + max_pairs_per_chunk=2_000, + ) + row = dict(survivor) + row[metric_field] = metrics.model_dump() + reranked.append(row) + completed_ids.add(survivor["candidate_id"]) + reranked.sort(key=lambda item: _rank_key_from_payload(item[metric_field]), reverse=True) + if on_update is not None: + on_update(reranked) + print( + f" {idx}/{len(survivors)} " + f"Sharpe={metrics.sharpe_ratio or 0:.2f} " + f"Ret={(metrics.total_return_pct or 0)*100:.1f}%" + ) + return reranked[:keep_top] + + +async def _build_walk_forward_for_candidate( + context, + client: OracleClient, + overrides: dict[str, Any], + *, + train_days: int, + test_days: int, + step_days: int, + progress_prefix: str = "", +) -> Any: + wf_train_days = train_days + wf_test_days = test_days + windows = generate_walk_forward_windows( + context.trading_days, + train_days=wf_train_days, + test_days=wf_test_days, + step_days=step_days, + ) + folds: list[dict[str, Any]] = [] + params = build_orb_params(context.config, overrides) + for idx, (fold_train_days, fold_test_days) in enumerate(windows, start=1): + if progress_prefix: + print( + f"{progress_prefix}fold {idx}/{len(windows)}: " + f"train {fold_train_days[0]}→{fold_train_days[-1]} " + f"test {fold_test_days[0]}→{fold_test_days[-1]}" + ) + train_metrics = await simulate_orb_overrides( + context, + client, + overrides, + fold_train_days, + run_id=f"wf_tr_{idx:02d}", + progress_prefix=f"{progress_prefix}[train {idx}/{len(windows)}] " if progress_prefix else "", + ) + test_metrics = await simulate_orb_overrides( + context, + client, + overrides, + fold_test_days, + run_id=f"wf_te_{idx:02d}", + progress_prefix=f"{progress_prefix}[test {idx}/{len(windows)}] " if progress_prefix else "", + ) + folds.append({ + "train_start": fold_train_days[0], + "train_end": fold_train_days[-1], + "test_start": fold_test_days[0], + "test_end": fold_test_days[-1], + "train_result": intraday_metrics_to_split_result(train_metrics[1], params), + "test_result": intraday_metrics_to_split_result(test_metrics[1], params), + }) + return build_walk_forward_summary( + folds, + train_days=wf_train_days, + test_days=wf_test_days, + step_days=step_days, + ) + + +async def _run_finalist_scenarios( + robustness_context, + main_context, + client: OracleClient, + periods: ORBResearchPeriods, + test_metrics, + *, + quick: bool, +) -> dict[str, dict[str, Any]]: + if quick: + robustness_days = robustness_context.trading_days + head_end = robustness_days[min(len(robustness_days) - 1, 62)] + tail_start = robustness_days[max(0, len(robustness_days) - 63)] + scenario_defs = { + "robustness_head": {"start": robustness_days[0], "end": head_end}, + "robustness_tail": {"start": tail_start, "end": robustness_days[-1]}, + "no_rvol_filter": { + "start": tail_start, + "end": robustness_days[-1], + "param_override": {"min_rvol": 0.0}, + }, + } + elif periods == DEFAULT_ORB_RESEARCH_PERIODS: + scenario_defs = { + "bear_2022": {"start": "2022-01-03", "end": "2022-12-30"}, + "recovery_2023h1": {"start": "2023-01-03", "end": "2023-06-30"}, + "bull_2023h2": {"start": "2023-07-03", "end": "2023-12-29"}, + "no_rvol_filter": {"param_override": {"min_rvol": 0.0}}, + "random_ranking": {"shuffle_candidates": True}, + } + else: + robustness_days = robustness_context.trading_days + midpoint = len(robustness_days) // 2 + scenario_defs = { + "robustness_1": {"start": robustness_days[0], "end": robustness_days[max(0, midpoint - 1)]}, + "robustness_2": {"start": robustness_days[midpoint], "end": robustness_days[-1]}, + "no_rvol_filter": {"param_override": {"min_rvol": 0.0}}, + "random_ranking": {"shuffle_candidates": True}, + } + results: dict[str, dict[str, Any]] = {} + from apps.intraday_bt.scenario_test import run_scenario + + for name, definition in scenario_defs.items(): + results[name] = await run_scenario( + name, + definition, + robustness_context, + client, + robustness_context.trading_days[0], + progress_prefix=" [scenario] ", + ) + results["oos_2026"] = { + "scenario": "oos_2026", + "period": f"{main_context.trading_days[-1]}", + "sharpe_ratio": test_metrics.sharpe_ratio or 0.0, + "total_return_pct": (test_metrics.total_return_pct or 0.0) * 100.0, + "max_drawdown_pct": abs((test_metrics.max_drawdown_pct or 0.0) * 100.0), + "win_rate": (test_metrics.win_rate or 0.0) * 100.0, + "profit_factor": test_metrics.profit_factor or 0.0, + "total_trades": test_metrics.total_trades or 0, + } + return results + + +def _write_stage5_payload( + path: Path, + *, + ranking: list[dict[str, Any]], + walk_forward: dict[str, Any], + scenarios: dict[str, Any], + overfit: dict[str, Any], +) -> None: + write_json( + path, + { + "ranking": ranking, + "walk_forward": walk_forward, + "scenarios": scenarios, + "overfit": overfit, + }, + ) + + +def _promotion_status(valid_result, test_result) -> str: + if valid_result.trade_count < 80 or test_result.trade_count < 80: + return "blocked_low_activity" + if (valid_result.total_return_pct or 0.0) <= 0.0 or (test_result.total_return_pct or 0.0) <= 0.0: + return "blocked_negative_oos" + return "eligible" + + +def _select_champions( + ranking: list[dict[str, Any]], +) -> tuple[dict[str, Any] | None, dict[str, Any] | None, dict[str, Any] | None]: + top_candidate = ranking[0] if ranking else None + eligible_rows = [row for row in ranking if row.get("promotion_status") == "eligible"] + overall = eligible_rows[0] if eligible_rows else None + live_ready = next((row for row in eligible_rows if row.get("live_readiness") == "live_ready"), None) + return top_candidate, overall, live_ready + + +def _finalist_summary_row(row: dict[str, Any]) -> dict[str, Any]: + return { + "candidate_id": row["candidate_id"], + "engine_family": row["engine_family"], + "live_readiness": row["live_readiness"], + "promotion_status": row["promotion_status"], + "overrides": row["overrides"], + "train_sharpe": row["train_metrics_obj"].sharpe_ratio, + "valid_sharpe": row["valid_metrics_obj"].sharpe_ratio, + "test_sharpe": row["test_metrics_obj"].sharpe_ratio, + "train_trade_count": row["train_metrics_obj"].total_trades, + "valid_trade_count": row["valid_metrics_obj"].total_trades, + "test_trade_count": row["test_metrics_obj"].total_trades, + "train_return_pct": (row["train_metrics_obj"].total_return_pct or 0.0) * 100.0, + "valid_return_pct": (row["valid_metrics_obj"].total_return_pct or 0.0) * 100.0, + "test_return_pct": (row["test_metrics_obj"].total_return_pct or 0.0) * 100.0, + } + + +def _resolve_period_overrides(args: argparse.Namespace) -> ORBResearchPeriods: + defaults = DEFAULT_ORB_RESEARCH_PERIODS + return ORBResearchPeriods( + train_start=args.train_start or defaults.train_start, + train_end=args.train_end or defaults.train_end, + valid_start=args.valid_start or defaults.valid_start, + valid_end=args.valid_end or defaults.valid_end, + test_start=args.test_start or defaults.test_start, + test_end=args.test_end or defaults.test_end, + robustness_start=args.robustness_start or defaults.robustness_start, + robustness_end=args.robustness_end or defaults.robustness_end, + ) + + +async def run_lab( + config_path: str, + *, + periods: ORBResearchPeriods = DEFAULT_ORB_RESEARCH_PERIODS, + quick: bool, + beam_width: int, + wf_train_days: int | None = None, + wf_test_days: int | None = None, + permutations: int | None = None, + output_dir: str | None = None, +) -> dict[str, Any]: + _, base_config = resolve_orb_config(config_path) + base_config = force_simple_returns(base_config) + wf_train_days = wf_train_days or (84 if quick else 252) + wf_test_days = wf_test_days or (21 if quick else 63) + wf_step_days = 42 if quick else wf_test_days + permutations = permutations or (3 if quick else 20) + coarse_keep = max(1, beam_width) if quick else max(beam_width, 5) + rerank_keep = 1 if quick else 2 + coarse_sample_days = 42 if quick else 126 + robustness_keep = 1 if quick else 3 + output_root = Path(output_dir) if output_dir else Path("runs/intraday_orb/lab") / f"{Path(config_path).stem}_{datetime.now().strftime('%Y%m%d_%H%M%S')}" + output_root.mkdir(parents=True, exist_ok=True) + stage1_path = output_root / "stage1_coarse.json" + stage2_path = output_root / "stage2_rerank.json" + stage4_path = output_root / "stage4_locked_test.json" + stage5_path = output_root / "stage5_robustness.json" + + settings = get_settings() + async with make_intraday_oracle_client(settings) as client: + print("[1/6] Building main research context (2024-2026Q1)...") + main_context = await build_orb_research_context( + base_config, + periods.train_start, + periods.test_end, + client, + print_progress=True, + ) + splits = resolve_lab_splits(main_context.trading_days, periods) + train_days = splits["train"] + valid_days = splits["valid"] + test_days = splits["test"] + combined_days = train_days + valid_days + coarse_train_days = _sample_representative_days(train_days, coarse_sample_days) + + print( + f"[2/6] Coarse search on representative train slice " + f"({len(coarse_train_days)}/{len(train_days)} days)..." + ) + stage1: dict[str, list[dict[str, Any]]] = _read_json(stage1_path, default={}) + for spec in _engine_specs(quick): + if spec.family in stage1: + print(f"\n[coarse] {spec.family} (resume hit)") + continue + stage1[spec.family] = await _evaluate_hypotheses_family( + spec, + main_context, + client, + coarse_train_days, + keep_top=coarse_keep, + ) + write_json(stage1_path, stage1) + + rerank_days = valid_days if quick else combined_days + rerank_metric_field = "valid_metrics" if quick else "train_valid_metrics" + rerank_label = "valid only" if quick else "train+valid" + print(f"[3/6] Re-rank survivors on {rerank_label}...") + stage2: dict[str, list[dict[str, Any]]] = _read_json(stage2_path, default={}) + for spec in _engine_specs(quick): + existing_stage2 = [ + row for row in stage2.get(spec.family, []) + if rerank_metric_field in row + ] + if existing_stage2: + print(f"\n[rerank] {spec.family} ({len(existing_stage2)} cached)") + def _save_stage2(rows: list[dict[str, Any]], family: str = spec.family) -> None: + stage2[family] = rows + write_json(stage2_path, stage2) + stage2[spec.family] = await _rerank_family( + spec, + stage1[spec.family], + main_context, + client, + rerank_days, + keep_top=rerank_keep, + metric_field=rerank_metric_field, + existing_rows=existing_stage2, + on_update=_save_stage2, + ) + write_json(stage2_path, stage2) + + finalists = [row for rows in stage2.values() for row in rows] + print(f"[4/6] Locked test on finalists ({len(finalists)} configs)...") + stage4_payload = _read_json(stage4_path, default={"split_rows": [], "finalists": []}) + split_rows: list[dict[str, Any]] = list(stage4_payload.get("split_rows", [])) + finalist_eval_rows: list[dict[str, Any]] = [ + _deserialize_finalist_eval_row(row) + for row in stage4_payload.get("finalists", []) + ] + normalized_finalist_eval_rows: list[dict[str, Any]] = [] + for row in finalist_eval_rows: + promotion_status = _promotion_status(row["valid_result"], row["test_result"]) + normalized_finalist_eval_rows.append( + { + **row, + "promotion_status": promotion_status, + } + ) + finalist_eval_rows = normalized_finalist_eval_rows + finalist_eval_by_candidate = { + row["candidate_id"]: row + for row in finalist_eval_rows + } + split_rows = [ + { + **row, + "promotion_status": finalist_eval_by_candidate[row["candidate_id"]]["promotion_status"], + } + if row["candidate_id"] in finalist_eval_by_candidate + else row + for row in split_rows + ] + stage4_payload = { + "split_rows": split_rows, + "finalists": [_serialize_finalist_eval_row(row) for row in finalist_eval_rows], + } + write_json(stage4_path, stage4_payload) + completed_finalists = {row["candidate_id"] for row in finalist_eval_rows} + for idx, finalist in enumerate(finalists, start=1): + if finalist["candidate_id"] in completed_finalists: + print(f" {idx}/{len(finalists)} {finalist['engine_family']} (resume hit)") + continue + overrides = finalist["overrides"] + params = build_orb_params(main_context.config, overrides) + train_metrics_obj = IntradayMetrics.model_validate(finalist["train_metrics"]) + if "valid_metrics" in finalist: + valid_metrics_obj = IntradayMetrics.model_validate(finalist["valid_metrics"]) + print(f" [valid {idx}/{len(finalists)}] cached metrics hit from stage2") + else: + _, valid_metrics_obj = await simulate_orb_overrides( + main_context, + client, + overrides, + valid_days, + run_id=f"val_{idx:03d}", + progress_prefix=f" [valid {idx}/{len(finalists)}] ", + intraday_concurrency=2, + max_pairs_per_chunk=2_000, + ) + _, test_metrics_obj = await simulate_orb_overrides( + main_context, + client, + overrides, + test_days, + run_id=f"test_{idx:03d}", + progress_prefix=f" [test {idx}/{len(finalists)}] ", + intraday_concurrency=2, + max_pairs_per_chunk=2_000, + ) + train_result = intraday_metrics_to_split_result(train_metrics_obj, params) + valid_result = intraday_metrics_to_split_result(valid_metrics_obj, params) + test_result = intraday_metrics_to_split_result(test_metrics_obj, params) + promotion_status = _promotion_status(valid_result, test_result) + split_row = { + "candidate_id": finalist["candidate_id"], + "engine_family": finalist["engine_family"], + "live_readiness": finalist["live_readiness"], + "promotion_status": promotion_status, + "overrides": overrides, + "train": train_result.model_dump(), + "valid": valid_result.model_dump(), + "test": test_result.model_dump(), + } + split_rows.append(split_row) + finalist_row = { + **finalist, + "params": params, + "train_metrics_obj": train_metrics_obj, + "valid_metrics_obj": valid_metrics_obj, + "test_metrics_obj": test_metrics_obj, + "train_result": train_result, + "valid_result": valid_result, + "test_result": test_result, + "promotion_status": promotion_status, + } + finalist_eval_rows.append(finalist_row) + stage4_payload = { + "split_rows": split_rows, + "finalists": [_serialize_finalist_eval_row(row) for row in finalist_eval_rows], + } + write_json(stage4_path, stage4_payload) + print( + f" {idx}/{len(finalists)} {finalist['engine_family']} " + f"test Sharpe={test_metrics_obj.sharpe_ratio or 0:.2f} " + f"Ret={(test_metrics_obj.total_return_pct or 0)*100:.1f}% " + f"Trades={test_metrics_obj.total_trades or 0}" + ) + + finalist_eval_rows.sort( + key=lambda row: _pre_robustness_rank_key( + { + "train_sharpe": row["train_metrics_obj"].sharpe_ratio, + "valid_sharpe": row["valid_metrics_obj"].sharpe_ratio, + "test_sharpe": row["test_metrics_obj"].sharpe_ratio, + "test_trade_count": row["test_metrics_obj"].total_trades, + } + ), + reverse=True, + ) + top_split_candidate = finalist_eval_rows[0] if finalist_eval_rows else None + eligible_finalists = [ + row for row in finalist_eval_rows + if row["promotion_status"] == "eligible" + ] + robustness_finalists = eligible_finalists[:robustness_keep] + + ranking: list[dict[str, Any]] = [] + wf_by_candidate: dict[str, Any] = {} + scenarios_by_candidate: dict[str, Any] = {} + overfit_by_candidate: dict[str, Any] = {} + + if robustness_finalists: + print( + f"[5/6] Finalist robustness (WFV + scenario + overfit) " + f"on top {len(robustness_finalists)}/{len(finalist_eval_rows)} finalists..." + ) + robustness_context = await build_orb_research_context( + base_config, + periods.robustness_start, + periods.robustness_end, + client, + print_progress=True, + ) + + stage5_payload = _read_json( + stage5_path, + default={ + "ranking": [], + "walk_forward": {}, + "scenarios": {}, + "overfit": {}, + }, + ) + ranking = list(stage5_payload.get("ranking", [])) + wf_by_candidate = dict(stage5_payload.get("walk_forward", {})) + scenarios_by_candidate = dict(stage5_payload.get("scenarios", {})) + overfit_by_candidate = dict(stage5_payload.get("overfit", {})) + finalist_by_candidate_id = { + row["candidate_id"]: row + for row in finalist_eval_rows + } + normalized_ranking: list[dict[str, Any]] = [] + for row in ranking: + finalist = finalist_by_candidate_id.get(row["candidate_id"]) + if finalist is None: + normalized_ranking.append(row) + continue + normalized_ranking.append( + { + **row, + "engine_family": finalist["engine_family"], + "live_readiness": finalist["live_readiness"], + "promotion_status": finalist["promotion_status"], + "overrides": finalist["overrides"], + } + ) + ranking = normalized_ranking + ranked_candidates = {row["candidate_id"] for row in ranking} + + for idx, finalist in enumerate(robustness_finalists, start=1): + candidate_id = finalist["candidate_id"] + if finalist["candidate_id"] in ranked_candidates: + print(f" finalist {idx}/{len(robustness_finalists)} {finalist['candidate_id']} (resume hit)") + continue + params = finalist["params"] + config_for_finalist = main_context.config.model_copy(update={"orb_strategy": params}) + wf_payload = wf_by_candidate.get(candidate_id) + if wf_payload is None: + print(f" finalist {idx}/{len(robustness_finalists)} {candidate_id} walk-forward...") + wf_summary = await _build_walk_forward_for_candidate( + main_context, + client, + finalist["overrides"], + train_days=wf_train_days, + test_days=wf_test_days, + step_days=wf_step_days, + progress_prefix=" [wf] ", + ) + wf_payload = wf_summary.model_dump(mode="json") + wf_by_candidate[candidate_id] = wf_payload + _write_stage5_payload( + stage5_path, + ranking=ranking, + walk_forward=wf_by_candidate, + scenarios=scenarios_by_candidate, + overfit=overfit_by_candidate, + ) + else: + print(f" finalist {idx}/{len(robustness_finalists)} {candidate_id} walk-forward (resume hit)") + wf_summary = WalkForwardSummary.model_validate(wf_payload) + + scenario_results = scenarios_by_candidate.get(candidate_id) + if scenario_results is None: + print(f" finalist {idx}/{len(robustness_finalists)} {candidate_id} scenarios...") + scenario_results = await _run_finalist_scenarios( + robustness_context, + main_context, + client, + periods, + finalist["test_metrics_obj"], + quick=quick, + ) + scenarios_by_candidate[candidate_id] = scenario_results + _write_stage5_payload( + stage5_path, + ranking=ranking, + walk_forward=wf_by_candidate, + scenarios=scenarios_by_candidate, + overfit=overfit_by_candidate, + ) + else: + print(f" finalist {idx}/{len(robustness_finalists)} {candidate_id} scenarios (resume hit)") + + overfit_tests = dict(overfit_by_candidate.get(candidate_id, {})) + if "walk_forward" not in overfit_tests: + print(f" finalist {idx}/{len(robustness_finalists)} {candidate_id} overfit walk-forward (reuse)...") + overfit_tests["walk_forward"] = summarize_walk_forward_test_from_summary(wf_summary) + overfit_by_candidate[candidate_id] = overfit_tests + _write_stage5_payload( + stage5_path, + ranking=ranking, + walk_forward=wf_by_candidate, + scenarios=scenarios_by_candidate, + overfit=overfit_by_candidate, + ) + if "is_oos" not in overfit_tests: + print(f" finalist {idx}/{len(robustness_finalists)} {candidate_id} overfit is_oos (reuse)...") + overfit_tests["is_oos"] = summarize_is_oos_from_results( + finalist["train_result"], + finalist["test_result"], + is_period=f"{periods.train_start} → {periods.valid_end}", + oos_period=f"{periods.test_start} → {periods.test_end}", + ) + overfit_by_candidate[candidate_id] = overfit_tests + _write_stage5_payload( + stage5_path, + ranking=ranking, + walk_forward=wf_by_candidate, + scenarios=scenarios_by_candidate, + overfit=overfit_by_candidate, + ) + if "param_plateau" not in overfit_tests: + print(f" finalist {idx}/{len(robustness_finalists)} {candidate_id} overfit plateau...") + overfit_tests["param_plateau"] = await run_param_plateau_test( + main_context, + client, + config_for_finalist, + quick=quick, + param_names=["atr_stop_multiplier"] if quick else None, + ) + overfit_by_candidate[candidate_id] = overfit_tests + _write_stage5_payload( + stage5_path, + ranking=ranking, + walk_forward=wf_by_candidate, + scenarios=scenarios_by_candidate, + overfit=overfit_by_candidate, + ) + if "permutation" not in overfit_tests: + print(f" finalist {idx}/{len(robustness_finalists)} {candidate_id} overfit permutation...") + overfit_tests["permutation"] = await run_permutation_test( + main_context, + client, + config_for_finalist, + n_permutations=permutations, + ) + overfit_by_candidate[candidate_id] = overfit_tests + _write_stage5_payload( + stage5_path, + ranking=ranking, + walk_forward=wf_by_candidate, + scenarios=scenarios_by_candidate, + overfit=overfit_by_candidate, + ) + + orbqs_score, orbqs_breakdown = compute_orbqs( + finalist["train_result"], + finalist["valid_result"], + finalist["test_result"], + wf_summary, + scenario_results, + overfit_tests, + ) + rank_row = { + "candidate_id": finalist["candidate_id"], + "engine_family": finalist["engine_family"], + "live_readiness": finalist["live_readiness"], + "promotion_status": finalist["promotion_status"], + "overrides": finalist["overrides"], + "orbqs_score": orbqs_score, + "orbqs_breakdown": orbqs_breakdown, + "train_sharpe": finalist["train_metrics_obj"].sharpe_ratio, + "valid_sharpe": finalist["valid_metrics_obj"].sharpe_ratio, + "test_sharpe": finalist["test_metrics_obj"].sharpe_ratio, + "train_trade_count": finalist["train_metrics_obj"].total_trades, + "valid_trade_count": finalist["valid_metrics_obj"].total_trades, + "test_trade_count": finalist["test_metrics_obj"].total_trades, + "train_return_pct": (finalist["train_metrics_obj"].total_return_pct or 0.0) * 100.0, + "valid_return_pct": (finalist["valid_metrics_obj"].total_return_pct or 0.0) * 100.0, + "test_return_pct": (finalist["test_metrics_obj"].total_return_pct or 0.0) * 100.0, + } + ranking.append(rank_row) + wf_by_candidate[candidate_id] = wf_summary.model_dump(mode="json") + scenarios_by_candidate[candidate_id] = scenario_results + overfit_by_candidate[candidate_id] = overfit_tests + _write_stage5_payload( + stage5_path, + ranking=ranking, + walk_forward=wf_by_candidate, + scenarios=scenarios_by_candidate, + overfit=overfit_by_candidate, + ) + print( + f" finalist {idx}/{len(robustness_finalists)} " + f"{finalist['candidate_id']} ORBQS={orbqs_score if orbqs_score is not None else 'NA'}" + ) + else: + print("[5/6] No eligible finalists after locked test; skipping robustness.") + _write_stage5_payload( + stage5_path, + ranking=[], + walk_forward={}, + scenarios={}, + overfit={}, + ) + + ranking.sort(key=_orbqs_rank_key, reverse=True) + top_candidate_from_rank, overall, live_ready = _select_champions(ranking) + top_candidate = top_candidate_from_rank or ( + _finalist_summary_row(top_split_candidate) if top_split_candidate is not None else None + ) + + write_json(output_root / "split_results.json", split_rows) + write_json(output_root / "ranking.json", ranking) + + summary = { + "output_dir": str(output_root), + "config": str(config_path), + "periods": { + "train": [periods.train_start, periods.train_end], + "valid": [periods.valid_start, periods.valid_end], + "test": [periods.test_start, periods.test_end], + "robustness": [periods.robustness_start, periods.robustness_end], + }, + "quick": quick, + "research_mode": "hypothesis_first", + "beam_width": beam_width, + "wf_train_days": wf_train_days, + "wf_test_days": wf_test_days, + "wf_step_days": wf_step_days, + "permutations": permutations, + "coarse_sample_days": len(coarse_train_days), + "stage1_counts": {family: len(rows) for family, rows in stage1.items()}, + "stage2_counts": {family: len(rows) for family, rows in stage2.items()}, + "robustness_candidates": len(robustness_finalists), + "top_candidate": top_candidate, + "overall_champion": overall, + "best_live_ready_champion": live_ready, + "top_candidate_id": top_candidate["candidate_id"] if top_candidate else None, + "overall_champion_candidate_id": overall["candidate_id"] if overall else None, + "best_live_ready_candidate_id": live_ready["candidate_id"] if live_ready else None, + "top_candidate_orbqs": top_candidate.get("orbqs_score") if top_candidate else None, + "overall_champion_orbqs": overall.get("orbqs_score") if overall else None, + "best_live_ready_orbqs": live_ready.get("orbqs_score") if live_ready else None, + "ranking_count": len(ranking), + } + write_json(output_root / "summary.json", summary) + + if overall is not None: + champion_params = build_orb_params(base_config, overall["overrides"]) + champion_config = base_config.model_dump() + champion_config["orb_strategy"] = champion_params.model_dump() + (output_root / "champion.yaml").write_text( + yaml.safe_dump(champion_config, sort_keys=False, allow_unicode=False) + ) + write_json(output_root / "walk_forward_summary.json", wf_by_candidate[overall["candidate_id"]]) + write_json(output_root / "scenario_report.json", scenarios_by_candidate[overall["candidate_id"]]) + write_json(output_root / "overfit_report.json", overfit_by_candidate[overall["candidate_id"]]) + else: + (output_root / "champion.yaml").write_text("") + write_json(output_root / "walk_forward_summary.json", {}) + write_json(output_root / "scenario_report.json", {}) + write_json(output_root / "overfit_report.json", {}) + + print(f"[6/6] Complete → {output_root}") + return { + "output_dir": str(output_root), + "summary": summary, + } + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + prog="fithia2 intraday-orb-lab", + description="ORB research lab orchestration (coarse → rerank → test → robustness → rank)", + ) + parser.add_argument("--config", default=DEFAULT_CONFIG, help="Base ORB config YAML or slug") + parser.add_argument("--quick", action="store_true", help="Use a reduced search grid for smoke tests") + parser.add_argument("--beam-width", type=int, default=6, help="Per-family survivor cap for coarse stage") + parser.add_argument("--train-start", default=None) + parser.add_argument("--train-end", default=None) + parser.add_argument("--valid-start", default=None) + parser.add_argument("--valid-end", default=None) + parser.add_argument("--test-start", default=None) + parser.add_argument("--test-end", default=None) + parser.add_argument("--robustness-start", default=None) + parser.add_argument("--robustness-end", default=None) + parser.add_argument("--wf-train-days", type=int, default=None) + parser.add_argument("--wf-test-days", type=int, default=None) + parser.add_argument("--permutations", type=int, default=None) + parser.add_argument("--output-dir", default=None, help="Optional output directory") + return parser.parse_args() + + +def main() -> None: + args = parse_args() + periods = _resolve_period_overrides(args) + result = asyncio.run( + run_lab( + args.config, + periods=periods, + quick=args.quick, + beam_width=args.beam_width, + wf_train_days=args.wf_train_days, + wf_test_days=args.wf_test_days, + permutations=args.permutations, + output_dir=args.output_dir, + ) + ) + print(json.dumps(result, indent=2)) + + +if __name__ == "__main__": + main() diff --git a/apps/intraday_bt/momentum_quarterly.py b/apps/intraday_bt/momentum_quarterly.py new file mode 100644 index 0000000..066c2f6 --- /dev/null +++ b/apps/intraday_bt/momentum_quarterly.py @@ -0,0 +1,176 @@ +"""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() diff --git a/apps/intraday_bt/momentum_research.py b/apps/intraday_bt/momentum_research.py new file mode 100644 index 0000000..da8ffe4 --- /dev/null +++ b/apps/intraday_bt/momentum_research.py @@ -0,0 +1,841 @@ +"""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, + } diff --git a/apps/intraday_bt/momentum_wfv.py b/apps/intraday_bt/momentum_wfv.py new file mode 100644 index 0000000..eb40ef6 --- /dev/null +++ b/apps/intraday_bt/momentum_wfv.py @@ -0,0 +1,200 @@ +"""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() diff --git a/apps/intraday_bt/oracle.py b/apps/intraday_bt/oracle.py new file mode 100644 index 0000000..8c6f579 --- /dev/null +++ b/apps/intraday_bt/oracle.py @@ -0,0 +1,27 @@ +"""Shared Stock Oracle client helpers for ORB/intraday workflows.""" +from __future__ import annotations + +from libs.common.config import Settings +from libs.oracle_client import OracleClient + + +_INTRADAY_ORACLE_MIN_TIMEOUT = 90.0 + + +def intraday_oracle_timeout(settings: Settings) -> float: + """Return a safer timeout for heavy ORB historical bulk requests. + + Oracle may spend tens of seconds inside upstream Alpaca retries. The global + default timeout (`stock_oracle_timeout=30`) is fine for lighter PIT lookups + but too short for ORB multi-ticker intraday batches, which can trigger + client-side timeouts and singleton retry cascades. + """ + return max(float(settings.stock_oracle_timeout), _INTRADAY_ORACLE_MIN_TIMEOUT) + + +def make_intraday_oracle_client(settings: Settings) -> OracleClient: + """Build an Oracle client tuned for ORB/intraday research workloads.""" + return OracleClient( + base_url=settings.stock_oracle_url, + timeout=intraday_oracle_timeout(settings), + ) diff --git a/apps/intraday_bt/orb_research.py b/apps/intraday_bt/orb_research.py new file mode 100644 index 0000000..374ac00 --- /dev/null +++ b/apps/intraday_bt/orb_research.py @@ -0,0 +1,1271 @@ +"""Common ORB research helpers for streaming lab/evaluation workflows.""" +from __future__ import annotations + +import contextlib +import datetime as dt +import gzip +import hashlib +import io +import json +import pickle +import random +import statistics +import sys +from dataclasses import asdict +from dataclasses import dataclass +from dataclasses import field +from pathlib import Path +from typing import Any + +import yaml + +from libs.backtest.domain import ( + SplitResult, + WalkForwardAggregate, + WalkForwardFoldResult, + WalkForwardGapStats, + WalkForwardSummary, +) +from libs.backtest.tracker import compute_rqs, compute_wfqs_v2 +from libs.common.config import get_settings +from libs.intraday.cache import DailyBarCache, IntradayCache +from libs.intraday.domain import ( + BacktestParams, + CacheParams, + IntradayConfig, + IntradayMetrics, + ORBStrategyParams, + OutputParams, + UniverseParams, +) +from libs.intraday.features import enrich_daily_bars +from libs.intraday.metrics import IntradayMetricsAccumulator +from libs.intraday.orb_simulator import ORBSimulationState, run_orb_simulation_with_state +from libs.oracle_client import OracleClient + +from apps.intraday_bt.run import _chunk_trading_days_by_pairs, get_trading_days, _make_progress_bar +from apps.intraday_bt.sweep import apply_overrides +from libs.intraday.screener import ( + fetch_daily_bars_bulk, + fetch_intraday_bulk, + orb_pre_screen_candidates, + resolve_universe, +) + + +LAB_TRAIN_START = "2024-01-02" +LAB_TRAIN_END = "2024-12-31" +LAB_VALID_START = "2025-01-02" +LAB_VALID_END = "2025-12-31" +LAB_TEST_START = "2026-01-02" +LAB_TEST_END = "2026-03-31" +LAB_ROBUSTNESS_START = "2022-01-03" +LAB_ROBUSTNESS_END = "2023-12-29" +_ORB_RESEARCH_SNAPSHOT_VERSION = 1 +_ORB_PERIOD_METRICS_CACHE_VERSION = 1 +_ORB_TAPE_CACHE_VERSION = 1 + + +@dataclass(frozen=True) +class ORBResearchPeriods: + train_start: str = LAB_TRAIN_START + train_end: str = LAB_TRAIN_END + valid_start: str = LAB_VALID_START + valid_end: str = LAB_VALID_END + test_start: str = LAB_TEST_START + test_end: str = LAB_TEST_END + robustness_start: str = LAB_ROBUSTNESS_START + robustness_end: str = LAB_ROBUSTNESS_END + + +DEFAULT_ORB_RESEARCH_PERIODS = ORBResearchPeriods() + + +def _load_orb_ticker_sectors(tickers: list[str]) -> dict[str, str]: + settings = get_settings() + path = Path(settings.data_root) / "cache" / "sector_cache.json" + if not path.exists(): + return {ticker: "UNKNOWN" for ticker in tickers} + try: + payload = json.loads(path.read_text()) + except Exception: + return {ticker: "UNKNOWN" for ticker in tickers} + return {ticker: str(payload.get(ticker) or "UNKNOWN") for ticker in tickers} + + +@dataclass +class ORBResearchContext: + config: IntradayConfig + tickers: list[str] + trading_days: list[str] + daily_bars: dict[str, list[dict]] + enrichment: dict[str, dict[str, dict]] + candidates: dict[str, list[str]] + cache: IntradayCache | None + daily_cache: DailyBarCache | None + eval_cache: ORBPeriodMetricsCache | None + tape_cache: ORBPreparedTapeStore | None + oracle_url: str + ticker_sectors: dict[str, str] = field(default_factory=dict) + vix_by_day: dict[str, float] | None = None + research_snapshot_key: str | None = None + + @property + def total_pairs(self) -> int: + return sum(len(v) for v in self.candidates.values()) + + +class ORBResearchSnapshotStore: + """Disk snapshot for expensive ORB 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" + + @staticmethod + def _signature_payload( + config: IntradayConfig, + *, + start_date: str, + end_date: str, + tickers: list[str], + trading_days: list[str], + ) -> dict[str, Any]: + orb = config.orb_strategy or ORBStrategyParams() + return { + "version": _ORB_RESEARCH_SNAPSHOT_VERSION, + "strategy_mode": config.strategy_mode, + "start_date": start_date, + "end_date": end_date, + "universe": config.universe.model_dump(mode="json"), + "context_filters": { + "min_price": orb.min_price, + "min_atr_14": orb.min_atr_14, + "min_avg_dollar_volume": orb.min_avg_dollar_volume, + "market_regime_ticker": getattr(orb, "market_regime_ticker", "SPY") or "SPY", + "market_regime_spy_threshold": orb.market_regime_spy_threshold, + }, + "tickers": tickers, + "trading_days": trading_days, + } + + @classmethod + def build_key( + cls, + config: IntradayConfig, + *, + start_date: str, + end_date: str, + tickers: list[str], + trading_days: list[str], + ) -> str: + payload = cls._signature_payload( + config, + start_date=start_date, + end_date=end_date, + 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") != _ORB_RESEARCH_SNAPSHOT_VERSION: + path.unlink(missing_ok=True) + return None + if payload.get("key") != key: + path.unlink(missing_ok=True) + return None + for required in ("tickers", "trading_days", "daily_bars", "enrichment", "candidates"): + if required not in 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"] = _ORB_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 + + +class ORBPeriodMetricsCache: + """Disk cache for expensive period-level ORB simulations.""" + + def __init__(self, root: str | Path) -> None: + self.root = Path(root) + + def _path(self, key: str) -> Path: + return self.root / key[:2] / f"{key}.json.gz" + + def _checkpoint_path(self, key: str) -> Path: + return self.root / key[:2] / f"{key}.checkpoint.json.gz" + + @classmethod + def build_key( + cls, + *, + research_snapshot_key: str, + orb_params: ORBStrategyParams, + trading_days: list[str], + shuffle_candidates_seed: int | None, + ) -> str: + payload = { + "version": _ORB_PERIOD_METRICS_CACHE_VERSION, + "research_snapshot_key": research_snapshot_key, + "orb_params": orb_params.model_dump(mode="json"), + "trading_day_count": len(trading_days), + "trading_day_start": trading_days[0] if trading_days else "", + "trading_day_end": trading_days[-1] if trading_days else "", + "trading_days_hash": hashlib.sha1( + ",".join(trading_days).encode("utf-8") + ).hexdigest(), + "shuffle_candidates_seed": shuffle_candidates_seed, + } + 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, "rt", encoding="utf-8") as fh: + payload = json.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") != _ORB_PERIOD_METRICS_CACHE_VERSION: + path.unlink(missing_ok=True) + return None + if payload.get("key") != key: + path.unlink(missing_ok=True) + return None + if "metrics" not in payload: + path.unlink(missing_ok=True) + return None + return payload + + def load_checkpoint(self, key: str) -> dict[str, Any] | None: + path = self._checkpoint_path(key) + if not path.exists(): + return None + try: + with gzip.open(path, "rt", encoding="utf-8") as fh: + payload = json.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") != _ORB_PERIOD_METRICS_CACHE_VERSION: + path.unlink(missing_ok=True) + return None + if payload.get("key") != key: + path.unlink(missing_ok=True) + return None + required = {"completed_chunks", "total_chunks", "chunk_layout", "accumulator", "sim_state"} + 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"] = _ORB_PERIOD_METRICS_CACHE_VERSION + record["key"] = key + with gzip.open(tmp, "wt", encoding="utf-8", compresslevel=3) as fh: + json.dump(record, fh, ensure_ascii=True) + tmp.replace(path) + return path + + def save_checkpoint(self, key: str, payload: dict[str, Any]) -> Path: + path = self._checkpoint_path(key) + path.parent.mkdir(parents=True, exist_ok=True) + tmp = path.with_suffix(".tmp") + record = dict(payload) + record["version"] = _ORB_PERIOD_METRICS_CACHE_VERSION + record["key"] = key + with gzip.open(tmp, "wt", encoding="utf-8", compresslevel=3) as fh: + json.dump(record, fh, ensure_ascii=True) + tmp.replace(path) + return path + + def clear_checkpoint(self, key: str) -> None: + self._checkpoint_path(key).unlink(missing_ok=True) + + +class ORBPreparedTapeStore: + """Prepared ORB tape cache to avoid rereading raw intraday parquet files.""" + + 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, + *, + research_snapshot_key: str, + trading_days: list[str], + candidates: dict[str, list[str]], + ) -> str: + ordered_candidates = { + day: list(candidates.get(day, [])) + for day in trading_days + if candidates.get(day) + } + payload = { + "version": _ORB_TAPE_CACHE_VERSION, + "research_snapshot_key": research_snapshot_key, + "trading_day_count": len(trading_days), + "trading_day_start": trading_days[0] if trading_days else "", + "trading_day_end": trading_days[-1] if trading_days else "", + "trading_days_hash": hashlib.sha1( + ",".join(trading_days).encode("utf-8") + ).hexdigest(), + "candidate_hash": hashlib.sha1( + json.dumps( + ordered_candidates, + sort_keys=True, + separators=(",", ":"), + default=str, + ).encode("utf-8") + ).hexdigest(), + } + 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") != _ORB_TAPE_CACHE_VERSION: + path.unlink(missing_ok=True) + return None + if payload.get("key") != key: + path.unlink(missing_ok=True) + return None + if "bars_by_day" not in 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"] = _ORB_TAPE_CACHE_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 resolve_orb_config(name_or_path: str) -> tuple[Path, IntradayConfig]: + """Resolve YAML path or slug to a validated IntradayConfig.""" + p = Path(name_or_path) + if p.exists() and p.suffix in {".yaml", ".yml"}: + yaml_path = p + else: + strategies_dir = Path("configs/intraday/strategies") + candidates = sorted(strategies_dir.glob(f"{name_or_path}*.yaml")) + if not candidates: + candidates = sorted(strategies_dir.glob(f"orb_{name_or_path}*.yaml")) + if not candidates: + raise FileNotFoundError( + f"Cannot find config for '{name_or_path}'. " + f"Provide a full YAML path or a slug matching files in {strategies_dir}/" + ) + yaml_path = candidates[0] + + raw = yaml.safe_load(yaml_path.read_text()) or {} + config = IntradayConfig( + strategy_mode=raw.get("strategy_mode", "orb"), + orb_strategy=ORBStrategyParams(**raw.get("orb_strategy", {})), + universe=UniverseParams(**raw.get("universe", {"source": "midlarge"})), + backtest=BacktestParams(**raw.get("backtest", {})), + cache=CacheParams(**raw.get("cache", {"enabled": True, "dir": "data/cache/intraday"})), + output=OutputParams(**raw.get("output", {})), + ) + if config.strategy_mode != "orb": + raise ValueError(f"{yaml_path} is not an ORB intraday config") + if config.orb_strategy is None: + config = config.model_copy(update={"orb_strategy": ORBStrategyParams()}) + return yaml_path, config + + +def force_simple_returns(config: IntradayConfig) -> IntradayConfig: + """Research runs default to simple returns regardless of the source YAML.""" + orb = (config.orb_strategy or ORBStrategyParams()).model_copy(update={"compound_returns": False}) + return config.model_copy(update={"orb_strategy": orb}) + + +async def build_orb_research_context( + config: IntradayConfig, + start_date: str, + end_date: str, + client: OracleClient, + *, + daily_concurrency: int = 3, + print_progress: bool = False, +) -> ORBResearchContext: + """Fetch shared ORB daily context once; intraday is fetched later per period chunk.""" + settings = get_settings() + orb_params = config.orb_strategy or ORBStrategyParams() + 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 + ) + eval_cache = ( + ORBPeriodMetricsCache(Path(config.cache.dir).with_name("orb_eval")) + if config.cache.enabled else None + ) + tape_cache = ( + ORBPreparedTapeStore(Path(config.cache.dir).with_name("orb_tape")) + if config.cache.enabled else None + ) + research_snapshot = ( + ORBResearchSnapshotStore(Path(config.cache.dir).with_name("orb_research")) + if config.cache.enabled else None + ) + + tickers = await resolve_universe(config.universe, client) + ticker_sectors = _load_orb_ticker_sectors(tickers) + 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 research_snapshot is not None: + snapshot_key = research_snapshot.build_key( + config, + start_date=start_date, + end_date=end_date, + tickers=tickers, + trading_days=trading_days, + ) + snapshot = research_snapshot.load(snapshot_key) + if snapshot is not None: + if print_progress: + print( + " Research snapshot hit: " + f"{len(snapshot['tickers'])} tickers, {len(snapshot['trading_days'])} days" + ) + return ORBResearchContext( + config=config, + tickers=list(snapshot["tickers"]), + trading_days=list(snapshot["trading_days"]), + daily_bars=dict(snapshot["daily_bars"]), + enrichment=dict(snapshot["enrichment"]), + candidates=dict(snapshot["candidates"]), + ticker_sectors=ticker_sectors, + cache=cache, + daily_cache=daily_cache, + eval_cache=eval_cache, + tape_cache=tape_cache, + oracle_url=settings.stock_oracle_url, + research_snapshot_key=snapshot_key, + ) + + warmup_start = (dt.date.fromisoformat(trading_days[0]) - dt.timedelta(days=90)).isoformat() + + 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, + warmup_start, + trading_days[-1], + client, + cache=daily_cache, + intraday_cache_fallback=cache, + concurrency=daily_concurrency, + progress_callback=_daily_progress if print_progress else None, + ) + if print_progress: + print(f"\r Daily: {len(daily_bars)}/{len(tickers)} tickers loaded") + + regime_ticker = getattr(orb_params, "market_regime_ticker", "SPY") or "SPY" + if orb_params.market_regime_spy_threshold is not None and regime_ticker not in daily_bars: + extra = await fetch_daily_bars_bulk( + [regime_ticker], + warmup_start, + trading_days[-1], + client, + cache=daily_cache, + intraday_cache_fallback=cache, + concurrency=1, + ) + daily_bars.update(extra) + + enrichment = enrich_daily_bars(daily_bars, trading_days) + candidates = orb_pre_screen_candidates( + daily_bars, + trading_days, + enrichment, + min_price=orb_params.min_price, + min_atr=orb_params.min_atr_14, + min_avg_dollar_vol=orb_params.min_avg_dollar_volume, + max_per_day=None, + ) + + # Fetch VIX if the strategy uses it + from apps.intraday_bt.run import _orb_strategy_uses_vix, _fetch_vix_by_day + orb_vix_by_day: dict[str, float] | None = None + if _orb_strategy_uses_vix(orb_params): + if print_progress: + print(" Fetching VIX regime series for ORB research...") + orb_vix_by_day = await _fetch_vix_by_day(client, trading_days) + + context = ORBResearchContext( + config=config, + tickers=tickers, + trading_days=trading_days, + daily_bars=daily_bars, + enrichment=enrichment, + candidates=candidates, + ticker_sectors=ticker_sectors, + vix_by_day=orb_vix_by_day, + cache=cache, + daily_cache=daily_cache, + eval_cache=eval_cache, + tape_cache=tape_cache, + oracle_url=settings.stock_oracle_url, + research_snapshot_key=snapshot_key, + ) + if research_snapshot is not None and snapshot_key is not None: + snapshot_path = research_snapshot.save( + snapshot_key, + { + "tickers": tickers, + "trading_days": trading_days, + "daily_bars": daily_bars, + "enrichment": enrichment, + "candidates": candidates, + }, + ) + if print_progress: + print(f" Research snapshot saved: {snapshot_path}") + return context + + +def filter_days(trading_days: list[str], start_date: str, end_date: str) -> list[str]: + return [day for day in trading_days if start_date <= day <= end_date] + + +def split_trading_days( + trading_days: list[str], + split_date: str | None = None, + train_ratio: float | None = None, +) -> tuple[list[str], list[str]]: + if split_date: + train = [d for d in trading_days if d < split_date] + test = [d for d in trading_days if d >= split_date] + elif train_ratio is not None: + split_idx = int(len(trading_days) * train_ratio) + train = trading_days[:split_idx] + test = trading_days[split_idx:] + else: + mid = len(trading_days) // 2 + train = trading_days[:mid] + test = trading_days[mid:] + return train, test + + +def generate_walk_forward_windows( + trading_days: list[str], + train_days: int, + test_days: int, + step_days: int | None = None, +) -> list[tuple[list[str], list[str]]]: + if step_days is None: + step_days = test_days + + windows: list[tuple[list[str], list[str]]] = [] + idx = 0 + while idx + train_days + test_days <= len(trading_days): + train = trading_days[idx : idx + train_days] + test = trading_days[idx + train_days : idx + train_days + test_days] + windows.append((train, test)) + idx += step_days + return windows + + +def resolve_lab_splits( + trading_days: list[str], + periods: ORBResearchPeriods = DEFAULT_ORB_RESEARCH_PERIODS, +) -> dict[str, list[str]]: + return { + "train": filter_days(trading_days, periods.train_start, periods.train_end), + "valid": filter_days(trading_days, periods.valid_start, periods.valid_end), + "test": filter_days(trading_days, periods.test_start, periods.test_end), + "robustness": filter_days(trading_days, periods.robustness_start, periods.robustness_end), + } + + +def build_orb_params(base_config: IntradayConfig, overrides: dict[str, Any] | None = None) -> ORBStrategyParams: + if not overrides: + return base_config.orb_strategy or ORBStrategyParams() + updated = apply_overrides(base_config, overrides) + return updated.orb_strategy or ORBStrategyParams() + + +@contextlib.contextmanager +def _candidate_shuffle(seed: int | None): + if seed is None: + yield + return + + import libs.intraday.orb_simulator as _orb_mod + + rng = random.Random(seed) + original = _orb_mod.compute_orb_candidates + + def _shuffled(*args: Any, **kwargs: Any) -> list[dict]: + candidates = list(original(*args, **kwargs)) + rng.shuffle(candidates) + return candidates + + _orb_mod.compute_orb_candidates = _shuffled + try: + yield + finally: + _orb_mod.compute_orb_candidates = original + + +def _chunk_layout_signature(chunks: list[list[str]]) -> list[dict[str, Any]]: + """Describe period chunk boundaries so checkpoint resumes can verify layout.""" + return [ + { + "start": chunk[0], + "end": chunk[-1], + "days": len(chunk), + } + for chunk in chunks + if chunk + ] + + +async def simulate_orb_period( + context: ORBResearchContext, + client: OracleClient, + orb_params: ORBStrategyParams, + trading_days: list[str], + *, + run_id: str = "", + shuffle_candidates_seed: int | None = None, + intraday_concurrency: int = 3, + max_pairs_per_chunk: int = 5_000, + progress_prefix: str = "", +) -> IntradayMetrics: + """Run a streaming ORB backtest over a subset of days using shared daily context.""" + cache_key: str | None = None + if ( + context.eval_cache is not None + and context.research_snapshot_key is not None + and trading_days + ): + cache_key = context.eval_cache.build_key( + research_snapshot_key=context.research_snapshot_key, + orb_params=orb_params, + trading_days=trading_days, + shuffle_candidates_seed=shuffle_candidates_seed, + ) + cached = context.eval_cache.load(cache_key) + if cached is not None: + metrics = IntradayMetrics.model_validate(cached["metrics"]) + if run_id and run_id != metrics.run_id: + metrics = metrics.model_copy(update={"run_id": run_id}) + if progress_prefix: + print( + f"{progress_prefix}cached metrics hit: " + f"{trading_days[0]} → {trading_days[-1]} ({len(trading_days)} days)" + ) + return metrics + + config = context.config.model_copy(update={"orb_strategy": orb_params}) + if not trading_days: + return IntradayMetricsAccumulator(config, run_id=run_id).finalize() + + chunks = _chunk_trading_days_by_pairs( + trading_days, + context.candidates, + max_pairs_per_chunk=max_pairs_per_chunk, + ) + chunk_layout = _chunk_layout_signature(chunks) + checkpoint_enabled = ( + context.eval_cache is not None + and cache_key is not None + and shuffle_candidates_seed is None + ) + accumulator = IntradayMetricsAccumulator(config, run_id=run_id) + sim_state = None + start_chunk_idx = 0 + if checkpoint_enabled: + checkpoint = context.eval_cache.load_checkpoint(cache_key) + if checkpoint is not None: + if checkpoint.get("chunk_layout") == chunk_layout: + accumulator = IntradayMetricsAccumulator.from_snapshot( + config, + checkpoint["accumulator"], + run_id=run_id, + ) + state_payload = checkpoint.get("sim_state") + if state_payload: + sim_state = ORBSimulationState(**state_payload) + start_chunk_idx = int(checkpoint.get("completed_chunks", 0)) + if progress_prefix: + print( + f"{progress_prefix}checkpoint resume hit: " + f"chunk {start_chunk_idx}/{len(chunks)}" + ) + else: + context.eval_cache.clear_checkpoint(cache_key) + + if start_chunk_idx >= len(chunks): + metrics = accumulator.finalize() + if cache_key is not None and context.eval_cache is not None: + context.eval_cache.save( + cache_key, + { + "metrics": metrics.model_dump(mode="json"), + }, + ) + context.eval_cache.clear_checkpoint(cache_key) + return metrics + + progress_enabled = bool(progress_prefix) + + with _candidate_shuffle(shuffle_candidates_seed): + for chunk_idx, day_chunk in enumerate(chunks[start_chunk_idx:], start=start_chunk_idx + 1): + chunk_candidates = { + day: context.candidates.get(day, []) + for day in day_chunk + if context.candidates.get(day) + } + tape_key = None + chunk_intraday: dict[str, dict[str, list[dict]]] | None = None + fetched_from_source = False + if ( + context.tape_cache is not None + and context.research_snapshot_key is not None + and chunk_candidates + ): + tape_key = context.tape_cache.build_key( + research_snapshot_key=context.research_snapshot_key, + trading_days=day_chunk, + candidates=chunk_candidates, + ) + tape_payload = context.tape_cache.load(tape_key) + if tape_payload is not None: + chunk_intraday = tape_payload["bars_by_day"] + if progress_enabled: + pair_count = sum(len(v) for v in chunk_candidates.values()) + print( + f"{progress_prefix}tape hit {chunk_idx}/{len(chunks)}: " + f"{day_chunk[0]} → {day_chunk[-1]} ({pair_count} pairs)" + ) + + if progress_enabled: + pair_count = sum(len(v) for v in chunk_candidates.values()) + print( + f"{progress_prefix}batch {chunk_idx}/{len(chunks)}: " + f"{day_chunk[0]} → {day_chunk[-1]} ({pair_count} pairs)" + ) + + def _intraday_progress(done: int, total: int, hits: int, calls: int) -> None: + if not progress_enabled: + return + sys.stdout.write( + f"\r{progress_prefix} {_make_progress_bar(done, total)} cache:{hits} api:{calls}" + ) + sys.stdout.flush() + + if chunk_intraday is None: + chunk_intraday = await fetch_intraday_bulk( + chunk_candidates, + client, + context.cache, + concurrency=intraday_concurrency, + progress_callback=_intraday_progress if progress_enabled else None, + ) + fetched_from_source = True + if tape_key is not None and context.tape_cache is not None: + context.tape_cache.save( + tape_key, + { + "bars_by_day": chunk_intraday, + }, + ) + if progress_enabled and chunk_candidates and fetched_from_source: + print() + + stderr_buffer = io.StringIO() + with contextlib.redirect_stderr(stderr_buffer): + chunk_results, sim_state = run_orb_simulation_with_state( + chunk_intraday, + day_chunk, + orb_params, + context.enrichment, + ticker_sectors=context.ticker_sectors, + state=sim_state, + vix_by_day=context.vix_by_day, + ) + accumulator.extend(chunk_results) + del chunk_intraday + if checkpoint_enabled and cache_key is not None and context.eval_cache is not None: + context.eval_cache.save_checkpoint( + cache_key, + { + "completed_chunks": chunk_idx, + "total_chunks": len(chunks), + "chunk_layout": chunk_layout, + "accumulator": accumulator.snapshot(), + "sim_state": asdict(sim_state) if sim_state is not None else None, + }, + ) + + metrics = accumulator.finalize() + if ( + context.eval_cache is not None + and cache_key is not None + ): + context.eval_cache.save( + cache_key, + { + "metrics": metrics.model_dump(mode="json"), + }, + ) + context.eval_cache.clear_checkpoint(cache_key) + return metrics + + +async def simulate_orb_overrides( + context: ORBResearchContext, + client: OracleClient, + overrides: dict[str, Any] | None, + trading_days: list[str], + *, + run_id: str = "", + shuffle_candidates_seed: int | None = None, + progress_prefix: str = "", + intraday_concurrency: int = 3, + max_pairs_per_chunk: int = 5_000, +) -> tuple[ORBStrategyParams, IntradayMetrics]: + params = build_orb_params(context.config, overrides) + metrics = await simulate_orb_period( + context, + client, + params, + trading_days, + run_id=run_id, + shuffle_candidates_seed=shuffle_candidates_seed, + progress_prefix=progress_prefix, + intraday_concurrency=intraday_concurrency, + max_pairs_per_chunk=max_pairs_per_chunk, + ) + return params, metrics + + +def intraday_metrics_to_split_result( + metrics: IntradayMetrics, + orb_params: ORBStrategyParams, +) -> SplitResult: + """Adapt intraday metrics into the generic split schema used by RQS/WFQS.""" + 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) + + # Intraday ORB does not maintain exposure aggregates today; use a bounded proxy + # from strategy constraints so RQS does not zero out the exposure dimensions. + gross_proxy = min( + 40.0, + max( + 8.0, + orb_params.max_position_pct * 100.0 * max(1.0, min(float(orb_params.max_candidates), 4.0)), + ), + ) + 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 + ) + + 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 build_walk_forward_summary( + folds: list[dict[str, Any]], + *, + train_days: int, + test_days: int, + step_days: int, +) -> WalkForwardSummary: + fold_models: list[WalkForwardFoldResult] = [] + train_results: list[SplitResult] = [] + test_results: list[SplitResult] = [] + + for idx, fold in enumerate(folds, start=1): + train_result = fold["train_result"] + test_result = fold["test_result"] + train_results.append(train_result) + test_results.append(test_result) + fold_models.append( + WalkForwardFoldResult( + fold_index=idx, + train_start=dt.date.fromisoformat(fold["train_start"]), + train_end=dt.date.fromisoformat(fold["train_end"]), + test_start=dt.date.fromisoformat(fold["test_start"]), + test_end=dt.date.fromisoformat(fold["test_end"]), + train_run_id=train_result.run_id, + test_run_id=test_result.run_id, + train_metrics=train_result, + test_metrics=test_result, + ) + ) + + def _aggregate(results: list[SplitResult]) -> WalkForwardAggregate: + returns = [r.total_return_pct for r in results if r.total_return_pct is not None] + profit_factors = [r.profit_factor for r in results if r.profit_factor is not None] + drawdowns = [r.max_drawdown_pct for r in results if r.max_drawdown_pct is not None] + trade_counts = [float(r.trade_count) for r in results] + win_rates = [r.win_rate for r in results if r.win_rate is not None] + positives = [r for r in returns if r > 0] + return WalkForwardAggregate( + mean_return_pct=round(statistics.mean(returns), 2) if returns else None, + median_return_pct=round(statistics.median(returns), 2) if returns else None, + worst_return_pct=round(min(returns), 2) if returns else None, + positive_fold_rate_pct=round(len(positives) / len(results) * 100.0, 1) if results else None, + mean_profit_factor=round(statistics.mean(profit_factors), 2) if profit_factors else None, + mean_max_drawdown_pct=round(statistics.mean(drawdowns), 2) if drawdowns else None, + mean_trade_count=round(statistics.mean(trade_counts), 1) if trade_counts else None, + mean_win_rate=round(statistics.mean(win_rates), 4) if win_rates else None, + ) + + train_aggregate = _aggregate(train_results) + test_aggregate = _aggregate(test_results) + train_test_gaps = [ + abs((train.total_return_pct or 0.0) - (test.total_return_pct or 0.0)) + for train, test in zip(train_results, test_results, strict=False) + ] + test_returns = [r.total_return_pct for r in test_results if r.total_return_pct is not None] + fold_cv = None + if len(test_returns) >= 2: + mean_return = statistics.mean(test_returns) + if abs(mean_return) > 1e-9: + fold_cv = round(statistics.stdev(test_returns) / abs(mean_return), 3) + + gap_stats = WalkForwardGapStats( + mean_train_test_return_gap_pct=round(statistics.mean(train_test_gaps), 2) if train_test_gaps else None, + worst_train_test_return_gap_pct=round(max(train_test_gaps), 2) if train_test_gaps else None, + fold_return_cv=fold_cv, + ) + + return WalkForwardSummary( + train_days=train_days, + test_days=test_days, + step_days=step_days, + fold_count=len(fold_models), + folds=fold_models, + train_aggregate=train_aggregate, + test_aggregate=test_aggregate, + gap_stats=gap_stats, + engine_reliability_ratio=1.0, + ) + + +def compute_orb_overfit_score( + is_oos_test: dict[str, Any], + walk_forward_test: dict[str, Any], + plateau_test: dict[str, Any], + permutation_test: dict[str, Any], +) -> tuple[float, dict[str, float]]: + """Blend the 4 ORB overfit diagnostics into a 0-100 score.""" + retention_score = max(0.0, min(100.0, float(is_oos_test.get("retention_pct", 0.0)))) + + mean_sharpe = float(walk_forward_test.get("mean_sharpe", 0.0)) + cv = walk_forward_test.get("cv") + wf_stability = 0.0 + if cv is not None: + wf_stability = max(0.0, min(100.0, 100.0 * (1.0 - min(float(cv), 2.0) / 2.0))) + if mean_sharpe <= 0: + wf_stability *= 0.6 + + plateau_params = plateau_test.get("params", []) + plateau_values = [float(p.get("plateau", 0.0)) * 100.0 for p in plateau_params] + plateau_score = statistics.mean(plateau_values) if plateau_values else 0.0 + + p_value = permutation_test.get("p_value") + permutation_score = 0.0 + if p_value is not None: + permutation_score = max(0.0, min(100.0, 100.0 * (1.0 - min(float(p_value), 0.50) / 0.50))) + + score = ( + 0.35 * retention_score + + 0.25 * wf_stability + + 0.20 * plateau_score + + 0.20 * permutation_score + ) + breakdown = { + "is_oos_retention": round(retention_score, 1), + "wf_stability": round(wf_stability, 1), + "parameter_plateau": round(plateau_score, 1), + "candidate_permutation": round(permutation_score, 1), + } + return round(score, 1), breakdown + + +def compute_orb_rrs(scenario_results: dict[str, dict[str, Any]]) -> tuple[float, dict[str, float]]: + """ORB-specific Regime Robustness Score (0-100).""" + sharpes = { + key: value.get("sharpe_ratio", 0.0) + for key, value in scenario_results.items() + if "sharpe_ratio" in value + } + drawdowns = { + key: abs(value.get("max_drawdown_pct", 0.0)) + for key, value in scenario_results.items() + if "max_drawdown_pct" in value + } + + bear_sr = sharpes.get("bear_2022") + bear_survival = 50.0 if bear_sr is None else min(100.0, max(0.0, 100.0 + bear_sr * 25.0)) + + n_positive = sum(1 for s in sharpes.values() if s > 0) + breadth = 100.0 * n_positive / max(len(sharpes), 1) + + worst_dd = max(drawdowns.values()) if drawdowns else 0.0 + drawdown_resilience = max(0.0, min(100.0, 100.0 * (1.0 - worst_dd / 50.0))) + + oos_sr = sharpes.get("oos_2026") + oos_integrity = 50.0 if oos_sr is None else min(100.0, max(0.0, 50.0 + oos_sr * 25.0)) + + stability_keys = [k for k in ["recovery_2023h1", "bull_2023h2", "mixed_2024", "bull_2025"] if k in sharpes] + if len(stability_keys) >= 2: + stab_sharpes = [sharpes[k] for k in stability_keys] + mean_s = statistics.mean(stab_sharpes) + std_s = statistics.stdev(stab_sharpes) + if abs(mean_s) > 0.01: + cv = std_s / abs(mean_s) + stability = max(0.0, min(100.0, 100.0 * (1.0 - min(cv, 2.0) / 2.0))) + else: + stability = max(0.0, 50.0 - std_s * 25.0) + else: + stability = 50.0 + + rrs = ( + 0.25 * bear_survival + + 0.25 * breadth + + 0.20 * drawdown_resilience + + 0.20 * oos_integrity + + 0.10 * stability + ) + breakdown = { + "bear_survival": round(bear_survival, 1), + "breadth": round(breadth, 1), + "drawdown_resilience": round(drawdown_resilience, 1), + "oos_integrity": round(oos_integrity, 1), + "stability": round(stability, 1), + } + return round(rrs, 1), breakdown + + +def compute_orbqs( + train_result: SplitResult | None, + valid_result: SplitResult | None, + test_result: SplitResult | None, + walk_forward_summary: WalkForwardSummary | None, + scenario_results: dict[str, dict[str, Any]], + overfit_tests: dict[str, dict[str, Any]], +) -> tuple[float | None, dict[str, Any]]: + """Compute ORB Quality Score (ORBQS) using split/WF/scenario/overfit components.""" + rqs_score, rqs_breakdown = compute_rqs(train_result, valid_result, test_result) + wfqs_score, wfqs_breakdown = compute_wfqs_v2(walk_forward_summary) + rrs_score, rrs_breakdown = compute_orb_rrs(scenario_results) if scenario_results else (None, {}) + overfit_score, overfit_breakdown = compute_orb_overfit_score( + overfit_tests.get("is_oos", {}), + overfit_tests.get("walk_forward", {}), + overfit_tests.get("param_plateau", {}), + overfit_tests.get("permutation", {}), + ) + + valid_trades = valid_result.trade_count if valid_result else 0 + test_trades = test_result.trade_count if test_result else 0 + valid_test_trades = valid_trades + test_trades + if valid_test_trades < 80: + activity_factor = 0.70 + elif valid_test_trades < 150: + activity_factor = 0.85 + else: + activity_factor = 1.00 + + if rqs_score is None or wfqs_score is None or rrs_score is None: + return None, { + "rqs": rqs_score, + "wfqs_v2": wfqs_score, + "rrs": rrs_score, + "overfit": overfit_score, + "activity_factor": activity_factor, + } + + orbqs = ( + 0.45 * rqs_score + + 0.30 * wfqs_score + + 0.15 * rrs_score + + 0.10 * overfit_score + ) * activity_factor + + breakdown = { + "rqs": round(rqs_score, 1), + "wfqs_v2": round(wfqs_score, 1), + "rrs": round(rrs_score, 1), + "overfit": round(overfit_score, 1), + "activity_factor": round(activity_factor, 2), + "valid_test_trade_count": valid_test_trades, + "rqs_breakdown": rqs_breakdown, + "wfqs_v2_breakdown": wfqs_breakdown, + "rrs_breakdown": rrs_breakdown, + "overfit_breakdown": overfit_breakdown, + } + return round(orbqs, 1), breakdown + + +def write_json(path: Path, payload: Any) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(payload, indent=2, ensure_ascii=True, default=str)) + + +def analyze_skip_reasons(json_path: str, focus_date: str | None = None) -> None: + """Print a diagnostic summary of why days were skipped in a backtest run. + + Args: + json_path: Path to the backtest JSON output file. + focus_date: Optional date ('YYYY-MM-DD') for detailed per-day drill-down. + """ + data = json.loads(Path(json_path).read_text()) + + skip_breakdown = data.get("skip_breakdown", {}) + agg_filter_stats = data.get("aggregate_filter_stats", {}) + daily_summary = data.get("daily_summary", []) + total_days = len(daily_summary) + + print(f"\n=== Skip Reason Breakdown ({total_days} trading days) ===") + for key in ("traded", "traded_no_fill", "market_regime", "breadth", "vix_gate", + "rolling_loss", "spy_trend", "no_candidates", "below_min_candidates"): + n = skip_breakdown.get(key, 0) + if n > 0: + pct = n / total_days * 100 + print(f" {key:<25} {n:>4} ({pct:.1f}%)") + + if agg_filter_stats: + print("\n=== Aggregate Candidate Filter Drops (all days combined) ===") + for key in ("gap", "rvol", "atr", "dolvol", "dir", "no_bars", "late", "price"): + n = agg_filter_stats.get(key, 0) + if n > 0: + print(f" {key:<10} {n:>6} tickers dropped") + + # V20: soft-day breakdown + soft_days = [r for r in daily_summary if r.get("is_soft_day")] + if soft_days: + soft_trades = [t for t in data.get("trades", []) if any( + r["date"] == t.get("date") and r.get("is_soft_day") for r in daily_summary + )] + soft_wins = sum(1 for t in soft_trades if t.get("pnl", 0) > 0) + soft_wr = soft_wins / len(soft_trades) * 100 if soft_trades else 0.0 + soft_pnl = sum(t.get("pnl", 0) for t in soft_trades) + print(f"\n=== V20 Soft-Day Breakdown ({len(soft_days)} days) ===") + print(f" soft days : {len(soft_days)}") + print(f" soft-day trades : {len(soft_trades)}") + print(f" soft-day WR : {soft_wr:.1f}%") + print(f" soft-day total PnL : {soft_pnl:+.2f}") + print(f" (thesis: soft-day WR>=52% and PnL>0 = viable)") + + if focus_date: + match = next((r for r in daily_summary if r["date"] == focus_date), None) + if match is None: + print(f"\nfocus_date {focus_date}: NOT FOUND in results") + return + print(f"\n=== Focus Date: {focus_date} ===") + print(f" skip_reason : {match.get('skip_reason') or '(traded)'}") + print(f" candidates_found : {match.get('candidates_found', 0)}") + print(f" trades : {match.get('trades', 0)}") + print(f" daily_pnl : {match.get('daily_pnl', 0):+.2f}") + print(f" regime_scaler : {match.get('regime_scaler')}") + print(f" breadth_scaler : {match.get('breadth_scaler')}") + print(f" is_soft_day : {match.get('is_soft_day')}") + fs = match.get("candidate_filter_stats") + if fs: + print(" candidate_filter_stats:") + for k, v in sorted(fs.items(), key=lambda x: -x[1]): + if v > 0: + print(f" {k:<10} {v:>4} dropped") + trades_detail = [t for t in data.get("trades", []) if t.get("date") == focus_date] + if trades_detail: + print(" selected tickers:") + for t in trades_detail: + ticker = t.get("ticker", "?") + pnl = t.get("pnl", 0) + pnl_r = t.get("pnl_r", 0) + print(f" {ticker:<8} pnl={pnl:+.2f} R={pnl_r:+.2f}") diff --git a/apps/intraday_bt/overfit_check.py b/apps/intraday_bt/overfit_check.py new file mode 100644 index 0000000..6b39160 --- /dev/null +++ b/apps/intraday_bt/overfit_check.py @@ -0,0 +1,427 @@ +"""ORB strategy overfitting analysis with streaming intraday simulation.""" +from __future__ import annotations + +import argparse +import asyncio +import json +import statistics +import sys +import time +from pathlib import Path +from typing import Any + +import yaml + +from apps.intraday_bt.oracle import make_intraday_oracle_client +from rich import box +from rich.console import Console +from rich.panel import Panel +from rich.progress import BarColumn, Progress, SpinnerColumn, TextColumn, TimeElapsedColumn + +from libs.backtest.domain import SplitResult, WalkForwardSummary +from libs.common.config import get_settings +from libs.intraday.domain import ORBStrategyParams +from libs.oracle_client import OracleClient + +from apps.intraday_bt.orb_research import ( + build_orb_research_context, + force_simple_returns, + generate_walk_forward_windows, + resolve_orb_config, + simulate_orb_period, + split_trading_days, +) +from apps.intraday_bt.run import _latest_backtest_date + +_console = Console(width=120) + +_DEFAULT_SPLIT_DATE = "2026-01-01" +_DEFAULT_START_DATE = "2022-01-01" +_WF_TRAIN_DAYS = 252 +_WF_TEST_DAYS = 63 + + +async def run_is_oos_test( + context, + client: OracleClient, + config, + split_date: str, +) -> dict[str, Any]: + train_days, test_days = split_trading_days(context.trading_days, split_date=split_date) + if not train_days or not test_days: + return {"verdict": "SKIP", "notes": "Not enough data to split"} + + orb_params = config.orb_strategy or ORBStrategyParams() + is_metrics = await simulate_orb_period(context, client, orb_params, train_days, run_id="is") + oos_metrics = await simulate_orb_period(context, client, orb_params, test_days, run_id="oos") + is_sharpe = is_metrics.sharpe_ratio or 0.0 + oos_sharpe = oos_metrics.sharpe_ratio or 0.0 + retention = 0.0 if is_sharpe <= 0 else oos_sharpe / is_sharpe + + if retention >= 0.60: + verdict = "PASS" + elif retention >= 0.40: + verdict = "WARN" + else: + verdict = "FAIL" + + return { + "verdict": verdict, + "is_sharpe": round(is_sharpe, 3), + "oos_sharpe": round(oos_sharpe, 3), + "retention_pct": round(retention * 100, 1), + "is_period": f"{train_days[0]} → {train_days[-1]} ({len(train_days)} days)", + "oos_period": f"{test_days[0]} → {test_days[-1]} ({len(test_days)} days)", + } + + +def summarize_is_oos_from_results( + is_result: SplitResult, + oos_result: SplitResult, + *, + is_period: str | None = None, + oos_period: str | None = None, +) -> dict[str, Any]: + """Reuse already-computed train/test split results for IS/OOS retention.""" + is_sharpe = is_result.sharpe_ratio or 0.0 + oos_sharpe = oos_result.sharpe_ratio or 0.0 + retention = 0.0 if is_sharpe <= 0 else oos_sharpe / is_sharpe + + if retention >= 0.60: + verdict = "PASS" + elif retention >= 0.40: + verdict = "WARN" + else: + verdict = "FAIL" + + payload = { + "verdict": verdict, + "is_sharpe": round(is_sharpe, 3), + "oos_sharpe": round(oos_sharpe, 3), + "retention_pct": round(retention * 100, 1), + "source": "split_results", + } + if is_period is not None: + payload["is_period"] = is_period + if oos_period is not None: + payload["oos_period"] = oos_period + return payload + + +async def run_walk_forward_test( + context, + client: OracleClient, + config, + train_days: int = _WF_TRAIN_DAYS, + test_days: int = _WF_TEST_DAYS, +) -> dict[str, Any]: + windows = generate_walk_forward_windows(context.trading_days, train_days, test_days) + if len(windows) < 2: + return {"verdict": "SKIP", "notes": f"Need ≥ {train_days + 2 * test_days} days, got {len(context.trading_days)}"} + + orb_params = config.orb_strategy or ORBStrategyParams() + sharpes: list[float] = [] + for i, (_, wf_test) in enumerate(windows, start=1): + metrics = await simulate_orb_period(context, client, orb_params, wf_test, run_id=f"wf{i:02d}") + sharpe = metrics.sharpe_ratio or 0.0 + sharpes.append(sharpe) + sys.stdout.write(f"\r WF window {i}/{len(windows)}: test Sharpe={sharpe:.2f} ") + sys.stdout.flush() + print() + + mean_sr = statistics.mean(sharpes) + std_sr = statistics.stdev(sharpes) if len(sharpes) > 1 else 0.0 + cv = std_sr / abs(mean_sr) if abs(mean_sr) > 0.01 else float("inf") + n_positive = sum(1 for sharpe in sharpes if sharpe > 0) + if mean_sr > 0.5 and cv < 0.80: + verdict = "PASS" + elif mean_sr > 0 and cv < 1.5: + verdict = "WARN" + else: + verdict = "FAIL" + + return { + "verdict": verdict, + "n_windows": len(windows), + "mean_sharpe": round(mean_sr, 3), + "std_sharpe": round(std_sr, 3), + "cv": round(cv, 3), + "n_positive": n_positive, + "window_sharpes": [round(s, 3) for s in sharpes], + } + + +def summarize_walk_forward_test_from_summary(wf_summary: WalkForwardSummary) -> dict[str, Any]: + """Reuse an already-built walk-forward summary for the overfit verdict.""" + sharpes = [fold.test_metrics.sharpe_ratio or 0.0 for fold in wf_summary.folds] + if len(sharpes) < 2: + return { + "verdict": "SKIP", + "notes": f"Need ≥ 2 folds, got {len(sharpes)}", + } + + mean_sr = statistics.mean(sharpes) + std_sr = statistics.stdev(sharpes) if len(sharpes) > 1 else 0.0 + cv = std_sr / abs(mean_sr) if abs(mean_sr) > 0.01 else float("inf") + n_positive = sum(1 for sharpe in sharpes if sharpe > 0) + if mean_sr > 0.5 and cv < 0.80: + verdict = "PASS" + elif mean_sr > 0 and cv < 1.5: + verdict = "WARN" + else: + verdict = "FAIL" + + return { + "verdict": verdict, + "n_windows": len(sharpes), + "mean_sharpe": round(mean_sr, 3), + "std_sharpe": round(std_sr, 3), + "cv": round(cv, 3), + "n_positive": n_positive, + "window_sharpes": [round(s, 3) for s in sharpes], + "source": "walk_forward_summary", + } + + +async def run_param_plateau_test( + context, + client: OracleClient, + config, + quick: bool = False, + param_names: list[str] | None = None, +) -> dict[str, Any]: + orb_params = config.orb_strategy or ORBStrategyParams() + n_values = 3 if quick else 5 + params_to_test = [ + ("atr_stop_multiplier", orb_params.atr_stop_multiplier, 0.4), + ("breakeven_at_r", orb_params.breakeven_at_r, 0.4), + ("trailing_stop_atr_multiplier", orb_params.trailing_stop_atr_multiplier, 0.4), + ] + if param_names is not None: + selected = set(param_names) + params_to_test = [item for item in params_to_test if item[0] in selected] + results = [] + + for param_name, base_value, spread in params_to_test: + lo = base_value * (1 - spread) + hi = base_value * (1 + spread) + test_values = [lo + (hi - lo) * i / (n_values - 1) for i in range(n_values)] + sharpes: list[float] = [] + for value in test_values: + modified = orb_params.model_copy(update={param_name: round(value, 6)}) + metrics = await simulate_orb_period( + context, + client, + modified, + context.trading_days, + run_id=f"{param_name[:4]}_{value:.4f}", + ) + sharpe = metrics.sharpe_ratio or 0.0 + sharpes.append(sharpe) + sys.stdout.write(f"\r {param_name}={value:.4f} → Sharpe={sharpe:.2f} ") + sys.stdout.flush() + print() + mean_sharpe = statistics.mean(sharpes) + plateau = max(0.0, 1.0 - statistics.stdev(sharpes) / abs(mean_sharpe)) if len(sharpes) > 1 and abs(mean_sharpe) > 0.01 else 0.0 + verdict = "PASS" if plateau >= 0.70 else "WARN" if plateau >= 0.40 else "FAIL" + results.append({ + "param": param_name, + "base_value": base_value, + "test_values": [round(v, 5) for v in test_values], + "sharpe_values": [round(s, 3) for s in sharpes], + "plateau": round(plateau, 3), + "verdict": verdict, + }) + + verdicts = [result["verdict"] for result in results] + if all(v == "PASS" for v in verdicts): + overall = "PASS" + elif "FAIL" in verdicts: + overall = "FAIL" + else: + overall = "WARN" + return {"verdict": overall, "params": results} + + +async def run_permutation_test( + context, + client: OracleClient, + config, + n_permutations: int = 30, +) -> dict[str, Any]: + orb_params = config.orb_strategy or ORBStrategyParams() + observed = await simulate_orb_period(context, client, orb_params, context.trading_days, run_id="perm_obs") + observed_sharpe = observed.sharpe_ratio or 0.0 + _console.print(f" [dim]Observed Sharpe (real ranking): {observed_sharpe:.3f}[/dim]") + + null_sharpes: list[float] = [] + with Progress( + SpinnerColumn(), + TextColumn("[progress.description]{task.description}"), + BarColumn(), + "{task.completed}/{task.total}", + TimeElapsedColumn(), + console=_console, + transient=True, + ) as progress: + task = progress.add_task("Permutation test", total=n_permutations) + for i in range(n_permutations): + metrics = await simulate_orb_period( + context, + client, + orb_params, + context.trading_days, + run_id=f"perm{i:03d}", + shuffle_candidates_seed=42 + i, + ) + null_sharpes.append(metrics.sharpe_ratio or 0.0) + progress.advance(task) + + p_value = sum(1 for sr in null_sharpes if sr >= observed_sharpe) / max(len(null_sharpes), 1) + null_sorted = sorted(null_sharpes) + p95_idx = min(len(null_sorted) - 1, int(0.95 * (len(null_sorted) - 1))) if null_sorted else 0 + null_p95 = null_sorted[p95_idx] if null_sorted else 0.0 + null_median = statistics.median(null_sharpes) if null_sharpes else 0.0 + + if p_value < 0.05: + verdict = "PASS" + elif p_value < 0.20: + verdict = "WARN" + else: + verdict = "FAIL" + return { + "verdict": verdict, + "observed_sharpe": round(observed_sharpe, 3), + "n_permutations": n_permutations, + "null_median": round(null_median, 3), + "null_p95": round(null_p95, 3), + "p_value": round(p_value, 4), + "null_sharpes": [round(sr, 3) for sr in null_sharpes], + } + + +def _vc(verdict: str) -> str: + return {"PASS": "green", "WARN": "yellow", "FAIL": "red", "SKIP": "dim"}.get(verdict, "white") + + +def _verdict_tag(verdict: str) -> str: + color = _vc(verdict) + return f"[{color}][{verdict}][/{color}]" + + +def _print_report( + config_path: Path, + test1: dict[str, Any], + test2: dict[str, Any], + test3: dict[str, Any], + test4: dict[str, Any], + elapsed: float, +) -> None: + weights = {"PASS": 1.0, "WARN": 0.5, "FAIL": 0.0, "SKIP": None} + scores = [weights[t.get("verdict", "SKIP")] for t in [test1, test2, test3, test4]] + scores = [score for score in scores if score is not None] + overall_score = int(statistics.mean(scores) * 100) if scores else 0 + pass_count = sum(1 for t in [test1, test2, test3, test4] if t.get("verdict") == "PASS") + overall_verdict = "PASS" if pass_count >= 3 else "WARN" if pass_count >= 2 else "FAIL" + color = _vc(overall_verdict) + + _console.print() + _console.print( + Panel( + f"[bold]ORB OVERFITTING ANALYSIS[/bold]\n" + f"Config: [cyan]{config_path}[/cyan]\n\n" + f"Overall: [{color} bold]{overall_verdict}[/{color} bold] " + f"Score: [bold]{overall_score}/100[/bold] ({elapsed:.0f}s)", + box=box.DOUBLE, + width=100, + ) + ) + for label, payload in [ + ("1. IS/OOS Retention", test1), + ("2. Walk-Forward Stability", test2), + ("3. Parameter Plateau", test3), + ("4. Candidate Ranking Permutation", test4), + ]: + _console.print(f"\n [bold]{label}[/bold] {_verdict_tag(payload.get('verdict', 'SKIP'))}") + _console.print(f" {json.dumps(payload, ensure_ascii=True)}") + + +async def _async_main(args: argparse.Namespace) -> int: + t0 = time.time() + config_path, config = resolve_orb_config(args.config) + config = force_simple_returns(config) + + settings = get_settings() + async with make_intraday_oracle_client(settings) as client: + context = await build_orb_research_context( + config, + args.start, + args.end or _latest_backtest_date().isoformat(), + client, + print_progress=True, + ) + + skip = set(args.skip.split(",")) if args.skip else set() + test1: dict[str, Any] = {"verdict": "SKIP"} + test2: dict[str, Any] = {"verdict": "SKIP"} + test3: dict[str, Any] = {"verdict": "SKIP"} + test4: dict[str, Any] = {"verdict": "SKIP"} + + if "is_oos" not in skip: + _console.print("\n[bold][1/4] IS/OOS Retention...[/bold]") + test1 = await run_is_oos_test(context, client, config, args.split_date) + if "wf" not in skip: + _console.print("\n[bold][2/4] Walk-Forward Stability...[/bold]") + wf_train = 126 if args.quick else _WF_TRAIN_DAYS + wf_test = 42 if args.quick else _WF_TEST_DAYS + test2 = await run_walk_forward_test(context, client, config, train_days=wf_train, test_days=wf_test) + if "plateau" not in skip: + _console.print("\n[bold][3/4] Parameter Plateau...[/bold]") + test3 = await run_param_plateau_test(context, client, config, quick=args.quick) + if "perm" not in skip: + _console.print(f"\n[bold][4/4] Candidate Permutation (N={args.permutations})...[/bold]") + test4 = await run_permutation_test(context, client, config, n_permutations=args.permutations) + + elapsed = time.time() - t0 + _print_report(config_path, test1, test2, test3, test4, elapsed) + + if args.output_json: + report = { + "config": str(config_path), + "period": f"{context.trading_days[0]} → {context.trading_days[-1]}", + "split_date": args.split_date, + "elapsed_seconds": round(elapsed, 1), + "tests": { + "is_oos": test1, + "walk_forward": test2, + "param_plateau": test3, + "permutation": test4, + }, + } + Path(args.output_json).parent.mkdir(parents=True, exist_ok=True) + Path(args.output_json).write_text(json.dumps(report, indent=2)) + _console.print(f"\n[dim]Report saved → {args.output_json}[/dim]") + return 0 + + +def main() -> None: + parser = argparse.ArgumentParser( + prog="fithia2 intraday-overfit-check", + description="ORB strategy overfitting analysis (IS/OOS, WFV, param plateau, permutation)", + ) + parser.add_argument("--config", "-c", required=True, help="YAML config path or strategy slug") + parser.add_argument("--split-date", default=_DEFAULT_SPLIT_DATE, help=f"IS/OOS split date (default: {_DEFAULT_SPLIT_DATE})") + parser.add_argument("--start", default=_DEFAULT_START_DATE, help=f"Start of backtest window (default: {_DEFAULT_START_DATE})") + parser.add_argument("--end", default=None, help="End of backtest window (default: latest available)") + parser.add_argument("--quick", action="store_true", help="Quick mode: fewer WF windows, 3-point plateau, 15 permutations") + parser.add_argument("--permutations", type=int, default=50, help="Candidate permutation test iterations") + parser.add_argument("--skip", default="", help="Comma-separated tests to skip: is_oos,wf,plateau,perm") + parser.add_argument("--output-json", default=None, help="Save report as JSON to this path") + args = parser.parse_args() + if args.quick: + args.permutations = 15 + raise SystemExit(asyncio.run(_async_main(args))) + + +if __name__ == "__main__": + main() diff --git a/apps/intraday_bt/run.py b/apps/intraday_bt/run.py new file mode 100644 index 0000000..e7d62c9 --- /dev/null +++ b/apps/intraday_bt/run.py @@ -0,0 +1,2092 @@ +"""Morning Momentum Intraday Backtester — Main Entry Point. + +Usage: + # Default run (sp500, 40 trading days, default params) + python -m apps.intraday_bt.run + + # Override params inline + python -m apps.intraday_bt.run --days 200 --universe midlarge --top-n 5 --stop-loss -0.03 + + # Custom config file + python -m apps.intraday_bt.run --config configs/intraday/default.yaml + + # Parameter sweep + python -m apps.intraday_bt.run --sweep configs/intraday/sweep_basic.yaml + + # Cache management + python -m apps.intraday_bt.run --no-cache + python -m apps.intraday_bt.run --refresh-cache + + # Verbose daily output + python -m apps.intraday_bt.run --verbose +""" +from __future__ import annotations + +import argparse +import asyncio +import json +import pickle +import re +import sys +import uuid +from datetime import date, timedelta +from pathlib import Path + +import yaml + +from apps.intraday_bt.oracle import make_intraday_oracle_client +from libs.common.config import get_settings +from libs.common.time_utils import is_trading_day, to_eastern, trading_days_between, utc_now +from libs.intraday.cache import DailyBarCache, IntradayCache +from libs.intraday.catalyst import ( + AttentionEventCache, + FilingEventCache, + fetch_attention_features_bulk, + fetch_filing_event_features_bulk, +) +from libs.intraday.domain import ( + BacktestParams, + CacheParams, + IntradayConfig, + ORBStrategyParams, + OutputParams, + StrategyParams, + UniverseParams, +) +from libs.intraday.features import compute_gap_pct, enrich_daily_bars +from libs.intraday.metrics import ( + compute_metrics, + format_daily_breakdown, + format_summary, + format_sweep_comparison, + format_top_trades, + write_results, +) +from libs.intraday.screener import ( + fetch_daily_bars_bulk, + fetch_intraday_bulk, + momentum_intraday_first_candidates, + momentum_pre_screen_candidates, + orb_pre_screen_candidates, + pre_screen_candidates, + resolve_universe, +) +from libs.intraday.simulator import ( + _bar_at_offset, + _dollar_volume_up_to_bar, + _market_open_ts, + _volume_up_to_bar, + filter_market_hours, + run_simulation, +) +from libs.oracle_client import CompanyService +from libs.oracle_client.fred import FredService + + +# ── Config Loading ───────────────────────────────────────────────────────── + + +def load_config(path: str | None) -> IntradayConfig: + """Load config from YAML file or return defaults.""" + if path is None: + default_path = Path("configs/intraday/default.yaml") + if default_path.exists(): + path = str(default_path) + else: + return IntradayConfig() + + with open(path) as f: + raw = yaml.safe_load(f) or {} + + strategy_mode = raw.get("strategy_mode", "momentum") + strategy = StrategyParams(**raw.get("strategy", {})) + universe = UniverseParams(**raw.get("universe", {})) + backtest = BacktestParams(**raw.get("backtest", {})) + cache = CacheParams(**raw.get("cache", {})) + output = OutputParams(**raw.get("output", {})) + + orb_strategy = None + if strategy_mode == "orb": + orb_strategy = ORBStrategyParams(**raw.get("orb_strategy", {})) + + return IntradayConfig( + strategy_mode=strategy_mode, + strategy=strategy, + orb_strategy=orb_strategy, + universe=universe, + backtest=backtest, + cache=cache, + output=output, + ) + + +def apply_cli_overrides(config: IntradayConfig, args: argparse.Namespace) -> IntradayConfig: + """Apply CLI argument overrides to config.""" + strategy_dict = config.strategy.model_dump() + universe_dict = config.universe.model_dump() + backtest_dict = config.backtest.model_dump() + cache_dict = config.cache.model_dump() + output_dict = config.output.model_dump() + + if args.days is not None: + backtest_dict["lookback_trading_days"] = args.days + if getattr(args, "start", None) is not None: + backtest_dict["start_date"] = args.start + if getattr(args, "end", None) is not None: + backtest_dict["end_date"] = args.end + if args.universe is not None: + universe_dict["source"] = args.universe + if args.top_n is not None: + strategy_dict["top_n"] = args.top_n + if args.stop_loss is not None: + strategy_dict["stop_loss_pct"] = None if args.stop_loss.lower() == "none" else float(args.stop_loss) + if args.entry_min is not None: + strategy_dict["entry_minutes_after_open"] = args.entry_min + if args.exit_min is not None: + strategy_dict["exit_minutes_before_close"] = args.exit_min + if args.min_gain is not None: + strategy_dict["min_morning_gain_pct"] = args.min_gain + if args.no_cache: + cache_dict["enabled"] = False + if args.verbose: + output_dict["verbose"] = True + if args.output_dir is not None: + output_dict["dir"] = args.output_dir + + strategy_mode = config.strategy_mode + if hasattr(args, "strategy") and args.strategy is not None: + strategy_mode = args.strategy + + orb_strategy = config.orb_strategy + if strategy_mode == "orb" and orb_strategy is None: + orb_strategy = ORBStrategyParams() + + # Override compound_returns / initial_capital / daily_budget_reset via CLI + compound_override = getattr(args, "compound_returns", None) + capital_override = getattr(args, "initial_capital", None) + reset_override = getattr(args, "daily_budget_reset", None) + if strategy_mode == "orb" and orb_strategy is not None: + if compound_override is not None or capital_override is not None or reset_override is not None: + orb_dict = orb_strategy.model_dump() + if compound_override is not None: + orb_dict["compound_returns"] = compound_override + if capital_override is not None: + orb_dict["initial_capital"] = capital_override + if reset_override is not None: + orb_dict["daily_budget_reset"] = reset_override + orb_strategy = ORBStrategyParams(**orb_dict) + elif strategy_mode != "orb": + if compound_override is not None or capital_override is not None or reset_override is not None: + if compound_override is not None: + strategy_dict["compound_returns"] = compound_override + if capital_override is not None: + strategy_dict["initial_capital"] = capital_override + if reset_override is not None: + strategy_dict["daily_budget_reset"] = reset_override + + return IntradayConfig( + strategy_mode=strategy_mode, + strategy=StrategyParams(**strategy_dict), + orb_strategy=orb_strategy, + universe=UniverseParams(**universe_dict), + backtest=BacktestParams(**backtest_dict), + cache=CacheParams(**cache_dict), + output=OutputParams(**output_dict), + ) + + +# ── Trading Day Resolution ───────────────────────────────────────────────── + + +async def get_trading_days( + client: OracleClient, + start_date: str | None, + end_date: str | None, + lookback: int, +) -> list[str]: + """Resolve the list of trading days for the backtest period.""" + # Use the local NYSE calendar for deterministic date resolution. + # This avoids long-range Oracle timeouts when resolving multi-year periods. + today = _latest_backtest_date() + if end_date: + end = date.fromisoformat(end_date) + else: + end = today + + if start_date: + start = date.fromisoformat(start_date) + else: + # Fetch extra calendar days to account for weekends/holidays. + start = end - timedelta(days=lookback * 2) + + days = [d.isoformat() for d in trading_days_between(start, end)] + + # If an explicit start_date was pinned, return all days in range (no lookback cap) + if start_date: + return days + + # Otherwise return last N trading days + return days[-lookback:] + + +def _latest_backtest_date(now_et=None) -> date: + """Return the latest completed date safe for historical backtests. + + - Trading day after 16:00 ET: include today + - Trading day before 16:00 ET: stop at yesterday + - Non-trading day: use today as upper bound; the NYSE calendar trims to the + last completed trading session automatically. + """ + if now_et is None: + now_et = to_eastern(utc_now()) + today = now_et.date() + if not is_trading_day(today) or now_et.hour >= 16: + return today + return today - timedelta(days=1) + + +def _latest_completed_trading_day(now_et=None) -> date: + """Return the most recent completed trading session date.""" + latest = _latest_backtest_date(now_et) + while not is_trading_day(latest): + latest -= timedelta(days=1) + return latest + + +def _load_ticker_sectors(tickers: list[str]) -> dict[str, str]: + """Load cached sector labels for intraday basket diversification filters.""" + settings = get_settings() + path = Path(settings.data_root) / "cache" / "sector_cache.json" + if not path.exists(): + return {ticker: "UNKNOWN" for ticker in tickers} + try: + payload = json.loads(path.read_text()) + except Exception: + return {ticker: "UNKNOWN" for ticker in tickers} + return {ticker: str(payload.get(ticker) or "UNKNOWN") for ticker in tickers} + + +def _write_ticker_sector_cache(payload: dict[str, str]) -> None: + settings = get_settings() + path = Path(settings.data_root) / "cache" / "sector_cache.json" + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(dict(sorted(payload.items())), indent=2)) + + +def _sector_info_is_placeholder(info: object) -> bool: + sector = getattr(info, "sector", None) + industry = getattr(info, "industry", None) + exchange = getattr(info, "exchange", None) + market_cap = getattr(info, "market_cap", None) + return ( + sector == "Technology" + and industry == "Software" + and exchange is None + and market_cap is None + ) + + +async def _load_ticker_sectors_with_oracle( + tickers: list[str], + client, + *, + concurrency: int = 12, +) -> dict[str, str]: + """Load sector labels from cache, backfilling missing values from Oracle.""" + if not tickers: + return {} + + result = _load_ticker_sectors(tickers) + missing = sorted( + ticker + for ticker, sector in result.items() + if not sector or str(sector).upper() == "UNKNOWN" + ) + if not missing or client is None: + return result + + settings = get_settings() + path = Path(settings.data_root) / "cache" / "sector_cache.json" + try: + cache_payload = json.loads(path.read_text()) if path.exists() else {} + except Exception: + cache_payload = {} + + semaphore = asyncio.Semaphore(max(1, concurrency)) + company_svc = CompanyService(client) + + async def _fetch_sector(ticker: str) -> tuple[str, str | None]: + async with semaphore: + try: + info = await company_svc.get_company(ticker) + except Exception: + return ticker, None + if _sector_info_is_placeholder(info): + return ticker, None + sector = str(getattr(info, "sector", None) or "").strip() + if not sector or sector.upper() == "UNKNOWN": + return ticker, None + return ticker, sector + + updated = False + tasks = [asyncio.create_task(_fetch_sector(ticker)) for ticker in missing] + for task in asyncio.as_completed(tasks): + ticker, sector = await task + if not sector: + continue + result[ticker] = sector + cache_payload[ticker] = sector + updated = True + + if updated: + _write_ticker_sector_cache(cache_payload) + return result + + +def _load_orb_ticker_sectors(tickers: list[str]) -> dict[str, str]: + """Backward-compatible alias for ORB paths.""" + return _load_ticker_sectors(tickers) + + +def _orb_strategy_uses_catalyst(params: ORBStrategyParams) -> bool: + return ( + params.engine_family == "stocks_in_play_dual_regime" + or params.require_event_flag + or params.weight_event_catalyst > 0 + ) + + +def _orb_strategy_uses_attention(params: ORBStrategyParams) -> bool: + return ( + params.engine_family == "stocks_in_play_dual_regime" + or params.weight_attention_wiki > 0 + or params.weight_attention_news > 0 + or params.attention_min_wiki_spike_10d is not None + or params.attention_min_wiki_zscore_20d is not None + or params.attention_min_article_count_3d is not None + or params.attention_min_us_article_count_3d is not None + or params.attention_min_resolver_confidence is not None + ) + + +def _orb_strategy_uses_vix(params: ORBStrategyParams) -> bool: + return any( + value is not None and value != default + for value, default in [ + (params.max_vix, None), + (params.vix_size_scale_low, None), + (params.vix_size_scale_high, None), + ] + ) or params.vix_size_scale_min != 1.0 + + +def _merge_orb_event_features( + enrichment: dict[str, dict[str, dict]], + event_features: dict[str, dict[str, dict]], +) -> None: + for ticker, day_map in event_features.items(): + ticker_enrichment = enrichment.setdefault(ticker, {}) + for day, features in day_map.items(): + ticker_day = ticker_enrichment.setdefault(day, {}) + ticker_day.update(features) + + +def _merge_orb_attention_features( + enrichment: dict[str, dict[str, dict]], + attention_features: dict[str, dict[str, dict]], +) -> None: + for ticker, day_map in attention_features.items(): + ticker_enrichment = enrichment.setdefault(ticker, {}) + for day, features in day_map.items(): + ticker_day = ticker_enrichment.setdefault(day, {}) + ticker_day.update(features) + + +def _orb_candidate_event_tickers( + candidates: dict[str, list[str]], + enrichment: dict[str, dict[str, dict]], + params: ORBStrategyParams, +) -> list[str]: + """Reduce catalyst fetches to the most relevant daily stocks-in-play names.""" + selected: set[str] = set() + min_abs_gap = getattr(params, "min_abs_gap_pct", None) + per_day_limit = max(int(getattr(params, "max_candidates", 20) * 2), 12) + for day, day_tickers in candidates.items(): + ranked: list[tuple[float, str]] = [] + for ticker in day_tickers: + ticker_day = enrichment.get(ticker, {}).get(day, {}) + prev_close = ticker_day.get("prev_close") + today_open = ticker_day.get("today_open") + if prev_close and today_open and prev_close > 0: + abs_gap = abs((today_open - prev_close) / prev_close) + if min_abs_gap is not None and abs_gap < min_abs_gap: + continue + ranked.append((abs_gap, ticker)) + ranked.sort(reverse=True) + for _, ticker in ranked[:per_day_limit]: + selected.add(ticker) + return sorted(selected) + + +def _orb_candidate_event_pairs( + candidates: dict[str, list[str]], + enrichment: dict[str, dict[str, dict]], + params: ORBStrategyParams, +) -> list[tuple[str, str]]: + selected: list[tuple[str, str]] = [] + min_abs_gap = getattr(params, "min_abs_gap_pct", None) + per_day_limit = max(int(getattr(params, "max_candidates", 20) * 2), 12) + for day, day_tickers in candidates.items(): + ranked: list[tuple[float, str]] = [] + for ticker in day_tickers: + ticker_day = enrichment.get(ticker, {}).get(day, {}) + prev_close = ticker_day.get("prev_close") + today_open = ticker_day.get("today_open") + if prev_close and today_open and prev_close > 0: + abs_gap = abs((today_open - prev_close) / prev_close) + if min_abs_gap is not None and abs_gap < min_abs_gap: + continue + ranked.append((abs_gap, ticker)) + ranked.sort(reverse=True) + for _, ticker in ranked[:per_day_limit]: + selected.append((ticker, day)) + return selected + + +def _momentum_strategy_uses_daily_enrichment(params: StrategyParams) -> bool: + return any( + value is not None and value != default + for value, default in [ + (params.min_gap_pct, None), + (params.max_gap_pct, None), + (params.min_volume_ratio_14d, None), + (params.min_ret_5d, None), + (params.min_entropy_20d, None), + (params.max_entropy_20d, None), + (params.entropy_size_scale_low, None), + (params.entropy_size_scale_high, None), + ] + ) or params.use_five_sleeves + + +def _momentum_strategy_uses_catalyst(params: StrategyParams) -> bool: + return ( + params.candidate_require_event_flag + or params.candidate_min_event_score is not None + or params.candidate_weight_event_score > 0 + or params.candidate_intraday_weight_event_score > 0 + ) + + +def _momentum_strategy_uses_attention(params: StrategyParams) -> bool: + return ( + params.candidate_weight_attention_wiki > 0 + or params.candidate_weight_attention_news > 0 + or params.candidate_intraday_weight_attention_wiki > 0 + or params.candidate_intraday_weight_attention_news > 0 + or params.candidate_min_attention_wiki_spike_10d is not None + or params.candidate_min_attention_article_count_3d is not None + or params.candidate_min_attention_us_article_count_3d is not None + or params.candidate_min_attention_resolver_confidence is not None + ) + + +def _momentum_strategy_uses_vix(params: StrategyParams) -> bool: + return any( + value is not None and value != default + for value, default in [ + (params.max_vix, None), + (params.vix_size_scale_low, None), + (params.vix_size_scale_high, None), + ] + ) or params.vix_size_scale_min != 1.0 + + +def _momentum_uses_historical_intraday_first(params: StrategyParams) -> bool: + return str(getattr(params, "candidate_source_mode", "daily_gap")).lower() == "intraday_first" + + +def _should_use_recent_live_scan( + strategy: StrategyParams, + trading_days: list[str], +) -> bool: + if strategy.recent_live_scan_days <= 0 or not trading_days: + return False + if len(trading_days) > strategy.recent_live_scan_days: + return False + latest_safe = _latest_backtest_date() + end_day = date.fromisoformat(trading_days[-1]) + delta_days = (latest_safe - end_day).days + return 0 <= delta_days <= strategy.recent_live_scan_days + + +def _recent_live_scan_universe(strategy: StrategyParams) -> UniverseParams: + return UniverseParams( + source="screener", + market_cap_min=strategy.recent_live_scan_market_cap_min, + avg_volume_min=strategy.recent_live_scan_avg_volume_min, + min_price=strategy.recent_live_scan_min_price, + ) + + +def _strategy_for_recent_live_scan( + strategy: StrategyParams, + *, + recent_live_scan: bool, +) -> StrategyParams: + """Apply recent-window-only overrides without affecting research/Q1 configs.""" + if not recent_live_scan: + return strategy + updates: dict[str, object] = {} + override_fields = [ + ("recent_live_scan_top_n", "top_n"), + ("recent_live_scan_min_morning_gain_pct", "min_morning_gain_pct"), + ("recent_live_scan_max_morning_gain_pct", "max_morning_gain_pct"), + ("recent_live_scan_min_confirmation_return_pct", "min_confirmation_return_pct"), + ("recent_live_scan_min_entry_dollar_volume", "min_entry_dollar_volume"), + ("recent_live_scan_max_gap_pct", "max_gap_pct"), + ("recent_live_scan_max_entropy_20d", "max_entropy_20d"), + ("recent_live_scan_use_slow_ignite_sleeve", "use_slow_ignite_sleeve"), + ("recent_live_scan_slow_ignite_weight", "slow_ignite_weight"), + ("recent_live_scan_slow_ignite_min_gain_pct", "slow_ignite_min_gain_pct"), + ("recent_live_scan_slow_ignite_max_gain_pct", "slow_ignite_max_gain_pct"), + ("recent_live_scan_slow_ignite_min_entry_dollar_volume", "slow_ignite_min_entry_dollar_volume"), + ("recent_live_scan_slow_ignite_max_entropy_20d", "slow_ignite_max_entropy_20d"), + ("recent_live_scan_use_liquid_largecap_sleeve", "use_liquid_largecap_sleeve"), + ("recent_live_scan_liquid_largecap_weight", "liquid_largecap_weight"), + ("recent_live_scan_liquid_largecap_min_gain_pct", "liquid_largecap_min_gain_pct"), + ("recent_live_scan_liquid_largecap_max_gain_pct", "liquid_largecap_max_gain_pct"), + ("recent_live_scan_liquid_largecap_min_confirmation_return_pct", "liquid_largecap_min_confirmation_return_pct"), + ("recent_live_scan_liquid_largecap_min_entry_dollar_volume", "liquid_largecap_min_entry_dollar_volume"), + ("recent_live_scan_liquid_largecap_min_avg_dollar_vol_30d", "liquid_largecap_min_avg_dollar_vol_30d"), + ("recent_live_scan_liquid_largecap_max_entropy_20d", "liquid_largecap_max_entropy_20d"), + ] + for source_field, target_field in override_fields: + value = getattr(strategy, source_field, None) + if value is not None: + updates[target_field] = value + if not updates: + return strategy + return strategy.model_copy(update=updates) + + +def _should_use_recent_intraday_first_scan(trading_days: list[str]) -> bool: + """Recent live-scan windows should use intraday-first candidate generation. + + Using daily-first on 1-5 day sanity windows misses obvious leaders such as + recent Yahoo gainers that are absent from the static research universe or + do not pass a daily pre-screen despite strong same-day intraday momentum. + """ + return bool(trading_days) + + +def _merge_momentum_event_features( + enrichment: dict[str, dict[str, dict]], + event_features: dict[str, dict[str, dict]], +) -> None: + for ticker, day_map in event_features.items(): + ticker_enrichment = enrichment.setdefault(ticker, {}) + for day, features in day_map.items(): + ticker_day = ticker_enrichment.setdefault(day, {}) + ticker_day.update(features) + + +def _merge_momentum_attention_features( + enrichment: dict[str, dict[str, dict]], + attention_features: dict[str, dict[str, dict]], +) -> None: + for ticker, day_map in attention_features.items(): + ticker_enrichment = enrichment.setdefault(ticker, {}) + for day, features in day_map.items(): + ticker_day = ticker_enrichment.setdefault(day, {}) + ticker_day.update(features) + + +def _momentum_preliminary_candidates( + daily_bars: dict[str, list[dict]], + trading_days: list[str], + enrichment: dict[str, dict[str, dict]], + threshold: float, + strategy: StrategyParams, +) -> dict[str, list[str]]: + return momentum_pre_screen_candidates( + daily_bars, + trading_days, + enrichment, + threshold=threshold, + max_per_day=None, + strategy=None, + ) + + +def _momentum_intraday_seed_candidates( + daily_bars: dict[str, list[dict]], + trading_days: list[str], + enrichment: dict[str, dict[str, dict]], + strategy: StrategyParams, + *, + default_threshold: float, + use_signal_features: bool = False, +) -> dict[str, list[str]]: + seed_threshold = strategy.candidate_seed_threshold + seed_max_per_day = strategy.candidate_seed_max_per_day + if not _momentum_uses_historical_intraday_first(strategy): + seed_threshold = default_threshold + seed_max_per_day = max(strategy.top_n * 20, 120) + return momentum_pre_screen_candidates( + daily_bars, + trading_days, + enrichment, + threshold=seed_threshold, + max_per_day=seed_max_per_day, + strategy=strategy if use_signal_features else None, + ) + + +def _augment_momentum_seed_candidates_with_liquid_overlay( + candidates: dict[str, list[str]], + daily_bars: dict[str, list[dict]], + trading_days: list[str], + enrichment: dict[str, dict[str, dict]], + strategy: StrategyParams, +) -> dict[str, list[str]]: + slots = int(getattr(strategy, "candidate_seed_liquid_overlay_slots", 0) or 0) + leader_slots = int(getattr(strategy, "candidate_seed_leader_overlay_slots", 0) or 0) + if slots <= 0 and leader_slots <= 0: + return {day: list(day_tickers) for day, day_tickers in candidates.items() if day_tickers} + + ticker_day_bar: dict[str, dict[str, dict]] = {} + for ticker, bars in daily_bars.items(): + day_map: dict[str, dict] = {} + for bar in bars: + day_map[str(bar["date"])[:10]] = bar + ticker_day_bar[ticker] = day_map + + result: dict[str, list[str]] = {} + min_gap = getattr(strategy, "candidate_seed_liquid_min_gap_pct", None) + max_gap = getattr(strategy, "candidate_seed_liquid_max_gap_pct", None) + min_avg_dollar_vol = getattr(strategy, "candidate_seed_liquid_min_avg_dollar_vol_30d", None) + min_ret_5d = getattr(strategy, "candidate_seed_liquid_min_ret_5d", None) + max_entropy = getattr(strategy, "candidate_seed_liquid_max_entropy_20d", None) + leader_min_gap = getattr(strategy, "candidate_seed_leader_min_gap_pct", None) + leader_max_gap = getattr(strategy, "candidate_seed_leader_max_gap_pct", None) + leader_min_avg_dollar_vol = getattr(strategy, "candidate_seed_leader_min_avg_dollar_vol_30d", None) + leader_min_ret_5d = getattr(strategy, "candidate_seed_leader_min_ret_5d", None) + leader_min_atr_pct = getattr(strategy, "candidate_seed_leader_min_atr_pct", None) + leader_max_entropy = getattr(strategy, "candidate_seed_leader_max_entropy_20d", None) + + for day in trading_days: + day_candidates = list(candidates.get(day, [])) + chosen = set(day_candidates) + overlay_ranked: list[tuple[float, float, float, str]] = [] + for ticker, date_map in ticker_day_bar.items(): + if ticker in chosen: + continue + if day not in date_map: + continue + info = enrichment.get(ticker, {}).get(day, {}) + gap_pct = info.get("gap_pct") + if gap_pct is None: + continue + if min_gap is not None and gap_pct < min_gap: + continue + if max_gap is not None and gap_pct > max_gap: + continue + avg_dollar_vol = info.get("avg_dollar_vol_30d") + if min_avg_dollar_vol is not None and ( + avg_dollar_vol is None or avg_dollar_vol < min_avg_dollar_vol + ): + continue + ret_5d = info.get("ret_5d") + if min_ret_5d is not None and (ret_5d is None or ret_5d < min_ret_5d): + continue + entropy_20d = info.get("entropy_20d") + if max_entropy is not None and ( + entropy_20d is None or entropy_20d > max_entropy + ): + continue + overlay_ranked.append( + ( + float(avg_dollar_vol or 0.0), + float(gap_pct or 0.0), + float(ret_5d or 0.0), + ticker, + ) + ) + if overlay_ranked: + ranked_overlay = [ticker for *_rest, ticker in sorted(overlay_ranked, reverse=True)[:slots]] + for ticker in ranked_overlay: + if ticker not in chosen: + day_candidates.append(ticker) + chosen.add(ticker) + leader_ranked: list[tuple[float, float, float, float, str]] = [] + for ticker, date_map in ticker_day_bar.items(): + if ticker in chosen: + continue + bar = date_map.get(day) + if not bar: + continue + info = enrichment.get(ticker, {}).get(day, {}) + gap_pct = info.get("gap_pct") + if leader_min_gap is not None and (gap_pct is None or gap_pct < leader_min_gap): + continue + if leader_max_gap is not None and (gap_pct is None or gap_pct > leader_max_gap): + continue + avg_dollar_vol = info.get("avg_dollar_vol_30d") + if leader_min_avg_dollar_vol is not None and ( + avg_dollar_vol is None or avg_dollar_vol < leader_min_avg_dollar_vol + ): + continue + ret_5d = info.get("ret_5d") + if leader_min_ret_5d is not None and (ret_5d is None or ret_5d < leader_min_ret_5d): + continue + entropy_20d = info.get("entropy_20d") + if leader_max_entropy is not None and ( + entropy_20d is None or entropy_20d > leader_max_entropy + ): + continue + atr_14 = info.get("atr_14") + open_price = bar.get("open") + atr_pct = ( + float(atr_14) / float(open_price) + if atr_14 is not None and open_price not in (None, 0) + else None + ) + if leader_min_atr_pct is not None and (atr_pct is None or atr_pct < leader_min_atr_pct): + continue + leader_ranked.append( + ( + float(ret_5d or 0.0), + float(avg_dollar_vol or 0.0), + float(atr_pct or 0.0), + -float(entropy_20d or 1.0), + ticker, + ) + ) + if leader_ranked: + ranked_leaders = [ + ticker for *_rest, ticker in sorted(leader_ranked, reverse=True)[:leader_slots] + ] + for ticker in ranked_leaders: + if ticker not in chosen: + day_candidates.append(ticker) + chosen.add(ticker) + if day_candidates: + result[day] = day_candidates + return result + + +def _momentum_candidate_event_tickers( + candidates: dict[str, list[str]], +) -> list[str]: + selected: set[str] = set() + for day_tickers in candidates.values(): + selected.update(day_tickers) + return sorted(selected) + + +def _momentum_candidate_event_pairs( + candidates: dict[str, list[str]], + enrichment: dict[str, dict[str, dict]], + strategy: StrategyParams, +) -> list[tuple[str, str]]: + per_day_limit = max(strategy.top_n * 15, 60) + selected: list[tuple[str, str]] = [] + for day, day_tickers in candidates.items(): + ranked = sorted( + day_tickers, + key=lambda ticker: ( + float(enrichment.get(ticker, {}).get(day, {}).get("gap_pct") or 0.0), + float(enrichment.get(ticker, {}).get(day, {}).get("ret_5d") or 0.0), + -float(enrichment.get(ticker, {}).get(day, {}).get("entropy_20d") or 1.0), + float(enrichment.get(ticker, {}).get(day, {}).get("avg_dollar_vol_30d") or 0.0), + ), + reverse=True, + ) + for ticker in ranked[:per_day_limit]: + selected.append((ticker, day)) + return selected + + +def _recent_intraday_first_candidates( + all_intraday: dict[str, dict[str, list[dict]]], + trading_days: list[str], + strategy: StrategyParams, +) -> dict[str, list[str]]: + """Build a recent-day candidate shortlist directly from intraday action. + + This path is only used for very recent sanity windows where a static universe and + daily-first pre-screen can miss obvious day leaders (for example names that are + absent from the research universe but present in today's Yahoo gainers list). + """ + result: dict[str, list[str]] = {} + shortlist_size = max(strategy.top_n * 25, strategy.recent_live_scan_max_candidates_per_day) + volume_gate = max(100_000, int((strategy.min_entry_volume or 0) * 0.5)) + liquid_dollar_gate = max( + 50_000_000.0, + float(strategy.min_entry_dollar_volume or 0.0) * 10.0, + ) + largecap_dollar_gate = max(liquid_dollar_gate, 100_000_000.0) + + for day in trading_days: + bars_by_ticker = all_intraday.get(day, {}) + if not bars_by_ticker: + continue + market_open = _market_open_ts(day) + fast_scored: list[tuple[float, float, str]] = [] + largecap_scored: list[tuple[float, float, float, str]] = [] + liquid_scored: list[tuple[float, float, float, str]] = [] + slow_scored: list[tuple[float, float, float, str]] = [] + for ticker, all_bars in bars_by_ticker.items(): + mkt_bars = filter_market_hours(all_bars) + if len(mkt_bars) < 5: + continue + open_price = float(mkt_bars[0].get("open", 0.0) or 0.0) + if open_price <= 0: + continue + entry_bar = _bar_at_offset(mkt_bars, market_open, strategy.entry_minutes_after_open) + if entry_bar is None: + continue + entry_price = float(entry_bar.get("close", 0.0) or 0.0) + if entry_price <= 0: + continue + gain_pct = (entry_price - open_price) / open_price + confirmation_bar = entry_bar + if strategy.confirmation_minutes_after_entry > 0: + confirmation_bar = _bar_at_offset( + mkt_bars, + market_open, + strategy.entry_minutes_after_open + strategy.confirmation_minutes_after_entry, + ) + if confirmation_bar is None: + continue + confirmation_price = float(confirmation_bar.get("close", 0.0) or 0.0) + if confirmation_price <= 0: + continue + confirmation_gain_pct = (confirmation_price - open_price) / open_price + confirmation_return = (confirmation_price - entry_price) / entry_price if entry_price > 0 else 0.0 + entry_ts = _parse_et_bar_timestamp(confirmation_bar["timestamp"]) + entry_volume = _volume_up_to_bar(mkt_bars, entry_ts) + if entry_volume < volume_gate: + continue + entry_dollar_volume = _dollar_volume_up_to_bar(mkt_bars, entry_ts) + if gain_pct > 0: + fast_scored.append((gain_pct, entry_volume, ticker)) + if ( + confirmation_gain_pct >= 0.002 + and confirmation_return >= -0.001 + and entry_dollar_volume >= largecap_dollar_gate + ): + largecap_scored.append((entry_dollar_volume, confirmation_return, confirmation_gain_pct, ticker)) + if ( + confirmation_gain_pct >= 0.003 + and confirmation_return >= 0.0 + and entry_dollar_volume >= liquid_dollar_gate + ): + liquid_scored.append((entry_dollar_volume, confirmation_gain_pct, confirmation_return, ticker)) + if ( + confirmation_gain_pct >= 0.003 + and confirmation_gain_pct < max(strategy.min_morning_gain_pct, 0.015) + and confirmation_return >= 0.0 + and entry_dollar_volume >= liquid_dollar_gate + ): + slow_scored.append((confirmation_return, entry_dollar_volume, confirmation_gain_pct, ticker)) + if not fast_scored and not largecap_scored and not liquid_scored and not slow_scored: + continue + fast_slots = max(strategy.top_n * 8, int(shortlist_size * 0.45)) + largecap_slots = max(strategy.top_n * 4, int(shortlist_size * 0.20)) + liquid_slots = max(strategy.top_n * 4, int(shortlist_size * 0.20)) + slow_slots = max(strategy.top_n * 2, shortlist_size - fast_slots - largecap_slots - liquid_slots) + ranked_fast = [ticker for _gain, _vol, ticker in sorted(fast_scored, reverse=True)[:fast_slots]] + ranked_largecap = [ + ticker for _dvol, _conf_ret, _gain, ticker in sorted(largecap_scored, reverse=True)[:largecap_slots] + ] + ranked_liquid = [ + ticker for _dvol, _gain, _conf, ticker in sorted(liquid_scored, reverse=True)[:liquid_slots] + ] + ranked_slow = [ + ticker for _conf, _dvol, _gain, ticker in sorted(slow_scored, reverse=True)[:slow_slots] + ] + merged: list[str] = [] + for source in (ranked_fast, ranked_largecap, ranked_liquid, ranked_slow): + for ticker in source: + if ticker not in merged: + merged.append(ticker) + if len(merged) >= shortlist_size: + break + if len(merged) >= shortlist_size: + break + if merged: + result[day] = merged + return result + + +def _retain_recent_intraday_shortlist( + candidates: dict[str, list[str]], + daily_bars: dict[str, list[dict]], + *, + require_daily_features: bool, +) -> dict[str, list[str]]: + """Keep the intraday-first shortlist instead of overwriting it with daily pre-screen. + + When momentum strategies need daily enrichment, recent same-day leaders still need a + cached daily history row for gap/entropy/trend features. Otherwise retain the + intraday-first shortlist as-is. + """ + if not require_daily_features: + return {day: list(day_tickers) for day, day_tickers in candidates.items() if day_tickers} + retained: dict[str, list[str]] = {} + for day, day_tickers in candidates.items(): + kept = [ticker for ticker in day_tickers if ticker in daily_bars] + if kept: + retained[day] = kept + return retained + + +def _parse_et_bar_timestamp(timestamp: str): + from libs.intraday.simulator import _parse_ts + + return _parse_ts(timestamp) + + +async def _fetch_vix_by_day( + client: OracleClient, + trading_days: list[str], +) -> dict[str, float]: + if not trading_days: + return {} + try: + if not await client.health_check_fast(): + return _load_vix_from_local_macro_snapshots(trading_days) + except Exception: + return _load_vix_from_local_macro_snapshots(trading_days) + try: + fred = FredService(client) + response = await fred.get_observations("VIXCLS", start=trading_days[0], end=trading_days[-1]) + result: dict[str, float] = {} + for obs in response.observations: + if obs.value is None: + continue + result[obs.date] = float(obs.value) + if result: + return result + except Exception: + pass + return _load_vix_from_local_macro_snapshots(trading_days) + + +_MACRO_WINDOW_RE = re.compile(r"macro_window_(\d{4}-\d{2}-\d{2})_(\d{4}-\d{2}-\d{2})\.pkl$") + + +def _load_vix_from_local_macro_snapshots(trading_days: list[str]) -> dict[str, float]: + """Best-effort local fallback when Oracle/FRED is unavailable. + + Several backtester workflows persist macro_window_*.pkl files with point-in-time + VIX values. Reusing them keeps intraday research and official backtests from + hard-failing when the FRED proxy is temporarily down. + """ + if not trading_days: + return {} + settings = get_settings() + root = Path(settings.data_root) / "parquet" + if not root.exists(): + return {} + + start = trading_days[0] + end = trading_days[-1] + best_path: Path | None = None + best_span: int | None = None + for path in root.rglob("macro_window_*.pkl"): + match = _MACRO_WINDOW_RE.search(path.name) + if not match: + continue + window_start, window_end = match.groups() + if window_start > start or window_end < end: + continue + span = (date.fromisoformat(window_end) - date.fromisoformat(window_start)).days + if best_span is None or span < best_span: + best_span = span + best_path = path + + if best_path is None: + return {} + + try: + with best_path.open("rb") as fh: + payload = pickle.load(fh) + except Exception: + return {} + if not isinstance(payload, dict): + return {} + + result: dict[str, float] = {} + for key, values in payload.items(): + if not isinstance(values, dict): + continue + vix_value = values.get("VIXCLS") + if vix_value is None: + continue + if hasattr(key, "isoformat"): + key_str = key.isoformat() + else: + key_str = str(key) + if start <= key_str <= end: + result[key_str] = float(vix_value) + return result + + +def _momentum_enrichment_for_days( + daily_bars: dict[str, list[dict]], + trading_days: list[str], +) -> dict[str, dict[str, dict]]: + enrichment = enrich_daily_bars(daily_bars, trading_days) + for ticker, day_map in enrichment.items(): + ticker_bars = sorted(daily_bars.get(ticker, []), key=lambda bar: bar["date"]) + by_day = {bar["date"][:10]: bar for bar in ticker_bars} + for day, features in day_map.items(): + prev_close = features.get("prev_close") + today_open = features.get("today_open") + features["gap_pct"] = ( + compute_gap_pct(prev_close, today_open) + if prev_close is not None and today_open is not None + else None + ) + today_bar = by_day.get(day) + if today_bar and today_bar.get("volume") and features.get("avg_daily_vol_14d"): + avg_daily_vol = features["avg_daily_vol_14d"] + features["daily_volume_ratio_14d"] = ( + today_bar["volume"] / avg_daily_vol if avg_daily_vol and avg_daily_vol > 0 else None + ) + else: + features["daily_volume_ratio_14d"] = None + return enrichment + + +# ── Progress Reporting ───────────────────────────────────────────────────── + + +def _make_progress_bar(completed: int, total: int, width: int = 30) -> str: + pct = completed / total if total > 0 else 0 + filled = int(width * pct) + bar = "█" * filled + "░" * (width - filled) + return f"[{bar}] {completed}/{total} ({pct*100:.0f}%)" + + +def _chunk_trading_days_by_pairs( + trading_days: list[str], + candidates: dict[str, list[str]], + max_pairs_per_chunk: int = 5_000, +) -> list[list[str]]: + """Split trading days into contiguous chunks capped by candidate pair count. + + ORB single-run backtests previously loaded every candidate day's 5-minute bars + into memory before simulation. Large windows can exceed multiple GB in Python + objects, so we stream a few days at a time instead. + """ + chunks: list[list[str]] = [] + current: list[str] = [] + current_pairs = 0 + + for day in trading_days: + day_pairs = len(candidates.get(day, [])) + if current and current_pairs + day_pairs > max_pairs_per_chunk: + chunks.append(current) + current = [] + current_pairs = 0 + current.append(day) + current_pairs += day_pairs + + if current: + chunks.append(current) + + return chunks + + +# ── Main Orchestrator ────────────────────────────────────────────────────── + + +async def run(config: IntradayConfig, refresh_cache: bool = False) -> tuple: + """Full pipeline: universe → daily bars → intraday bars → simulate → metrics. + + Returns (day_results, metrics, all_intraday, trading_days). + """ + settings = get_settings() + + is_orb = config.strategy_mode == "orb" + + if is_orb: + p = config.orb_strategy or ORBStrategyParams() + timeout_minutes = 9 * 60 + 30 + p.order_timeout_minutes + timeout_hour, timeout_minute = divmod(timeout_minutes, 60) + print( + f"\nORB | {config.universe.source} | ${p.initial_capital:,.0f} | " + f"last {config.backtest.lookback_trading_days}d | " + f"orb:{p.orb_minutes}min stop:{p.atr_stop_multiplier}xATR " + f"be:{p.breakeven_at_r}R tr:{p.trailing_at_r}R timeout:{timeout_hour:02d}:{timeout_minute:02d} " + f"rvol:{p.min_rvol} risk:{p.risk_per_trade_pct*100:.2f}%" + ) + else: + s = config.strategy + print( + f"\nMoMo | {config.universe.source} | ${s.initial_capital:,.0f} | " + f"last {config.backtest.lookback_trading_days}d | " + f"entry:+{s.entry_minutes_after_open}min exit:-{s.exit_minutes_before_close}min " + f"stop:{s.stop_loss_pct or 'off'} gain:{s.min_morning_gain_pct*100:.1f}% top:{s.top_n}" + ) + + 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("orb_catalyst"))) + if config.cache.enabled else None + ) + attention_cache = ( + AttentionEventCache(str(Path(config.cache.dir).with_name("orb_attention"))) + if config.cache.enabled else None + ) + attention_cache = ( + AttentionEventCache(str(Path(config.cache.dir).with_name("orb_attention"))) + if config.cache.enabled else None + ) + if refresh_cache and cache: + print("\n Refreshing cache (evicting all entries)...") + removed = cache.evict() + print(f" Removed {removed} cached files.") + + if cache: + stats = cache.stats() + print(f"\n Cache: {stats['total_files']} files, {stats['total_mb']} MB") + + async with make_intraday_oracle_client(settings) as client: + # Step 1: Get trading days + print("[1/4] Resolving trading calendar...") + trading_days = await get_trading_days( + client, + config.backtest.start_date, + config.backtest.end_date, + config.backtest.lookback_trading_days, + ) + print(f" {trading_days[0]} → {trading_days[-1]} ({len(trading_days)} days)") + + # Step 2: Resolve universe + recent_live_scan = (not is_orb) and _should_use_recent_live_scan(config.strategy, trading_days) + if not is_orb: + config = config.model_copy( + update={"strategy": _strategy_for_recent_live_scan(config.strategy, recent_live_scan=recent_live_scan)} + ) + universe_params = _recent_live_scan_universe(config.strategy) if recent_live_scan else config.universe + universe_label = ( + f"{config.universe.source} + recent-live-scan" + if recent_live_scan + else config.universe.source + ) + recent_intraday_first_scan = recent_live_scan and _should_use_recent_intraday_first_scan(trading_days) + print(f"[2/4] Resolving universe ({universe_label})...") + tickers = await resolve_universe(universe_params, client) + print(f" {len(tickers)} tickers") + + if recent_intraday_first_scan: + print( + f"[3/4] Phase 1: Fetching intraday bars for recent live scan " + f"({len(tickers)} tickers across {len(trading_days)} days)..." + ) + intraday_seed = {day: tickers for day in trading_days} + _live_last_pct = [-1] + + def live_intraday_progress(completed: int, total: int, hits: int, calls: int) -> None: + if completed == 0 and calls == 0 and total > 0: + sys.stdout.write("\n") + sys.stdout.flush() + _live_last_pct[0] = -1 + pct = int(completed / total * 10) * 10 if total > 0 else 0 + if pct > _live_last_pct[0] or completed == total: + _live_last_pct[0] = pct + sys.stdout.write( + f"\r {_make_progress_bar(completed, total)} cache:{hits} api:{calls}" + ) + sys.stdout.flush() + + all_intraday = await fetch_intraday_bulk( + intraday_seed, + client, + cache, + concurrency=8, + progress_callback=live_intraday_progress, + ) + print() + candidates = _recent_intraday_first_candidates(all_intraday, trading_days, config.strategy) + total_pairs = sum(len(v) for v in candidates.values()) + print( + f" Intraday-first shortlisted: {total_pairs} ticker-day pairs " + f"across {len(candidates)} days" + ) + shortlisted_tickers = sorted({ticker for day in candidates.values() for ticker in day}) + daily_bars: dict[str, list[dict]] = {} + if shortlisted_tickers and _momentum_strategy_uses_daily_enrichment(config.strategy): + print(f" Fetching daily enrichment bars for {len(shortlisted_tickers)} shortlisted tickers...") + n_done = [0] + _daily_last_pct = [-1] + + def daily_progress(completed: int, total: int) -> None: + n_done[0] = completed + pct = int(completed / total * 10) * 10 if total > 0 else 0 + if pct > _daily_last_pct[0] or completed == total: + _daily_last_pct[0] = pct + sys.stdout.write(f"\r {_make_progress_bar(completed, total)}") + sys.stdout.flush() + + daily_fetch_start = ( + date.fromisoformat(trading_days[0]) - timedelta(days=90) + ).isoformat() + daily_bars = await fetch_daily_bars_bulk( + shortlisted_tickers, + daily_fetch_start, + trading_days[-1], + client, + cache=daily_cache, + intraday_cache_fallback=cache, + prefer_intraday_fallback=True, + concurrency=20, + progress_callback=daily_progress, + ) + print(f"\n {len(daily_bars)}/{len(shortlisted_tickers)} shortlisted tickers with daily data") + else: + # Step 3: Phase 1 — Fetch daily bars + pre-screen + print(f"[3/4] Phase 1: Fetching daily bars for {len(tickers)} tickers...") + ticker_sectors = ( + _load_orb_ticker_sectors(tickers) + if is_orb + else ( + await _load_ticker_sectors_with_oracle(tickers, client) + if config.strategy.max_positions_per_sector + else {} + ) + ) + n_done = [0] + _daily_last_pct = [-1] + + def daily_progress(completed: int, total: int) -> None: + n_done[0] = completed + pct = int(completed / total * 10) * 10 if total > 0 else 0 + if pct > _daily_last_pct[0] or completed == total: + _daily_last_pct[0] = pct + sys.stdout.write(f"\r {_make_progress_bar(completed, total)}") + sys.stdout.flush() + + # For ORB mode, fetch extra prior history for enrichment (ATR/volume warmup). + # enrich_daily_bars() uses only bars BEFORE each trading day, so extra bars + # before trading_days[0] act as warmup and never appear in simulation results. + # Without this, a short (e.g. single-day) backtest has no prior bars and all + # candidates get filtered out (ATR/dollar-vol = None → zero trades). + if not recent_intraday_first_scan: + if is_orb or _momentum_strategy_uses_daily_enrichment(config.strategy): + warmup_start = ( + date.fromisoformat(trading_days[0]) - timedelta(days=90) + ).isoformat() + daily_fetch_start = warmup_start + else: + daily_fetch_start = trading_days[0] + + 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=20, + progress_callback=daily_progress, + ) + print(f"\n {len(daily_bars)}/{len(tickers)} tickers with data") + + momentum_enrichment: dict[str, dict[str, dict]] | None = None + momentum_vix_by_day: dict[str, float] | None = None + momentum_seed_candidates: dict[str, list[str]] | None = None + if is_orb: + # ORB: enrich daily bars, then filter by quality metrics + orb_params = config.orb_strategy or ORBStrategyParams() + # Ensure regime ticker is in daily_bars for market regime filter + regime_ticker = getattr(orb_params, "market_regime_ticker", "SPY") or "SPY" + if orb_params.market_regime_spy_threshold is not None and regime_ticker not in daily_bars: + extra_bars = await fetch_daily_bars_bulk( + [regime_ticker], daily_fetch_start, trading_days[-1], client, + cache=daily_cache, intraday_cache_fallback=cache, concurrency=1 + ) + daily_bars.update(extra_bars) + print(" Computing ATR/volume enrichment...") + enrichment = enrich_daily_bars(daily_bars, trading_days) + candidates = orb_pre_screen_candidates( + daily_bars, + trading_days, + enrichment, + min_price=orb_params.min_price, + min_atr=orb_params.min_atr_14, + min_avg_dollar_vol=orb_params.min_avg_dollar_volume, + max_per_day=None, + ) + if _orb_strategy_uses_catalyst(orb_params): + event_tickers = _orb_candidate_event_tickers(candidates, enrichment, orb_params) + print(f" Fetching filing catalyst events for {len(event_tickers)} tickers...") + _evt_last_pct = [-1] + + def event_progress(completed: int, total: int) -> None: + pct = int(completed / total * 10) * 10 if total > 0 else 0 + if pct > _evt_last_pct[0] or completed == total: + _evt_last_pct[0] = pct + sys.stdout.write(f"\r {_make_progress_bar(completed, total)}") + sys.stdout.flush() + + event_features = await fetch_filing_event_features_bulk( + event_tickers, + trading_days[0], + trading_days[-1], + client, + cache=event_cache, + concurrency=16, + progress_callback=event_progress, + ) + print() + _merge_orb_event_features(enrichment, event_features) + + if _orb_strategy_uses_attention(orb_params): + attention_pairs = _orb_candidate_event_pairs(candidates, enrichment, orb_params) + print(f" Fetching event attention for {len(attention_pairs)} ticker-days...") + _attn_last_pct = [-1] + + def attention_progress(completed: int, total: int) -> None: + pct = int(completed / total * 10) * 10 if total > 0 else 0 + if pct > _attn_last_pct[0] or completed == total: + _attn_last_pct[0] = pct + sys.stdout.write(f"\r {_make_progress_bar(completed, total)}") + sys.stdout.flush() + + attention_features = await fetch_attention_features_bulk( + attention_pairs, + client, + cache=attention_cache, + concurrency=16, + progress_callback=attention_progress, + ) + print() + _merge_orb_attention_features(enrichment, attention_features) + if _orb_strategy_uses_vix(orb_params): + print(" Fetching VIX regime series for ORB...") + momentum_vix_by_day = await _fetch_vix_by_day(client, trading_days) + else: + enrichment = {} + if recent_intraday_first_scan: + candidates = _retain_recent_intraday_shortlist( + candidates, + daily_bars, + require_daily_features=_momentum_strategy_uses_daily_enrichment(config.strategy), + ) + if _momentum_strategy_uses_daily_enrichment(config.strategy): + print(" Computing momentum daily enrichment...") + momentum_enrichment = _momentum_enrichment_for_days(daily_bars, trading_days) + if _momentum_strategy_uses_vix(config.strategy): + print(" Fetching VIX regime series...") + momentum_vix_by_day = await _fetch_vix_by_day(client, trading_days) + if ( + not recent_intraday_first_scan + and momentum_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, + momentum_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) + print(f" Fetching momentum filing catalysts for {len(event_tickers)} tickers...") + _evt_last_pct = [-1] + + def event_progress(completed: int, total: int) -> None: + pct = int(completed / total * 10) * 10 if total > 0 else 0 + if pct > _evt_last_pct[0] or completed == total: + _evt_last_pct[0] = pct + sys.stdout.write(f"\r {_make_progress_bar(completed, total)}") + sys.stdout.flush() + + event_features = await fetch_filing_event_features_bulk( + event_tickers, + trading_days[0], + trading_days[-1], + client, + cache=event_cache, + concurrency=16, + progress_callback=event_progress, + ) + print() + _merge_momentum_event_features(momentum_enrichment, event_features) + if _momentum_strategy_uses_attention(config.strategy): + attention_pairs = _momentum_candidate_event_pairs( + preliminary_candidates, + momentum_enrichment, + config.strategy, + ) + print(f" Fetching momentum attention for {len(attention_pairs)} ticker-days...") + _attn_last_pct = [-1] + + def attention_progress(completed: int, total: int) -> None: + pct = int(completed / total * 10) * 10 if total > 0 else 0 + if pct > _attn_last_pct[0] or completed == total: + _attn_last_pct[0] = pct + sys.stdout.write(f"\r {_make_progress_bar(completed, total)}") + sys.stdout.flush() + + attention_features = await fetch_attention_features_bulk( + attention_pairs, + client, + cache=attention_cache, + concurrency=16, + progress_callback=attention_progress, + ) + print() + _merge_momentum_attention_features(momentum_enrichment, attention_features) + if not recent_intraday_first_scan: + momentum_seed_candidates = _momentum_intraday_seed_candidates( + daily_bars, + trading_days, + momentum_enrichment or {}, + config.strategy, + default_threshold=config.backtest.pre_screen_threshold, + use_signal_features=True, + ) + momentum_seed_candidates = _augment_momentum_seed_candidates_with_liquid_overlay( + momentum_seed_candidates, + daily_bars, + trading_days, + momentum_enrichment or {}, + config.strategy, + ) + if _momentum_uses_historical_intraday_first(config.strategy): + candidates = { + day: list(day_tickers) + for day, day_tickers in momentum_seed_candidates.items() + if day_tickers + } + else: + candidates = momentum_pre_screen_candidates( + daily_bars, + trading_days, + momentum_enrichment or {}, + threshold=config.backtest.pre_screen_threshold, + max_per_day=config.strategy.candidate_final_max_per_day, + strategy=config.strategy, + ) + + total_pairs = sum(len(v) for v in candidates.values()) + print(f" Pre-screened: {total_pairs} ticker-day pairs across {len(candidates)} days") + + if is_orb: + from libs.intraday.orb_simulator import run_orb_simulation_with_state + + # Step 4: ORB fetch + simulate in bounded day batches. + # Large windows (e.g. 90k+ ticker-days) can exceed several GB if every + # 5-minute bar is materialized in one giant dict before simulation. + day_chunks = _chunk_trading_days_by_pairs(trading_days, candidates) + print( + f"[4/4] Phase 2: Fetching intraday bars ({total_pairs} pairs) " + f"in {len(day_chunks)} batches..." + ) + print(" Streaming ORB simulation to keep memory bounded...") + + orb_params = config.orb_strategy or ORBStrategyParams() + day_results = [] + sim_state = None + all_intraday: dict[str, dict[str, list[dict]]] = {} + simulated_days = 0 + + for chunk_idx, day_chunk in enumerate(day_chunks, start=1): + chunk_candidates = { + day: candidates[day] + for day in day_chunk + if candidates.get(day) + } + chunk_pairs = sum(len(v) for v in chunk_candidates.values()) + print( + f"\n Batch {chunk_idx}/{len(day_chunks)}: " + f"{day_chunk[0]} → {day_chunk[-1]} ({chunk_pairs} pairs)" + ) + + chunk_last_pct = [-1] + + def chunk_intraday_progress(completed: int, total: int, hits: int, calls: int) -> None: + if completed == 0 and calls == 0 and total > 0: + sys.stdout.write("\n") + sys.stdout.flush() + chunk_last_pct[0] = -1 + pct = int(completed / total * 10) * 10 if total > 0 else 0 + if pct > chunk_last_pct[0] or completed == total: + chunk_last_pct[0] = pct + sys.stdout.write( + f"\r {_make_progress_bar(completed, total)} " + f"cache:{hits} api:{calls}" + ) + sys.stdout.flush() + + chunk_intraday = await fetch_intraday_bulk( + chunk_candidates, + client, + cache, + skip_oracle_when_unhealthy=True, + concurrency=8, + progress_callback=chunk_intraday_progress, + ) + if chunk_pairs > 0: + print() + + chunk_results, sim_state = run_orb_simulation_with_state( + chunk_intraday, + day_chunk, + orb_params, + enrichment, + ticker_sectors=ticker_sectors, + state=sim_state, + vix_by_day=momentum_vix_by_day, + ) + day_results.extend(chunk_results) + simulated_days += len(day_chunk) + print(f" Simulated {simulated_days}/{len(trading_days)} days") + del chunk_intraday + + print(f"\n Done. Simulated {len(day_results)} days") + else: + # Step 4: Phase 2 — Fetch intraday bars (cache-first) + if recent_intraday_first_scan: + print(f"[4/4] Phase 2: Reusing recent live-scan intraday bars ({total_pairs} pairs)...") + shortlisted_intraday: dict[str, dict[str, list[dict]]] = {} + for day, day_candidates in candidates.items(): + day_bars = all_intraday.get(day, {}) + subset = { + ticker: day_bars[ticker] + for ticker in day_candidates + if ticker in day_bars + } + if subset: + shortlisted_intraday[day] = subset + all_intraday = shortlisted_intraday + print(f" Done. {len(all_intraday)} days with shortlisted intraday data") + else: + print(f"[4/4] Phase 2: Fetching intraday bars ({total_pairs} pairs)...") + n_api = [0] + + _intra_last_pct = [-1] + + def intraday_progress(completed: int, total: int, hits: int, calls: int) -> None: + n_api[0] = calls + if completed == 0 and calls == 0 and total > 0: + # Phase 2 reset signal: new total = miss_total, restart bar from 0 + sys.stdout.write("\n") + sys.stdout.flush() + _intra_last_pct[0] = -1 + pct = int(completed / total * 10) * 10 if total > 0 else 0 + if pct > _intra_last_pct[0] or completed == total: + _intra_last_pct[0] = pct + sys.stdout.write( + f"\r {_make_progress_bar(completed, total)} " + f"cache:{hits} api:{calls}" + ) + sys.stdout.flush() + + all_intraday = await fetch_intraday_bulk( + candidates, + client, + cache, + skip_oracle_when_unhealthy=True, + concurrency=8, + progress_callback=intraday_progress, + ) + print(f"\n Done. {len(all_intraday)} days with intraday data") + + if _momentum_uses_historical_intraday_first(config.strategy): + candidates = momentum_intraday_first_candidates( + all_intraday, + trading_days, + config.strategy, + daily_enrichment=momentum_enrichment, + max_per_day=config.strategy.candidate_final_max_per_day, + ) + total_pairs = sum(len(v) for v in candidates.values()) + shortlisted_intraday: dict[str, dict[str, list[dict]]] = {} + for day, day_candidates in candidates.items(): + day_bars = all_intraday.get(day, {}) + subset = { + ticker: day_bars[ticker] + for ticker in day_candidates + if ticker in day_bars + } + if subset: + shortlisted_intraday[day] = subset + all_intraday = shortlisted_intraday + print( + " Intraday-first reranked: " + f"{total_pairs} ticker-day pairs across {len(candidates)} days" + ) + + if not is_orb: + # Step 5: Simulate (momentum mode still runs after full preload) + print("\nSimulating trades...") + _sim_last_pct = [-1] + + def sim_progress(done: int, total: int) -> None: + pct = int(done / total * 10) * 10 if total > 0 else 0 + if pct > _sim_last_pct[0] or done == total: + _sim_last_pct[0] = pct + sys.stdout.write(f"\r {_make_progress_bar(done, total)}") + sys.stdout.flush() + + day_results = run_simulation( + all_intraday, + trading_days, + config.strategy, + daily_enrichment=momentum_enrichment, + vix_by_day=momentum_vix_by_day, + ticker_sectors=ticker_sectors, + ) + print() + + # Step 6: Compute metrics + run_id = str(uuid.uuid4())[:8] + metrics = compute_metrics(day_results, config, run_id=run_id) + + return day_results, metrics, all_intraday, trading_days + + +async def run_with_sweep(config: IntradayConfig, sweep_path: str) -> None: + """Run data pipeline once, then sweep over parameter combinations.""" + from apps.intraday_bt.sweep import load_sweep_config, run_sweep + + is_orb = config.strategy_mode == "orb" + + sweep = load_sweep_config(sweep_path, config) + mode_label = "ORB" if is_orb else "MoMo" + print(f"\nSweep [{mode_label}] {sweep.total_combinations} combos | " + " | ".join(f"{k}:{v}" for k, v in sweep.sweep_params.items())) + + settings = get_settings() + + 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("orb_catalyst"))) + if config.cache.enabled else None + ) + attention_cache = ( + AttentionEventCache(str(Path(config.cache.dir).with_name("orb_attention"))) + if config.cache.enabled else None + ) + + async with make_intraday_oracle_client(settings) as client: + print(f"\n[1/4] Resolving universe ({config.universe.source})...") + tickers = await resolve_universe(config.universe, client) + print(f" {len(tickers)} tickers") + + print("[2/4] Resolving trading calendar...") + trading_days = await get_trading_days( + client, + config.backtest.start_date, + config.backtest.end_date, + config.backtest.lookback_trading_days, + ) + print(f" {trading_days[0]} → {trading_days[-1]} ({len(trading_days)} days)") + ticker_sectors = ( + _load_orb_ticker_sectors(tickers) + if is_orb + else ( + await _load_ticker_sectors_with_oracle(tickers, client) + if config.strategy.max_positions_per_sector + else {} + ) + ) + + print(f"[3/4] Phase 1: Fetching daily bars for {len(tickers)} tickers...") + + _daily_prog_last = [-1] + + def daily_prog(completed: int, total: int) -> None: + pct = int(completed / total * 10) * 10 if total > 0 else 0 + if pct > _daily_prog_last[0] or completed == total: + _daily_prog_last[0] = pct + sys.stdout.write(f"\r {_make_progress_bar(completed, total)}") + sys.stdout.flush() + + daily_fetch_start_sw = ( + (date.fromisoformat(trading_days[0]) - timedelta(days=90)).isoformat() + if (is_orb or _momentum_strategy_uses_daily_enrichment(config.strategy)) + else trading_days[0] + ) + daily_bars = await fetch_daily_bars_bulk( + tickers, daily_fetch_start_sw, trading_days[-1], client, + cache=daily_cache, + intraday_cache_fallback=cache, + prefer_intraday_fallback=True, + skip_oracle_when_unhealthy=True, + concurrency=20, progress_callback=daily_prog, + ) + print(f"\n {len(daily_bars)}/{len(tickers)} with data") + + momentum_enrichment: dict[str, dict[str, dict]] | None = None + momentum_vix_by_day: dict[str, float] | None = None + if is_orb: + # Ensure regime ticker is in daily_bars for market regime filter (sweep mode) + orb_params_sweep_check = config.orb_strategy or ORBStrategyParams() + regime_ticker_sw = getattr(orb_params_sweep_check, "market_regime_ticker", "SPY") or "SPY" + if orb_params_sweep_check.market_regime_spy_threshold is not None and regime_ticker_sw not in daily_bars: + extra_bars_sw = await fetch_daily_bars_bulk( + [regime_ticker_sw], daily_fetch_start_sw, trading_days[-1], client, + cache=daily_cache, intraday_cache_fallback=cache, concurrency=1 + ) + daily_bars.update(extra_bars_sw) + print(" Computing ATR/volume enrichment...") + enrichment = enrich_daily_bars(daily_bars, trading_days) + orb_params = config.orb_strategy or ORBStrategyParams() + candidates = orb_pre_screen_candidates( + daily_bars, trading_days, enrichment, + min_price=orb_params.min_price, + min_atr=orb_params.min_atr_14, + min_avg_dollar_vol=orb_params.min_avg_dollar_volume, + max_per_day=None, + ) + if _orb_strategy_uses_catalyst(orb_params_sweep_check): + event_tickers = _orb_candidate_event_tickers(candidates, enrichment, orb_params) + _evt_prog_last = [-1] + + def event_prog(completed: int, total: int) -> None: + pct = int(completed / total * 10) * 10 if total > 0 else 0 + if pct > _evt_prog_last[0] or completed == total: + _evt_prog_last[0] = pct + sys.stdout.write(f"\r {_make_progress_bar(completed, total)}") + sys.stdout.flush() + + event_features = await fetch_filing_event_features_bulk( + event_tickers, + trading_days[0], + trading_days[-1], + client, + cache=event_cache, + concurrency=16, + progress_callback=event_prog, + ) + print() + _merge_orb_event_features(enrichment, event_features) + if _orb_strategy_uses_vix(orb_params_sweep_check): + print(" Fetching VIX regime series for ORB sweep...") + momentum_vix_by_day = await _fetch_vix_by_day(client, trading_days) + else: + enrichment = {} + if _momentum_strategy_uses_daily_enrichment(config.strategy): + print(" Computing momentum daily enrichment...") + momentum_enrichment = _momentum_enrichment_for_days(daily_bars, trading_days) + if _momentum_strategy_uses_vix(config.strategy): + print(" Fetching VIX regime series...") + momentum_vix_by_day = await _fetch_vix_by_day(client, trading_days) + if ( + momentum_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, + momentum_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) + print(f" Fetching momentum filing catalysts for {len(event_tickers)} tickers...") + _evt_prog_last = [-1] + + def event_prog(completed: int, total: int) -> None: + pct = int(completed / total * 10) * 10 if total > 0 else 0 + if pct > _evt_prog_last[0] or completed == total: + _evt_prog_last[0] = pct + sys.stdout.write(f"\r {_make_progress_bar(completed, total)}") + sys.stdout.flush() + + event_features = await fetch_filing_event_features_bulk( + event_tickers, + trading_days[0], + trading_days[-1], + client, + cache=event_cache, + concurrency=16, + progress_callback=event_prog, + ) + print() + _merge_momentum_event_features(momentum_enrichment, event_features) + if _momentum_strategy_uses_attention(config.strategy): + attention_pairs = _momentum_candidate_event_pairs( + preliminary_candidates, + momentum_enrichment, + config.strategy, + ) + print(f" Fetching momentum attention for {len(attention_pairs)} ticker-days...") + _attn_prog_last = [-1] + + def attention_prog(completed: int, total: int) -> None: + pct = int(completed / total * 10) * 10 if total > 0 else 0 + if pct > _attn_prog_last[0] or completed == total: + _attn_prog_last[0] = pct + sys.stdout.write(f"\r {_make_progress_bar(completed, total)}") + sys.stdout.flush() + + attention_features = await fetch_attention_features_bulk( + attention_pairs, + client, + cache=attention_cache, + concurrency=16, + progress_callback=attention_prog, + ) + print() + _merge_momentum_attention_features(momentum_enrichment, attention_features) + candidates = _momentum_intraday_seed_candidates( + daily_bars, + trading_days, + momentum_enrichment or {}, + config.strategy, + default_threshold=config.backtest.pre_screen_threshold, + use_signal_features=True, + ) + candidates = _augment_momentum_seed_candidates_with_liquid_overlay( + candidates, + daily_bars, + trading_days, + momentum_enrichment or {}, + config.strategy, + ) + + total_pairs = sum(len(v) for v in candidates.values()) + print(f" {total_pairs} candidate pairs") + + print(f"[4/4] Phase 2: Fetching intraday bars...") + + _intra_prog_last = [-1] + + def intra_prog(completed: int, total: int, hits: int, calls: int) -> None: + if completed == 0 and calls == 0 and total > 0: + sys.stdout.write("\n") + sys.stdout.flush() + _intra_prog_last[0] = -1 + pct = int(completed / total * 10) * 10 if total > 0 else 0 + if pct > _intra_prog_last[0] or completed == total: + _intra_prog_last[0] = pct + sys.stdout.write( + f"\r {_make_progress_bar(completed, total)} cache:{hits} api:{calls}" + ) + sys.stdout.flush() + + all_intraday = await fetch_intraday_bulk( + candidates, client, cache, concurrency=8, progress_callback=intra_prog, + ) + print(f"\n Done. {len(all_intraday)} days with data") + + if (not is_orb) and _momentum_uses_historical_intraday_first(config.strategy): + candidates = momentum_intraday_first_candidates( + all_intraday, + trading_days, + config.strategy, + daily_enrichment=momentum_enrichment, + max_per_day=config.strategy.candidate_final_max_per_day, + ) + total_pairs = sum(len(v) for v in candidates.values()) + shortlisted_intraday: dict[str, dict[str, list[dict]]] = {} + for day, day_candidates in candidates.items(): + day_bars = all_intraday.get(day, {}) + subset = { + ticker: day_bars[ticker] + for ticker in day_candidates + if ticker in day_bars + } + if subset: + shortlisted_intraday[day] = subset + all_intraday = shortlisted_intraday + print( + " Intraday-first reranked: " + f"{total_pairs} ticker-day pairs across {len(candidates)} days" + ) + + print(f"\nRunning {sweep.total_combinations} sweep combinations...") + completed_sw = [0] + _sweep_last_pct = [-1] + + def sweep_prog(done: int, total: int) -> None: + completed_sw[0] = done + pct = int(done / total * 10) * 10 if total > 0 else 0 + if pct > _sweep_last_pct[0] or done == total: + _sweep_last_pct[0] = pct + sys.stdout.write(f"\r {_make_progress_bar(done, total)}") + sys.stdout.flush() + + sweep_results = run_sweep( + sweep, all_intraday, trading_days, + progress_callback=sweep_prog, + enrichment=enrichment, + momentum_enrichment=momentum_enrichment, + vix_by_day=momentum_vix_by_day, + ticker_sectors=ticker_sectors if not is_orb else None, + ) + print() + + # Display top results + print(format_sweep_comparison(sweep_results, top_n=20)) + + # Also show full details for the #1 configuration + if sweep_results: + from apps.intraday_bt.sweep import apply_overrides + best = sweep_results[0] + best_config = apply_overrides(config, best.params) + if is_orb: + from libs.intraday.orb_simulator import run_orb_simulation + best_day_results = run_orb_simulation( + all_intraday, trading_days, best_config.orb_strategy, enrichment, + vix_by_day=momentum_vix_by_day, + ) + else: + best_day_results = run_simulation( + all_intraday, + trading_days, + best_config.strategy, + daily_enrichment=momentum_enrichment, + vix_by_day=momentum_vix_by_day, + ticker_sectors=ticker_sectors, + ) + print("\n=== Best Configuration Detail ===") + print(format_summary(best.metrics, best_config)) + print(format_top_trades(best_day_results, n=5)) + + # Save sweep results + out = Path(config.output.dir) + out.mkdir(parents=True, exist_ok=True) + from datetime import datetime + import json + ts = datetime.now().strftime("%Y%m%d_%H%M%S") + sweep_file = out / f"sweep_{ts}.json" + sweep_data = [ + {"params": sr.params, "metrics": sr.metrics.model_dump()} + for sr in sweep_results + ] + sweep_file.write_text(json.dumps(sweep_data, indent=2, default=str)) + print(f"\nSweep results saved to: {sweep_file}") + + +# ── CLI ──────────────────────────────────────────────────────────────────── + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Morning Momentum Intraday Backtester", + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + parser.add_argument("--config", help="Strategy config YAML file path") + parser.add_argument("--strategy", choices=["momentum", "orb"], default=None, + help="Strategy mode: 'momentum' (default) or 'orb'") + parser.add_argument("--sweep", help="Parameter sweep YAML file path") + parser.add_argument("--days", type=int, help="Number of trading days to backtest") + parser.add_argument("--start", help="Start date YYYY-MM-DD (overrides --days)") + parser.add_argument("--end", help="End date YYYY-MM-DD (default: today)") + parser.add_argument("--universe", + choices=["sp500", "nasdaq100", "midlarge", "largecap", "midcap", "smallmid", "screener"], + help="Universe source") + parser.add_argument("--top-n", type=int, dest="top_n", help="Number of stocks to buy per day") + parser.add_argument("--stop-loss", dest="stop_loss", + help="Stop loss % (e.g. -0.02) or 'none' to disable") + parser.add_argument("--entry-min", type=int, dest="entry_min", + help="Minutes after open to enter (default 30)") + parser.add_argument("--exit-min", type=int, dest="exit_min", + help="Minutes before close to exit (default 30)") + parser.add_argument("--min-gain", type=float, dest="min_gain", + help="Minimum morning gain to qualify (default 0.01 = 1%%)") + parser.add_argument("--no-cache", action="store_true", dest="no_cache", + help="Disable cache use for this run") + parser.add_argument("--refresh-cache", action="store_true", dest="refresh_cache", + help="Force re-download all intraday data") + parser.add_argument("--verbose", action="store_true", help="Show detailed per-day output") + parser.add_argument("--output-dir", dest="output_dir", help="Override output directory") + parser.add_argument("--initial-capital", type=float, dest="initial_capital", default=None, + help="Override initial capital (e.g. 50000)") + parser.add_argument("--compound-returns", dest="compound_returns", action="store_true", default=None, + help="Override: use compound returns (복리) regardless of config") + parser.add_argument("--no-compound-returns", dest="compound_returns", action="store_false", + help="Override: use simple returns (단리) regardless of config") + parser.add_argument("--daily-budget-reset", dest="daily_budget_reset", action="store_true", default=None, + help="Research mode: reset sizing_capital to initial_capital every day " + "(ignores prior PnL; takes precedence over compound_returns)") + parser.add_argument("--no-daily-budget-reset", dest="daily_budget_reset", action="store_false", + help="Disable daily budget reset mode") + parser.add_argument("--cache-stats", action="store_true", dest="cache_stats", + help="Show cache statistics and exit") + return parser.parse_args() + + +async def main_async() -> None: + args = parse_args() + + # Cache stats shortcut + if args.cache_stats: + config = load_config(args.config) + cache = IntradayCache(config.cache.dir) + stats = cache.stats() + print(f"Cache directory: {config.cache.dir}") + print(f" Files: {stats['total_files']}") + print(f" Size: {stats['total_mb']} MB") + print(f" Tickers: {stats['tickers']}") + print(f" Date range: {stats['date_min']} → {stats['date_max']}") + return + + # Load + apply config + config = load_config(args.config) + config = apply_cli_overrides(config, args) + + # Sweep mode + if args.sweep: + await run_with_sweep(config, args.sweep) + return + + # Single run mode + day_results, metrics, all_intraday, trading_days = await run( + config, refresh_cache=args.refresh_cache + ) + + # Display results + print(format_summary(metrics, config)) + + if config.output.verbose or args.verbose: + print(format_daily_breakdown(day_results)) + + print(format_top_trades(day_results, n=5)) + + # Always show daily breakdown (compact version) + if not (config.output.verbose or args.verbose): + print(format_daily_breakdown(day_results)) + + # Save results + if day_results: + out_file = write_results(metrics, day_results, config, config.output.dir) + print(f"\nResults saved to: {out_file}") + + +def main() -> None: + asyncio.run(main_async()) + + +if __name__ == "__main__": + main() diff --git a/apps/intraday_bt/scenario_test.py b/apps/intraday_bt/scenario_test.py new file mode 100644 index 0000000..1de1539 --- /dev/null +++ b/apps/intraday_bt/scenario_test.py @@ -0,0 +1,329 @@ +"""ORB scenario test with streaming intraday fetch.""" +from __future__ import annotations + +import argparse +import asyncio +import json +import time +from pathlib import Path +from typing import Any + +from apps.intraday_bt.oracle import make_intraday_oracle_client +from rich import box +from rich.console import Console +from rich.panel import Panel +from rich.progress import BarColumn, Progress, SpinnerColumn, TextColumn, TimeElapsedColumn +from rich.table import Table + +from libs.common.config import get_settings +from libs.intraday.domain import ORBStrategyParams +from libs.oracle_client import OracleClient + +from apps.intraday_bt.orb_research import ( + build_orb_research_context, + compute_orb_rrs, + filter_days, + force_simple_returns, + resolve_orb_config, + simulate_orb_period, +) +from apps.intraday_bt.run import _latest_backtest_date + +_console = Console(width=120) +_DEFAULT_START_DATE = "2022-01-01" + + +SCENARIO_REGISTRY: dict[str, dict[str, Any]] = { + "bear_2022": { + "description": "2022 bear market — fed hikes, tech selloff", + "start": "2022-01-03", + "end": "2022-12-30", + "group": "regime", + "expected": "negative (long-only headwind)", + }, + "recovery_2023h1": { + "description": "Early 2023 recovery from bear market lows", + "start": "2023-01-03", + "end": "2023-06-30", + "group": "regime", + "expected": "positive (volatility + momentum)", + }, + "bull_2023h2": { + "description": "Strong H2 2023 AI-driven bull run", + "start": "2023-07-03", + "end": "2023-12-29", + "group": "regime", + "expected": "positive (strong trend)", + }, + "mixed_2024": { + "description": "Mixed 2024 — rate-cut expectations, choppy mid-year", + "start": "2024-01-02", + "end": "2024-12-31", + "group": "regime", + "expected": "moderate", + }, + "bull_2025": { + "description": "2025 continuation bull market", + "start": "2025-01-02", + "end": "2025-12-31", + "group": "regime", + "expected": "positive", + }, + "oos_2026": { + "description": "Pure OOS holdout — 2026 YTD (never seen in IS)", + "start": "2026-01-02", + "end": None, + "group": "regime", + "expected": "validation only", + }, + "no_rvol_filter": { + "description": "Full period, RVOL filter disabled (min_rvol=0)", + "start": None, + "end": None, + "group": "signal", + "expected": "should degrade if RVOL adds value", + "param_override": {"min_rvol": 0.0}, + }, + "random_ranking": { + "description": "Full period, candidates ranked randomly", + "start": None, + "end": None, + "group": "signal", + "expected": "should degrade if ranking signal is real", + "shuffle_candidates": True, + }, +} + +SCENARIO_GROUPS: dict[str, list[str]] = { + "regime": ["bear_2022", "recovery_2023h1", "bull_2023h2", "mixed_2024", "bull_2025", "oos_2026"], + "signal": ["no_rvol_filter", "random_ranking"], + "quick": ["bear_2022", "bull_2023h2", "oos_2026"], + "all": list(SCENARIO_REGISTRY.keys()), +} + + +async def run_scenario( + scenario_name: str, + scenario_def: dict[str, Any], + context, + client: OracleClient, + full_start: str, + progress_prefix: str = "", +) -> dict[str, Any]: + base_params = context.config.orb_strategy or ORBStrategyParams() + if scenario_def.get("param_override"): + base_params = base_params.model_copy(update=scenario_def["param_override"]) + + sc_start = scenario_def.get("start") or full_start + sc_end = scenario_def.get("end") or context.trading_days[-1] + days = filter_days(context.trading_days, sc_start, sc_end) + if len(days) < 10: + return {"scenario": scenario_name, "verdict": "SKIP", "notes": f"Only {len(days)} days in range"} + + if progress_prefix: + print(f"{progress_prefix}{scenario_name}: {days[0]} → {days[-1]} ({len(days)} days)") + + metrics = await simulate_orb_period( + context, + client, + base_params, + days, + run_id=f"sc_{scenario_name[:8]}", + shuffle_candidates_seed=1234 if scenario_def.get("shuffle_candidates") else None, + progress_prefix=f"{progress_prefix}[{scenario_name}] " if progress_prefix else "", + ) + return { + "scenario": scenario_name, + "period": f"{days[0]} → {days[-1]} ({len(days)} days)", + "sharpe_ratio": metrics.sharpe_ratio or 0.0, + "total_return_pct": (metrics.total_return_pct or 0.0) * 100, + "max_drawdown_pct": abs((metrics.max_drawdown_pct or 0.0) * 100), + "win_rate": (metrics.win_rate or 0.0) * 100, + "profit_factor": metrics.profit_factor or 0.0, + "total_trades": metrics.total_trades or 0, + } + + +def _rrs_verdict(rrs: float) -> str: + if rrs >= 70: + return "ROBUST" + if rrs >= 40: + return "FRAGILE" + return "OVERFIT" + + +def _print_scenario_table(scenario_results: dict[str, dict], config_name: str) -> None: + table = Table(title=f"ORB Scenario Results — {config_name}", box=box.ROUNDED, width=118) + table.add_column("Scenario", style="cyan", min_width=20) + table.add_column("Period", style="dim", min_width=26) + table.add_column("Sharpe", justify="right", min_width=7) + table.add_column("Return%", justify="right", min_width=9) + table.add_column("MaxDD%", justify="right", min_width=8) + table.add_column("Win%", justify="right", min_width=6) + table.add_column("PF", justify="right", min_width=6) + table.add_column("Trades", justify="right", min_width=7) + for name, result in scenario_results.items(): + if result.get("verdict") == "SKIP": + table.add_row(name, "[dim]SKIP[/dim]", "-", "-", "-", "-", "-", "-") + continue + sr = result.get("sharpe_ratio", 0.0) + ret = result.get("total_return_pct", 0.0) + dd = result.get("max_drawdown_pct", 0.0) + win = result.get("win_rate", 0.0) + pf = result.get("profit_factor", 0.0) + trades = result.get("total_trades", 0) + period = result.get("period", "") + sharpe_str = f"[green]{sr:.2f}[/green]" if sr >= 1.5 else f"[yellow]{sr:.2f}[/yellow]" if sr >= 0.5 else f"[red]{sr:.2f}[/red]" if sr < 0 else f"[dim]{sr:.2f}[/dim]" + ret_str = f"[green]+{ret:.1f}%[/green]" if ret > 0 else f"[red]{ret:.1f}%[/red]" + dd_str = f"[red]{dd:.1f}%[/red]" if dd > 15 else f"{dd:.1f}%" + table.add_row(name, period, sharpe_str, ret_str, dd_str, f"{win:.0f}%", f"{pf:.2f}", str(trades)) + _console.print() + _console.print(table) + + +def _score_bar(score: float) -> str: + filled = int(round(score / 5)) + bar = "█" * filled + "░" * (20 - filled) + color = "green" if score >= 70 else "yellow" if score >= 40 else "red" + return f"[{color}]{bar}[/{color}] {score:.0f}/100" + + +def _print_rrs_panel(rrs: float, components: dict[str, float], config_name: str) -> None: + verdict = _rrs_verdict(rrs) + color = "green" if verdict == "ROBUST" else "yellow" if verdict == "FRAGILE" else "red" + lines = [ + "[bold]ORB REGIME ROBUSTNESS SCORE (RRS)[/bold]", + f"Strategy: [cyan]{config_name}[/cyan]", + "", + f" Bear Survival {_score_bar(components['bear_survival'])}", + f" Breadth {_score_bar(components['breadth'])}", + f" Drawdown Resilience {_score_bar(components['drawdown_resilience'])}", + f" OOS Integrity {_score_bar(components['oos_integrity'])}", + f" Stability {_score_bar(components['stability'])}", + "", + f" [bold]RRS: {_score_bar(rrs)}[/bold]", + f" [{color} bold]Verdict: {verdict}[/{color} bold]", + ] + _console.print() + _console.print(Panel("\n".join(lines), box=box.DOUBLE, width=100)) + + +async def _async_main(args: argparse.Namespace) -> int: + if args.list or args.config is None: + _console.print("\n[bold]Available scenarios:[/bold]") + for name, scenario in SCENARIO_REGISTRY.items(): + start_str = scenario.get("start") or "full period" + end_str = scenario.get("end") or "present" + _console.print(f" [cyan]{name:<22}[/cyan] {start_str} → {end_str} {scenario['description']}") + _console.print("\n[bold]Scenario groups:[/bold]") + for group, names in SCENARIO_GROUPS.items(): + _console.print(f" [yellow]{group:<10}[/yellow] {', '.join(names)}") + return 0 + + if args.scenario: + if args.scenario not in SCENARIO_REGISTRY: + _console.print(f"[red]Unknown scenario '{args.scenario}'[/red]") + return 1 + scenario_names = [args.scenario] + elif args.quick: + scenario_names = SCENARIO_GROUPS["quick"] + elif args.group: + if args.group not in SCENARIO_GROUPS: + _console.print(f"[red]Unknown group '{args.group}'[/red]") + return 1 + scenario_names = SCENARIO_GROUPS[args.group] + else: + scenario_names = SCENARIO_GROUPS["all"] + + config_path, config = resolve_orb_config(args.config) + config = force_simple_returns(config) + config_slug = config_path.stem + + _console.print() + _console.print( + Panel( + f"[bold]ORB SCENARIO TEST[/bold]\n" + f"Config: [cyan]{config_path}[/cyan]\n" + f"Scenarios: [yellow]{len(scenario_names)}[/yellow] ({', '.join(scenario_names)})\n" + f"Full data window: {args.start} → {args.end or _latest_backtest_date().isoformat()}", + box=box.DOUBLE, + width=100, + ) + ) + + settings = get_settings() + async with make_intraday_oracle_client(settings) as client: + context = await build_orb_research_context( + config, + args.start, + args.end or _latest_backtest_date().isoformat(), + client, + print_progress=True, + ) + + t0 = time.time() + scenario_results: dict[str, dict] = {} + with Progress( + SpinnerColumn(), + TextColumn("[progress.description]{task.description}"), + BarColumn(bar_width=30), + "{task.completed}/{task.total}", + TimeElapsedColumn(), + console=_console, + ) as progress: + task = progress.add_task("Running scenarios...", total=len(scenario_names)) + for name in scenario_names: + progress.update(task, description=f"[cyan]{name}[/cyan]") + scenario_results[name] = await run_scenario(name, SCENARIO_REGISTRY[name], context, client, args.start) + progress.advance(task) + progress.update(task, description="Complete") + elapsed = time.time() - t0 + + _print_scenario_table(scenario_results, config_slug) + non_skipped = {k: v for k, v in scenario_results.items() if v.get("verdict") != "SKIP" and "sharpe_ratio" in v} + if len(non_skipped) >= 3: + rrs, components = compute_orb_rrs(non_skipped) + _print_rrs_panel(rrs, components, config_slug) + else: + rrs, components = 0.0, {} + _console.print(f"\n[dim]RRS not computed: need ≥ 3 non-skipped scenarios, got {len(non_skipped)}[/dim]") + + _console.print(f"\n[dim]Elapsed: {elapsed:.0f}s[/dim]\n") + + if args.save: + save_dir = Path("runs/intraday_orb") + save_dir.mkdir(parents=True, exist_ok=True) + out_path = save_dir / f"{config_slug}_scenario_report.json" + payload = { + "config": str(config_path), + "scenarios_run": scenario_names, + "rrs": rrs, + "verdict": _rrs_verdict(rrs) if rrs > 0 else "N/A", + "components": components, + "results": scenario_results, + "elapsed_seconds": round(elapsed, 1), + } + out_path.write_text(json.dumps(payload, indent=2)) + _console.print(f"[dim]Report saved → {out_path}[/dim]\n") + return 0 + + +def main() -> None: + parser = argparse.ArgumentParser( + prog="fithia2 intraday-scenario-test", + description="ORB strategy regime robustness test.", + ) + parser.add_argument("--config", "-c", default=None, help="YAML config path or strategy slug") + parser.add_argument("--scenario", default=None, help="Run a single named scenario") + parser.add_argument("--group", default=None, help="Run a scenario group: regime, signal, quick, all") + parser.add_argument("--quick", action="store_true", help="Quick mode: run only bear_2022, bull_2023h2, oos_2026") + parser.add_argument("--start", default=_DEFAULT_START_DATE, help=f"Start of full data window (default: {_DEFAULT_START_DATE})") + parser.add_argument("--end", default=None, help="End of full data window (default: latest available)") + parser.add_argument("--save", action="store_true", help="Save JSON report to runs/intraday_orb") + parser.add_argument("--list", action="store_true", help="List all available scenarios and groups") + args = parser.parse_args() + raise SystemExit(asyncio.run(_async_main(args))) + + +if __name__ == "__main__": + main() diff --git a/apps/intraday_bt/sweep.py b/apps/intraday_bt/sweep.py new file mode 100644 index 0000000..e5ec1c8 --- /dev/null +++ b/apps/intraday_bt/sweep.py @@ -0,0 +1,148 @@ +"""Parameter grid search engine for intraday backtesting. + +Data is fetched once; simulations run repeatedly with different params. +288 combinations × ~200 days ≈ 5 minutes total simulation time. +""" +from __future__ import annotations + +import itertools +from pathlib import Path +from typing import Any + +import yaml + +from libs.intraday.domain import ( + IntradayConfig, + ORBStrategyParams, + StrategyParams, + SweepResult, +) +from libs.intraday.metrics import compute_metrics +from libs.intraday.simulator import run_simulation + + +class SweepConfig: + """Parsed sweep configuration.""" + + def __init__(self, base_config: IntradayConfig, sweep_params: dict[str, list[Any]]) -> None: + self.base_config = base_config + self.sweep_params = sweep_params + + @property + def total_combinations(self) -> int: + total = 1 + for vals in self.sweep_params.values(): + total *= len(vals) + return total + + +def load_sweep_config(sweep_path: str, base_config: IntradayConfig) -> SweepConfig: + """Load a sweep YAML and merge with the base config.""" + with open(sweep_path) as f: + raw = yaml.safe_load(f) + + sweep_params: dict[str, list[Any]] = {} + for key, vals in raw.get("sweep", {}).items(): + if not isinstance(vals, list): + vals = [vals] + # Normalize None strings and null values + normalized = [None if v in (None, "null", "none", "None") else v for v in vals] + sweep_params[key] = normalized + + return SweepConfig(base_config=base_config, sweep_params=sweep_params) + + +def generate_combinations(sweep: SweepConfig) -> list[dict[str, Any]]: + """Generate Cartesian product of all sweep parameters.""" + keys = sorted(sweep.sweep_params.keys()) + values = [sweep.sweep_params[k] for k in keys] + combos = list(itertools.product(*values)) + return [dict(zip(keys, combo)) for combo in combos] + + +def apply_overrides(base_config: IntradayConfig, overrides: dict[str, Any]) -> IntradayConfig: + """Apply parameter overrides to base config, returning a new config. + + Branches on strategy_mode: momentum overrides go to StrategyParams, + ORB overrides go to ORBStrategyParams. + """ + if base_config.strategy_mode == "orb": + orb = base_config.orb_strategy or ORBStrategyParams() + orb_dict = orb.model_dump() + orb_fields = set(ORBStrategyParams.model_fields.keys()) + for key, val in overrides.items(): + if key in orb_fields: + orb_dict[key] = val + new_orb = ORBStrategyParams(**orb_dict) + return base_config.model_copy(update={"orb_strategy": new_orb}) + + # Momentum mode (default) + strategy_dict = base_config.strategy.model_dump() + strategy_fields = set(StrategyParams.model_fields.keys()) + for key, val in overrides.items(): + if key in strategy_fields: + strategy_dict[key] = val + new_strategy = StrategyParams(**strategy_dict) + return base_config.model_copy(update={"strategy": new_strategy}) + + +def run_sweep( + sweep: SweepConfig, + all_intraday: dict[str, dict[str, list[dict]]], + trading_days: list[str], + progress_callback: Any = None, + enrichment: dict | None = None, + momentum_enrichment: dict | None = None, + vix_by_day: dict[str, float] | None = None, + ticker_sectors: dict[str, str] | None = None, +) -> list[SweepResult]: + """Run simulation for each parameter combination. + + Data is pre-fetched and shared across all runs. + Only the simulation (pure CPU computation) varies per combination. + + Args: + sweep: SweepConfig with base config and param grid. + all_intraday: Pre-loaded {date: {ticker: [bars]}} data. + trading_days: List of dates. + progress_callback: Optional callable(completed, total) for progress. + enrichment: Pre-computed daily enrichment (required for ORB strategy). + + Returns: + List of SweepResult sorted by Sharpe ratio descending. + """ + combos = generate_combinations(sweep) + results: list[SweepResult] = [] + is_orb = sweep.base_config.strategy_mode == "orb" + + for i, overrides in enumerate(combos): + config = apply_overrides(sweep.base_config, overrides) + + if is_orb: + from libs.intraday.orb_simulator import run_orb_simulation + day_results = run_orb_simulation( + all_intraday, trading_days, config.orb_strategy, enrichment or {}, + vix_by_day=vix_by_day, + ) + else: + day_results = run_simulation( + all_intraday, + trading_days, + config.strategy, + daily_enrichment=momentum_enrichment, + vix_by_day=vix_by_day, + ticker_sectors=ticker_sectors, + ) + + metrics = compute_metrics(day_results, config, run_id=f"sw{i:04d}") + results.append(SweepResult(params=overrides, metrics=metrics)) + + if progress_callback: + progress_callback(i + 1, len(combos)) + + # Sort by Sharpe descending (None treated as -inf) + results.sort( + key=lambda r: (r.metrics.sharpe_ratio or float("-inf"), r.metrics.total_return_pct or -999), + reverse=True, + ) + return results diff --git a/apps/paper_trader/alpaca_broker.py b/apps/paper_trader/alpaca_broker.py index f5d4a9c..225cdb9 100644 --- a/apps/paper_trader/alpaca_broker.py +++ b/apps/paper_trader/alpaca_broker.py @@ -319,25 +319,30 @@ class AlpacaBroker: return result def get_latest_bars(self, symbols: list[str]) -> dict[str, Bar]: - """Fetch the latest bar for each symbol.""" + """Fetch the latest bar for each symbol via Oracle snapshot API. + + Routes through Oracle so that problematic symbols (e.g. BF-B → BF.B) + are normalised server-side before hitting Alpaca. + """ if not symbols: return {} - from alpaca.data.requests import StockLatestBarRequest + from libs.oracle_client.alpaca import get_snapshots - req = StockLatestBarRequest(symbol_or_symbols=symbols, feed="iex") - response = self._data.get_stock_latest_bar(req) + snaps = get_snapshots(symbols) result: dict[str, Bar] = {} - for sym in symbols: - b = response.get(sym) - if b is not None: + import datetime as _dt + today = _dt.date.today().isoformat() + for sym, snap in snaps.items(): + price = snap.price or snap.mid + if price is not None: result[sym] = Bar( - date=b.timestamp.date().isoformat() if hasattr(b.timestamp, "date") else str(b.timestamp)[:10], - open=float(b.open), - high=float(b.high), - low=float(b.low), - close=float(b.close), - volume=float(b.volume), + date=today, + open=price, + high=price, + low=price, + close=price, + volume=snap.volume or 0, ) return result diff --git a/apps/paper_trader/models.py b/apps/paper_trader/models.py index 22dc270..5091853 100644 --- a/apps/paper_trader/models.py +++ b/apps/paper_trader/models.py @@ -172,6 +172,7 @@ def create_schema(db_path: str | Path) -> None: for col, dtype in [ ("engine_id", "TEXT"), ("capital_bucket_id", "TEXT"), + ("entry_shares", "INTEGER"), ]: try: conn.execute(f"ALTER TABLE trades ADD COLUMN {col} {dtype}") diff --git a/apps/paper_trader/state.py b/apps/paper_trader/state.py index d265870..f0c2382 100644 --- a/apps/paper_trader/state.py +++ b/apps/paper_trader/state.py @@ -328,9 +328,9 @@ class StateManager: with self._connect() as conn: conn.execute( "INSERT INTO trades (trade_id, session_id, symbol, engine_id, capital_bucket_id, " - "entry_date, entry_price, shares) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + "entry_date, entry_price, shares, entry_shares) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", (trade_id, session_id, symbol, engine_id, capital_bucket_id, - entry_date, entry_price, shares), + entry_date, entry_price, shares, shares), ) return trade_id diff --git a/apps/tracker/cli.py b/apps/tracker/cli.py index 0fcac8d..bbf9ba9 100644 --- a/apps/tracker/cli.py +++ b/apps/tracker/cli.py @@ -1176,6 +1176,21 @@ def _print_help() -> None: "합성 시나리오 백테스트 (Regime Robustness Score)", "--config NAME --scenario NAME --group NAME --quick --save", ) + t.add_row( + "intraday-overfit-check", + "ORB 과적합 분석 (IS/OOS·WFV·파라미터 plateau·permutation)", + "--config NAME --split-date DATE --quick --permutations N", + ) + t.add_row( + "intraday-scenario-test", + "ORB 시나리오 테스트 (연도별 regime 슬라이스 + RVOL/ranking 검증)", + "--config NAME --scenario NAME --group NAME --quick --save", + ) + t.add_row( + "intraday-orb-lab", + "ORB v2 연구 파이프라인 (coarse → rerank → test → robustness → rank)", + "--config NAME --quick --beam-width N", + ) _console.print(t) # ── 트레이딩 & 파이프라인 ──────────────────────────────────── @@ -1247,6 +1262,27 @@ def main() -> None: scenario_main() return + # Delegate `fithia2 intraday-overfit-check ...` to the ORB overfitting analysis CLI + if len(sys.argv) >= 2 and sys.argv[1] == "intraday-overfit-check": + sys.argv = [sys.argv[0]] + sys.argv[2:] + from apps.intraday_bt.overfit_check import main as intraday_overfit_main + intraday_overfit_main() + return + + # Delegate `fithia2 intraday-scenario-test ...` to the ORB scenario test CLI + if len(sys.argv) >= 2 and sys.argv[1] == "intraday-scenario-test": + sys.argv = [sys.argv[0]] + sys.argv[2:] + from apps.intraday_bt.scenario_test import main as intraday_scenario_main + intraday_scenario_main() + return + + # Delegate `fithia2 intraday-orb-lab ...` to the ORB research lab CLI + if len(sys.argv) >= 2 and sys.argv[1] == "intraday-orb-lab": + sys.argv = [sys.argv[0]] + sys.argv[2:] + from apps.intraday_bt.lab import main as intraday_orb_lab_main + intraday_orb_lab_main() + return + # Delegate `fithia2 paper ...` to the paper trader CLI if len(sys.argv) >= 2 and sys.argv[1] == "paper": sys.argv = [sys.argv[0]] + sys.argv[2:] diff --git a/apps/web/routers/intraday.py b/apps/web/routers/intraday.py index ffb2e7a..3e778b7 100644 --- a/apps/web/routers/intraday.py +++ b/apps/web/routers/intraday.py @@ -29,7 +29,30 @@ _tasks: dict[str, dict[str, Any]] = {} _tasks_lock = threading.Lock() _tasks_initialized = False -INTRADAY_OUTPUT_DIR = "runs/intraday_orb" +_DEFAULT_OUTPUT_DIR_BY_MODE: dict[str, str] = { + "momentum": "runs/intraday", + "orb": "runs/intraday_orb", +} + +_RUN_VALID_UNIVERSES: set[str] = { + "sp500", + "nasdaq100", + "midlarge", + "largecap", + "midcap", + "smallmid", + "screener", + "yaml", +} + +_EDITOR_VALID_UNIVERSES: set[str] = { + "sp500", + "nasdaq100", + "midlarge", + "largecap", + "midcap", + "smallmid", +} # Built-in strategies (read-only presets shipped with the system) # All strategies are now directory-based (configs/intraday/strategies/). @@ -93,9 +116,10 @@ def _log_tail_error(log_path: Path, lines: int = 50) -> str: return "(log unavailable)" -def _detect_result_file(started_at: datetime) -> Path | None: +def _detect_result_file(started_at: datetime, output_dir: str | None = None) -> Path | None: """Find the most recent intraday result JSON written after started_at.""" - out_dir = get_project_root() / INTRADAY_OUTPUT_DIR + out_dir_name = output_dir or _DEFAULT_OUTPUT_DIR_BY_MODE["orb"] + out_dir = get_project_root() / out_dir_name if not out_dir.exists(): return None candidates = [] @@ -125,11 +149,43 @@ def _result_summary_from_file(result_path: Path) -> dict[str, Any] | None: "trade_count": m.get("total_trades"), "final_equity": m.get("final_equity"), "calmar": m.get("calmar_ratio"), + "loss_containment_score": m.get("loss_containment_score"), + "avg_loss_day_pct": m.get("avg_loss_day_pct"), + "tail_loss_20_pct": m.get("tail_loss_20_pct"), + "worst_day_return_pct": m.get("worst_day_return_pct"), } except Exception: return None +def _load_strategy_runtime(config_path: str | Path) -> tuple[str, str]: + """Return (strategy_mode, output_dir) derived from a config file.""" + raw = yaml.safe_load(Path(config_path).read_text()) or {} + strategy_mode = str(raw.get("strategy_mode") or "momentum").lower() + if strategy_mode not in _DEFAULT_OUTPUT_DIR_BY_MODE: + strategy_mode = "momentum" + output_dir = str( + raw.get("output", {}).get("dir") + or _DEFAULT_OUTPUT_DIR_BY_MODE[strategy_mode] + ) + return strategy_mode, output_dir + + +def _load_strategy_universe(config_path: str | Path) -> tuple[str, str | None]: + """Return (universe_source, symbols_file) from a strategy config.""" + raw = yaml.safe_load(Path(config_path).read_text()) or {} + universe = raw.get("universe", {}) or {} + source = str(universe.get("source") or "midlarge").lower() + symbols_file = universe.get("symbols_file") + return source, str(symbols_file) if symbols_file else None + + +def _format_universe_label(source: str, symbols_file: str | None) -> str: + if source == "yaml" and symbols_file: + return f"yaml:{Path(symbols_file).name}" + return source + + def _watch_process(task_id: str, proc: subprocess.Popen) -> None: # type: ignore[type-arg] """Background thread: wait for subprocess to finish, then update task state.""" import time @@ -162,7 +218,7 @@ def _watch_process(task_id: str, proc: subprocess.Popen) -> None: # type: ignor # Allow a brief moment for file system writes to flush time.sleep(1) - result_path = _detect_result_file(started_at) + result_path = _detect_result_file(started_at, task.get("output_dir") if task else None) log_path = _log_file(task_id) with _tasks_lock: @@ -175,6 +231,7 @@ def _watch_process(task_id: str, proc: subprocess.Popen) -> None: # type: ignor task["pid"] = None task["finished_at"] = datetime.now(timezone.utc).isoformat() + task["returncode"] = proc.returncode if proc.returncode == 0 and result_path: task["status"] = "completed" @@ -215,7 +272,7 @@ def _load_tasks_from_disk() -> None: if started_at_str: try: started_at = datetime.fromisoformat(started_at_str.replace("Z", "+00:00")) - result_path = _detect_result_file(started_at) + result_path = _detect_result_file(started_at, data.get("output_dir")) except Exception: pass if result_path: @@ -274,37 +331,68 @@ def _load_user_strategy(slug: str) -> dict[str, Any] | None: try: raw = yaml.safe_load(path.read_text()) or {} meta = raw.get("_meta", {}) + strategy_mode = str(raw.get("strategy_mode") or "momentum").lower() orb = raw.get("orb_strategy", {}) + momentum = raw.get("strategy", {}) backtest = raw.get("backtest", {}) universe = raw.get("universe", {}) - return { + base = { "slug": slug, "id": meta.get("id"), "name": meta.get("name", slug), "description": meta.get("description", ""), "builtin": False, "config_path": str(path.relative_to(get_project_root())), - "initial_capital": orb.get("initial_capital", 10000.0), - "risk_per_trade_pct": orb.get("risk_per_trade_pct", 0.0025), - "max_position_pct": orb.get("max_position_pct", 0.20), - "atr_stop_multiplier": orb.get("atr_stop_multiplier", 0.50), - "min_rvol": orb.get("min_rvol", 1.0), - "max_candidates": orb.get("max_candidates", 20), - "daily_max_loss_pct": orb.get("daily_max_loss_pct", 0.0125), - "max_stops_per_day": orb.get("max_stops_per_day", 3), - "breakeven_at_r": orb.get("breakeven_at_r", 1.0), - "trailing_at_r": orb.get("trailing_at_r", 2.0), - "trailing_stop_atr_multiplier": orb.get("trailing_stop_atr_multiplier", 0.0), - "order_timeout_minutes": orb.get("order_timeout_minutes", 45), - "settlement_days": orb.get("settlement_days", 1), - "min_candidate_breadth": orb.get("min_candidate_breadth"), - "max_gap_pct": orb.get("max_gap_pct", 0.10), - "sim_bar_minutes": orb.get("sim_bar_minutes", 5), - "orb_minutes": orb.get("orb_minutes", 5), - "compound_returns": orb.get("compound_returns", True), "days": backtest.get("lookback_trading_days", 200), "universe": universe.get("source", "midlarge"), + "universe_symbols_file": universe.get("symbols_file"), + "universe_label": _format_universe_label( + str(universe.get("source", "midlarge")), + str(universe.get("symbols_file")) if universe.get("symbols_file") else None, + ), + "strategy_mode": strategy_mode, + "output_dir": raw.get("output", {}).get( + "dir", + _DEFAULT_OUTPUT_DIR_BY_MODE.get(strategy_mode, _DEFAULT_OUTPUT_DIR_BY_MODE["momentum"]), + ), } + if strategy_mode == "orb": + base.update({ + "initial_capital": orb.get("initial_capital", 10000.0), + "risk_per_trade_pct": orb.get("risk_per_trade_pct", 0.0025), + "max_position_pct": orb.get("max_position_pct", 0.20), + "atr_stop_multiplier": orb.get("atr_stop_multiplier", 0.50), + "min_rvol": orb.get("min_rvol", 1.0), + "max_candidates": orb.get("max_candidates", 20), + "daily_max_loss_pct": orb.get("daily_max_loss_pct", 0.0125), + "max_stops_per_day": orb.get("max_stops_per_day", 3), + "breakeven_at_r": orb.get("breakeven_at_r", 1.0), + "trailing_at_r": orb.get("trailing_at_r", 2.0), + "trailing_stop_atr_multiplier": orb.get("trailing_stop_atr_multiplier", 0.0), + "order_timeout_minutes": orb.get("order_timeout_minutes", 45), + "settlement_days": orb.get("settlement_days", 1), + "min_candidate_breadth": orb.get("min_candidate_breadth"), + "max_gap_pct": orb.get("max_gap_pct", 0.10), + "sim_bar_minutes": orb.get("sim_bar_minutes", 5), + "orb_minutes": orb.get("orb_minutes", 5), + "compound_returns": orb.get("compound_returns", True), + }) + else: + base.update({ + "initial_capital": momentum.get("initial_capital", 10000.0), + "entry_minutes_after_open": momentum.get("entry_minutes_after_open", 30), + "exit_minutes_before_close": momentum.get("exit_minutes_before_close", 30), + "top_n": momentum.get("top_n", 3), + "min_morning_gain_pct": momentum.get("min_morning_gain_pct", 0.01), + "max_morning_gain_pct": momentum.get("max_morning_gain_pct"), + "min_entry_volume": momentum.get("min_entry_volume"), + "stop_loss_pct": momentum.get("stop_loss_pct"), + "trailing_stop_pct": momentum.get("trailing_stop_pct"), + "ticker_cooldown_days": momentum.get("ticker_cooldown_days", 0), + "market_regime_spy_threshold": momentum.get("market_regime_spy_threshold"), + "compound_returns": False, + }) + return base except Exception: return None @@ -426,6 +514,7 @@ class IntradayBacktestRequest(BaseModel): # Per-run overrides compound_returns: bool | None = None # None = use strategy config default initial_capital: float | None = None # None = use strategy config default + daily_budget_reset: bool | None = None # Research mode: reset budget daily class CreateStrategyRequest(BaseModel): @@ -509,8 +598,7 @@ def get_strategy(slug: str) -> dict[str, Any]: @router.post("/strategies") def create_strategy(req: CreateStrategyRequest) -> dict[str, Any]: """Create a new user-defined strategy.""" - valid_universes = {"sp500", "nasdaq100", "midlarge", "largecap", "midcap"} - if req.universe not in valid_universes: + if req.universe not in _EDITOR_VALID_UNIVERSES: raise HTTPException(status_code=400, detail=f"Unknown universe: {req.universe!r}") slug = _slugify(req.name) @@ -528,6 +616,7 @@ def create_strategy(req: CreateStrategyRequest) -> dict[str, Any]: "builtin": False, "days": req.days, "universe": req.universe, + "universe_label": req.universe, "initial_capital": req.initial_capital, "risk_per_trade_pct": req.risk_per_trade_pct, "max_position_pct": req.max_position_pct, @@ -557,13 +646,18 @@ def update_strategy(slug: str, req: UpdateStrategyRequest) -> dict[str, Any]: strat = _load_user_strategy(slug) if not strat: raise HTTPException(status_code=404, detail=f"Strategy not found: {slug!r}") + if strat.get("strategy_mode") != "orb": + raise HTTPException( + status_code=400, + detail="Web strategy editor currently supports ORB strategies only", + ) # Apply non-None updates updates = req.model_dump(exclude_none=True) for k, v in updates.items(): strat[k] = v - if req.universe and req.universe not in {"sp500", "nasdaq100", "midlarge", "largecap", "midcap"}: + if req.universe and req.universe not in _EDITOR_VALID_UNIVERSES: raise HTTPException(status_code=400, detail=f"Unknown universe: {req.universe!r}") _save_user_strategy(strat) @@ -576,6 +670,11 @@ def copy_strategy(slug: str) -> dict[str, Any]: strat = _load_user_strategy(slug) if not strat: raise HTTPException(status_code=404, detail=f"Strategy not found: {slug!r}") + if strat.get("strategy_mode") != "orb": + raise HTTPException( + status_code=400, + detail="Web strategy copy currently supports ORB strategies only", + ) new_name = f"{strat['name']} (copy)" base_slug = _slugify(new_name) @@ -616,8 +715,7 @@ def submit_intraday_backtest(req: IntradayBacktestRequest) -> dict[str, Any]: # Resolve config slug → YAML path config_path = _resolve_config_path(req.config) - valid_universes = {"sp500", "nasdaq100", "midlarge", "largecap", "midcap"} - if req.universe not in valid_universes: + if req.universe not in _RUN_VALID_UNIVERSES: raise HTTPException(status_code=400, detail=f"Unknown universe: {req.universe!r}") # Normalize dates @@ -636,11 +734,13 @@ def submit_intraday_backtest(req: IntradayBacktestRequest) -> dict[str, Any]: "start_date": start_iso, "end_date": end_iso, "compound_returns": req.compound_returns, + "daily_budget_reset": req.daily_budget_reset, "status": "queued", "created_at": datetime.now(timezone.utc).isoformat(), "started_at": None, "finished_at": None, "pid": None, + "returncode": None, "error": None, "result_file": None, "result_summary": None, @@ -650,14 +750,23 @@ def submit_intraday_backtest(req: IntradayBacktestRequest) -> dict[str, Any]: _log_dir().mkdir(parents=True, exist_ok=True) log_path = _log_file(task_id) - output_dir = str(get_project_root() / INTRADAY_OUTPUT_DIR) + strategy_mode, output_dir_name = _load_strategy_runtime(config_path) + _strategy_universe_source, strategy_universe_symbols_file = _load_strategy_universe(config_path) + task["strategy_mode"] = strategy_mode + task["output_dir"] = output_dir_name + task["universe_label"] = _format_universe_label( + req.universe, + strategy_universe_symbols_file if req.universe == "yaml" else None, + ) + output_dir = str(project_root / output_dir_name) cmd = [ - sys.executable, "-m", "apps.intraday_bt.run", - "--strategy", "orb", + sys.executable, "-u", "-m", "apps.intraday_bt.run", + "--strategy", strategy_mode, "--config", config_path, - "--universe", req.universe, "--output-dir", output_dir, ] + if req.universe != "yaml": + cmd.extend(["--universe", req.universe]) # Date params: explicit range takes precedence over lookback days if start_iso: cmd.extend(["--start", start_iso]) @@ -676,6 +785,12 @@ def submit_intraday_backtest(req: IntradayBacktestRequest) -> dict[str, Any]: elif req.compound_returns is False: cmd.append("--no-compound-returns") + # Per-run daily budget reset override (research mode) + if req.daily_budget_reset is True: + cmd.append("--daily-budget-reset") + elif req.daily_budget_reset is False: + cmd.append("--no-daily-budget-reset") + with _tasks_lock: _tasks[task_id] = task _persist_task(task) @@ -722,7 +837,7 @@ def _try_resolve_dead_task(task_id: str) -> None: except Exception: pass - result_path = _detect_result_file(started_at) + result_path = _detect_result_file(started_at, task.get("output_dir")) task["pid"] = None if not task.get("finished_at"): task["finished_at"] = datetime.now(timezone.utc).isoformat() diff --git a/apps/web/routers/paper_trading.py b/apps/web/routers/paper_trading.py index a4a1bb5..7c976ff 100644 --- a/apps/web/routers/paper_trading.py +++ b/apps/web/routers/paper_trading.py @@ -231,18 +231,40 @@ def resume_session(session_id: str) -> dict[str, Any]: @router.delete("/sessions/{session_id}") def close_session(session_id: str) -> dict[str, Any]: - """Liquidate all Alpaca positions and delete all session data.""" + """Liquidate this session's Alpaca positions and delete all session data.""" state = _get_state_manager() session = state.get_session(session_id) if session is None: raise HTTPException(status_code=404, detail="Session not found") + # Collect (symbol, qty) for this session only — use trade records for strategy + # positions so orphaned shares in the broker are not accidentally sold. + positions_to_close: list[tuple[str, int | None]] = [] + open_trades_by_sym = { + t["symbol"]: t + for t in state.list_trades(session.session_id) + if t.get("exit_date") is None + } + for ss in state.get_open_strategy_states(session.session_id): + ot = open_trades_by_sym.get(ss.symbol) + qty = int(ot["shares"]) if ot and ot.get("shares") else None + positions_to_close.append((ss.symbol, qty)) + parking = state.get_parking_state(session.session_id) + if parking: + # Parking position has no orphaned shares — use full close (no qty) to avoid + # mismatch errors if Alpaca qty differs slightly from DB. + positions_to_close.append((parking["symbol"].upper(), None)) + orders_closed = 0 broker_error: str | None = None try: broker = _get_broker() - orders = broker.close_all_positions() - orders_closed = len(orders) + for sym, qty in positions_to_close: + try: + broker.close_position(sym, qty=qty) + orders_closed += 1 + except Exception as exc: + broker_error = (broker_error + "; " if broker_error else "") + f"{sym}: {exc}" except Exception as exc: broker_error = str(exc) @@ -272,36 +294,54 @@ def get_positions(session_id: str) -> dict[str, Any]: for ss in state.get_open_strategy_states(session.session_id) } + parking_state = state.get_parking_state(session.session_id) + parking_symbol = parking_state["symbol"].upper() if parking_state else None + + # Open trade records keyed by symbol — for locally-tracked entry price/qty + open_trades = { + t["symbol"]: t + for t in state.list_trades(session.session_id) + if t.get("exit_date") is None and t.get("symbol") != (parking_symbol or "") + } + try: broker = _get_broker() positions = broker.list_positions() + broker_by_symbol = {p.symbol: p for p in positions} result = [] for p in sorted(positions, key=lambda x: x.symbol): ss = strategy_states.get(p.symbol) - if ss is None: + is_parking = parking_symbol and p.symbol == parking_symbol + if ss is None and not is_parking: continue # Only show positions tracked by this session - qty = float(p.qty) - entry = float(p.avg_entry_price) if p.avg_entry_price else 0.0 - pnl = float(p.unrealized_pl) + # Use locally-recorded entry price/qty for strategy positions to avoid + # orphaned-share contamination of Alpaca's blended avg_entry_price. + ot = open_trades.get(p.symbol) if not is_parking else None + qty = float(ot["shares"]) if ot and ot.get("shares") else float(p.qty) + entry = float(ot["entry_price"]) if ot and ot.get("entry_price") else ( + float(p.avg_entry_price) if p.avg_entry_price else 0.0 + ) + cur_price = float(p.current_price) if p.current_price else None + pnl = (cur_price - entry) * qty if cur_price and entry and qty else float(p.unrealized_pl) pnl_pct = pnl / (entry * qty) * 100 if entry and qty else 0.0 result.append({ "symbol": p.symbol, "qty": qty, "avg_entry_price": entry, - "current_price": float(p.current_price) if p.current_price else None, + "current_price": cur_price, "unrealized_pl": pnl, "unrealized_pl_pct": pnl_pct, "days_held": ss.days_held if ss else None, "stop_price": ss.current_stop if ss else None, "target_price": ss.target_price if ss else None, - "entry_date": ss.entry_date if ss else None, - "engine_id": ss.engine_id if ss else None, + "entry_date": ss.entry_date if ss else (parking_state["entry_date"] if is_parking else None), + "engine_id": ss.engine_id if ss else ("parking" if is_parking else None), "trade_direction": ss.trade_direction if ss else None, + "_parking": bool(is_parking and not ss), }) # Include strategy states missing from broker (ghost positions) - broker_symbols = {p.symbol for p in positions} for sym, ss in strategy_states.items(): - if sym not in broker_symbols: + if sym not in broker_by_symbol: result.append({ "symbol": sym, "qty": None, @@ -317,6 +357,24 @@ def get_positions(session_id: str) -> dict[str, Any]: "trade_direction": ss.trade_direction, "_ghost": True, }) + # Include parking if not already in broker positions + if parking_symbol and parking_symbol not in broker_by_symbol: + result.append({ + "symbol": parking_symbol, + "qty": parking_state.get("qty"), + "avg_entry_price": parking_state.get("avg_price"), + "current_price": None, + "unrealized_pl": None, + "unrealized_pl_pct": None, + "days_held": None, + "stop_price": None, + "target_price": None, + "entry_date": parking_state.get("entry_date"), + "engine_id": "parking", + "trade_direction": None, + "_parking": True, + "_ghost": True, + }) return {"positions": result, "broker_available": True} except Exception as exc: result = [] @@ -335,6 +393,23 @@ def get_positions(session_id: str) -> dict[str, Any]: "engine_id": ss.engine_id, "trade_direction": ss.trade_direction, }) + if parking_state: + result.append({ + "symbol": parking_symbol, + "qty": parking_state.get("qty"), + "avg_entry_price": parking_state.get("avg_price"), + "current_price": None, + "unrealized_pl": None, + "unrealized_pl_pct": None, + "days_held": None, + "stop_price": None, + "target_price": None, + "entry_date": parking_state.get("entry_date"), + "engine_id": "parking", + "trade_direction": None, + "_parking": True, + "_ghost": True, + }) return {"positions": result, "broker_available": False, "broker_error": str(exc)} @@ -350,10 +425,14 @@ def get_trades( import datetime trades = state.list_trades(session.session_id, limit=last) - # Include parking entries (active + closed) as parking-sleeve trades + # Include parking entries as fallback only when trades table has no record for that symbol + # (old sessions before open_trade was added to _parking_buy). + symbols_in_trades = {t["symbol"] for t in trades} parking_entries = state.list_parking_entries(session.session_id) today = datetime.date.today() for p in parking_entries: + if p["symbol"] in symbols_in_trades: + continue # already recorded via open_trade / record_trade entry_date = p.get("entry_date", "") try: days = (today - datetime.date.fromisoformat(entry_date)).days diff --git a/apps/web_frontend/src/api/client.ts b/apps/web_frontend/src/api/client.ts index 9eb7197..29891ff 100644 --- a/apps/web_frontend/src/api/client.ts +++ b/apps/web_frontend/src/api/client.ts @@ -357,6 +357,10 @@ export interface IntradayTask { trade_count: number | null; final_equity: number | null; calmar: number | null; + loss_containment_score?: number | null; + avg_loss_day_pct?: number | null; + tail_loss_20_pct?: number | null; + worst_day_return_pct?: number | null; } | null; } @@ -400,6 +404,10 @@ export interface IntradayResult { calmar_ratio: number | null; initial_capital: number; final_equity: number; + avg_loss_day_pct?: number | null; + tail_loss_20_pct?: number | null; + worst_day_return_pct?: number | null; + loss_containment_score?: number | null; }; trades: IntradayTrade[]; daily_summary: { date: string; daily_pnl: number; daily_return_pct: number; candidates_found: number; trades: number }[]; diff --git a/apps/web_frontend/src/pages/IntradayBacktest.tsx b/apps/web_frontend/src/pages/IntradayBacktest.tsx index 0f422ea..87f4492 100644 --- a/apps/web_frontend/src/pages/IntradayBacktest.tsx +++ b/apps/web_frontend/src/pages/IntradayBacktest.tsx @@ -61,12 +61,35 @@ function taskPeriodLabel(task: IntradayTask): string { return `last ${task.days}d`; } -const UNIVERSE_OPTIONS = [ +const RUN_UNIVERSE_OPTIONS = [ { value: 'midlarge', label: 'Mid+Large Cap (971)' }, + { value: 'midcap', label: 'Mid Cap' }, + { value: 'largecap', label: 'Large Cap' }, + { value: 'smallmid', label: 'Small+Mid' }, { value: 'sp500', label: 'S&P 500' }, { value: 'nasdaq100', label: 'NASDAQ 100' }, ]; +const STRATEGY_UNIVERSE_OPTIONS = [ + { value: 'midlarge', label: 'Mid+Large Cap (971)' }, + { value: 'midcap', label: 'Mid Cap' }, + { value: 'largecap', label: 'Large Cap' }, + { value: 'smallmid', label: 'Small+Mid' }, + { value: 'sp500', label: 'S&P 500' }, + { value: 'nasdaq100', label: 'NASDAQ 100' }, +]; + +function buildRunUniverseOptions(strategy: IntradayStrategy) { + const options = [...RUN_UNIVERSE_OPTIONS]; + if (strategy.universe === 'yaml') { + const yamlLabel = strategy.universe_symbols_file + ? `Strategy YAML (${strategy.universe_symbols_file.split('/').slice(-1)[0]})` + : 'Strategy YAML Universe'; + options.unshift({ value: 'yaml', label: yamlLabel }); + } + return options; +} + const inputStyle: React.CSSProperties = { padding: '7px 10px', background: 'var(--bg2)', border: '1px solid var(--border)', borderRadius: 6, color: 'var(--text1)', fontSize: 13, width: '100%', @@ -568,7 +591,7 @@ export function IntradayTaskDetailPage() {