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"]