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