You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

925 lines
43 KiB
Python

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