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
I Luk Kim 4 months ago
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()

@ -0,0 +1,148 @@
"""Parameter grid search engine for intraday backtesting.
Data is fetched once; simulations run repeatedly with different params.
288 combinations × ~200 days 5 minutes total simulation time.
"""
from __future__ import annotations
import itertools
from pathlib import Path
from typing import Any
import yaml
from libs.intraday.domain import (
IntradayConfig,
ORBStrategyParams,
StrategyParams,
SweepResult,
)
from libs.intraday.metrics import compute_metrics
from libs.intraday.simulator import run_simulation
class SweepConfig:
"""Parsed sweep configuration."""
def __init__(self, base_config: IntradayConfig, sweep_params: dict[str, list[Any]]) -> None:
self.base_config = base_config
self.sweep_params = sweep_params
@property
def total_combinations(self) -> int:
total = 1
for vals in self.sweep_params.values():
total *= len(vals)
return total
def load_sweep_config(sweep_path: str, base_config: IntradayConfig) -> SweepConfig:
"""Load a sweep YAML and merge with the base config."""
with open(sweep_path) as f:
raw = yaml.safe_load(f)
sweep_params: dict[str, list[Any]] = {}
for key, vals in raw.get("sweep", {}).items():
if not isinstance(vals, list):
vals = [vals]
# Normalize None strings and null values
normalized = [None if v in (None, "null", "none", "None") else v for v in vals]
sweep_params[key] = normalized
return SweepConfig(base_config=base_config, sweep_params=sweep_params)
def generate_combinations(sweep: SweepConfig) -> list[dict[str, Any]]:
"""Generate Cartesian product of all sweep parameters."""
keys = sorted(sweep.sweep_params.keys())
values = [sweep.sweep_params[k] for k in keys]
combos = list(itertools.product(*values))
return [dict(zip(keys, combo)) for combo in combos]
def apply_overrides(base_config: IntradayConfig, overrides: dict[str, Any]) -> IntradayConfig:
"""Apply parameter overrides to base config, returning a new config.
Branches on strategy_mode: momentum overrides go to StrategyParams,
ORB overrides go to ORBStrategyParams.
"""
if base_config.strategy_mode == "orb":
orb = base_config.orb_strategy or ORBStrategyParams()
orb_dict = orb.model_dump()
orb_fields = set(ORBStrategyParams.model_fields.keys())
for key, val in overrides.items():
if key in orb_fields:
orb_dict[key] = val
new_orb = ORBStrategyParams(**orb_dict)
return base_config.model_copy(update={"orb_strategy": new_orb})
# Momentum mode (default)
strategy_dict = base_config.strategy.model_dump()
strategy_fields = set(StrategyParams.model_fields.keys())
for key, val in overrides.items():
if key in strategy_fields:
strategy_dict[key] = val
new_strategy = StrategyParams(**strategy_dict)
return base_config.model_copy(update={"strategy": new_strategy})
def run_sweep(
sweep: SweepConfig,
all_intraday: dict[str, dict[str, list[dict]]],
trading_days: list[str],
progress_callback: Any = None,
enrichment: dict | None = None,
momentum_enrichment: dict | None = None,
vix_by_day: dict[str, float] | None = None,
ticker_sectors: dict[str, str] | None = None,
) -> list[SweepResult]:
"""Run simulation for each parameter combination.
Data is pre-fetched and shared across all runs.
Only the simulation (pure CPU computation) varies per combination.
Args:
sweep: SweepConfig with base config and param grid.
all_intraday: Pre-loaded {date: {ticker: [bars]}} data.
trading_days: List of dates.
progress_callback: Optional callable(completed, total) for progress.
enrichment: Pre-computed daily enrichment (required for ORB strategy).
Returns:
List of SweepResult sorted by Sharpe ratio descending.
"""
combos = generate_combinations(sweep)
results: list[SweepResult] = []
is_orb = sweep.base_config.strategy_mode == "orb"
for i, overrides in enumerate(combos):
config = apply_overrides(sweep.base_config, overrides)
if is_orb:
from libs.intraday.orb_simulator import run_orb_simulation
day_results = run_orb_simulation(
all_intraday, trading_days, config.orb_strategy, enrichment or {},
vix_by_day=vix_by_day,
)
else:
day_results = run_simulation(
all_intraday,
trading_days,
config.strategy,
daily_enrichment=momentum_enrichment,
vix_by_day=vix_by_day,
ticker_sectors=ticker_sectors,
)
metrics = compute_metrics(day_results, config, run_id=f"sw{i:04d}")
results.append(SweepResult(params=overrides, metrics=metrics))
if progress_callback:
progress_callback(i + 1, len(combos))
# Sort by Sharpe descending (None treated as -inf)
results.sort(
key=lambda r: (r.metrics.sharpe_ratio or float("-inf"), r.metrics.total_return_pct or -999),
reverse=True,
)
return results

@ -319,25 +319,30 @@ class AlpacaBroker:
return result
def get_latest_bars(self, symbols: list[str]) -> dict[str, Bar]:
"""Fetch the latest bar for each symbol."""
"""Fetch the latest bar for each symbol via Oracle snapshot API.
Routes through Oracle so that problematic symbols (e.g. BF-B BF.B)
are normalised server-side before hitting Alpaca.
"""
if not symbols:
return {}
from alpaca.data.requests import StockLatestBarRequest
from libs.oracle_client.alpaca import get_snapshots
req = StockLatestBarRequest(symbol_or_symbols=symbols, feed="iex")
response = self._data.get_stock_latest_bar(req)
snaps = get_snapshots(symbols)
result: dict[str, Bar] = {}
for sym in symbols:
b = response.get(sym)
if b is not None:
import datetime as _dt
today = _dt.date.today().isoformat()
for sym, snap in snaps.items():
price = snap.price or snap.mid
if price is not None:
result[sym] = Bar(
date=b.timestamp.date().isoformat() if hasattr(b.timestamp, "date") else str(b.timestamp)[:10],
open=float(b.open),
high=float(b.high),
low=float(b.low),
close=float(b.close),
volume=float(b.volume),
date=today,
open=price,
high=price,
low=price,
close=price,
volume=snap.volume or 0,
)
return result

@ -172,6 +172,7 @@ def create_schema(db_path: str | Path) -> None:
for col, dtype in [
("engine_id", "TEXT"),
("capital_bucket_id", "TEXT"),
("entry_shares", "INTEGER"),
]:
try:
conn.execute(f"ALTER TABLE trades ADD COLUMN {col} {dtype}")

@ -328,9 +328,9 @@ class StateManager:
with self._connect() as conn:
conn.execute(
"INSERT INTO trades (trade_id, session_id, symbol, engine_id, capital_bucket_id, "
"entry_date, entry_price, shares) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
"entry_date, entry_price, shares, entry_shares) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
(trade_id, session_id, symbol, engine_id, capital_bucket_id,
entry_date, entry_price, shares),
entry_date, entry_price, shares, shares),
)
return trade_id

@ -1176,6 +1176,21 @@ def _print_help() -> None:
"합성 시나리오 백테스트 (Regime Robustness Score)",
"--config NAME --scenario NAME --group NAME --quick --save",
)
t.add_row(
"intraday-overfit-check",
"ORB 과적합 분석 (IS/OOS·WFV·파라미터 plateau·permutation)",
"--config NAME --split-date DATE --quick --permutations N",
)
t.add_row(
"intraday-scenario-test",
"ORB 시나리오 테스트 (연도별 regime 슬라이스 + RVOL/ranking 검증)",
"--config NAME --scenario NAME --group NAME --quick --save",
)
t.add_row(
"intraday-orb-lab",
"ORB v2 연구 파이프라인 (coarse → rerank → test → robustness → rank)",
"--config NAME --quick --beam-width N",
)
_console.print(t)
# ── 트레이딩 & 파이프라인 ────────────────────────────────────
@ -1247,6 +1262,27 @@ def main() -> None:
scenario_main()
return
# Delegate `fithia2 intraday-overfit-check ...` to the ORB overfitting analysis CLI
if len(sys.argv) >= 2 and sys.argv[1] == "intraday-overfit-check":
sys.argv = [sys.argv[0]] + sys.argv[2:]
from apps.intraday_bt.overfit_check import main as intraday_overfit_main
intraday_overfit_main()
return
# Delegate `fithia2 intraday-scenario-test ...` to the ORB scenario test CLI
if len(sys.argv) >= 2 and sys.argv[1] == "intraday-scenario-test":
sys.argv = [sys.argv[0]] + sys.argv[2:]
from apps.intraday_bt.scenario_test import main as intraday_scenario_main
intraday_scenario_main()
return
# Delegate `fithia2 intraday-orb-lab ...` to the ORB research lab CLI
if len(sys.argv) >= 2 and sys.argv[1] == "intraday-orb-lab":
sys.argv = [sys.argv[0]] + sys.argv[2:]
from apps.intraday_bt.lab import main as intraday_orb_lab_main
intraday_orb_lab_main()
return
# Delegate `fithia2 paper ...` to the paper trader CLI
if len(sys.argv) >= 2 and sys.argv[1] == "paper":
sys.argv = [sys.argv[0]] + sys.argv[2:]

