Add paper trader improvements, web GUI updates, and experiment registry cleanup
- Paper trader: Alpaca broker fixes, catchup-thread state improvements - Web GUI: intraday backtest duplicate run button, paper trading fixes - Experiment registry: cleanup old v15/v16 experiments, update index - Tests: Oracle client test additions Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>main
parent
d7bfda039b
commit
b98442b28a
@ -0,0 +1 @@
|
||||
"""Morning Momentum Intraday Backtester app."""
|
||||
@ -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()
|
||||
@ -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()
|
||||
File diff suppressed because it is too large
Load Diff
@ -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()
|
||||
@ -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,
|
||||
}
|
||||
@ -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()
|
||||
@ -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),
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
@ -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()
|
||||
File diff suppressed because it is too large
Load Diff
@ -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()
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
@ -0,0 +1,156 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
import libs.intraday.catalyst as catalyst_mod
|
||||
from libs.intraday.catalyst import (
|
||||
AttentionEventCache,
|
||||
FilingEventCache,
|
||||
fetch_attention_features_bulk,
|
||||
fetch_filing_event_features_bulk,
|
||||
)
|
||||
from libs.oracle_client.models import (
|
||||
EntityInfo,
|
||||
EventAttentionResponse,
|
||||
FilingEventEntry,
|
||||
FilingEventsResponse,
|
||||
NewsFeatures,
|
||||
WikiFeatures,
|
||||
)
|
||||
|
||||
|
||||
def test_filing_event_cache_roundtrip(tmp_path) -> None:
|
||||
cache = FilingEventCache(str(tmp_path))
|
||||
cache.put(
|
||||
"ABC",
|
||||
"2026-01-01",
|
||||
"2026-03-31",
|
||||
[
|
||||
{
|
||||
"ticker": "ABC",
|
||||
"filing_date": "2026-02-10",
|
||||
"accession_number": "1",
|
||||
"event_type": "other_material_event",
|
||||
"item_number": "8.01",
|
||||
"form_type": "8-K",
|
||||
"title": "Material event",
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
hit = cache.get("ABC", "2026-01-02", "2026-03-01")
|
||||
miss = cache.get("ABC", "2025-12-31", "2026-03-01")
|
||||
|
||||
assert hit is not None and hit[0]["event_type"] == "other_material_event"
|
||||
assert miss is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_filing_event_features_bulk_uses_cache_after_first_hit(tmp_path, monkeypatch) -> None:
|
||||
cache = FilingEventCache(str(tmp_path))
|
||||
calls = {"count": 0}
|
||||
|
||||
async def fake_get_events(self, ticker, start_date=None, end_date=None):
|
||||
calls["count"] += 1
|
||||
return FilingEventsResponse(
|
||||
ticker=ticker,
|
||||
events=[
|
||||
FilingEventEntry(
|
||||
id="evt-1",
|
||||
ticker=ticker,
|
||||
accession_number="acc-1",
|
||||
form_type="8-K",
|
||||
filing_date="2026-02-10",
|
||||
item_number="8.01",
|
||||
event_type="other_material_event",
|
||||
title="Material event",
|
||||
)
|
||||
],
|
||||
total_count=1,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(catalyst_mod.FilingsService, "get_filing_events", fake_get_events)
|
||||
|
||||
first = await fetch_filing_event_features_bulk(
|
||||
["ABC"],
|
||||
"2026-01-01",
|
||||
"2026-03-31",
|
||||
client=object(), # patched method ignores the client
|
||||
cache=cache,
|
||||
concurrency=1,
|
||||
)
|
||||
second = await fetch_filing_event_features_bulk(
|
||||
["ABC"],
|
||||
"2026-01-01",
|
||||
"2026-03-31",
|
||||
client=object(),
|
||||
cache=cache,
|
||||
concurrency=1,
|
||||
)
|
||||
|
||||
assert first["ABC"]["2026-02-10"]["event_flag"] is True
|
||||
assert first["ABC"]["2026-02-10"]["event_score"] >= 1.0
|
||||
assert second == first
|
||||
assert calls["count"] == 1
|
||||
|
||||
|
||||
def test_attention_event_cache_roundtrip(tmp_path) -> None:
|
||||
cache = AttentionEventCache(str(tmp_path))
|
||||
cache.put(
|
||||
"ABC",
|
||||
"2026-02-10",
|
||||
{
|
||||
"attention_wiki_spike_10d": 2.3,
|
||||
"attention_wiki_zscore_20d": 3.1,
|
||||
"attention_article_count_3d": 5,
|
||||
"attention_us_article_count_3d": 3,
|
||||
"attention_resolver_confidence": 0.95,
|
||||
},
|
||||
)
|
||||
|
||||
hit = cache.get("ABC", "2026-02-10")
|
||||
miss = cache.get("ABC", "2026-02-11")
|
||||
|
||||
assert hit is not None and hit["attention_article_count_3d"] == 5
|
||||
assert miss is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_attention_features_bulk_uses_cache_after_first_hit(tmp_path, monkeypatch) -> None:
|
||||
cache = AttentionEventCache(str(tmp_path))
|
||||
calls = {"count": 0}
|
||||
|
||||
async def fake_get_attention(self, ticker, event_date):
|
||||
calls["count"] += 1
|
||||
return EventAttentionResponse(
|
||||
ticker=ticker,
|
||||
event_date=event_date,
|
||||
entity=EntityInfo(
|
||||
ticker=ticker,
|
||||
canonical_name=ticker,
|
||||
resolver_confidence=0.91,
|
||||
),
|
||||
wiki=WikiFeatures(spike_10d=2.8, zscore_20d=3.4),
|
||||
news=NewsFeatures(article_count_3d=6, us_article_count_3d=4),
|
||||
metadata={},
|
||||
)
|
||||
|
||||
monkeypatch.setattr(catalyst_mod.AttentionService, "get_event_attention", fake_get_attention)
|
||||
|
||||
first = await fetch_attention_features_bulk(
|
||||
[("ABC", "2026-02-10")],
|
||||
client=object(),
|
||||
cache=cache,
|
||||
concurrency=1,
|
||||
)
|
||||
second = await fetch_attention_features_bulk(
|
||||
[("ABC", "2026-02-10")],
|
||||
client=object(),
|
||||
cache=cache,
|
||||
concurrency=1,
|
||||
)
|
||||
|
||||
assert first["ABC"]["2026-02-10"]["attention_wiki_spike_10d"] == 2.8
|
||||
assert first["ABC"]["2026-02-10"]["attention_us_article_count_3d"] == 4
|
||||
assert second == first
|
||||
assert calls["count"] == 1
|
||||
@ -0,0 +1,71 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from libs.intraday.features import (
|
||||
compute_average_range,
|
||||
compute_average_true_range,
|
||||
compute_entropy_approx,
|
||||
compute_gap_zscore,
|
||||
enrich_daily_bars,
|
||||
)
|
||||
|
||||
|
||||
def _daily_bar(day: str, open_: float, high: float, low: float, close: float, volume: float = 1_000_000.0) -> dict:
|
||||
return {
|
||||
"date": day,
|
||||
"open": open_,
|
||||
"high": high,
|
||||
"low": low,
|
||||
"close": close,
|
||||
"volume": volume,
|
||||
}
|
||||
|
||||
|
||||
def test_entropy_approx_is_bounded() -> None:
|
||||
bars = []
|
||||
close = 100.0
|
||||
for idx in range(30):
|
||||
close *= 1.0 + (0.01 if idx % 2 == 0 else -0.008)
|
||||
bars.append(_daily_bar(f"2024-01-{idx+1:02d}", close * 0.99, close * 1.01, close * 0.98, close))
|
||||
|
||||
entropy = compute_entropy_approx(bars, lookback=20)
|
||||
assert entropy is not None
|
||||
assert 0.0 <= entropy <= 1.0
|
||||
|
||||
|
||||
def test_enrich_daily_bars_populates_new_research_features() -> None:
|
||||
ticker = "AAA"
|
||||
bars = []
|
||||
close = 100.0
|
||||
for idx in range(70):
|
||||
date_str = f"2024-03-{idx+1:02d}" if idx < 31 else f"2024-04-{idx-30:02d}"
|
||||
gap = 0.002 if idx % 3 == 0 else -0.001
|
||||
open_ = close * (1.0 + gap)
|
||||
high = open_ * 1.02
|
||||
low = open_ * 0.99
|
||||
close = open_ * (1.0 + (0.004 if idx % 2 == 0 else -0.003))
|
||||
bars.append(_daily_bar(date_str, open_, high, low, close))
|
||||
|
||||
trading_day = bars[-1]["date"]
|
||||
enriched = enrich_daily_bars({ticker: bars}, [trading_day])
|
||||
features = enriched[ticker][trading_day]
|
||||
|
||||
assert features["entropy_20d"] is not None
|
||||
assert 0.0 <= features["entropy_20d"] <= 1.0
|
||||
assert features["atr_ratio_10_60"] is not None
|
||||
assert features["range_compression_10_60"] is not None
|
||||
assert features["gap_zscore_20d"] is not None
|
||||
|
||||
|
||||
def test_gap_zscore_and_range_helpers_return_values() -> None:
|
||||
bars = []
|
||||
close = 50.0
|
||||
for idx in range(65):
|
||||
open_ = close * (1.0 + 0.002)
|
||||
high = open_ * 1.03
|
||||
low = open_ * 0.98
|
||||
close = open_ * 1.001
|
||||
bars.append(_daily_bar(f"2024-05-{idx+1:02d}", open_, high, low, close))
|
||||
|
||||
assert compute_average_true_range(bars, 10) is not None
|
||||
assert compute_average_range(bars, 10) is not None
|
||||
assert compute_gap_zscore(bars[:-1], today_open=bars[-1]["open"], lookback=20) is not None
|
||||
@ -0,0 +1,185 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pyarrow as pa
|
||||
import pyarrow.parquet as pq
|
||||
|
||||
from libs.intraday.cache import DailyBarCache, IntradayCache
|
||||
|
||||
|
||||
def test_legacy_cache_file_is_treated_as_miss_and_removed(tmp_path) -> None:
|
||||
cache = IntradayCache(str(tmp_path))
|
||||
cache_path = tmp_path / "AAPL" / "2026-01-05.parquet"
|
||||
cache_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
legacy = pa.table({
|
||||
"timestamp": ["2026-01-05T13:30:00Z"],
|
||||
"open": [100.0],
|
||||
"high": [101.0],
|
||||
"low": [99.5],
|
||||
"close": [100.5],
|
||||
"volume": [1000.0],
|
||||
"vwap": [100.4],
|
||||
})
|
||||
pq.write_table(legacy, str(cache_path))
|
||||
|
||||
assert cache.has("AAPL", "2026-01-05") is False
|
||||
assert cache.get("AAPL", "2026-01-05") is None
|
||||
assert cache_path.exists() is False
|
||||
|
||||
|
||||
def test_cache_put_writes_metadata_valid_file(tmp_path) -> None:
|
||||
cache = IntradayCache(str(tmp_path))
|
||||
bars = []
|
||||
for i in range(10):
|
||||
bars.append({
|
||||
"timestamp": f"2026-01-05T13:{30 + i:02d}:00Z",
|
||||
"open": 100.0,
|
||||
"high": 101.0,
|
||||
"low": 99.5,
|
||||
"close": 100.5,
|
||||
"volume": 1000.0,
|
||||
"vwap": 100.4,
|
||||
})
|
||||
|
||||
cache.put("AAPL", "2026-01-05", bars)
|
||||
|
||||
assert cache.has("AAPL", "2026-01-05") is True
|
||||
assert cache.get("AAPL", "2026-01-05") == bars
|
||||
|
||||
|
||||
def test_intraday_cache_rejects_too_few_rows(tmp_path) -> None:
|
||||
cache = IntradayCache(str(tmp_path))
|
||||
cache_path = tmp_path / "AAPL" / "2026-01-05.parquet"
|
||||
cache_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
tiny = pa.table({
|
||||
"timestamp": [f"2026-01-05T13:{30+i:02d}:00Z" for i in range(5)],
|
||||
"open": [100.0] * 5,
|
||||
"high": [101.0] * 5,
|
||||
"low": [99.5] * 5,
|
||||
"close": [100.5] * 5,
|
||||
"volume": [1000.0] * 5,
|
||||
"vwap": [100.4] * 5,
|
||||
}, schema=pa.schema([
|
||||
pa.field("timestamp", pa.string()),
|
||||
pa.field("open", pa.float64()),
|
||||
pa.field("high", pa.float64()),
|
||||
pa.field("low", pa.float64()),
|
||||
pa.field("close", pa.float64()),
|
||||
pa.field("volume", pa.float64()),
|
||||
pa.field("vwap", pa.float64()),
|
||||
]).with_metadata({
|
||||
b"intraday_cache_version": b"3",
|
||||
b"intraday_cache_source": b"api_v1_alpaca_intraday",
|
||||
b"intraday_cache_interval": b"5min",
|
||||
}))
|
||||
pq.write_table(tiny, str(cache_path))
|
||||
|
||||
assert cache.has("AAPL", "2026-01-05") is False
|
||||
assert cache_path.exists() is False
|
||||
|
||||
|
||||
def test_intraday_cache_accepts_existing_positive_file_without_kind_metadata(tmp_path) -> None:
|
||||
cache = IntradayCache(str(tmp_path))
|
||||
cache_path = tmp_path / "AAPL" / "2026-01-05.parquet"
|
||||
cache_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
rows = 12
|
||||
table = pa.table({
|
||||
"timestamp": [f"2026-01-05T13:{30+i:02d}:00Z" for i in range(rows)],
|
||||
"open": [100.0] * rows,
|
||||
"high": [101.0] * rows,
|
||||
"low": [99.5] * rows,
|
||||
"close": [100.5] * rows,
|
||||
"volume": [1000.0] * rows,
|
||||
"vwap": [100.4] * rows,
|
||||
}, schema=pa.schema([
|
||||
pa.field("timestamp", pa.string()),
|
||||
pa.field("open", pa.float64()),
|
||||
pa.field("high", pa.float64()),
|
||||
pa.field("low", pa.float64()),
|
||||
pa.field("close", pa.float64()),
|
||||
pa.field("volume", pa.float64()),
|
||||
pa.field("vwap", pa.float64()),
|
||||
]).with_metadata({
|
||||
b"intraday_cache_version": b"3",
|
||||
b"intraday_cache_source": b"api_v1_alpaca_intraday",
|
||||
b"intraday_cache_interval": b"5min",
|
||||
}))
|
||||
pq.write_table(table, str(cache_path))
|
||||
|
||||
assert cache.has("AAPL", "2026-01-05") is True
|
||||
assert len(cache.get("AAPL", "2026-01-05") or []) == rows
|
||||
|
||||
|
||||
def test_intraday_cache_negative_entry_suppresses_rereads(tmp_path) -> None:
|
||||
cache = IntradayCache(str(tmp_path))
|
||||
|
||||
cache.put_negative("AAPL", "2026-01-05", reason="sparse")
|
||||
|
||||
assert cache.has("AAPL", "2026-01-05") is True
|
||||
assert cache.get("AAPL", "2026-01-05") == []
|
||||
|
||||
|
||||
def test_daily_cache_put_and_get_uses_requested_coverage_range(tmp_path) -> None:
|
||||
cache = DailyBarCache(str(tmp_path))
|
||||
bars = [
|
||||
{
|
||||
"date": "2026-01-02",
|
||||
"open": 100.0,
|
||||
"high": 101.0,
|
||||
"low": 99.0,
|
||||
"close": 100.5,
|
||||
"volume": 1000.0,
|
||||
},
|
||||
{
|
||||
"date": "2026-01-05",
|
||||
"open": 101.0,
|
||||
"high": 102.0,
|
||||
"low": 100.0,
|
||||
"close": 101.5,
|
||||
"volume": 1200.0,
|
||||
},
|
||||
]
|
||||
|
||||
cache.put("AAPL", "2026-01-01", "2026-01-10", bars)
|
||||
|
||||
assert cache.get("AAPL", "2026-01-01", "2026-01-10") == bars
|
||||
assert cache.get("AAPL", "2026-01-02", "2026-01-05") == bars
|
||||
assert cache.get("AAPL", "2025-12-31", "2026-01-10") is None
|
||||
|
||||
|
||||
def test_daily_cache_rejects_egregiously_partial_warmup_file(tmp_path) -> None:
|
||||
cache = DailyBarCache(str(tmp_path))
|
||||
cache_path = tmp_path / "BLD.parquet"
|
||||
rows = []
|
||||
for i in range(11):
|
||||
rows.append(
|
||||
{
|
||||
"date": f"2026-04-{6 + i:02d}",
|
||||
"open": 100.0 + i,
|
||||
"high": 101.0 + i,
|
||||
"low": 99.0 + i,
|
||||
"close": 100.5 + i,
|
||||
"volume": 1_000.0 + i,
|
||||
}
|
||||
)
|
||||
|
||||
schema = pa.schema([
|
||||
pa.field("date", pa.string()),
|
||||
pa.field("open", pa.float64()),
|
||||
pa.field("high", pa.float64()),
|
||||
pa.field("low", pa.float64()),
|
||||
pa.field("close", pa.float64()),
|
||||
pa.field("volume", pa.float64()),
|
||||
]).with_metadata({
|
||||
b"daily_cache_version": b"1",
|
||||
b"daily_cache_source": b"api_v1_price_data",
|
||||
b"daily_cache_interval": b"1d",
|
||||
b"daily_cache_coverage_start": b"2026-01-20",
|
||||
b"daily_cache_coverage_end": b"2026-04-20",
|
||||
})
|
||||
pq.write_table(pa.Table.from_pylist(rows, schema=schema), str(cache_path))
|
||||
|
||||
assert cache.get("BLD", "2026-01-20", "2026-04-20") is None
|
||||
assert cache_path.exists() is False
|
||||
@ -0,0 +1,140 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from libs.intraday.domain import DayResult, IntradayConfig, IntradayTrade, ORBStrategyParams
|
||||
from libs.intraday.metrics import IntradayMetricsAccumulator, compute_metrics, format_summary
|
||||
|
||||
|
||||
def test_no_trade_run_preserves_period_and_zero_return_metrics() -> None:
|
||||
config = IntradayConfig(
|
||||
strategy_mode="orb",
|
||||
orb_strategy=ORBStrategyParams(initial_capital=10_000.0),
|
||||
)
|
||||
day_results = [
|
||||
DayResult(date="2026-03-13", daily_pnl=0.0, daily_return_pct=0.0),
|
||||
DayResult(date="2026-03-16", daily_pnl=0.0, daily_return_pct=0.0),
|
||||
]
|
||||
|
||||
metrics = compute_metrics(day_results, config, run_id="test1234")
|
||||
summary = format_summary(metrics, config)
|
||||
|
||||
assert metrics.run_id == "test1234"
|
||||
assert metrics.start_date == "2026-03-13"
|
||||
assert metrics.end_date == "2026-03-16"
|
||||
assert metrics.trading_days == 2
|
||||
assert metrics.days_with_trades == 0
|
||||
assert metrics.total_trades == 0
|
||||
assert metrics.total_return_pct == 0.0
|
||||
assert metrics.annualized_return_pct == 0.0
|
||||
assert metrics.avg_daily_return_pct == 0.0
|
||||
assert metrics.max_drawdown_pct == 0.0
|
||||
assert metrics.final_equity == 10_000.0
|
||||
assert "2026-03-13" in summary
|
||||
assert "2026-03-16" in summary
|
||||
assert "2 days (0 with trades)" in summary
|
||||
|
||||
|
||||
def test_metrics_accumulator_matches_batch_metrics() -> None:
|
||||
config = IntradayConfig(
|
||||
strategy_mode="orb",
|
||||
orb_strategy=ORBStrategyParams(initial_capital=10_000.0),
|
||||
)
|
||||
trade = IntradayTrade(
|
||||
date="2026-03-13",
|
||||
ticker="AAA",
|
||||
entry_price=100.0,
|
||||
exit_price=101.0,
|
||||
entry_time="2026-03-13T09:35:00-05:00",
|
||||
exit_time="2026-03-13T15:55:00-05:00",
|
||||
shares=10,
|
||||
pnl=10.0,
|
||||
pnl_pct=0.01,
|
||||
exit_reason="close",
|
||||
)
|
||||
day_results = [
|
||||
DayResult(
|
||||
date="2026-03-13",
|
||||
trades=[trade],
|
||||
daily_pnl=10.0,
|
||||
daily_return_pct=0.001,
|
||||
capital_deployed=1_000.0,
|
||||
),
|
||||
DayResult(
|
||||
date="2026-03-16",
|
||||
daily_pnl=-5.0,
|
||||
daily_return_pct=-0.0005,
|
||||
capital_deployed=0.0,
|
||||
),
|
||||
]
|
||||
|
||||
batch = compute_metrics(day_results, config, run_id="batch")
|
||||
accumulator = IntradayMetricsAccumulator(config, run_id="stream")
|
||||
accumulator.extend(day_results)
|
||||
stream = accumulator.finalize()
|
||||
|
||||
assert stream.start_date == batch.start_date
|
||||
assert stream.end_date == batch.end_date
|
||||
assert stream.total_trades == batch.total_trades
|
||||
assert stream.days_with_trades == batch.days_with_trades
|
||||
assert stream.final_equity == batch.final_equity
|
||||
assert stream.total_return_pct == batch.total_return_pct
|
||||
|
||||
|
||||
def test_metrics_accumulator_snapshot_roundtrip() -> None:
|
||||
config = IntradayConfig(
|
||||
strategy_mode="orb",
|
||||
orb_strategy=ORBStrategyParams(initial_capital=10_000.0),
|
||||
)
|
||||
day_results = [
|
||||
DayResult(date="2026-03-13", daily_pnl=12.0, daily_return_pct=0.0012),
|
||||
DayResult(date="2026-03-16", daily_pnl=-4.0, daily_return_pct=-0.0004),
|
||||
]
|
||||
|
||||
accumulator = IntradayMetricsAccumulator(config, run_id="snap")
|
||||
accumulator.extend(day_results)
|
||||
restored = IntradayMetricsAccumulator.from_snapshot(
|
||||
config,
|
||||
accumulator.snapshot(),
|
||||
run_id="restored",
|
||||
)
|
||||
|
||||
assert restored.run_id == "restored"
|
||||
assert restored.n_days == accumulator.n_days
|
||||
assert restored.days_with_trades == accumulator.days_with_trades
|
||||
assert restored.daily_returns == accumulator.daily_returns
|
||||
assert restored.equity == accumulator.equity
|
||||
assert restored.max_drawdown == accumulator.max_drawdown
|
||||
assert restored.finalize().final_equity == accumulator.finalize().final_equity
|
||||
|
||||
|
||||
def test_metrics_compute_loss_containment_fields() -> None:
|
||||
config = IntradayConfig(
|
||||
strategy_mode="orb",
|
||||
orb_strategy=ORBStrategyParams(initial_capital=10_000.0),
|
||||
)
|
||||
base_trade = IntradayTrade(
|
||||
date="2026-03-13",
|
||||
ticker="AAA",
|
||||
entry_price=100.0,
|
||||
exit_price=101.0,
|
||||
entry_time="2026-03-13T09:35:00-05:00",
|
||||
exit_time="2026-03-13T15:55:00-05:00",
|
||||
shares=10,
|
||||
pnl=10.0,
|
||||
pnl_pct=0.01,
|
||||
exit_reason="close",
|
||||
)
|
||||
day_results = [
|
||||
DayResult(date="2026-03-13", trades=[base_trade], daily_pnl=100.0, daily_return_pct=0.01),
|
||||
DayResult(date="2026-03-16", trades=[base_trade.model_copy(update={"date": "2026-03-16", "pnl": -50.0, "pnl_pct": -0.005})], daily_pnl=-50.0, daily_return_pct=-0.005),
|
||||
DayResult(date="2026-03-17", trades=[base_trade.model_copy(update={"date": "2026-03-17", "pnl": -200.0, "pnl_pct": -0.02})], daily_pnl=-200.0, daily_return_pct=-0.02),
|
||||
DayResult(date="2026-03-18", trades=[base_trade.model_copy(update={"date": "2026-03-18", "pnl": 0.0, "pnl_pct": 0.0})], daily_pnl=0.0, daily_return_pct=0.0),
|
||||
]
|
||||
|
||||
metrics = compute_metrics(day_results, config, run_id="lossctl")
|
||||
|
||||
assert metrics.loss_day_rate == 0.5
|
||||
assert metrics.avg_loss_day_pct == -0.0125
|
||||
assert metrics.tail_loss_20_pct == -0.02
|
||||
assert metrics.worst_day_return_pct == -0.02
|
||||
assert metrics.loss_containment_score is not None
|
||||
assert 0.0 < metrics.loss_containment_score < 100.0
|
||||
@ -0,0 +1,478 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
from libs.backtest.domain import WalkForwardAggregate, WalkForwardGapStats, WalkForwardSummary
|
||||
from libs.intraday.domain import CacheParams, IntradayConfig, IntradayMetrics, StrategyParams
|
||||
|
||||
from apps.intraday_bt.momentum_research import (
|
||||
_normalize_momentum_research_strategy,
|
||||
_quarterly_score_payload,
|
||||
_wfv_score_payload,
|
||||
MomentumResearchContext,
|
||||
MomentumResearchSnapshotStore,
|
||||
build_momentum_strategy,
|
||||
build_momentum_research_context,
|
||||
group_trading_days_by_quarter,
|
||||
intraday_metrics_to_momentum_split_result,
|
||||
simulate_momentum_params,
|
||||
)
|
||||
|
||||
|
||||
def _summary(
|
||||
*,
|
||||
mean_return: float,
|
||||
positive_rate: float,
|
||||
worst_return: float,
|
||||
) -> WalkForwardSummary:
|
||||
return WalkForwardSummary(
|
||||
train_days=84,
|
||||
test_days=21,
|
||||
step_days=21,
|
||||
fold_count=5,
|
||||
folds=[],
|
||||
train_aggregate=WalkForwardAggregate(mean_return_pct=4.0),
|
||||
test_aggregate=WalkForwardAggregate(
|
||||
mean_return_pct=mean_return,
|
||||
median_return_pct=mean_return,
|
||||
worst_return_pct=worst_return,
|
||||
positive_fold_rate_pct=positive_rate,
|
||||
mean_profit_factor=1.5,
|
||||
mean_max_drawdown_pct=4.0,
|
||||
mean_trade_count=40.0,
|
||||
mean_win_rate=0.52,
|
||||
),
|
||||
gap_stats=WalkForwardGapStats(
|
||||
mean_train_test_return_gap_pct=2.0,
|
||||
worst_train_test_return_gap_pct=4.0,
|
||||
fold_return_cv=0.4,
|
||||
),
|
||||
engine_reliability_ratio=1.0,
|
||||
)
|
||||
|
||||
|
||||
def test_wfv_score_prefers_better_walk_forward_and_holdout() -> None:
|
||||
strong = _wfv_score_payload(
|
||||
_summary(mean_return=2.5, positive_rate=80.0, worst_return=-1.0),
|
||||
IntradayMetrics(total_return_pct=0.10, sharpe_ratio=1.4),
|
||||
)
|
||||
weak = _wfv_score_payload(
|
||||
_summary(mean_return=0.3, positive_rate=40.0, worst_return=-6.0),
|
||||
IntradayMetrics(total_return_pct=0.01, sharpe_ratio=0.2),
|
||||
)
|
||||
|
||||
assert strong["selection_score"] > weak["selection_score"]
|
||||
assert strong["holdout_return_pct"] > weak["holdout_return_pct"]
|
||||
|
||||
|
||||
def test_wfv_score_rewards_better_holdout_loss_containment() -> None:
|
||||
summary = _summary(mean_return=1.2, positive_rate=60.0, worst_return=-2.0)
|
||||
strong = _wfv_score_payload(
|
||||
summary,
|
||||
IntradayMetrics(
|
||||
total_return_pct=0.06,
|
||||
sharpe_ratio=0.9,
|
||||
avg_loss_day_pct=-0.004,
|
||||
tail_loss_20_pct=-0.009,
|
||||
loss_containment_score=89.0,
|
||||
),
|
||||
)
|
||||
weak = _wfv_score_payload(
|
||||
summary,
|
||||
IntradayMetrics(
|
||||
total_return_pct=0.06,
|
||||
sharpe_ratio=0.9,
|
||||
avg_loss_day_pct=-0.012,
|
||||
tail_loss_20_pct=-0.026,
|
||||
loss_containment_score=54.0,
|
||||
),
|
||||
)
|
||||
|
||||
assert strong["selection_score"] > weak["selection_score"]
|
||||
assert strong["holdout_loss_containment_score"] > weak["holdout_loss_containment_score"]
|
||||
|
||||
|
||||
def test_group_trading_days_by_quarter_preserves_calendar_order() -> None:
|
||||
grouped = group_trading_days_by_quarter(
|
||||
[
|
||||
"2025-01-02",
|
||||
"2025-03-31",
|
||||
"2025-04-01",
|
||||
"2025-07-01",
|
||||
"2025-10-01",
|
||||
]
|
||||
)
|
||||
assert grouped == [
|
||||
("2025Q1", ["2025-01-02", "2025-03-31"]),
|
||||
("2025Q2", ["2025-04-01"]),
|
||||
("2025Q3", ["2025-07-01"]),
|
||||
("2025Q4", ["2025-10-01"]),
|
||||
]
|
||||
|
||||
|
||||
def test_quarterly_score_prefers_stable_positive_quarters() -> None:
|
||||
wfv = _wfv_score_payload(
|
||||
_summary(mean_return=1.8, positive_rate=80.0, worst_return=-1.0),
|
||||
IntradayMetrics(total_return_pct=0.08, sharpe_ratio=1.1),
|
||||
)
|
||||
strong, _ = _quarterly_score_payload(
|
||||
wfv,
|
||||
[
|
||||
("2025Q1", IntradayMetrics(total_return_pct=0.08, sharpe_ratio=1.2, max_drawdown_pct=-0.03)),
|
||||
("2025Q2", IntradayMetrics(total_return_pct=0.06, sharpe_ratio=1.0, max_drawdown_pct=-0.02)),
|
||||
("2025Q3", IntradayMetrics(total_return_pct=0.07, sharpe_ratio=1.1, max_drawdown_pct=-0.03)),
|
||||
("2025Q4", IntradayMetrics(total_return_pct=0.05, sharpe_ratio=0.9, max_drawdown_pct=-0.02)),
|
||||
],
|
||||
)
|
||||
weak, _ = _quarterly_score_payload(
|
||||
wfv,
|
||||
[
|
||||
("2025Q1", IntradayMetrics(total_return_pct=0.16, sharpe_ratio=1.8, max_drawdown_pct=-0.08)),
|
||||
("2025Q2", IntradayMetrics(total_return_pct=-0.09, sharpe_ratio=-0.7, max_drawdown_pct=-0.10)),
|
||||
("2025Q3", IntradayMetrics(total_return_pct=0.02, sharpe_ratio=0.2, max_drawdown_pct=-0.05)),
|
||||
("2025Q4", IntradayMetrics(total_return_pct=-0.03, sharpe_ratio=-0.3, max_drawdown_pct=-0.06)),
|
||||
],
|
||||
)
|
||||
|
||||
assert strong["quarter_mean_return_pct"] > weak["quarter_mean_return_pct"]
|
||||
assert strong["quarter_worst_return_pct"] > weak["quarter_worst_return_pct"]
|
||||
assert strong["quarterly_selection_score"] > weak["quarterly_selection_score"]
|
||||
|
||||
|
||||
def test_build_momentum_strategy_applies_overrides_without_mutating_base() -> None:
|
||||
config = IntradayConfig(strategy_mode="momentum", strategy=StrategyParams(top_n=5, max_vix=30.0))
|
||||
updated = build_momentum_strategy(config, {"top_n": 4, "max_vix": 28.0})
|
||||
|
||||
assert updated.top_n == 4
|
||||
assert updated.max_vix == 28.0
|
||||
assert config.strategy.top_n == 5
|
||||
assert config.strategy.max_vix == 30.0
|
||||
|
||||
|
||||
def test_normalize_momentum_research_strategy_forces_reset_simple_mode() -> None:
|
||||
strategy = StrategyParams(
|
||||
top_n=5,
|
||||
compound_returns=True,
|
||||
daily_budget_reset=False,
|
||||
)
|
||||
|
||||
normalized = _normalize_momentum_research_strategy(strategy)
|
||||
|
||||
assert normalized.compound_returns is False
|
||||
assert normalized.daily_budget_reset is True
|
||||
assert strategy.compound_returns is True
|
||||
assert strategy.daily_budget_reset is False
|
||||
|
||||
|
||||
def test_intraday_metrics_to_momentum_split_result_maps_simple_returns() -> None:
|
||||
strategy = StrategyParams(top_n=5)
|
||||
result = intraday_metrics_to_momentum_split_result(
|
||||
IntradayMetrics(
|
||||
run_id="mwf",
|
||||
trading_days=20,
|
||||
days_with_trades=8,
|
||||
total_trades=12,
|
||||
total_return_pct=0.1234,
|
||||
annualized_return_pct=0.4567,
|
||||
max_drawdown_pct=-0.089,
|
||||
sharpe_ratio=1.8,
|
||||
profit_factor=1.4,
|
||||
win_rate=0.55,
|
||||
),
|
||||
strategy,
|
||||
)
|
||||
|
||||
assert result.run_id == "mwf"
|
||||
assert result.trade_count == 12
|
||||
assert result.total_return_pct == 12.34
|
||||
assert result.annualized_return_pct == 45.67
|
||||
assert result.max_drawdown_pct == 8.9
|
||||
assert result.avg_gross_exposure_pct == 100.0
|
||||
assert result.days_in_market_pct == 40.0
|
||||
|
||||
|
||||
def test_build_momentum_research_context_uses_snapshot_cache(tmp_path, monkeypatch) -> None:
|
||||
config = IntradayConfig(
|
||||
strategy_mode="momentum",
|
||||
strategy=StrategyParams(top_n=5),
|
||||
cache=CacheParams(enabled=True, dir=str(tmp_path / "intraday")),
|
||||
)
|
||||
|
||||
trading_days = ["2025-01-02", "2025-01-03"]
|
||||
daily_bars = {
|
||||
"AAA": [{"date": "2025-01-02", "close": 10.0}],
|
||||
"BBB": [{"date": "2025-01-02", "close": 11.0}],
|
||||
}
|
||||
candidates = {"2025-01-02": ["AAA"], "2025-01-03": ["BBB"]}
|
||||
all_intraday = {
|
||||
"2025-01-02": {"AAA": [{"timestamp": "2025-01-02T14:30:00+00:00", "open": 10.0, "high": 10.5, "low": 9.9, "close": 10.3, "volume": 1000}]},
|
||||
"2025-01-03": {"BBB": [{"timestamp": "2025-01-03T14:30:00+00:00", "open": 11.0, "high": 11.4, "low": 10.8, "close": 11.2, "volume": 1200}]},
|
||||
}
|
||||
|
||||
async def _resolve_universe(_universe, _client):
|
||||
return ["AAA", "BBB"]
|
||||
|
||||
async def _get_trading_days(_client, _start, _end, lookback=0):
|
||||
assert lookback == 0
|
||||
return trading_days
|
||||
|
||||
async def _fetch_daily_bars_bulk(*args, **kwargs):
|
||||
return daily_bars
|
||||
|
||||
def _pre_screen_candidates(*args, **kwargs):
|
||||
return candidates
|
||||
|
||||
async def _fetch_intraday_bulk(*args, **kwargs):
|
||||
return all_intraday
|
||||
|
||||
monkeypatch.setattr("apps.intraday_bt.momentum_research.resolve_universe", _resolve_universe)
|
||||
monkeypatch.setattr("apps.intraday_bt.momentum_research.get_trading_days", _get_trading_days)
|
||||
monkeypatch.setattr("apps.intraday_bt.momentum_research.fetch_daily_bars_bulk", _fetch_daily_bars_bulk)
|
||||
monkeypatch.setattr("apps.intraday_bt.momentum_research.momentum_pre_screen_candidates", _pre_screen_candidates)
|
||||
monkeypatch.setattr("apps.intraday_bt.momentum_research.fetch_intraday_bulk", _fetch_intraday_bulk)
|
||||
|
||||
first = asyncio.run(build_momentum_research_context(config, "2025-01-02", "2025-01-03", client=None))
|
||||
assert first.candidate_pairs == 2
|
||||
assert first.research_snapshot_key is not None
|
||||
|
||||
async def _should_not_fetch(*args, **kwargs):
|
||||
raise AssertionError("fetch path should not run after snapshot is saved")
|
||||
|
||||
def _should_not_screen(*args, **kwargs):
|
||||
raise AssertionError("screen path should not run after snapshot is saved")
|
||||
|
||||
monkeypatch.setattr("apps.intraday_bt.momentum_research.fetch_daily_bars_bulk", _should_not_fetch)
|
||||
monkeypatch.setattr("apps.intraday_bt.momentum_research.momentum_pre_screen_candidates", _should_not_screen)
|
||||
monkeypatch.setattr("apps.intraday_bt.momentum_research.fetch_intraday_bulk", _should_not_fetch)
|
||||
|
||||
second = asyncio.run(build_momentum_research_context(config, "2025-01-02", "2025-01-03", client=None))
|
||||
assert second.research_snapshot_key == first.research_snapshot_key
|
||||
assert second.candidates == candidates
|
||||
assert second.all_intraday == all_intraday
|
||||
|
||||
|
||||
def test_momentum_research_snapshot_key_changes_when_seed_overlay_changes(tmp_path) -> None:
|
||||
config_a = IntradayConfig(
|
||||
strategy_mode="momentum",
|
||||
strategy=StrategyParams(top_n=5, candidate_source_mode="intraday_first"),
|
||||
cache=CacheParams(enabled=True, dir=str(tmp_path / "intraday")),
|
||||
)
|
||||
config_b = IntradayConfig(
|
||||
strategy_mode="momentum",
|
||||
strategy=StrategyParams(
|
||||
top_n=5,
|
||||
candidate_source_mode="intraday_first",
|
||||
candidate_seed_liquid_overlay_slots=1,
|
||||
),
|
||||
cache=CacheParams(enabled=True, dir=str(tmp_path / "intraday")),
|
||||
)
|
||||
|
||||
key_a = MomentumResearchSnapshotStore(tmp_path / "snapshots").build_key(
|
||||
config_a,
|
||||
start_date="2025-01-02",
|
||||
end_date="2025-01-03",
|
||||
tickers=["AAA"],
|
||||
trading_days=["2025-01-02", "2025-01-03"],
|
||||
)
|
||||
key_b = MomentumResearchSnapshotStore(tmp_path / "snapshots").build_key(
|
||||
config_b,
|
||||
start_date="2025-01-02",
|
||||
end_date="2025-01-03",
|
||||
tickers=["AAA"],
|
||||
trading_days=["2025-01-02", "2025-01-03"],
|
||||
)
|
||||
|
||||
assert key_a != key_b
|
||||
|
||||
|
||||
def test_build_momentum_research_context_applies_seed_overlay_before_intraday_fetch(
|
||||
tmp_path,
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
config = IntradayConfig(
|
||||
strategy_mode="momentum",
|
||||
strategy=StrategyParams(
|
||||
top_n=5,
|
||||
candidate_source_mode="intraday_first",
|
||||
candidate_seed_liquid_overlay_slots=1,
|
||||
),
|
||||
cache=CacheParams(enabled=False, dir=str(tmp_path / "intraday")),
|
||||
)
|
||||
|
||||
async def _resolve_universe(_universe, _client):
|
||||
return ["AAA", "BBB"]
|
||||
|
||||
async def _get_trading_days(_client, _start, _end, lookback=0):
|
||||
assert lookback == 0
|
||||
return ["2025-01-02"]
|
||||
|
||||
async def _fetch_daily_bars_bulk(*args, **kwargs):
|
||||
return {"AAA": [{"date": "2025-01-02", "close": 10.0}], "BBB": [{"date": "2025-01-02", "close": 11.0}]}
|
||||
|
||||
def _enrichment(*args, **kwargs):
|
||||
return {}
|
||||
|
||||
def _seed_candidates(*args, **kwargs):
|
||||
return {"2025-01-02": ["AAA"]}
|
||||
|
||||
def _augment(candidates, *args, **kwargs):
|
||||
assert candidates == {"2025-01-02": ["AAA"]}
|
||||
return {"2025-01-02": ["AAA", "BBB"]}
|
||||
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
async def _fetch_intraday_bulk(candidates, *args, **kwargs):
|
||||
captured["candidates"] = candidates
|
||||
return {
|
||||
"2025-01-02": {
|
||||
"AAA": [{"timestamp": "2025-01-02T14:30:00+00:00", "open": 10.0, "high": 10.2, "low": 9.9, "close": 10.1, "volume": 1000}],
|
||||
"BBB": [{"timestamp": "2025-01-02T14:30:00+00:00", "open": 11.0, "high": 11.2, "low": 10.9, "close": 11.1, "volume": 1000}],
|
||||
}
|
||||
}
|
||||
|
||||
def _intraday_first_candidates(*args, **kwargs):
|
||||
return {"2025-01-02": ["AAA", "BBB"]}
|
||||
|
||||
monkeypatch.setattr("apps.intraday_bt.momentum_research.resolve_universe", _resolve_universe)
|
||||
monkeypatch.setattr("apps.intraday_bt.momentum_research.get_trading_days", _get_trading_days)
|
||||
monkeypatch.setattr("apps.intraday_bt.momentum_research.fetch_daily_bars_bulk", _fetch_daily_bars_bulk)
|
||||
monkeypatch.setattr("apps.intraday_bt.momentum_research._momentum_enrichment_for_days", _enrichment)
|
||||
monkeypatch.setattr("apps.intraday_bt.momentum_research._momentum_intraday_seed_candidates", _seed_candidates)
|
||||
monkeypatch.setattr(
|
||||
"apps.intraday_bt.momentum_research._augment_momentum_seed_candidates_with_liquid_overlay",
|
||||
_augment,
|
||||
)
|
||||
monkeypatch.setattr("apps.intraday_bt.momentum_research.fetch_intraday_bulk", _fetch_intraday_bulk)
|
||||
monkeypatch.setattr(
|
||||
"apps.intraday_bt.momentum_research.momentum_intraday_first_candidates",
|
||||
_intraday_first_candidates,
|
||||
)
|
||||
|
||||
context = asyncio.run(build_momentum_research_context(config, "2025-01-02", "2025-01-02", client=None))
|
||||
|
||||
assert captured["candidates"] == {"2025-01-02": ["AAA", "BBB"]}
|
||||
assert context.candidates == {"2025-01-02": ["AAA", "BBB"]}
|
||||
|
||||
|
||||
def test_simulate_momentum_params_recomputes_candidates_for_strategy() -> None:
|
||||
context = MomentumResearchContext(
|
||||
config=IntradayConfig(
|
||||
strategy_mode="momentum",
|
||||
strategy=StrategyParams(top_n=2),
|
||||
),
|
||||
tickers=["AAA", "BBB"],
|
||||
ticker_sectors={},
|
||||
trading_days=["2026-01-05"],
|
||||
daily_bars={
|
||||
"AAA": [
|
||||
{"date": "2026-01-02", "open": 10.0, "high": 10.2, "low": 9.8, "close": 10.0, "volume": 1000},
|
||||
{"date": "2026-01-05", "open": 10.3, "high": 10.8, "low": 10.2, "close": 10.6, "volume": 2000},
|
||||
],
|
||||
"BBB": [
|
||||
{"date": "2026-01-02", "open": 11.0, "high": 11.1, "low": 10.9, "close": 11.0, "volume": 1000},
|
||||
{"date": "2026-01-05", "open": 11.4, "high": 11.9, "low": 11.3, "close": 11.7, "volume": 2000},
|
||||
],
|
||||
},
|
||||
all_intraday={
|
||||
"2026-01-05": {
|
||||
"AAA": [
|
||||
{"timestamp": "2026-01-05T14:30:00+00:00", "open": 10.3, "high": 10.5, "low": 10.2, "close": 10.4, "volume": 50000},
|
||||
{"timestamp": "2026-01-05T14:35:00+00:00", "open": 10.4, "high": 10.6, "low": 10.3, "close": 10.5, "volume": 50000},
|
||||
{"timestamp": "2026-01-05T14:40:00+00:00", "open": 10.5, "high": 10.7, "low": 10.4, "close": 10.6, "volume": 50000},
|
||||
{"timestamp": "2026-01-05T14:45:00+00:00", "open": 10.6, "high": 10.8, "low": 10.5, "close": 10.7, "volume": 50000},
|
||||
{"timestamp": "2026-01-05T14:50:00+00:00", "open": 10.7, "high": 10.9, "low": 10.6, "close": 10.8, "volume": 50000},
|
||||
{"timestamp": "2026-01-05T20:55:00+00:00", "open": 10.9, "high": 11.0, "low": 10.8, "close": 10.95, "volume": 50000},
|
||||
],
|
||||
"BBB": [
|
||||
{"timestamp": "2026-01-05T14:30:00+00:00", "open": 11.4, "high": 11.5, "low": 11.3, "close": 11.45, "volume": 50000},
|
||||
{"timestamp": "2026-01-05T14:35:00+00:00", "open": 11.45, "high": 11.6, "low": 11.4, "close": 11.55, "volume": 50000},
|
||||
{"timestamp": "2026-01-05T14:40:00+00:00", "open": 11.55, "high": 11.8, "low": 11.5, "close": 11.75, "volume": 50000},
|
||||
{"timestamp": "2026-01-05T14:45:00+00:00", "open": 11.75, "high": 11.9, "low": 11.7, "close": 11.85, "volume": 50000},
|
||||
{"timestamp": "2026-01-05T14:50:00+00:00", "open": 11.85, "high": 12.0, "low": 11.8, "close": 11.95, "volume": 50000},
|
||||
{"timestamp": "2026-01-05T20:55:00+00:00", "open": 11.9, "high": 12.0, "low": 11.8, "close": 11.92, "volume": 50000},
|
||||
],
|
||||
}
|
||||
},
|
||||
daily_enrichment={
|
||||
"AAA": {"2026-01-05": {"gap_pct": 0.03, "ret_5d": 0.01, "entropy_20d": 0.8, "avg_dollar_vol_30d": 20_000_000.0, "atr_14": 1.0, "event_flag": False}},
|
||||
"BBB": {"2026-01-05": {"gap_pct": 0.03, "ret_5d": 0.02, "entropy_20d": 0.7, "avg_dollar_vol_30d": 25_000_000.0, "atr_14": 1.1, "event_flag": True, "event_score": 1.0}},
|
||||
},
|
||||
vix_by_day=None,
|
||||
candidates={"2026-01-05": ["AAA", "BBB"]},
|
||||
candidate_pairs=2,
|
||||
research_snapshot_key=None,
|
||||
)
|
||||
|
||||
strategy = StrategyParams(
|
||||
top_n=2,
|
||||
entry_minutes_after_open=20,
|
||||
min_morning_gain_pct=0.0,
|
||||
min_entry_volume=0,
|
||||
candidate_require_event_flag=True,
|
||||
exit_minutes_before_close=5,
|
||||
)
|
||||
|
||||
day_results, metrics = simulate_momentum_params(context, strategy, ["2026-01-05"], run_id="test")
|
||||
|
||||
assert metrics.total_trades == 1
|
||||
assert len(day_results) == 1
|
||||
assert day_results[0].trades[0].ticker == "BBB"
|
||||
|
||||
|
||||
def test_simulate_momentum_params_recomputes_intraday_first_candidates_for_strategy() -> None:
|
||||
context = MomentumResearchContext(
|
||||
config=IntradayConfig(
|
||||
strategy_mode="momentum",
|
||||
strategy=StrategyParams(top_n=2, candidate_source_mode="intraday_first"),
|
||||
),
|
||||
tickers=["AAA", "BBB"],
|
||||
ticker_sectors={},
|
||||
trading_days=["2026-01-05"],
|
||||
daily_bars={},
|
||||
all_intraday={
|
||||
"2026-01-05": {
|
||||
"AAA": [
|
||||
{"timestamp": "2026-01-05T14:30:00+00:00", "open": 10.0, "high": 10.1, "low": 9.9, "close": 10.0, "volume": 60_000},
|
||||
{"timestamp": "2026-01-05T14:35:00+00:00", "open": 10.0, "high": 10.2, "low": 9.9, "close": 10.1, "volume": 60_000},
|
||||
{"timestamp": "2026-01-05T14:40:00+00:00", "open": 10.1, "high": 10.5, "low": 10.0, "close": 10.3, "volume": 60_000},
|
||||
{"timestamp": "2026-01-05T14:45:00+00:00", "open": 10.3, "high": 10.4, "low": 10.1, "close": 10.2, "volume": 60_000},
|
||||
{"timestamp": "2026-01-05T14:50:00+00:00", "open": 10.2, "high": 10.3, "low": 10.1, "close": 10.2, "volume": 60_000},
|
||||
{"timestamp": "2026-01-05T20:55:00+00:00", "open": 10.2, "high": 10.3, "low": 10.0, "close": 10.1, "volume": 60_000},
|
||||
],
|
||||
"BBB": [
|
||||
{"timestamp": "2026-01-05T14:30:00+00:00", "open": 11.0, "high": 11.1, "low": 10.9, "close": 11.0, "volume": 70_000},
|
||||
{"timestamp": "2026-01-05T14:35:00+00:00", "open": 11.0, "high": 11.2, "low": 10.9, "close": 11.1, "volume": 70_000},
|
||||
{"timestamp": "2026-01-05T14:40:00+00:00", "open": 11.1, "high": 11.6, "low": 11.0, "close": 11.4, "volume": 70_000},
|
||||
{"timestamp": "2026-01-05T14:45:00+00:00", "open": 11.4, "high": 11.8, "low": 11.3, "close": 11.7, "volume": 70_000},
|
||||
{"timestamp": "2026-01-05T14:50:00+00:00", "open": 11.7, "high": 11.9, "low": 11.6, "close": 11.8, "volume": 70_000},
|
||||
{"timestamp": "2026-01-05T20:55:00+00:00", "open": 11.8, "high": 11.9, "low": 11.7, "close": 11.85, "volume": 70_000},
|
||||
],
|
||||
}
|
||||
},
|
||||
daily_enrichment={
|
||||
"AAA": {"2026-01-05": {"gap_pct": 0.01, "avg_daily_vol_14d": 1_000_000.0}},
|
||||
"BBB": {"2026-01-05": {"gap_pct": 0.01, "avg_daily_vol_14d": 1_000_000.0}},
|
||||
},
|
||||
vix_by_day=None,
|
||||
candidates={"2026-01-05": ["AAA", "BBB"]},
|
||||
candidate_pairs=2,
|
||||
research_snapshot_key=None,
|
||||
)
|
||||
|
||||
strategy = StrategyParams(
|
||||
top_n=2,
|
||||
candidate_source_mode="intraday_first",
|
||||
candidate_final_max_per_day=1,
|
||||
entry_minutes_after_open=10,
|
||||
confirmation_minutes_after_entry=5,
|
||||
min_confirmation_return_pct=0.0,
|
||||
min_morning_gain_pct=0.01,
|
||||
min_entry_volume=0,
|
||||
exit_minutes_before_close=5,
|
||||
)
|
||||
|
||||
day_results, metrics = simulate_momentum_params(context, strategy, ["2026-01-05"], run_id="test_if")
|
||||
|
||||
assert metrics.total_trades == 1
|
||||
assert len(day_results) == 1
|
||||
assert day_results[0].trades[0].ticker == "BBB"
|
||||
@ -0,0 +1,208 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from apps.intraday_bt.lab import (
|
||||
_deserialize_finalist_eval_row,
|
||||
_engine_specs,
|
||||
_pre_robustness_rank_key,
|
||||
_promotion_status,
|
||||
_read_json,
|
||||
_sample_representative_days,
|
||||
_select_champions,
|
||||
_serialize_finalist_eval_row,
|
||||
)
|
||||
from apps.intraday_bt.overfit_check import (
|
||||
summarize_is_oos_from_results,
|
||||
summarize_walk_forward_test_from_summary,
|
||||
)
|
||||
from libs.backtest.domain import (
|
||||
SplitResult,
|
||||
WalkForwardAggregate,
|
||||
WalkForwardFoldResult,
|
||||
WalkForwardGapStats,
|
||||
WalkForwardSummary,
|
||||
)
|
||||
from libs.intraday.domain import IntradayMetrics, ORBStrategyParams
|
||||
|
||||
|
||||
def test_sample_representative_days_preserves_order_and_endpoints() -> None:
|
||||
days = [f"2026-01-{day:02d}" for day in range(2, 22)]
|
||||
|
||||
sampled = _sample_representative_days(days, 5)
|
||||
|
||||
assert sampled[0] == days[0]
|
||||
assert sampled[-1] == days[-1]
|
||||
assert sampled == sorted(sampled)
|
||||
assert len(sampled) == 5
|
||||
|
||||
|
||||
def test_pre_robustness_rank_key_prefers_test_then_valid_then_activity() -> None:
|
||||
weak_test = {
|
||||
"train_sharpe": 3.0,
|
||||
"valid_sharpe": 2.0,
|
||||
"test_sharpe": 0.5,
|
||||
"test_trade_count": 200,
|
||||
}
|
||||
strong_test = {
|
||||
"train_sharpe": 1.0,
|
||||
"valid_sharpe": 0.8,
|
||||
"test_sharpe": 0.9,
|
||||
"test_trade_count": 50,
|
||||
}
|
||||
|
||||
assert _pre_robustness_rank_key(strong_test) > _pre_robustness_rank_key(weak_test)
|
||||
|
||||
|
||||
def test_engine_specs_quick_are_curated_and_small() -> None:
|
||||
specs = _engine_specs(True)
|
||||
|
||||
assert [spec.family for spec in specs] == [
|
||||
"classic_breakout",
|
||||
"quality_breakout",
|
||||
"gainers_leader",
|
||||
"compression_breakout",
|
||||
]
|
||||
assert all(spec.thesis for spec in specs)
|
||||
assert [len(spec.hypotheses) for spec in specs] == [4, 4, 4, 4]
|
||||
quality = next(spec for spec in specs if spec.family == "quality_breakout")
|
||||
assert all(h["entry_direction"] == "long_only" for h in quality.hypotheses)
|
||||
assert all(h["min_candidate_breadth"] is not None for h in quality.hypotheses)
|
||||
assert all(h["market_regime_spy_threshold"] is not None for h in quality.hypotheses)
|
||||
gainers = next(spec for spec in specs if spec.family == "gainers_leader")
|
||||
assert all(h["entry_direction"] == "long_only" for h in gainers.hypotheses)
|
||||
assert all(h["max_gap_pct"] is None for h in gainers.hypotheses)
|
||||
assert all(h["min_candidates_to_trade"] == 1 for h in gainers.hypotheses)
|
||||
compression = next(spec for spec in specs if spec.family == "compression_breakout")
|
||||
assert all(h["min_candidate_breadth"] is not None for h in compression.hypotheses)
|
||||
assert all(h["market_regime_spy_threshold"] is not None for h in compression.hypotheses)
|
||||
|
||||
|
||||
def test_finalist_eval_row_round_trips() -> None:
|
||||
row = {
|
||||
"candidate_id": "abc",
|
||||
"engine_family": "quality_breakout",
|
||||
"live_readiness": "live_ready",
|
||||
"promotion_status": "eligible",
|
||||
"overrides": {"orb_minutes": 5},
|
||||
"params": ORBStrategyParams(orb_minutes=5),
|
||||
"train_metrics_obj": IntradayMetrics(run_id="tr", trading_days=10, total_trades=5),
|
||||
"valid_metrics_obj": IntradayMetrics(run_id="va", trading_days=10, total_trades=4),
|
||||
"test_metrics_obj": IntradayMetrics(run_id="te", trading_days=10, total_trades=6),
|
||||
"train_result": None,
|
||||
"valid_result": None,
|
||||
"test_result": None,
|
||||
}
|
||||
payload = _serialize_finalist_eval_row(row)
|
||||
restored = _deserialize_finalist_eval_row(payload)
|
||||
assert restored["candidate_id"] == "abc"
|
||||
assert restored["params"].orb_minutes == 5
|
||||
assert restored["test_metrics_obj"].run_id == "te"
|
||||
|
||||
|
||||
def test_read_json_returns_default_for_missing_file(tmp_path: Path) -> None:
|
||||
missing = tmp_path / "missing.json"
|
||||
assert _read_json(missing, default={"ok": True}) == {"ok": True}
|
||||
|
||||
|
||||
def test_summarize_walk_forward_test_from_summary_reuses_existing_folds() -> None:
|
||||
wf_summary = WalkForwardSummary(
|
||||
train_days=84,
|
||||
test_days=21,
|
||||
step_days=21,
|
||||
fold_count=3,
|
||||
folds=[
|
||||
WalkForwardFoldResult(
|
||||
fold_index=1,
|
||||
train_start="2025-01-02",
|
||||
train_end="2025-03-31",
|
||||
test_start="2025-04-01",
|
||||
test_end="2025-04-30",
|
||||
train_run_id="tr1",
|
||||
test_run_id="te1",
|
||||
train_metrics=SplitResult(run_id="tr1", trade_count=10, sharpe_ratio=1.0),
|
||||
test_metrics=SplitResult(run_id="te1", trade_count=10, sharpe_ratio=0.9),
|
||||
),
|
||||
WalkForwardFoldResult(
|
||||
fold_index=2,
|
||||
train_start="2025-02-01",
|
||||
train_end="2025-04-30",
|
||||
test_start="2025-05-01",
|
||||
test_end="2025-05-31",
|
||||
train_run_id="tr2",
|
||||
test_run_id="te2",
|
||||
train_metrics=SplitResult(run_id="tr2", trade_count=10, sharpe_ratio=1.0),
|
||||
test_metrics=SplitResult(run_id="te2", trade_count=10, sharpe_ratio=0.7),
|
||||
),
|
||||
WalkForwardFoldResult(
|
||||
fold_index=3,
|
||||
train_start="2025-03-01",
|
||||
train_end="2025-05-31",
|
||||
test_start="2025-06-01",
|
||||
test_end="2025-06-30",
|
||||
train_run_id="tr3",
|
||||
test_run_id="te3",
|
||||
train_metrics=SplitResult(run_id="tr3", trade_count=10, sharpe_ratio=1.0),
|
||||
test_metrics=SplitResult(run_id="te3", trade_count=10, sharpe_ratio=0.8),
|
||||
),
|
||||
],
|
||||
train_aggregate=WalkForwardAggregate(),
|
||||
test_aggregate=WalkForwardAggregate(),
|
||||
gap_stats=WalkForwardGapStats(),
|
||||
)
|
||||
|
||||
result = summarize_walk_forward_test_from_summary(wf_summary)
|
||||
|
||||
assert result["source"] == "walk_forward_summary"
|
||||
assert result["n_windows"] == 3
|
||||
assert result["window_sharpes"] == [0.9, 0.7, 0.8]
|
||||
assert result["verdict"] == "PASS"
|
||||
|
||||
|
||||
def test_summarize_is_oos_from_results_reuses_existing_splits() -> None:
|
||||
result = summarize_is_oos_from_results(
|
||||
SplitResult(run_id="is", trade_count=100, sharpe_ratio=1.0),
|
||||
SplitResult(run_id="oos", trade_count=80, sharpe_ratio=0.7),
|
||||
is_period="2024-01-02 → 2025-12-31",
|
||||
oos_period="2026-01-02 → 2026-03-31",
|
||||
)
|
||||
|
||||
assert result["source"] == "split_results"
|
||||
assert result["verdict"] == "PASS"
|
||||
assert result["retention_pct"] == 70.0
|
||||
|
||||
|
||||
def test_promotion_status_blocks_negative_oos_even_with_activity() -> None:
|
||||
valid = SplitResult(run_id="valid", trade_count=120, total_return_pct=-1.0)
|
||||
test = SplitResult(run_id="test", trade_count=120, total_return_pct=2.0)
|
||||
|
||||
assert _promotion_status(valid, test) == "blocked_negative_oos"
|
||||
|
||||
|
||||
def test_select_champions_uses_only_eligible_rows() -> None:
|
||||
ranking = [
|
||||
{
|
||||
"candidate_id": "blocked",
|
||||
"promotion_status": "blocked_negative_oos",
|
||||
"live_readiness": "live_ready",
|
||||
"orbqs_score": 30.0,
|
||||
},
|
||||
{
|
||||
"candidate_id": "eligible_live",
|
||||
"promotion_status": "eligible",
|
||||
"live_readiness": "live_ready",
|
||||
"orbqs_score": 20.0,
|
||||
},
|
||||
{
|
||||
"candidate_id": "eligible_research",
|
||||
"promotion_status": "eligible",
|
||||
"live_readiness": "research_only",
|
||||
"orbqs_score": 10.0,
|
||||
},
|
||||
]
|
||||
|
||||
top_candidate, overall, live_ready = _select_champions(ranking)
|
||||
|
||||
assert top_candidate["candidate_id"] == "blocked"
|
||||
assert overall["candidate_id"] == "eligible_live"
|
||||
assert live_ready["candidate_id"] == "eligible_live"
|
||||
@ -0,0 +1,924 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from libs.intraday.domain import ORBStrategyParams
|
||||
from libs.intraday.orb_simulator import (
|
||||
compute_orb_candidates,
|
||||
run_orb_simulation,
|
||||
run_orb_simulation_with_state,
|
||||
simulate_orb_day,
|
||||
simulate_orb_trade,
|
||||
)
|
||||
|
||||
|
||||
def _enrichment_for(*tickers: str) -> dict[str, dict[str, dict]]:
|
||||
return {
|
||||
ticker: {
|
||||
"2026-01-05": {
|
||||
"atr_14": 1.0,
|
||||
"avg_dollar_vol_30d": 1_000_000_000.0,
|
||||
"avg_daily_vol_14d": 10_000.0,
|
||||
"prev_close": 99.0,
|
||||
"today_open": 100.0,
|
||||
"entropy_20d": 0.5,
|
||||
"atr_ratio_10_60": 0.8,
|
||||
"range_compression_10_60": 0.7,
|
||||
"gap_zscore_20d": 0.2,
|
||||
}
|
||||
}
|
||||
for ticker in tickers
|
||||
}
|
||||
|
||||
|
||||
def test_aggregated_bar_entry_fills_on_next_raw_bar_after_signal() -> None:
|
||||
bars = [
|
||||
{"timestamp": "2026-01-05T09:30:00-05:00", "open": 100.0, "high": 101.0, "low": 99.5, "close": 100.8, "volume": 1000},
|
||||
{"timestamp": "2026-01-05T09:35:00-05:00", "open": 103.0, "high": 103.2, "low": 102.8, "close": 103.0, "volume": 1000},
|
||||
{"timestamp": "2026-01-05T09:40:00-05:00", "open": 103.0, "high": 103.0, "low": 102.9, "close": 102.95, "volume": 1000},
|
||||
{"timestamp": "2026-01-05T09:45:00-05:00", "open": 102.95, "high": 103.1, "low": 102.9, "close": 103.0, "volume": 1000},
|
||||
{"timestamp": "2026-01-05T09:50:00-05:00", "open": 103.0, "high": 103.1, "low": 102.8, "close": 102.9, "volume": 1000},
|
||||
{"timestamp": "2026-01-05T09:55:00-05:00", "open": 102.9, "high": 103.0, "low": 102.7, "close": 102.8, "volume": 1000},
|
||||
{"timestamp": "2026-01-05T10:00:00-05:00", "open": 102.8, "high": 103.3, "low": 102.7, "close": 103.1, "volume": 1000},
|
||||
{"timestamp": "2026-01-05T10:05:00-05:00", "open": 104.5, "high": 104.8, "low": 104.4, "close": 104.7, "volume": 1000},
|
||||
{"timestamp": "2026-01-05T10:10:00-05:00", "open": 104.7, "high": 104.9, "low": 104.6, "close": 104.8, "volume": 1000},
|
||||
]
|
||||
params = ORBStrategyParams(
|
||||
sim_bar_minutes=30,
|
||||
orb_minutes=5,
|
||||
order_timeout_minutes=45,
|
||||
atr_stop_multiplier=10.0,
|
||||
trailing_at_r=99.0,
|
||||
breakeven_at_r=99.0,
|
||||
slippage_bps=0.0,
|
||||
)
|
||||
|
||||
trade = simulate_orb_trade(
|
||||
bars,
|
||||
bars[0],
|
||||
"long",
|
||||
atr=1.0,
|
||||
rvol=2.0,
|
||||
gap_pct=0.01,
|
||||
params=params,
|
||||
equity=10_000.0,
|
||||
date_str="2026-01-05",
|
||||
ticker="TEST",
|
||||
)
|
||||
|
||||
assert trade is not None
|
||||
assert trade.entry_time == "2026-01-05T10:05:00-05:00"
|
||||
assert trade.entry_price == 104.5
|
||||
|
||||
|
||||
def test_daily_loss_limit_counts_only_losses_realized_before_next_breakout() -> None:
|
||||
params = ORBStrategyParams(
|
||||
orb_minutes=5,
|
||||
sim_bar_minutes=5,
|
||||
order_timeout_minutes=45,
|
||||
atr_stop_multiplier=1.0,
|
||||
trailing_at_r=99.0,
|
||||
breakeven_at_r=99.0,
|
||||
slippage_bps=0.0,
|
||||
min_price=10.0,
|
||||
min_avg_dollar_volume=0.0,
|
||||
min_atr_14=0.1,
|
||||
min_rvol=0.1,
|
||||
max_candidates=20,
|
||||
min_candidates_to_trade=1,
|
||||
risk_per_trade_pct=1.0,
|
||||
max_position_pct=1.0,
|
||||
daily_max_loss_pct=0.005,
|
||||
max_stops_per_day=99,
|
||||
)
|
||||
|
||||
early_close_loser = [
|
||||
{"timestamp": "2026-01-05T09:30:00-05:00", "open": 100.0, "high": 101.0, "low": 99.5, "close": 100.8, "volume": 1000},
|
||||
{"timestamp": "2026-01-05T09:35:00-05:00", "open": 100.8, "high": 101.2, "low": 100.5, "close": 101.1, "volume": 1000},
|
||||
{"timestamp": "2026-01-05T09:40:00-05:00", "open": 100.7, "high": 100.8, "low": 100.5, "close": 100.6, "volume": 1000},
|
||||
{"timestamp": "2026-01-05T09:45:00-05:00", "open": 100.6, "high": 100.7, "low": 100.4, "close": 100.6, "volume": 1000},
|
||||
{"timestamp": "2026-01-05T10:00:00-05:00", "open": 100.6, "high": 100.7, "low": 100.4, "close": 100.5, "volume": 1000},
|
||||
{"timestamp": "2026-01-05T15:55:00-05:00", "open": 100.5, "high": 100.6, "low": 100.4, "close": 100.4, "volume": 1000},
|
||||
]
|
||||
late_winner = [
|
||||
{"timestamp": "2026-01-05T09:30:00-05:00", "open": 100.0, "high": 101.0, "low": 99.5, "close": 100.8, "volume": 1000},
|
||||
{"timestamp": "2026-01-05T09:35:00-05:00", "open": 100.8, "high": 100.9, "low": 100.5, "close": 100.7, "volume": 1000},
|
||||
{"timestamp": "2026-01-05T09:40:00-05:00", "open": 100.7, "high": 100.8, "low": 100.5, "close": 100.6, "volume": 1000},
|
||||
{"timestamp": "2026-01-05T09:45:00-05:00", "open": 100.6, "high": 100.7, "low": 100.4, "close": 100.6, "volume": 1000},
|
||||
{"timestamp": "2026-01-05T10:00:00-05:00", "open": 100.6, "high": 101.3, "low": 100.5, "close": 101.2, "volume": 1000},
|
||||
{"timestamp": "2026-01-05T15:55:00-05:00", "open": 101.2, "high": 102.0, "low": 101.0, "close": 101.8, "volume": 1000},
|
||||
]
|
||||
|
||||
day_result = simulate_orb_day(
|
||||
{
|
||||
"EARLY_CLOSE_LOSER": early_close_loser,
|
||||
"LATE_WINNER": late_winner,
|
||||
},
|
||||
"2026-01-05",
|
||||
params,
|
||||
_enrichment_for("EARLY_CLOSE_LOSER", "LATE_WINNER"),
|
||||
equity=10_000.0,
|
||||
)
|
||||
|
||||
assert [trade.ticker for trade in day_result.trades] == ["EARLY_CLOSE_LOSER", "LATE_WINNER"]
|
||||
assert day_result.trades[0].exit_time == "2026-01-05T15:55:00-05:00"
|
||||
assert day_result.trades[1].entry_time == "2026-01-05T10:00:00-05:00"
|
||||
|
||||
|
||||
def test_chunked_orb_simulation_matches_single_run_statefully() -> None:
|
||||
params = ORBStrategyParams(
|
||||
orb_minutes=5,
|
||||
sim_bar_minutes=5,
|
||||
order_timeout_minutes=20,
|
||||
atr_stop_multiplier=1.0,
|
||||
trailing_at_r=99.0,
|
||||
breakeven_at_r=99.0,
|
||||
slippage_bps=0.0,
|
||||
min_price=10.0,
|
||||
min_avg_dollar_volume=0.0,
|
||||
min_atr_14=0.1,
|
||||
min_rvol=0.1,
|
||||
max_candidates=20,
|
||||
min_candidates_to_trade=1,
|
||||
risk_per_trade_pct=0.01,
|
||||
max_position_pct=1.0,
|
||||
daily_max_loss_pct=1.0,
|
||||
max_stops_per_day=99,
|
||||
ticker_cooldown_days=1,
|
||||
settlement_days=1,
|
||||
compound_returns=False,
|
||||
)
|
||||
|
||||
def bars(day: str) -> list[dict]:
|
||||
return [
|
||||
{"timestamp": f"{day}T09:30:00-05:00", "open": 100.0, "high": 101.0, "low": 99.5, "close": 100.8, "volume": 1000},
|
||||
{"timestamp": f"{day}T09:35:00-05:00", "open": 100.8, "high": 101.4, "low": 100.7, "close": 101.2, "volume": 1000},
|
||||
{"timestamp": f"{day}T09:40:00-05:00", "open": 101.2, "high": 101.6, "low": 101.1, "close": 101.5, "volume": 1000},
|
||||
{"timestamp": f"{day}T15:55:00-05:00", "open": 101.5, "high": 102.0, "low": 101.4, "close": 101.9, "volume": 1000},
|
||||
]
|
||||
|
||||
trading_days = ["2026-01-05", "2026-01-06", "2026-01-07"]
|
||||
all_intraday = {
|
||||
day: {"AAA": bars(day)}
|
||||
for day in trading_days
|
||||
}
|
||||
enrichment = {
|
||||
"AAA": {
|
||||
day: {
|
||||
"atr_14": 1.0,
|
||||
"avg_dollar_vol_30d": 1_000_000_000.0,
|
||||
"avg_daily_vol_14d": 10_000.0,
|
||||
"prev_close": 99.0,
|
||||
"today_open": 100.0,
|
||||
}
|
||||
for day in trading_days
|
||||
}
|
||||
}
|
||||
|
||||
single = run_orb_simulation(all_intraday, trading_days, params, enrichment)
|
||||
chunk1, state = run_orb_simulation_with_state(
|
||||
{day: all_intraday[day] for day in trading_days[:2]},
|
||||
trading_days[:2],
|
||||
params,
|
||||
enrichment,
|
||||
)
|
||||
chunk2, _ = run_orb_simulation_with_state(
|
||||
{trading_days[2]: all_intraday[trading_days[2]]},
|
||||
trading_days[2:],
|
||||
params,
|
||||
enrichment,
|
||||
state=state,
|
||||
)
|
||||
combined = chunk1 + chunk2
|
||||
|
||||
assert [len(day.trades) for day in combined] == [len(day.trades) for day in single]
|
||||
assert [round(day.daily_pnl, 6) for day in combined] == [round(day.daily_pnl, 6) for day in single]
|
||||
assert [trade.ticker for day in combined for trade in day.trades] == [
|
||||
trade.ticker for day in single for trade in day.trades
|
||||
]
|
||||
|
||||
|
||||
def test_quality_breakout_min_body_ratio_filters_weak_candle() -> None:
|
||||
params = ORBStrategyParams(
|
||||
engine_family="quality_breakout",
|
||||
min_body_ratio=0.3,
|
||||
min_price=10.0,
|
||||
min_avg_dollar_volume=0.0,
|
||||
min_atr_14=0.1,
|
||||
min_rvol=0.1,
|
||||
max_candidates=10,
|
||||
min_candidates_to_trade=1,
|
||||
)
|
||||
bars_by_ticker = {
|
||||
"STRONG": [
|
||||
{"timestamp": "2026-01-05T09:30:00-05:00", "open": 100.0, "high": 102.0, "low": 99.8, "close": 101.8, "volume": 2000},
|
||||
{"timestamp": "2026-01-05T09:35:00-05:00", "open": 101.8, "high": 102.2, "low": 101.7, "close": 102.0, "volume": 2000},
|
||||
{"timestamp": "2026-01-05T09:40:00-05:00", "open": 102.0, "high": 102.1, "low": 101.9, "close": 102.0, "volume": 2000},
|
||||
{"timestamp": "2026-01-05T09:45:00-05:00", "open": 102.0, "high": 102.1, "low": 101.9, "close": 102.0, "volume": 2000},
|
||||
{"timestamp": "2026-01-05T09:50:00-05:00", "open": 102.0, "high": 102.1, "low": 101.9, "close": 102.0, "volume": 2000},
|
||||
],
|
||||
"WEAK": [
|
||||
{"timestamp": "2026-01-05T09:30:00-05:00", "open": 100.0, "high": 102.0, "low": 99.8, "close": 100.2, "volume": 2000},
|
||||
{"timestamp": "2026-01-05T09:35:00-05:00", "open": 100.2, "high": 100.5, "low": 100.1, "close": 100.4, "volume": 2000},
|
||||
{"timestamp": "2026-01-05T09:40:00-05:00", "open": 100.4, "high": 100.5, "low": 100.3, "close": 100.4, "volume": 2000},
|
||||
{"timestamp": "2026-01-05T09:45:00-05:00", "open": 100.4, "high": 100.5, "low": 100.3, "close": 100.4, "volume": 2000},
|
||||
{"timestamp": "2026-01-05T09:50:00-05:00", "open": 100.4, "high": 100.5, "low": 100.3, "close": 100.4, "volume": 2000},
|
||||
],
|
||||
}
|
||||
candidates = compute_orb_candidates(
|
||||
bars_by_ticker,
|
||||
"2026-01-05",
|
||||
params,
|
||||
{
|
||||
**_enrichment_for("STRONG", "WEAK"),
|
||||
},
|
||||
)
|
||||
|
||||
assert [cand["ticker"] for cand in candidates] == ["STRONG"]
|
||||
|
||||
|
||||
def test_compression_breakout_uses_entropy_weight_in_ranking() -> None:
|
||||
params = ORBStrategyParams(
|
||||
engine_family="compression_breakout",
|
||||
weight_rvol=0.0,
|
||||
weight_gap=0.0,
|
||||
weight_dollar_vol=0.0,
|
||||
weight_entropy=-0.15,
|
||||
weight_atr_ratio=0.0,
|
||||
weight_gap_zscore=0.0,
|
||||
min_price=10.0,
|
||||
min_avg_dollar_volume=0.0,
|
||||
min_atr_14=0.1,
|
||||
min_rvol=0.1,
|
||||
max_candidates=10,
|
||||
min_candidates_to_trade=1,
|
||||
)
|
||||
bars = [
|
||||
{"timestamp": "2026-01-05T09:30:00-05:00", "open": 100.0, "high": 102.0, "low": 99.8, "close": 101.8, "volume": 2000},
|
||||
{"timestamp": "2026-01-05T09:35:00-05:00", "open": 101.8, "high": 102.2, "low": 101.7, "close": 102.0, "volume": 2000},
|
||||
{"timestamp": "2026-01-05T09:40:00-05:00", "open": 102.0, "high": 102.1, "low": 101.9, "close": 102.0, "volume": 2000},
|
||||
{"timestamp": "2026-01-05T09:45:00-05:00", "open": 102.0, "high": 102.1, "low": 101.9, "close": 102.0, "volume": 2000},
|
||||
{"timestamp": "2026-01-05T09:50:00-05:00", "open": 102.0, "high": 102.1, "low": 101.9, "close": 102.0, "volume": 2000},
|
||||
]
|
||||
enrichment = _enrichment_for("LOW_ENT", "HIGH_ENT")
|
||||
enrichment["LOW_ENT"]["2026-01-05"]["entropy_20d"] = 0.1
|
||||
enrichment["HIGH_ENT"]["2026-01-05"]["entropy_20d"] = 0.9
|
||||
candidates = compute_orb_candidates(
|
||||
{"LOW_ENT": bars, "HIGH_ENT": bars},
|
||||
"2026-01-05",
|
||||
params,
|
||||
enrichment,
|
||||
)
|
||||
|
||||
assert [cand["ticker"] for cand in candidates] == ["LOW_ENT", "HIGH_ENT"]
|
||||
|
||||
|
||||
def test_candidates_can_rank_on_premarket_dollar_volume() -> None:
|
||||
params = ORBStrategyParams(
|
||||
engine_family="compression_breakout",
|
||||
weight_rvol=0.0,
|
||||
weight_gap=0.0,
|
||||
weight_dollar_vol=0.0,
|
||||
weight_premarket_dollar_vol=1.0,
|
||||
weight_entropy=0.0,
|
||||
weight_atr_ratio=0.0,
|
||||
weight_gap_zscore=0.0,
|
||||
min_price=10.0,
|
||||
min_avg_dollar_volume=0.0,
|
||||
min_atr_14=0.1,
|
||||
min_rvol=0.1,
|
||||
max_candidates=10,
|
||||
min_candidates_to_trade=1,
|
||||
)
|
||||
market_bars = [
|
||||
{"timestamp": "2026-01-05T09:30:00-05:00", "open": 100.0, "high": 102.0, "low": 99.8, "close": 101.8, "volume": 2000},
|
||||
{"timestamp": "2026-01-05T09:35:00-05:00", "open": 101.8, "high": 102.2, "low": 101.7, "close": 102.0, "volume": 2000},
|
||||
{"timestamp": "2026-01-05T09:40:00-05:00", "open": 102.0, "high": 102.1, "low": 101.9, "close": 102.0, "volume": 2000},
|
||||
{"timestamp": "2026-01-05T09:45:00-05:00", "open": 102.0, "high": 102.1, "low": 101.9, "close": 102.0, "volume": 2000},
|
||||
{"timestamp": "2026-01-05T09:50:00-05:00", "open": 102.0, "high": 102.1, "low": 101.9, "close": 102.0, "volume": 2000},
|
||||
]
|
||||
bars_by_ticker = {
|
||||
"HIGH_PM": [
|
||||
{"timestamp": "2026-01-05T08:00:00-05:00", "open": 100.0, "high": 100.2, "low": 99.9, "close": 100.0, "volume": 10_000},
|
||||
*market_bars,
|
||||
],
|
||||
"LOW_PM": [
|
||||
{"timestamp": "2026-01-05T08:00:00-05:00", "open": 100.0, "high": 100.2, "low": 99.9, "close": 100.0, "volume": 1_000},
|
||||
*market_bars,
|
||||
],
|
||||
}
|
||||
|
||||
candidates = compute_orb_candidates(
|
||||
bars_by_ticker,
|
||||
"2026-01-05",
|
||||
params,
|
||||
_enrichment_for("HIGH_PM", "LOW_PM"),
|
||||
)
|
||||
|
||||
assert [cand["ticker"] for cand in candidates] == ["HIGH_PM", "LOW_PM"]
|
||||
|
||||
|
||||
def test_stocks_in_play_can_gate_on_attention_and_sector_relative_strength() -> None:
|
||||
params = ORBStrategyParams(
|
||||
engine_family="stocks_in_play_dual_regime",
|
||||
entry_direction="long_only",
|
||||
min_price=10.0,
|
||||
min_avg_dollar_volume=0.0,
|
||||
min_atr_14=0.1,
|
||||
min_rvol=0.1,
|
||||
min_candidates_to_trade=1,
|
||||
max_candidates=10,
|
||||
require_event_flag=True,
|
||||
attention_min_wiki_spike_10d=2.0,
|
||||
attention_min_article_count_3d=3,
|
||||
min_close_location=0.6,
|
||||
min_sector_relative_strength=0.01,
|
||||
require_vwap_confirmation=False,
|
||||
)
|
||||
strong_bars = [
|
||||
{"timestamp": "2026-01-05T09:30:00-05:00", "open": 100.0, "high": 103.0, "low": 99.8, "close": 102.8, "volume": 2000, "vwap": 101.5},
|
||||
{"timestamp": "2026-01-05T09:35:00-05:00", "open": 102.8, "high": 103.1, "low": 102.6, "close": 103.0, "volume": 2000, "vwap": 102.9},
|
||||
{"timestamp": "2026-01-05T09:40:00-05:00", "open": 103.0, "high": 103.2, "low": 102.9, "close": 103.1, "volume": 2000, "vwap": 103.0},
|
||||
{"timestamp": "2026-01-05T09:45:00-05:00", "open": 103.1, "high": 103.2, "low": 103.0, "close": 103.1, "volume": 2000, "vwap": 103.1},
|
||||
{"timestamp": "2026-01-05T09:50:00-05:00", "open": 103.1, "high": 103.2, "low": 103.0, "close": 103.1, "volume": 2000, "vwap": 103.1},
|
||||
]
|
||||
weak_same_sector = [
|
||||
{"timestamp": "2026-01-05T09:30:00-05:00", "open": 100.0, "high": 101.2, "low": 99.8, "close": 100.7, "volume": 2000, "vwap": 100.5},
|
||||
{"timestamp": "2026-01-05T09:35:00-05:00", "open": 100.7, "high": 100.9, "low": 100.6, "close": 100.8, "volume": 2000, "vwap": 100.8},
|
||||
{"timestamp": "2026-01-05T09:40:00-05:00", "open": 100.8, "high": 100.9, "low": 100.7, "close": 100.8, "volume": 2000, "vwap": 100.8},
|
||||
{"timestamp": "2026-01-05T09:45:00-05:00", "open": 100.8, "high": 100.9, "low": 100.7, "close": 100.8, "volume": 2000, "vwap": 100.8},
|
||||
{"timestamp": "2026-01-05T09:50:00-05:00", "open": 100.8, "high": 100.9, "low": 100.7, "close": 100.8, "volume": 2000, "vwap": 100.8},
|
||||
]
|
||||
no_attention = [
|
||||
{"timestamp": "2026-01-05T09:30:00-05:00", "open": 100.0, "high": 102.5, "low": 99.8, "close": 102.0, "volume": 2000, "vwap": 101.5},
|
||||
{"timestamp": "2026-01-05T09:35:00-05:00", "open": 102.0, "high": 102.2, "low": 101.9, "close": 102.1, "volume": 2000, "vwap": 102.0},
|
||||
{"timestamp": "2026-01-05T09:40:00-05:00", "open": 102.1, "high": 102.2, "low": 102.0, "close": 102.1, "volume": 2000, "vwap": 102.1},
|
||||
{"timestamp": "2026-01-05T09:45:00-05:00", "open": 102.1, "high": 102.2, "low": 102.0, "close": 102.1, "volume": 2000, "vwap": 102.1},
|
||||
{"timestamp": "2026-01-05T09:50:00-05:00", "open": 102.1, "high": 102.2, "low": 102.0, "close": 102.1, "volume": 2000, "vwap": 102.1},
|
||||
]
|
||||
enrichment = _enrichment_for("STRONG", "WEAK", "NO_ATTN")
|
||||
enrichment["STRONG"]["2026-01-05"].update({
|
||||
"event_flag": True,
|
||||
"event_score": 1.0,
|
||||
"attention_wiki_spike_10d": 3.0,
|
||||
"attention_article_count_3d": 5,
|
||||
})
|
||||
enrichment["WEAK"]["2026-01-05"].update({
|
||||
"event_flag": True,
|
||||
"event_score": 1.0,
|
||||
"attention_wiki_spike_10d": 2.5,
|
||||
"attention_article_count_3d": 4,
|
||||
})
|
||||
enrichment["NO_ATTN"]["2026-01-05"].update({
|
||||
"event_flag": True,
|
||||
"event_score": 1.0,
|
||||
"attention_wiki_spike_10d": 1.2,
|
||||
"attention_article_count_3d": 0,
|
||||
})
|
||||
candidates = compute_orb_candidates(
|
||||
{"STRONG": strong_bars, "WEAK": weak_same_sector, "NO_ATTN": no_attention},
|
||||
"2026-01-05",
|
||||
params,
|
||||
enrichment,
|
||||
ticker_sectors={"STRONG": "TECH", "WEAK": "TECH", "NO_ATTN": "HEALTH"},
|
||||
)
|
||||
|
||||
assert [cand["ticker"] for cand in candidates] == ["STRONG"]
|
||||
|
||||
|
||||
def test_stocks_in_play_can_allow_red_to_green_reclaim() -> None:
|
||||
params = ORBStrategyParams(
|
||||
engine_family="stocks_in_play_dual_regime",
|
||||
entry_direction="long_only",
|
||||
allow_doji_breakout=True,
|
||||
allow_red_to_green_breakout=True,
|
||||
min_price=10.0,
|
||||
min_avg_dollar_volume=0.0,
|
||||
min_atr_14=0.1,
|
||||
min_rvol=0.1,
|
||||
min_candidates_to_trade=1,
|
||||
max_candidates=10,
|
||||
require_event_flag=True,
|
||||
)
|
||||
bars = [
|
||||
{"timestamp": "2026-01-05T09:30:00-05:00", "open": 100.0, "high": 101.0, "low": 99.8, "close": 99.9, "volume": 2000, "vwap": 100.0},
|
||||
{"timestamp": "2026-01-05T09:35:00-05:00", "open": 99.9, "high": 100.3, "low": 99.8, "close": 100.2, "volume": 2000, "vwap": 100.1},
|
||||
{"timestamp": "2026-01-05T09:40:00-05:00", "open": 100.2, "high": 100.4, "low": 100.1, "close": 100.3, "volume": 2000, "vwap": 100.3},
|
||||
{"timestamp": "2026-01-05T09:45:00-05:00", "open": 100.3, "high": 100.4, "low": 100.2, "close": 100.3, "volume": 2000, "vwap": 100.3},
|
||||
{"timestamp": "2026-01-05T09:50:00-05:00", "open": 100.3, "high": 100.4, "low": 100.2, "close": 100.3, "volume": 2000, "vwap": 100.3},
|
||||
]
|
||||
enrichment = _enrichment_for("R2G")
|
||||
enrichment["R2G"]["2026-01-05"].update({"event_flag": True, "event_score": 1.0})
|
||||
candidates = compute_orb_candidates({"R2G": bars}, "2026-01-05", params, enrichment)
|
||||
|
||||
assert [cand["ticker"] for cand in candidates] == ["R2G"]
|
||||
|
||||
|
||||
def test_candidates_can_filter_on_min_abs_gap_pct() -> None:
|
||||
params = ORBStrategyParams(
|
||||
engine_family="compression_breakout",
|
||||
min_abs_gap_pct=0.02,
|
||||
min_price=10.0,
|
||||
min_avg_dollar_volume=0.0,
|
||||
min_atr_14=0.1,
|
||||
min_rvol=0.1,
|
||||
max_candidates=10,
|
||||
min_candidates_to_trade=1,
|
||||
)
|
||||
bars = [
|
||||
{"timestamp": "2026-01-05T09:30:00-05:00", "open": 100.0, "high": 102.0, "low": 99.8, "close": 101.8, "volume": 2000},
|
||||
{"timestamp": "2026-01-05T09:35:00-05:00", "open": 101.8, "high": 102.2, "low": 101.7, "close": 102.0, "volume": 2000},
|
||||
{"timestamp": "2026-01-05T09:40:00-05:00", "open": 102.0, "high": 102.1, "low": 101.9, "close": 102.0, "volume": 2000},
|
||||
{"timestamp": "2026-01-05T09:45:00-05:00", "open": 102.0, "high": 102.1, "low": 101.9, "close": 102.0, "volume": 2000},
|
||||
{"timestamp": "2026-01-05T09:50:00-05:00", "open": 102.0, "high": 102.1, "low": 101.9, "close": 102.0, "volume": 2000},
|
||||
]
|
||||
enrichment = _enrichment_for("BIG_GAP", "SMALL_GAP")
|
||||
enrichment["BIG_GAP"]["2026-01-05"]["prev_close"] = 95.0
|
||||
enrichment["SMALL_GAP"]["2026-01-05"]["prev_close"] = 99.5
|
||||
|
||||
candidates = compute_orb_candidates(
|
||||
{"BIG_GAP": bars, "SMALL_GAP": bars},
|
||||
"2026-01-05",
|
||||
params,
|
||||
enrichment,
|
||||
)
|
||||
|
||||
assert [cand["ticker"] for cand in candidates] == ["BIG_GAP"]
|
||||
|
||||
|
||||
def test_candidates_can_cap_names_per_sector_after_ranking() -> None:
|
||||
params = ORBStrategyParams(
|
||||
weight_rvol=1.0,
|
||||
weight_gap=0.0,
|
||||
weight_dollar_vol=0.0,
|
||||
min_price=10.0,
|
||||
min_avg_dollar_volume=0.0,
|
||||
min_atr_14=0.1,
|
||||
min_rvol=0.1,
|
||||
max_candidates=3,
|
||||
max_candidates_per_sector=1,
|
||||
min_candidates_to_trade=1,
|
||||
)
|
||||
bars_by_ticker = {
|
||||
"TECH1": [
|
||||
{"timestamp": "2026-01-05T09:30:00-05:00", "open": 100.0, "high": 102.0, "low": 99.8, "close": 101.8, "volume": 4000},
|
||||
{"timestamp": "2026-01-05T09:35:00-05:00", "open": 101.8, "high": 102.2, "low": 101.7, "close": 102.0, "volume": 2000},
|
||||
{"timestamp": "2026-01-05T09:40:00-05:00", "open": 102.0, "high": 102.1, "low": 101.9, "close": 102.0, "volume": 2000},
|
||||
{"timestamp": "2026-01-05T09:45:00-05:00", "open": 102.0, "high": 102.1, "low": 101.9, "close": 102.0, "volume": 2000},
|
||||
{"timestamp": "2026-01-05T09:50:00-05:00", "open": 102.0, "high": 102.1, "low": 101.9, "close": 102.0, "volume": 2000},
|
||||
],
|
||||
"TECH2": [
|
||||
{"timestamp": "2026-01-05T09:30:00-05:00", "open": 100.0, "high": 102.0, "low": 99.8, "close": 101.8, "volume": 3000},
|
||||
{"timestamp": "2026-01-05T09:35:00-05:00", "open": 101.8, "high": 102.2, "low": 101.7, "close": 102.0, "volume": 2000},
|
||||
{"timestamp": "2026-01-05T09:40:00-05:00", "open": 102.0, "high": 102.1, "low": 101.9, "close": 102.0, "volume": 2000},
|
||||
{"timestamp": "2026-01-05T09:45:00-05:00", "open": 102.0, "high": 102.1, "low": 101.9, "close": 102.0, "volume": 2000},
|
||||
{"timestamp": "2026-01-05T09:50:00-05:00", "open": 102.0, "high": 102.1, "low": 101.9, "close": 102.0, "volume": 2000},
|
||||
],
|
||||
"HEALTH1": [
|
||||
{"timestamp": "2026-01-05T09:30:00-05:00", "open": 100.0, "high": 102.0, "low": 99.8, "close": 101.8, "volume": 2000},
|
||||
{"timestamp": "2026-01-05T09:35:00-05:00", "open": 101.8, "high": 102.2, "low": 101.7, "close": 102.0, "volume": 2000},
|
||||
{"timestamp": "2026-01-05T09:40:00-05:00", "open": 102.0, "high": 102.1, "low": 101.9, "close": 102.0, "volume": 2000},
|
||||
{"timestamp": "2026-01-05T09:45:00-05:00", "open": 102.0, "high": 102.1, "low": 101.9, "close": 102.0, "volume": 2000},
|
||||
{"timestamp": "2026-01-05T09:50:00-05:00", "open": 102.0, "high": 102.1, "low": 101.9, "close": 102.0, "volume": 2000},
|
||||
],
|
||||
}
|
||||
|
||||
candidates = compute_orb_candidates(
|
||||
bars_by_ticker,
|
||||
"2026-01-05",
|
||||
params,
|
||||
_enrichment_for("TECH1", "TECH2", "HEALTH1"),
|
||||
ticker_sectors={"TECH1": "Technology", "TECH2": "Technology", "HEALTH1": "Healthcare"},
|
||||
)
|
||||
|
||||
assert [cand["ticker"] for cand in candidates] == ["TECH1", "HEALTH1"]
|
||||
|
||||
|
||||
def test_gainers_leader_prefers_premarket_attention_and_abs_gap() -> None:
|
||||
params = ORBStrategyParams(
|
||||
engine_family="gainers_leader",
|
||||
weight_rvol=0.50,
|
||||
weight_gap=0.15,
|
||||
weight_dollar_vol=0.10,
|
||||
weight_premarket_dollar_vol=0.25,
|
||||
min_price=10.0,
|
||||
min_avg_dollar_volume=0.0,
|
||||
min_atr_14=0.1,
|
||||
min_rvol=0.1,
|
||||
min_abs_gap_pct=0.02,
|
||||
max_gap_pct=None,
|
||||
max_candidates=10,
|
||||
min_candidates_to_trade=1,
|
||||
)
|
||||
market_bars = [
|
||||
{"timestamp": "2026-01-05T09:30:00-05:00", "open": 100.0, "high": 102.0, "low": 99.8, "close": 101.8, "volume": 2000},
|
||||
{"timestamp": "2026-01-05T09:35:00-05:00", "open": 101.8, "high": 102.2, "low": 101.7, "close": 102.0, "volume": 2000},
|
||||
{"timestamp": "2026-01-05T09:40:00-05:00", "open": 102.0, "high": 102.1, "low": 101.9, "close": 102.0, "volume": 2000},
|
||||
{"timestamp": "2026-01-05T09:45:00-05:00", "open": 102.0, "high": 102.1, "low": 101.9, "close": 102.0, "volume": 2000},
|
||||
{"timestamp": "2026-01-05T09:50:00-05:00", "open": 102.0, "high": 102.1, "low": 101.9, "close": 102.0, "volume": 2000},
|
||||
]
|
||||
bars_by_ticker = {
|
||||
"LEADER": [
|
||||
{"timestamp": "2026-01-05T08:00:00-05:00", "open": 104.0, "high": 104.5, "low": 103.8, "close": 104.2, "volume": 20_000},
|
||||
*market_bars,
|
||||
],
|
||||
"LAGGARD": [
|
||||
{"timestamp": "2026-01-05T08:00:00-05:00", "open": 101.0, "high": 101.2, "low": 100.8, "close": 101.1, "volume": 1_000},
|
||||
*market_bars,
|
||||
],
|
||||
}
|
||||
enrichment = _enrichment_for("LEADER", "LAGGARD")
|
||||
enrichment["LEADER"]["2026-01-05"]["prev_close"] = 95.0
|
||||
enrichment["LAGGARD"]["2026-01-05"]["prev_close"] = 97.0
|
||||
|
||||
candidates = compute_orb_candidates(
|
||||
bars_by_ticker,
|
||||
"2026-01-05",
|
||||
params,
|
||||
enrichment,
|
||||
)
|
||||
|
||||
assert [cand["ticker"] for cand in candidates] == ["LEADER", "LAGGARD"]
|
||||
|
||||
|
||||
def test_gainers_leader_can_allow_doji_followthrough_breakouts() -> None:
|
||||
params = ORBStrategyParams(
|
||||
engine_family="gainers_leader",
|
||||
entry_direction="long_only",
|
||||
allow_doji_breakout=True,
|
||||
weight_rvol=0.40,
|
||||
weight_gap=0.20,
|
||||
weight_dollar_vol=0.05,
|
||||
weight_premarket_dollar_vol=0.35,
|
||||
min_price=10.0,
|
||||
min_avg_dollar_volume=0.0,
|
||||
min_atr_14=0.1,
|
||||
min_rvol=0.1,
|
||||
min_abs_gap_pct=0.02,
|
||||
min_premarket_dollar_vol=100_000.0,
|
||||
max_gap_pct=None,
|
||||
max_candidates=5,
|
||||
min_candidates_to_trade=1,
|
||||
)
|
||||
bars_by_ticker = {
|
||||
"DOJI": [
|
||||
{"timestamp": "2026-01-05T08:15:00-05:00", "open": 103.0, "high": 103.5, "low": 102.8, "close": 103.2, "volume": 15_000},
|
||||
{"timestamp": "2026-01-05T09:30:00-05:00", "open": 105.0, "high": 106.0, "low": 104.0, "close": 105.02, "volume": 8_000},
|
||||
{"timestamp": "2026-01-05T09:35:00-05:00", "open": 105.2, "high": 106.5, "low": 105.0, "close": 106.2, "volume": 6_000},
|
||||
{"timestamp": "2026-01-05T09:40:00-05:00", "open": 106.2, "high": 106.4, "low": 105.9, "close": 106.1, "volume": 3_000},
|
||||
{"timestamp": "2026-01-05T09:45:00-05:00", "open": 106.1, "high": 106.6, "low": 106.0, "close": 106.4, "volume": 2_000},
|
||||
{"timestamp": "2026-01-05T09:50:00-05:00", "open": 106.4, "high": 106.8, "low": 106.2, "close": 106.7, "volume": 2_000},
|
||||
],
|
||||
}
|
||||
enrichment = _enrichment_for("DOJI")
|
||||
enrichment["DOJI"]["2026-01-05"]["prev_close"] = 100.0
|
||||
|
||||
candidates = compute_orb_candidates(
|
||||
bars_by_ticker,
|
||||
"2026-01-05",
|
||||
params,
|
||||
enrichment,
|
||||
)
|
||||
|
||||
assert [cand["ticker"] for cand in candidates] == ["DOJI"]
|
||||
|
||||
|
||||
def test_gainers_leader_can_allow_red_to_green_followthrough_breakouts() -> None:
|
||||
params = ORBStrategyParams(
|
||||
engine_family="gainers_leader",
|
||||
entry_direction="long_only",
|
||||
allow_red_to_green_breakout=True,
|
||||
weight_rvol=0.40,
|
||||
weight_gap=0.20,
|
||||
weight_dollar_vol=0.05,
|
||||
weight_premarket_dollar_vol=0.35,
|
||||
min_price=10.0,
|
||||
min_avg_dollar_volume=0.0,
|
||||
min_atr_14=0.1,
|
||||
min_rvol=0.1,
|
||||
min_abs_gap_pct=0.02,
|
||||
min_premarket_dollar_vol=100_000.0,
|
||||
max_gap_pct=None,
|
||||
max_candidates=5,
|
||||
min_candidates_to_trade=1,
|
||||
)
|
||||
bars_by_ticker = {
|
||||
"RED_GREEN": [
|
||||
{"timestamp": "2026-01-05T08:15:00-05:00", "open": 103.0, "high": 103.5, "low": 102.8, "close": 103.2, "volume": 15_000},
|
||||
{"timestamp": "2026-01-05T09:30:00-05:00", "open": 105.0, "high": 105.4, "low": 103.8, "close": 104.3, "volume": 8_000},
|
||||
{"timestamp": "2026-01-05T09:35:00-05:00", "open": 104.4, "high": 105.8, "low": 104.2, "close": 105.6, "volume": 6_000},
|
||||
{"timestamp": "2026-01-05T09:40:00-05:00", "open": 105.6, "high": 105.9, "low": 105.4, "close": 105.8, "volume": 3_000},
|
||||
{"timestamp": "2026-01-05T09:45:00-05:00", "open": 105.8, "high": 106.0, "low": 105.6, "close": 105.9, "volume": 2_000},
|
||||
{"timestamp": "2026-01-05T09:50:00-05:00", "open": 105.9, "high": 106.2, "low": 105.7, "close": 106.1, "volume": 2_000},
|
||||
],
|
||||
}
|
||||
enrichment = _enrichment_for("RED_GREEN")
|
||||
enrichment["RED_GREEN"]["2026-01-05"]["prev_close"] = 100.0
|
||||
|
||||
candidates = compute_orb_candidates(
|
||||
bars_by_ticker,
|
||||
"2026-01-05",
|
||||
params,
|
||||
enrichment,
|
||||
)
|
||||
|
||||
assert [cand["ticker"] for cand in candidates] == ["RED_GREEN"]
|
||||
|
||||
|
||||
def test_dual_regime_requires_actual_event_flag_and_filters_event_types() -> None:
|
||||
params = ORBStrategyParams(
|
||||
engine_family="stocks_in_play_dual_regime",
|
||||
entry_direction="candle",
|
||||
require_event_flag=True,
|
||||
allowed_event_types=["other_material_event"],
|
||||
min_price=10.0,
|
||||
min_avg_dollar_volume=0.0,
|
||||
min_atr_14=0.1,
|
||||
min_rvol=0.1,
|
||||
min_close_location=0.6,
|
||||
weight_event_catalyst=1.0,
|
||||
max_candidates=5,
|
||||
min_candidates_to_trade=1,
|
||||
)
|
||||
bars = [
|
||||
{"timestamp": "2026-01-05T09:30:00-05:00", "open": 105.0, "high": 107.0, "low": 104.9, "close": 106.8, "volume": 5000, "vwap": 106.1},
|
||||
{"timestamp": "2026-01-05T09:35:00-05:00", "open": 106.8, "high": 107.2, "low": 106.7, "close": 107.0, "volume": 3000, "vwap": 106.9},
|
||||
{"timestamp": "2026-01-05T09:40:00-05:00", "open": 107.0, "high": 107.2, "low": 106.8, "close": 107.1, "volume": 2000, "vwap": 107.0},
|
||||
{"timestamp": "2026-01-05T09:45:00-05:00", "open": 107.1, "high": 107.3, "low": 106.9, "close": 107.2, "volume": 2000, "vwap": 107.1},
|
||||
{"timestamp": "2026-01-05T09:50:00-05:00", "open": 107.2, "high": 107.4, "low": 107.0, "close": 107.3, "volume": 2000, "vwap": 107.2},
|
||||
]
|
||||
enrichment = _enrichment_for("GOOD", "WRONG_TYPE", "NO_EVENT")
|
||||
for ticker in ("GOOD", "WRONG_TYPE", "NO_EVENT"):
|
||||
enrichment[ticker]["2026-01-05"]["prev_close"] = 100.0
|
||||
enrichment["GOOD"]["2026-01-05"]["event_flag"] = True
|
||||
enrichment["GOOD"]["2026-01-05"]["event_types"] = ["other_material_event"]
|
||||
enrichment["GOOD"]["2026-01-05"]["event_score"] = 1.0
|
||||
enrichment["WRONG_TYPE"]["2026-01-05"]["event_flag"] = True
|
||||
enrichment["WRONG_TYPE"]["2026-01-05"]["event_types"] = ["management_change"]
|
||||
enrichment["WRONG_TYPE"]["2026-01-05"]["event_score"] = 0.6
|
||||
|
||||
candidates = compute_orb_candidates(
|
||||
{"GOOD": bars, "WRONG_TYPE": bars, "NO_EVENT": bars},
|
||||
"2026-01-05",
|
||||
params,
|
||||
enrichment,
|
||||
)
|
||||
|
||||
assert [cand["ticker"] for cand in candidates] == ["GOOD"]
|
||||
|
||||
|
||||
def test_dual_regime_can_take_failed_gap_up_short_below_vwap() -> None:
|
||||
params = ORBStrategyParams(
|
||||
engine_family="stocks_in_play_dual_regime",
|
||||
entry_direction="candle",
|
||||
require_event_flag=True,
|
||||
allow_failed_orb_short=True,
|
||||
require_vwap_confirmation=True,
|
||||
max_close_location_short=0.40,
|
||||
min_price=10.0,
|
||||
min_avg_dollar_volume=0.0,
|
||||
min_atr_14=0.1,
|
||||
min_rvol=0.1,
|
||||
max_candidates=5,
|
||||
min_candidates_to_trade=1,
|
||||
)
|
||||
bars_by_ticker = {
|
||||
"FAILED": [
|
||||
{"timestamp": "2026-01-05T09:30:00-05:00", "open": 105.0, "high": 105.3, "low": 103.8, "close": 104.0, "volume": 6000, "vwap": 104.7},
|
||||
{"timestamp": "2026-01-05T09:35:00-05:00", "open": 104.0, "high": 104.2, "low": 103.6, "close": 103.8, "volume": 3000, "vwap": 103.9},
|
||||
{"timestamp": "2026-01-05T09:40:00-05:00", "open": 103.8, "high": 104.0, "low": 103.5, "close": 103.7, "volume": 2000, "vwap": 103.8},
|
||||
{"timestamp": "2026-01-05T09:45:00-05:00", "open": 103.7, "high": 103.9, "low": 103.4, "close": 103.5, "volume": 2000, "vwap": 103.6},
|
||||
{"timestamp": "2026-01-05T09:50:00-05:00", "open": 103.5, "high": 103.7, "low": 103.2, "close": 103.3, "volume": 2000, "vwap": 103.4},
|
||||
],
|
||||
}
|
||||
enrichment = _enrichment_for("FAILED")
|
||||
enrichment["FAILED"]["2026-01-05"]["prev_close"] = 100.0
|
||||
enrichment["FAILED"]["2026-01-05"]["event_flag"] = True
|
||||
enrichment["FAILED"]["2026-01-05"]["event_types"] = ["other_material_event"]
|
||||
enrichment["FAILED"]["2026-01-05"]["event_score"] = 1.0
|
||||
|
||||
candidates = compute_orb_candidates(
|
||||
bars_by_ticker,
|
||||
"2026-01-05",
|
||||
params,
|
||||
enrichment,
|
||||
)
|
||||
|
||||
assert [cand["ticker"] for cand in candidates] == ["FAILED"]
|
||||
assert candidates[0]["direction"] == "bearish"
|
||||
|
||||
|
||||
def test_gainers_leader_can_override_min_gap_for_high_attention_small_gap_names() -> None:
|
||||
params = ORBStrategyParams(
|
||||
engine_family="gainers_leader",
|
||||
entry_direction="long_only",
|
||||
allow_red_to_green_breakout=True,
|
||||
min_abs_gap_pct=0.02,
|
||||
small_gap_attention_override_premarket_dollar_vol=1_000_000.0,
|
||||
small_gap_attention_override_rvol=1.5,
|
||||
weight_rvol=0.40,
|
||||
weight_gap=0.20,
|
||||
weight_dollar_vol=0.05,
|
||||
weight_premarket_dollar_vol=0.35,
|
||||
min_price=10.0,
|
||||
min_avg_dollar_volume=0.0,
|
||||
min_atr_14=0.1,
|
||||
min_rvol=0.1,
|
||||
min_premarket_dollar_vol=100_000.0,
|
||||
max_gap_pct=None,
|
||||
max_candidates=5,
|
||||
min_candidates_to_trade=1,
|
||||
)
|
||||
bars_by_ticker = {
|
||||
"ATTN_SMALL_GAP": [
|
||||
{"timestamp": "2026-01-05T08:15:00-05:00", "open": 100.5, "high": 101.0, "low": 100.4, "close": 100.8, "volume": 20_000},
|
||||
{"timestamp": "2026-01-05T09:30:00-05:00", "open": 101.0, "high": 101.1, "low": 99.8, "close": 100.4, "volume": 3_500},
|
||||
{"timestamp": "2026-01-05T09:35:00-05:00", "open": 100.5, "high": 101.6, "low": 100.4, "close": 101.5, "volume": 3_000},
|
||||
{"timestamp": "2026-01-05T09:40:00-05:00", "open": 101.5, "high": 101.7, "low": 101.3, "close": 101.6, "volume": 2_000},
|
||||
{"timestamp": "2026-01-05T09:45:00-05:00", "open": 101.6, "high": 101.8, "low": 101.5, "close": 101.7, "volume": 2_000},
|
||||
{"timestamp": "2026-01-05T09:50:00-05:00", "open": 101.7, "high": 101.9, "low": 101.6, "close": 101.8, "volume": 2_000},
|
||||
],
|
||||
}
|
||||
enrichment = _enrichment_for("ATTN_SMALL_GAP")
|
||||
enrichment["ATTN_SMALL_GAP"]["2026-01-05"]["prev_close"] = 100.5
|
||||
enrichment["ATTN_SMALL_GAP"]["2026-01-05"]["avg_daily_vol_14d"] = 100_000.0
|
||||
|
||||
candidates = compute_orb_candidates(
|
||||
bars_by_ticker,
|
||||
"2026-01-05",
|
||||
params,
|
||||
enrichment,
|
||||
)
|
||||
|
||||
assert [cand["ticker"] for cand in candidates] == ["ATTN_SMALL_GAP"]
|
||||
|
||||
|
||||
def test_gainers_leader_can_cap_small_gap_attention_override_names_per_day() -> None:
|
||||
params = ORBStrategyParams(
|
||||
engine_family="gainers_leader",
|
||||
entry_direction="long_only",
|
||||
allow_red_to_green_breakout=True,
|
||||
min_abs_gap_pct=0.02,
|
||||
small_gap_attention_override_premarket_dollar_vol=1_000_000.0,
|
||||
small_gap_attention_override_rvol=1.5,
|
||||
max_small_gap_attention_candidates=1,
|
||||
weight_rvol=1.0,
|
||||
weight_gap=0.0,
|
||||
weight_dollar_vol=0.0,
|
||||
weight_premarket_dollar_vol=0.0,
|
||||
min_price=10.0,
|
||||
min_avg_dollar_volume=0.0,
|
||||
min_atr_14=0.1,
|
||||
min_rvol=0.1,
|
||||
min_premarket_dollar_vol=100_000.0,
|
||||
max_gap_pct=None,
|
||||
max_candidates=5,
|
||||
min_candidates_to_trade=1,
|
||||
)
|
||||
bars_by_ticker = {
|
||||
"ATTN1": [
|
||||
{"timestamp": "2026-01-05T08:15:00-05:00", "open": 100.5, "high": 101.0, "low": 100.4, "close": 100.8, "volume": 20_000},
|
||||
{"timestamp": "2026-01-05T09:30:00-05:00", "open": 101.0, "high": 101.1, "low": 99.8, "close": 100.4, "volume": 4_500},
|
||||
{"timestamp": "2026-01-05T09:35:00-05:00", "open": 100.5, "high": 101.6, "low": 100.4, "close": 101.5, "volume": 3_000},
|
||||
{"timestamp": "2026-01-05T09:40:00-05:00", "open": 101.5, "high": 101.7, "low": 101.3, "close": 101.6, "volume": 2_000},
|
||||
{"timestamp": "2026-01-05T09:45:00-05:00", "open": 101.6, "high": 101.8, "low": 101.5, "close": 101.7, "volume": 2_000},
|
||||
{"timestamp": "2026-01-05T09:50:00-05:00", "open": 101.7, "high": 101.9, "low": 101.6, "close": 101.8, "volume": 2_000},
|
||||
],
|
||||
"ATTN2": [
|
||||
{"timestamp": "2026-01-05T08:15:00-05:00", "open": 100.5, "high": 101.0, "low": 100.4, "close": 100.8, "volume": 20_000},
|
||||
{"timestamp": "2026-01-05T09:30:00-05:00", "open": 101.0, "high": 101.1, "low": 99.8, "close": 100.4, "volume": 3_500},
|
||||
{"timestamp": "2026-01-05T09:35:00-05:00", "open": 100.5, "high": 101.6, "low": 100.4, "close": 101.5, "volume": 3_000},
|
||||
{"timestamp": "2026-01-05T09:40:00-05:00", "open": 101.5, "high": 101.7, "low": 101.3, "close": 101.6, "volume": 2_000},
|
||||
{"timestamp": "2026-01-05T09:45:00-05:00", "open": 101.6, "high": 101.8, "low": 101.5, "close": 101.7, "volume": 2_000},
|
||||
{"timestamp": "2026-01-05T09:50:00-05:00", "open": 101.7, "high": 101.9, "low": 101.6, "close": 101.8, "volume": 2_000},
|
||||
],
|
||||
"BIG_GAP": [
|
||||
{"timestamp": "2026-01-05T08:15:00-05:00", "open": 103.0, "high": 103.5, "low": 102.8, "close": 103.2, "volume": 15_000},
|
||||
{"timestamp": "2026-01-05T09:30:00-05:00", "open": 105.0, "high": 106.0, "low": 104.4, "close": 105.8, "volume": 3_000},
|
||||
{"timestamp": "2026-01-05T09:35:00-05:00", "open": 105.8, "high": 106.2, "low": 105.6, "close": 106.0, "volume": 2_000},
|
||||
{"timestamp": "2026-01-05T09:40:00-05:00", "open": 106.0, "high": 106.2, "low": 105.8, "close": 106.1, "volume": 2_000},
|
||||
{"timestamp": "2026-01-05T09:45:00-05:00", "open": 106.1, "high": 106.3, "low": 106.0, "close": 106.2, "volume": 2_000},
|
||||
{"timestamp": "2026-01-05T09:50:00-05:00", "open": 106.2, "high": 106.4, "low": 106.1, "close": 106.3, "volume": 2_000},
|
||||
],
|
||||
}
|
||||
enrichment = _enrichment_for("ATTN1", "ATTN2", "BIG_GAP")
|
||||
enrichment["ATTN1"]["2026-01-05"]["prev_close"] = 100.5
|
||||
enrichment["ATTN1"]["2026-01-05"]["avg_daily_vol_14d"] = 100_000.0
|
||||
enrichment["ATTN2"]["2026-01-05"]["prev_close"] = 100.5
|
||||
enrichment["ATTN2"]["2026-01-05"]["avg_daily_vol_14d"] = 100_000.0
|
||||
enrichment["BIG_GAP"]["2026-01-05"]["prev_close"] = 100.0
|
||||
|
||||
candidates = compute_orb_candidates(
|
||||
bars_by_ticker,
|
||||
"2026-01-05",
|
||||
params,
|
||||
enrichment,
|
||||
)
|
||||
|
||||
assert [cand["ticker"] for cand in candidates] == ["BIG_GAP", "ATTN1"]
|
||||
|
||||
|
||||
def test_leader_followthrough_requires_strong_close_location_for_red_to_green() -> None:
|
||||
params = ORBStrategyParams(
|
||||
engine_family="leader_followthrough",
|
||||
entry_direction="long_only",
|
||||
allow_red_to_green_breakout=True,
|
||||
min_close_location=0.55,
|
||||
weight_rvol=0.30,
|
||||
weight_gap=0.05,
|
||||
weight_dollar_vol=0.10,
|
||||
weight_premarket_dollar_vol=0.35,
|
||||
weight_close_location=0.20,
|
||||
min_price=10.0,
|
||||
min_avg_dollar_volume=0.0,
|
||||
min_atr_14=0.1,
|
||||
min_rvol=0.1,
|
||||
min_abs_gap_pct=0.005,
|
||||
min_premarket_dollar_vol=100_000.0,
|
||||
max_gap_pct=None,
|
||||
max_candidates=5,
|
||||
min_candidates_to_trade=1,
|
||||
)
|
||||
bars_by_ticker = {
|
||||
"STRONG_RECLAIM": [
|
||||
{"timestamp": "2026-01-05T08:15:00-05:00", "open": 102.0, "high": 102.4, "low": 101.8, "close": 102.3, "volume": 18_000},
|
||||
{"timestamp": "2026-01-05T09:30:00-05:00", "open": 105.0, "high": 105.4, "low": 103.8, "close": 104.85, "volume": 8_000},
|
||||
{"timestamp": "2026-01-05T09:35:00-05:00", "open": 104.9, "high": 105.8, "low": 104.8, "close": 105.7, "volume": 6_000},
|
||||
{"timestamp": "2026-01-05T09:40:00-05:00", "open": 105.7, "high": 106.0, "low": 105.5, "close": 105.9, "volume": 3_000},
|
||||
{"timestamp": "2026-01-05T09:45:00-05:00", "open": 105.9, "high": 106.1, "low": 105.8, "close": 106.0, "volume": 2_000},
|
||||
{"timestamp": "2026-01-05T09:50:00-05:00", "open": 106.0, "high": 106.2, "low": 105.8, "close": 106.1, "volume": 2_000},
|
||||
],
|
||||
"WEAK_RECLAIM": [
|
||||
{"timestamp": "2026-01-05T08:15:00-05:00", "open": 102.0, "high": 102.4, "low": 101.8, "close": 102.3, "volume": 18_000},
|
||||
{"timestamp": "2026-01-05T09:30:00-05:00", "open": 105.0, "high": 105.4, "low": 103.8, "close": 104.2, "volume": 8_000},
|
||||
{"timestamp": "2026-01-05T09:35:00-05:00", "open": 104.2, "high": 104.6, "low": 104.0, "close": 104.3, "volume": 4_000},
|
||||
{"timestamp": "2026-01-05T09:40:00-05:00", "open": 104.3, "high": 104.5, "low": 104.1, "close": 104.4, "volume": 2_000},
|
||||
{"timestamp": "2026-01-05T09:45:00-05:00", "open": 104.4, "high": 104.5, "low": 104.2, "close": 104.4, "volume": 2_000},
|
||||
{"timestamp": "2026-01-05T09:50:00-05:00", "open": 104.4, "high": 104.6, "low": 104.2, "close": 104.5, "volume": 2_000},
|
||||
],
|
||||
}
|
||||
enrichment = _enrichment_for("STRONG_RECLAIM", "WEAK_RECLAIM")
|
||||
enrichment["STRONG_RECLAIM"]["2026-01-05"]["prev_close"] = 104.0
|
||||
enrichment["WEAK_RECLAIM"]["2026-01-05"]["prev_close"] = 104.0
|
||||
|
||||
candidates = compute_orb_candidates(
|
||||
bars_by_ticker,
|
||||
"2026-01-05",
|
||||
params,
|
||||
enrichment,
|
||||
)
|
||||
|
||||
assert [cand["ticker"] for cand in candidates] == ["STRONG_RECLAIM"]
|
||||
|
||||
|
||||
def test_leader_followthrough_ranking_rewards_close_location() -> None:
|
||||
params = ORBStrategyParams(
|
||||
engine_family="leader_followthrough",
|
||||
entry_direction="long_only",
|
||||
allow_red_to_green_breakout=True,
|
||||
min_close_location=0.0,
|
||||
weight_rvol=0.0,
|
||||
weight_gap=0.0,
|
||||
weight_dollar_vol=0.0,
|
||||
weight_premarket_dollar_vol=0.0,
|
||||
weight_close_location=1.0,
|
||||
min_price=10.0,
|
||||
min_avg_dollar_volume=0.0,
|
||||
min_atr_14=0.1,
|
||||
min_rvol=0.1,
|
||||
min_abs_gap_pct=0.005,
|
||||
min_premarket_dollar_vol=100_000.0,
|
||||
max_gap_pct=None,
|
||||
max_candidates=5,
|
||||
min_candidates_to_trade=1,
|
||||
)
|
||||
bars_by_ticker = {
|
||||
"HIGH_CLOSE": [
|
||||
{"timestamp": "2026-01-05T08:15:00-05:00", "open": 102.0, "high": 102.2, "low": 101.9, "close": 102.1, "volume": 12_000},
|
||||
{"timestamp": "2026-01-05T09:30:00-05:00", "open": 105.0, "high": 105.4, "low": 103.8, "close": 104.85, "volume": 8_000},
|
||||
{"timestamp": "2026-01-05T09:35:00-05:00", "open": 104.95, "high": 105.8, "low": 104.9, "close": 105.7, "volume": 5_000},
|
||||
{"timestamp": "2026-01-05T09:40:00-05:00", "open": 105.7, "high": 105.9, "low": 105.5, "close": 105.8, "volume": 2_000},
|
||||
{"timestamp": "2026-01-05T09:45:00-05:00", "open": 105.8, "high": 105.9, "low": 105.6, "close": 105.8, "volume": 2_000},
|
||||
{"timestamp": "2026-01-05T09:50:00-05:00", "open": 105.8, "high": 106.0, "low": 105.6, "close": 105.9, "volume": 2_000},
|
||||
],
|
||||
"LOW_CLOSE": [
|
||||
{"timestamp": "2026-01-05T08:15:00-05:00", "open": 102.0, "high": 102.2, "low": 101.9, "close": 102.1, "volume": 12_000},
|
||||
{"timestamp": "2026-01-05T09:30:00-05:00", "open": 105.0, "high": 105.4, "low": 103.8, "close": 104.3, "volume": 8_000},
|
||||
{"timestamp": "2026-01-05T09:35:00-05:00", "open": 104.3, "high": 104.8, "low": 104.2, "close": 104.5, "volume": 5_000},
|
||||
{"timestamp": "2026-01-05T09:40:00-05:00", "open": 104.5, "high": 104.7, "low": 104.3, "close": 104.6, "volume": 2_000},
|
||||
{"timestamp": "2026-01-05T09:45:00-05:00", "open": 104.6, "high": 104.7, "low": 104.4, "close": 104.6, "volume": 2_000},
|
||||
{"timestamp": "2026-01-05T09:50:00-05:00", "open": 104.6, "high": 104.8, "low": 104.4, "close": 104.7, "volume": 2_000},
|
||||
],
|
||||
}
|
||||
enrichment = _enrichment_for("HIGH_CLOSE", "LOW_CLOSE")
|
||||
enrichment["HIGH_CLOSE"]["2026-01-05"]["prev_close"] = 104.0
|
||||
enrichment["LOW_CLOSE"]["2026-01-05"]["prev_close"] = 104.0
|
||||
|
||||
candidates = compute_orb_candidates(
|
||||
bars_by_ticker,
|
||||
"2026-01-05",
|
||||
params,
|
||||
enrichment,
|
||||
)
|
||||
|
||||
assert [cand["ticker"] for cand in candidates] == ["HIGH_CLOSE", "LOW_CLOSE"]
|
||||
@ -0,0 +1,547 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import pickle
|
||||
from datetime import datetime
|
||||
from types import SimpleNamespace
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import apps.intraday_bt.run as run_mod
|
||||
from apps.intraday_bt.run import (
|
||||
_augment_momentum_seed_candidates_with_liquid_overlay,
|
||||
_chunk_trading_days_by_pairs,
|
||||
_fetch_vix_by_day,
|
||||
_latest_backtest_date,
|
||||
_latest_completed_trading_day,
|
||||
_load_vix_from_local_macro_snapshots,
|
||||
_momentum_intraday_seed_candidates,
|
||||
_momentum_strategy_uses_attention,
|
||||
_momentum_strategy_uses_catalyst,
|
||||
_retain_recent_intraday_shortlist,
|
||||
_recent_intraday_first_candidates,
|
||||
_strategy_for_recent_live_scan,
|
||||
_should_use_recent_live_scan,
|
||||
_should_use_recent_intraday_first_scan,
|
||||
apply_cli_overrides,
|
||||
get_trading_days,
|
||||
load_config,
|
||||
)
|
||||
from libs.intraday.domain import StrategyParams
|
||||
|
||||
|
||||
def test_get_trading_days_uses_local_calendar_for_explicit_range() -> None:
|
||||
days = asyncio.run(get_trading_days(None, "2026-01-01", "2026-01-10", lookback=0))
|
||||
|
||||
assert days == [
|
||||
"2026-01-02",
|
||||
"2026-01-05",
|
||||
"2026-01-06",
|
||||
"2026-01-07",
|
||||
"2026-01-08",
|
||||
"2026-01-09",
|
||||
]
|
||||
|
||||
|
||||
def test_get_trading_days_trims_to_lookback_when_start_not_pinned() -> None:
|
||||
days = asyncio.run(get_trading_days(None, None, "2026-01-10", lookback=3))
|
||||
|
||||
assert days == [
|
||||
"2026-01-07",
|
||||
"2026-01-08",
|
||||
"2026-01-09",
|
||||
]
|
||||
|
||||
|
||||
def test_chunk_trading_days_by_pairs_keeps_days_contiguous_and_bounded() -> None:
|
||||
trading_days = [
|
||||
"2026-01-05",
|
||||
"2026-01-06",
|
||||
"2026-01-07",
|
||||
"2026-01-08",
|
||||
]
|
||||
candidates = {
|
||||
"2026-01-05": ["A"] * 2000,
|
||||
"2026-01-06": ["B"] * 2000,
|
||||
"2026-01-07": ["C"] * 1500,
|
||||
"2026-01-08": ["D"] * 2500,
|
||||
}
|
||||
|
||||
chunks = _chunk_trading_days_by_pairs(trading_days, candidates, max_pairs_per_chunk=3500)
|
||||
|
||||
assert chunks == [
|
||||
["2026-01-05"],
|
||||
["2026-01-06", "2026-01-07"],
|
||||
["2026-01-08"],
|
||||
]
|
||||
|
||||
|
||||
def test_latest_backtest_date_excludes_today_before_close() -> None:
|
||||
now_et = datetime(2026, 4, 14, 12, 0, tzinfo=ZoneInfo("America/New_York"))
|
||||
|
||||
latest = _latest_backtest_date(now_et)
|
||||
|
||||
assert latest.isoformat() == "2026-04-13"
|
||||
|
||||
|
||||
def test_latest_backtest_date_includes_today_after_close() -> None:
|
||||
now_et = datetime(2026, 4, 14, 16, 1, tzinfo=ZoneInfo("America/New_York"))
|
||||
|
||||
latest = _latest_backtest_date(now_et)
|
||||
|
||||
assert latest.isoformat() == "2026-04-14"
|
||||
|
||||
|
||||
def test_latest_backtest_date_uses_last_session_on_weekend() -> None:
|
||||
now_et = datetime(2026, 4, 18, 10, 0, tzinfo=ZoneInfo("America/New_York"))
|
||||
|
||||
latest = _latest_backtest_date(now_et)
|
||||
|
||||
assert latest.isoformat() == "2026-04-18"
|
||||
|
||||
|
||||
def test_latest_completed_trading_day_walks_back_on_weekend() -> None:
|
||||
now_et = datetime(2026, 4, 18, 10, 0, tzinfo=ZoneInfo("America/New_York"))
|
||||
|
||||
latest = _latest_completed_trading_day(now_et)
|
||||
|
||||
assert latest.isoformat() == "2026-04-17"
|
||||
|
||||
|
||||
def test_load_vix_from_local_macro_snapshots_uses_covering_file(tmp_path, monkeypatch) -> None:
|
||||
parquet_dir = tmp_path / "parquet" / "sample"
|
||||
parquet_dir.mkdir(parents=True, exist_ok=True)
|
||||
payload = {
|
||||
datetime(2025, 1, 2).date(): {"VIXCLS": 17.1},
|
||||
datetime(2025, 1, 3).date(): {"VIXCLS": 18.2},
|
||||
datetime(2025, 1, 6).date(): {"VIXCLS": 19.3},
|
||||
}
|
||||
path = parquet_dir / "macro_window_2025-01-01_2025-01-10.pkl"
|
||||
with path.open("wb") as fh:
|
||||
pickle.dump(payload, fh, protocol=pickle.HIGHEST_PROTOCOL)
|
||||
|
||||
monkeypatch.setattr(
|
||||
run_mod,
|
||||
"get_settings",
|
||||
lambda: SimpleNamespace(data_root=str(tmp_path)),
|
||||
)
|
||||
|
||||
result = _load_vix_from_local_macro_snapshots(["2025-01-02", "2025-01-03", "2025-01-06"])
|
||||
|
||||
assert result == {
|
||||
"2025-01-02": 17.1,
|
||||
"2025-01-03": 18.2,
|
||||
"2025-01-06": 19.3,
|
||||
}
|
||||
|
||||
|
||||
def test_fetch_vix_by_day_uses_local_snapshot_when_health_check_fails(tmp_path, monkeypatch) -> None:
|
||||
parquet_dir = tmp_path / "parquet" / "sample"
|
||||
parquet_dir.mkdir(parents=True, exist_ok=True)
|
||||
payload = {
|
||||
datetime(2025, 1, 2).date(): {"VIXCLS": 17.1},
|
||||
datetime(2025, 1, 3).date(): {"VIXCLS": 18.2},
|
||||
}
|
||||
path = parquet_dir / "macro_window_2025-01-01_2025-01-10.pkl"
|
||||
with path.open("wb") as fh:
|
||||
pickle.dump(payload, fh, protocol=pickle.HIGHEST_PROTOCOL)
|
||||
|
||||
monkeypatch.setattr(
|
||||
run_mod,
|
||||
"get_settings",
|
||||
lambda: SimpleNamespace(data_root=str(tmp_path)),
|
||||
)
|
||||
|
||||
class _Client:
|
||||
async def health_check_fast(self, timeout: float = 3.0) -> bool:
|
||||
return False
|
||||
|
||||
result = asyncio.run(_fetch_vix_by_day(_Client(), ["2025-01-02", "2025-01-03"]))
|
||||
|
||||
assert result == {
|
||||
"2025-01-02": 17.1,
|
||||
"2025-01-03": 18.2,
|
||||
}
|
||||
|
||||
|
||||
def test_load_ticker_sectors_with_oracle_backfills_missing_cache(tmp_path, monkeypatch) -> None:
|
||||
cache_path = tmp_path / "cache" / "sector_cache.json"
|
||||
cache_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
cache_path.write_text('{"TSLA": "Consumer Cyclical"}')
|
||||
|
||||
monkeypatch.setattr(
|
||||
run_mod,
|
||||
"get_settings",
|
||||
lambda: SimpleNamespace(data_root=str(tmp_path)),
|
||||
)
|
||||
|
||||
class FakeCompanyService:
|
||||
def __init__(self, client) -> None:
|
||||
self.client = client
|
||||
|
||||
async def get_company(self, symbol: str):
|
||||
if symbol == "CPRX":
|
||||
return SimpleNamespace(
|
||||
sector="Healthcare",
|
||||
industry="Biotechnology",
|
||||
exchange="NASDAQ",
|
||||
market_cap=2_979_108_098.0,
|
||||
)
|
||||
raise AssertionError(f"unexpected symbol {symbol}")
|
||||
|
||||
monkeypatch.setattr(run_mod, "CompanyService", FakeCompanyService)
|
||||
|
||||
result = asyncio.run(
|
||||
run_mod._load_ticker_sectors_with_oracle(["TSLA", "CPRX"], client=object())
|
||||
)
|
||||
|
||||
assert result == {
|
||||
"TSLA": "Consumer Cyclical",
|
||||
"CPRX": "Healthcare",
|
||||
}
|
||||
assert cache_path.exists()
|
||||
assert '"CPRX": "Healthcare"' in cache_path.read_text()
|
||||
|
||||
|
||||
def test_load_ticker_sectors_with_oracle_skips_placeholder_metadata(tmp_path, monkeypatch) -> None:
|
||||
cache_path = tmp_path / "cache" / "sector_cache.json"
|
||||
cache_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
cache_path.write_text("{}")
|
||||
|
||||
monkeypatch.setattr(
|
||||
run_mod,
|
||||
"get_settings",
|
||||
lambda: SimpleNamespace(data_root=str(tmp_path)),
|
||||
)
|
||||
|
||||
class FakeCompanyService:
|
||||
def __init__(self, client) -> None:
|
||||
self.client = client
|
||||
|
||||
async def get_company(self, symbol: str):
|
||||
return SimpleNamespace(
|
||||
sector="Technology",
|
||||
industry="Software",
|
||||
exchange=None,
|
||||
market_cap=None,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(run_mod, "CompanyService", FakeCompanyService)
|
||||
|
||||
result = asyncio.run(
|
||||
run_mod._load_ticker_sectors_with_oracle(["AVGO"], client=object())
|
||||
)
|
||||
|
||||
assert result == {"AVGO": "UNKNOWN"}
|
||||
assert cache_path.read_text() == "{}"
|
||||
|
||||
|
||||
def test_should_use_recent_live_scan_only_for_small_recent_windows(monkeypatch) -> None:
|
||||
monkeypatch.setattr(
|
||||
run_mod,
|
||||
"_latest_backtest_date",
|
||||
lambda now_et=None: datetime(2026, 4, 16, tzinfo=ZoneInfo("America/New_York")).date(),
|
||||
)
|
||||
strategy = StrategyParams(recent_live_scan_days=5)
|
||||
|
||||
assert _should_use_recent_live_scan(strategy, ["2026-04-15", "2026-04-16"]) is True
|
||||
assert _should_use_recent_live_scan(
|
||||
strategy,
|
||||
["2026-04-09", "2026-04-10", "2026-04-13", "2026-04-14", "2026-04-15", "2026-04-16"],
|
||||
) is False
|
||||
assert _should_use_recent_live_scan(strategy, ["2026-03-01"]) is False
|
||||
|
||||
|
||||
def test_momentum_strategy_uses_intraday_event_weight_for_fetch_activation() -> None:
|
||||
strategy = StrategyParams(candidate_intraday_weight_event_score=0.05)
|
||||
|
||||
assert _momentum_strategy_uses_catalyst(strategy) is True
|
||||
|
||||
|
||||
def test_momentum_strategy_uses_intraday_attention_weight_for_fetch_activation() -> None:
|
||||
strategy = StrategyParams(candidate_intraday_weight_attention_news=0.03)
|
||||
|
||||
assert _momentum_strategy_uses_attention(strategy) is True
|
||||
|
||||
|
||||
def test_momentum_intraday_seed_candidates_only_apply_signal_filters_in_final_pass() -> None:
|
||||
daily_bars = {
|
||||
"AAA": [
|
||||
{"date": "2026-01-05", "open": 10.0},
|
||||
],
|
||||
"BBB": [
|
||||
{"date": "2026-01-05", "open": 10.0},
|
||||
],
|
||||
}
|
||||
enrichment = {
|
||||
"AAA": {
|
||||
"2026-01-05": {
|
||||
"gap_pct": 0.03,
|
||||
"event_flag": True,
|
||||
"event_score": 1.0,
|
||||
"ret_5d": 0.01,
|
||||
"entropy_20d": 0.4,
|
||||
"avg_dollar_vol_30d": 1_000_000.0,
|
||||
"atr_14": 1.0,
|
||||
}
|
||||
},
|
||||
"BBB": {
|
||||
"2026-01-05": {
|
||||
"gap_pct": 0.04,
|
||||
"event_flag": False,
|
||||
"event_score": 0.0,
|
||||
"ret_5d": 0.01,
|
||||
"entropy_20d": 0.4,
|
||||
"avg_dollar_vol_30d": 1_000_000.0,
|
||||
"atr_14": 1.0,
|
||||
}
|
||||
},
|
||||
}
|
||||
strategy = StrategyParams(
|
||||
candidate_source_mode="intraday_first",
|
||||
candidate_seed_threshold=0.02,
|
||||
candidate_seed_max_per_day=1,
|
||||
candidate_require_event_flag=True,
|
||||
candidate_weight_event_score=1.0,
|
||||
)
|
||||
|
||||
preliminary = _momentum_intraday_seed_candidates(
|
||||
daily_bars,
|
||||
["2026-01-05"],
|
||||
enrichment,
|
||||
strategy,
|
||||
default_threshold=0.02,
|
||||
use_signal_features=False,
|
||||
)
|
||||
final_seed = _momentum_intraday_seed_candidates(
|
||||
daily_bars,
|
||||
["2026-01-05"],
|
||||
enrichment,
|
||||
strategy,
|
||||
default_threshold=0.02,
|
||||
use_signal_features=True,
|
||||
)
|
||||
|
||||
assert preliminary == {"2026-01-05": ["BBB"]}
|
||||
assert final_seed == {"2026-01-05": ["AAA"]}
|
||||
|
||||
|
||||
def test_recent_intraday_first_candidates_uses_intraday_leaders_without_static_universe() -> None:
|
||||
strategy = StrategyParams(
|
||||
entry_minutes_after_open=10,
|
||||
confirmation_minutes_after_entry=5,
|
||||
min_entry_volume=250000,
|
||||
min_entry_dollar_volume=2_000_000,
|
||||
top_n=8,
|
||||
recent_live_scan_max_candidates_per_day=10,
|
||||
)
|
||||
day = "2026-04-16"
|
||||
bars = {
|
||||
day: {
|
||||
"XNDU": [
|
||||
{"timestamp": "2026-04-16T13:30:00+00:00", "open": 2.00, "high": 2.06, "low": 1.98, "close": 2.05, "volume": 150000},
|
||||
{"timestamp": "2026-04-16T13:35:00+00:00", "open": 2.05, "high": 2.12, "low": 2.04, "close": 2.11, "volume": 175000},
|
||||
{"timestamp": "2026-04-16T13:40:00+00:00", "open": 2.11, "high": 2.15, "low": 2.10, "close": 2.14, "volume": 180000},
|
||||
{"timestamp": "2026-04-16T13:45:00+00:00", "open": 2.14, "high": 2.20, "low": 2.13, "close": 2.19, "volume": 200000},
|
||||
{"timestamp": "2026-04-16T13:50:00+00:00", "open": 2.19, "high": 2.24, "low": 2.18, "close": 2.22, "volume": 210000},
|
||||
],
|
||||
"SLOW": [
|
||||
{"timestamp": "2026-04-16T13:30:00+00:00", "open": 20.00, "high": 20.01, "low": 19.95, "close": 19.98, "volume": 50000},
|
||||
{"timestamp": "2026-04-16T13:35:00+00:00", "open": 19.98, "high": 20.00, "low": 19.90, "close": 19.95, "volume": 50000},
|
||||
{"timestamp": "2026-04-16T13:40:00+00:00", "open": 19.95, "high": 19.99, "low": 19.92, "close": 19.97, "volume": 50000},
|
||||
{"timestamp": "2026-04-16T13:45:00+00:00", "open": 19.97, "high": 19.98, "low": 19.94, "close": 19.96, "volume": 50000},
|
||||
{"timestamp": "2026-04-16T13:50:00+00:00", "open": 19.96, "high": 19.97, "low": 19.93, "close": 19.95, "volume": 50000},
|
||||
{"timestamp": "2026-04-16T13:55:00+00:00", "open": 19.95, "high": 19.96, "low": 19.92, "close": 19.94, "volume": 50000},
|
||||
],
|
||||
"LIQUID": [
|
||||
{"timestamp": "2026-04-16T13:30:00+00:00", "open": 400.00, "high": 401.00, "low": 399.00, "close": 400.20, "volume": 80000},
|
||||
{"timestamp": "2026-04-16T13:35:00+00:00", "open": 400.20, "high": 401.20, "low": 400.10, "close": 400.80, "volume": 90000},
|
||||
{"timestamp": "2026-04-16T13:40:00+00:00", "open": 400.80, "high": 402.50, "low": 400.70, "close": 402.20, "volume": 120000},
|
||||
{"timestamp": "2026-04-16T13:45:00+00:00", "open": 402.20, "high": 403.20, "low": 401.90, "close": 402.80, "volume": 130000},
|
||||
{"timestamp": "2026-04-16T13:50:00+00:00", "open": 402.80, "high": 404.00, "low": 402.70, "close": 403.60, "volume": 140000},
|
||||
{"timestamp": "2026-04-16T13:55:00+00:00", "open": 403.60, "high": 404.20, "low": 403.40, "close": 404.00, "volume": 150000},
|
||||
],
|
||||
"MEGA": [
|
||||
{"timestamp": "2026-04-16T13:30:00+00:00", "open": 500.00, "high": 500.80, "low": 499.50, "close": 500.10, "volume": 120000},
|
||||
{"timestamp": "2026-04-16T13:35:00+00:00", "open": 500.10, "high": 500.90, "low": 500.00, "close": 500.40, "volume": 140000},
|
||||
{"timestamp": "2026-04-16T13:40:00+00:00", "open": 500.40, "high": 501.10, "low": 500.20, "close": 500.70, "volume": 150000},
|
||||
{"timestamp": "2026-04-16T13:45:00+00:00", "open": 500.70, "high": 501.30, "low": 500.50, "close": 500.90, "volume": 160000},
|
||||
{"timestamp": "2026-04-16T13:50:00+00:00", "open": 500.90, "high": 501.50, "low": 500.80, "close": 501.20, "volume": 180000},
|
||||
{"timestamp": "2026-04-16T13:55:00+00:00", "open": 501.20, "high": 501.70, "low": 501.00, "close": 501.40, "volume": 190000},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
candidates = _recent_intraday_first_candidates(bars, [day], strategy)
|
||||
|
||||
assert "XNDU" in candidates[day]
|
||||
assert "LIQUID" in candidates[day]
|
||||
assert "MEGA" in candidates[day]
|
||||
assert "SLOW" not in candidates[day]
|
||||
|
||||
|
||||
def test_recent_intraday_first_scan_only_applies_to_latest_safe_day(monkeypatch) -> None:
|
||||
assert _should_use_recent_intraday_first_scan(["2026-04-16"]) is True
|
||||
assert _should_use_recent_intraday_first_scan(["2026-04-15"]) is True
|
||||
|
||||
|
||||
def test_recent_intraday_first_scan_treats_latest_completed_weekday_as_recent(monkeypatch) -> None:
|
||||
assert _should_use_recent_intraday_first_scan(["2026-04-17"]) is True
|
||||
|
||||
|
||||
def test_recent_intraday_first_scan_false_for_empty_window() -> None:
|
||||
assert _should_use_recent_intraday_first_scan([]) is False
|
||||
|
||||
|
||||
def test_liquid_seed_overlay_adds_liquid_name_without_replacing_base_seed() -> None:
|
||||
strategy = StrategyParams(
|
||||
candidate_seed_liquid_overlay_slots=1,
|
||||
candidate_seed_liquid_min_gap_pct=0.005,
|
||||
candidate_seed_liquid_max_gap_pct=0.03,
|
||||
candidate_seed_liquid_min_avg_dollar_vol_30d=500_000_000.0,
|
||||
candidate_seed_liquid_min_ret_5d=0.0,
|
||||
candidate_seed_liquid_max_entropy_20d=0.90,
|
||||
)
|
||||
daily_bars = {
|
||||
"BASE": [{"date": "2026-04-15", "open": 50.0}],
|
||||
"TSLA": [{"date": "2026-04-15", "open": 250.0}],
|
||||
"NOPE": [{"date": "2026-04-15", "open": 30.0}],
|
||||
}
|
||||
enrichment = {
|
||||
"BASE": {"2026-04-15": {"gap_pct": 0.03, "avg_dollar_vol_30d": 200_000_000.0, "ret_5d": 0.02, "entropy_20d": 0.40}},
|
||||
"TSLA": {"2026-04-15": {"gap_pct": 0.007, "avg_dollar_vol_30d": 1_200_000_000.0, "ret_5d": 0.05, "entropy_20d": 0.70}},
|
||||
"NOPE": {"2026-04-15": {"gap_pct": 0.009, "avg_dollar_vol_30d": 200_000_000.0, "ret_5d": 0.05, "entropy_20d": 0.50}},
|
||||
}
|
||||
candidates = {"2026-04-15": ["BASE"]}
|
||||
|
||||
augmented = _augment_momentum_seed_candidates_with_liquid_overlay(
|
||||
candidates,
|
||||
daily_bars,
|
||||
["2026-04-15"],
|
||||
enrichment,
|
||||
strategy,
|
||||
)
|
||||
|
||||
assert augmented["2026-04-15"] == ["BASE", "TSLA"]
|
||||
|
||||
|
||||
def test_strategy_for_recent_live_scan_applies_only_recent_overrides() -> None:
|
||||
strategy = StrategyParams(
|
||||
top_n=8,
|
||||
min_morning_gain_pct=0.015,
|
||||
max_morning_gain_pct=0.06,
|
||||
min_confirmation_return_pct=0.005,
|
||||
max_gap_pct=0.055,
|
||||
use_slow_ignite_sleeve=False,
|
||||
recent_live_scan_top_n=10,
|
||||
recent_live_scan_min_morning_gain_pct=0.01,
|
||||
recent_live_scan_max_morning_gain_pct=0.05,
|
||||
recent_live_scan_min_confirmation_return_pct=0.001,
|
||||
recent_live_scan_max_gap_pct=0.04,
|
||||
recent_live_scan_max_entropy_20d=0.9,
|
||||
recent_live_scan_use_slow_ignite_sleeve=True,
|
||||
recent_live_scan_slow_ignite_weight=0.2,
|
||||
recent_live_scan_use_liquid_largecap_sleeve=True,
|
||||
recent_live_scan_liquid_largecap_weight=0.3,
|
||||
recent_live_scan_liquid_largecap_min_gain_pct=0.004,
|
||||
recent_live_scan_liquid_largecap_max_gain_pct=0.02,
|
||||
recent_live_scan_liquid_largecap_min_confirmation_return_pct=0.0005,
|
||||
recent_live_scan_liquid_largecap_min_entry_dollar_volume=50_000_000.0,
|
||||
recent_live_scan_liquid_largecap_min_avg_dollar_vol_30d=500_000_000.0,
|
||||
recent_live_scan_liquid_largecap_max_entropy_20d=0.9,
|
||||
)
|
||||
|
||||
unchanged = _strategy_for_recent_live_scan(strategy, recent_live_scan=False)
|
||||
recent = _strategy_for_recent_live_scan(strategy, recent_live_scan=True)
|
||||
|
||||
assert unchanged.top_n == 8
|
||||
assert unchanged.min_morning_gain_pct == 0.015
|
||||
assert unchanged.max_morning_gain_pct == 0.06
|
||||
assert unchanged.max_gap_pct == 0.055
|
||||
assert unchanged.use_slow_ignite_sleeve is False
|
||||
assert recent.top_n == 10
|
||||
assert recent.min_morning_gain_pct == 0.01
|
||||
assert recent.max_morning_gain_pct == 0.05
|
||||
assert recent.min_confirmation_return_pct == 0.001
|
||||
assert recent.max_gap_pct == 0.04
|
||||
assert recent.max_entropy_20d == 0.9
|
||||
assert recent.use_slow_ignite_sleeve is True
|
||||
assert recent.use_liquid_largecap_sleeve is True
|
||||
assert recent.liquid_largecap_weight == 0.3
|
||||
assert recent.liquid_largecap_min_gain_pct == 0.004
|
||||
assert recent.liquid_largecap_max_gain_pct == 0.02
|
||||
assert recent.liquid_largecap_min_confirmation_return_pct == 0.0005
|
||||
assert recent.slow_ignite_weight == 0.2
|
||||
|
||||
|
||||
def test_augment_momentum_seed_candidates_with_leader_overlay_adds_negative_gap_continuation_name() -> None:
|
||||
strategy = StrategyParams(
|
||||
candidate_seed_leader_overlay_slots=1,
|
||||
candidate_seed_leader_min_gap_pct=-0.01,
|
||||
candidate_seed_leader_max_gap_pct=0.01,
|
||||
candidate_seed_leader_min_avg_dollar_vol_30d=500_000_000.0,
|
||||
candidate_seed_leader_min_ret_5d=0.15,
|
||||
candidate_seed_leader_min_atr_pct=0.05,
|
||||
candidate_seed_leader_max_entropy_20d=0.75,
|
||||
)
|
||||
daily_bars = {
|
||||
"BASE": [{"date": "2026-04-20", "open": 50.0}],
|
||||
"CAR": [{"date": "2026-04-20", "open": 491.26}],
|
||||
"NOPE": [{"date": "2026-04-20", "open": 300.0}],
|
||||
}
|
||||
enrichment = {
|
||||
"BASE": {"2026-04-20": {"gap_pct": 0.03, "avg_dollar_vol_30d": 200_000_000.0, "ret_5d": 0.02, "entropy_20d": 0.40, "atr_14": 2.0}},
|
||||
"CAR": {"2026-04-20": {"gap_pct": -0.005, "avg_dollar_vol_30d": 730_000_000.0, "ret_5d": 0.33, "entropy_20d": 0.44, "atr_14": 51.8}},
|
||||
"NOPE": {"2026-04-20": {"gap_pct": 0.0, "avg_dollar_vol_30d": 450_000_000.0, "ret_5d": 0.18, "entropy_20d": 0.55, "atr_14": 5.0}},
|
||||
}
|
||||
candidates = {"2026-04-20": ["BASE"]}
|
||||
|
||||
augmented = _augment_momentum_seed_candidates_with_liquid_overlay(
|
||||
candidates,
|
||||
daily_bars,
|
||||
["2026-04-20"],
|
||||
enrichment,
|
||||
strategy,
|
||||
)
|
||||
|
||||
assert augmented["2026-04-20"] == ["BASE", "CAR"]
|
||||
|
||||
|
||||
def test_retain_recent_intraday_shortlist_preserves_intraday_candidates() -> None:
|
||||
candidates = {"2026-04-15": ["TSLA", "XNDU", "AXTI"]}
|
||||
daily_bars = {"TSLA": [{"date": "2026-04-15"}], "AXTI": [{"date": "2026-04-15"}]}
|
||||
|
||||
retained = _retain_recent_intraday_shortlist(
|
||||
candidates,
|
||||
daily_bars,
|
||||
require_daily_features=True,
|
||||
)
|
||||
|
||||
assert retained == {"2026-04-15": ["TSLA", "AXTI"]}
|
||||
|
||||
|
||||
def test_momentum_strategy_defaults_to_simple_returns_and_cli_can_override() -> None:
|
||||
config = load_config("configs/intraday/strategies/leader_intraday_momentum_high_wr.yaml")
|
||||
|
||||
assert config.strategy.compound_returns is False
|
||||
|
||||
args = SimpleNamespace(
|
||||
days=None,
|
||||
start=None,
|
||||
end=None,
|
||||
universe=None,
|
||||
top_n=None,
|
||||
stop_loss=None,
|
||||
entry_min=None,
|
||||
exit_min=None,
|
||||
min_gain=None,
|
||||
no_cache=False,
|
||||
verbose=False,
|
||||
output_dir=None,
|
||||
strategy="momentum",
|
||||
compound_returns=True,
|
||||
initial_capital=None,
|
||||
)
|
||||
|
||||
updated = apply_cli_overrides(config, args)
|
||||
|
||||
assert updated.strategy.compound_returns is True
|
||||
@ -0,0 +1,940 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from types import SimpleNamespace
|
||||
|
||||
from libs.intraday.cache import DailyBarCache, IntradayCache
|
||||
from libs.intraday import screener as screener_module
|
||||
from libs.intraday.domain import StrategyParams, UniverseParams
|
||||
from libs.intraday.screener import (
|
||||
fetch_daily_bars_bulk,
|
||||
momentum_intraday_first_candidates,
|
||||
momentum_pre_screen_candidates,
|
||||
orb_pre_screen_candidates,
|
||||
pre_screen_candidates,
|
||||
resolve_universe,
|
||||
)
|
||||
|
||||
|
||||
def test_pre_screen_candidates_uses_opening_gap_not_same_day_high() -> None:
|
||||
daily_bars = {
|
||||
"AAA": [
|
||||
{"date": "2026-01-02", "open": 100.0, "high": 102.0, "low": 99.0, "close": 100.0, "volume": 1_000},
|
||||
{"date": "2026-01-05", "open": 100.0, "high": 130.0, "low": 95.0, "close": 96.0, "volume": 2_000},
|
||||
],
|
||||
"BBB": [
|
||||
{"date": "2026-01-02", "open": 50.0, "high": 51.0, "low": 49.0, "close": 50.0, "volume": 1_000},
|
||||
{"date": "2026-01-05", "open": 51.5, "high": 53.0, "low": 51.0, "close": 52.0, "volume": 2_000},
|
||||
],
|
||||
}
|
||||
|
||||
result = pre_screen_candidates(
|
||||
daily_bars,
|
||||
["2026-01-05"],
|
||||
threshold=0.02,
|
||||
max_per_day=30,
|
||||
)
|
||||
|
||||
assert result == {"2026-01-05": ["BBB"]}
|
||||
|
||||
|
||||
def test_pre_screen_candidates_can_use_precomputed_gap_enrichment() -> None:
|
||||
daily_bars = {
|
||||
"AAA": [
|
||||
{"date": "2026-01-02", "open": 100.0, "high": 102.0, "low": 99.0, "close": 100.0, "volume": 1_000},
|
||||
{"date": "2026-01-05", "open": 100.0, "high": 130.0, "low": 95.0, "close": 96.0, "volume": 2_000},
|
||||
],
|
||||
"BBB": [
|
||||
{"date": "2026-01-02", "open": 50.0, "high": 51.0, "low": 49.0, "close": 50.0, "volume": 1_000},
|
||||
{"date": "2026-01-05", "open": 51.5, "high": 53.0, "low": 51.0, "close": 52.0, "volume": 2_000},
|
||||
],
|
||||
}
|
||||
enrichment = {
|
||||
"AAA": {"2026-01-05": {"gap_pct": 0.0}},
|
||||
"BBB": {"2026-01-05": {"gap_pct": 0.03}},
|
||||
}
|
||||
|
||||
result = pre_screen_candidates(
|
||||
daily_bars,
|
||||
["2026-01-05"],
|
||||
threshold=0.02,
|
||||
max_per_day=30,
|
||||
enrichment=enrichment,
|
||||
)
|
||||
|
||||
assert result == {"2026-01-05": ["BBB"]}
|
||||
|
||||
|
||||
def test_momentum_pre_screen_candidates_ranks_by_gap_then_prior_features() -> None:
|
||||
daily_bars = {
|
||||
"AAA": [
|
||||
{"date": "2026-01-02", "open": 10.0, "high": 10.2, "low": 9.8, "close": 10.0, "volume": 1_000},
|
||||
{"date": "2026-01-05", "open": 10.3, "high": 10.8, "low": 10.2, "close": 10.6, "volume": 2_000},
|
||||
],
|
||||
"BBB": [
|
||||
{"date": "2026-01-02", "open": 10.0, "high": 10.1, "low": 9.9, "close": 10.0, "volume": 1_000},
|
||||
{"date": "2026-01-05", "open": 10.3, "high": 10.5, "low": 10.1, "close": 10.2, "volume": 2_000},
|
||||
],
|
||||
"CCC": [
|
||||
{"date": "2026-01-02", "open": 10.0, "high": 10.1, "low": 9.9, "close": 10.0, "volume": 1_000},
|
||||
{"date": "2026-01-05", "open": 10.5, "high": 10.7, "low": 10.4, "close": 10.6, "volume": 2_000},
|
||||
],
|
||||
}
|
||||
enrichment = {
|
||||
"AAA": {
|
||||
"2026-01-05": {
|
||||
"gap_pct": 0.03,
|
||||
"ret_5d": 0.10,
|
||||
"entropy_20d": 0.50,
|
||||
"avg_dollar_vol_30d": 50_000_000.0,
|
||||
"atr_14": 1.5,
|
||||
}
|
||||
},
|
||||
"BBB": {
|
||||
"2026-01-05": {
|
||||
"gap_pct": 0.03,
|
||||
"ret_5d": 0.02,
|
||||
"entropy_20d": 0.70,
|
||||
"avg_dollar_vol_30d": 30_000_000.0,
|
||||
"atr_14": 1.0,
|
||||
}
|
||||
},
|
||||
"CCC": {
|
||||
"2026-01-05": {
|
||||
"gap_pct": 0.05,
|
||||
"ret_5d": -0.01,
|
||||
"entropy_20d": 0.80,
|
||||
"avg_dollar_vol_30d": 10_000_000.0,
|
||||
"atr_14": 0.8,
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
result = momentum_pre_screen_candidates(
|
||||
daily_bars,
|
||||
["2026-01-05"],
|
||||
enrichment,
|
||||
threshold=0.02,
|
||||
max_per_day=3,
|
||||
)
|
||||
|
||||
assert result == {"2026-01-05": ["CCC", "AAA", "BBB"]}
|
||||
|
||||
|
||||
def test_momentum_pre_screen_candidates_can_require_event_and_attention() -> None:
|
||||
daily_bars = {
|
||||
"AAA": [
|
||||
{"date": "2026-01-02", "open": 10.0, "high": 10.2, "low": 9.8, "close": 10.0, "volume": 1_000},
|
||||
{"date": "2026-01-05", "open": 10.3, "high": 10.8, "low": 10.2, "close": 10.6, "volume": 2_000},
|
||||
],
|
||||
"BBB": [
|
||||
{"date": "2026-01-02", "open": 10.0, "high": 10.1, "low": 9.9, "close": 10.0, "volume": 1_000},
|
||||
{"date": "2026-01-05", "open": 10.4, "high": 10.6, "low": 10.3, "close": 10.5, "volume": 2_000},
|
||||
],
|
||||
}
|
||||
enrichment = {
|
||||
"AAA": {
|
||||
"2026-01-05": {
|
||||
"gap_pct": 0.03,
|
||||
"ret_5d": 0.02,
|
||||
"entropy_20d": 0.70,
|
||||
"avg_dollar_vol_30d": 20_000_000.0,
|
||||
"atr_14": 1.0,
|
||||
"event_flag": True,
|
||||
"event_score": 1.0,
|
||||
"attention_wiki_spike_10d": 2.0,
|
||||
"attention_article_count_3d": 5,
|
||||
"attention_us_article_count_3d": 4,
|
||||
"attention_resolver_confidence": 0.9,
|
||||
}
|
||||
},
|
||||
"BBB": {
|
||||
"2026-01-05": {
|
||||
"gap_pct": 0.04,
|
||||
"ret_5d": 0.04,
|
||||
"entropy_20d": 0.50,
|
||||
"avg_dollar_vol_30d": 25_000_000.0,
|
||||
"atr_14": 1.2,
|
||||
"event_flag": False,
|
||||
"event_score": 0.0,
|
||||
"attention_wiki_spike_10d": 0.2,
|
||||
"attention_article_count_3d": 0,
|
||||
"attention_us_article_count_3d": 0,
|
||||
"attention_resolver_confidence": 0.2,
|
||||
}
|
||||
},
|
||||
}
|
||||
strategy = StrategyParams(
|
||||
candidate_require_event_flag=True,
|
||||
candidate_min_attention_wiki_spike_10d=1.0,
|
||||
candidate_weight_event_score=1.0,
|
||||
candidate_weight_attention_wiki=1.0,
|
||||
candidate_weight_attention_news=1.0,
|
||||
)
|
||||
|
||||
result = momentum_pre_screen_candidates(
|
||||
daily_bars,
|
||||
["2026-01-05"],
|
||||
enrichment,
|
||||
threshold=0.02,
|
||||
max_per_day=5,
|
||||
strategy=strategy,
|
||||
)
|
||||
|
||||
assert result == {"2026-01-05": ["AAA"]}
|
||||
|
||||
|
||||
def test_momentum_intraday_first_candidates_uses_entry_time_info_only() -> None:
|
||||
strategy = StrategyParams(
|
||||
candidate_source_mode="intraday_first",
|
||||
entry_minutes_after_open=10,
|
||||
confirmation_minutes_after_entry=5,
|
||||
min_confirmation_return_pct=0.0,
|
||||
min_morning_gain_pct=0.01,
|
||||
min_entry_volume=50_000,
|
||||
candidate_final_max_per_day=2,
|
||||
candidate_require_event_flag=True,
|
||||
)
|
||||
all_intraday = {
|
||||
"2026-01-05": {
|
||||
"AAA": [
|
||||
{"timestamp": "2026-01-05T14:30:00+00:00", "open": 10.0, "high": 10.2, "low": 9.9, "close": 10.1, "volume": 30_000},
|
||||
{"timestamp": "2026-01-05T14:35:00+00:00", "open": 10.1, "high": 10.3, "low": 10.0, "close": 10.2, "volume": 30_000},
|
||||
{"timestamp": "2026-01-05T14:40:00+00:00", "open": 10.2, "high": 10.5, "low": 10.1, "close": 10.4, "volume": 30_000},
|
||||
{"timestamp": "2026-01-05T14:45:00+00:00", "open": 10.4, "high": 10.7, "low": 10.3, "close": 10.6, "volume": 30_000},
|
||||
{"timestamp": "2026-01-05T14:50:00+00:00", "open": 10.6, "high": 10.8, "low": 10.5, "close": 10.7, "volume": 30_000},
|
||||
],
|
||||
"BBB": [
|
||||
{"timestamp": "2026-01-05T14:30:00+00:00", "open": 20.0, "high": 20.1, "low": 19.9, "close": 20.0, "volume": 40_000},
|
||||
{"timestamp": "2026-01-05T14:35:00+00:00", "open": 20.0, "high": 20.2, "low": 19.9, "close": 20.1, "volume": 40_000},
|
||||
{"timestamp": "2026-01-05T14:40:00+00:00", "open": 20.1, "high": 20.7, "low": 20.0, "close": 20.5, "volume": 40_000},
|
||||
{"timestamp": "2026-01-05T14:45:00+00:00", "open": 20.5, "high": 21.0, "low": 20.4, "close": 20.9, "volume": 40_000},
|
||||
{"timestamp": "2026-01-05T14:50:00+00:00", "open": 20.9, "high": 21.3, "low": 20.8, "close": 21.1, "volume": 40_000},
|
||||
],
|
||||
}
|
||||
}
|
||||
daily_enrichment = {
|
||||
"AAA": {"2026-01-05": {"event_flag": False, "gap_pct": 0.01, "avg_daily_vol_14d": 1_000_000.0}},
|
||||
"BBB": {"2026-01-05": {"event_flag": True, "event_score": 1.0, "gap_pct": 0.01, "avg_daily_vol_14d": 1_000_000.0}},
|
||||
}
|
||||
|
||||
result = momentum_intraday_first_candidates(
|
||||
all_intraday,
|
||||
["2026-01-05"],
|
||||
strategy,
|
||||
daily_enrichment=daily_enrichment,
|
||||
max_per_day=2,
|
||||
)
|
||||
|
||||
assert result == {"2026-01-05": ["BBB"]}
|
||||
|
||||
|
||||
def test_momentum_intraday_first_candidates_can_use_weighted_ranking() -> None:
|
||||
strategy = StrategyParams(
|
||||
candidate_source_mode="intraday_first",
|
||||
candidate_intraday_rank_mode="weighted",
|
||||
candidate_intraday_weight_gain=0.2,
|
||||
candidate_intraday_weight_confirmation=0.5,
|
||||
candidate_intraday_weight_volume_ratio=0.2,
|
||||
candidate_intraday_weight_entry_dollar_volume=0.1,
|
||||
entry_minutes_after_open=10,
|
||||
confirmation_minutes_after_entry=5,
|
||||
min_confirmation_return_pct=0.0,
|
||||
min_morning_gain_pct=0.01,
|
||||
candidate_final_max_per_day=1,
|
||||
)
|
||||
all_intraday = {
|
||||
"2026-01-05": {
|
||||
"AAA": [
|
||||
{"timestamp": "2026-01-05T14:30:00+00:00", "open": 10.0, "high": 10.1, "low": 9.9, "close": 10.0, "volume": 80_000},
|
||||
{"timestamp": "2026-01-05T14:35:00+00:00", "open": 10.0, "high": 10.2, "low": 10.0, "close": 10.1, "volume": 80_000},
|
||||
{"timestamp": "2026-01-05T14:40:00+00:00", "open": 10.1, "high": 10.4, "low": 10.0, "close": 10.2, "volume": 80_000},
|
||||
{"timestamp": "2026-01-05T14:45:00+00:00", "open": 10.2, "high": 10.8, "low": 10.2, "close": 10.7, "volume": 80_000},
|
||||
{"timestamp": "2026-01-05T14:50:00+00:00", "open": 10.7, "high": 10.9, "low": 10.6, "close": 10.8, "volume": 80_000},
|
||||
],
|
||||
"BBB": [
|
||||
{"timestamp": "2026-01-05T14:30:00+00:00", "open": 20.0, "high": 20.2, "low": 19.9, "close": 20.1, "volume": 70_000},
|
||||
{"timestamp": "2026-01-05T14:35:00+00:00", "open": 20.1, "high": 20.4, "low": 20.0, "close": 20.3, "volume": 70_000},
|
||||
{"timestamp": "2026-01-05T14:40:00+00:00", "open": 20.3, "high": 21.0, "low": 20.2, "close": 20.9, "volume": 70_000},
|
||||
{"timestamp": "2026-01-05T14:45:00+00:00", "open": 20.9, "high": 21.0, "low": 20.7, "close": 20.95, "volume": 70_000},
|
||||
{"timestamp": "2026-01-05T14:50:00+00:00", "open": 20.95, "high": 21.1, "low": 20.8, "close": 21.0, "volume": 70_000},
|
||||
],
|
||||
}
|
||||
}
|
||||
daily_enrichment = {
|
||||
"AAA": {
|
||||
"2026-01-05": {
|
||||
"gap_pct": 0.01,
|
||||
"avg_daily_vol_14d": 1_000_000.0,
|
||||
}
|
||||
},
|
||||
"BBB": {
|
||||
"2026-01-05": {
|
||||
"gap_pct": 0.01,
|
||||
"avg_daily_vol_14d": 1_000_000.0,
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
result = momentum_intraday_first_candidates(
|
||||
all_intraday,
|
||||
["2026-01-05"],
|
||||
strategy,
|
||||
daily_enrichment=daily_enrichment,
|
||||
max_per_day=1,
|
||||
)
|
||||
|
||||
assert result == {"2026-01-05": ["AAA"]}
|
||||
|
||||
|
||||
def test_momentum_intraday_first_candidates_weighted_ranking_can_use_prior_dollar_volume() -> None:
|
||||
strategy = StrategyParams(
|
||||
candidate_source_mode="intraday_first",
|
||||
candidate_intraday_rank_mode="weighted",
|
||||
candidate_intraday_weight_gain=0.2,
|
||||
candidate_intraday_weight_confirmation=0.4,
|
||||
candidate_intraday_weight_volume_ratio=0.2,
|
||||
candidate_intraday_weight_entry_dollar_volume=0.1,
|
||||
candidate_intraday_weight_avg_dollar_vol_30d=0.5,
|
||||
entry_minutes_after_open=10,
|
||||
confirmation_minutes_after_entry=5,
|
||||
min_confirmation_return_pct=0.0,
|
||||
min_morning_gain_pct=0.005,
|
||||
candidate_final_max_per_day=1,
|
||||
)
|
||||
all_intraday = {
|
||||
"2026-01-05": {
|
||||
"AAA": [
|
||||
{"timestamp": "2026-01-05T14:30:00+00:00", "open": 10.0, "high": 10.1, "low": 9.9, "close": 10.0, "volume": 400_000},
|
||||
{"timestamp": "2026-01-05T14:35:00+00:00", "open": 10.0, "high": 10.2, "low": 10.0, "close": 10.1, "volume": 400_000},
|
||||
{"timestamp": "2026-01-05T14:40:00+00:00", "open": 10.1, "high": 10.2, "low": 10.0, "close": 10.1, "volume": 400_000},
|
||||
{"timestamp": "2026-01-05T14:45:00+00:00", "open": 10.1, "high": 10.3, "low": 10.1, "close": 10.2, "volume": 400_000},
|
||||
{"timestamp": "2026-01-05T14:50:00+00:00", "open": 10.2, "high": 10.4, "low": 10.1, "close": 10.3, "volume": 400_000},
|
||||
],
|
||||
"BBB": [
|
||||
{"timestamp": "2026-01-05T14:30:00+00:00", "open": 20.0, "high": 20.1, "low": 19.9, "close": 20.0, "volume": 200_000},
|
||||
{"timestamp": "2026-01-05T14:35:00+00:00", "open": 20.0, "high": 20.2, "low": 20.0, "close": 20.1, "volume": 200_000},
|
||||
{"timestamp": "2026-01-05T14:40:00+00:00", "open": 20.1, "high": 20.2, "low": 20.0, "close": 20.1, "volume": 200_000},
|
||||
{"timestamp": "2026-01-05T14:45:00+00:00", "open": 20.1, "high": 20.3, "low": 20.1, "close": 20.2, "volume": 200_000},
|
||||
{"timestamp": "2026-01-05T14:50:00+00:00", "open": 20.2, "high": 20.4, "low": 20.1, "close": 20.3, "volume": 200_000},
|
||||
],
|
||||
}
|
||||
}
|
||||
daily_enrichment = {
|
||||
"AAA": {
|
||||
"2026-01-05": {
|
||||
"gap_pct": 0.01,
|
||||
"avg_daily_vol_14d": 5_000_000.0,
|
||||
"avg_dollar_vol_30d": 200_000_000.0,
|
||||
}
|
||||
},
|
||||
"BBB": {
|
||||
"2026-01-05": {
|
||||
"gap_pct": 0.01,
|
||||
"avg_daily_vol_14d": 5_000_000.0,
|
||||
"avg_dollar_vol_30d": 8_000_000_000.0,
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
result = momentum_intraday_first_candidates(
|
||||
all_intraday,
|
||||
["2026-01-05"],
|
||||
strategy,
|
||||
daily_enrichment=daily_enrichment,
|
||||
max_per_day=1,
|
||||
)
|
||||
|
||||
assert result == {"2026-01-05": ["BBB"]}
|
||||
|
||||
|
||||
class _StubOracleClient:
|
||||
def __init__(self, responses: dict[str, object], *, health_ok: bool = True) -> None:
|
||||
self._responses = responses
|
||||
self._health_ok = health_ok
|
||||
self.calls: list[tuple[str, dict | None]] = []
|
||||
|
||||
async def get(self, path: str, params: dict | None = None) -> object:
|
||||
self.calls.append((path, params))
|
||||
response = self._responses[path]
|
||||
if isinstance(response, Exception):
|
||||
raise response
|
||||
if callable(response):
|
||||
return response(params)
|
||||
return response
|
||||
|
||||
async def health_check_fast(self, timeout: float = 3.0) -> bool:
|
||||
return self._health_ok
|
||||
|
||||
|
||||
def test_orb_pre_screen_candidates_returns_full_ranked_universe_when_uncapped() -> None:
|
||||
daily_bars = {
|
||||
"AAA": [{"date": "2026-01-05", "open": 10.0}],
|
||||
"BBB": [{"date": "2026-01-05", "open": 20.0}],
|
||||
"CCC": [{"date": "2026-01-05", "open": 40.0}],
|
||||
}
|
||||
enrichment = {
|
||||
"AAA": {"2026-01-05": {"atr_14": 4.0, "avg_dollar_vol_30d": 50_000_000.0}},
|
||||
"BBB": {"2026-01-05": {"atr_14": 3.0, "avg_dollar_vol_30d": 50_000_000.0}},
|
||||
"CCC": {"2026-01-05": {"atr_14": 1.0, "avg_dollar_vol_30d": 50_000_000.0}},
|
||||
}
|
||||
|
||||
capped = orb_pre_screen_candidates(
|
||||
daily_bars,
|
||||
["2026-01-05"],
|
||||
enrichment,
|
||||
max_per_day=2,
|
||||
)
|
||||
uncapped = orb_pre_screen_candidates(
|
||||
daily_bars,
|
||||
["2026-01-05"],
|
||||
enrichment,
|
||||
max_per_day=None,
|
||||
)
|
||||
|
||||
assert capped["2026-01-05"] == ["AAA", "BBB"]
|
||||
assert uncapped["2026-01-05"] == ["AAA", "BBB", "CCC"]
|
||||
|
||||
|
||||
def test_fetch_daily_bars_bulk_uses_bulk_endpoint_by_default() -> None:
|
||||
client = _StubOracleClient(
|
||||
{
|
||||
"/api/v1/price/data": {
|
||||
"bars": {
|
||||
"AAA": [
|
||||
{
|
||||
"date": "2026-01-02",
|
||||
"open": 10,
|
||||
"high": 11,
|
||||
"low": 9,
|
||||
"close": 10.5,
|
||||
"volume": 1000,
|
||||
}
|
||||
],
|
||||
"BBB": [
|
||||
{
|
||||
"date": "2026-01-02",
|
||||
"open": 20,
|
||||
"high": 21,
|
||||
"low": 19,
|
||||
"close": 20.5,
|
||||
"volume": 2000,
|
||||
}
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
bars = asyncio.run(
|
||||
fetch_daily_bars_bulk(
|
||||
["AAA", "BBB"], "2026-01-02", "2026-01-02", client, concurrency=2
|
||||
)
|
||||
)
|
||||
|
||||
assert list(bars) == ["AAA", "BBB"]
|
||||
assert len(client.calls) == 1
|
||||
assert client.calls[0][0] == "/api/v1/price/data"
|
||||
assert client.calls[0][1] == {
|
||||
"tickers": "AAA,BBB",
|
||||
"start_date": "2026-01-02",
|
||||
"end_date": "2026-01-02",
|
||||
}
|
||||
|
||||
|
||||
def test_fetch_daily_bars_bulk_falls_back_to_single_ticker_on_chunk_failure() -> None:
|
||||
client = _StubOracleClient(
|
||||
{
|
||||
"/api/v1/price/data": RuntimeError("bulk failed"),
|
||||
"/api/v1/price/data/AAA": {
|
||||
"ticker": "AAA",
|
||||
"data": [
|
||||
{
|
||||
"date": "2026-01-02",
|
||||
"open": 10,
|
||||
"high": 11,
|
||||
"low": 9,
|
||||
"close": 10.5,
|
||||
"volume": 1000,
|
||||
}
|
||||
],
|
||||
},
|
||||
"/api/v1/price/data/BBB": {
|
||||
"ticker": "BBB",
|
||||
"data": [
|
||||
{
|
||||
"date": "2026-01-02",
|
||||
"open": 20,
|
||||
"high": 21,
|
||||
"low": 19,
|
||||
"close": 20.5,
|
||||
"volume": 2000,
|
||||
}
|
||||
],
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
bars = asyncio.run(
|
||||
fetch_daily_bars_bulk(
|
||||
["AAA", "BBB"], "2026-01-02", "2026-01-02", client, concurrency=2
|
||||
)
|
||||
)
|
||||
|
||||
assert list(bars) == ["AAA", "BBB"]
|
||||
assert [call[0] for call in client.calls] == [
|
||||
"/api/v1/price/data",
|
||||
"/api/v1/price/data/AAA",
|
||||
"/api/v1/price/data/BBB",
|
||||
]
|
||||
|
||||
|
||||
def test_fetch_daily_bars_bulk_repairs_suspiciously_short_bulk_responses() -> None:
|
||||
short_rows = [
|
||||
{
|
||||
"date": f"2026-04-{6 + i:02d}",
|
||||
"open": 100 + i,
|
||||
"high": 101 + i,
|
||||
"low": 99 + i,
|
||||
"close": 100.5 + i,
|
||||
"volume": 1000 + i,
|
||||
}
|
||||
for i in range(11)
|
||||
]
|
||||
full_rows = [
|
||||
{
|
||||
"date": f"2026-02-{1 + i:02d}",
|
||||
"open": 100 + i,
|
||||
"high": 101 + i,
|
||||
"low": 99 + i,
|
||||
"close": 100.5 + i,
|
||||
"volume": 1000 + i,
|
||||
}
|
||||
for i in range(25)
|
||||
]
|
||||
client = _StubOracleClient(
|
||||
{
|
||||
"/api/v1/price/data": {
|
||||
"bars": {
|
||||
"AAA": short_rows,
|
||||
}
|
||||
},
|
||||
"/api/v1/price/data/AAA": {
|
||||
"ticker": "AAA",
|
||||
"data": full_rows,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
bars = asyncio.run(
|
||||
fetch_daily_bars_bulk(
|
||||
["AAA"],
|
||||
"2026-01-20",
|
||||
"2026-04-20",
|
||||
client,
|
||||
concurrency=1,
|
||||
)
|
||||
)
|
||||
|
||||
assert len(bars["AAA"]) == 25
|
||||
assert [call[0] for call in client.calls] == [
|
||||
"/api/v1/price/data",
|
||||
"/api/v1/price/data/AAA",
|
||||
]
|
||||
|
||||
|
||||
def test_fetch_daily_bars_bulk_hits_daily_cache_on_repeat_range(tmp_path) -> None:
|
||||
cache = DailyBarCache(str(tmp_path))
|
||||
client = _StubOracleClient(
|
||||
{
|
||||
"/api/v1/price/data": {
|
||||
"bars": {
|
||||
"AAA": [
|
||||
{
|
||||
"date": "2026-01-02",
|
||||
"open": 10,
|
||||
"high": 11,
|
||||
"low": 9,
|
||||
"close": 10.5,
|
||||
"volume": 1000,
|
||||
}
|
||||
],
|
||||
"BBB": [
|
||||
{
|
||||
"date": "2026-01-02",
|
||||
"open": 20,
|
||||
"high": 21,
|
||||
"low": 19,
|
||||
"close": 20.5,
|
||||
"volume": 2000,
|
||||
}
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
first = asyncio.run(
|
||||
fetch_daily_bars_bulk(
|
||||
["AAA", "BBB"],
|
||||
"2026-01-01",
|
||||
"2026-01-10",
|
||||
client,
|
||||
cache=cache,
|
||||
concurrency=2,
|
||||
)
|
||||
)
|
||||
assert list(first) == ["AAA", "BBB"]
|
||||
assert len(client.calls) == 1
|
||||
|
||||
client.calls.clear()
|
||||
second = asyncio.run(
|
||||
fetch_daily_bars_bulk(
|
||||
["AAA", "BBB"],
|
||||
"2026-01-01",
|
||||
"2026-01-10",
|
||||
client,
|
||||
cache=cache,
|
||||
concurrency=2,
|
||||
)
|
||||
)
|
||||
|
||||
assert second == first
|
||||
assert client.calls == []
|
||||
|
||||
|
||||
def test_fetch_intraday_bulk_recovers_from_bulk_chunk_failure_by_splitting() -> None:
|
||||
from libs.intraday.screener import fetch_intraday_bulk
|
||||
|
||||
def intraday_response(params: dict | None) -> dict:
|
||||
tickers = (params or {}).get("tickers", "")
|
||||
if "," in tickers:
|
||||
raise RuntimeError("chunk failed")
|
||||
ticker = tickers
|
||||
return {
|
||||
"bars": {
|
||||
ticker: [
|
||||
{
|
||||
"timestamp": f"2026-01-02T14:{30 + i:02d}:00+00:00",
|
||||
"open": 10.0,
|
||||
"high": 11.0,
|
||||
"low": 9.5,
|
||||
"close": 10.5,
|
||||
"volume": 1000.0,
|
||||
"vwap": 10.4,
|
||||
}
|
||||
for i in range(10)
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
client = _StubOracleClient({"/api/v1/alpaca/intraday": intraday_response})
|
||||
|
||||
bars = asyncio.run(
|
||||
fetch_intraday_bulk(
|
||||
{"2026-01-02": ["AAA", "BBB"]},
|
||||
client,
|
||||
cache=None,
|
||||
concurrency=2,
|
||||
)
|
||||
)
|
||||
|
||||
assert list(bars["2026-01-02"]) == ["AAA", "BBB"]
|
||||
assert [call[1]["tickers"] for call in client.calls] == ["AAA,BBB", "AAA", "BBB"]
|
||||
|
||||
|
||||
def test_fetch_intraday_bulk_negative_caches_sparse_responses(tmp_path) -> None:
|
||||
from libs.intraday.cache import IntradayCache
|
||||
from libs.intraday.screener import fetch_intraday_bulk
|
||||
|
||||
def intraday_response(params: dict | None) -> dict:
|
||||
tickers = (params or {}).get("tickers", "")
|
||||
result = {"bars": {}}
|
||||
for ticker in tickers.split(","):
|
||||
if ticker == "AAA":
|
||||
result["bars"][ticker] = [
|
||||
{
|
||||
"timestamp": "2026-01-02T14:30:00+00:00",
|
||||
"open": 10.0,
|
||||
"high": 11.0,
|
||||
"low": 9.5,
|
||||
"close": 10.5,
|
||||
"volume": 1000.0,
|
||||
"vwap": 10.4,
|
||||
}
|
||||
]
|
||||
else:
|
||||
result["bars"][ticker] = [
|
||||
{
|
||||
"timestamp": f"2026-01-02T14:{30 + i:02d}:00+00:00",
|
||||
"open": 20.0,
|
||||
"high": 21.0,
|
||||
"low": 19.5,
|
||||
"close": 20.5,
|
||||
"volume": 1000.0,
|
||||
"vwap": 20.4,
|
||||
}
|
||||
for i in range(10)
|
||||
]
|
||||
return result
|
||||
|
||||
client = _StubOracleClient({"/api/v1/alpaca/intraday": intraday_response})
|
||||
cache = IntradayCache(str(tmp_path))
|
||||
|
||||
first = asyncio.run(
|
||||
fetch_intraday_bulk(
|
||||
{"2026-01-02": ["AAA", "BBB"]},
|
||||
client,
|
||||
cache=cache,
|
||||
concurrency=2,
|
||||
)
|
||||
)
|
||||
|
||||
assert list(first["2026-01-02"]) == ["BBB"]
|
||||
assert cache.has("AAA", "2026-01-02") is True
|
||||
assert cache.get("AAA", "2026-01-02") == []
|
||||
|
||||
client.calls.clear()
|
||||
second = asyncio.run(
|
||||
fetch_intraday_bulk(
|
||||
{"2026-01-02": ["AAA", "BBB"]},
|
||||
client,
|
||||
cache=cache,
|
||||
concurrency=2,
|
||||
)
|
||||
)
|
||||
|
||||
assert list(second["2026-01-02"]) == ["BBB"]
|
||||
assert client.calls == []
|
||||
|
||||
|
||||
def test_fetch_daily_bars_bulk_rebuilds_from_intraday_cache_when_oracle_unavailable(tmp_path) -> None:
|
||||
daily_cache = DailyBarCache(str(tmp_path / "daily"))
|
||||
intraday_cache = IntradayCache(str(tmp_path / "intraday"))
|
||||
|
||||
intraday_cache.put(
|
||||
"AAA",
|
||||
"2026-01-02",
|
||||
[
|
||||
{
|
||||
"timestamp": "2026-01-02T14:30:00+00:00",
|
||||
"open": 10.0,
|
||||
"high": 10.5,
|
||||
"low": 9.9,
|
||||
"close": 10.4,
|
||||
"volume": 100.0,
|
||||
"vwap": 10.2,
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-01-02T14:35:00+00:00",
|
||||
"open": 10.4,
|
||||
"high": 11.0,
|
||||
"low": 10.2,
|
||||
"close": 10.8,
|
||||
"volume": 150.0,
|
||||
"vwap": 10.7,
|
||||
},
|
||||
]
|
||||
* 5,
|
||||
)
|
||||
intraday_cache.put(
|
||||
"AAA",
|
||||
"2026-01-03",
|
||||
[
|
||||
{
|
||||
"timestamp": "2026-01-03T14:30:00+00:00",
|
||||
"open": 11.0,
|
||||
"high": 11.2,
|
||||
"low": 10.8,
|
||||
"close": 11.1,
|
||||
"volume": 120.0,
|
||||
"vwap": 11.0,
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-01-03T14:35:00+00:00",
|
||||
"open": 11.1,
|
||||
"high": 11.4,
|
||||
"low": 11.0,
|
||||
"close": 11.3,
|
||||
"volume": 180.0,
|
||||
"vwap": 11.2,
|
||||
},
|
||||
]
|
||||
* 5,
|
||||
)
|
||||
|
||||
client = _StubOracleClient({"/api/v1/price/data": RuntimeError("oracle down")})
|
||||
bars = asyncio.run(
|
||||
fetch_daily_bars_bulk(
|
||||
["AAA"],
|
||||
"2026-01-02",
|
||||
"2026-01-03",
|
||||
client,
|
||||
cache=daily_cache,
|
||||
intraday_cache_fallback=intraday_cache,
|
||||
concurrency=1,
|
||||
)
|
||||
)
|
||||
|
||||
assert list(bars) == ["AAA"]
|
||||
assert bars["AAA"] == [
|
||||
{
|
||||
"date": "2026-01-02",
|
||||
"open": 10.0,
|
||||
"high": 11.0,
|
||||
"low": 9.9,
|
||||
"close": 10.8,
|
||||
"volume": 1250.0,
|
||||
},
|
||||
{
|
||||
"date": "2026-01-03",
|
||||
"open": 11.0,
|
||||
"high": 11.4,
|
||||
"low": 10.8,
|
||||
"close": 11.3,
|
||||
"volume": 1500.0,
|
||||
},
|
||||
]
|
||||
assert daily_cache.get("AAA", "2026-01-02", "2026-01-03") == bars["AAA"]
|
||||
|
||||
|
||||
def test_fetch_daily_bars_bulk_prefers_intraday_fallback_without_oracle_calls(tmp_path) -> None:
|
||||
intraday_cache = IntradayCache(str(tmp_path / "intraday"))
|
||||
intraday_cache.put(
|
||||
"AAA",
|
||||
"2026-01-02",
|
||||
[
|
||||
{
|
||||
"timestamp": f"2026-01-02T14:{30 + i:02d}:00+00:00",
|
||||
"open": 10.0,
|
||||
"high": 10.0 + i * 0.1,
|
||||
"low": 9.8,
|
||||
"close": 10.0 + i * 0.1,
|
||||
"volume": 100.0 + i,
|
||||
"vwap": 10.0 + i * 0.05,
|
||||
}
|
||||
for i in range(10)
|
||||
],
|
||||
)
|
||||
client = _StubOracleClient({"/api/v1/price/data": RuntimeError("should not call")})
|
||||
|
||||
bars = asyncio.run(
|
||||
fetch_daily_bars_bulk(
|
||||
["AAA"],
|
||||
"2026-01-02",
|
||||
"2026-01-02",
|
||||
client,
|
||||
intraday_cache_fallback=intraday_cache,
|
||||
prefer_intraday_fallback=True,
|
||||
concurrency=1,
|
||||
)
|
||||
)
|
||||
|
||||
assert list(bars) == ["AAA"]
|
||||
assert client.calls == []
|
||||
|
||||
|
||||
def test_fetch_daily_bars_bulk_skips_oracle_misses_when_health_check_fails(tmp_path) -> None:
|
||||
intraday_cache = IntradayCache(str(tmp_path / "intraday"))
|
||||
intraday_cache.put(
|
||||
"AAA",
|
||||
"2026-01-02",
|
||||
[
|
||||
{
|
||||
"timestamp": f"2026-01-02T14:{30 + i:02d}:00+00:00",
|
||||
"open": 10.0,
|
||||
"high": 10.2,
|
||||
"low": 9.8,
|
||||
"close": 10.1,
|
||||
"volume": 100.0,
|
||||
"vwap": 10.0,
|
||||
}
|
||||
for i in range(10)
|
||||
],
|
||||
)
|
||||
client = _StubOracleClient({"/api/v1/price/data": RuntimeError("should not call")}, health_ok=False)
|
||||
|
||||
bars = asyncio.run(
|
||||
fetch_daily_bars_bulk(
|
||||
["AAA", "BBB"],
|
||||
"2026-01-02",
|
||||
"2026-01-02",
|
||||
client,
|
||||
intraday_cache_fallback=intraday_cache,
|
||||
prefer_intraday_fallback=True,
|
||||
skip_oracle_when_unhealthy=True,
|
||||
concurrency=1,
|
||||
)
|
||||
)
|
||||
|
||||
assert list(bars) == ["AAA"]
|
||||
assert client.calls == []
|
||||
|
||||
|
||||
def test_fetch_intraday_bulk_skips_uncached_pairs_when_oracle_health_fails(tmp_path) -> None:
|
||||
from libs.intraday.screener import fetch_intraday_bulk
|
||||
|
||||
cache = IntradayCache(str(tmp_path))
|
||||
cache.put(
|
||||
"AAA",
|
||||
"2026-01-02",
|
||||
[
|
||||
{
|
||||
"timestamp": f"2026-01-02T14:{30 + i:02d}:00+00:00",
|
||||
"open": 10.0,
|
||||
"high": 10.5,
|
||||
"low": 9.8,
|
||||
"close": 10.3,
|
||||
"volume": 1000.0,
|
||||
"vwap": 10.2,
|
||||
}
|
||||
for i in range(10)
|
||||
],
|
||||
)
|
||||
client = _StubOracleClient({"/api/v1/alpaca/intraday": RuntimeError("should not call")}, health_ok=False)
|
||||
|
||||
bars = asyncio.run(
|
||||
fetch_intraday_bulk(
|
||||
{"2026-01-02": ["AAA", "BBB"]},
|
||||
client,
|
||||
cache=cache,
|
||||
skip_oracle_when_unhealthy=True,
|
||||
concurrency=1,
|
||||
)
|
||||
)
|
||||
|
||||
assert list(bars["2026-01-02"]) == ["AAA"]
|
||||
assert client.calls == []
|
||||
|
||||
|
||||
def test_resolve_universe_screener_uses_snapshot_fallback(tmp_path, monkeypatch) -> None:
|
||||
monkeypatch.setattr(
|
||||
screener_module,
|
||||
"get_settings",
|
||||
lambda: SimpleNamespace(data_root=str(tmp_path)),
|
||||
)
|
||||
|
||||
params = UniverseParams(
|
||||
source="screener",
|
||||
market_cap_min=100_000_000.0,
|
||||
avg_volume_min=200_000,
|
||||
min_price=2.0,
|
||||
)
|
||||
|
||||
async def first_search(self, **kwargs):
|
||||
return [
|
||||
SimpleNamespace(symbol="TSLA"),
|
||||
SimpleNamespace(symbol="NVDA"),
|
||||
SimpleNamespace(symbol="TSLA"),
|
||||
]
|
||||
|
||||
async def failing_search(self, **kwargs):
|
||||
raise RuntimeError("screener down")
|
||||
|
||||
monkeypatch.setattr(screener_module.ScreenerService, "search_all_stocks", first_search)
|
||||
first = asyncio.run(resolve_universe(params, client=object()))
|
||||
assert first == ["NVDA", "TSLA"]
|
||||
|
||||
monkeypatch.setattr(screener_module.ScreenerService, "search_all_stocks", failing_search)
|
||||
second = asyncio.run(resolve_universe(params, client=object()))
|
||||
assert second == ["NVDA", "TSLA"]
|
||||
@ -0,0 +1,836 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from libs.intraday.domain import StrategyParams
|
||||
from libs.intraday.simulator import _select_momentum_sleeves, compute_morning_gains, run_simulation, simulate_day
|
||||
|
||||
|
||||
def _bars(day: str, *, open_price: float, closes: list[float], volumes: list[int]) -> list[dict]:
|
||||
times = ["09:30:00", "09:35:00", "09:40:00", "09:45:00", "09:50:00", "15:55:00"]
|
||||
bars: list[dict] = []
|
||||
prev = open_price
|
||||
for idx, close in enumerate(closes):
|
||||
ts = f"{day}T{times[idx]}-05:00"
|
||||
high = max(prev, close) + 0.2
|
||||
low = min(prev, close) - 0.2
|
||||
bars.append(
|
||||
{
|
||||
"timestamp": ts,
|
||||
"open": prev,
|
||||
"high": high,
|
||||
"low": low,
|
||||
"close": close,
|
||||
"volume": volumes[idx],
|
||||
}
|
||||
)
|
||||
prev = close
|
||||
return bars
|
||||
|
||||
|
||||
def test_compute_morning_gains_respects_vix_gate() -> None:
|
||||
strategy = StrategyParams(
|
||||
entry_minutes_after_open=10,
|
||||
min_morning_gain_pct=0.01,
|
||||
max_vix=25.0,
|
||||
)
|
||||
bars_by_ticker = {
|
||||
"AAA": _bars(
|
||||
"2026-01-05",
|
||||
open_price=10.0,
|
||||
closes=[10.2, 10.5, 10.7, 10.8, 10.9, 11.0],
|
||||
volumes=[100_000, 100_000, 100_000, 100_000, 100_000, 100_000],
|
||||
)
|
||||
}
|
||||
gains = compute_morning_gains(
|
||||
bars_by_ticker,
|
||||
strategy,
|
||||
"2026-01-05",
|
||||
vix_value=30.0,
|
||||
)
|
||||
assert gains == {}
|
||||
|
||||
|
||||
def test_simulate_day_uses_five_sleeves_and_tags_trade_sleeve() -> None:
|
||||
strategy = StrategyParams(
|
||||
entry_minutes_after_open=10,
|
||||
min_morning_gain_pct=0.01,
|
||||
top_n=3,
|
||||
use_five_sleeves=True,
|
||||
min_entry_volume=50_000,
|
||||
)
|
||||
bars_by_ticker = {
|
||||
"CORE": _bars("2026-01-05", open_price=10.0, closes=[10.3, 10.8, 11.0, 11.2, 11.3, 11.5], volumes=[200_000] * 6),
|
||||
"GAP": _bars("2026-01-05", open_price=20.0, closes=[20.2, 20.6, 20.8, 20.9, 21.0, 21.1], volumes=[180_000] * 6),
|
||||
"VOL": _bars("2026-01-05", open_price=30.0, closes=[30.2, 30.7, 31.0, 31.2, 31.3, 31.4], volumes=[500_000] * 6),
|
||||
"ENT": _bars("2026-01-05", open_price=40.0, closes=[40.3, 40.8, 41.2, 41.4, 41.5, 41.7], volumes=[160_000] * 6),
|
||||
"TRND": _bars("2026-01-05", open_price=50.0, closes=[50.4, 51.0, 51.4, 51.5, 51.7, 52.0], volumes=[150_000] * 6),
|
||||
}
|
||||
daily_features = {
|
||||
"CORE": {"gap_pct": 0.01, "avg_daily_vol_14d": 1_000_000.0, "ret_5d": 0.02, "entropy_20d": 0.50},
|
||||
"GAP": {"gap_pct": 0.07, "avg_daily_vol_14d": 1_000_000.0, "ret_5d": 0.01, "entropy_20d": 0.60},
|
||||
"VOL": {"gap_pct": 0.02, "avg_daily_vol_14d": 600_000.0, "ret_5d": 0.00, "entropy_20d": 0.55},
|
||||
"ENT": {"gap_pct": 0.02, "avg_daily_vol_14d": 1_000_000.0, "ret_5d": 0.03, "entropy_20d": 0.20},
|
||||
"TRND": {"gap_pct": 0.03, "avg_daily_vol_14d": 1_000_000.0, "ret_5d": 0.12, "entropy_20d": 0.45},
|
||||
}
|
||||
|
||||
day = simulate_day(
|
||||
bars_by_ticker,
|
||||
"2026-01-05",
|
||||
strategy,
|
||||
daily_features_by_ticker=daily_features,
|
||||
)
|
||||
|
||||
assert len(day.trades) == 3
|
||||
sleeves = {trade.trade_sleeve for trade in day.trades}
|
||||
assert sleeves <= {"core", "gap", "volume", "entropy", "trend", "blend"}
|
||||
assert all(trade.trade_sleeve is not None for trade in day.trades)
|
||||
|
||||
|
||||
def test_run_simulation_applies_entropy_filter_and_scaler() -> None:
|
||||
strategy = StrategyParams(
|
||||
entry_minutes_after_open=10,
|
||||
min_morning_gain_pct=0.01,
|
||||
top_n=2,
|
||||
max_entropy_20d=0.80,
|
||||
entropy_size_scale_low=0.40,
|
||||
entropy_size_scale_high=0.80,
|
||||
entropy_size_scale_min=0.50,
|
||||
)
|
||||
all_intraday = {
|
||||
"2026-01-05": {
|
||||
"LOW": _bars("2026-01-05", open_price=10.0, closes=[10.2, 10.8, 11.2, 11.3, 11.4, 11.5], volumes=[120_000] * 6),
|
||||
"HIGH": _bars("2026-01-05", open_price=15.0, closes=[15.2, 15.8, 16.2, 16.3, 16.4, 16.5], volumes=[120_000] * 6),
|
||||
}
|
||||
}
|
||||
enrichment = {
|
||||
"LOW": {"2026-01-05": {"gap_pct": 0.02, "avg_daily_vol_14d": 1_000_000.0, "ret_5d": 0.05, "entropy_20d": 0.40}},
|
||||
"HIGH": {"2026-01-05": {"gap_pct": 0.02, "avg_daily_vol_14d": 1_000_000.0, "ret_5d": 0.05, "entropy_20d": 0.90}},
|
||||
}
|
||||
|
||||
results = run_simulation(
|
||||
all_intraday,
|
||||
["2026-01-05"],
|
||||
strategy,
|
||||
daily_enrichment=enrichment,
|
||||
)
|
||||
|
||||
assert len(results) == 1
|
||||
assert [trade.ticker for trade in results[0].trades] == ["LOW"]
|
||||
|
||||
|
||||
def test_select_momentum_sleeves_respects_weights_and_force_count() -> None:
|
||||
strategy = StrategyParams(
|
||||
top_n=2,
|
||||
use_five_sleeves=True,
|
||||
five_sleeve_force_count=1,
|
||||
five_sleeve_core_weight=0.05,
|
||||
five_sleeve_gap_weight=0.0,
|
||||
five_sleeve_volume_weight=0.0,
|
||||
five_sleeve_entropy_weight=0.90,
|
||||
five_sleeve_trend_weight=0.05,
|
||||
)
|
||||
morning_gains = {
|
||||
"CORE": {
|
||||
"gain_pct": 0.09,
|
||||
"entry_volume": 150_000,
|
||||
"volume_ratio_14d": 0.03,
|
||||
"gap_pct": 0.01,
|
||||
"ret_5d": 0.02,
|
||||
"entropy_20d": 0.60,
|
||||
},
|
||||
"ENT": {
|
||||
"gain_pct": 0.05,
|
||||
"entry_volume": 140_000,
|
||||
"volume_ratio_14d": 0.02,
|
||||
"gap_pct": 0.01,
|
||||
"ret_5d": 0.03,
|
||||
"entropy_20d": 0.10,
|
||||
},
|
||||
"TRND": {
|
||||
"gain_pct": 0.04,
|
||||
"entry_volume": 130_000,
|
||||
"volume_ratio_14d": 0.02,
|
||||
"gap_pct": 0.01,
|
||||
"ret_5d": 0.15,
|
||||
"entropy_20d": 0.55,
|
||||
},
|
||||
}
|
||||
|
||||
picks = _select_momentum_sleeves(morning_gains, strategy)
|
||||
|
||||
assert picks[0] == ("ENT", "entropy")
|
||||
assert len(picks) == 2
|
||||
|
||||
|
||||
def test_select_momentum_sleeves_respects_sector_cap() -> None:
|
||||
strategy = StrategyParams(
|
||||
top_n=3,
|
||||
use_five_sleeves=False,
|
||||
max_positions_per_sector=1,
|
||||
)
|
||||
morning_gains = {
|
||||
"TECH_A": {"gain_pct": 0.10, "entry_volume": 300_000},
|
||||
"TECH_B": {"gain_pct": 0.09, "entry_volume": 280_000},
|
||||
"HEALTH": {"gain_pct": 0.08, "entry_volume": 260_000},
|
||||
"FIN": {"gain_pct": 0.07, "entry_volume": 250_000},
|
||||
}
|
||||
picks = _select_momentum_sleeves(
|
||||
morning_gains,
|
||||
strategy,
|
||||
ticker_sectors={
|
||||
"TECH_A": "Technology",
|
||||
"TECH_B": "Technology",
|
||||
"HEALTH": "Healthcare",
|
||||
"FIN": "Financial Services",
|
||||
},
|
||||
)
|
||||
|
||||
assert [ticker for ticker, _ in picks] == ["TECH_A", "HEALTH", "FIN"]
|
||||
|
||||
|
||||
def test_compute_morning_gains_applies_confirmation_filter_and_later_entry() -> None:
|
||||
strategy = StrategyParams(
|
||||
entry_minutes_after_open=10,
|
||||
confirmation_minutes_after_entry=5,
|
||||
min_confirmation_return_pct=0.0,
|
||||
min_morning_gain_pct=0.01,
|
||||
)
|
||||
bars_by_ticker = {
|
||||
"KEEP": _bars(
|
||||
"2026-01-13",
|
||||
open_price=10.0,
|
||||
closes=[10.2, 10.6, 10.8, 11.0, 11.1, 11.2],
|
||||
volumes=[100_000] * 6,
|
||||
),
|
||||
"DROP": _bars(
|
||||
"2026-01-13",
|
||||
open_price=10.0,
|
||||
closes=[10.2, 10.6, 10.8, 10.7, 10.6, 10.5],
|
||||
volumes=[100_000] * 6,
|
||||
),
|
||||
}
|
||||
|
||||
gains = compute_morning_gains(
|
||||
bars_by_ticker,
|
||||
strategy,
|
||||
"2026-01-13",
|
||||
)
|
||||
|
||||
assert list(gains) == ["KEEP"]
|
||||
assert gains["KEEP"]["entry_bar"]["timestamp"].endswith("09:45:00-05:00")
|
||||
|
||||
|
||||
def test_compute_morning_gains_allows_slow_ignite_below_primary_gain_floor() -> None:
|
||||
strategy = StrategyParams(
|
||||
entry_minutes_after_open=10,
|
||||
confirmation_minutes_after_entry=5,
|
||||
min_confirmation_return_pct=0.0005,
|
||||
min_morning_gain_pct=0.015,
|
||||
use_slow_ignite_sleeve=True,
|
||||
slow_ignite_weight=0.08,
|
||||
slow_ignite_min_gain_pct=0.005,
|
||||
slow_ignite_max_gain_pct=0.015,
|
||||
slow_ignite_min_entry_dollar_volume=50_000_000,
|
||||
slow_ignite_min_volume_ratio_14d=0.08,
|
||||
slow_ignite_min_ret_5d=0.03,
|
||||
slow_ignite_max_entropy_20d=0.80,
|
||||
)
|
||||
bars_by_ticker = {
|
||||
"SLOW": _bars(
|
||||
"2026-01-13",
|
||||
open_price=100.0,
|
||||
closes=[100.2, 100.4, 100.6, 101.2, 101.3, 101.5],
|
||||
volumes=[250_000, 250_000, 250_000, 250_000, 250_000, 250_000],
|
||||
),
|
||||
}
|
||||
gains = compute_morning_gains(
|
||||
bars_by_ticker,
|
||||
strategy,
|
||||
"2026-01-13",
|
||||
daily_features_by_ticker={
|
||||
"SLOW": {
|
||||
"avg_daily_vol_14d": 10_000_000.0,
|
||||
"ret_5d": 0.06,
|
||||
"entropy_20d": 0.70,
|
||||
"gap_pct": 0.01,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
assert list(gains) == ["SLOW"]
|
||||
assert gains["SLOW"]["is_slow_ignite"] is True
|
||||
assert gains["SLOW"]["gain_pct"] < strategy.min_morning_gain_pct
|
||||
|
||||
|
||||
def test_select_momentum_sleeves_can_force_slow_ignite_pick() -> None:
|
||||
strategy = StrategyParams(
|
||||
top_n=2,
|
||||
use_five_sleeves=True,
|
||||
use_slow_ignite_sleeve=True,
|
||||
five_sleeve_core_weight=0.0,
|
||||
five_sleeve_gap_weight=0.0,
|
||||
five_sleeve_volume_weight=0.0,
|
||||
five_sleeve_entropy_weight=0.0,
|
||||
five_sleeve_trend_weight=0.0,
|
||||
slow_ignite_weight=1.0,
|
||||
five_sleeve_force_count=1,
|
||||
)
|
||||
morning_gains = {
|
||||
"FAST": {
|
||||
"gain_pct": 0.06,
|
||||
"entry_volume": 300_000,
|
||||
"entry_dollar_volume": 20_000_000.0,
|
||||
"volume_ratio_14d": 0.03,
|
||||
"gap_pct": 0.01,
|
||||
"ret_5d": 0.02,
|
||||
"entropy_20d": 0.55,
|
||||
"confirmation_return_pct": 0.001,
|
||||
"is_slow_ignite": False,
|
||||
},
|
||||
"SLOW": {
|
||||
"gain_pct": 0.009,
|
||||
"entry_volume": 1_000_000,
|
||||
"entry_dollar_volume": 150_000_000.0,
|
||||
"volume_ratio_14d": 0.12,
|
||||
"gap_pct": 0.008,
|
||||
"ret_5d": 0.08,
|
||||
"entropy_20d": 0.70,
|
||||
"confirmation_return_pct": 0.006,
|
||||
"is_slow_ignite": True,
|
||||
},
|
||||
}
|
||||
|
||||
picks = _select_momentum_sleeves(morning_gains, strategy)
|
||||
|
||||
assert ("SLOW", "slow_ignite") in picks
|
||||
|
||||
|
||||
def test_compute_morning_gains_allows_gap_reclaim_candidates() -> None:
|
||||
strategy = StrategyParams(
|
||||
entry_minutes_after_open=10,
|
||||
confirmation_minutes_after_entry=5,
|
||||
min_morning_gain_pct=0.015,
|
||||
min_confirmation_return_pct=0.005,
|
||||
max_gap_pct=0.055,
|
||||
use_gap_reclaim_sleeve=True,
|
||||
gap_reclaim_weight=0.05,
|
||||
gap_reclaim_min_gap_pct=0.10,
|
||||
gap_reclaim_min_gain_pct=-0.03,
|
||||
gap_reclaim_max_gain_pct=0.003,
|
||||
gap_reclaim_min_confirmation_return_pct=0.0015,
|
||||
gap_reclaim_min_entry_dollar_volume=100_000_000,
|
||||
gap_reclaim_min_recovery_from_opening_low_pct=0.005,
|
||||
)
|
||||
bars_by_ticker = {
|
||||
"RECLAIM": _bars(
|
||||
"2026-01-13",
|
||||
open_price=100.0,
|
||||
closes=[96.0, 95.0, 97.8, 98.0, 98.5, 101.0],
|
||||
volumes=[400_000, 400_000, 400_000, 400_000, 400_000, 400_000],
|
||||
),
|
||||
}
|
||||
|
||||
gains = compute_morning_gains(
|
||||
bars_by_ticker,
|
||||
strategy,
|
||||
"2026-01-13",
|
||||
daily_features_by_ticker={
|
||||
"RECLAIM": {
|
||||
"avg_daily_vol_14d": 5_000_000.0,
|
||||
"avg_dollar_vol_30d": 800_000_000.0,
|
||||
"ret_5d": 0.02,
|
||||
"entropy_20d": None,
|
||||
"gap_pct": 0.18,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
assert list(gains) == ["RECLAIM"]
|
||||
assert gains["RECLAIM"]["is_gap_reclaim"] is True
|
||||
assert gains["RECLAIM"]["gain_pct"] < 0.0
|
||||
assert gains["RECLAIM"]["recovery_from_opening_low_pct"] > 0.005
|
||||
|
||||
|
||||
def test_select_momentum_sleeves_can_force_gap_reclaim_pick() -> None:
|
||||
strategy = StrategyParams(
|
||||
top_n=1,
|
||||
use_five_sleeves=True,
|
||||
use_gap_reclaim_sleeve=True,
|
||||
five_sleeve_core_weight=0.0,
|
||||
five_sleeve_gap_weight=0.0,
|
||||
five_sleeve_volume_weight=0.0,
|
||||
five_sleeve_entropy_weight=0.0,
|
||||
five_sleeve_trend_weight=0.0,
|
||||
gap_reclaim_weight=1.0,
|
||||
five_sleeve_force_count=1,
|
||||
)
|
||||
morning_gains = {
|
||||
"FAST": {
|
||||
"gain_pct": 0.05,
|
||||
"entry_volume": 200_000,
|
||||
"entry_dollar_volume": 10_000_000.0,
|
||||
"gap_pct": 0.02,
|
||||
"confirmation_return_pct": 0.002,
|
||||
"recovery_from_opening_low_pct": 0.002,
|
||||
"is_gap_reclaim": False,
|
||||
},
|
||||
"RECLAIM": {
|
||||
"gain_pct": -0.015,
|
||||
"entry_volume": 900_000,
|
||||
"entry_dollar_volume": 220_000_000.0,
|
||||
"gap_pct": 0.21,
|
||||
"confirmation_return_pct": 0.003,
|
||||
"recovery_from_opening_low_pct": 0.01,
|
||||
"is_gap_reclaim": True,
|
||||
},
|
||||
}
|
||||
|
||||
picks = _select_momentum_sleeves(morning_gains, strategy)
|
||||
|
||||
assert ("RECLAIM", "gap_reclaim") in picks
|
||||
|
||||
|
||||
def test_compute_morning_gains_allows_liquid_largecap_candidates() -> None:
|
||||
strategy = StrategyParams(
|
||||
entry_minutes_after_open=10,
|
||||
confirmation_minutes_after_entry=5,
|
||||
min_morning_gain_pct=0.015,
|
||||
min_confirmation_return_pct=0.0005,
|
||||
use_liquid_largecap_sleeve=True,
|
||||
liquid_largecap_weight=0.3,
|
||||
liquid_largecap_min_gain_pct=0.004,
|
||||
liquid_largecap_max_gain_pct=0.02,
|
||||
liquid_largecap_min_confirmation_return_pct=0.0005,
|
||||
liquid_largecap_min_entry_dollar_volume=50_000_000,
|
||||
liquid_largecap_min_avg_dollar_vol_30d=500_000_000,
|
||||
liquid_largecap_max_entropy_20d=0.90,
|
||||
)
|
||||
bars_by_ticker = {
|
||||
"LQ": _bars(
|
||||
"2026-01-13",
|
||||
open_price=100.0,
|
||||
closes=[100.2, 100.5, 100.8, 101.4, 101.6, 101.8],
|
||||
volumes=[500_000, 500_000, 500_000, 500_000, 500_000, 500_000],
|
||||
),
|
||||
}
|
||||
|
||||
gains = compute_morning_gains(
|
||||
bars_by_ticker,
|
||||
strategy,
|
||||
"2026-01-13",
|
||||
daily_features_by_ticker={
|
||||
"LQ": {
|
||||
"avg_daily_vol_14d": 10_000_000.0,
|
||||
"avg_dollar_vol_30d": 1_000_000_000.0,
|
||||
"ret_5d": 0.01,
|
||||
"entropy_20d": 0.88,
|
||||
"gap_pct": 0.01,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
assert list(gains) == ["LQ"]
|
||||
assert gains["LQ"]["is_liquid_largecap"] is True
|
||||
|
||||
|
||||
def test_compute_morning_gains_can_relax_global_entropy_for_liquid_largecap() -> None:
|
||||
strategy = StrategyParams(
|
||||
entry_minutes_after_open=10,
|
||||
confirmation_minutes_after_entry=5,
|
||||
min_morning_gain_pct=0.015,
|
||||
min_confirmation_return_pct=0.0005,
|
||||
max_entropy_20d=0.86,
|
||||
use_liquid_largecap_sleeve=True,
|
||||
liquid_largecap_weight=0.3,
|
||||
liquid_largecap_min_gain_pct=0.004,
|
||||
liquid_largecap_max_gain_pct=0.02,
|
||||
liquid_largecap_min_confirmation_return_pct=0.0005,
|
||||
liquid_largecap_min_entry_dollar_volume=50_000_000,
|
||||
liquid_largecap_min_avg_dollar_vol_30d=500_000_000,
|
||||
liquid_largecap_max_entropy_20d=0.87,
|
||||
)
|
||||
bars_by_ticker = {
|
||||
"LQ": _bars(
|
||||
"2026-01-13",
|
||||
open_price=100.0,
|
||||
closes=[100.2, 100.5, 100.8, 101.4, 101.6, 101.8],
|
||||
volumes=[500_000, 500_000, 500_000, 500_000, 500_000, 500_000],
|
||||
),
|
||||
}
|
||||
|
||||
gains = compute_morning_gains(
|
||||
bars_by_ticker,
|
||||
strategy,
|
||||
"2026-01-13",
|
||||
daily_features_by_ticker={
|
||||
"LQ": {
|
||||
"avg_daily_vol_14d": 10_000_000.0,
|
||||
"avg_dollar_vol_30d": 1_000_000_000.0,
|
||||
"ret_5d": 0.01,
|
||||
"entropy_20d": 0.865,
|
||||
"gap_pct": 0.01,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
assert list(gains) == ["LQ"]
|
||||
assert gains["LQ"]["is_liquid_largecap"] is True
|
||||
|
||||
|
||||
def test_select_momentum_sleeves_can_force_liquid_largecap_pick() -> None:
|
||||
strategy = StrategyParams(
|
||||
top_n=1,
|
||||
use_five_sleeves=True,
|
||||
use_liquid_largecap_sleeve=True,
|
||||
five_sleeve_core_weight=0.0,
|
||||
five_sleeve_gap_weight=0.0,
|
||||
five_sleeve_volume_weight=0.0,
|
||||
five_sleeve_entropy_weight=0.0,
|
||||
five_sleeve_trend_weight=0.0,
|
||||
liquid_largecap_weight=1.0,
|
||||
five_sleeve_force_count=1,
|
||||
)
|
||||
morning_gains = {
|
||||
"FAST": {
|
||||
"gain_pct": 0.06,
|
||||
"entry_volume": 150_000,
|
||||
"entry_dollar_volume": 5_000_000.0,
|
||||
"avg_dollar_vol_30d": 100_000_000.0,
|
||||
"confirmation_return_pct": 0.003,
|
||||
"volume_ratio_14d": 0.03,
|
||||
"gap_pct": 0.01,
|
||||
"ret_5d": 0.02,
|
||||
"entropy_20d": 0.50,
|
||||
"is_liquid_largecap": False,
|
||||
},
|
||||
"LQ": {
|
||||
"gain_pct": 0.008,
|
||||
"entry_volume": 500_000,
|
||||
"entry_dollar_volume": 80_000_000.0,
|
||||
"avg_dollar_vol_30d": 1_500_000_000.0,
|
||||
"confirmation_return_pct": 0.001,
|
||||
"volume_ratio_14d": 0.12,
|
||||
"gap_pct": 0.01,
|
||||
"ret_5d": 0.01,
|
||||
"entropy_20d": 0.85,
|
||||
"is_liquid_largecap": True,
|
||||
},
|
||||
}
|
||||
|
||||
picks = _select_momentum_sleeves(morning_gains, strategy)
|
||||
|
||||
assert ("LQ", "liquid_largecap") in picks
|
||||
|
||||
|
||||
def test_select_momentum_sleeves_can_add_liquid_largecap_fallback_when_sparse() -> None:
|
||||
strategy = StrategyParams(
|
||||
top_n=3,
|
||||
use_five_sleeves=True,
|
||||
five_sleeve_force_count=0,
|
||||
five_sleeve_core_weight=1.0,
|
||||
five_sleeve_gap_weight=0.0,
|
||||
five_sleeve_volume_weight=0.0,
|
||||
five_sleeve_entropy_weight=0.0,
|
||||
five_sleeve_trend_weight=0.0,
|
||||
fallback_liquid_largecap_slots=1,
|
||||
fallback_liquid_largecap_trigger_below=2,
|
||||
)
|
||||
morning_gains = {
|
||||
"FAST": {
|
||||
"gain_pct": 0.06,
|
||||
"entry_volume": 150_000,
|
||||
"entry_dollar_volume": 5_000_000.0,
|
||||
"avg_dollar_vol_30d": 100_000_000.0,
|
||||
"confirmation_return_pct": 0.003,
|
||||
"volume_ratio_14d": 0.03,
|
||||
"gap_pct": 0.01,
|
||||
"ret_5d": 0.02,
|
||||
"entropy_20d": 0.50,
|
||||
"is_liquid_largecap": False,
|
||||
},
|
||||
"LQ": {
|
||||
"gain_pct": 0.008,
|
||||
"entry_volume": 500_000,
|
||||
"entry_dollar_volume": 80_000_000.0,
|
||||
"avg_dollar_vol_30d": 1_500_000_000.0,
|
||||
"confirmation_return_pct": 0.005,
|
||||
"volume_ratio_14d": 0.12,
|
||||
"gap_pct": 0.01,
|
||||
"ret_5d": 0.01,
|
||||
"entropy_20d": 0.85,
|
||||
"is_liquid_largecap": True,
|
||||
},
|
||||
}
|
||||
|
||||
picks = _select_momentum_sleeves(morning_gains, strategy)
|
||||
|
||||
assert ("FAST", "blend") in picks
|
||||
assert ("LQ", "liquid_largecap_fallback") in picks
|
||||
|
||||
|
||||
def test_compute_morning_gains_applies_entry_dollar_volume_filter() -> None:
|
||||
strategy = StrategyParams(
|
||||
entry_minutes_after_open=10,
|
||||
min_morning_gain_pct=0.01,
|
||||
min_entry_volume=100_000,
|
||||
min_entry_dollar_volume=2_000_000,
|
||||
)
|
||||
bars_by_ticker = {
|
||||
"CHEAP": _bars(
|
||||
"2026-01-14",
|
||||
open_price=5.0,
|
||||
closes=[5.1, 5.3, 5.4, 5.5, 5.6, 5.7],
|
||||
volumes=[60_000, 60_000, 60_000, 60_000, 60_000, 60_000],
|
||||
),
|
||||
"RICH": _bars(
|
||||
"2026-01-14",
|
||||
open_price=20.0,
|
||||
closes=[20.2, 20.8, 21.0, 21.2, 21.3, 21.4],
|
||||
volumes=[60_000, 60_000, 60_000, 60_000, 60_000, 60_000],
|
||||
),
|
||||
}
|
||||
|
||||
gains = compute_morning_gains(
|
||||
bars_by_ticker,
|
||||
strategy,
|
||||
"2026-01-14",
|
||||
)
|
||||
|
||||
assert list(gains) == ["RICH"]
|
||||
|
||||
|
||||
def test_compute_morning_gains_allows_liquid_largecap_candidates_for_fallback() -> None:
|
||||
strategy = StrategyParams(
|
||||
entry_minutes_after_open=10,
|
||||
confirmation_minutes_after_entry=5,
|
||||
min_morning_gain_pct=0.015,
|
||||
min_confirmation_return_pct=0.005,
|
||||
fallback_liquid_largecap_slots=1,
|
||||
fallback_liquid_largecap_trigger_below=2,
|
||||
liquid_largecap_min_gain_pct=0.004,
|
||||
liquid_largecap_max_gain_pct=0.02,
|
||||
liquid_largecap_min_confirmation_return_pct=0.0005,
|
||||
liquid_largecap_min_entry_dollar_volume=50_000_000,
|
||||
liquid_largecap_min_avg_dollar_vol_30d=500_000_000,
|
||||
liquid_largecap_max_entropy_20d=0.90,
|
||||
)
|
||||
bars_by_ticker = {
|
||||
"LQ": _bars(
|
||||
"2026-01-13",
|
||||
open_price=100.0,
|
||||
closes=[100.2, 100.5, 100.8, 101.4, 101.6, 101.8],
|
||||
volumes=[500_000, 500_000, 500_000, 500_000, 500_000, 500_000],
|
||||
),
|
||||
}
|
||||
|
||||
gains = compute_morning_gains(
|
||||
bars_by_ticker,
|
||||
strategy,
|
||||
"2026-01-13",
|
||||
daily_features_by_ticker={
|
||||
"LQ": {
|
||||
"avg_daily_vol_14d": 10_000_000.0,
|
||||
"avg_dollar_vol_30d": 1_000_000_000.0,
|
||||
"ret_5d": 0.01,
|
||||
"entropy_20d": 0.88,
|
||||
"gap_pct": 0.01,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
assert list(gains) == ["LQ"]
|
||||
assert gains["LQ"]["is_liquid_largecap"] is True
|
||||
|
||||
|
||||
def test_simulate_day_skips_sparse_baskets_when_min_positions_required() -> None:
|
||||
strategy = StrategyParams(
|
||||
entry_minutes_after_open=10,
|
||||
min_morning_gain_pct=0.01,
|
||||
top_n=3,
|
||||
min_positions_to_trade=2,
|
||||
)
|
||||
bars_by_ticker = {
|
||||
"ONLY": _bars(
|
||||
"2026-01-15",
|
||||
open_price=10.0,
|
||||
closes=[10.2, 10.7, 10.9, 11.0, 11.1, 11.2],
|
||||
volumes=[120_000] * 6,
|
||||
),
|
||||
}
|
||||
|
||||
day = simulate_day(
|
||||
bars_by_ticker,
|
||||
"2026-01-15",
|
||||
strategy,
|
||||
)
|
||||
|
||||
assert day.candidates_found == 1
|
||||
assert day.trades == []
|
||||
assert day.daily_pnl == 0.0
|
||||
|
||||
|
||||
def test_simulate_day_scales_sparse_days_when_threshold_set() -> None:
|
||||
base = StrategyParams(
|
||||
entry_minutes_after_open=10,
|
||||
min_morning_gain_pct=0.01,
|
||||
top_n=3,
|
||||
initial_capital=9_000.0,
|
||||
exit_minutes_before_close=5,
|
||||
)
|
||||
scaled = StrategyParams(
|
||||
entry_minutes_after_open=10,
|
||||
min_morning_gain_pct=0.01,
|
||||
top_n=3,
|
||||
initial_capital=9_000.0,
|
||||
exit_minutes_before_close=5,
|
||||
full_size_positions_threshold=4,
|
||||
sparse_day_size_floor=0.5,
|
||||
)
|
||||
bars_by_ticker = {
|
||||
"ONLY": _bars(
|
||||
"2026-01-16",
|
||||
open_price=10.0,
|
||||
closes=[10.2, 10.8, 11.0, 11.1, 11.2, 11.4],
|
||||
volumes=[120_000] * 6,
|
||||
),
|
||||
}
|
||||
|
||||
base_day = simulate_day(bars_by_ticker, "2026-01-16", base)
|
||||
scaled_day = simulate_day(bars_by_ticker, "2026-01-16", scaled)
|
||||
|
||||
assert len(base_day.trades) == 1
|
||||
assert len(scaled_day.trades) == 1
|
||||
assert round(scaled_day.daily_pnl, 2) == round(base_day.daily_pnl * 0.5, 2)
|
||||
|
||||
|
||||
def test_simulate_day_tightens_trailing_for_overextended_leaders() -> None:
|
||||
base = StrategyParams(
|
||||
entry_minutes_after_open=10,
|
||||
min_morning_gain_pct=0.01,
|
||||
trailing_stop_pct=-0.075,
|
||||
exit_minutes_before_close=5,
|
||||
)
|
||||
tightened = StrategyParams(
|
||||
entry_minutes_after_open=10,
|
||||
min_morning_gain_pct=0.01,
|
||||
trailing_stop_pct=-0.075,
|
||||
overextended_trailing_gain_pct=0.05,
|
||||
overextended_trailing_stop_pct=-0.065,
|
||||
exit_minutes_before_close=5,
|
||||
)
|
||||
bars_by_ticker = {
|
||||
"HOT": _bars(
|
||||
"2026-01-20",
|
||||
open_price=10.0,
|
||||
closes=[10.3, 10.8, 10.9, 10.4, 10.3, 10.2],
|
||||
volumes=[120_000] * 6,
|
||||
),
|
||||
}
|
||||
|
||||
base_day = simulate_day(bars_by_ticker, "2026-01-20", base)
|
||||
tightened_day = simulate_day(bars_by_ticker, "2026-01-20", tightened)
|
||||
|
||||
assert len(base_day.trades) == 1
|
||||
assert len(tightened_day.trades) == 1
|
||||
assert tightened_day.trades[0].exit_reason == "trailing_stop"
|
||||
assert base_day.trades[0].exit_reason == "trailing_stop"
|
||||
assert tightened_day.trades[0].exit_price > base_day.trades[0].exit_price
|
||||
assert tightened_day.daily_pnl > base_day.daily_pnl
|
||||
|
||||
|
||||
def test_simulate_day_can_use_atr_catastrophic_stop_without_trailing() -> None:
|
||||
strategy = StrategyParams(
|
||||
entry_minutes_after_open=10,
|
||||
min_morning_gain_pct=0.01,
|
||||
exit_minutes_before_close=5,
|
||||
atr_stop_multiplier=0.5,
|
||||
trailing_stop_pct=None,
|
||||
)
|
||||
bars_by_ticker = {
|
||||
"ATR": _bars(
|
||||
"2026-01-21",
|
||||
open_price=10.0,
|
||||
closes=[10.2, 10.8, 11.0, 10.6, 10.5, 10.4],
|
||||
volumes=[120_000] * 6,
|
||||
),
|
||||
}
|
||||
day = simulate_day(
|
||||
bars_by_ticker,
|
||||
"2026-01-21",
|
||||
strategy,
|
||||
daily_features_by_ticker={
|
||||
"ATR": {
|
||||
"gap_pct": 0.01,
|
||||
"avg_daily_vol_14d": 1_000_000.0,
|
||||
"atr_14": 1.0,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
assert len(day.trades) == 1
|
||||
assert day.trades[0].exit_reason == "stop_loss"
|
||||
|
||||
|
||||
def test_simulate_day_delays_trailing_until_gain_threshold() -> None:
|
||||
strategy = StrategyParams(
|
||||
entry_minutes_after_open=10,
|
||||
min_morning_gain_pct=0.01,
|
||||
exit_minutes_before_close=5,
|
||||
atr_stop_multiplier=0.5,
|
||||
trailing_stop_pct=-0.05,
|
||||
trailing_activation_gain_pct=0.03,
|
||||
)
|
||||
bars_by_ticker = {
|
||||
"TRAIL": _bars(
|
||||
"2026-01-22",
|
||||
open_price=10.0,
|
||||
closes=[10.2, 10.8, 11.0, 11.3, 11.0, 10.9],
|
||||
volumes=[120_000] * 6,
|
||||
),
|
||||
}
|
||||
day = simulate_day(
|
||||
bars_by_ticker,
|
||||
"2026-01-22",
|
||||
strategy,
|
||||
daily_features_by_ticker={
|
||||
"TRAIL": {
|
||||
"gap_pct": 0.01,
|
||||
"avg_daily_vol_14d": 1_000_000.0,
|
||||
"atr_14": 1.0,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
assert len(day.trades) == 1
|
||||
assert day.trades[0].exit_reason == "trailing_stop"
|
||||
|
||||
|
||||
def test_simulate_day_can_use_opening_range_catastrophic_stop() -> None:
|
||||
strategy = StrategyParams(
|
||||
entry_minutes_after_open=10,
|
||||
min_morning_gain_pct=0.01,
|
||||
exit_minutes_before_close=5,
|
||||
opening_range_stop_multiplier=1.0,
|
||||
trailing_stop_pct=None,
|
||||
)
|
||||
bars_by_ticker = {
|
||||
"OR": _bars(
|
||||
"2026-01-23",
|
||||
open_price=10.0,
|
||||
closes=[10.15, 10.25, 10.3, 9.6, 9.5, 9.4],
|
||||
volumes=[120_000] * 6,
|
||||
),
|
||||
}
|
||||
day = simulate_day(
|
||||
bars_by_ticker,
|
||||
"2026-01-23",
|
||||
strategy,
|
||||
daily_features_by_ticker={
|
||||
"OR": {
|
||||
"gap_pct": 0.01,
|
||||
"avg_daily_vol_14d": 1_000_000.0,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
assert len(day.trades) == 1
|
||||
assert day.trades[0].exit_reason == "stop_loss"
|
||||
@ -0,0 +1,219 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from apps.web.routers import intraday
|
||||
|
||||
|
||||
MOMENTUM_CONFIG = """\
|
||||
_meta:
|
||||
name: Leader Intraday Momentum
|
||||
description: Test momentum config
|
||||
id: 7
|
||||
strategy_mode: momentum
|
||||
strategy:
|
||||
entry_minutes_after_open: 10
|
||||
exit_minutes_before_close: 5
|
||||
stop_loss_pct: null
|
||||
trailing_stop_pct: -0.06
|
||||
min_morning_gain_pct: 0.02
|
||||
max_morning_gain_pct: null
|
||||
min_entry_volume: 250000
|
||||
ticker_cooldown_days: 0
|
||||
top_n: 5
|
||||
initial_capital: 10000.0
|
||||
slippage_bps: 5.0
|
||||
market_regime_spy_threshold: null
|
||||
universe:
|
||||
source: midcap
|
||||
min_price: 10.0
|
||||
backtest:
|
||||
lookback_trading_days: 200
|
||||
pre_screen_threshold: 0.02
|
||||
cache:
|
||||
enabled: true
|
||||
dir: data/cache/intraday
|
||||
output:
|
||||
dir: runs/intraday
|
||||
verbose: false
|
||||
"""
|
||||
|
||||
MOMENTUM_YAML_UNIVERSE_CONFIG = """\
|
||||
_meta:
|
||||
name: Leader Intraday Momentum
|
||||
description: Test momentum config
|
||||
id: 7
|
||||
strategy_mode: momentum
|
||||
strategy:
|
||||
entry_minutes_after_open: 10
|
||||
exit_minutes_before_close: 5
|
||||
stop_loss_pct: null
|
||||
trailing_stop_pct: -0.06
|
||||
min_morning_gain_pct: 0.02
|
||||
max_morning_gain_pct: null
|
||||
min_entry_volume: 250000
|
||||
ticker_cooldown_days: 0
|
||||
top_n: 5
|
||||
initial_capital: 10000.0
|
||||
slippage_bps: 5.0
|
||||
market_regime_spy_threshold: null
|
||||
universe:
|
||||
source: yaml
|
||||
symbols_file: configs/symbols_midcap_smallmid.yaml
|
||||
min_price: 10.0
|
||||
backtest:
|
||||
lookback_trading_days: 200
|
||||
pre_screen_threshold: 0.02
|
||||
cache:
|
||||
enabled: true
|
||||
dir: data/cache/intraday
|
||||
output:
|
||||
dir: runs/intraday
|
||||
verbose: false
|
||||
"""
|
||||
|
||||
|
||||
def _write_config(tmp_path: Path, slug: str, text: str = MOMENTUM_CONFIG) -> Path:
|
||||
strategies_dir = tmp_path / "configs" / "intraday" / "strategies"
|
||||
strategies_dir.mkdir(parents=True, exist_ok=True)
|
||||
path = strategies_dir / f"{slug}.yaml"
|
||||
path.write_text(text)
|
||||
return path
|
||||
|
||||
|
||||
def test_load_user_strategy_supports_momentum(monkeypatch, tmp_path: Path) -> None:
|
||||
_write_config(tmp_path, "leader_intraday_momentum")
|
||||
monkeypatch.setattr(intraday, "get_project_root", lambda: tmp_path)
|
||||
|
||||
payload = intraday._load_user_strategy("leader_intraday_momentum")
|
||||
|
||||
assert payload is not None
|
||||
assert payload["strategy_mode"] == "momentum"
|
||||
assert payload["output_dir"] == "runs/intraday"
|
||||
assert payload["top_n"] == 5
|
||||
assert payload["min_morning_gain_pct"] == 0.02
|
||||
|
||||
|
||||
def test_submit_intraday_backtest_uses_config_strategy_mode(monkeypatch, tmp_path: Path) -> None:
|
||||
_write_config(tmp_path, "leader_intraday_momentum")
|
||||
monkeypatch.setattr(intraday, "get_project_root", lambda: tmp_path)
|
||||
monkeypatch.setattr(intraday, "get_runs_dir", lambda: tmp_path / "runs")
|
||||
intraday._tasks.clear()
|
||||
intraday._tasks_initialized = True
|
||||
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
class DummyProc:
|
||||
pid = 4242
|
||||
|
||||
def fake_popen(cmd, stdout=None, stderr=None, cwd=None): # noqa: ANN001
|
||||
captured["cmd"] = cmd
|
||||
captured["cwd"] = cwd
|
||||
return DummyProc()
|
||||
|
||||
class DummyThread:
|
||||
def __init__(self, target=None, args=None, daemon=None): # noqa: ANN001
|
||||
captured["thread_target"] = target
|
||||
captured["thread_args"] = args
|
||||
|
||||
def start(self) -> None:
|
||||
captured["thread_started"] = True
|
||||
|
||||
monkeypatch.setattr(intraday.subprocess, "Popen", fake_popen)
|
||||
monkeypatch.setattr(intraday.threading, "Thread", DummyThread)
|
||||
|
||||
req = intraday.IntradayBacktestRequest(
|
||||
config="leader_intraday_momentum",
|
||||
universe="midcap",
|
||||
start_date="2026-01-02",
|
||||
end_date="2026-03-31",
|
||||
compound_returns=False,
|
||||
)
|
||||
resp = intraday.submit_intraday_backtest(req)
|
||||
|
||||
task = intraday._tasks[resp["task_id"]]
|
||||
cmd = captured["cmd"]
|
||||
|
||||
assert task["strategy_mode"] == "momentum"
|
||||
assert task["output_dir"] == "runs/intraday"
|
||||
assert "--strategy" in cmd
|
||||
assert cmd[cmd.index("--strategy") + 1] == "momentum"
|
||||
assert "--output-dir" in cmd
|
||||
assert cmd[cmd.index("--output-dir") + 1] == str(tmp_path / "runs" / "intraday")
|
||||
|
||||
|
||||
def test_submit_intraday_backtest_preserves_yaml_strategy_universe(monkeypatch, tmp_path: Path) -> None:
|
||||
_write_config(tmp_path, "leader_intraday_momentum_yaml", MOMENTUM_YAML_UNIVERSE_CONFIG)
|
||||
monkeypatch.setattr(intraday, "get_project_root", lambda: tmp_path)
|
||||
monkeypatch.setattr(intraday, "get_runs_dir", lambda: tmp_path / "runs")
|
||||
intraday._tasks.clear()
|
||||
intraday._tasks_initialized = True
|
||||
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
class DummyProc:
|
||||
pid = 4242
|
||||
|
||||
def fake_popen(cmd, stdout=None, stderr=None, cwd=None): # noqa: ANN001
|
||||
captured["cmd"] = cmd
|
||||
captured["cwd"] = cwd
|
||||
return DummyProc()
|
||||
|
||||
class DummyThread:
|
||||
def __init__(self, target=None, args=None, daemon=None): # noqa: ANN001
|
||||
captured["thread_target"] = target
|
||||
captured["thread_args"] = args
|
||||
|
||||
def start(self) -> None:
|
||||
captured["thread_started"] = True
|
||||
|
||||
monkeypatch.setattr(intraday.subprocess, "Popen", fake_popen)
|
||||
monkeypatch.setattr(intraday.threading, "Thread", DummyThread)
|
||||
|
||||
req = intraday.IntradayBacktestRequest(
|
||||
config="leader_intraday_momentum_yaml",
|
||||
universe="yaml",
|
||||
start_date="2026-01-02",
|
||||
end_date="2026-03-31",
|
||||
compound_returns=False,
|
||||
)
|
||||
resp = intraday.submit_intraday_backtest(req)
|
||||
|
||||
task = intraday._tasks[resp["task_id"]]
|
||||
cmd = captured["cmd"]
|
||||
|
||||
assert task["universe"] == "yaml"
|
||||
assert task["universe_label"] == "yaml:symbols_midcap_smallmid.yaml"
|
||||
assert "--strategy" in cmd
|
||||
assert cmd[cmd.index("--strategy") + 1] == "momentum"
|
||||
assert "--universe" not in cmd
|
||||
|
||||
|
||||
def test_result_summary_from_file_includes_loss_containment_fields(tmp_path: Path) -> None:
|
||||
result_path = tmp_path / "intraday_result.json"
|
||||
result_path.write_text(
|
||||
"""{
|
||||
"metrics": {
|
||||
"total_return_pct": 0.0688,
|
||||
"max_drawdown_pct": -0.1325,
|
||||
"sharpe_ratio": 1.23,
|
||||
"win_rate": 0.569,
|
||||
"total_trades": 116,
|
||||
"final_equity": 10688.0,
|
||||
"calmar_ratio": 0.52,
|
||||
"loss_containment_score": 43.78,
|
||||
"avg_loss_day_pct": -0.0168,
|
||||
"tail_loss_20_pct": -0.0377,
|
||||
"worst_day_return_pct": -0.0672
|
||||
}
|
||||
}"""
|
||||
)
|
||||
|
||||
summary = intraday._result_summary_from_file(result_path)
|
||||
|
||||
assert summary is not None
|
||||
assert summary["return_pct"] == 0.0688
|
||||
assert summary["loss_containment_score"] == 43.78
|
||||
assert summary["avg_loss_day_pct"] == -0.0168
|
||||
assert summary["tail_loss_20_pct"] == -0.0377
|
||||
assert summary["worst_day_return_pct"] == -0.0672
|
||||
Loading…
Reference in New Issue