@ -29,7 +29,30 @@ _tasks: dict[str, dict[str, Any]] = {}
_tasks_lock = threading.Lock()
_tasks_initialized = False
INTRADAY_OUTPUT_DIR = "runs/intraday_orb"
_DEFAULT_OUTPUT_DIR_BY_MODE: dict[str, str] = {
"momentum": "runs/intraday",
"orb": "runs/intraday_orb",
}
_RUN_VALID_UNIVERSES: set[str] = {
"sp500",
"nasdaq100",
"midlarge",
"largecap",
"midcap",
"smallmid",
"screener",
"yaml",
}
_EDITOR_VALID_UNIVERSES: set[str] = {
"sp500",
"nasdaq100",
"midlarge",
"largecap",
"midcap",
"smallmid",
}
# Built-in strategies (read-only presets shipped with the system)
# All strategies are now directory-based (configs/intraday/strategies/).
@ -93,9 +116,10 @@ def _log_tail_error(log_path: Path, lines: int = 50) -> str:
return "(log unavailable)"
def _detect_result_file(started_at: datetime) -> Path | None:
def _detect_result_file(started_at: datetime, output_dir: str | None = None) -> Path | None:
"""Find the most recent intraday result JSON written after started_at."""
out_dir = get_project_root() / INTRADAY_OUTPUT_DIR
out_dir_name = output_dir or _DEFAULT_OUTPUT_DIR_BY_MODE["orb"]
out_dir = get_project_root() / out_dir_name
if not out_dir.exists():
return None
candidates = []
@ -125,11 +149,43 @@ def _result_summary_from_file(result_path: Path) -> dict[str, Any] | None:
"trade_count": m.get("total_trades"),
"final_equity": m.get("final_equity"),
"calmar": m.get("calmar_ratio"),
"loss_containment_score": m.get("loss_containment_score"),
"avg_loss_day_pct": m.get("avg_loss_day_pct"),
"tail_loss_20_pct": m.get("tail_loss_20_pct"),
"worst_day_return_pct": m.get("worst_day_return_pct"),
}
except Exception:
return None
def _load_strategy_runtime(config_path: str | Path) -> tuple[str, str]:
"""Return (strategy_mode, output_dir) derived from a config file."""
raw = yaml.safe_load(Path(config_path).read_text()) or {}
strategy_mode = str(raw.get("strategy_mode") or "momentum").lower()
if strategy_mode not in _DEFAULT_OUTPUT_DIR_BY_MODE:
strategy_mode = "momentum"
output_dir = str(
raw.get("output", {}).get("dir")
or _DEFAULT_OUTPUT_DIR_BY_MODE[strategy_mode]
)
return strategy_mode, output_dir
def _load_strategy_universe(config_path: str | Path) -> tuple[str, str | None]:
"""Return (universe_source, symbols_file) from a strategy config."""
raw = yaml.safe_load(Path(config_path).read_text()) or {}
universe = raw.get("universe", {}) or {}
source = str(universe.get("source") or "midlarge").lower()
symbols_file = universe.get("symbols_file")
return source, str(symbols_file) if symbols_file else None
def _format_universe_label(source: str, symbols_file: str | None) -> str:
if source == "yaml" and symbols_file:
return f"yaml:{Path(symbols_file).name}"
return source
def _watch_process(task_id: str, proc: subprocess.Popen) -> None: # type: ignore[type-arg]
"""Background thread: wait for subprocess to finish, then update task state."""
import time
@ -162,7 +218,7 @@ def _watch_process(task_id: str, proc: subprocess.Popen) -> None: # type: ignor
# Allow a brief moment for file system writes to flush
time.sleep(1)
result_path = _detect_result_file(started_at)
result_path = _detect_result_file(started_at, task.get("output_dir") if task else None)
log_path = _log_file(task_id)
with _tasks_lock:
@ -175,6 +231,7 @@ def _watch_process(task_id: str, proc: subprocess.Popen) -> None: # type: ignor
task["pid"] = None
task["finished_at"] = datetime.now(timezone.utc).isoformat()
task["returncode"] = proc.returncode
if proc.returncode == 0 and result_path:
task["status"] = "completed"
@ -215,7 +272,7 @@ def _load_tasks_from_disk() -> None:
if started_at_str:
try:
started_at = datetime.fromisoformat(started_at_str.replace("Z", "+00:00"))
result_path = _detect_result_file(started_at)
result_path = _detect_result_file(started_at, data.get("output_dir"))
except Exception:
pass
if result_path:
@ -274,37 +331,68 @@ def _load_user_strategy(slug: str) -> dict[str, Any] | None:
try:
raw = yaml.safe_load(path.read_text()) or {}
meta = raw.get("_meta", {})
strategy_mode = str(raw.get("strategy_mode") or "momentum").lower()
orb = raw.get("orb_strategy", {})
momentum = raw.get("strategy", {})
backtest = raw.get("backtest", {})
universe = raw.get("universe", {})
return {
base = {
"slug": slug,
"id": meta.get("id"),
"name": meta.get("name", slug),
"description": meta.get("description", ""),
"builtin": False,
"config_path": str(path.relative_to(get_project_root())),
"initial_capital": orb.get("initial_capital", 10000.0),
"risk_per_trade_pct": orb.get("risk_per_trade_pct", 0.0025),
"max_position_pct": orb.get("max_position_pct", 0.20),
"atr_stop_multiplier": orb.get("atr_stop_multiplier", 0.50),
"min_rvol": orb.get("min_rvol", 1.0),
"max_candidates": orb.get("max_candidates", 20),
"daily_max_loss_pct": orb.get("daily_max_loss_pct", 0.0125),
"max_stops_per_day": orb.get("max_stops_per_day", 3),
"breakeven_at_r": orb.get("breakeven_at_r", 1.0),
"trailing_at_r": orb.get("trailing_at_r", 2.0),
"trailing_stop_atr_multiplier": orb.get("trailing_stop_atr_multiplier", 0.0),
"order_timeout_minutes": orb.get("order_timeout_minutes", 45),
"settlement_days": orb.get("settlement_days", 1),
"min_candidate_breadth": orb.get("min_candidate_breadth"),
"max_gap_pct": orb.get("max_gap_pct", 0.10),
"sim_bar_minutes": orb.get("sim_bar_minutes", 5),
"orb_minutes": orb.get("orb_minutes", 5),
"compound_returns": orb.get("compound_returns", True),
"days": backtest.get("lookback_trading_days", 200),
"universe": universe.get("source", "midlarge"),
"universe_symbols_file": universe.get("symbols_file"),
"universe_label": _format_universe_label(
str(universe.get("source", "midlarge")),
str(universe.get("symbols_file")) if universe.get("symbols_file") else None,
),
"strategy_mode": strategy_mode,
"output_dir": raw.get("output", {}).get(
"dir",
_DEFAULT_OUTPUT_DIR_BY_MODE.get(strategy_mode, _DEFAULT_OUTPUT_DIR_BY_MODE["momentum"]),
),
}
if strategy_mode == "orb":
base.update({
"initial_capital": orb.get("initial_capital", 10000.0),
"risk_per_trade_pct": orb.get("risk_per_trade_pct", 0.0025),
"max_position_pct": orb.get("max_position_pct", 0.20),
"atr_stop_multiplier": orb.get("atr_stop_multiplier", 0.50),
"min_rvol": orb.get("min_rvol", 1.0),
"max_candidates": orb.get("max_candidates", 20),
"daily_max_loss_pct": orb.get("daily_max_loss_pct", 0.0125),
"max_stops_per_day": orb.get("max_stops_per_day", 3),
"breakeven_at_r": orb.get("breakeven_at_r", 1.0),
"trailing_at_r": orb.get("trailing_at_r", 2.0),
"trailing_stop_atr_multiplier": orb.get("trailing_stop_atr_multiplier", 0.0),
"order_timeout_minutes": orb.get("order_timeout_minutes", 45),
"settlement_days": orb.get("settlement_days", 1),
"min_candidate_breadth": orb.get("min_candidate_breadth"),
"max_gap_pct": orb.get("max_gap_pct", 0.10),
"sim_bar_minutes": orb.get("sim_bar_minutes", 5),
"orb_minutes": orb.get("orb_minutes", 5),
"compound_returns": orb.get("compound_returns", True),
})
else:
base.update({
"initial_capital": momentum.get("initial_capital", 10000.0),
"entry_minutes_after_open": momentum.get("entry_minutes_after_open", 30),
"exit_minutes_before_close": momentum.get("exit_minutes_before_close", 30),
"top_n": momentum.get("top_n", 3),
"min_morning_gain_pct": momentum.get("min_morning_gain_pct", 0.01),
"max_morning_gain_pct": momentum.get("max_morning_gain_pct"),
"min_entry_volume": momentum.get("min_entry_volume"),
"stop_loss_pct": momentum.get("stop_loss_pct"),
"trailing_stop_pct": momentum.get("trailing_stop_pct"),
"ticker_cooldown_days": momentum.get("ticker_cooldown_days", 0),
"market_regime_spy_threshold": momentum.get("market_regime_spy_threshold"),
"compound_returns": False,
})
return base
except Exception:
return None
@ -426,6 +514,7 @@ class IntradayBacktestRequest(BaseModel):
# Per-run overrides
compound_returns: bool | None = None # None = use strategy config default
initial_capital: float | None = None # None = use strategy config default
daily_budget_reset: bool | None = None # Research mode: reset budget daily
class CreateStrategyRequest(BaseModel):
@ -509,8 +598,7 @@ def get_strategy(slug: str) -> dict[str, Any]:
@router.post("/strategies")
def create_strategy(req: CreateStrategyRequest) -> dict[str, Any]:
"""Create a new user-defined strategy."""
valid_universes = {"sp500", "nasdaq100", "midlarge", "largecap", "midcap"}
if req.universe not in valid_universes:
if req.universe not in _EDITOR_VALID_UNIVERSES:
raise HTTPException(status_code=400, detail=f"Unknown universe: {req.universe!r}")
slug = _slugify(req.name)
@ -528,6 +616,7 @@ def create_strategy(req: CreateStrategyRequest) -> dict[str, Any]:
"builtin": False,
"days": req.days,
"universe": req.universe,
"universe_label": req.universe,
"initial_capital": req.initial_capital,
"risk_per_trade_pct": req.risk_per_trade_pct,
"max_position_pct": req.max_position_pct,
@ -557,13 +646,18 @@ def update_strategy(slug: str, req: UpdateStrategyRequest) -> dict[str, Any]:
strat = _load_user_strategy(slug)
if not strat:
raise HTTPException(status_code=404, detail=f"Strategy not found: {slug!r}")
if strat.get("strategy_mode") != "orb":
raise HTTPException(
status_code=400,
detail="Web strategy editor currently supports ORB strategies only",
)
# Apply non-None updates
updates = req.model_dump(exclude_none=True)
for k, v in updates.items():
strat[k] = v
if req.universe and req.universe not in {"sp500", "nasdaq100", "midlarge", "largecap", "midcap"}:
if req.universe and req.universe not in _EDITOR_VALID_UNIVERSES:
raise HTTPException(status_code=400, detail=f"Unknown universe: {req.universe!r}")
_save_user_strategy(strat)
@ -576,6 +670,11 @@ def copy_strategy(slug: str) -> dict[str, Any]:
strat = _load_user_strategy(slug)
if not strat:
raise HTTPException(status_code=404, detail=f"Strategy not found: {slug!r}")
if strat.get("strategy_mode") != "orb":
raise HTTPException(
status_code=400,
detail="Web strategy copy currently supports ORB strategies only",
)
new_name = f"{strat['name']} (copy)"
base_slug = _slugify(new_name)
@ -616,8 +715,7 @@ def submit_intraday_backtest(req: IntradayBacktestRequest) -> dict[str, Any]:
# Resolve config slug → YAML path
config_path = _resolve_config_path(req.config)
valid_universes = {"sp500", "nasdaq100", "midlarge", "largecap", "midcap"}
if req.universe not in valid_universes:
if req.universe not in _RUN_VALID_UNIVERSES:
raise HTTPException(status_code=400, detail=f"Unknown universe: {req.universe!r}")
# Normalize dates
@ -636,11 +734,13 @@ def submit_intraday_backtest(req: IntradayBacktestRequest) -> dict[str, Any]:
"start_date": start_iso,
"end_date": end_iso,
"compound_returns": req.compound_returns,
"daily_budget_reset": req.daily_budget_reset,
"status": "queued",
"created_at": datetime.now(timezone.utc).isoformat(),
"started_at": None,
"finished_at": None,
"pid": None,
"returncode": None,
"error": None,
"result_file": None,
"result_summary": None,
@ -650,14 +750,23 @@ def submit_intraday_backtest(req: IntradayBacktestRequest) -> dict[str, Any]:
_log_dir().mkdir(parents=True, exist_ok=True)
log_path = _log_file(task_id)
output_dir = str(get_project_root() / INTRADAY_OUTPUT_DIR)
strategy_mode, output_dir_name = _load_strategy_runtime(config_path)
_strategy_universe_source, strategy_universe_symbols_file = _load_strategy_universe(config_path)
task["strategy_mode"] = strategy_mode
task["output_dir"] = output_dir_name
task["universe_label"] = _format_universe_label(
req.universe,
strategy_universe_symbols_file if req.universe == "yaml" else None,
)
output_dir = str(project_root / output_dir_name)
cmd = [
sys.executable, "-m", "apps.intraday_bt.run",
"--strategy", "orb",
sys.executable, "-u", "-m", "apps.intraday_bt.run",
"--strategy", strategy_mode,
"--config", config_path,
"--universe", req.universe,
"--output-dir", output_dir,
]
if req.universe != "yaml":
cmd.extend(["--universe", req.universe])
# Date params: explicit range takes precedence over lookback days
if start_iso:
cmd.extend(["--start", start_iso])
@ -676,6 +785,12 @@ def submit_intraday_backtest(req: IntradayBacktestRequest) -> dict[str, Any]:
elif req.compound_returns is False:
cmd.append("--no-compound-returns")
# Per-run daily budget reset override (research mode)
if req.daily_budget_reset is True:
cmd.append("--daily-budget-reset")
elif req.daily_budget_reset is False:
cmd.append("--no-daily-budget-reset")
with _tasks_lock:
_tasks[task_id] = task
_persist_task(task)
@ -722,7 +837,7 @@ def _try_resolve_dead_task(task_id: str) -> None:
except Exception:
pass
result_path = _detect_result_file(started_at)
result_path = _detect_result_file(started_at, task.get("output_dir"))
task["pid"] = None
if not task.get("finished_at"):
task["finished_at"] = datetime.now(timezone.utc).isoformat()

@ -231,18 +231,40 @@ def resume_session(session_id: str) -> dict[str, Any]:
@router.delete("/sessions/{session_id}")
def close_session(session_id: str) -> dict[str, Any]:
"""Liquidate all Alpaca positions and delete all session data."""
"""Liquidate this session's Alpaca positions and delete all session data."""
state = _get_state_manager()
session = state.get_session(session_id)
if session is None:
raise HTTPException(status_code=404, detail="Session not found")
# Collect (symbol, qty) for this session only — use trade records for strategy
# positions so orphaned shares in the broker are not accidentally sold.
positions_to_close: list[tuple[str, int | None]] = []
open_trades_by_sym = {
t["symbol"]: t
for t in state.list_trades(session.session_id)
if t.get("exit_date") is None
}
for ss in state.get_open_strategy_states(session.session_id):
ot = open_trades_by_sym.get(ss.symbol)
qty = int(ot["shares"]) if ot and ot.get("shares") else None
positions_to_close.append((ss.symbol, qty))
parking = state.get_parking_state(session.session_id)
if parking:
# Parking position has no orphaned shares — use full close (no qty) to avoid
# mismatch errors if Alpaca qty differs slightly from DB.
positions_to_close.append((parking["symbol"].upper(), None))
orders_closed = 0
broker_error: str | None = None
try:
broker = _get_broker()
orders = broker.close_all_positions()
orders_closed = len(orders)
for sym, qty in positions_to_close:
try:
broker.close_position(sym, qty=qty)
orders_closed += 1
except Exception as exc:
broker_error = (broker_error + "; " if broker_error else "") + f"{sym}: {exc}"
except Exception as exc:
broker_error = str(exc)
@ -272,36 +294,54 @@ def get_positions(session_id: str) -> dict[str, Any]:
for ss in state.get_open_strategy_states(session.session_id)
}
parking_state = state.get_parking_state(session.session_id)
parking_symbol = parking_state["symbol"].upper() if parking_state else None
# Open trade records keyed by symbol — for locally-tracked entry price/qty
open_trades = {
t["symbol"]: t
for t in state.list_trades(session.session_id)
if t.get("exit_date") is None and t.get("symbol") != (parking_symbol or "")
}
try:
broker = _get_broker()
positions = broker.list_positions()
broker_by_symbol = {p.symbol: p for p in positions}
result = []
for p in sorted(positions, key=lambda x: x.symbol):
ss = strategy_states.get(p.symbol)
if ss is None:
is_parking = parking_symbol and p.symbol == parking_symbol
if ss is None and not is_parking:
continue # Only show positions tracked by this session
qty = float(p.qty)
entry = float(p.avg_entry_price) if p.avg_entry_price else 0.0
pnl = float(p.unrealized_pl)
# Use locally-recorded entry price/qty for strategy positions to avoid
# orphaned-share contamination of Alpaca's blended avg_entry_price.
ot = open_trades.get(p.symbol) if not is_parking else None
qty = float(ot["shares"]) if ot and ot.get("shares") else float(p.qty)
entry = float(ot["entry_price"]) if ot and ot.get("entry_price") else (
float(p.avg_entry_price) if p.avg_entry_price else 0.0
)
cur_price = float(p.current_price) if p.current_price else None
pnl = (cur_price - entry) * qty if cur_price and entry and qty else float(p.unrealized_pl)
pnl_pct = pnl / (entry * qty) * 100 if entry and qty else 0.0
result.append({
"symbol": p.symbol,
"qty": qty,
"avg_entry_price": entry,
"current_price": float(p.current_price) if p.current_price else None,
"current_price": cur_price,
"unrealized_pl": pnl,
"unrealized_pl_pct": pnl_pct,
"days_held": ss.days_held if ss else None,
"stop_price": ss.current_stop if ss else None,
"target_price": ss.target_price if ss else None,
"entry_date": ss.entry_date if ss else None,
"engine_id": ss.engine_id if ss else None,
"entry_date": ss.entry_date if ss else (parking_state["entry_date"] if is_parking else None),
"engine_id": ss.engine_id if ss else ("parking" if is_parking else None),
"trade_direction": ss.trade_direction if ss else None,
"_parking": bool(is_parking and not ss),
})
# Include strategy states missing from broker (ghost positions)
broker_symbols = {p.symbol for p in positions}
for sym, ss in strategy_states.items():
if sym not in broker_symbols:
if sym not in broker_by_symbol:
result.append({
"symbol": sym,
"qty": None,
@ -317,6 +357,24 @@ def get_positions(session_id: str) -> dict[str, Any]:
"trade_direction": ss.trade_direction,
"_ghost": True,
})
# Include parking if not already in broker positions
if parking_symbol and parking_symbol not in broker_by_symbol:
result.append({
"symbol": parking_symbol,
"qty": parking_state.get("qty"),
"avg_entry_price": parking_state.get("avg_price"),
"current_price": None,
"unrealized_pl": None,
"unrealized_pl_pct": None,
"days_held": None,
"stop_price": None,
"target_price": None,
"entry_date": parking_state.get("entry_date"),
"engine_id": "parking",
"trade_direction": None,
"_parking": True,
"_ghost": True,
})
return {"positions": result, "broker_available": True}
except Exception as exc:
result = []
@ -335,6 +393,23 @@ def get_positions(session_id: str) -> dict[str, Any]:
"engine_id": ss.engine_id,
"trade_direction": ss.trade_direction,
})
if parking_state:
result.append({
"symbol": parking_symbol,
"qty": parking_state.get("qty"),
"avg_entry_price": parking_state.get("avg_price"),
"current_price": None,
"unrealized_pl": None,
"unrealized_pl_pct": None,
"days_held": None,
"stop_price": None,
"target_price": None,
"entry_date": parking_state.get("entry_date"),
"engine_id": "parking",
"trade_direction": None,
"_parking": True,
"_ghost": True,
})
return {"positions": result, "broker_available": False, "broker_error": str(exc)}
@ -350,10 +425,14 @@ def get_trades(
import datetime
trades = state.list_trades(session.session_id, limit=last)
# Include parking entries (active + closed) as parking-sleeve trades
# Include parking entries as fallback only when trades table has no record for that symbol
# (old sessions before open_trade was added to _parking_buy).
symbols_in_trades = {t["symbol"] for t in trades}
parking_entries = state.list_parking_entries(session.session_id)
today = datetime.date.today()
for p in parking_entries:
if p["symbol"] in symbols_in_trades:
continue # already recorded via open_trade / record_trade
entry_date = p.get("entry_date", "")
try:
days = (today - datetime.date.fromisoformat(entry_date)).days

@ -357,6 +357,10 @@ export interface IntradayTask {
trade_count: number | null;
final_equity: number | null;
calmar: number | null;
loss_containment_score?: number | null;
avg_loss_day_pct?: number | null;
tail_loss_20_pct?: number | null;
worst_day_return_pct?: number | null;
} | null;
}
@ -400,6 +404,10 @@ export interface IntradayResult {
calmar_ratio: number | null;
initial_capital: number;
final_equity: number;
avg_loss_day_pct?: number | null;
tail_loss_20_pct?: number | null;
worst_day_return_pct?: number | null;
loss_containment_score?: number | null;
};
trades: IntradayTrade[];
daily_summary: { date: string; daily_pnl: number; daily_return_pct: number; candidates_found: number; trades: number }[];

@ -61,12 +61,35 @@ function taskPeriodLabel(task: IntradayTask): string {
return `last ${task.days}d`;
}
const UNIVERSE_OPTIONS = [
const RUN_UNIVERSE_OPTIONS = [
{ value: 'midlarge', label: 'Mid+Large Cap (971)' },
{ value: 'midcap', label: 'Mid Cap' },
{ value: 'largecap', label: 'Large Cap' },
{ value: 'smallmid', label: 'Small+Mid' },
{ value: 'sp500', label: 'S&P 500' },
{ value: 'nasdaq100', label: 'NASDAQ 100' },
];
const STRATEGY_UNIVERSE_OPTIONS = [
{ value: 'midlarge', label: 'Mid+Large Cap (971)' },
{ value: 'midcap', label: 'Mid Cap' },
{ value: 'largecap', label: 'Large Cap' },
{ value: 'smallmid', label: 'Small+Mid' },
{ value: 'sp500', label: 'S&P 500' },
{ value: 'nasdaq100', label: 'NASDAQ 100' },
];
function buildRunUniverseOptions(strategy: IntradayStrategy) {
const options = [...RUN_UNIVERSE_OPTIONS];
if (strategy.universe === 'yaml') {
const yamlLabel = strategy.universe_symbols_file
? `Strategy YAML (${strategy.universe_symbols_file.split('/').slice(-1)[0]})`
: 'Strategy YAML Universe';
options.unshift({ value: 'yaml', label: yamlLabel });
}
return options;
}
const inputStyle: React.CSSProperties = {
padding: '7px 10px', background: 'var(--bg2)', border: '1px solid var(--border)',
borderRadius: 6, color: 'var(--text1)', fontSize: 13, width: '100%',
@ -568,7 +591,7 @@ export function IntradayTaskDetailPage() {
<div>
<h1 style={{ fontSize: 22, fontWeight: 700, color: 'var(--text1)', margin: '0 0 6px' }}>{strategyName}</h1>
<div style={{ fontSize: 13, color: 'var(--text3)', fontFamily: 'var(--font-mono)' }}>
{taskPeriodLabel(task)} · {task.universe}
{taskPeriodLabel(task)} · {task.universe_label ?? task.universe}
</div>
</div>
{isActive && (
@ -597,6 +620,7 @@ export function IntradayTaskDetailPage() {
<MetricCard label="Return" value={r.return_pct != null ? fmtPct(r.return_pct) : '—'} color={r.return_pct != null ? (r.return_pct >= 0 ? 'var(--green)' : 'var(--red)') : undefined} />
<MetricCard label="Equity" value={fmtMoney(r.final_equity)} />
<MetricCard label="Max DD" value={r.max_dd_pct != null ? fmtPct(r.max_dd_pct) : '—'} color={r.max_dd_pct != null && r.max_dd_pct < 0 ? 'var(--red)' : 'var(--text1)'} />
<MetricCard label="Loss Score" value={r.loss_containment_score != null ? fmt(r.loss_containment_score) : '—'} color={r.loss_containment_score != null && r.loss_containment_score >= 70 ? 'var(--green)' : undefined} />
<MetricCard label="Sharpe" value={fmt(r.sharpe)} color={r.sharpe != null && r.sharpe >= 1 ? 'var(--green)' : undefined} />
<MetricCard label="Win Rate" value={r.win_rate != null ? `${r.win_rate.toFixed(1)}%` : '—'} />
<MetricCard label="Trades" value={r.trade_count != null ? String(r.trade_count) : '—'} />
@ -634,6 +658,10 @@ export function IntradayTaskDetailPage() {
['Win Rate', result.metrics.win_rate != null ? `${(result.metrics.win_rate * 100).toFixed(1)}%` : '—'],
['Avg Win', fmtPct(result.metrics.avg_win_pct)],
['Avg Loss', fmtPct(result.metrics.avg_loss_pct)],
['Avg Losing Day', fmtPct(result.metrics.avg_loss_day_pct)],
['Tail Loss (20%)', fmtPct(result.metrics.tail_loss_20_pct)],
['Worst Day', fmtPct(result.metrics.worst_day_return_pct)],
['Loss Score', fmt(result.metrics.loss_containment_score)],
['Profit Factor', fmt(result.metrics.profit_factor)],
['Total Trades', String(result.metrics.total_trades)],
['Initial Capital', fmtMoney(result.metrics.initial_capital)],
@ -746,9 +774,16 @@ interface RunConfig {
endDate: string;
universe: string;
compound_returns: boolean;
daily_budget_reset: boolean;
initialCapital: number;
}
type ReturnsMode = 'simple' | 'compound' | 'reset';
function modeFromCfg(c: { compound_returns: boolean; daily_budget_reset: boolean }): ReturnsMode {
if (c.daily_budget_reset) return 'reset';
return c.compound_returns ? 'compound' : 'simple';
}
function RunConfigPopup({
strategy,
initialConfig,
@ -770,6 +805,7 @@ function RunConfigPopup({
endDate: '',
universe: strategy.universe,
compound_returns: strategy.compound_returns ?? false,
daily_budget_reset: strategy.daily_budget_reset ?? false,
initialCapital: strategy.initial_capital ?? 10000,
...initialConfig,
});
@ -850,23 +886,49 @@ function RunConfigPopup({
<div>
<label style={labelStyle}>Universe</label>
<select value={cfg.universe} onChange={e => set('universe', e.target.value)} style={selectStyle}>
{UNIVERSE_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
{buildRunUniverseOptions(strategy).map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
</div>
</div>
{/* Returns toggle */}
<div style={{ marginBottom: 28, display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '12px 14px', background: 'var(--bg2)', borderRadius: 8, border: '1px solid var(--border)' }}>
<span style={{ fontSize: 12, color: 'var(--text3)', textTransform: 'uppercase', letterSpacing: '0.07em', fontWeight: 600 }}>Returns Mode</span>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<span style={{ fontSize: 13, color: cfg.compound_returns ? 'var(--text3)' : 'var(--cyan)', fontWeight: cfg.compound_returns ? 400 : 600 }}></span>
<div onClick={() => set('compound_returns', !cfg.compound_returns)}
style={{ width: 42, height: 24, borderRadius: 12, background: cfg.compound_returns ? 'var(--cyan)' : 'var(--bg3)', border: '1px solid var(--border)', cursor: 'pointer', position: 'relative', transition: 'background 0.2s' }}>
<div style={{ position: 'absolute', top: 3, left: cfg.compound_returns ? 20 : 3, width: 16, height: 16, borderRadius: '50%', background: 'white', transition: 'left 0.2s', boxShadow: '0 1px 3px rgba(0,0,0,0.3)' }} />
{/* Returns mode: 3-way selector */}
{(() => {
const mode = modeFromCfg(cfg);
const setMode = (m: ReturnsMode) => {
setCfg(c => ({
...c,
compound_returns: m === 'compound',
daily_budget_reset: m === 'reset',
}));
};
const opt = (m: ReturnsMode, label: string, sub: string) => {
const active = mode === m;
return (
<button key={m} onClick={() => setMode(m)}
style={{
flex: 1, padding: '8px 10px', fontSize: 12, fontFamily: 'var(--font-mono)',
background: active ? 'var(--cyan-dim)' : 'var(--bg2)',
border: 'none', borderRight: '1px solid var(--border)',
color: active ? 'var(--cyan)' : 'var(--text3)',
cursor: 'pointer', fontWeight: active ? 600 : 400,
display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 2,
}}>
<span>{label}</span>
<span style={{ fontSize: 10, opacity: 0.75 }}>{sub}</span>
</button>
);
};
return (
<div style={{ marginBottom: 28 }}>
<label style={labelStyle}>Returns Mode</label>
<div style={{ display: 'flex', border: '1px solid var(--border)', borderRadius: 8, overflow: 'hidden' }}>
{opt('simple', '단리', 'fixed capital')}
{opt('compound', '복리', 'grows w/ equity')}
{opt('reset', '리셋', 'daily reset (연구용)')}
</div>
</div>
<span style={{ fontSize: 13, color: cfg.compound_returns ? 'var(--cyan)' : 'var(--text3)', fontWeight: cfg.compound_returns ? 600 : 400 }}></span>
</div>
</div>
);
})()}
{/* Buttons */}
<div style={{ display: 'flex', gap: 10 }}>
@ -892,7 +954,7 @@ const DEFAULT_FORM: Omit<IntradayStrategy, 'slug'> = {
daily_max_loss_pct: 0.06, max_stops_per_day: 5, breakeven_at_r: 1.0,
trailing_at_r: 2.0, trailing_stop_atr_multiplier: 0.0, order_timeout_minutes: 45,
settlement_days: 1, max_gap_pct: 0.10, sim_bar_minutes: 30,
orb_minutes: 5, compound_returns: false,
orb_minutes: 5, compound_returns: false, daily_budget_reset: false,
};
function StrategyModal({
@ -910,11 +972,11 @@ function StrategyModal({
setForm(f => ({ ...f, [key]: v }));
};
const fields: { key: keyof typeof form; label: string; type: 'text' | 'number' | 'select' | 'textarea' | 'checkbox'; options?: { value: string; label: string }[]; step?: number; min?: number; max?: number; hint?: string }[] = [
const fields: { key: keyof typeof form; label: string; type: 'text' | 'number' | 'select' | 'textarea' | 'checkbox'; options?: { value: string; label: string }[]; step?: number; min?: number; max?: number; hint?: string; offLabel?: string; onLabel?: string }[] = [
{ key: 'name', label: 'Strategy Name', type: 'text', hint: 'Unique name' },
{ key: 'description', label: 'Description', type: 'textarea', hint: 'Optional' },
{ key: 'days', label: 'Default Lookback Days', type: 'number', min: 20, max: 500, step: 1 },
{ key: 'universe', label: 'Default Universe', type: 'select', options: UNIVERSE_OPTIONS },
{ key: 'universe', label: 'Default Universe', type: 'select', options: STRATEGY_UNIVERSE_OPTIONS },
{ key: 'initial_capital', label: 'Initial Capital ($)', type: 'number', min: 1000, step: 1000 },
{ key: 'risk_per_trade_pct', label: 'Risk / Trade', type: 'number', min: 0.001, max: 0.5, step: 0.001, hint: '0.02 = 2%' },
{ key: 'max_position_pct', label: 'Max Position', type: 'number', min: 0.05, max: 1.0, step: 0.05, hint: '0.6 = 60%' },
@ -931,7 +993,8 @@ function StrategyModal({
{ key: 'max_gap_pct', label: 'Max Gap %', type: 'number', min: 0.005, max: 1.0, step: 0.005, hint: '0.01 = 1%' },
{ key: 'orb_minutes', label: 'ORB Window (min)', type: 'number', min: 5, max: 30, step: 5, hint: '5/10/15/20 — opening range width' },
{ key: 'sim_bar_minutes', label: 'Sim Bar (min)', type: 'number', min: 5, max: 120, step: 5, hint: '30/60/90/120 — stop check interval' },
{ key: 'compound_returns', label: 'Compound Returns (복리)', type: 'checkbox', hint: 'On=position size grows with equity; Off=fixed initial capital (단리)' },
{ key: 'compound_returns', label: 'Compound Returns (복리)', type: 'checkbox', hint: 'On=position size grows with equity; Off=fixed initial capital (단리)', offLabel: '단리', onLabel: '복리' },
{ key: 'daily_budget_reset', label: 'Daily Budget Reset (연구용)', type: 'checkbox', hint: 'Research mode: each day resets sizing to initial_capital (overrides compound)', offLabel: '일반', onLabel: '리셋' },
];
return (
@ -942,7 +1005,7 @@ function StrategyModal({
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '14px 20px' }}>
{fields.map(f => (
<div key={f.key} style={f.key === 'name' || f.key === 'description' || f.key === 'compound_returns' ? { gridColumn: '1 / -1' } : {}}>
<div key={f.key} style={f.key === 'name' || f.key === 'description' || f.key === 'compound_returns' || f.key === 'daily_budget_reset' ? { gridColumn: '1 / -1' } : {}}>
<label style={{ display: 'block', fontSize: 12, color: 'var(--text3)', marginBottom: 4 }}>
{f.label}{f.hint && <span style={{ marginLeft: 6, fontSize: 11, opacity: 0.7 }}> {f.hint}</span>}
</label>
@ -954,12 +1017,12 @@ function StrategyModal({
<textarea value={String(form[f.key])} onChange={set(f.key)} rows={2} style={{ ...inputStyle, resize: 'vertical', fontFamily: 'inherit' }} />
) : f.type === 'checkbox' ? (
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 4 }}>
<span style={{ fontSize: 13, color: form[f.key] ? 'var(--text3)' : 'var(--cyan)', fontWeight: form[f.key] ? 400 : 600 }}></span>
<span style={{ fontSize: 13, color: form[f.key] ? 'var(--text3)' : 'var(--cyan)', fontWeight: form[f.key] ? 400 : 600 }}>{f.offLabel ?? 'Off'}</span>
<div onClick={() => setForm(prev => ({ ...prev, [f.key]: !prev[f.key] }))}
style={{ width: 40, height: 22, borderRadius: 11, background: form[f.key] ? 'var(--cyan)' : 'var(--bg3)', border: '1px solid var(--border)', cursor: 'pointer', position: 'relative', transition: 'background 0.2s' }}>
<div style={{ position: 'absolute', top: 2, left: form[f.key] ? 19 : 2, width: 16, height: 16, borderRadius: '50%', background: 'white', transition: 'left 0.2s', boxShadow: '0 1px 3px rgba(0,0,0,0.3)' }} />
</div>
<span style={{ fontSize: 13, color: form[f.key] ? 'var(--cyan)' : 'var(--text3)', fontWeight: form[f.key] ? 600 : 400 }}></span>
<span style={{ fontSize: 13, color: form[f.key] ? 'var(--cyan)' : 'var(--text3)', fontWeight: form[f.key] ? 600 : 400 }}>{f.onLabel ?? 'On'}</span>
</div>
) : (
<input type={f.type} value={String(form[f.key])} onChange={set(f.key)} min={f.min} max={f.max} step={f.step} style={inputStyle} />
@ -991,6 +1054,21 @@ const stdStyle: React.CSSProperties = {
padding: '7px 8px', fontSize: 12, fontFamily: 'var(--font-mono)', color: 'var(--text2)', whiteSpace: 'nowrap',
};
function isOrbStrategy(s: IntradayStrategy): boolean {
return (s.strategy_mode ?? 'orb') === 'orb';
}
function strategyModeLabel(s: IntradayStrategy): string {
return isOrbStrategy(s) ? 'ORB' : 'Momentum';
}
function strategySummaryLabel(s: IntradayStrategy): string {
if (isOrbStrategy(s)) {
return `risk ${fmtPct(s.risk_per_trade_pct, 1)} · atr ${fmt(s.atr_stop_multiplier)} · rvol ${fmt(s.min_rvol, 1)}`;
}
return `entry +${s.entry_minutes_after_open ?? '—'}m · top ${s.top_n ?? '—'} · gain ${fmtPct(s.min_morning_gain_pct, 1)}`;
}
function StrategyTable({
strategies, onEdit, onDelete, onRun, onCopy,
}: {
@ -1006,12 +1084,10 @@ function StrategyTable({
<thead>
<tr style={{ background: 'var(--bg2)', borderBottom: '1px solid var(--border)' }}>
<th style={sthStyle}>Name</th>
<th style={{ ...sthStyle, textAlign: 'right' }}>Risk</th>
<th style={{ ...sthStyle, textAlign: 'right' }}>Pos</th>
<th style={{ ...sthStyle, textAlign: 'right' }}>ATR×</th>
<th style={{ ...sthStyle, textAlign: 'right' }}>RVOL</th>
<th style={{ ...sthStyle, textAlign: 'right' }}>Gap</th>
<th style={{ ...sthStyle, textAlign: 'right' }}>Settle</th>
<th style={{ ...sthStyle, textAlign: 'center' }}>Mode</th>
<th style={{ ...sthStyle, textAlign: 'right' }}>Universe</th>
<th style={{ ...sthStyle, textAlign: 'right' }}>Profile</th>
<th style={{ ...sthStyle, textAlign: 'right' }}>Days</th>
<th style={{ ...sthStyle, textAlign: 'center' }}></th>
</tr>
</thead>
@ -1026,18 +1102,22 @@ function StrategyTable({
<span style={{ fontSize: 10, fontWeight: 700, color: 'var(--text3)', background: 'var(--bg2)', border: '1px solid var(--border)', borderRadius: 4, padding: '1px 5px', fontFamily: 'var(--font-mono)', flexShrink: 0 }}>#{s.id}</span>
)}
<span>{s.name}</span>
<button onClick={() => onEdit(s)} title="Edit"
style={{ padding: '2px 4px', background: 'none', border: 'none', color: 'var(--text3)', cursor: 'pointer', display: 'flex', flexShrink: 0 }}
onMouseEnter={e => (e.currentTarget.style.color = 'var(--cyan)')}
onMouseLeave={e => (e.currentTarget.style.color = 'var(--text3)')}>
<Edit2 size={11} />
</button>
<button onClick={() => onCopy(s.slug)} title="Copy"
style={{ padding: '2px 4px', background: 'none', border: 'none', color: 'var(--text3)', cursor: 'pointer', display: 'flex', flexShrink: 0 }}
onMouseEnter={e => (e.currentTarget.style.color = 'var(--cyan)')}
onMouseLeave={e => (e.currentTarget.style.color = 'var(--text3)')}>
<Copy size={11} />
</button>
{isOrbStrategy(s) && (
<>
<button onClick={() => onEdit(s)} title="Edit"
style={{ padding: '2px 4px', background: 'none', border: 'none', color: 'var(--text3)', cursor: 'pointer', display: 'flex', flexShrink: 0 }}
onMouseEnter={e => (e.currentTarget.style.color = 'var(--cyan)')}
onMouseLeave={e => (e.currentTarget.style.color = 'var(--text3)')}>
<Edit2 size={11} />
</button>
<button onClick={() => onCopy(s.slug)} title="Copy"
style={{ padding: '2px 4px', background: 'none', border: 'none', color: 'var(--text3)', cursor: 'pointer', display: 'flex', flexShrink: 0 }}
onMouseEnter={e => (e.currentTarget.style.color = 'var(--cyan)')}
onMouseLeave={e => (e.currentTarget.style.color = 'var(--text3)')}>
<Copy size={11} />
</button>
</>
)}
<button onClick={() => onDelete(s.slug)} title="Delete"
style={{ padding: '2px 4px', background: 'none', border: 'none', color: 'var(--text3)', cursor: 'pointer', display: 'flex', flexShrink: 0 }}
onMouseEnter={e => (e.currentTarget.style.color = 'var(--red)')}
@ -1046,12 +1126,16 @@ function StrategyTable({
</button>
</div>
</td>
<td style={{ ...stdStyle, textAlign: 'right' }}>{(s.risk_per_trade_pct * 100).toFixed(1)}%</td>
<td style={{ ...stdStyle, textAlign: 'right' }}>{(s.max_position_pct * 100).toFixed(0)}%</td>
<td style={{ ...stdStyle, textAlign: 'right' }}>{s.atr_stop_multiplier.toFixed(2)}</td>
<td style={{ ...stdStyle, textAlign: 'right' }}>{s.min_rvol.toFixed(1)}</td>
<td style={{ ...stdStyle, textAlign: 'right' }}>{s.max_gap_pct != null ? `${(s.max_gap_pct * 100).toFixed(0)}%` : '—'}</td>
<td style={{ ...stdStyle, textAlign: 'right' }}>{s.settlement_days ? `T+${s.settlement_days}` : 'Off'}</td>
<td style={{ ...stdStyle, textAlign: 'center' }}>
<span style={{ padding: '2px 7px', borderRadius: 4, background: 'var(--bg2)', color: isOrbStrategy(s) ? 'var(--cyan)' : 'var(--green)' }}>
{strategyModeLabel(s)}
</span>
</td>
<td style={{ ...stdStyle, textAlign: 'right' }}>{s.universe_label ?? s.universe}</td>
<td style={{ ...stdStyle, textAlign: 'right', maxWidth: 320, overflow: 'hidden', textOverflow: 'ellipsis' }} title={strategySummaryLabel(s)}>
{strategySummaryLabel(s)}
</td>
<td style={{ ...stdStyle, textAlign: 'right' }}>{s.days}</td>
<td style={{ padding: '5px 8px', textAlign: 'center' }}>
<button onClick={e => { e.stopPropagation(); onRun(s); }}
style={{ padding: '4px 12px', borderRadius: 5, background: 'var(--cyan)', color: 'white', border: 'none', cursor: 'pointer', fontSize: 12, fontWeight: 600, display: 'inline-flex', alignItems: 'center', gap: 4 }}>
@ -1137,6 +1221,7 @@ export function IntradayBacktestPage() {
const req: Parameters<typeof intradayApi.submit>[0] = {
config: runningFor.slug, days: cfg.days, universe: cfg.universe,
compound_returns: cfg.compound_returns,
daily_budget_reset: cfg.daily_budget_reset,
initial_capital: cfg.initialCapital,
};
if (cfg.dateMode === 'year') req.year = cfg.year;
@ -1152,6 +1237,7 @@ export function IntradayBacktestPage() {
days: task.days,
universe: task.universe,
compound_returns: task.compound_returns ?? false,
daily_budget_reset: task.daily_budget_reset ?? false,
};
if (task.year) return { ...base, dateMode: 'year', year: task.year };
if (task.start_date) return { ...base, dateMode: 'range', startDate: task.start_date, endDate: task.end_date ?? '' };
@ -1163,6 +1249,7 @@ export function IntradayBacktestPage() {
const req: Parameters<typeof intradayApi.submit>[0] = {
config: duplicateTask.config, days: cfg.days, universe: cfg.universe,
compound_returns: cfg.compound_returns,
daily_budget_reset: cfg.daily_budget_reset,
initial_capital: cfg.initialCapital,
};
if (cfg.dateMode === 'year') req.year = cfg.year;
@ -1190,7 +1277,7 @@ export function IntradayBacktestPage() {
}
return (
<div style={{ padding: '28px 32px', maxWidth: 1200, margin: '0 auto' }}>
<div style={{ padding: '28px 32px' }}>
<style>{`@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }`}</style>
{/* Header */}
@ -1269,8 +1356,18 @@ export function IntradayBacktestPage() {
{tasks.map(task => {
const r = task.result_summary;
const ret = r?.return_pct;
const compoundLabel = task.compound_returns === true ? '복리' : task.compound_returns === false ? '단리' : '—';
const compoundColor = task.compound_returns === true ? 'var(--cyan)' : task.compound_returns === false ? 'var(--text3)' : 'var(--text3)';
const compoundLabel = task.daily_budget_reset === true
? '리셋'
: task.compound_returns === true
? '복리'
: task.compound_returns === false
? '단리'
: '—';
const compoundColor = task.daily_budget_reset === true
? 'var(--yellow)'
: task.compound_returns === true
? 'var(--cyan)'
: 'var(--text3)';
return (
<tr key={task.task_id}
onClick={() => navigate(`/intraday/tasks/${task.task_id}`)}
@ -1280,7 +1377,7 @@ export function IntradayBacktestPage() {
<td style={{ padding: '10px 14px' }}><StatusDot status={task.status} /></td>
<td style={{ padding: '10px 14px', fontSize: 13, color: 'var(--text2)', fontFamily: 'var(--font-mono)' }}>{getStrategyName(task.config)}</td>
<td style={{ padding: '10px 14px', fontSize: 12, color: 'var(--text3)', fontFamily: 'var(--font-mono)' }}>{taskPeriodLabel(task)}</td>
<td style={{ padding: '10px 14px', fontSize: 13, color: 'var(--text2)' }}>{task.universe}</td>
<td style={{ padding: '10px 14px', fontSize: 13, color: 'var(--text2)' }}>{task.universe_label ?? task.universe}</td>
<td style={{ padding: '10px 14px', fontSize: 12, color: compoundColor, fontWeight: 600 }}>{compoundLabel}</td>
<td style={{ padding: '10px 14px', fontSize: 13, fontFamily: 'var(--font-mono)', fontWeight: 600, color: ret != null ? (ret >= 0 ? 'var(--green)' : 'var(--red)') : 'var(--text3)' }}>
{ret != null ? `${ret >= 0 ? '+' : ''}${(ret * 100).toFixed(1)}%` : task.status === 'running' ? '…' : '—'}

@ -928,6 +928,13 @@ function PositionsTab({ session }: { session: PaperSession }) {
// ── Trades Tab ────────────────────────────────────────────────────────────────
type TradeEvent = {
key: string;
type: 'BUY' | 'SELL';
date: string;
trade: PaperTrade & { _sleeve: TradeSleeve };
};
function TradesTab({ session }: { session: PaperSession }) {
const [lastN, setLastN] = useState<string>('');
const [sleeveFilter, setSleeveFilter] = useState<'' | TradeSleeve>('');
@ -943,18 +950,34 @@ function TradesTab({ session }: { session: PaperSession }) {
_sleeve: classifyTradeSleeve(t),
})) as (PaperTrade & { _sleeve: TradeSleeve })[];
const filteredTrades = sleeveFilter ? sleevedTrades.filter(t => t._sleeve === sleeveFilter) : sleevedTrades;
const wins = filteredTrades.filter(t => t.net_pnl > 0).length;
const totalPnl = filteredTrades.reduce((s, t) => s + (t.net_pnl ?? 0), 0);
// Stats: based on closed round trips only
const closedTrades = filteredTrades.filter(t => t.exit_date);
const wins = closedTrades.filter(t => (t.net_pnl ?? 0) > 0).length;
const totalPnl = closedTrades.reduce((s, t) => s + (t.net_pnl ?? 0), 0);
// Expand each trade into BUY and SELL events, sorted newest first
const events: TradeEvent[] = [];
for (const t of filteredTrades) {
if (t.entry_date) {
events.push({ key: `${t.trade_id}_buy`, type: 'BUY', date: t.entry_date, trade: t });
}
if (t.exit_date) {
events.push({ key: `${t.trade_id}_sell`, type: 'SELL', date: t.exit_date, trade: t });
}
}
events.sort((a, b) => b.date.localeCompare(a.date));
const sleeveOptions: TradeSleeve[] = ['core', 'idle_alpha', 'risk_off_alpha', 'ownership', 'parking'];
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<span style={{ fontSize: 13, color: 'var(--text2)' }}>
{filteredTrades.length}{filteredTrades.length !== (data?.total ?? 0) ? ` / ${data?.total ?? 0}` : ''} trades
{filteredTrades.length > 0 && ` · Win Rate: ${((wins / filteredTrades.length) * 100).toFixed(0)}%`}
{filteredTrades.length > 0 && ` · P&L: `}
{filteredTrades.length > 0 && <span style={{ color: pnlColor(totalPnl), fontFamily: 'var(--font-mono)' }}>{fmtMoney(totalPnl, 0)}</span>}
{closedTrades.length}{closedTrades.length !== (data?.total ?? 0) ? ` / ${data?.total ?? 0}` : ''} trades
{closedTrades.length > 0 && ` · Win Rate: ${((wins / closedTrades.length) * 100).toFixed(0)}%`}
{closedTrades.length > 0 && ` · P&L: `}
{closedTrades.length > 0 && <span style={{ color: pnlColor(totalPnl), fontFamily: 'var(--font-mono)' }}>{fmtMoney(totalPnl, 0)}</span>}
</span>
<div style={{ marginLeft: 'auto', display: 'flex', alignItems: 'center', gap: 8 }}>
<select
@ -1007,44 +1030,68 @@ function TradesTab({ session }: { session: PaperSession }) {
{isLoading && <Loading />}
{error && <ErrorState error={error as Error} />}
{!isLoading && filteredTrades.length === 0 && (
{!isLoading && events.length === 0 && (
<div style={{ ...card, padding: '40px 24px', textAlign: 'center', color: 'var(--text3)', fontSize: 14 }}>
{trades.length === 0 ? 'No trades recorded yet' : 'No trades match the sleeve filter'}
</div>
)}
{filteredTrades.length > 0 && (
{events.length > 0 && (
<div style={card}>
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead>
<tr style={{ background: 'var(--bg2)', borderBottom: '1px solid var(--border-md)' }}>
{['Symbol', 'Sleeve', 'Engine', 'Entry Date', 'Exit Date', 'Entry $', 'Exit $', 'Shares', 'Net P&L', 'R', 'Days', 'Reason'].map(h => (
<th key={h} style={{ padding: '11px 14px', textAlign: h === 'Symbol' || h === 'Sleeve' || h === 'Engine' || h === 'Reason' ? 'left' : 'right', fontFamily: 'var(--font-mono)', fontSize: 11, fontWeight: 500, letterSpacing: '0.07em', color: 'var(--text3)', textTransform: 'uppercase', whiteSpace: 'nowrap' }}>
{['Date', 'Type', 'Symbol', 'Sleeve', 'Engine', 'Price', 'Qty', 'Entry $', 'Net P&L', 'R', 'Days', 'Reason'].map(h => (
<th key={h} style={{ padding: '11px 14px', textAlign: h === 'Symbol' || h === 'Sleeve' || h === 'Engine' || h === 'Reason' || h === 'Type' ? 'left' : 'right', fontFamily: 'var(--font-mono)', fontSize: 11, fontWeight: 500, letterSpacing: '0.07em', color: 'var(--text3)', textTransform: 'uppercase', whiteSpace: 'nowrap' }}>
{h}
</th>
))}
</tr>
</thead>
<tbody>
{filteredTrades.map((t, i) => (
<tr key={t.trade_id} style={{ borderBottom: '1px solid var(--border)', background: i % 2 === 1 ? 'rgba(0,0,0,0.015)' : 'transparent' }}>
<td style={{ padding: '9px 14px', fontFamily: 'var(--font-mono)', fontWeight: 700, color: 'var(--text1)' }}>{t.symbol}</td>
<td style={{ padding: '9px 14px', textAlign: 'left' }}><SleeveBadge sleeve={t._sleeve} /></td>
<td style={{ padding: '9px 14px', textAlign: 'left', fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--text3)' }}>{t.engine_id ?? '—'}</td>
<td style={{ padding: '9px 14px', textAlign: 'right', fontFamily: 'var(--font-mono)', color: 'var(--text2)' }}>{t.entry_date ?? '—'}</td>
<td style={{ padding: '9px 14px', textAlign: 'right', fontFamily: 'var(--font-mono)', color: 'var(--text2)' }}>{t.exit_date}</td>
<td style={{ padding: '9px 14px', textAlign: 'right', fontFamily: 'var(--font-mono)', color: 'var(--text2)' }}>{fmtPrice(t.entry_price)}</td>
<td style={{ padding: '9px 14px', textAlign: 'right', fontFamily: 'var(--font-mono)', color: 'var(--text2)' }}>{fmtPrice(t.exit_price)}</td>
<td style={{ padding: '9px 14px', textAlign: 'right', fontFamily: 'var(--font-mono)', color: 'var(--text3)' }}>{t.shares}</td>
<td style={{ padding: '9px 14px', textAlign: 'right', fontFamily: 'var(--font-mono)', fontWeight: 600, color: pnlColor(t.net_pnl) }}>{fmtMoney(t.net_pnl)}</td>
<td style={{ padding: '9px 14px', textAlign: 'right', fontFamily: 'var(--font-mono)', color: pnlColor(t.r_multiple) }}>
{t.r_multiple != null ? `${t.r_multiple >= 0 ? '+' : ''}${t.r_multiple.toFixed(2)}R` : '—'}
</td>
<td style={{ padding: '9px 14px', textAlign: 'right', fontFamily: 'var(--font-mono)', color: 'var(--text3)' }}>{t.holding_days != null ? `${t.holding_days}d` : '—'}</td>
<td style={{ padding: '9px 14px', fontFamily: 'var(--font-mono)', fontSize: 12, color: 'var(--text3)' }}>{t.exit_reason}</td>
</tr>
))}
{events.map((ev, i) => {
const t = ev.trade;
const isBuy = ev.type === 'BUY';
const price = isBuy ? t.entry_price : t.exit_price;
return (
<tr key={ev.key} style={{ borderBottom: '1px solid var(--border)', background: i % 2 === 1 ? 'rgba(0,0,0,0.015)' : 'transparent' }}>
<td style={{ padding: '9px 14px', textAlign: 'right', fontFamily: 'var(--font-mono)', color: 'var(--text2)', whiteSpace: 'nowrap' }}>{ev.date}</td>
<td style={{ padding: '9px 14px', textAlign: 'left' }}>
<span style={{
fontFamily: 'var(--font-mono)',
fontSize: 11,
fontWeight: 700,
letterSpacing: '0.05em',
color: isBuy ? 'var(--green)' : 'var(--red)',
background: isBuy ? 'rgba(34,197,94,0.1)' : 'rgba(239,68,68,0.1)',
padding: '2px 7px',
borderRadius: 4,
}}>
{ev.type}
</span>
</td>
<td style={{ padding: '9px 14px', fontFamily: 'var(--font-mono)', fontWeight: 700, color: 'var(--text1)' }}>{t.symbol}</td>
<td style={{ padding: '9px 14px', textAlign: 'left' }}><SleeveBadge sleeve={t._sleeve} /></td>
<td style={{ padding: '9px 14px', textAlign: 'left', fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--text3)' }}>{t.engine_id ?? '—'}</td>
<td style={{ padding: '9px 14px', textAlign: 'right', fontFamily: 'var(--font-mono)', color: 'var(--text2)' }}>{fmtPrice(price)}</td>
<td style={{ padding: '9px 14px', textAlign: 'right', fontFamily: 'var(--font-mono)', color: 'var(--text3)' }}>{isBuy ? (t.entry_shares ?? t.shares) : t.shares}</td>
<td style={{ padding: '9px 14px', textAlign: 'right', fontFamily: 'var(--font-mono)', color: 'var(--text3)' }}>{isBuy ? '—' : fmtPrice(t.entry_price)}</td>
<td style={{ padding: '9px 14px', textAlign: 'right', fontFamily: 'var(--font-mono)', fontWeight: isBuy ? 400 : 600, color: isBuy ? 'var(--text3)' : pnlColor(t.net_pnl) }}>
{isBuy ? '—' : fmtMoney(t.net_pnl)}
</td>
<td style={{ padding: '9px 14px', textAlign: 'right', fontFamily: 'var(--font-mono)', color: isBuy ? 'var(--text3)' : pnlColor(t.r_multiple) }}>
{isBuy ? '—' : (t.r_multiple != null ? `${t.r_multiple >= 0 ? '+' : ''}${t.r_multiple.toFixed(2)}R` : '—')}
</td>
<td style={{ padding: '9px 14px', textAlign: 'right', fontFamily: 'var(--font-mono)', color: 'var(--text3)' }}>
{isBuy ? '—' : (t.holding_days != null ? `${t.holding_days}d` : '—')}
</td>
<td style={{ padding: '9px 14px', fontFamily: 'var(--font-mono)', fontSize: 12, color: 'var(--text3)' }}>
{isBuy ? '—' : t.exit_reason}
</td>
</tr>
);
})}
</tbody>
</table>
</div>

@ -1,5 +1,5 @@
# Strategy Improvement Leaderboard
_Updated: 2026-04-10T19:55:04.001891+00:00_
_Updated: 2026-04-11T15:54:30.976974+00:00_
_Default view excludes retired legacy PEAD / short-core / exact-pocket families, retired pre-IMP-0606 research, and incomplete train-only scans. Use `fithia2 lb --include-retired` to inspect retired research._
@ -7,22 +7,25 @@ _`SQS` below is public SQS v9: 3-pillar additive core (RQS 35% + WFQS 40% + Regi
| # | ID | Experiment | SQS | RCW | [Tr]Ret% | [V]Ret% | [T]Ret% | [T]Ann% | [T]DD% | [T]Gross% | [T]DIM% | [T]R/G | Date |
|---|-----|-----------|-----|-----|----------|----------|----------|----------|--------|------------|----------|---------|------|
| 1 | 1348 | return_max_long_v7.356_composed_gld | 81.8 | - | +419.5 | +152.4 | +229.3 | +1419.2 | 6.4 | 65.5 | 85.5 | 3.50 | 2026-04-10 |
| 2 | 1331 | return_max_long_v7.330_composed_gld | 81.5 | - | +393.1 | +156.0 | +235.2 | +1482.0 | 6.4 | 65.0 | 85.5 | 3.62 | 2026-04-10 |
| 3 | 1283 | return_max_long_v7.266_composed_gld | 81.3 | - | +365.3 | +152.8 | +182.5 | +970.2 | 7.9 | 67.4 | 87.3 | 2.71 | 2026-04-10 |
| 4 | 1272 | return_max_long_v16.9 | 79.7 | - | +636.4 | +152.6 | +171.2 | +875.5 | 9.4 | 61.9 | 82.7 | 2.76 | 2026-04-10 |
| 5 | 1284 | return_max_long_v7.266_composed_gld_cp | 79.0 | - | +617.1 | +178.9 | +218.8 | +1310.7 | 10.6 | 74.6 | 85.5 | 2.93 | 2026-04-10 |
| 6 | 1152 | return_max_long_v7.119_composed_gld | 78.6 | - | +593.3 | +114.0 | +111.4 | +464.6 | 7.3 | 56.5 | 86.2 | 1.97 | 2026-04-08 |
| 7 | 1151 | return_max_long_v7.119_composed | 78.1 | - | +572.3 | +105.9 | +79.7 | +287.5 | 7.3 | 56.1 | 86.2 | 1.42 | 2026-04-08 |
| 8 | 1351 | return_max_long_v7.360_composed_gld | 77.4 | - | +421.7 | +153.4 | +228.3 | +1408.3 | 6.4 | 65.6 | 85.5 | 3.48 | 2026-04-10 |
| 9 | 1352 | return_max_long_v7.361_composed_gld | 77.4 | - | +421.7 | +153.4 | +228.3 | +1408.3 | 6.4 | 65.6 | 85.5 | 3.48 | 2026-04-10 |
| 10 | 1153 | return_max_long_v7.120_composed_gld | 77.3 | - | +606.6 | +113.3 | +112.9 | +473.5 | 7.4 | 56.9 | 86.2 | 1.98 | 2026-04-08 |
| 11 | 1146 | return_max_long_v7.123 | 66.6 | - | +113.6 | +49.5 | +53.4 | +168.8 | 2.4 | 50.4 | 79.8 | 1.06 | 2026-04-07 |
| 12 | 971 | return_max_long_v19.1 | 63.4 | 38.1 | +167.9 | +79.6 | +63.6 | +212.1 | 2.2 | 49.9 | 82.6 | 1.27 | 2026-04-03 |
| 13 | 415 | return_max_long_v7.70 | 62.2 | 43.3 | +130.2 | +50.6 | +57.7 | +200.9 | 2.9 | 42.7 | 79.8 | 1.35 | 2026-03-28 |
| 14 | 1138 | return_max_long_v7.119 | 60.0 | - | +130.0 | +46.2 | +51.6 | +161.5 | 3.0 | 47.8 | 71.6 | 1.08 | 2026-04-07 |
| 1 | 1357 | return_max_long_v7.364_composed_gld | 92.6 | - | +710.6 | +190.6 | +275.8 | +1954.2 | 8.8 | 69.2 | 80.0 | 3.99 | 2026-04-11 |
| 2 | 1351 | return_max_long_v7.356_composed_gld_compound | 80.0 | - | +703.7 | +189.0 | +276.8 | +1965.8 | 8.7 | 69.0 | 80.0 | 4.01 | 2026-04-11 |
| 3 | 1353 | return_max_long_v7.360_composed_gld | 77.4 | - | +421.7 | +153.4 | +228.3 | +1408.3 | 6.4 | 65.6 | 85.5 | 3.48 | 2026-04-10 |
| 4 | 1354 | return_max_long_v7.361_composed_gld | 77.4 | - | +421.7 | +153.4 | +228.3 | +1408.3 | 6.4 | 65.6 | 85.5 | 3.48 | 2026-04-10 |
| 5 | 1348 | return_max_long_v7.356_composed_gld | 77.4 | - | +419.5 | +152.4 | +229.3 | +1419.2 | 6.4 | 65.5 | 85.5 | 3.50 | 2026-04-10 |
| 6 | 1153 | return_max_long_v7.120_composed_gld | 77.3 | - | +606.6 | +113.3 | +112.9 | +473.5 | 7.4 | 56.9 | 86.2 | 1.98 | 2026-04-08 |
| 7 | 415 | return_max_long_v7.70 | 62.2 | 43.3 | +130.2 | +50.6 | +57.7 | +200.9 | 2.9 | 42.7 | 79.8 | 1.35 | 2026-03-28 |
## Recent Entries
### IMP-0955 (2026-04-11) — return_max_long_v7.364_composed_gld
Hypothesis: Scale ALL per_trade_risk_pct by 0.615x (0.65→0.40) to reduce DD while maintaining compound growth. Expected: train DD 9.46%→5.8%, test gross_exp 69%→42%.
Verdict: **NEUTRAL** (SQS 92.6, Stress SQS 76.6)
Reasoning: DD unchanged at 9.46% on train (DD is scale-invariant — scaling all positions proportionally doesn't change equity curve drawdown %). Test gross_exp still 69.2% (unchanged vs baseline 69.0%). Train gross_exp did drop to 42.7%, showing the scaling effect. Returns essentially identical across all splits (train +710.6% vs 703.7%, valid +190.6% vs 189.0%, test +275.8% vs 276.8%). Hypothesis failed: per_trade_risk_pct scaling alone cannot reduce DD.
Next: DD reduction requires structural changes: tighter stops, different max_holding_days, or smaller max_positions_per_sector — not just risk scaling. Alternatively, accept current DD level and focus on boosting test returns.
### IMP-0954 (2026-04-11) — return_max_long_v7.356_composed_gld_compound
Hypothesis: Auto-recorded via web GUI
Verdict: **UNKNOWN** (SQS 80.0)
### IMP-0953 (2026-04-10) — return_max_long_v7.361_composed_gld
Hypothesis: interleave_head_score only: test if it avoids OVERFIT scenario verdict vs v7.360
Verdict: **UNKNOWN** (SQS 77.4)
@ -33,13 +36,5 @@ Verdict: **UNKNOWN** (SQS 77.4)
### IMP-0951 (2026-04-10) — return_max_long_v7.356_composed_gld
Hypothesis: Auto-recorded via web GUI
Verdict: **UNKNOWN** (SQS 81.8)
### IMP-0950 (2026-04-10) — return_max_long_v7.330_composed_gld
Hypothesis: Auto-recorded via web GUI
Verdict: **UNKNOWN** (SQS 81.5)
### IMP-0949 (2026-04-10) — return_max_long_v7.330_composed_gld
Hypothesis: Auto-recorded via web GUI
Verdict: **UNKNOWN** (SQS 81.5)
Verdict: **UNKNOWN** (SQS 77.4, Stress SQS 78.5)

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"

@ -396,3 +396,21 @@ async def test_collect_attention_gdelt(httpx_mock: HTTPXMock):
assert result.source == "gdelt"
assert result.records_collected == 12
@pytest.mark.asyncio
async def test_health_checks_use_api_v1_health(httpx_mock: HTTPXMock):
from libs.oracle_client.client import OracleClient
httpx_mock.add_response(
json={"status": "ok"},
url="http://oracle:18001/api/v1/health",
)
httpx_mock.add_response(
json={"status": "ok"},
url="http://oracle:18001/api/v1/health",
)
async with OracleClient("http://oracle:18001") as client:
assert await client.health_check() is True
assert await client.health_check_fast() is True

@ -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…
Cancel
Save