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.
6479 lines
263 KiB
Python
6479 lines
263 KiB
Python
from __future__ import annotations
|
|
|
|
import datetime as dt
|
|
|
|
import pytest
|
|
|
|
from libs.intraday.domain import ORBStrategyParams
|
|
from libs.intraday.orb_simulator import (
|
|
ORBSimulationState,
|
|
_build_idle_sleeve_orders,
|
|
_entry_market_guard_scale,
|
|
_market_return_at_entry_open,
|
|
_orb_form4_size_scale,
|
|
_orb_liquid_leader_conviction_size_scale,
|
|
_orb_opening_burst_liquid_size_scale,
|
|
_orb_ownership_initial_size_scale,
|
|
_orb_sector_confirmation_size_scale,
|
|
_orb_soft_day_sector_confirmation_override_allows,
|
|
_orb_soft_day_sector_confirmation_override_base_sizing,
|
|
_orb_soft_day_setup_profile_allows,
|
|
_orb_soft_day_vwap_min_score_pct,
|
|
_orb_soft_day_vwap_reason_allowed,
|
|
_orb_soft_day_vwap_reason_param,
|
|
_orb_soft_day_vwap_size_scale,
|
|
_orb_unboosted_primary_fragility_size_scale,
|
|
_orb_unsupported_attention_size_scale,
|
|
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 _parse_test_ts(value: str) -> dt.datetime:
|
|
return dt.datetime.fromisoformat(value)
|
|
|
|
|
|
def _idle_bars(open_price: float, close_price: float) -> list[dict]:
|
|
return [
|
|
{
|
|
"timestamp": "2026-01-05T09:30:00-05:00",
|
|
"open": open_price,
|
|
"high": max(open_price, close_price),
|
|
"low": min(open_price, close_price),
|
|
"close": open_price,
|
|
"volume": 100_000,
|
|
},
|
|
{
|
|
"timestamp": "2026-01-05T15:55:00-05:00",
|
|
"open": close_price,
|
|
"high": close_price,
|
|
"low": close_price,
|
|
"close": close_price,
|
|
"volume": 100_000,
|
|
},
|
|
]
|
|
|
|
|
|
def _idle_reclaim_bars(open_price: float, checkpoint_close: float, close_price: float) -> list[dict]:
|
|
return [
|
|
{
|
|
"timestamp": "2026-01-05T09:30:00-05:00",
|
|
"open": open_price,
|
|
"high": max(open_price, checkpoint_close),
|
|
"low": min(open_price, checkpoint_close),
|
|
"close": open_price,
|
|
"volume": 100_000,
|
|
},
|
|
{
|
|
"timestamp": "2026-01-05T10:30:00-05:00",
|
|
"open": open_price,
|
|
"high": max(open_price, checkpoint_close),
|
|
"low": min(open_price, checkpoint_close),
|
|
"close": checkpoint_close,
|
|
"volume": 100_000,
|
|
},
|
|
{
|
|
"timestamp": "2026-01-05T15:55:00-05:00",
|
|
"open": checkpoint_close,
|
|
"high": max(open_price, checkpoint_close, close_price),
|
|
"low": min(open_price, checkpoint_close, close_price),
|
|
"close": close_price,
|
|
"volume": 100_000,
|
|
},
|
|
]
|
|
|
|
|
|
def test_pead_like_idle_parking_uses_tqqq_on_strong_market_close() -> None:
|
|
params = ORBStrategyParams(
|
|
orb_idle_sleeve_enabled=True,
|
|
orb_idle_sleeve_parking_mode="pead_like",
|
|
orb_idle_sleeve_parking_symbols=["QQQM", "TQQQ"],
|
|
orb_idle_sleeve_weights={"parking": 1.0},
|
|
orb_idle_sleeve_market_ticker="QQQ",
|
|
orb_idle_sleeve_min_market_day_return_pct=-0.005,
|
|
orb_idle_sleeve_parking_overlay_min_market_return_pct=0.005,
|
|
orb_idle_sleeve_parking_overlay_min_market_close_location=0.70,
|
|
)
|
|
orders = _build_idle_sleeve_orders(
|
|
{
|
|
"QQQ": _idle_bars(100.0, 101.0),
|
|
"QQQM": _idle_bars(100.0, 100.4),
|
|
"TQQQ": _idle_bars(100.0, 103.0),
|
|
},
|
|
"2026-01-05",
|
|
params,
|
|
{},
|
|
)
|
|
|
|
assert [(order.ticker, order.sleeve) for order in orders] == [("TQQQ", "parking")]
|
|
|
|
|
|
def test_pead_like_idle_parking_uses_qqqm_on_ordinary_risk_on_close() -> None:
|
|
params = ORBStrategyParams(
|
|
orb_idle_sleeve_enabled=True,
|
|
orb_idle_sleeve_parking_mode="pead_like",
|
|
orb_idle_sleeve_parking_symbols=["QQQM", "TQQQ"],
|
|
orb_idle_sleeve_weights={"parking": 1.0},
|
|
orb_idle_sleeve_market_ticker="QQQ",
|
|
orb_idle_sleeve_min_market_day_return_pct=-0.005,
|
|
orb_idle_sleeve_parking_overlay_min_market_return_pct=0.005,
|
|
orb_idle_sleeve_parking_overlay_min_market_close_location=0.70,
|
|
)
|
|
orders = _build_idle_sleeve_orders(
|
|
{
|
|
"QQQ": _idle_bars(100.0, 100.1),
|
|
"QQQM": _idle_bars(100.0, 100.2),
|
|
"TQQQ": _idle_bars(100.0, 100.6),
|
|
},
|
|
"2026-01-05",
|
|
params,
|
|
{},
|
|
)
|
|
|
|
assert [(order.ticker, order.sleeve) for order in orders] == [("QQQM", "parking")]
|
|
|
|
|
|
def test_pead_like_idle_parking_falls_back_to_sgov_when_risk_on_disallowed() -> None:
|
|
params = ORBStrategyParams(
|
|
orb_idle_sleeve_enabled=True,
|
|
orb_idle_sleeve_parking_mode="pead_like",
|
|
orb_idle_sleeve_parking_symbols=["QQQM", "TQQQ"],
|
|
orb_idle_sleeve_risk_off_symbols=["GLD", "SGOV"],
|
|
orb_idle_sleeve_weights={"parking": 1.0},
|
|
orb_idle_sleeve_market_ticker="QQQ",
|
|
orb_idle_sleeve_min_market_day_return_pct=-0.005,
|
|
orb_idle_sleeve_force_defensive_fallback=True,
|
|
)
|
|
orders = _build_idle_sleeve_orders(
|
|
{
|
|
"QQQ": _idle_bars(100.0, 99.0),
|
|
"QQQM": _idle_bars(100.0, 99.5),
|
|
"TQQQ": _idle_bars(100.0, 97.0),
|
|
"GLD": _idle_bars(100.0, 101.0),
|
|
"SGOV": _idle_bars(100.0, 100.01),
|
|
},
|
|
"2026-01-05",
|
|
params,
|
|
{},
|
|
)
|
|
|
|
assert [(order.ticker, order.sleeve) for order in orders] == [("SGOV", "parking")]
|
|
|
|
|
|
def test_idle_sleeve_can_build_pead_like_five_sleeve_book() -> None:
|
|
params = ORBStrategyParams(
|
|
orb_idle_sleeve_enabled=True,
|
|
orb_idle_sleeve_parking_mode="pead_like",
|
|
orb_idle_sleeve_parking_symbols=["QQQM", "TQQQ"],
|
|
orb_idle_sleeve_risk_off_symbols=["GLD", "SGOV"],
|
|
orb_idle_sleeve_weights={
|
|
"parking": 0.35,
|
|
"idle_alpha": 0.30,
|
|
"form4": 0.15,
|
|
"ownership": 0.10,
|
|
"risk_off_alpha": 0.10,
|
|
},
|
|
orb_idle_sleeve_max_positions=5,
|
|
orb_idle_sleeve_market_ticker="QQQ",
|
|
orb_idle_sleeve_min_market_day_return_pct=-0.005,
|
|
orb_idle_sleeve_min_market_day_return_for_stock_sleeves_pct=-0.005,
|
|
)
|
|
enrichment = _enrichment_for("ALPHA", "F4", "OWN")
|
|
enrichment["F4"]["2026-01-05"].update(
|
|
{
|
|
"form4_flag": True,
|
|
"form4_owner_count": 2,
|
|
"form4_c_suite_count": 1,
|
|
"form4_total_value": 1_000_000,
|
|
}
|
|
)
|
|
enrichment["OWN"]["2026-01-05"].update(
|
|
{
|
|
"ownership_13dg_flag": True,
|
|
"ownership_13dg_initial_flag": True,
|
|
"ownership_13dg_strength_score": 3.0,
|
|
}
|
|
)
|
|
|
|
orders = _build_idle_sleeve_orders(
|
|
{
|
|
"QQQ": _idle_bars(100.0, 99.8),
|
|
"QQQM": _idle_bars(100.0, 100.1),
|
|
"TQQQ": _idle_bars(100.0, 100.3),
|
|
"GLD": _idle_bars(100.0, 100.4),
|
|
"SGOV": _idle_bars(100.0, 100.01),
|
|
"ALPHA": _idle_bars(100.0, 102.0),
|
|
"F4": _idle_bars(100.0, 100.2),
|
|
"OWN": _idle_bars(100.0, 100.3),
|
|
},
|
|
"2026-01-05",
|
|
params,
|
|
enrichment,
|
|
)
|
|
|
|
assert {(order.ticker, order.sleeve) for order in orders} == {
|
|
("QQQM", "parking"),
|
|
("ALPHA", "idle_alpha"),
|
|
("F4", "form4"),
|
|
("OWN", "ownership"),
|
|
("GLD", "risk_off_alpha"),
|
|
}
|
|
|
|
|
|
def test_idle_sleeve_can_build_late_close_reclaim_book() -> None:
|
|
params = ORBStrategyParams(
|
|
orb_idle_sleeve_enabled=True,
|
|
orb_idle_sleeve_weights={"close_reclaim": 1.0},
|
|
orb_idle_sleeve_reclaim_checkpoint_minutes=60,
|
|
orb_idle_sleeve_reclaim_min_day_return_pct=0.004,
|
|
orb_idle_sleeve_reclaim_max_early_return_pct=0.004,
|
|
orb_idle_sleeve_reclaim_min_late_return_pct=0.006,
|
|
orb_idle_sleeve_reclaim_min_close_location=0.70,
|
|
orb_idle_sleeve_reclaim_min_day_dollar_vol=20_000_000,
|
|
orb_idle_sleeve_reclaim_min_avg_dollar_vol=80_000_000,
|
|
)
|
|
orders = _build_idle_sleeve_orders(
|
|
{
|
|
"RECL": _idle_reclaim_bars(100.0, 99.2, 102.0),
|
|
"MORN": _idle_reclaim_bars(100.0, 102.0, 103.0),
|
|
},
|
|
"2026-01-05",
|
|
params,
|
|
_enrichment_for("RECL", "MORN"),
|
|
)
|
|
|
|
assert [(order.ticker, order.sleeve) for order in orders] == [("RECL", "close_reclaim")]
|
|
|
|
|
|
def test_idle_sleeve_can_build_sector_rotation_book() -> None:
|
|
params = ORBStrategyParams(
|
|
orb_idle_sleeve_enabled=True,
|
|
orb_idle_sleeve_weights={"sector_rotation": 1.0},
|
|
orb_idle_sleeve_sector_rotation_symbols=["XLK", "XLE"],
|
|
orb_idle_sleeve_sector_rotation_min_day_return_pct=0.002,
|
|
orb_idle_sleeve_sector_rotation_min_close_location=0.60,
|
|
orb_idle_sleeve_min_market_day_return_pct=None,
|
|
)
|
|
orders = _build_idle_sleeve_orders(
|
|
{
|
|
"XLK": _idle_bars(100.0, 100.3),
|
|
"XLE": _idle_bars(100.0, 101.0),
|
|
},
|
|
"2026-01-05",
|
|
params,
|
|
{},
|
|
)
|
|
|
|
assert [(order.ticker, order.sleeve) for order in orders] == [("XLE", "sector_rotation")]
|
|
|
|
|
|
def test_idle_sleeve_opens_on_streaming_chunk_boundary() -> None:
|
|
params = ORBStrategyParams(
|
|
min_candidates_to_trade=99,
|
|
initial_capital=10_000.0,
|
|
daily_budget_reset=True,
|
|
compound_returns=False,
|
|
orb_idle_sleeve_enabled=True,
|
|
orb_idle_sleeve_parking_mode="pead_like",
|
|
orb_idle_sleeve_parking_symbols=["QQQM"],
|
|
orb_idle_sleeve_weights={"parking": 1.0},
|
|
orb_idle_sleeve_market_ticker="QQQ",
|
|
orb_idle_sleeve_min_market_day_return_pct=-0.005,
|
|
)
|
|
day1 = "2026-01-05"
|
|
day2 = "2026-01-06"
|
|
all_intraday = {
|
|
day1: {
|
|
"QQQ": _idle_bars(100.0, 100.2),
|
|
"QQQM": _idle_bars(100.0, 100.5),
|
|
},
|
|
day2: {
|
|
"QQQM": [
|
|
{
|
|
"timestamp": f"{day2}T09:30:00-05:00",
|
|
"open": 101.0,
|
|
"high": 101.1,
|
|
"low": 100.9,
|
|
"close": 101.0,
|
|
"volume": 100_000,
|
|
}
|
|
],
|
|
},
|
|
}
|
|
|
|
chunk1, state = run_orb_simulation_with_state(
|
|
{day1: all_intraday[day1]},
|
|
[day1],
|
|
params,
|
|
{},
|
|
next_trading_day_after_window=day2,
|
|
)
|
|
chunk2, _ = run_orb_simulation_with_state(
|
|
{day2: all_intraday[day2]},
|
|
[day2],
|
|
params,
|
|
{},
|
|
state=state,
|
|
)
|
|
|
|
assert chunk1[0].entry_diagnostics["orb_idle_sleeve_symbols_opened"] == ["QQQM"]
|
|
assert len(chunk2[0].trades) == 1
|
|
assert chunk2[0].trades[0].ticker == "QQQM"
|
|
assert chunk2[0].trades[0].exit_reason == "idle_sleeve_next_open"
|
|
|
|
|
|
def test_idle_sleeve_can_enter_after_open_and_exit_same_day_close() -> None:
|
|
params = ORBStrategyParams(
|
|
min_candidates_to_trade=99,
|
|
initial_capital=10_000.0,
|
|
daily_budget_reset=True,
|
|
compound_returns=False,
|
|
orb_idle_sleeve_enabled=True,
|
|
orb_idle_sleeve_entry_timing="minutes_after_open",
|
|
orb_idle_sleeve_entry_minutes_after_open=60,
|
|
orb_idle_sleeve_exit_timing="same_day_close",
|
|
orb_idle_sleeve_parking_mode="pead_like",
|
|
orb_idle_sleeve_parking_symbols=["QQQM"],
|
|
orb_idle_sleeve_weights={"parking": 1.0},
|
|
orb_idle_sleeve_market_ticker="QQQ",
|
|
orb_idle_sleeve_min_market_day_return_pct=-0.005,
|
|
)
|
|
day = "2026-01-05"
|
|
bars = [
|
|
{
|
|
"timestamp": f"{day}T09:30:00-05:00",
|
|
"open": 100.0,
|
|
"high": 100.2,
|
|
"low": 99.9,
|
|
"close": 100.1,
|
|
"volume": 100_000,
|
|
},
|
|
{
|
|
"timestamp": f"{day}T10:30:00-05:00",
|
|
"open": 100.1,
|
|
"high": 100.6,
|
|
"low": 100.0,
|
|
"close": 100.5,
|
|
"volume": 100_000,
|
|
},
|
|
{
|
|
"timestamp": f"{day}T15:55:00-05:00",
|
|
"open": 100.5,
|
|
"high": 101.1,
|
|
"low": 100.4,
|
|
"close": 101.0,
|
|
"volume": 100_000,
|
|
},
|
|
]
|
|
|
|
results = run_orb_simulation(
|
|
{day: {"QQQ": bars, "QQQM": bars}},
|
|
[day],
|
|
params,
|
|
{},
|
|
)
|
|
|
|
assert len(results[0].trades) == 1
|
|
trade = results[0].trades[0]
|
|
assert trade.ticker == "QQQM"
|
|
assert trade.entry_time == f"{day}T10:30:00-05:00"
|
|
assert trade.exit_time == f"{day}T15:55:00-05:00"
|
|
assert trade.exit_reason == "idle_sleeve_same_day_close"
|
|
assert trade.orb_idle_sleeve_overnight is False
|
|
|
|
|
|
def test_idle_sleeve_same_day_stop_loss_exits_before_close() -> None:
|
|
params = ORBStrategyParams(
|
|
min_candidates_to_trade=99,
|
|
initial_capital=10_000.0,
|
|
daily_budget_reset=True,
|
|
compound_returns=False,
|
|
orb_idle_sleeve_enabled=True,
|
|
orb_idle_sleeve_entry_timing="minutes_after_open",
|
|
orb_idle_sleeve_entry_minutes_after_open=60,
|
|
orb_idle_sleeve_exit_timing="same_day_close",
|
|
orb_idle_sleeve_same_day_stop_loss_pct=-0.03,
|
|
orb_idle_sleeve_parking_mode="pead_like",
|
|
orb_idle_sleeve_parking_symbols=["QQQM"],
|
|
orb_idle_sleeve_weights={"parking": 1.0},
|
|
orb_idle_sleeve_market_ticker="QQQ",
|
|
orb_idle_sleeve_min_market_day_return_pct=-0.005,
|
|
)
|
|
day = "2026-01-05"
|
|
bars = [
|
|
{
|
|
"timestamp": f"{day}T09:30:00-05:00",
|
|
"open": 100.0,
|
|
"high": 100.2,
|
|
"low": 99.8,
|
|
"close": 100.0,
|
|
"volume": 100_000,
|
|
},
|
|
{
|
|
"timestamp": f"{day}T10:30:00-05:00",
|
|
"open": 100.0,
|
|
"high": 102.2,
|
|
"low": 99.9,
|
|
"close": 102.0,
|
|
"volume": 100_000,
|
|
},
|
|
{
|
|
"timestamp": f"{day}T11:00:00-05:00",
|
|
"open": 102.0,
|
|
"high": 102.1,
|
|
"low": 98.5,
|
|
"close": 99.0,
|
|
"volume": 100_000,
|
|
},
|
|
{
|
|
"timestamp": f"{day}T15:55:00-05:00",
|
|
"open": 99.0,
|
|
"high": 105.0,
|
|
"low": 98.9,
|
|
"close": 105.0,
|
|
"volume": 100_000,
|
|
},
|
|
]
|
|
|
|
results = run_orb_simulation(
|
|
{day: {"QQQ": bars, "QQQM": bars}},
|
|
[day],
|
|
params,
|
|
{},
|
|
)
|
|
|
|
trade = results[0].trades[0]
|
|
assert trade.exit_reason == "idle_sleeve_same_day_stop_loss"
|
|
assert trade.exit_time == f"{day}T11:00:00-05:00"
|
|
assert trade.exit_price == 98.94
|
|
assert trade.orb_idle_sleeve_overnight is False
|
|
|
|
|
|
def test_soft_day_setup_profile_rejects_market_regime_only_fallback() -> None:
|
|
params = ORBStrategyParams(soft_day_setup_profile="skip_day_reclaim_v1")
|
|
cand = {
|
|
"score": 1.05,
|
|
"rvol": 10.0,
|
|
"gap_pct": 0.03,
|
|
"premarket_dollar_vol": 100_000_000,
|
|
"body_ratio": 0.8,
|
|
"close_location": 0.9,
|
|
}
|
|
enrich = {"ret_5d": 0.3}
|
|
|
|
assert not _orb_soft_day_setup_profile_allows(
|
|
params, "market_regime", cand, enrich
|
|
)
|
|
|
|
|
|
def test_soft_day_setup_profile_allows_breadth_gap_down_momentum() -> None:
|
|
params = ORBStrategyParams(soft_day_setup_profile="skip_day_reclaim_v1")
|
|
cand = {
|
|
"score": 0.70,
|
|
"rvol": 18.0,
|
|
"gap_pct": -0.04,
|
|
"premarket_dollar_vol": 50_000_000,
|
|
"body_ratio": 0.5,
|
|
"close_location": 0.65,
|
|
}
|
|
enrich = {"ret_5d": 0.20}
|
|
|
|
assert _orb_soft_day_setup_profile_allows(params, "breadth", cand, enrich)
|
|
|
|
|
|
def test_soft_day_vwap_reason_overrides_default_score_and_size() -> None:
|
|
params = ORBStrategyParams(
|
|
soft_day_vwap_reclaim_min_score_pct=0.85,
|
|
soft_day_vwap_reclaim_min_score_pct_market_regime=0.70,
|
|
soft_day_vwap_reclaim_min_score_pct_breadth=0.80,
|
|
soft_day_vwap_reclaim_min_score_pct_joint=0.90,
|
|
soft_day_vwap_reclaim_size_scale=0.03,
|
|
soft_day_vwap_reclaim_size_scale_market_regime=0.05,
|
|
soft_day_vwap_reclaim_size_scale_joint=0.02,
|
|
)
|
|
|
|
assert _orb_soft_day_vwap_min_score_pct(params, "market_regime") == 0.70
|
|
assert _orb_soft_day_vwap_min_score_pct(params, "breadth") == 0.80
|
|
assert _orb_soft_day_vwap_min_score_pct(params, "market_regime+breadth") == 0.90
|
|
assert _orb_soft_day_vwap_min_score_pct(params, None) == 0.85
|
|
assert _orb_soft_day_vwap_size_scale(params, "market_regime") == 0.05
|
|
assert _orb_soft_day_vwap_size_scale(params, "market_regime+breadth") == 0.02
|
|
assert _orb_soft_day_vwap_size_scale(params, "breadth") == 0.03
|
|
|
|
|
|
def test_soft_day_vwap_reason_allowlist_limits_auxiliary_sleeve() -> None:
|
|
params = ORBStrategyParams(
|
|
soft_day_vwap_reclaim_allowed_reason_parts=["hard_breadth"]
|
|
)
|
|
|
|
assert _orb_soft_day_vwap_reason_allowed(params, "market_regime+hard_breadth")
|
|
assert not _orb_soft_day_vwap_reason_allowed(params, "market_regime+breadth")
|
|
assert not _orb_soft_day_vwap_reason_allowed(params, "breadth")
|
|
|
|
|
|
def test_soft_day_vwap_reason_quality_overrides_default_gates() -> None:
|
|
params = ORBStrategyParams(
|
|
soft_day_vwap_reclaim_min_premarket_dollar_vol=20_000_000,
|
|
soft_day_vwap_reclaim_min_premarket_dollar_vol_hard_breadth=5_000_000,
|
|
soft_day_vwap_reclaim_min_body_ratio=0.15,
|
|
soft_day_vwap_reclaim_min_body_ratio_hard_breadth=0.0,
|
|
soft_day_vwap_reclaim_min_close_location=0.65,
|
|
soft_day_vwap_reclaim_min_close_location_hard_breadth=0.32,
|
|
soft_day_vwap_reclaim_min_rvol=4.0,
|
|
soft_day_vwap_reclaim_min_rvol_joint=5.0,
|
|
soft_day_vwap_reclaim_max_rvol=12.0,
|
|
soft_day_vwap_reclaim_max_rvol_breadth=35.0,
|
|
)
|
|
|
|
assert (
|
|
_orb_soft_day_vwap_reason_param(
|
|
params,
|
|
"market_regime+hard_breadth",
|
|
"soft_day_vwap_reclaim_min_premarket_dollar_vol",
|
|
)
|
|
== 5_000_000
|
|
)
|
|
assert (
|
|
_orb_soft_day_vwap_reason_param(
|
|
params,
|
|
"market_regime",
|
|
"soft_day_vwap_reclaim_min_premarket_dollar_vol",
|
|
)
|
|
== 20_000_000
|
|
)
|
|
assert (
|
|
_orb_soft_day_vwap_reason_param(
|
|
params,
|
|
"market_regime+hard_breadth",
|
|
"soft_day_vwap_reclaim_min_body_ratio",
|
|
)
|
|
== 0.0
|
|
)
|
|
assert (
|
|
_orb_soft_day_vwap_reason_param(
|
|
params,
|
|
"market_regime+hard_breadth",
|
|
"soft_day_vwap_reclaim_min_close_location",
|
|
)
|
|
== 0.32
|
|
)
|
|
assert (
|
|
_orb_soft_day_vwap_reason_param(
|
|
params,
|
|
"breadth",
|
|
"soft_day_vwap_reclaim_min_rvol",
|
|
)
|
|
== 4.0
|
|
)
|
|
assert (
|
|
_orb_soft_day_vwap_reason_param(
|
|
params,
|
|
"market_regime+breadth",
|
|
"soft_day_vwap_reclaim_min_rvol",
|
|
)
|
|
== 5.0
|
|
)
|
|
assert (
|
|
_orb_soft_day_vwap_reason_param(
|
|
params,
|
|
"breadth",
|
|
"soft_day_vwap_reclaim_max_rvol",
|
|
)
|
|
== 35.0
|
|
)
|
|
|
|
|
|
def test_broad_gapup_continuation_admits_bounded_high_gap_candidate() -> None:
|
|
bars = {
|
|
"HIGH": [
|
|
{"timestamp": "2026-01-05T08:00:00-05:00", "open": 100.0, "high": 100.2, "low": 99.8, "close": 100.0, "volume": 300_000},
|
|
{"timestamp": "2026-01-05T09:30:00-05:00", "open": 100.0, "high": 101.0, "low": 99.5, "close": 100.9, "volume": 10_000},
|
|
{"timestamp": "2026-01-05T09:35:00-05:00", "open": 100.9, "high": 101.5, "low": 100.7, "close": 101.3, "volume": 5_000},
|
|
],
|
|
}
|
|
enrichment = _enrichment_for("HIGH")
|
|
enrichment["HIGH"]["2026-01-05"]["prev_close"] = 90.0
|
|
enrichment["HIGH"]["2026-01-05"]["ret_5d"] = 0.08
|
|
enrichment["HIGH"]["2026-01-05"]["avg_daily_vol_14d"] = 1_000_000.0
|
|
|
|
base_params = ORBStrategyParams(
|
|
engine_family="gainers_leader",
|
|
min_price=10.0,
|
|
min_avg_dollar_volume=0.0,
|
|
min_atr_14=0.1,
|
|
min_rvol=1000.0,
|
|
min_abs_gap_pct=0.02,
|
|
max_gap_pct=0.04,
|
|
max_candidates=10,
|
|
)
|
|
assert compute_orb_candidates(bars, "2026-01-05", base_params, enrichment) == []
|
|
|
|
params = base_params.model_copy(
|
|
update={
|
|
"broad_gapup_continuation_enabled": True,
|
|
"broad_gapup_continuation_min_gap_pct": 0.04,
|
|
"broad_gapup_continuation_max_gap_pct": 0.20,
|
|
"broad_gapup_continuation_min_rvol": None,
|
|
"broad_gapup_continuation_min_premarket_dollar_vol": 10_000_000,
|
|
"broad_gapup_continuation_min_first_bar_dollar_vol": 500_000,
|
|
"broad_gapup_continuation_min_body_ratio": 0.50,
|
|
"broad_gapup_continuation_min_close_location": 0.80,
|
|
}
|
|
)
|
|
|
|
candidates = compute_orb_candidates(bars, "2026-01-05", params, enrichment)
|
|
|
|
assert len(candidates) == 1
|
|
assert candidates[0]["ticker"] == "HIGH"
|
|
assert candidates[0]["broad_gapup_continuation"] is True
|
|
assert candidates[0]["rvol"] < base_params.min_rvol
|
|
|
|
liquid_only_params = params.model_copy(
|
|
update={"broad_gapup_continuation_min_avg_dollar_vol": 2_000_000_000}
|
|
)
|
|
assert compute_orb_candidates(bars, "2026-01-05", liquid_only_params, enrichment) == []
|
|
|
|
|
|
def test_broad_gapup_continuation_trades_with_dedicated_size_scale() -> None:
|
|
bars = {
|
|
"HIGH": [
|
|
{"timestamp": "2026-01-05T08:00:00-05:00", "open": 100.0, "high": 100.2, "low": 99.8, "close": 100.0, "volume": 300_000},
|
|
{"timestamp": "2026-01-05T09:30:00-05:00", "open": 100.0, "high": 101.0, "low": 99.5, "close": 100.9, "volume": 10_000},
|
|
{"timestamp": "2026-01-05T09:35:00-05:00", "open": 101.0, "high": 102.0, "low": 100.8, "close": 101.8, "volume": 5_000},
|
|
{"timestamp": "2026-01-05T15:55:00-05:00", "open": 101.8, "high": 102.0, "low": 101.5, "close": 101.9, "volume": 5_000},
|
|
],
|
|
}
|
|
enrichment = _enrichment_for("HIGH")
|
|
enrichment["HIGH"]["2026-01-05"]["prev_close"] = 90.0
|
|
enrichment["HIGH"]["2026-01-05"]["ret_5d"] = 0.08
|
|
params = ORBStrategyParams(
|
|
engine_family="gainers_leader",
|
|
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=0.04,
|
|
max_candidates=10,
|
|
min_candidates_to_trade=1,
|
|
atr_stop_multiplier=10.0,
|
|
trailing_at_r=99.0,
|
|
breakeven_at_r=99.0,
|
|
risk_per_trade_pct=0.01,
|
|
max_position_pct=1.0,
|
|
slippage_bps=0.0,
|
|
broad_gapup_continuation_enabled=True,
|
|
broad_gapup_continuation_min_gap_pct=0.04,
|
|
broad_gapup_continuation_max_gap_pct=0.20,
|
|
broad_gapup_continuation_min_rvol=1.0,
|
|
broad_gapup_continuation_min_premarket_dollar_vol=10_000_000,
|
|
broad_gapup_continuation_min_first_bar_dollar_vol=500_000,
|
|
broad_gapup_continuation_min_body_ratio=0.50,
|
|
broad_gapup_continuation_min_close_location=0.80,
|
|
broad_gapup_continuation_size_scale=0.25,
|
|
broad_gapup_continuation_max_trades=1,
|
|
)
|
|
|
|
result = simulate_orb_day(bars, "2026-01-05", params, enrichment, equity=10_000.0)
|
|
|
|
assert len(result.trades) == 1
|
|
trade = result.trades[0]
|
|
assert trade.trigger_type == "broad_gapup_continuation"
|
|
assert trade.broad_gapup_continuation is True
|
|
assert trade.broad_gapup_continuation_size_scale == 0.25
|
|
assert trade.shares == 2
|
|
|
|
|
|
def test_broad_gapup_continuation_opening_burst_enters_next_bar_open() -> None:
|
|
bars = {
|
|
"HIGH": [
|
|
{"timestamp": "2026-01-05T08:00:00-05:00", "open": 100.0, "high": 100.2, "low": 99.8, "close": 100.0, "volume": 300_000},
|
|
{"timestamp": "2026-01-05T09:30:00-05:00", "open": 100.0, "high": 101.0, "low": 99.5, "close": 100.9, "volume": 10_000},
|
|
{"timestamp": "2026-01-05T09:35:00-05:00", "open": 100.7, "high": 100.9, "low": 100.2, "close": 100.8, "volume": 5_000},
|
|
{"timestamp": "2026-01-05T15:55:00-05:00", "open": 100.8, "high": 101.4, "low": 100.7, "close": 101.2, "volume": 5_000},
|
|
],
|
|
}
|
|
enrichment = _enrichment_for("HIGH")
|
|
enrichment["HIGH"]["2026-01-05"]["prev_close"] = 90.0
|
|
enrichment["HIGH"]["2026-01-05"]["ret_5d"] = 0.08
|
|
params = ORBStrategyParams(
|
|
engine_family="gainers_leader",
|
|
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=0.04,
|
|
max_candidates=10,
|
|
min_candidates_to_trade=1,
|
|
atr_stop_multiplier=10.0,
|
|
trailing_at_r=99.0,
|
|
breakeven_at_r=99.0,
|
|
risk_per_trade_pct=0.01,
|
|
max_position_pct=1.0,
|
|
slippage_bps=0.0,
|
|
broad_gapup_continuation_enabled=True,
|
|
broad_gapup_continuation_entry_mode="opening_burst",
|
|
broad_gapup_continuation_min_gap_pct=0.04,
|
|
broad_gapup_continuation_max_gap_pct=0.20,
|
|
broad_gapup_continuation_min_rvol=1.0,
|
|
broad_gapup_continuation_min_premarket_dollar_vol=10_000_000,
|
|
broad_gapup_continuation_min_first_bar_dollar_vol=500_000,
|
|
broad_gapup_continuation_min_body_ratio=0.50,
|
|
broad_gapup_continuation_min_close_location=0.80,
|
|
broad_gapup_continuation_size_scale=0.25,
|
|
broad_gapup_continuation_max_trades=1,
|
|
)
|
|
|
|
result = simulate_orb_day(bars, "2026-01-05", params, enrichment, equity=10_000.0)
|
|
|
|
assert len(result.trades) == 1
|
|
assert result.trades[0].trigger_type == "broad_gapup_continuation"
|
|
assert result.trades[0].entry_price == 100.7
|
|
|
|
|
|
def test_entry_tie_break_rank_by_score_prioritizes_auxiliary_leader_on_same_bar() -> None:
|
|
bars = {
|
|
"BASE": [
|
|
{"timestamp": "2026-01-05T09:30:00-05:00", "open": 100.0, "high": 101.0, "low": 99.5, "close": 100.8, "volume": 10_000},
|
|
{"timestamp": "2026-01-05T09:35:00-05:00", "open": 101.0, "high": 102.0, "low": 100.8, "close": 101.8, "volume": 10_000},
|
|
{"timestamp": "2026-01-05T15:55:00-05:00", "open": 101.8, "high": 102.0, "low": 101.5, "close": 101.9, "volume": 10_000},
|
|
],
|
|
"HIGH": [
|
|
{"timestamp": "2026-01-05T08:00:00-05:00", "open": 100.0, "high": 100.2, "low": 99.8, "close": 100.0, "volume": 300_000},
|
|
{"timestamp": "2026-01-05T09:30:00-05:00", "open": 100.0, "high": 101.0, "low": 99.5, "close": 100.9, "volume": 100_000},
|
|
{"timestamp": "2026-01-05T09:35:00-05:00", "open": 101.0, "high": 102.0, "low": 100.8, "close": 101.8, "volume": 50_000},
|
|
{"timestamp": "2026-01-05T15:55:00-05:00", "open": 101.8, "high": 102.0, "low": 101.5, "close": 101.9, "volume": 50_000},
|
|
],
|
|
}
|
|
enrichment = _enrichment_for("BASE", "HIGH")
|
|
enrichment["BASE"]["2026-01-05"]["prev_close"] = 98.0
|
|
enrichment["BASE"]["2026-01-05"]["ret_5d"] = 0.03
|
|
enrichment["HIGH"]["2026-01-05"]["prev_close"] = 90.0
|
|
enrichment["HIGH"]["2026-01-05"]["ret_5d"] = 0.08
|
|
params = ORBStrategyParams(
|
|
engine_family="gainers_leader",
|
|
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=0.04,
|
|
max_candidates=10,
|
|
min_candidates_to_trade=1,
|
|
atr_stop_multiplier=1.0,
|
|
trailing_at_r=99.0,
|
|
breakeven_at_r=99.0,
|
|
risk_per_trade_pct=1.0,
|
|
max_position_pct=1.0,
|
|
max_trades_per_day=2,
|
|
slippage_bps=0.0,
|
|
broad_gapup_continuation_enabled=True,
|
|
broad_gapup_continuation_min_gap_pct=0.04,
|
|
broad_gapup_continuation_max_gap_pct=0.20,
|
|
broad_gapup_continuation_min_rvol=1.0,
|
|
broad_gapup_continuation_min_premarket_dollar_vol=10_000_000,
|
|
broad_gapup_continuation_min_first_bar_dollar_vol=500_000,
|
|
broad_gapup_continuation_min_body_ratio=0.50,
|
|
broad_gapup_continuation_min_close_location=0.80,
|
|
broad_gapup_continuation_size_scale=1.0,
|
|
broad_gapup_continuation_max_trades=1,
|
|
)
|
|
|
|
legacy = simulate_orb_day(
|
|
bars,
|
|
"2026-01-05",
|
|
params,
|
|
enrichment,
|
|
equity=10_000.0,
|
|
available_cash=10_000.0,
|
|
)
|
|
ranked = simulate_orb_day(
|
|
bars,
|
|
"2026-01-05",
|
|
params.model_copy(update={"entry_tie_break_rank_by_score": True}),
|
|
enrichment,
|
|
equity=10_000.0,
|
|
available_cash=10_000.0,
|
|
)
|
|
|
|
assert legacy.trades[0].ticker == "BASE"
|
|
assert ranked.trades[0].ticker == "HIGH"
|
|
assert ranked.trades[0].trigger_type == "broad_gapup_continuation"
|
|
|
|
|
|
def test_broad_gapup_continuation_bypasses_generic_soft_day_profile() -> None:
|
|
bars = {
|
|
"HIGH": [
|
|
{"timestamp": "2026-01-05T08:00:00-05:00", "open": 100.0, "high": 100.2, "low": 99.8, "close": 100.0, "volume": 300_000},
|
|
{"timestamp": "2026-01-05T09:30:00-05:00", "open": 100.0, "high": 101.0, "low": 99.5, "close": 100.9, "volume": 10_000},
|
|
{"timestamp": "2026-01-05T09:35:00-05:00", "open": 101.0, "high": 102.0, "low": 100.8, "close": 101.8, "volume": 5_000},
|
|
{"timestamp": "2026-01-05T15:55:00-05:00", "open": 101.8, "high": 102.0, "low": 101.5, "close": 101.9, "volume": 5_000},
|
|
],
|
|
}
|
|
enrichment = _enrichment_for("HIGH", "QQQ")
|
|
enrichment["HIGH"]["2026-01-05"]["prev_close"] = 90.0
|
|
enrichment["HIGH"]["2026-01-05"]["ret_5d"] = 0.08
|
|
enrichment["QQQ"]["2026-01-05"]["prev_close"] = 100.0
|
|
enrichment["QQQ"]["2026-01-05"]["today_open"] = 100.0
|
|
params = ORBStrategyParams(
|
|
engine_family="gainers_leader",
|
|
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=0.04,
|
|
max_candidates=10,
|
|
min_candidates_to_trade=1,
|
|
atr_stop_multiplier=10.0,
|
|
trailing_at_r=99.0,
|
|
breakeven_at_r=99.0,
|
|
risk_per_trade_pct=0.01,
|
|
max_position_pct=1.0,
|
|
slippage_bps=0.0,
|
|
market_regime_spy_threshold=0.01,
|
|
market_regime_ticker="QQQ",
|
|
soft_day_fallback_on_regime_skip=True,
|
|
soft_day_regime_skip_size_scale=0.1,
|
|
soft_day_combined_size_scale_floor=1.0,
|
|
soft_day_setup_profile="skip_day_reclaim_v1",
|
|
soft_day_min_ret_5d=0.50,
|
|
broad_gapup_continuation_enabled=True,
|
|
broad_gapup_continuation_min_gap_pct=0.04,
|
|
broad_gapup_continuation_max_gap_pct=0.20,
|
|
broad_gapup_continuation_min_rvol=1.0,
|
|
broad_gapup_continuation_min_premarket_dollar_vol=10_000_000,
|
|
broad_gapup_continuation_min_first_bar_dollar_vol=500_000,
|
|
broad_gapup_continuation_min_body_ratio=0.50,
|
|
broad_gapup_continuation_min_close_location=0.80,
|
|
broad_gapup_continuation_size_scale=0.25,
|
|
)
|
|
|
|
result = simulate_orb_day(bars, "2026-01-05", params, enrichment, equity=10_000.0)
|
|
|
|
assert result.is_soft_day is True
|
|
assert result.soft_day_reason == "market_regime"
|
|
assert len(result.trades) == 1
|
|
assert result.trades[0].trigger_type == "broad_gapup_continuation"
|
|
|
|
|
|
def test_broad_gapup_continuation_scan_is_not_blocked_by_primary_sector_cap() -> None:
|
|
bars = {
|
|
"BASE": [
|
|
{"timestamp": "2026-01-05T09:30:00-05:00", "open": 100.0, "high": 100.8, "low": 99.8, "close": 100.7, "volume": 20_000},
|
|
{"timestamp": "2026-01-05T09:35:00-05:00", "open": 100.8, "high": 101.2, "low": 100.7, "close": 101.1, "volume": 10_000},
|
|
{"timestamp": "2026-01-05T15:55:00-05:00", "open": 101.1, "high": 101.3, "low": 100.9, "close": 101.2, "volume": 10_000},
|
|
],
|
|
"HIGH": [
|
|
{"timestamp": "2026-01-05T08:00:00-05:00", "open": 100.0, "high": 100.2, "low": 99.8, "close": 100.0, "volume": 300_000},
|
|
{"timestamp": "2026-01-05T09:30:00-05:00", "open": 100.0, "high": 101.0, "low": 99.5, "close": 100.9, "volume": 10_000},
|
|
{"timestamp": "2026-01-05T09:35:00-05:00", "open": 101.0, "high": 102.0, "low": 100.8, "close": 101.8, "volume": 5_000},
|
|
{"timestamp": "2026-01-05T15:55:00-05:00", "open": 101.8, "high": 102.0, "low": 101.5, "close": 101.9, "volume": 5_000},
|
|
],
|
|
}
|
|
enrichment = _enrichment_for("BASE", "HIGH")
|
|
enrichment["BASE"]["2026-01-05"]["prev_close"] = 98.0
|
|
enrichment["BASE"]["2026-01-05"]["ret_5d"] = 0.03
|
|
enrichment["HIGH"]["2026-01-05"]["prev_close"] = 90.0
|
|
enrichment["HIGH"]["2026-01-05"]["ret_5d"] = 0.08
|
|
params = ORBStrategyParams(
|
|
engine_family="gainers_leader",
|
|
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=0.04,
|
|
max_candidates=1,
|
|
max_candidates_per_sector=1,
|
|
min_candidates_to_trade=1,
|
|
atr_stop_multiplier=10.0,
|
|
trailing_at_r=99.0,
|
|
breakeven_at_r=99.0,
|
|
risk_per_trade_pct=0.01,
|
|
max_position_pct=1.0,
|
|
slippage_bps=0.0,
|
|
broad_gapup_continuation_enabled=True,
|
|
broad_gapup_continuation_min_gap_pct=0.04,
|
|
broad_gapup_continuation_max_gap_pct=0.20,
|
|
broad_gapup_continuation_min_rvol=1.0,
|
|
broad_gapup_continuation_min_premarket_dollar_vol=10_000_000,
|
|
broad_gapup_continuation_min_first_bar_dollar_vol=500_000,
|
|
broad_gapup_continuation_min_body_ratio=0.50,
|
|
broad_gapup_continuation_min_close_location=0.80,
|
|
broad_gapup_continuation_size_scale=0.25,
|
|
broad_gapup_continuation_max_candidates=1,
|
|
broad_gapup_continuation_max_trades=1,
|
|
)
|
|
|
|
result = simulate_orb_day(
|
|
bars,
|
|
"2026-01-05",
|
|
params,
|
|
enrichment,
|
|
equity=10_000.0,
|
|
ticker_sectors={"BASE": "TECH", "HIGH": "TECH"},
|
|
)
|
|
|
|
assert "broad_gapup_continuation" in {trade.trigger_type for trade in result.trades}
|
|
|
|
|
|
def test_intraday_continuation_can_use_dedicated_low_atr_pct_gate() -> None:
|
|
bars = {
|
|
"LIQ": [
|
|
{"timestamp": "2026-01-05T09:30:00-05:00", "open": 100.0, "high": 100.2, "low": 99.8, "close": 100.1, "volume": 10_000},
|
|
{"timestamp": "2026-01-05T09:35:00-05:00", "open": 100.1, "high": 100.5, "low": 100.0, "close": 100.4, "volume": 10_000},
|
|
{"timestamp": "2026-01-05T09:40:00-05:00", "open": 100.4, "high": 100.9, "low": 100.3, "close": 100.8, "volume": 10_000},
|
|
{"timestamp": "2026-01-05T09:45:00-05:00", "open": 100.8, "high": 101.2, "low": 100.7, "close": 101.1, "volume": 10_000},
|
|
{"timestamp": "2026-01-05T09:50:00-05:00", "open": 101.1, "high": 101.6, "low": 101.0, "close": 101.5, "volume": 10_000},
|
|
{"timestamp": "2026-01-05T09:55:00-05:00", "open": 101.5, "high": 102.1, "low": 101.4, "close": 102.0, "volume": 10_000},
|
|
{"timestamp": "2026-01-05T10:00:00-05:00", "open": 102.1, "high": 103.0, "low": 102.0, "close": 102.8, "volume": 10_000},
|
|
{"timestamp": "2026-01-05T15:55:00-05:00", "open": 102.8, "high": 103.2, "low": 102.6, "close": 103.0, "volume": 10_000},
|
|
],
|
|
}
|
|
enrichment = _enrichment_for("LIQ")
|
|
enrichment["LIQ"]["2026-01-05"]["atr_14"] = 2.0
|
|
enrichment["LIQ"]["2026-01-05"]["prev_close"] = 99.0
|
|
|
|
base_params = ORBStrategyParams(
|
|
engine_family="gainers_leader",
|
|
min_price=10.0,
|
|
min_avg_dollar_volume=100_000_000.0,
|
|
min_atr_14=0.1,
|
|
min_atr_pct=0.04,
|
|
min_rvol=1000.0,
|
|
min_abs_gap_pct=0.02,
|
|
max_gap_pct=0.04,
|
|
max_candidates=10,
|
|
)
|
|
assert compute_orb_candidates(bars, "2026-01-05", base_params, enrichment) == []
|
|
|
|
params = base_params.model_copy(
|
|
update={
|
|
"intraday_continuation_reclaim_enabled": True,
|
|
"intraday_continuation_reclaim_signal_minutes": 30,
|
|
"intraday_continuation_reclaim_min_signal_return_pct": 0.015,
|
|
"intraday_continuation_reclaim_min_signal_dollar_vol": 1_000_000,
|
|
"intraday_continuation_reclaim_min_signal_close_location": 0.80,
|
|
"intraday_continuation_reclaim_require_signal_above_vwap": False,
|
|
"intraday_continuation_reclaim_min_gap_pct": -0.02,
|
|
"intraday_continuation_reclaim_max_gap_pct": 0.02,
|
|
"intraday_continuation_reclaim_min_avg_dollar_vol": 100_000_000,
|
|
"intraday_continuation_reclaim_min_atr_pct": 0.01,
|
|
"intraday_continuation_reclaim_max_atr_pct": 0.03,
|
|
}
|
|
)
|
|
|
|
candidates = compute_orb_candidates(bars, "2026-01-05", params, enrichment)
|
|
|
|
assert len(candidates) == 1
|
|
assert candidates[0]["ticker"] == "LIQ"
|
|
assert candidates[0]["intraday_continuation_reclaim"] is True
|
|
assert candidates[0]["intraday_continuation_entry_price"] == 102.1
|
|
|
|
too_low_volatility_ceiling = params.model_copy(
|
|
update={"intraday_continuation_reclaim_max_atr_pct": 0.015}
|
|
)
|
|
assert compute_orb_candidates(
|
|
bars,
|
|
"2026-01-05",
|
|
too_low_volatility_ceiling,
|
|
enrichment,
|
|
) == []
|
|
|
|
|
|
def test_market_thrust_liquid_opening_burst_enters_without_breakout() -> None:
|
|
bars = {
|
|
"BURST": [
|
|
{"timestamp": "2026-01-05T09:30:00-05:00", "open": 100.0, "high": 101.0, "low": 99.8, "close": 100.9, "volume": 10_000},
|
|
{"timestamp": "2026-01-05T09:35:00-05:00", "open": 101.2, "high": 101.2, "low": 100.8, "close": 101.0, "volume": 5_000},
|
|
{"timestamp": "2026-01-05T15:55:00-05:00", "open": 101.0, "high": 102.2, "low": 100.9, "close": 102.0, "volume": 5_000},
|
|
],
|
|
}
|
|
enrichment = _enrichment_for("BURST")
|
|
enrichment["BURST"]["2026-01-05"]["prev_close"] = 98.0
|
|
enrichment["BURST"]["2026-01-05"]["ret_5d"] = 0.03
|
|
params = ORBStrategyParams(
|
|
engine_family="gainers_leader",
|
|
min_price=10.0,
|
|
min_avg_dollar_volume=0.0,
|
|
min_atr_14=0.1,
|
|
min_rvol=1000.0,
|
|
min_abs_gap_pct=0.01,
|
|
max_gap_pct=0.04,
|
|
max_candidates=10,
|
|
min_candidates_to_trade=1,
|
|
min_candidate_breadth=1.1,
|
|
soft_day_fallback_on_breadth_skip=True,
|
|
soft_day_breadth_skip_size_scale=0.1,
|
|
soft_day_combined_size_scale_floor=1.0,
|
|
atr_stop_multiplier=10.0,
|
|
trailing_at_r=99.0,
|
|
breakeven_at_r=99.0,
|
|
risk_per_trade_pct=0.01,
|
|
max_position_pct=1.0,
|
|
slippage_bps=0.0,
|
|
market_thrust_breadth_override_enabled=True,
|
|
market_thrust_liquid_continuation_enabled=True,
|
|
market_thrust_liquid_continuation_entry_mode="opening_burst",
|
|
market_thrust_liquid_continuation_min_gap_pct=0.02,
|
|
market_thrust_liquid_continuation_max_gap_pct=0.04,
|
|
market_thrust_liquid_continuation_min_first_bar_return_pct=0.007,
|
|
market_thrust_liquid_continuation_min_first_bar_dollar_vol=100_000,
|
|
market_thrust_liquid_continuation_min_avg_dollar_vol=100_000,
|
|
market_thrust_liquid_continuation_min_body_ratio=0.5,
|
|
market_thrust_liquid_continuation_min_close_location=0.8,
|
|
market_thrust_liquid_continuation_max_candidates=1,
|
|
market_thrust_liquid_continuation_max_trades=1,
|
|
market_thrust_liquid_continuation_size_scale=0.2,
|
|
)
|
|
|
|
result = simulate_orb_day(bars, "2026-01-05", params, enrichment, equity=10_000.0)
|
|
|
|
assert result.entry_diagnostics["market_thrust_opening_burst_candidates"] == 1
|
|
assert len(result.trades) == 1
|
|
trade = result.trades[0]
|
|
assert trade.trigger_type == "market_thrust_opening_burst"
|
|
assert trade.entry_time == "2026-01-05T09:35:00-05:00"
|
|
assert trade.entry_price == 101.2
|
|
assert trade.market_thrust_opening_burst is True
|
|
|
|
|
|
def test_iex_live_volume_multiplier_rescales_market_thrust_first_bar_gate() -> None:
|
|
bars = {
|
|
"IEX": [
|
|
{"timestamp": "2026-01-05T09:30:00-05:00", "open": 100.0, "high": 101.0, "low": 99.8, "close": 100.9, "volume": 500},
|
|
{"timestamp": "2026-01-05T09:35:00-05:00", "open": 101.2, "high": 101.5, "low": 100.8, "close": 101.3, "volume": 500},
|
|
{"timestamp": "2026-01-05T15:55:00-05:00", "open": 101.3, "high": 102.0, "low": 101.0, "close": 101.8, "volume": 500},
|
|
],
|
|
}
|
|
enrichment = _enrichment_for("IEX")
|
|
enrichment["IEX"]["2026-01-05"]["prev_close"] = 98.0
|
|
enrichment["IEX"]["2026-01-05"]["avg_dollar_vol_30d"] = 300_000_000.0
|
|
|
|
params = ORBStrategyParams(
|
|
engine_family="gainers_leader",
|
|
min_price=10.0,
|
|
min_avg_dollar_volume=0.0,
|
|
min_atr_14=0.1,
|
|
min_rvol=None,
|
|
min_abs_gap_pct=0.01,
|
|
max_gap_pct=0.04,
|
|
max_candidates=10,
|
|
market_thrust_liquid_continuation_enabled=True,
|
|
market_thrust_liquid_continuation_min_gap_pct=0.02,
|
|
market_thrust_liquid_continuation_max_gap_pct=0.04,
|
|
market_thrust_liquid_continuation_min_first_bar_return_pct=0.007,
|
|
market_thrust_liquid_continuation_min_first_bar_dollar_vol=1_000_000,
|
|
market_thrust_liquid_continuation_min_avg_dollar_vol=100_000_000,
|
|
market_thrust_liquid_continuation_min_body_ratio=0.5,
|
|
market_thrust_liquid_continuation_min_close_location=0.8,
|
|
)
|
|
|
|
unscaled = compute_orb_candidates(
|
|
bars,
|
|
"2026-01-05",
|
|
params,
|
|
enrichment,
|
|
iex_live_mode=True,
|
|
)
|
|
scaled = compute_orb_candidates(
|
|
bars,
|
|
"2026-01-05",
|
|
params.model_copy(update={"iex_live_intraday_volume_multiplier": 30.0}),
|
|
enrichment,
|
|
iex_live_mode=True,
|
|
)
|
|
|
|
assert len(unscaled) == 1
|
|
assert unscaled[0]["market_thrust_liquid_continuation"] is False
|
|
assert len(scaled) == 1
|
|
assert scaled[0]["market_thrust_liquid_continuation"] is True
|
|
assert scaled[0]["first_bar_dollar_vol"] == 50_000.0
|
|
assert scaled[0]["filter_first_bar_dollar_vol"] == 1_500_000.0
|
|
|
|
|
|
def test_no_thrust_liquid_repair_can_rank_by_opening_impulse() -> None:
|
|
bars = {
|
|
"SCORE": [
|
|
{"timestamp": "2026-01-05T08:00:00-05:00", "open": 100.0, "high": 100.0, "low": 100.0, "close": 100.0, "volume": 1_000_000},
|
|
{"timestamp": "2026-01-05T09:30:00-05:00", "open": 100.0, "high": 101.2, "low": 99.9, "close": 101.0, "volume": 20_000},
|
|
{"timestamp": "2026-01-05T09:35:00-05:00", "open": 101.1, "high": 101.5, "low": 100.8, "close": 101.2, "volume": 10_000},
|
|
{"timestamp": "2026-01-05T15:55:00-05:00", "open": 101.2, "high": 101.4, "low": 101.0, "close": 101.3, "volume": 10_000},
|
|
],
|
|
"IMPULSE": [
|
|
{"timestamp": "2026-01-05T08:00:00-05:00", "open": 100.0, "high": 100.0, "low": 100.0, "close": 100.0, "volume": 10_000},
|
|
{"timestamp": "2026-01-05T09:30:00-05:00", "open": 100.0, "high": 103.8, "low": 99.9, "close": 103.5, "volume": 2_000},
|
|
{"timestamp": "2026-01-05T09:35:00-05:00", "open": 103.6, "high": 104.2, "low": 103.3, "close": 104.0, "volume": 2_000},
|
|
{"timestamp": "2026-01-05T15:55:00-05:00", "open": 104.0, "high": 104.5, "low": 103.8, "close": 104.4, "volume": 2_000},
|
|
],
|
|
}
|
|
enrichment = _enrichment_for("SCORE", "IMPULSE")
|
|
for ticker in enrichment:
|
|
enrichment[ticker]["2026-01-05"]["prev_close"] = 99.0
|
|
enrichment[ticker]["2026-01-05"]["ret_5d"] = 0.02
|
|
|
|
params = ORBStrategyParams(
|
|
engine_family="gainers_leader",
|
|
min_price=10.0,
|
|
min_avg_dollar_volume=0.0,
|
|
min_atr_14=0.1,
|
|
min_rvol=1000.0,
|
|
min_abs_gap_pct=0.02,
|
|
max_gap_pct=0.04,
|
|
max_candidates=10,
|
|
min_candidates_to_trade=1,
|
|
min_candidate_breadth=0.0,
|
|
weight_rvol=0.0,
|
|
weight_gap=0.0,
|
|
weight_dollar_vol=0.0,
|
|
weight_premarket_dollar_vol=1.0,
|
|
weight_momentum=0.0,
|
|
weight_obv_slope=0.0,
|
|
weight_event_catalyst=0.0,
|
|
weight_close_location=0.0,
|
|
atr_stop_multiplier=10.0,
|
|
trailing_at_r=99.0,
|
|
breakeven_at_r=99.0,
|
|
risk_per_trade_pct=0.01,
|
|
max_position_pct=1.0,
|
|
slippage_bps=0.0,
|
|
market_thrust_liquid_continuation_enabled=True,
|
|
market_thrust_liquid_continuation_require_market_thrust=False,
|
|
market_thrust_liquid_continuation_entry_mode="opening_burst",
|
|
market_thrust_liquid_continuation_no_thrust_min_gap_pct=0.0,
|
|
market_thrust_liquid_continuation_no_thrust_max_gap_pct=0.04,
|
|
market_thrust_liquid_continuation_no_thrust_min_first_bar_return_pct=0.005,
|
|
market_thrust_liquid_continuation_no_thrust_min_first_bar_dollar_vol=100_000,
|
|
market_thrust_liquid_continuation_no_thrust_min_avg_dollar_vol=100_000,
|
|
market_thrust_liquid_continuation_no_thrust_max_candidates=1,
|
|
market_thrust_liquid_continuation_no_thrust_max_trades=1,
|
|
market_thrust_liquid_continuation_no_thrust_rank_mode="opening_impulse",
|
|
market_thrust_liquid_continuation_no_thrust_size_scale=0.2,
|
|
)
|
|
|
|
result = simulate_orb_day(bars, "2026-01-05", params, enrichment, equity=10_000.0)
|
|
|
|
assert result.entry_diagnostics["market_thrust_opening_burst_candidates"] == 1
|
|
assert len(result.trades) == 1
|
|
assert result.trades[0].ticker == "IMPULSE"
|
|
assert result.trades[0].trigger_type == "market_thrust_opening_burst"
|
|
|
|
priority_params = params.model_copy(
|
|
update={
|
|
"market_thrust_liquid_continuation_no_thrust_rank_mode": None,
|
|
"min_candidate_breadth": 1.1,
|
|
"soft_day_fallback_on_breadth_skip": True,
|
|
"soft_day_combined_size_scale_floor": 1.0,
|
|
"soft_day_rank_before_time": True,
|
|
(
|
|
"market_thrust_liquid_continuation_no_thrust_"
|
|
"priority_min_first_bar_return_pct"
|
|
): 0.03,
|
|
}
|
|
)
|
|
priority_result = simulate_orb_day(
|
|
bars,
|
|
"2026-01-05",
|
|
priority_params,
|
|
enrichment,
|
|
equity=10_000.0,
|
|
)
|
|
|
|
assert len(priority_result.trades) == 1
|
|
assert priority_result.trades[0].ticker == "IMPULSE"
|
|
|
|
|
|
def test_nofill_vwap_reclaim_adds_trade_when_orb_never_breaks() -> None:
|
|
bars = {
|
|
"VWAP": [
|
|
{"timestamp": "2026-01-05T09:30:00-05:00", "open": 100.0, "high": 101.0, "low": 98.0, "close": 99.0, "volume": 1000},
|
|
{"timestamp": "2026-01-05T09:35:00-05:00", "open": 99.0, "high": 99.2, "low": 98.5, "close": 98.8, "volume": 1000},
|
|
{"timestamp": "2026-01-05T09:40:00-05:00", "open": 98.8, "high": 99.5, "low": 98.7, "close": 99.4, "volume": 1000},
|
|
{"timestamp": "2026-01-05T09:45:00-05:00", "open": 99.4, "high": 99.9, "low": 99.2, "close": 99.8, "volume": 1000},
|
|
{"timestamp": "2026-01-05T10:00:00-05:00", "open": 99.8, "high": 100.5, "low": 99.7, "close": 100.4, "volume": 2000},
|
|
{"timestamp": "2026-01-05T15:55:00-05:00", "open": 100.4, "high": 100.9, "low": 100.2, "close": 100.8, "volume": 1000},
|
|
]
|
|
}
|
|
enrichment = _enrichment_for("VWAP")
|
|
enrichment["VWAP"]["2026-01-05"]["prev_close"] = 98.0
|
|
|
|
params = ORBStrategyParams(
|
|
engine_family="gainers_leader",
|
|
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_abs_gap_pct=0.0,
|
|
max_candidates=10,
|
|
min_candidates_to_trade=1,
|
|
atr_stop_multiplier=10.0,
|
|
trailing_at_r=99.0,
|
|
breakeven_at_r=99.0,
|
|
risk_per_trade_pct=0.01,
|
|
max_position_pct=1.0,
|
|
slippage_bps=0.0,
|
|
nofill_vwap_reclaim_enabled=True,
|
|
nofill_vwap_reclaim_min_score_pct=0.0,
|
|
nofill_vwap_reclaim_size_scale=1.0,
|
|
vwap_reclaim_window_start_min=30,
|
|
vwap_reclaim_window_end_min=120,
|
|
vwap_reclaim_require_prior_dip=True,
|
|
)
|
|
|
|
day_result = simulate_orb_day(
|
|
bars,
|
|
"2026-01-05",
|
|
params,
|
|
enrichment,
|
|
equity=10_000.0,
|
|
)
|
|
|
|
assert len(day_result.trades) == 1
|
|
assert day_result.trades[0].trigger_type == "vwap_reclaim"
|
|
assert day_result.trades[0].entry_time == "2026-01-05T10:00:00-05:00"
|
|
|
|
|
|
def test_reclaim_entry_attention_can_gate_weak_nofill_vwap_reclaim() -> None:
|
|
bars = {
|
|
"VWAP": [
|
|
{"timestamp": "2026-01-05T09:30:00-05:00", "open": 100.0, "high": 101.0, "low": 98.0, "close": 99.0, "volume": 1000},
|
|
{"timestamp": "2026-01-05T09:35:00-05:00", "open": 99.0, "high": 99.2, "low": 98.5, "close": 98.8, "volume": 1000},
|
|
{"timestamp": "2026-01-05T09:40:00-05:00", "open": 98.8, "high": 99.5, "low": 98.7, "close": 99.4, "volume": 1000},
|
|
{"timestamp": "2026-01-05T09:45:00-05:00", "open": 99.4, "high": 99.9, "low": 99.2, "close": 99.8, "volume": 1000},
|
|
{"timestamp": "2026-01-05T10:00:00-05:00", "open": 99.8, "high": 100.5, "low": 99.7, "close": 100.4, "volume": 1200},
|
|
{"timestamp": "2026-01-05T15:55:00-05:00", "open": 100.4, "high": 100.9, "low": 100.2, "close": 100.8, "volume": 1000},
|
|
]
|
|
}
|
|
enrichment = _enrichment_for("VWAP")
|
|
enrichment["VWAP"]["2026-01-05"]["prev_close"] = 98.0
|
|
|
|
base_params = ORBStrategyParams(
|
|
engine_family="gainers_leader",
|
|
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_abs_gap_pct=0.0,
|
|
max_candidates=10,
|
|
min_candidates_to_trade=1,
|
|
atr_stop_multiplier=10.0,
|
|
trailing_at_r=99.0,
|
|
breakeven_at_r=99.0,
|
|
risk_per_trade_pct=0.01,
|
|
max_position_pct=1.0,
|
|
slippage_bps=0.0,
|
|
nofill_vwap_reclaim_enabled=True,
|
|
nofill_vwap_reclaim_min_score_pct=0.0,
|
|
nofill_vwap_reclaim_size_scale=1.0,
|
|
vwap_reclaim_window_start_min=30,
|
|
vwap_reclaim_window_end_min=120,
|
|
vwap_reclaim_require_prior_dip=True,
|
|
reclaim_entry_attention_enabled=True,
|
|
reclaim_entry_attention_trigger_types=["vwap_reclaim"],
|
|
)
|
|
|
|
pass_result = simulate_orb_day(
|
|
bars,
|
|
"2026-01-05",
|
|
base_params.model_copy(
|
|
update={"reclaim_entry_attention_min_entry_rel_volume": 1.0}
|
|
),
|
|
enrichment,
|
|
equity=10_000.0,
|
|
)
|
|
assert len(pass_result.trades) == 1
|
|
assert pass_result.trades[0].entry_rel_volume is not None
|
|
assert pass_result.trades[0].entry_rel_volume > 1.0
|
|
|
|
reject_result = simulate_orb_day(
|
|
bars,
|
|
"2026-01-05",
|
|
base_params.model_copy(
|
|
update={"reclaim_entry_attention_min_entry_rel_volume": 2.0}
|
|
),
|
|
enrichment,
|
|
equity=10_000.0,
|
|
)
|
|
assert reject_result.trades == []
|
|
assert (
|
|
reject_result.entry_diagnostics["entry_reject_stats"][
|
|
"reclaim_entry_attention_relvol"
|
|
]
|
|
== 1
|
|
)
|
|
|
|
|
|
def test_late_breakout_adds_trade_after_primary_timeout() -> None:
|
|
bars = {
|
|
"LATE": [
|
|
{"timestamp": "2026-01-05T09:30:00-05:00", "open": 100.0, "high": 101.0, "low": 99.6, "close": 100.6, "volume": 1000},
|
|
{"timestamp": "2026-01-05T09:35:00-05:00", "open": 100.6, "high": 100.8, "low": 100.0, "close": 100.2, "volume": 800},
|
|
{"timestamp": "2026-01-05T09:40:00-05:00", "open": 100.2, "high": 100.7, "low": 99.9, "close": 100.5, "volume": 800},
|
|
{"timestamp": "2026-01-05T09:50:00-05:00", "open": 100.5, "high": 100.9, "low": 100.2, "close": 100.8, "volume": 800},
|
|
{"timestamp": "2026-01-05T10:00:00-05:00", "open": 100.8, "high": 101.6, "low": 95.0, "close": 101.4, "volume": 3000},
|
|
{"timestamp": "2026-01-05T15:55:00-05:00", "open": 101.4, "high": 102.0, "low": 101.1, "close": 101.8, "volume": 1000},
|
|
]
|
|
}
|
|
enrichment = _enrichment_for("LATE")
|
|
enrichment["LATE"]["2026-01-05"]["prev_close"] = 98.0
|
|
|
|
params = ORBStrategyParams(
|
|
engine_family="gainers_leader",
|
|
allow_red_to_green_breakout=True,
|
|
order_timeout_minutes=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.0,
|
|
max_candidates=10,
|
|
min_candidates_to_trade=1,
|
|
atr_stop_multiplier=0.5,
|
|
trailing_at_r=99.0,
|
|
breakeven_at_r=99.0,
|
|
risk_per_trade_pct=0.01,
|
|
max_position_pct=1.0,
|
|
slippage_bps=0.0,
|
|
late_breakout_enabled=True,
|
|
late_breakout_min_score_pct=0.0,
|
|
late_breakout_size_scale=1.0,
|
|
late_breakout_window_start_min=30,
|
|
late_breakout_window_end_min=120,
|
|
late_breakout_confirm_rel_vol=1.2,
|
|
)
|
|
|
|
day_result = simulate_orb_day(
|
|
bars,
|
|
"2026-01-05",
|
|
params,
|
|
enrichment,
|
|
equity=10_000.0,
|
|
)
|
|
|
|
assert day_result.entry_diagnostics["primary_timed_candidates"] == 0
|
|
assert day_result.entry_diagnostics["late_breakout_candidates"] == 1
|
|
assert len(day_result.trades) == 1
|
|
assert day_result.trades[0].trigger_type == "late_breakout"
|
|
assert day_result.trades[0].entry_time == "2026-01-05T10:00:00-05:00"
|
|
assert day_result.trades[0].exit_reason == "close"
|
|
|
|
|
|
def test_soft_day_vwap_reclaim_bypasses_market_regime_profile_block() -> None:
|
|
bars = {
|
|
"VWAP": [
|
|
{"timestamp": "2026-01-05T09:30:00-05:00", "open": 100.0, "high": 101.0, "low": 98.0, "close": 99.0, "volume": 1000},
|
|
{"timestamp": "2026-01-05T09:35:00-05:00", "open": 99.0, "high": 99.2, "low": 98.5, "close": 98.8, "volume": 1000},
|
|
{"timestamp": "2026-01-05T09:40:00-05:00", "open": 98.8, "high": 99.5, "low": 98.7, "close": 99.4, "volume": 1000},
|
|
{"timestamp": "2026-01-05T09:45:00-05:00", "open": 99.4, "high": 99.9, "low": 99.2, "close": 99.8, "volume": 1000},
|
|
{"timestamp": "2026-01-05T10:00:00-05:00", "open": 99.8, "high": 100.5, "low": 99.7, "close": 100.4, "volume": 2000},
|
|
{"timestamp": "2026-01-05T15:55:00-05:00", "open": 100.4, "high": 100.9, "low": 100.2, "close": 100.8, "volume": 1000},
|
|
]
|
|
}
|
|
enrichment = _enrichment_for("VWAP", "QQQ")
|
|
enrichment["VWAP"]["2026-01-05"]["prev_close"] = 98.0
|
|
enrichment["QQQ"]["2026-01-05"]["prev_close"] = 100.0
|
|
enrichment["QQQ"]["2026-01-05"]["today_open"] = 99.0
|
|
|
|
params = ORBStrategyParams(
|
|
engine_family="gainers_leader",
|
|
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_abs_gap_pct=0.0,
|
|
max_candidates=10,
|
|
min_candidates_to_trade=1,
|
|
market_regime_spy_threshold=0.001,
|
|
market_regime_ticker="QQQ",
|
|
soft_day_fallback_on_regime_skip=True,
|
|
soft_day_regime_skip_size_scale=0.1,
|
|
soft_day_setup_profile="skip_day_reclaim_v1",
|
|
atr_stop_multiplier=10.0,
|
|
trailing_at_r=99.0,
|
|
breakeven_at_r=99.0,
|
|
risk_per_trade_pct=0.01,
|
|
max_position_pct=1.0,
|
|
slippage_bps=0.0,
|
|
soft_day_vwap_reclaim_enabled=True,
|
|
soft_day_vwap_reclaim_min_score_pct=0.0,
|
|
soft_day_vwap_reclaim_min_premarket_dollar_vol=0.0,
|
|
soft_day_vwap_reclaim_size_scale=1.0,
|
|
vwap_reclaim_window_start_min=30,
|
|
vwap_reclaim_window_end_min=120,
|
|
vwap_reclaim_require_prior_dip=True,
|
|
)
|
|
|
|
day_result = simulate_orb_day(
|
|
bars,
|
|
"2026-01-05",
|
|
params,
|
|
enrichment,
|
|
equity=10_000.0,
|
|
)
|
|
|
|
assert day_result.is_soft_day
|
|
assert day_result.soft_day_reason == "market_regime"
|
|
assert len(day_result.trades) == 1
|
|
assert day_result.trades[0].trigger_type == "soft_day_vwap_reclaim"
|
|
|
|
|
|
def test_soft_day_vwap_weak_participation_guard_rejects_low_rank_body() -> None:
|
|
bars = {
|
|
"ANCHOR": [
|
|
{"timestamp": "2026-01-05T09:30:00-05:00", "open": 100.0, "high": 104.0, "low": 99.5, "close": 103.5, "volume": 20_000},
|
|
{"timestamp": "2026-01-05T09:35:00-05:00", "open": 103.5, "high": 104.0, "low": 103.0, "close": 103.7, "volume": 5_000},
|
|
{"timestamp": "2026-01-05T09:40:00-05:00", "open": 103.7, "high": 104.2, "low": 103.5, "close": 103.9, "volume": 5_000},
|
|
{"timestamp": "2026-01-05T10:00:00-05:00", "open": 103.9, "high": 104.5, "low": 103.8, "close": 104.1, "volume": 5_000},
|
|
{"timestamp": "2026-01-05T15:55:00-05:00", "open": 104.1, "high": 104.5, "low": 103.9, "close": 104.2, "volume": 5_000},
|
|
],
|
|
"VWAP": [
|
|
{"timestamp": "2026-01-05T09:30:00-05:00", "open": 100.0, "high": 101.0, "low": 98.0, "close": 99.0, "volume": 1_000},
|
|
{"timestamp": "2026-01-05T09:35:00-05:00", "open": 99.0, "high": 99.2, "low": 98.5, "close": 98.8, "volume": 1_000},
|
|
{"timestamp": "2026-01-05T09:40:00-05:00", "open": 98.8, "high": 99.5, "low": 98.7, "close": 99.4, "volume": 1_000},
|
|
{"timestamp": "2026-01-05T09:45:00-05:00", "open": 99.4, "high": 99.9, "low": 99.2, "close": 99.8, "volume": 1_000},
|
|
{"timestamp": "2026-01-05T10:00:00-05:00", "open": 99.8, "high": 100.5, "low": 99.7, "close": 100.4, "volume": 2_000},
|
|
{"timestamp": "2026-01-05T15:55:00-05:00", "open": 100.4, "high": 100.9, "low": 100.2, "close": 100.8, "volume": 1_000},
|
|
],
|
|
}
|
|
enrichment = _enrichment_for("ANCHOR", "VWAP", "QQQ")
|
|
enrichment["ANCHOR"]["2026-01-05"]["prev_close"] = 98.0
|
|
enrichment["VWAP"]["2026-01-05"]["prev_close"] = 98.0
|
|
enrichment["QQQ"]["2026-01-05"]["prev_close"] = 100.0
|
|
enrichment["QQQ"]["2026-01-05"]["today_open"] = 99.0
|
|
|
|
params = ORBStrategyParams(
|
|
engine_family="gainers_leader",
|
|
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_abs_gap_pct=0.0,
|
|
max_candidates=10,
|
|
min_candidates_to_trade=1,
|
|
market_regime_spy_threshold=0.001,
|
|
market_regime_ticker="QQQ",
|
|
soft_day_fallback_on_regime_skip=True,
|
|
soft_day_regime_skip_size_scale=0.1,
|
|
soft_day_setup_profile="skip_day_reclaim_v1",
|
|
atr_stop_multiplier=10.0,
|
|
trailing_at_r=99.0,
|
|
breakeven_at_r=99.0,
|
|
risk_per_trade_pct=0.01,
|
|
max_position_pct=1.0,
|
|
slippage_bps=0.0,
|
|
soft_day_vwap_reclaim_enabled=True,
|
|
soft_day_vwap_reclaim_min_score_pct=0.0,
|
|
soft_day_vwap_reclaim_min_premarket_dollar_vol=0.0,
|
|
soft_day_vwap_reclaim_size_scale=1.0,
|
|
vwap_reclaim_window_start_min=30,
|
|
vwap_reclaim_window_end_min=120,
|
|
vwap_reclaim_require_prior_dip=True,
|
|
)
|
|
|
|
unguarded = simulate_orb_day(bars, "2026-01-05", params, enrichment, equity=10_000.0)
|
|
guarded = simulate_orb_day(
|
|
bars,
|
|
"2026-01-05",
|
|
params.model_copy(
|
|
update={
|
|
"soft_day_vwap_reclaim_weak_participation_max_rvol_rank_pct": 0.65,
|
|
"soft_day_vwap_reclaim_weak_participation_max_body_ratio": 0.75,
|
|
}
|
|
),
|
|
enrichment,
|
|
equity=10_000.0,
|
|
)
|
|
|
|
assert len(unguarded.trades) == 1
|
|
assert unguarded.trades[0].ticker == "VWAP"
|
|
assert unguarded.trades[0].trigger_type == "soft_day_vwap_reclaim"
|
|
assert unguarded.trades[0].rvol_rank_pct == 0.0
|
|
assert len(guarded.trades) == 0
|
|
assert guarded.entry_diagnostics["entry_reject_stats"][
|
|
"soft_day_vwap_weak_participation"
|
|
] == 1
|
|
|
|
|
|
def test_soft_day_vwap_reclaim_can_be_limited_to_no_primary_trades() -> None:
|
|
bars = {
|
|
"ORB": [
|
|
{"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": 102.0, "low": 100.8, "close": 101.8, "volume": 1000},
|
|
{"timestamp": "2026-01-05T09:40:00-05:00", "open": 101.8, "high": 102.0, "low": 101.6, "close": 101.9, "volume": 1000},
|
|
{"timestamp": "2026-01-05T09:45:00-05:00", "open": 101.9, "high": 102.1, "low": 101.7, "close": 102.0, "volume": 1000},
|
|
{"timestamp": "2026-01-05T15:55:00-05:00", "open": 101.8, "high": 102.0, "low": 101.6, "close": 101.9, "volume": 1000},
|
|
],
|
|
"VWAP": [
|
|
{"timestamp": "2026-01-05T09:30:00-05:00", "open": 100.0, "high": 101.0, "low": 98.0, "close": 99.0, "volume": 1000},
|
|
{"timestamp": "2026-01-05T09:35:00-05:00", "open": 99.0, "high": 99.2, "low": 98.5, "close": 98.8, "volume": 1000},
|
|
{"timestamp": "2026-01-05T09:40:00-05:00", "open": 98.8, "high": 99.5, "low": 98.7, "close": 99.4, "volume": 1000},
|
|
{"timestamp": "2026-01-05T09:45:00-05:00", "open": 99.4, "high": 99.9, "low": 99.2, "close": 99.8, "volume": 1000},
|
|
{"timestamp": "2026-01-05T10:00:00-05:00", "open": 99.8, "high": 100.5, "low": 99.7, "close": 100.4, "volume": 2000},
|
|
{"timestamp": "2026-01-05T15:55:00-05:00", "open": 100.4, "high": 100.9, "low": 100.2, "close": 100.8, "volume": 1000},
|
|
],
|
|
}
|
|
enrichment = _enrichment_for("ORB", "VWAP", "QQQ")
|
|
enrichment["ORB"]["2026-01-05"]["prev_close"] = 98.0
|
|
enrichment["VWAP"]["2026-01-05"]["prev_close"] = 98.0
|
|
enrichment["QQQ"]["2026-01-05"]["prev_close"] = 100.0
|
|
enrichment["QQQ"]["2026-01-05"]["today_open"] = 99.0
|
|
|
|
params = ORBStrategyParams(
|
|
engine_family="gainers_leader",
|
|
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_abs_gap_pct=0.0,
|
|
max_candidates=10,
|
|
min_candidates_to_trade=1,
|
|
max_trades_per_day=10,
|
|
market_regime_spy_threshold=0.001,
|
|
market_regime_ticker="QQQ",
|
|
soft_day_fallback_on_regime_skip=True,
|
|
soft_day_regime_skip_size_scale=0.1,
|
|
soft_day_setup_profile="none",
|
|
atr_stop_multiplier=10.0,
|
|
trailing_at_r=99.0,
|
|
breakeven_at_r=99.0,
|
|
risk_per_trade_pct=0.01,
|
|
max_position_pct=1.0,
|
|
slippage_bps=0.0,
|
|
soft_day_vwap_reclaim_enabled=True,
|
|
soft_day_vwap_reclaim_only_when_no_primary_trades=True,
|
|
soft_day_vwap_reclaim_min_score_pct=0.0,
|
|
soft_day_vwap_reclaim_min_premarket_dollar_vol=0.0,
|
|
soft_day_vwap_reclaim_size_scale=1.0,
|
|
vwap_reclaim_window_start_min=30,
|
|
vwap_reclaim_window_end_min=120,
|
|
vwap_reclaim_require_prior_dip=True,
|
|
)
|
|
|
|
day_result = simulate_orb_day(
|
|
bars,
|
|
"2026-01-05",
|
|
params,
|
|
enrichment,
|
|
equity=10_000.0,
|
|
)
|
|
|
|
assert day_result.is_soft_day
|
|
assert day_result.entry_diagnostics["primary_timed_candidates"] == 1
|
|
assert day_result.entry_diagnostics["soft_day_vwap_candidates"] == 1
|
|
assert day_result.entry_diagnostics["timed_candidates"] == 2
|
|
assert day_result.entry_diagnostics["soft_day_vwap_trades"] == 0
|
|
assert day_result.entry_diagnostics["entry_reject_stats"] == {
|
|
"soft_day_vwap_primary_trade_exists": 1
|
|
}
|
|
assert len(day_result.trades) == 1
|
|
assert day_result.trades[0].trigger_type == "orb"
|
|
|
|
no_existing_params = params.model_copy(
|
|
update={
|
|
"soft_day_vwap_reclaim_only_when_no_primary_trades": False,
|
|
"soft_day_vwap_reclaim_only_when_no_existing_trades": True,
|
|
}
|
|
)
|
|
no_existing_result = simulate_orb_day(
|
|
bars,
|
|
"2026-01-05",
|
|
no_existing_params,
|
|
enrichment,
|
|
equity=10_000.0,
|
|
)
|
|
|
|
assert no_existing_result.entry_diagnostics["soft_day_vwap_trades"] == 0
|
|
assert no_existing_result.entry_diagnostics["entry_reject_stats"] == {
|
|
"soft_day_vwap_existing_trade": 1
|
|
}
|
|
|
|
|
|
def test_hard_breadth_soft_fallback_converts_breadth_skip_to_soft_day() -> None:
|
|
bars = {
|
|
ticker: [
|
|
{"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": 102.0, "low": 100.8, "close": 101.8, "volume": 1000},
|
|
{"timestamp": "2026-01-05T15:55:00-05:00", "open": 101.8, "high": 102.2, "low": 101.6, "close": 102.0, "volume": 1000},
|
|
]
|
|
for ticker in ("AAA", "BBB")
|
|
}
|
|
enrichment = _enrichment_for("AAA", "BBB")
|
|
enrichment["AAA"]["2026-01-05"]["prev_close"] = 98.0
|
|
enrichment["AAA"]["2026-01-05"]["today_open"] = 100.0
|
|
enrichment["BBB"]["2026-01-05"]["prev_close"] = 102.0
|
|
enrichment["BBB"]["2026-01-05"]["today_open"] = 100.0
|
|
params = ORBStrategyParams(
|
|
engine_family="gainers_leader",
|
|
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_abs_gap_pct=0.0,
|
|
max_candidates=10,
|
|
min_candidates_to_trade=1,
|
|
min_candidate_breadth=0.6,
|
|
breadth_skip_below=0.6,
|
|
hard_breadth_soft_fallback_enabled=True,
|
|
hard_breadth_soft_fallback_min_breadth=0.4,
|
|
hard_breadth_soft_fallback_size_scale=0.05,
|
|
soft_day_setup_profile="none",
|
|
atr_stop_multiplier=10.0,
|
|
trailing_at_r=99.0,
|
|
breakeven_at_r=99.0,
|
|
risk_per_trade_pct=0.01,
|
|
max_position_pct=1.0,
|
|
slippage_bps=0.0,
|
|
)
|
|
|
|
day_result = simulate_orb_day(
|
|
bars,
|
|
"2026-01-05",
|
|
params,
|
|
enrichment,
|
|
equity=10_000.0,
|
|
)
|
|
|
|
assert day_result.skip_reason != "breadth"
|
|
assert day_result.is_soft_day
|
|
assert day_result.soft_day_reason == "hard_breadth"
|
|
assert day_result.breadth_scaler == 0.05
|
|
|
|
|
|
def test_synthetic_today_daily_breadth_uses_intraday_open() -> None:
|
|
bars = {
|
|
ticker: [
|
|
{"timestamp": "2026-01-05T09:30:00-05:00", "open": 101.0, "high": 102.0, "low": 100.5, "close": 101.8, "volume": 1000},
|
|
{"timestamp": "2026-01-05T09:35:00-05:00", "open": 101.8, "high": 103.0, "low": 101.8, "close": 102.8, "volume": 1000},
|
|
{"timestamp": "2026-01-05T15:55:00-05:00", "open": 102.8, "high": 103.2, "low": 102.6, "close": 103.0, "volume": 1000},
|
|
]
|
|
for ticker in ("AAA", "BBB", "CCC")
|
|
}
|
|
enrichment = _enrichment_for("AAA", "BBB", "CCC")
|
|
for ticker in enrichment:
|
|
enrichment[ticker]["2026-01-05"]["prev_close"] = 100.0
|
|
enrichment[ticker]["2026-01-05"]["today_open"] = 100.0
|
|
enrichment[ticker]["2026-01-05"]["synthetic_today_daily"] = True
|
|
params = ORBStrategyParams(
|
|
engine_family="gainers_leader",
|
|
min_price=10.0,
|
|
min_avg_dollar_volume=0.0,
|
|
min_atr_14=0.1,
|
|
min_rvol=0.1,
|
|
min_abs_gap_pct=0.0,
|
|
max_candidates=10,
|
|
min_candidates_to_trade=1,
|
|
min_candidate_breadth=0.6,
|
|
breadth_skip_below=0.25,
|
|
hard_breadth_soft_fallback_enabled=True,
|
|
hard_breadth_soft_fallback_min_breadth=0.0,
|
|
hard_breadth_soft_fallback_size_scale=0.05,
|
|
atr_stop_multiplier=10.0,
|
|
trailing_at_r=99.0,
|
|
breakeven_at_r=99.0,
|
|
risk_per_trade_pct=0.01,
|
|
max_position_pct=1.0,
|
|
slippage_bps=0.0,
|
|
)
|
|
|
|
day_result = simulate_orb_day(
|
|
bars,
|
|
"2026-01-05",
|
|
params,
|
|
enrichment,
|
|
equity=10_000.0,
|
|
)
|
|
|
|
assert day_result.skip_reason is None
|
|
assert day_result.soft_day_reason is None
|
|
assert day_result.breadth_scaler == 1.0
|
|
assert day_result.breadth_ratio == 1.0
|
|
assert day_result.breadth_positive_count == 3
|
|
assert day_result.breadth_total_count == 3
|
|
|
|
|
|
def test_market_thrust_override_clears_breadth_only_soft_day() -> None:
|
|
bars = {
|
|
"SPY": [
|
|
{"timestamp": "2026-01-05T09:30:00-05:00", "open": 100.0, "high": 102.0, "low": 99.8, "close": 101.9, "volume": 1000},
|
|
{"timestamp": "2026-01-05T09:35:00-05:00", "open": 101.9, "high": 102.5, "low": 101.7, "close": 102.2, "volume": 1000},
|
|
],
|
|
"QQQ": [
|
|
{"timestamp": "2026-01-05T09:30:00-05:00", "open": 101.0, "high": 103.0, "low": 100.8, "close": 102.8, "volume": 1000},
|
|
{"timestamp": "2026-01-05T09:35:00-05:00", "open": 102.8, "high": 103.4, "low": 102.7, "close": 103.2, "volume": 1000},
|
|
],
|
|
"AAA": [
|
|
{"timestamp": "2026-01-05T09:30:00-05:00", "open": 99.0, "high": 99.2, "low": 98.0, "close": 98.2, "volume": 1000},
|
|
{"timestamp": "2026-01-05T09:35:00-05:00", "open": 98.2, "high": 98.4, "low": 97.8, "close": 98.0, "volume": 1000},
|
|
],
|
|
"BBB": [
|
|
{"timestamp": "2026-01-05T09:30:00-05:00", "open": 99.0, "high": 99.1, "low": 98.1, "close": 98.3, "volume": 1000},
|
|
{"timestamp": "2026-01-05T09:35:00-05:00", "open": 98.3, "high": 98.5, "low": 98.0, "close": 98.1, "volume": 1000},
|
|
],
|
|
"CCC": [
|
|
{"timestamp": "2026-01-05T09:30:00-05:00", "open": 99.0, "high": 99.1, "low": 98.0, "close": 98.4, "volume": 1000},
|
|
{"timestamp": "2026-01-05T09:35:00-05:00", "open": 98.4, "high": 98.6, "low": 98.0, "close": 98.2, "volume": 1000},
|
|
],
|
|
}
|
|
enrichment = _enrichment_for("SPY", "QQQ", "AAA", "BBB", "CCC")
|
|
for ticker in ("SPY", "QQQ"):
|
|
enrichment[ticker]["2026-01-05"]["prev_close"] = 100.0
|
|
enrichment[ticker]["2026-01-05"]["today_open"] = 101.0
|
|
for ticker in ("AAA", "BBB", "CCC"):
|
|
enrichment[ticker]["2026-01-05"]["prev_close"] = 100.0
|
|
enrichment[ticker]["2026-01-05"]["today_open"] = 99.0
|
|
|
|
params = ORBStrategyParams(
|
|
engine_family="gainers_leader",
|
|
market_regime_ticker="QQQ",
|
|
market_regime_spy_threshold=0.001,
|
|
market_orb_quality_ticker="SPY",
|
|
market_orb_quality_secondary_ticker="QQQ",
|
|
min_candidate_breadth=0.6,
|
|
breadth_skip_below=0.25,
|
|
soft_day_fallback_on_breadth_skip=True,
|
|
soft_day_breadth_skip_size_scale=0.1,
|
|
market_thrust_breadth_override_enabled=True,
|
|
market_thrust_breadth_override_min_primary_close_location=0.85,
|
|
market_thrust_breadth_override_min_secondary_close_location=0.85,
|
|
market_thrust_breadth_override_min_primary_return_pct=0.001,
|
|
market_thrust_breadth_override_min_secondary_return_pct=0.001,
|
|
market_thrust_breadth_override_min_regime_gap_pct=0.001,
|
|
market_thrust_breadth_override_min_breadth_ratio=0.35,
|
|
market_thrust_breadth_override_size_scale_floor=1.0,
|
|
market_thrust_breadth_override_clear_soft_day=True,
|
|
)
|
|
|
|
day_result = simulate_orb_day(
|
|
bars,
|
|
"2026-01-05",
|
|
params,
|
|
enrichment,
|
|
equity=10_000.0,
|
|
)
|
|
|
|
assert day_result.breadth_ratio == pytest.approx(0.4)
|
|
assert day_result.market_thrust_breadth_override_active
|
|
assert day_result.breadth_scaler == 1.0
|
|
assert day_result.is_soft_day is False
|
|
assert day_result.soft_day_reason is None
|
|
|
|
|
|
def test_max_trades_per_day_caps_later_orb_fills() -> None:
|
|
bars = {
|
|
ticker: [
|
|
{"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": 101.0, "high": 102.0, "low": 100.8, "close": 101.8, "volume": 1000},
|
|
{"timestamp": "2026-01-05T09:40:00-05:00", "open": 101.8, "high": 102.4, "low": 101.5, "close": 102.2, "volume": 1000},
|
|
{"timestamp": "2026-01-05T09:45:00-05:00", "open": 102.2, "high": 102.5, "low": 102.0, "close": 102.3, "volume": 1000},
|
|
{"timestamp": "2026-01-05T09:50:00-05:00", "open": 102.3, "high": 102.6, "low": 102.1, "close": 102.4, "volume": 1000},
|
|
{"timestamp": "2026-01-05T15:55:00-05:00", "open": 102.2, "high": 102.5, "low": 102.0, "close": 102.4, "volume": 1000},
|
|
]
|
|
for ticker in ("AAA", "BBB", "CCC")
|
|
}
|
|
enrichment = _enrichment_for("AAA", "BBB", "CCC")
|
|
params = ORBStrategyParams(
|
|
engine_family="gainers_leader",
|
|
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_abs_gap_pct=0.0,
|
|
max_candidates=10,
|
|
min_candidates_to_trade=1,
|
|
atr_stop_multiplier=10.0,
|
|
trailing_at_r=99.0,
|
|
breakeven_at_r=99.0,
|
|
risk_per_trade_pct=0.01,
|
|
max_position_pct=1.0,
|
|
slippage_bps=0.0,
|
|
max_trades_per_day=2,
|
|
)
|
|
|
|
day_result = simulate_orb_day(
|
|
bars,
|
|
"2026-01-05",
|
|
params,
|
|
enrichment,
|
|
equity=10_000.0,
|
|
)
|
|
|
|
assert len(day_result.trades) == 2
|
|
|
|
|
|
def test_late_trade_size_scale_reduces_later_fills_without_skipping() -> None:
|
|
bars = {
|
|
ticker: [
|
|
{"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": 101.0, "high": 102.0, "low": 100.8, "close": 101.8, "volume": 1000},
|
|
{"timestamp": "2026-01-05T09:40:00-05:00", "open": 101.8, "high": 102.4, "low": 101.5, "close": 102.2, "volume": 1000},
|
|
{"timestamp": "2026-01-05T09:45:00-05:00", "open": 102.2, "high": 102.5, "low": 102.0, "close": 102.3, "volume": 1000},
|
|
{"timestamp": "2026-01-05T09:50:00-05:00", "open": 102.3, "high": 102.6, "low": 102.1, "close": 102.4, "volume": 1000},
|
|
{"timestamp": "2026-01-05T15:55:00-05:00", "open": 102.2, "high": 102.5, "low": 102.0, "close": 102.4, "volume": 1000},
|
|
]
|
|
for ticker in ("AAA", "BBB", "CCC")
|
|
}
|
|
enrichment = _enrichment_for("AAA", "BBB", "CCC")
|
|
params = ORBStrategyParams(
|
|
engine_family="gainers_leader",
|
|
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_abs_gap_pct=0.0,
|
|
max_candidates=10,
|
|
min_candidates_to_trade=1,
|
|
atr_stop_multiplier=10.0,
|
|
trailing_at_r=99.0,
|
|
breakeven_at_r=99.0,
|
|
risk_per_trade_pct=0.01,
|
|
max_position_pct=1.0,
|
|
slippage_bps=0.0,
|
|
late_trade_size_scale_after_n=2,
|
|
late_trade_size_scale=0.5,
|
|
)
|
|
|
|
day_result = simulate_orb_day(
|
|
bars,
|
|
"2026-01-05",
|
|
params,
|
|
enrichment,
|
|
equity=10_000.0,
|
|
)
|
|
|
|
assert len(day_result.trades) == 3
|
|
assert day_result.trades[2].shares < day_result.trades[1].shares
|
|
assert day_result.trades[2].late_trade_size_scale == 0.5
|
|
|
|
|
|
def test_rank_rvol_pressure_size_scale_reduces_mid_rank_high_rvol_fills() -> None:
|
|
bars = {
|
|
ticker: [
|
|
{"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": 101.0, "high": 102.0, "low": 100.8, "close": 101.8, "volume": 1000},
|
|
{"timestamp": "2026-01-05T09:40:00-05:00", "open": 101.8, "high": 102.4, "low": 101.5, "close": 102.2, "volume": 1000},
|
|
{"timestamp": "2026-01-05T09:45:00-05:00", "open": 102.2, "high": 102.5, "low": 102.0, "close": 102.3, "volume": 1000},
|
|
{"timestamp": "2026-01-05T09:50:00-05:00", "open": 102.3, "high": 102.6, "low": 102.1, "close": 102.4, "volume": 1000},
|
|
{"timestamp": "2026-01-05T15:55:00-05:00", "open": 102.2, "high": 102.5, "low": 102.0, "close": 102.4, "volume": 1000},
|
|
]
|
|
for ticker in ("AAA", "BBB", "CCC")
|
|
}
|
|
enrichment = _enrichment_for("AAA", "BBB", "CCC")
|
|
for ticker in enrichment:
|
|
enrichment[ticker]["2026-01-05"]["ret_5d"] = -0.01
|
|
params = ORBStrategyParams(
|
|
engine_family="gainers_leader",
|
|
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_abs_gap_pct=0.0,
|
|
max_candidates=10,
|
|
min_candidates_to_trade=1,
|
|
atr_stop_multiplier=10.0,
|
|
trailing_at_r=99.0,
|
|
breakeven_at_r=99.0,
|
|
risk_per_trade_pct=0.01,
|
|
max_position_pct=1.0,
|
|
slippage_bps=0.0,
|
|
rank_rvol_pressure_min_rvol=0.1,
|
|
rank_rvol_pressure_max_score_rank_pct=0.5,
|
|
rank_rvol_pressure_max_ret_5d=0.0,
|
|
rank_rvol_pressure_size_scale=0.4,
|
|
)
|
|
|
|
day_result = simulate_orb_day(
|
|
bars,
|
|
"2026-01-05",
|
|
params,
|
|
enrichment,
|
|
equity=10_000.0,
|
|
)
|
|
|
|
assert len(day_result.trades) == 3
|
|
assert day_result.trades[0].rank_rvol_pressure_size_scale is None
|
|
assert day_result.trades[1].rank_rvol_pressure_size_scale == 0.4
|
|
assert day_result.trades[2].rank_rvol_pressure_size_scale == 0.4
|
|
assert day_result.trades[1].shares < day_result.trades[0].shares
|
|
|
|
|
|
def test_mid_liquidity_fragility_scales_only_fragile_mid_premarket_profiles() -> None:
|
|
def bars_for(premarket_dollar_vol: float) -> list[dict]:
|
|
premarket_volume = int(premarket_dollar_vol / 100.0)
|
|
return [
|
|
{"timestamp": "2026-01-05T08:00:00-05:00", "open": 100.0, "high": 100.0, "low": 100.0, "close": 100.0, "volume": premarket_volume},
|
|
{"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": 101.0, "high": 102.0, "low": 100.8, "close": 101.8, "volume": 1000},
|
|
{"timestamp": "2026-01-05T09:40:00-05:00", "open": 101.8, "high": 102.4, "low": 101.5, "close": 102.2, "volume": 1000},
|
|
{"timestamp": "2026-01-05T09:45:00-05:00", "open": 102.2, "high": 102.5, "low": 102.0, "close": 102.3, "volume": 1000},
|
|
{"timestamp": "2026-01-05T15:55:00-05:00", "open": 102.2, "high": 102.5, "low": 102.0, "close": 102.4, "volume": 1000},
|
|
]
|
|
|
|
bars = {
|
|
"FRAGILE": bars_for(30_000_000),
|
|
"LEADER": bars_for(90_000_000),
|
|
}
|
|
enrichment = _enrichment_for("FRAGILE", "LEADER")
|
|
params = ORBStrategyParams(
|
|
engine_family="gainers_leader",
|
|
allow_red_to_green_breakout=True,
|
|
min_price=10.0,
|
|
min_avg_dollar_volume=0.0,
|
|
min_atr_14=0.1,
|
|
min_rvol=0.0,
|
|
min_abs_gap_pct=0.0,
|
|
max_candidates=10,
|
|
max_trades_per_day=2,
|
|
min_candidates_to_trade=1,
|
|
atr_stop_multiplier=10.0,
|
|
trailing_at_r=99.0,
|
|
breakeven_at_r=99.0,
|
|
risk_per_trade_pct=0.01,
|
|
max_position_pct=1.0,
|
|
slippage_bps=0.0,
|
|
mid_liquidity_fragility_size_scale=0.25,
|
|
mid_liquidity_fragility_mid_min_premarket_dollar_vol=20_000_000,
|
|
mid_liquidity_fragility_mid_max_premarket_dollar_vol=75_000_000,
|
|
mid_liquidity_fragility_mid_min_body_ratio=0.5,
|
|
mid_liquidity_fragility_mid_max_body_ratio=0.75,
|
|
)
|
|
|
|
day_result = simulate_orb_day(
|
|
bars,
|
|
"2026-01-05",
|
|
params,
|
|
enrichment,
|
|
equity=10_000.0,
|
|
)
|
|
|
|
trades = {trade.ticker: trade for trade in day_result.trades}
|
|
assert trades["FRAGILE"].mid_liquidity_fragility_active is True
|
|
assert trades["FRAGILE"].mid_liquidity_fragility_size_scale == 0.25
|
|
assert trades["LEADER"].mid_liquidity_fragility_active is False
|
|
assert trades["LEADER"].mid_liquidity_fragility_size_scale is None
|
|
assert trades["FRAGILE"].shares < trades["LEADER"].shares
|
|
|
|
|
|
def test_gap_exhaustion_pressure_scales_stretched_weak_orb_close() -> None:
|
|
bars = {
|
|
"STRONG": [
|
|
{"timestamp": "2026-01-05T09:30:00-05:00", "open": 100.0, "high": 101.0, "low": 99.5, "close": 100.9, "volume": 1000},
|
|
{"timestamp": "2026-01-05T09:35:00-05:00", "open": 100.9, "high": 102.0, "low": 100.8, "close": 101.8, "volume": 1000},
|
|
{"timestamp": "2026-01-05T09:40:00-05:00", "open": 101.8, "high": 102.4, "low": 101.5, "close": 102.2, "volume": 1000},
|
|
{"timestamp": "2026-01-05T09:45:00-05:00", "open": 102.2, "high": 102.5, "low": 102.0, "close": 102.3, "volume": 1000},
|
|
{"timestamp": "2026-01-05T09:50:00-05:00", "open": 102.3, "high": 102.6, "low": 102.1, "close": 102.4, "volume": 1000},
|
|
{"timestamp": "2026-01-05T15:55:00-05:00", "open": 102.2, "high": 102.5, "low": 102.0, "close": 102.4, "volume": 1000},
|
|
],
|
|
"WEAK": [
|
|
{"timestamp": "2026-01-05T09:30:00-05:00", "open": 100.0, "high": 101.0, "low": 99.5, "close": 100.2, "volume": 1000},
|
|
{"timestamp": "2026-01-05T09:35:00-05:00", "open": 100.2, "high": 102.0, "low": 100.1, "close": 101.8, "volume": 1000},
|
|
{"timestamp": "2026-01-05T09:40:00-05:00", "open": 101.8, "high": 102.4, "low": 101.5, "close": 102.2, "volume": 1000},
|
|
{"timestamp": "2026-01-05T09:45:00-05:00", "open": 102.2, "high": 102.5, "low": 102.0, "close": 102.3, "volume": 1000},
|
|
{"timestamp": "2026-01-05T09:50:00-05:00", "open": 102.3, "high": 102.6, "low": 102.1, "close": 102.4, "volume": 1000},
|
|
{"timestamp": "2026-01-05T15:55:00-05:00", "open": 102.2, "high": 102.5, "low": 102.0, "close": 102.4, "volume": 1000},
|
|
],
|
|
}
|
|
enrichment = _enrichment_for("STRONG", "WEAK")
|
|
for ticker in enrichment:
|
|
enrichment[ticker]["2026-01-05"]["prev_close"] = 98.0
|
|
enrichment[ticker]["2026-01-05"]["ret_5d"] = 0.05
|
|
enrichment[ticker]["2026-01-05"]["gap_zscore_20d"] = 1.25
|
|
params = ORBStrategyParams(
|
|
engine_family="gainers_leader",
|
|
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_abs_gap_pct=0.0,
|
|
max_candidates=10,
|
|
min_candidates_to_trade=1,
|
|
atr_stop_multiplier=10.0,
|
|
trailing_at_r=99.0,
|
|
breakeven_at_r=99.0,
|
|
risk_per_trade_pct=0.01,
|
|
max_position_pct=1.0,
|
|
slippage_bps=0.0,
|
|
gap_exhaustion_pressure_min_gap_zscore=1.0,
|
|
gap_exhaustion_pressure_max_close_location=0.65,
|
|
gap_exhaustion_pressure_min_ret_5d=0.0,
|
|
gap_exhaustion_pressure_min_gap_pct=0.02,
|
|
gap_exhaustion_pressure_size_scale=0.25,
|
|
)
|
|
|
|
day_result = simulate_orb_day(
|
|
bars,
|
|
"2026-01-05",
|
|
params,
|
|
enrichment,
|
|
equity=10_000.0,
|
|
)
|
|
|
|
trades = {trade.ticker: trade for trade in day_result.trades}
|
|
assert trades["STRONG"].gap_exhaustion_pressure_size_scale is None
|
|
assert trades["WEAK"].gap_exhaustion_pressure_size_scale == 0.25
|
|
assert trades["WEAK"].shares < trades["STRONG"].shares
|
|
|
|
|
|
def test_stale_obv_rvol_pressure_scales_low_rvol_stale_obv_fill() -> None:
|
|
bars = {
|
|
ticker: [
|
|
{"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": 101.0, "high": 102.0, "low": 100.8, "close": 101.8, "volume": 1000},
|
|
{"timestamp": "2026-01-05T09:40:00-05:00", "open": 101.8, "high": 102.4, "low": 101.5, "close": 102.2, "volume": 1000},
|
|
{"timestamp": "2026-01-05T09:45:00-05:00", "open": 102.2, "high": 102.5, "low": 102.0, "close": 102.3, "volume": 1000},
|
|
{"timestamp": "2026-01-05T09:50:00-05:00", "open": 102.3, "high": 102.6, "low": 102.1, "close": 102.4, "volume": 1000},
|
|
{"timestamp": "2026-01-05T15:55:00-05:00", "open": 102.2, "high": 102.5, "low": 102.0, "close": 102.4, "volume": 1000},
|
|
]
|
|
for ticker in ("FRESH", "STALE")
|
|
}
|
|
enrichment = _enrichment_for("FRESH", "STALE")
|
|
enrichment["FRESH"]["2026-01-05"]["obv_slope_20"] = 0.2
|
|
enrichment["STALE"]["2026-01-05"]["obv_slope_20"] = -0.3
|
|
for ticker in enrichment:
|
|
enrichment[ticker]["2026-01-05"]["ret_5d"] = -0.01
|
|
params = ORBStrategyParams(
|
|
engine_family="gainers_leader",
|
|
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_abs_gap_pct=0.0,
|
|
max_candidates=10,
|
|
min_candidates_to_trade=1,
|
|
atr_stop_multiplier=10.0,
|
|
trailing_at_r=99.0,
|
|
breakeven_at_r=99.0,
|
|
risk_per_trade_pct=0.01,
|
|
max_position_pct=1.0,
|
|
slippage_bps=0.0,
|
|
stale_obv_rvol_pressure_max_rvol=8.0,
|
|
stale_obv_rvol_pressure_max_obv_slope_20d=-0.2,
|
|
stale_obv_rvol_pressure_max_ret_5d=0.05,
|
|
stale_obv_rvol_pressure_max_score_rank_pct=1.0,
|
|
stale_obv_rvol_pressure_size_scale=0.4,
|
|
)
|
|
|
|
day_result = simulate_orb_day(
|
|
bars,
|
|
"2026-01-05",
|
|
params,
|
|
enrichment,
|
|
equity=10_000.0,
|
|
)
|
|
|
|
trades = {trade.ticker: trade for trade in day_result.trades}
|
|
assert trades["FRESH"].stale_obv_rvol_pressure_size_scale is None
|
|
assert trades["STALE"].stale_obv_rvol_pressure_size_scale == 0.4
|
|
assert trades["STALE"].shares < trades["FRESH"].shares
|
|
|
|
|
|
def test_red_to_green_acceleration_boosts_only_clean_downside_reclaims() -> None:
|
|
bars = {
|
|
ticker: [
|
|
{"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": 101.0, "high": 102.0, "low": 100.8, "close": 101.8, "volume": 1000},
|
|
{"timestamp": "2026-01-05T09:40:00-05:00", "open": 101.8, "high": 102.4, "low": 101.5, "close": 102.2, "volume": 1000},
|
|
{"timestamp": "2026-01-05T09:45:00-05:00", "open": 102.2, "high": 102.5, "low": 102.0, "close": 102.3, "volume": 1000},
|
|
{"timestamp": "2026-01-05T09:50:00-05:00", "open": 102.3, "high": 102.6, "low": 102.1, "close": 102.4, "volume": 1000},
|
|
{"timestamp": "2026-01-05T15:55:00-05:00", "open": 102.2, "high": 102.5, "low": 102.0, "close": 102.4, "volume": 1000},
|
|
]
|
|
for ticker in ("BASE", "BOOST", "RISK")
|
|
}
|
|
enrichment = _enrichment_for("BASE", "BOOST", "RISK")
|
|
enrichment["BASE"]["2026-01-05"]["prev_close"] = 98.0
|
|
enrichment["BOOST"]["2026-01-05"]["prev_close"] = 106.0
|
|
enrichment["BOOST"]["2026-01-05"]["ret_5d"] = 0.0
|
|
enrichment["RISK"]["2026-01-05"]["prev_close"] = 106.0
|
|
enrichment["RISK"]["2026-01-05"]["ret_5d"] = 0.5
|
|
params = ORBStrategyParams(
|
|
engine_family="gainers_leader",
|
|
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_abs_gap_pct=0.0,
|
|
max_candidates=10,
|
|
min_candidates_to_trade=1,
|
|
atr_stop_multiplier=10.0,
|
|
trailing_at_r=99.0,
|
|
breakeven_at_r=99.0,
|
|
risk_per_trade_pct=0.01,
|
|
max_position_pct=1.0,
|
|
slippage_bps=0.0,
|
|
hot_reclaim_min_abs_gap_pct=0.04,
|
|
hot_reclaim_min_ret_5d=0.30,
|
|
hot_reclaim_max_premarket_dollar_vol=10_000_000_000,
|
|
hot_reclaim_min_body_ratio=0.50,
|
|
hot_reclaim_min_close_location=0.80,
|
|
hot_reclaim_action="scale",
|
|
hot_reclaim_size_scale=0.35,
|
|
red_to_green_acceleration_min_abs_gap_pct=0.04,
|
|
red_to_green_acceleration_min_orb_return=0.005,
|
|
red_to_green_acceleration_size_scale=1.5,
|
|
)
|
|
|
|
day_result = simulate_orb_day(
|
|
bars,
|
|
"2026-01-05",
|
|
params,
|
|
enrichment,
|
|
equity=10_000.0,
|
|
)
|
|
|
|
trades = {trade.ticker: trade for trade in day_result.trades}
|
|
assert trades["BASE"].red_to_green_acceleration_size_scale is None
|
|
assert trades["BOOST"].red_to_green_acceleration_size_scale == 1.5
|
|
assert trades["RISK"].hot_reclaim_size_scale == 0.35
|
|
assert trades["RISK"].red_to_green_acceleration_size_scale is None
|
|
assert trades["BOOST"].shares > trades["BASE"].shares
|
|
assert trades["RISK"].shares < trades["BASE"].shares
|
|
|
|
|
|
def test_liquid_leader_conviction_boosts_only_unscaled_liquid_leaders() -> None:
|
|
bars = {}
|
|
for ticker, premarket_volume in (
|
|
("THIN", 1_000),
|
|
("LIQUID", 1_000_000),
|
|
("RISK", 1_000_000),
|
|
):
|
|
bars[ticker] = [
|
|
{"timestamp": "2026-01-05T08:00:00-05:00", "open": 100.0, "high": 100.1, "low": 99.9, "close": 100.0, "volume": premarket_volume},
|
|
{"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": 101.0, "high": 102.0, "low": 100.8, "close": 101.8, "volume": 1000},
|
|
{"timestamp": "2026-01-05T09:40:00-05:00", "open": 101.8, "high": 102.4, "low": 101.5, "close": 102.2, "volume": 1000},
|
|
{"timestamp": "2026-01-05T09:45:00-05:00", "open": 102.2, "high": 102.5, "low": 102.0, "close": 102.3, "volume": 1000},
|
|
{"timestamp": "2026-01-05T09:50:00-05:00", "open": 102.3, "high": 102.6, "low": 102.1, "close": 102.4, "volume": 1000},
|
|
{"timestamp": "2026-01-05T15:55:00-05:00", "open": 102.2, "high": 102.5, "low": 102.0, "close": 102.4, "volume": 1000},
|
|
]
|
|
enrichment = _enrichment_for("THIN", "LIQUID", "RISK")
|
|
enrichment["THIN"]["2026-01-05"]["prev_close"] = 98.0
|
|
enrichment["LIQUID"]["2026-01-05"]["prev_close"] = 98.0
|
|
enrichment["RISK"]["2026-01-05"]["prev_close"] = 106.0
|
|
enrichment["RISK"]["2026-01-05"]["ret_5d"] = 0.5
|
|
|
|
params = ORBStrategyParams(
|
|
engine_family="gainers_leader",
|
|
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_abs_gap_pct=0.0,
|
|
min_premarket_dollar_vol=0.0,
|
|
max_candidates=10,
|
|
min_candidates_to_trade=1,
|
|
atr_stop_multiplier=10.0,
|
|
trailing_at_r=99.0,
|
|
breakeven_at_r=99.0,
|
|
risk_per_trade_pct=0.01,
|
|
max_position_pct=1.0,
|
|
slippage_bps=0.0,
|
|
hot_reclaim_min_abs_gap_pct=0.04,
|
|
hot_reclaim_min_ret_5d=0.30,
|
|
hot_reclaim_max_premarket_dollar_vol=200_000_000,
|
|
hot_reclaim_min_body_ratio=0.50,
|
|
hot_reclaim_min_close_location=0.80,
|
|
hot_reclaim_action="scale",
|
|
hot_reclaim_size_scale=0.35,
|
|
liquid_leader_conviction_min_premarket_dollar_vol=50_000_000,
|
|
liquid_leader_conviction_size_scale=1.5,
|
|
)
|
|
|
|
day_result = simulate_orb_day(
|
|
bars,
|
|
"2026-01-05",
|
|
params,
|
|
enrichment,
|
|
equity=10_000.0,
|
|
)
|
|
|
|
trades = {trade.ticker: trade for trade in day_result.trades}
|
|
assert trades["THIN"].liquid_leader_conviction_size_scale is None
|
|
assert trades["LIQUID"].liquid_leader_conviction_size_scale == 1.5
|
|
assert trades["RISK"].hot_reclaim_size_scale == 0.35
|
|
assert trades["RISK"].liquid_leader_conviction_size_scale is None
|
|
assert trades["LIQUID"].shares > trades["THIN"].shares
|
|
assert trades["RISK"].shares < trades["THIN"].shares
|
|
|
|
trigger_blocked = simulate_orb_day(
|
|
bars,
|
|
"2026-01-05",
|
|
params.model_copy(
|
|
update={"liquid_leader_conviction_allowed_trigger_types": ["vwap_reclaim"]}
|
|
),
|
|
enrichment,
|
|
equity=10_000.0,
|
|
)
|
|
|
|
trigger_blocked_trades = {trade.ticker: trade for trade in trigger_blocked.trades}
|
|
assert trigger_blocked_trades["LIQUID"].liquid_leader_conviction_size_scale is None
|
|
|
|
|
|
def test_liquid_leader_secondary_boost_can_require_sector_confirmation() -> None:
|
|
params = ORBStrategyParams(
|
|
liquid_leader_conviction_size_scale=2.0,
|
|
liquid_leader_conviction_secondary_requires_sector_confirmation=True,
|
|
liquid_leader_conviction_secondary_min_body_ratio=0.20,
|
|
)
|
|
cand = {"sector_confirmation_active": False, "body_ratio": 0.50}
|
|
confirmed = {"sector_confirmation_active": True, "body_ratio": 0.50}
|
|
weak_body = {"sector_confirmation_active": True, "body_ratio": 0.10}
|
|
|
|
assert (
|
|
_orb_liquid_leader_conviction_size_scale(
|
|
params,
|
|
cand,
|
|
score_rank_pct=1.0,
|
|
trigger_type="soft_day_vwap_reclaim",
|
|
direction_str="long",
|
|
risk_size_scales=(1.0,),
|
|
)
|
|
== 1.0
|
|
)
|
|
assert (
|
|
_orb_liquid_leader_conviction_size_scale(
|
|
params,
|
|
confirmed,
|
|
score_rank_pct=1.0,
|
|
trigger_type="soft_day_vwap_reclaim",
|
|
direction_str="long",
|
|
risk_size_scales=(1.0,),
|
|
)
|
|
== 2.0
|
|
)
|
|
assert (
|
|
_orb_liquid_leader_conviction_size_scale(
|
|
params,
|
|
weak_body,
|
|
score_rank_pct=1.0,
|
|
trigger_type="soft_day_vwap_reclaim",
|
|
direction_str="long",
|
|
risk_size_scales=(1.0,),
|
|
)
|
|
== 1.0
|
|
)
|
|
assert (
|
|
_orb_liquid_leader_conviction_size_scale(
|
|
params,
|
|
cand,
|
|
score_rank_pct=1.0,
|
|
trigger_type="orb",
|
|
direction_str="long",
|
|
risk_size_scales=(1.0,),
|
|
)
|
|
== 2.0
|
|
)
|
|
|
|
|
|
def test_unsupported_attention_governor_scales_unconfirmed_liquid_laggards() -> None:
|
|
params = ORBStrategyParams(
|
|
unsupported_attention_size_scale=0.35,
|
|
unsupported_attention_allowed_trigger_types=["orb"],
|
|
unsupported_attention_liquid_min_premarket_dollar_vol=50_000_000,
|
|
unsupported_attention_liquid_max_candidate_score=0.65,
|
|
)
|
|
weak = {
|
|
"premarket_dollar_vol": 75_000_000.0,
|
|
"score": 0.60,
|
|
"sector_confirmation_active": False,
|
|
}
|
|
confirmed = {**weak, "sector_confirmation_active": True}
|
|
strong_score = {**weak, "score": 0.80}
|
|
|
|
assert (
|
|
_orb_unsupported_attention_size_scale(
|
|
params,
|
|
weak,
|
|
trigger_type="orb",
|
|
direction_str="long",
|
|
high_conviction_size_scales=(1.0,),
|
|
)
|
|
== 0.35
|
|
)
|
|
assert (
|
|
_orb_unsupported_attention_size_scale(
|
|
params,
|
|
confirmed,
|
|
trigger_type="orb",
|
|
direction_str="long",
|
|
high_conviction_size_scales=(1.0,),
|
|
)
|
|
== 1.0
|
|
)
|
|
assert (
|
|
_orb_unsupported_attention_size_scale(
|
|
params,
|
|
strong_score,
|
|
trigger_type="orb",
|
|
direction_str="long",
|
|
high_conviction_size_scales=(1.0,),
|
|
)
|
|
== 1.0
|
|
)
|
|
|
|
|
|
def test_unsupported_attention_governor_scales_thin_positive_gap_spikes() -> None:
|
|
params = ORBStrategyParams(
|
|
unsupported_attention_size_scale=0.25,
|
|
unsupported_attention_thin_max_premarket_dollar_vol=10_000_000,
|
|
unsupported_attention_thin_min_gap_pct=0.025,
|
|
unsupported_attention_thin_min_rvol=20.0,
|
|
unsupported_attention_thin_max_rvol=25.0,
|
|
)
|
|
thin_spike = {
|
|
"premarket_dollar_vol": 8_000_000.0,
|
|
"gap_pct": 0.03,
|
|
"rvol": 22.0,
|
|
}
|
|
downside_reclaim = {**thin_spike, "gap_pct": -0.04}
|
|
high_conviction = {**thin_spike}
|
|
|
|
assert (
|
|
_orb_unsupported_attention_size_scale(
|
|
params,
|
|
thin_spike,
|
|
trigger_type="orb",
|
|
direction_str="long",
|
|
high_conviction_size_scales=(1.0,),
|
|
)
|
|
== 0.25
|
|
)
|
|
assert (
|
|
_orb_unsupported_attention_size_scale(
|
|
params,
|
|
downside_reclaim,
|
|
trigger_type="orb",
|
|
direction_str="long",
|
|
high_conviction_size_scales=(1.0,),
|
|
)
|
|
== 1.0
|
|
)
|
|
assert (
|
|
_orb_unsupported_attention_size_scale(
|
|
params,
|
|
high_conviction,
|
|
trigger_type="orb",
|
|
direction_str="long",
|
|
high_conviction_size_scales=(1.5,),
|
|
)
|
|
== 1.0
|
|
)
|
|
|
|
|
|
def test_thin_gap_up_loss_cap_marks_only_low_premarket_gap_ups() -> None:
|
|
bars = {}
|
|
for ticker, premarket_volume in (
|
|
("THIN", 1_000),
|
|
("LIQUID", 2_000_000),
|
|
):
|
|
bars[ticker] = [
|
|
{"timestamp": "2026-01-05T08:00:00-05:00", "open": 100.0, "high": 100.1, "low": 99.9, "close": 100.0, "volume": premarket_volume},
|
|
{"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": 101.0, "high": 102.0, "low": 100.8, "close": 101.8, "volume": 1000},
|
|
{"timestamp": "2026-01-05T09:40:00-05:00", "open": 101.8, "high": 102.4, "low": 101.5, "close": 102.2, "volume": 1000},
|
|
{"timestamp": "2026-01-05T09:45:00-05:00", "open": 102.2, "high": 102.5, "low": 102.0, "close": 102.3, "volume": 1000},
|
|
{"timestamp": "2026-01-05T09:50:00-05:00", "open": 102.3, "high": 102.6, "low": 102.1, "close": 102.4, "volume": 1000},
|
|
{"timestamp": "2026-01-05T15:55:00-05:00", "open": 102.2, "high": 102.5, "low": 102.0, "close": 102.4, "volume": 1000},
|
|
]
|
|
enrichment = _enrichment_for("THIN", "LIQUID")
|
|
for ticker in ("THIN", "LIQUID"):
|
|
enrichment[ticker]["2026-01-05"]["prev_close"] = 98.0
|
|
enrichment[ticker]["2026-01-05"]["ret_5d"] = 0.03
|
|
|
|
params = ORBStrategyParams(
|
|
engine_family="gainers_leader",
|
|
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_abs_gap_pct=0.0,
|
|
min_premarket_dollar_vol=0.0,
|
|
max_candidates=10,
|
|
min_candidates_to_trade=1,
|
|
atr_stop_multiplier=10.0,
|
|
trailing_at_r=99.0,
|
|
breakeven_at_r=99.0,
|
|
risk_per_trade_pct=0.01,
|
|
max_position_pct=1.0,
|
|
slippage_bps=0.0,
|
|
thin_gap_up_loss_cap_min_gap_pct=0.02,
|
|
thin_gap_up_loss_cap_min_ret_5d=-0.10,
|
|
thin_gap_up_loss_cap_max_ret_5d=0.10,
|
|
thin_gap_up_loss_cap_max_premarket_dollar_vol=10_000_000,
|
|
thin_gap_up_loss_cap_min_body_ratio=0.50,
|
|
thin_gap_up_loss_cap_min_close_location=0.80,
|
|
thin_gap_up_loss_cap_pct=0.02,
|
|
)
|
|
|
|
day_result = simulate_orb_day(
|
|
bars,
|
|
"2026-01-05",
|
|
params,
|
|
enrichment,
|
|
equity=10_000.0,
|
|
)
|
|
|
|
trades = {trade.ticker: trade for trade in day_result.trades}
|
|
assert trades["THIN"].thin_gap_up_loss_cap_active
|
|
assert not trades["LIQUID"].thin_gap_up_loss_cap_active
|
|
|
|
|
|
def test_moderate_downside_loss_cap_marks_only_moderate_weak_reclaims() -> None:
|
|
bars = {}
|
|
for ticker in ("MOD", "DEEP", "STRONG"):
|
|
bars[ticker] = [
|
|
{"timestamp": "2026-01-05T08:00:00-05:00", "open": 100.0, "high": 100.1, "low": 99.9, "close": 100.0, "volume": 1000},
|
|
{"timestamp": "2026-01-05T09:30:00-05:00", "open": 100.0, "high": 101.0, "low": 99.5, "close": 100.2, "volume": 1000},
|
|
{"timestamp": "2026-01-05T09:35:00-05:00", "open": 101.0, "high": 101.8, "low": 100.8, "close": 101.4, "volume": 1000},
|
|
{"timestamp": "2026-01-05T09:40:00-05:00", "open": 101.4, "high": 102.0, "low": 101.0, "close": 101.6, "volume": 1000},
|
|
{"timestamp": "2026-01-05T09:45:00-05:00", "open": 101.6, "high": 102.1, "low": 101.4, "close": 101.8, "volume": 1000},
|
|
{"timestamp": "2026-01-05T15:55:00-05:00", "open": 101.8, "high": 102.0, "low": 101.5, "close": 101.7, "volume": 1000},
|
|
]
|
|
enrichment = _enrichment_for("MOD", "DEEP", "STRONG")
|
|
enrichment["MOD"]["2026-01-05"]["prev_close"] = 106.0
|
|
enrichment["MOD"]["2026-01-05"]["ret_5d"] = 0.10
|
|
enrichment["DEEP"]["2026-01-05"]["prev_close"] = 116.0
|
|
enrichment["DEEP"]["2026-01-05"]["ret_5d"] = 0.10
|
|
enrichment["STRONG"]["2026-01-05"]["prev_close"] = 106.0
|
|
enrichment["STRONG"]["2026-01-05"]["ret_5d"] = 0.35
|
|
|
|
params = ORBStrategyParams(
|
|
engine_family="gainers_leader",
|
|
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_abs_gap_pct=0.0,
|
|
min_premarket_dollar_vol=0.0,
|
|
max_candidates=10,
|
|
min_candidates_to_trade=1,
|
|
atr_stop_multiplier=10.0,
|
|
trailing_at_r=99.0,
|
|
breakeven_at_r=99.0,
|
|
risk_per_trade_pct=0.01,
|
|
max_position_pct=1.0,
|
|
slippage_bps=0.0,
|
|
moderate_downside_loss_cap_min_abs_gap_pct=0.05,
|
|
moderate_downside_loss_cap_max_abs_gap_pct=0.10,
|
|
moderate_downside_loss_cap_min_ret_5d=0.0,
|
|
moderate_downside_loss_cap_max_ret_5d=0.25,
|
|
moderate_downside_loss_cap_max_premarket_dollar_vol=10_000_000,
|
|
moderate_downside_loss_cap_max_body_ratio=0.30,
|
|
moderate_downside_loss_cap_max_close_location=0.75,
|
|
moderate_downside_loss_cap_pct=0.01,
|
|
)
|
|
|
|
day_result = simulate_orb_day(
|
|
bars,
|
|
"2026-01-05",
|
|
params,
|
|
enrichment,
|
|
equity=10_000.0,
|
|
)
|
|
|
|
trades = {trade.ticker: trade for trade in day_result.trades}
|
|
assert trades["MOD"].moderate_downside_loss_cap_active
|
|
assert not trades["DEEP"].moderate_downside_loss_cap_active
|
|
assert not trades["STRONG"].moderate_downside_loss_cap_active
|
|
|
|
|
|
def test_stale_obv_reversal_extra_gates_target_positive_gap_weak_open() -> None:
|
|
bars = {}
|
|
for ticker in ("WEAK", "NEG_GAP", "HIGH_RVOL", "STRONG_BODY"):
|
|
first_volume = 500_000 if ticker == "HIGH_RVOL" else 1_000
|
|
first_close = 100.8 if ticker == "STRONG_BODY" else 98.9
|
|
bars[ticker] = [
|
|
{"timestamp": "2026-01-05T09:30:00-05:00", "open": 99.0, "high": 101.0, "low": 98.0, "close": first_close, "volume": first_volume},
|
|
{"timestamp": "2026-01-05T09:35:00-05:00", "open": first_close, "high": 101.6, "low": first_close - 0.2, "close": 101.2, "volume": 1_000},
|
|
{"timestamp": "2026-01-05T09:40:00-05:00", "open": 101.2, "high": 102.0, "low": 101.0, "close": 101.6, "volume": 1_000},
|
|
{"timestamp": "2026-01-05T15:55:00-05:00", "open": 101.5, "high": 101.8, "low": 101.2, "close": 101.7, "volume": 1_000},
|
|
]
|
|
enrichment = _enrichment_for("WEAK", "NEG_GAP", "HIGH_RVOL", "STRONG_BODY")
|
|
for ticker in enrichment:
|
|
enrichment[ticker]["2026-01-05"]["prev_close"] = 96.0
|
|
enrichment[ticker]["2026-01-05"]["ret_5d"] = 0.10
|
|
enrichment[ticker]["2026-01-05"]["obv_slope_20"] = 0.20
|
|
enrichment["NEG_GAP"]["2026-01-05"]["prev_close"] = 103.0
|
|
|
|
params = ORBStrategyParams(
|
|
engine_family="gainers_leader",
|
|
allow_red_to_green_breakout=True,
|
|
min_price=10.0,
|
|
min_avg_dollar_volume=0.0,
|
|
min_atr_14=0.1,
|
|
min_rvol=0.0,
|
|
min_abs_gap_pct=0.0,
|
|
min_premarket_dollar_vol=0.0,
|
|
max_candidates=10,
|
|
min_candidates_to_trade=1,
|
|
atr_stop_multiplier=10.0,
|
|
trailing_at_r=99.0,
|
|
breakeven_at_r=99.0,
|
|
risk_per_trade_pct=0.001,
|
|
max_position_pct=0.25,
|
|
slippage_bps=0.0,
|
|
stale_obv_reversal_max_obv_slope_20d=0.35,
|
|
stale_obv_reversal_min_ret_5d=0.0,
|
|
stale_obv_reversal_max_ret_5d=0.35,
|
|
stale_obv_reversal_min_gap_pct=0.02,
|
|
stale_obv_reversal_max_rvol=15.0,
|
|
stale_obv_reversal_max_body_ratio=0.20,
|
|
stale_obv_reversal_max_close_location=0.40,
|
|
stale_obv_reversal_max_orb_return=0.0,
|
|
stale_obv_reversal_action="confirm_scale",
|
|
stale_obv_reversal_size_scale=0.35,
|
|
stale_obv_reversal_loss_cap_pct=0.015,
|
|
)
|
|
|
|
day_result = simulate_orb_day(
|
|
bars,
|
|
"2026-01-05",
|
|
params,
|
|
enrichment,
|
|
equity=10_000.0,
|
|
)
|
|
|
|
trades = {trade.ticker: trade for trade in day_result.trades}
|
|
assert trades["WEAK"].stale_obv_reversal_requires_confirmation
|
|
assert trades["WEAK"].stale_obv_reversal_size_scale == 0.35
|
|
assert not trades["NEG_GAP"].stale_obv_reversal_requires_confirmation
|
|
assert not trades["HIGH_RVOL"].stale_obv_reversal_requires_confirmation
|
|
assert not trades["STRONG_BODY"].stale_obv_reversal_requires_confirmation
|
|
|
|
|
|
def test_fixed_loss_can_preserve_trailing_stop_management() -> None:
|
|
bars = [
|
|
{"timestamp": "2026-01-05T09:30:00-05:00", "open": 99.0, "high": 100.0, "low": 98.5, "close": 99.5, "volume": 1_000},
|
|
{"timestamp": "2026-01-05T09:35:00-05:00", "open": 100.0, "high": 101.0, "low": 99.5, "close": 101.0, "volume": 2_000},
|
|
{"timestamp": "2026-01-05T09:40:00-05:00", "open": 101.0, "high": 103.0, "low": 100.8, "close": 102.5, "volume": 2_000},
|
|
{"timestamp": "2026-01-05T09:45:00-05:00", "open": 102.0, "high": 102.2, "low": 101.0, "close": 101.2, "volume": 2_000},
|
|
{"timestamp": "2026-01-05T15:55:00-05:00", "open": 104.0, "high": 104.2, "low": 103.8, "close": 104.0, "volume": 1_000},
|
|
]
|
|
base_params = ORBStrategyParams(
|
|
orb_minutes=5,
|
|
sim_bar_minutes=5,
|
|
order_timeout_minutes=30,
|
|
atr_stop_multiplier=0.1,
|
|
breakeven_at_r=99.0,
|
|
trailing_at_r=1.0,
|
|
trailing_stop_atr_multiplier=0.15,
|
|
trailing_tighten_at_r=None,
|
|
fixed_loss_pct=0.05,
|
|
risk_per_trade_pct=0.01,
|
|
max_position_pct=1.0,
|
|
slippage_bps=0.0,
|
|
)
|
|
|
|
legacy_trade = simulate_orb_trade(
|
|
bars,
|
|
bars[0],
|
|
"long",
|
|
atr=10.0,
|
|
rvol=2.0,
|
|
gap_pct=0.03,
|
|
params=base_params,
|
|
equity=10_000.0,
|
|
date_str="2026-01-05",
|
|
ticker="LEGACY",
|
|
)
|
|
preserve_trade = simulate_orb_trade(
|
|
bars,
|
|
bars[0],
|
|
"long",
|
|
atr=10.0,
|
|
rvol=2.0,
|
|
gap_pct=0.03,
|
|
params=base_params.model_copy(update={"fixed_loss_preserve_trailing": True}),
|
|
equity=10_000.0,
|
|
date_str="2026-01-05",
|
|
ticker="PRESERVE",
|
|
)
|
|
|
|
assert legacy_trade is not None
|
|
assert preserve_trade is not None
|
|
assert legacy_trade.exit_reason == "close"
|
|
assert preserve_trade.exit_reason == "trailing_stop"
|
|
assert preserve_trade.exit_price == pytest.approx(101.5)
|
|
|
|
|
|
def test_conviction_runner_trail_only_delays_qualified_orb_tightening() -> None:
|
|
bars = [
|
|
{"timestamp": "2026-01-05T09:30:00-05:00", "open": 100.0, "high": 101.0, "low": 99.5, "close": 100.8, "volume": 1_000},
|
|
{"timestamp": "2026-01-05T09:35:00-05:00", "open": 101.0, "high": 101.2, "low": 101.0, "close": 101.1, "volume": 2_000},
|
|
{"timestamp": "2026-01-05T09:40:00-05:00", "open": 102.6, "high": 102.7, "low": 102.3, "close": 102.6, "volume": 2_000},
|
|
{"timestamp": "2026-01-05T09:45:00-05:00", "open": 102.4, "high": 102.6, "low": 102.4, "close": 102.5, "volume": 2_000},
|
|
{"timestamp": "2026-01-05T15:55:00-05:00", "open": 104.0, "high": 104.1, "low": 103.9, "close": 104.0, "volume": 1_000},
|
|
]
|
|
base_params = ORBStrategyParams(
|
|
orb_minutes=5,
|
|
sim_bar_minutes=5,
|
|
order_timeout_minutes=30,
|
|
atr_stop_multiplier=1.0,
|
|
breakeven_at_r=1.0,
|
|
trailing_at_r=1.0,
|
|
trailing_stop_atr_multiplier=0.6,
|
|
trailing_tighten_at_r=1.5,
|
|
trailing_stop_atr_multiplier_tight=0.2,
|
|
risk_per_trade_pct=0.01,
|
|
max_position_pct=1.0,
|
|
slippage_bps=0.0,
|
|
)
|
|
runner_params = base_params.model_copy(
|
|
update={
|
|
"conviction_runner_trail_allowed_trigger_types": ["orb"],
|
|
"conviction_runner_trail_tighten_at_r": 2.0,
|
|
"conviction_runner_trail_gap_atr_multiplier": 0.8,
|
|
"conviction_runner_trail_min_abs_gap_pct": 0.035,
|
|
"conviction_runner_trail_min_candidate_score": 0.9,
|
|
"conviction_runner_trail_min_score_rank_pct": 0.9,
|
|
}
|
|
)
|
|
|
|
base_trade = simulate_orb_trade(
|
|
bars,
|
|
bars[0],
|
|
"long",
|
|
atr=1.0,
|
|
rvol=8.0,
|
|
gap_pct=0.04,
|
|
params=base_params,
|
|
equity=10_000.0,
|
|
date_str="2026-01-05",
|
|
ticker="BASE",
|
|
score_rank_pct=1.0,
|
|
candidate_score=0.95,
|
|
)
|
|
runner_trade = simulate_orb_trade(
|
|
bars,
|
|
bars[0],
|
|
"long",
|
|
atr=1.0,
|
|
rvol=8.0,
|
|
gap_pct=0.04,
|
|
params=runner_params,
|
|
equity=10_000.0,
|
|
date_str="2026-01-05",
|
|
ticker="RUNNER",
|
|
score_rank_pct=1.0,
|
|
candidate_score=0.95,
|
|
)
|
|
blocked_trade = simulate_orb_trade(
|
|
bars,
|
|
bars[0],
|
|
"long",
|
|
atr=1.0,
|
|
rvol=8.0,
|
|
gap_pct=0.04,
|
|
params=runner_params,
|
|
equity=10_000.0,
|
|
date_str="2026-01-05",
|
|
ticker="BLOCKED",
|
|
score_rank_pct=1.0,
|
|
candidate_score=0.50,
|
|
)
|
|
|
|
assert base_trade is not None
|
|
assert runner_trade is not None
|
|
assert blocked_trade is not None
|
|
assert base_trade.exit_reason == "trailing_stop"
|
|
assert base_trade.exit_price == pytest.approx(102.4)
|
|
assert runner_trade.exit_reason == "close"
|
|
assert runner_trade.exit_price == pytest.approx(104.0)
|
|
assert blocked_trade.exit_reason == "trailing_stop"
|
|
assert blocked_trade.exit_price == pytest.approx(base_trade.exit_price)
|
|
|
|
|
|
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_same_bar_stop_confirmation_delays_ambiguous_entry_bar_stop() -> 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": 100.8, "high": 101.2, "low": 100.4, "close": 101.1, "volume": 1500},
|
|
{"timestamp": "2026-01-05T09:40:00-05:00", "open": 101.1, "high": 102.0, "low": 101.0, "close": 101.8, "volume": 1600},
|
|
{"timestamp": "2026-01-05T15:55:00-05:00", "open": 101.1, "high": 101.4, "low": 100.9, "close": 101.2, "volume": 1000},
|
|
]
|
|
base_params = ORBStrategyParams(
|
|
orb_minutes=5,
|
|
sim_bar_minutes=5,
|
|
order_timeout_minutes=30,
|
|
atr_stop_multiplier=0.5,
|
|
trailing_at_r=99.0,
|
|
breakeven_at_r=99.0,
|
|
slippage_bps=0.0,
|
|
risk_per_trade_pct=0.01,
|
|
max_position_pct=1.0,
|
|
)
|
|
|
|
default_trade = simulate_orb_trade(
|
|
bars,
|
|
bars[0],
|
|
"long",
|
|
atr=1.0,
|
|
rvol=8.0,
|
|
gap_pct=0.03,
|
|
params=base_params,
|
|
equity=10_000.0,
|
|
date_str="2026-01-05",
|
|
ticker="STOP",
|
|
)
|
|
delayed_trade = simulate_orb_trade(
|
|
bars,
|
|
bars[0],
|
|
"long",
|
|
atr=1.0,
|
|
rvol=8.0,
|
|
gap_pct=0.03,
|
|
params=base_params.model_copy(
|
|
update={
|
|
"same_bar_stop_confirmation_enabled": True,
|
|
"same_bar_stop_confirmation_allowed_trigger_types": ["orb"],
|
|
}
|
|
),
|
|
equity=10_000.0,
|
|
date_str="2026-01-05",
|
|
ticker="DELAY",
|
|
)
|
|
blocked_by_trigger = simulate_orb_trade(
|
|
bars,
|
|
bars[0],
|
|
"long",
|
|
atr=1.0,
|
|
rvol=8.0,
|
|
gap_pct=0.03,
|
|
params=base_params.model_copy(
|
|
update={
|
|
"same_bar_stop_confirmation_enabled": True,
|
|
"same_bar_stop_confirmation_allowed_trigger_types": ["vwap_reclaim"],
|
|
}
|
|
),
|
|
equity=10_000.0,
|
|
date_str="2026-01-05",
|
|
ticker="ALLOWLIST",
|
|
)
|
|
|
|
assert default_trade is not None
|
|
assert default_trade.exit_reason == "stop_loss"
|
|
assert default_trade.entry_time == default_trade.exit_time
|
|
assert default_trade.total_capital_deployed > 0
|
|
assert delayed_trade is not None
|
|
assert delayed_trade.entry_time == "2026-01-05T09:40:00-05:00"
|
|
assert delayed_trade.entry_price == 101.8
|
|
assert blocked_by_trigger is not None
|
|
assert blocked_by_trigger.exit_reason == "stop_loss"
|
|
assert blocked_by_trigger.total_capital_deployed > 0
|
|
|
|
|
|
def test_early_failure_exit_cuts_failed_breakout() -> 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": 100.8, "high": 101.2, "low": 100.7, "close": 101.0, "volume": 1200},
|
|
{"timestamp": "2026-01-05T09:40:00-05:00", "open": 101.0, "high": 101.1, "low": 100.6, "close": 100.8, "volume": 1500},
|
|
{"timestamp": "2026-01-05T15:55:00-05:00", "open": 102.0, "high": 102.2, "low": 101.8, "close": 102.0, "volume": 1000},
|
|
]
|
|
params = ORBStrategyParams(
|
|
orb_minutes=5,
|
|
sim_bar_minutes=5,
|
|
order_timeout_minutes=30,
|
|
atr_stop_multiplier=10.0,
|
|
trailing_at_r=99.0,
|
|
breakeven_at_r=99.0,
|
|
slippage_bps=0.0,
|
|
risk_per_trade_pct=0.01,
|
|
max_position_pct=1.0,
|
|
early_failure_exit_minutes=15,
|
|
early_failure_exit_level="breakout",
|
|
early_failure_exit_max_peak_r=0.50,
|
|
)
|
|
|
|
trade = simulate_orb_trade(
|
|
bars,
|
|
bars[0],
|
|
"long",
|
|
atr=1.0,
|
|
rvol=8.0,
|
|
gap_pct=0.03,
|
|
params=params,
|
|
equity=10_000.0,
|
|
date_str="2026-01-05",
|
|
ticker="FAIL",
|
|
)
|
|
|
|
assert trade is not None
|
|
assert trade.exit_reason == "early_failure"
|
|
assert trade.exit_time == "2026-01-05T09:40:00-05:00"
|
|
assert trade.exit_price == 100.8
|
|
|
|
|
|
def test_early_failure_exit_allows_trade_after_positive_close_excursion() -> 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": 100.8, "high": 101.2, "low": 100.7, "close": 101.0, "volume": 1200},
|
|
{"timestamp": "2026-01-05T09:40:00-05:00", "open": 101.0, "high": 102.2, "low": 100.9, "close": 102.0, "volume": 1500},
|
|
{"timestamp": "2026-01-05T09:45:00-05:00", "open": 102.0, "high": 102.1, "low": 100.5, "close": 100.8, "volume": 1500},
|
|
{"timestamp": "2026-01-05T15:55:00-05:00", "open": 102.0, "high": 102.2, "low": 101.8, "close": 102.0, "volume": 1000},
|
|
]
|
|
params = ORBStrategyParams(
|
|
orb_minutes=5,
|
|
sim_bar_minutes=5,
|
|
order_timeout_minutes=30,
|
|
atr_stop_multiplier=1.0,
|
|
trailing_at_r=99.0,
|
|
breakeven_at_r=99.0,
|
|
slippage_bps=0.0,
|
|
risk_per_trade_pct=0.01,
|
|
max_position_pct=1.0,
|
|
early_failure_exit_minutes=15,
|
|
early_failure_exit_level="breakout",
|
|
early_failure_exit_max_peak_r=0.50,
|
|
)
|
|
|
|
trade = simulate_orb_trade(
|
|
bars,
|
|
bars[0],
|
|
"long",
|
|
atr=1.0,
|
|
rvol=8.0,
|
|
gap_pct=0.03,
|
|
params=params,
|
|
equity=10_000.0,
|
|
date_str="2026-01-05",
|
|
ticker="HOLD",
|
|
)
|
|
|
|
assert trade is not None
|
|
assert trade.exit_reason == "close"
|
|
assert trade.exit_price == 102.0
|
|
|
|
|
|
def test_pyramid_quality_gates_control_add_on() -> 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": 100.8, "high": 101.2, "low": 100.7, "close": 101.0, "volume": 1200},
|
|
{"timestamp": "2026-01-05T09:40:00-05:00", "open": 101.0, "high": 102.4, "low": 101.0, "close": 102.2, "volume": 1500},
|
|
{"timestamp": "2026-01-05T15:55:00-05:00", "open": 103.8, "high": 104.1, "low": 103.7, "close": 104.0, "volume": 1000},
|
|
]
|
|
params = ORBStrategyParams(
|
|
orb_minutes=5,
|
|
sim_bar_minutes=5,
|
|
order_timeout_minutes=30,
|
|
atr_stop_multiplier=1.0,
|
|
trailing_at_r=99.0,
|
|
breakeven_at_r=99.0,
|
|
slippage_bps=0.0,
|
|
risk_per_trade_pct=0.02,
|
|
max_position_pct=0.50,
|
|
pyramid_at_r=1.0,
|
|
pyramid_add_pct=0.50,
|
|
pyramid_max_adds=1,
|
|
pyramid_allowed_trigger_types=["orb"],
|
|
pyramid_min_score_rank_pct=0.80,
|
|
pyramid_min_rvol=6.0,
|
|
)
|
|
|
|
allowed_trade = simulate_orb_trade(
|
|
bars,
|
|
bars[0],
|
|
"long",
|
|
atr=1.0,
|
|
rvol=8.0,
|
|
gap_pct=0.03,
|
|
params=params,
|
|
equity=10_000.0,
|
|
date_str="2026-01-05",
|
|
ticker="ALLOW",
|
|
score_rank_pct=0.90,
|
|
)
|
|
low_rank_trade = simulate_orb_trade(
|
|
bars,
|
|
bars[0],
|
|
"long",
|
|
atr=1.0,
|
|
rvol=8.0,
|
|
gap_pct=0.03,
|
|
params=params,
|
|
equity=10_000.0,
|
|
date_str="2026-01-05",
|
|
ticker="LOWRANK",
|
|
score_rank_pct=0.70,
|
|
)
|
|
low_rvol_trade = simulate_orb_trade(
|
|
bars,
|
|
bars[0],
|
|
"long",
|
|
atr=1.0,
|
|
rvol=5.0,
|
|
gap_pct=0.03,
|
|
params=params,
|
|
equity=10_000.0,
|
|
date_str="2026-01-05",
|
|
ticker="LOWRVOL",
|
|
score_rank_pct=0.90,
|
|
)
|
|
blocked_trigger_trade = simulate_orb_trade(
|
|
bars,
|
|
bars[0],
|
|
"long",
|
|
atr=1.0,
|
|
rvol=8.0,
|
|
gap_pct=0.03,
|
|
params=params.model_copy(update={"pyramid_allowed_trigger_types": ["vwap_reclaim"]}),
|
|
equity=10_000.0,
|
|
date_str="2026-01-05",
|
|
ticker="BLOCKED",
|
|
score_rank_pct=0.90,
|
|
)
|
|
blocked_context_trade = simulate_orb_trade(
|
|
bars,
|
|
bars[0],
|
|
"long",
|
|
atr=1.0,
|
|
rvol=8.0,
|
|
gap_pct=0.03,
|
|
params=params,
|
|
equity=10_000.0,
|
|
date_str="2026-01-05",
|
|
ticker="CTXBLOCK",
|
|
score_rank_pct=0.90,
|
|
pyramid_allowed=False,
|
|
)
|
|
|
|
assert allowed_trade is not None
|
|
assert low_rank_trade is not None
|
|
assert low_rvol_trade is not None
|
|
assert blocked_trigger_trade is not None
|
|
assert blocked_context_trade is not None
|
|
assert allowed_trade.pyramid_adds == 1
|
|
assert allowed_trade.pyramid_pnl > 0
|
|
assert low_rank_trade.pyramid_adds == 0
|
|
assert low_rvol_trade.pyramid_adds == 0
|
|
assert blocked_trigger_trade.pyramid_adds == 0
|
|
assert blocked_context_trade.pyramid_adds == 0
|
|
|
|
|
|
def test_pyramid_add_on_respects_available_cash() -> 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": 100.8, "high": 101.2, "low": 100.7, "close": 101.0, "volume": 1200},
|
|
{"timestamp": "2026-01-05T09:40:00-05:00", "open": 101.0, "high": 102.4, "low": 101.0, "close": 102.2, "volume": 1500},
|
|
{"timestamp": "2026-01-05T15:55:00-05:00", "open": 103.8, "high": 104.1, "low": 103.7, "close": 104.0, "volume": 1000},
|
|
]
|
|
base_params = ORBStrategyParams(
|
|
orb_minutes=5,
|
|
sim_bar_minutes=5,
|
|
order_timeout_minutes=30,
|
|
atr_stop_multiplier=1.0,
|
|
trailing_at_r=99.0,
|
|
breakeven_at_r=99.0,
|
|
slippage_bps=0.0,
|
|
risk_per_trade_pct=0.02,
|
|
pyramid_at_r=1.0,
|
|
pyramid_add_pct=0.50,
|
|
pyramid_max_adds=1,
|
|
)
|
|
|
|
capped_trade = simulate_orb_trade(
|
|
bars,
|
|
bars[0],
|
|
"long",
|
|
atr=1.0,
|
|
rvol=8.0,
|
|
gap_pct=0.03,
|
|
params=base_params.model_copy(update={"max_position_pct": 0.50}),
|
|
equity=10_000.0,
|
|
date_str="2026-01-05",
|
|
ticker="CAPPED",
|
|
available_cash=6_000.0,
|
|
score_rank_pct=0.90,
|
|
)
|
|
no_room_trade = simulate_orb_trade(
|
|
bars,
|
|
bars[0],
|
|
"long",
|
|
atr=1.0,
|
|
rvol=8.0,
|
|
gap_pct=0.03,
|
|
params=base_params.model_copy(update={"max_position_pct": 1.00}),
|
|
equity=10_000.0,
|
|
date_str="2026-01-05",
|
|
ticker="NOROOM",
|
|
available_cash=6_000.0,
|
|
score_rank_pct=0.90,
|
|
)
|
|
|
|
assert capped_trade is not None
|
|
assert no_room_trade is not None
|
|
assert capped_trade.pyramid_adds == 1
|
|
assert capped_trade.total_capital_deployed <= 6_000.0
|
|
assert no_room_trade.pyramid_adds == 0
|
|
assert no_room_trade.total_capital_deployed <= 6_000.0
|
|
|
|
|
|
def test_entry_market_guard_uses_entry_bar_open_without_close_lookahead() -> None:
|
|
bars = [
|
|
{"timestamp": "2026-01-05T09:30:00-05:00", "open": 100.0, "high": 101.0, "low": 99.8, "close": 100.8, "volume": 10_000},
|
|
{"timestamp": "2026-01-05T09:35:00-05:00", "open": 100.6, "high": 100.7, "low": 99.5, "close": 99.6, "volume": 12_000},
|
|
{"timestamp": "2026-01-05T09:40:00-05:00", "open": 99.4, "high": 99.6, "low": 99.0, "close": 99.2, "volume": 12_000},
|
|
]
|
|
entry_ts = _parse_test_ts("2026-01-05T09:35:00-05:00")
|
|
|
|
market_return = _market_return_at_entry_open(bars, entry_ts, "2026-01-05")
|
|
|
|
assert market_return == pytest.approx(0.006)
|
|
|
|
|
|
def test_entry_market_guard_scales_when_market_is_below_threshold() -> None:
|
|
bars = [
|
|
{"timestamp": "2026-01-05T09:30:00-05:00", "open": 100.0, "high": 100.2, "low": 99.7, "close": 99.8, "volume": 10_000},
|
|
{"timestamp": "2026-01-05T09:35:00-05:00", "open": 99.7, "high": 99.8, "low": 99.0, "close": 99.1, "volume": 12_000},
|
|
]
|
|
params = ORBStrategyParams(
|
|
entry_market_guard_enabled=True,
|
|
entry_market_guard_min_return_pct=-0.001,
|
|
entry_market_guard_size_scale=0.4,
|
|
)
|
|
|
|
active, market_return, size_scale = _entry_market_guard_scale(
|
|
params,
|
|
bars,
|
|
_parse_test_ts("2026-01-05T09:35:00-05:00"),
|
|
"2026-01-05",
|
|
is_soft_day=False,
|
|
)
|
|
|
|
assert active is True
|
|
assert market_return == pytest.approx(-0.003)
|
|
assert size_scale == pytest.approx(0.4)
|
|
|
|
|
|
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_crowded_gap_reject_filters_extended_positive_gap_candidates() -> None:
|
|
bars = {
|
|
"CROWDED": [
|
|
{"timestamp": "2026-01-05T09:30:00-05:00", "open": 100.0, "high": 105.0, "low": 99.0, "close": 104.0, "volume": 1000},
|
|
{"timestamp": "2026-01-05T09:35:00-05:00", "open": 104.0, "high": 106.0, "low": 103.0, "close": 105.0, "volume": 1000},
|
|
{"timestamp": "2026-01-05T09:40:00-05:00", "open": 105.0, "high": 106.0, "low": 104.0, "close": 105.5, "volume": 1000},
|
|
{"timestamp": "2026-01-05T09:45:00-05:00", "open": 105.5, "high": 106.0, "low": 105.0, "close": 105.7, "volume": 1000},
|
|
{"timestamp": "2026-01-05T15:55:00-05:00", "open": 105.5, "high": 106.0, "low": 105.0, "close": 105.8, "volume": 1000},
|
|
],
|
|
"FRESH": [
|
|
{"timestamp": "2026-01-05T09:30:00-05:00", "open": 100.0, "high": 102.0, "low": 99.0, "close": 101.0, "volume": 1000},
|
|
{"timestamp": "2026-01-05T09:35:00-05:00", "open": 101.0, "high": 103.0, "low": 100.8, "close": 102.5, "volume": 1000},
|
|
{"timestamp": "2026-01-05T09:40:00-05:00", "open": 102.5, "high": 103.0, "low": 102.0, "close": 102.8, "volume": 1000},
|
|
{"timestamp": "2026-01-05T09:45:00-05:00", "open": 102.8, "high": 103.0, "low": 102.5, "close": 102.9, "volume": 1000},
|
|
{"timestamp": "2026-01-05T15:55:00-05:00", "open": 102.8, "high": 103.0, "low": 102.5, "close": 102.9, "volume": 1000},
|
|
],
|
|
}
|
|
enrichment = _enrichment_for("CROWDED", "FRESH")
|
|
enrichment["CROWDED"]["2026-01-05"]["prev_close"] = 97.0
|
|
enrichment["CROWDED"]["2026-01-05"]["ret_5d"] = 0.30
|
|
enrichment["FRESH"]["2026-01-05"]["prev_close"] = 97.0
|
|
enrichment["FRESH"]["2026-01-05"]["ret_5d"] = 0.05
|
|
stats: dict[str, int] = {}
|
|
params = ORBStrategyParams(
|
|
min_price=10.0,
|
|
min_avg_dollar_volume=0.0,
|
|
min_atr_14=0.1,
|
|
min_rvol=0.1,
|
|
min_abs_gap_pct=None,
|
|
max_candidates=10,
|
|
crowded_gap_reject_min_gap_pct=0.02,
|
|
crowded_gap_reject_min_ret_5d=0.20,
|
|
crowded_gap_reject_min_body_ratio=0.60,
|
|
crowded_gap_reject_min_close_location=0.65,
|
|
)
|
|
|
|
candidates = compute_orb_candidates(
|
|
bars, "2026-01-05", params, enrichment, _stats_out=stats
|
|
)
|
|
|
|
assert [cand["ticker"] for cand in candidates] == ["FRESH"]
|
|
assert stats["crowded_gap"] == 1
|
|
|
|
|
|
def test_crowded_gap_confirm_keeps_candidate_with_confirmation_flag() -> None:
|
|
bars = {
|
|
"CROWDED": [
|
|
{"timestamp": "2026-01-05T09:30:00-05:00", "open": 100.0, "high": 105.0, "low": 99.0, "close": 104.0, "volume": 1000},
|
|
{"timestamp": "2026-01-05T09:35:00-05:00", "open": 104.0, "high": 106.0, "low": 103.0, "close": 105.0, "volume": 1000},
|
|
{"timestamp": "2026-01-05T09:40:00-05:00", "open": 105.0, "high": 106.0, "low": 104.0, "close": 105.5, "volume": 1000},
|
|
{"timestamp": "2026-01-05T09:45:00-05:00", "open": 105.5, "high": 106.0, "low": 105.0, "close": 105.7, "volume": 1000},
|
|
{"timestamp": "2026-01-05T15:55:00-05:00", "open": 105.5, "high": 106.0, "low": 105.0, "close": 105.8, "volume": 1000},
|
|
],
|
|
}
|
|
enrichment = _enrichment_for("CROWDED")
|
|
enrichment["CROWDED"]["2026-01-05"]["prev_close"] = 97.0
|
|
enrichment["CROWDED"]["2026-01-05"]["ret_5d"] = 0.30
|
|
stats: dict[str, int] = {}
|
|
params = ORBStrategyParams(
|
|
min_price=10.0,
|
|
min_avg_dollar_volume=0.0,
|
|
min_atr_14=0.1,
|
|
min_rvol=0.1,
|
|
min_abs_gap_pct=None,
|
|
max_candidates=10,
|
|
crowded_gap_reject_min_gap_pct=0.02,
|
|
crowded_gap_reject_min_ret_5d=0.20,
|
|
crowded_gap_reject_min_body_ratio=0.60,
|
|
crowded_gap_reject_min_close_location=0.65,
|
|
crowded_gap_action="confirm",
|
|
)
|
|
|
|
candidates = compute_orb_candidates(
|
|
bars, "2026-01-05", params, enrichment, _stats_out=stats
|
|
)
|
|
|
|
assert [cand["ticker"] for cand in candidates] == ["CROWDED"]
|
|
assert candidates[0]["crowded_gap_requires_confirmation"] is True
|
|
assert stats["crowded_gap"] == 1
|
|
|
|
|
|
def test_crowded_gap_scale_keeps_candidate_with_size_scale() -> None:
|
|
bars = {
|
|
"CROWDED": [
|
|
{"timestamp": "2026-01-05T09:30:00-05:00", "open": 100.0, "high": 105.0, "low": 99.0, "close": 104.0, "volume": 1000},
|
|
{"timestamp": "2026-01-05T09:35:00-05:00", "open": 104.0, "high": 106.0, "low": 103.0, "close": 105.0, "volume": 1000},
|
|
{"timestamp": "2026-01-05T09:40:00-05:00", "open": 105.0, "high": 106.0, "low": 104.0, "close": 105.5, "volume": 1000},
|
|
{"timestamp": "2026-01-05T09:45:00-05:00", "open": 105.5, "high": 106.0, "low": 105.0, "close": 105.7, "volume": 1000},
|
|
{"timestamp": "2026-01-05T15:55:00-05:00", "open": 105.5, "high": 106.0, "low": 105.0, "close": 105.8, "volume": 1000},
|
|
],
|
|
}
|
|
enrichment = _enrichment_for("CROWDED")
|
|
enrichment["CROWDED"]["2026-01-05"]["prev_close"] = 97.0
|
|
enrichment["CROWDED"]["2026-01-05"]["ret_5d"] = 0.30
|
|
stats: dict[str, int] = {}
|
|
params = ORBStrategyParams(
|
|
min_price=10.0,
|
|
min_avg_dollar_volume=0.0,
|
|
min_atr_14=0.1,
|
|
min_rvol=0.1,
|
|
min_abs_gap_pct=None,
|
|
max_candidates=10,
|
|
crowded_gap_reject_min_gap_pct=0.02,
|
|
crowded_gap_reject_min_ret_5d=0.20,
|
|
crowded_gap_reject_min_body_ratio=0.60,
|
|
crowded_gap_reject_min_close_location=0.65,
|
|
crowded_gap_action="scale",
|
|
crowded_gap_size_scale=0.4,
|
|
)
|
|
|
|
candidates = compute_orb_candidates(
|
|
bars, "2026-01-05", params, enrichment, _stats_out=stats
|
|
)
|
|
|
|
assert [cand["ticker"] for cand in candidates] == ["CROWDED"]
|
|
assert candidates[0]["crowded_gap_requires_confirmation"] is False
|
|
assert candidates[0]["crowded_gap_size_scale"] == 0.4
|
|
assert stats["crowded_gap"] == 1
|
|
|
|
|
|
def test_countertrend_gap_scale_keeps_candidate_with_size_scale() -> None:
|
|
bars = {
|
|
"BOUNCE": [
|
|
{"timestamp": "2026-01-05T09:30:00-05:00", "open": 100.0, "high": 105.0, "low": 99.0, "close": 104.5, "volume": 1000},
|
|
{"timestamp": "2026-01-05T09:35:00-05:00", "open": 104.5, "high": 106.0, "low": 103.0, "close": 105.0, "volume": 1000},
|
|
{"timestamp": "2026-01-05T09:40:00-05:00", "open": 105.0, "high": 106.0, "low": 104.0, "close": 105.5, "volume": 1000},
|
|
{"timestamp": "2026-01-05T09:45:00-05:00", "open": 105.5, "high": 106.0, "low": 105.0, "close": 105.7, "volume": 1000},
|
|
{"timestamp": "2026-01-05T15:55:00-05:00", "open": 105.5, "high": 106.0, "low": 105.0, "close": 105.8, "volume": 1000},
|
|
],
|
|
}
|
|
enrichment = _enrichment_for("BOUNCE")
|
|
enrichment["BOUNCE"]["2026-01-05"]["prev_close"] = 97.0
|
|
enrichment["BOUNCE"]["2026-01-05"]["ret_5d"] = -0.08
|
|
stats: dict[str, int] = {}
|
|
params = ORBStrategyParams(
|
|
min_price=10.0,
|
|
min_avg_dollar_volume=0.0,
|
|
min_atr_14=0.1,
|
|
min_rvol=0.1,
|
|
min_abs_gap_pct=None,
|
|
max_candidates=10,
|
|
countertrend_gap_min_gap_pct=0.02,
|
|
countertrend_gap_max_ret_5d=0.0,
|
|
countertrend_gap_min_body_ratio=0.60,
|
|
countertrend_gap_min_close_location=0.65,
|
|
countertrend_gap_action="scale",
|
|
countertrend_gap_size_scale=0.45,
|
|
)
|
|
|
|
candidates = compute_orb_candidates(
|
|
bars, "2026-01-05", params, enrichment, _stats_out=stats
|
|
)
|
|
|
|
assert [cand["ticker"] for cand in candidates] == ["BOUNCE"]
|
|
assert candidates[0]["countertrend_gap_requires_confirmation"] is False
|
|
assert candidates[0]["countertrend_gap_size_scale"] == 0.45
|
|
assert stats["countertrend_gap"] == 1
|
|
|
|
|
|
def test_red_to_green_gap_weight_prioritizes_down_gap_reclaim_candidates() -> None:
|
|
bars = {
|
|
ticker: [
|
|
{"timestamp": "2026-01-05T09:30:00-05:00", "open": 100.0, "high": 102.0, "low": 99.0, "close": 101.0, "volume": 1000},
|
|
{"timestamp": "2026-01-05T09:35:00-05:00", "open": 101.0, "high": 103.0, "low": 100.8, "close": 102.5, "volume": 1000},
|
|
{"timestamp": "2026-01-05T09:40:00-05:00", "open": 102.5, "high": 103.0, "low": 102.0, "close": 102.8, "volume": 1000},
|
|
{"timestamp": "2026-01-05T09:45:00-05:00", "open": 102.8, "high": 103.0, "low": 102.5, "close": 102.9, "volume": 1000},
|
|
{"timestamp": "2026-01-05T15:55:00-05:00", "open": 102.8, "high": 103.0, "low": 102.5, "close": 102.9, "volume": 1000},
|
|
]
|
|
for ticker in ("DOWN_RECLAIM", "UP_GAP")
|
|
}
|
|
enrichment = _enrichment_for("DOWN_RECLAIM", "UP_GAP")
|
|
enrichment["DOWN_RECLAIM"]["2026-01-05"]["prev_close"] = 103.0
|
|
enrichment["UP_GAP"]["2026-01-05"]["prev_close"] = 97.0
|
|
params = ORBStrategyParams(
|
|
engine_family="gainers_leader",
|
|
min_price=10.0,
|
|
min_avg_dollar_volume=0.0,
|
|
min_atr_14=0.1,
|
|
min_rvol=0.1,
|
|
min_abs_gap_pct=None,
|
|
weight_rvol=0.0,
|
|
weight_gap=0.0,
|
|
weight_dollar_vol=0.0,
|
|
weight_premarket_dollar_vol=0.0,
|
|
weight_red_to_green_gap=1.0,
|
|
max_candidates=10,
|
|
)
|
|
|
|
candidates = compute_orb_candidates(bars, "2026-01-05", params, enrichment)
|
|
|
|
assert [cand["ticker"] for cand in candidates] == ["DOWN_RECLAIM", "UP_GAP"]
|
|
assert candidates[0]["score"] > candidates[1]["score"]
|
|
|
|
|
|
def test_red_to_green_reserved_slot_replaces_lowest_normal_candidate() -> None:
|
|
bars = {
|
|
"UP_HIGH": [
|
|
{"timestamp": "2026-01-05T09:30:00-05:00", "open": 100.0, "high": 102.0, "low": 99.0, "close": 101.0, "volume": 3000},
|
|
{"timestamp": "2026-01-05T09:35:00-05:00", "open": 101.0, "high": 103.0, "low": 100.8, "close": 102.5, "volume": 1000},
|
|
{"timestamp": "2026-01-05T09:40:00-05:00", "open": 102.5, "high": 103.0, "low": 102.0, "close": 102.8, "volume": 1000},
|
|
{"timestamp": "2026-01-05T09:45:00-05:00", "open": 102.8, "high": 103.0, "low": 102.5, "close": 102.9, "volume": 1000},
|
|
{"timestamp": "2026-01-05T15:55:00-05:00", "open": 102.8, "high": 103.0, "low": 102.5, "close": 102.9, "volume": 1000},
|
|
],
|
|
"UP_SECOND": [
|
|
{"timestamp": "2026-01-05T09:30:00-05:00", "open": 100.0, "high": 102.0, "low": 99.0, "close": 101.0, "volume": 2000},
|
|
{"timestamp": "2026-01-05T09:35:00-05:00", "open": 101.0, "high": 103.0, "low": 100.8, "close": 102.5, "volume": 1000},
|
|
{"timestamp": "2026-01-05T09:40:00-05:00", "open": 102.5, "high": 103.0, "low": 102.0, "close": 102.8, "volume": 1000},
|
|
{"timestamp": "2026-01-05T09:45:00-05:00", "open": 102.8, "high": 103.0, "low": 102.5, "close": 102.9, "volume": 1000},
|
|
{"timestamp": "2026-01-05T15:55:00-05:00", "open": 102.8, "high": 103.0, "low": 102.5, "close": 102.9, "volume": 1000},
|
|
],
|
|
"DOWN_RECLAIM": [
|
|
{"timestamp": "2026-01-05T09:30:00-05:00", "open": 100.0, "high": 102.0, "low": 99.0, "close": 101.0, "volume": 1000},
|
|
{"timestamp": "2026-01-05T09:35:00-05:00", "open": 101.0, "high": 103.0, "low": 100.8, "close": 102.5, "volume": 1000},
|
|
{"timestamp": "2026-01-05T09:40:00-05:00", "open": 102.5, "high": 103.0, "low": 102.0, "close": 102.8, "volume": 1000},
|
|
{"timestamp": "2026-01-05T09:45:00-05:00", "open": 102.8, "high": 103.0, "low": 102.5, "close": 102.9, "volume": 1000},
|
|
{"timestamp": "2026-01-05T15:55:00-05:00", "open": 102.8, "high": 103.0, "low": 102.5, "close": 102.9, "volume": 1000},
|
|
],
|
|
}
|
|
enrichment = _enrichment_for("UP_HIGH", "UP_SECOND", "DOWN_RECLAIM")
|
|
enrichment["UP_HIGH"]["2026-01-05"]["prev_close"] = 97.0
|
|
enrichment["UP_SECOND"]["2026-01-05"]["prev_close"] = 97.0
|
|
enrichment["DOWN_RECLAIM"]["2026-01-05"]["prev_close"] = 103.0
|
|
params = ORBStrategyParams(
|
|
engine_family="gainers_leader",
|
|
min_price=10.0,
|
|
min_avg_dollar_volume=0.0,
|
|
min_atr_14=0.1,
|
|
min_rvol=0.1,
|
|
min_abs_gap_pct=None,
|
|
weight_rvol=0.0,
|
|
weight_gap=0.0,
|
|
weight_dollar_vol=1.0,
|
|
weight_premarket_dollar_vol=0.0,
|
|
max_candidates=2,
|
|
red_to_green_reserved_slots=1,
|
|
red_to_green_min_abs_gap_pct=0.02,
|
|
red_to_green_min_close_location=0.60,
|
|
)
|
|
|
|
candidates = compute_orb_candidates(bars, "2026-01-05", params, enrichment)
|
|
|
|
assert [cand["ticker"] for cand in candidates] == ["UP_HIGH", "DOWN_RECLAIM"]
|
|
assert candidates[1]["red_to_green_reserved"] is True
|
|
|
|
|
|
def test_red_to_green_reserved_slot_can_require_opening_acceleration() -> None:
|
|
bars = {
|
|
"UP_HIGH": [
|
|
{"timestamp": "2026-01-05T09:30:00-05:00", "open": 100.0, "high": 102.0, "low": 99.0, "close": 101.0, "volume": 3000},
|
|
{"timestamp": "2026-01-05T09:35:00-05:00", "open": 101.0, "high": 103.0, "low": 100.8, "close": 102.5, "volume": 1000},
|
|
{"timestamp": "2026-01-05T09:40:00-05:00", "open": 102.5, "high": 103.0, "low": 102.0, "close": 102.8, "volume": 1000},
|
|
],
|
|
"DOWN_WEAK_RECLAIM": [
|
|
{"timestamp": "2026-01-05T09:30:00-05:00", "open": 100.0, "high": 102.0, "low": 99.0, "close": 101.0, "volume": 2000},
|
|
{"timestamp": "2026-01-05T09:35:00-05:00", "open": 101.0, "high": 103.0, "low": 100.8, "close": 102.5, "volume": 1000},
|
|
{"timestamp": "2026-01-05T09:40:00-05:00", "open": 102.5, "high": 103.0, "low": 102.0, "close": 102.8, "volume": 1000},
|
|
],
|
|
"DOWN_ACCEL": [
|
|
{"timestamp": "2026-01-05T09:30:00-05:00", "open": 100.0, "high": 112.0, "low": 99.0, "close": 111.0, "volume": 1000},
|
|
{"timestamp": "2026-01-05T09:35:00-05:00", "open": 111.0, "high": 113.0, "low": 110.8, "close": 112.5, "volume": 1000},
|
|
{"timestamp": "2026-01-05T09:40:00-05:00", "open": 112.5, "high": 113.0, "low": 112.0, "close": 112.8, "volume": 1000},
|
|
],
|
|
}
|
|
enrichment = _enrichment_for("UP_HIGH", "DOWN_WEAK_RECLAIM", "DOWN_ACCEL")
|
|
enrichment["UP_HIGH"]["2026-01-05"]["prev_close"] = 97.0
|
|
enrichment["DOWN_WEAK_RECLAIM"]["2026-01-05"]["prev_close"] = 103.0
|
|
enrichment["DOWN_ACCEL"]["2026-01-05"]["prev_close"] = 113.0
|
|
params = ORBStrategyParams(
|
|
engine_family="gainers_leader",
|
|
min_price=10.0,
|
|
min_avg_dollar_volume=0.0,
|
|
min_atr_14=0.1,
|
|
min_rvol=0.1,
|
|
min_abs_gap_pct=None,
|
|
weight_rvol=0.0,
|
|
weight_gap=0.0,
|
|
weight_dollar_vol=1.0,
|
|
weight_premarket_dollar_vol=0.0,
|
|
max_candidates=2,
|
|
red_to_green_reserved_slots=1,
|
|
red_to_green_min_abs_gap_pct=0.02,
|
|
red_to_green_min_close_location=0.60,
|
|
red_to_green_min_orb_return=0.08,
|
|
)
|
|
|
|
candidates = compute_orb_candidates(bars, "2026-01-05", params, enrichment)
|
|
|
|
assert [cand["ticker"] for cand in candidates] == ["UP_HIGH", "DOWN_ACCEL"]
|
|
assert candidates[1]["red_to_green_reserved"] is True
|
|
|
|
|
|
def test_daily_loss_limit_is_not_tightened_by_day_size_scalers() -> None:
|
|
params = ORBStrategyParams(
|
|
orb_minutes=5,
|
|
sim_bar_minutes=5,
|
|
order_timeout_minutes=45,
|
|
atr_stop_multiplier=0.5,
|
|
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.06,
|
|
max_position_pct=1.0,
|
|
daily_max_loss_pct=0.05,
|
|
max_stops_per_day=99,
|
|
market_regime_ticker="SPY",
|
|
regime_size_scale_low=0.0,
|
|
regime_size_scale_high=0.02,
|
|
regime_size_scale_min=0.5,
|
|
)
|
|
|
|
early_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.7, "close": 101.1, "volume": 1000},
|
|
{"timestamp": "2026-01-05T09:40:00-05:00", "open": 101.0, "high": 101.0, "low": 100.2, "close": 100.3, "volume": 1000},
|
|
{"timestamp": "2026-01-05T09:45:00-05:00", "open": 100.3, "high": 100.4, "low": 100.2, "close": 100.3, "volume": 1000},
|
|
{"timestamp": "2026-01-05T15:55:00-05:00", "open": 100.3, "high": 100.4, "low": 100.2, "close": 100.3, "volume": 1000},
|
|
]
|
|
late_winner = [
|
|
{"timestamp": "2026-01-05T09:30:00-05:00", "open": 100.0, "high": 101.0, "low": 99.5, "close": 100.6, "volume": 1000},
|
|
{"timestamp": "2026-01-05T09:35:00-05:00", "open": 100.6, "high": 100.9, "low": 100.4, "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.5, "close": 100.6, "volume": 1000},
|
|
{"timestamp": "2026-01-05T10:00:00-05:00", "open": 100.6, "high": 101.3, "low": 100.7, "close": 101.2, "volume": 1000},
|
|
{"timestamp": "2026-01-05T15:55:00-05:00", "open": 101.2, "high": 102.2, "low": 101.1, "close": 102.0, "volume": 1000},
|
|
]
|
|
|
|
enrichment = _enrichment_for("EARLY_LOSER", "LATE_WINNER")
|
|
enrichment["SPY"] = {
|
|
"2026-01-05": {
|
|
"prev_close": 100.0,
|
|
"today_open": 100.0,
|
|
}
|
|
}
|
|
|
|
day_result = simulate_orb_day(
|
|
{
|
|
"EARLY_LOSER": early_loser,
|
|
"LATE_WINNER": late_winner,
|
|
},
|
|
"2026-01-05",
|
|
params,
|
|
enrichment,
|
|
equity=10_000.0,
|
|
)
|
|
|
|
assert day_result.regime_scaler == pytest.approx(0.5)
|
|
assert [trade.ticker for trade in day_result.trades] == ["EARLY_LOSER", "LATE_WINNER"]
|
|
|
|
|
|
def test_sparse_day_scaler_reduces_orb_day_budget_without_skipping_trade() -> 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": 100.8, "high": 101.4, "low": 100.7, "close": 101.2, "volume": 1000},
|
|
{"timestamp": "2026-01-05T09:40:00-05:00", "open": 101.2, "high": 101.6, "low": 101.1, "close": 101.5, "volume": 1000},
|
|
{"timestamp": "2026-01-05T09:45:00-05:00", "open": 101.5, "high": 101.8, "low": 101.4, "close": 101.7, "volume": 1000},
|
|
{"timestamp": "2026-01-05T09:50:00-05:00", "open": 101.7, "high": 101.9, "low": 101.6, "close": 101.8, "volume": 1000},
|
|
{"timestamp": "2026-01-05T15:55:00-05:00", "open": 101.5, "high": 102.0, "low": 101.4, "close": 101.9, "volume": 1000},
|
|
]
|
|
baseline = 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,
|
|
min_abs_gap_pct=None,
|
|
max_candidates=5,
|
|
min_candidates_to_trade=1,
|
|
risk_per_trade_pct=0.1,
|
|
max_position_pct=1.0,
|
|
daily_max_loss_pct=1.0,
|
|
max_stops_per_day=99,
|
|
)
|
|
scaled = baseline.model_copy(update={
|
|
"full_size_positions_threshold": 2,
|
|
"sparse_day_size_floor": 0.5,
|
|
})
|
|
enrichment = _enrichment_for("AAA")
|
|
|
|
base_day = simulate_orb_day(
|
|
{"AAA": bars},
|
|
"2026-01-05",
|
|
baseline,
|
|
enrichment,
|
|
equity=10_000.0,
|
|
)
|
|
scaled_day = simulate_orb_day(
|
|
{"AAA": bars},
|
|
"2026-01-05",
|
|
scaled,
|
|
enrichment,
|
|
equity=10_000.0,
|
|
)
|
|
|
|
assert len(base_day.trades) == 1
|
|
assert len(scaled_day.trades) == 1
|
|
assert scaled_day.sparse_day_scaler == 0.5
|
|
assert scaled_day.capital_deployed < base_day.capital_deployed
|
|
assert scaled_day.trades[0].shares < base_day.trades[0].shares
|
|
|
|
|
|
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_chunked_orb_simulation_preserves_streak_sizing_state() -> 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.1,
|
|
max_position_pct=1.0,
|
|
daily_max_loss_pct=1.0,
|
|
max_stops_per_day=99,
|
|
settlement_days=0,
|
|
daily_budget_reset=True,
|
|
streak_sizing_win_bonus=1.0,
|
|
streak_sizing_max=3.0,
|
|
single_trade_loss_cap_pct=0.5,
|
|
)
|
|
|
|
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": 103.0, "low": 101.4, "close": 102.8, "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 round(combined[2].capital_deployed, 6) == round(single[2].capital_deployed, 6)
|
|
assert round(combined[2].daily_pnl, 6) == round(single[2].daily_pnl, 6)
|
|
|
|
|
|
def test_daily_budget_reset_caps_buying_power_to_initial_capital() -> 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,
|
|
min_abs_gap_pct=0.0,
|
|
max_candidates=20,
|
|
min_candidates_to_trade=1,
|
|
risk_per_trade_pct=1.0,
|
|
max_position_pct=1.0,
|
|
daily_max_loss_pct=1.0,
|
|
max_stops_per_day=99,
|
|
settlement_days=1,
|
|
daily_budget_reset=True,
|
|
initial_capital=10_000.0,
|
|
streak_sizing_win_bonus=1.0,
|
|
streak_sizing_max=3.0,
|
|
)
|
|
|
|
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": 102.0, "low": 100.7, "close": 101.8, "volume": 1000},
|
|
{"timestamp": f"{day}T09:40:00-05:00", "open": 101.8, "high": 102.5, "low": 101.7, "close": 102.2, "volume": 1000},
|
|
{"timestamp": f"{day}T09:45:00-05:00", "open": 102.2, "high": 103.0, "low": 102.1, "close": 102.8, "volume": 1000},
|
|
{"timestamp": f"{day}T15:55:00-05:00", "open": 104.0, "high": 105.0, "low": 103.8, "close": 104.8, "volume": 1000},
|
|
]
|
|
|
|
trading_days = ["2026-01-05", "2026-01-06", "2026-01-07"]
|
|
all_intraday = {
|
|
"2026-01-05": {"AAA": bars("2026-01-05")},
|
|
"2026-01-06": {"AAA": bars("2026-01-06")},
|
|
"2026-01-07": {
|
|
"AAA": bars("2026-01-07"),
|
|
"BBB": bars("2026-01-07"),
|
|
},
|
|
}
|
|
enrichment = {
|
|
ticker: {
|
|
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
|
|
}
|
|
for ticker in ("AAA", "BBB")
|
|
}
|
|
|
|
results = run_orb_simulation(all_intraday, trading_days, params, enrichment)
|
|
|
|
assert all(day.available_cash_start == params.initial_capital for day in results)
|
|
assert results[2].trades
|
|
assert results[2].capital_deployed <= params.initial_capital
|
|
assert results[2].daily_return_pct == pytest.approx(
|
|
results[2].daily_pnl / params.initial_capital
|
|
)
|
|
|
|
|
|
def test_single_trade_loss_cap_equity_basis_scales_with_compound_equity() -> 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": 101.0, "high": 102.0, "low": 100.8, "close": 101.8, "volume": 1000},
|
|
{"timestamp": "2026-01-05T09:40:00-05:00", "open": 101.8, "high": 102.5, "low": 101.7, "close": 102.0, "volume": 1000},
|
|
{"timestamp": "2026-01-05T09:45:00-05:00", "open": 102.0, "high": 102.4, "low": 101.9, "close": 102.2, "volume": 1000},
|
|
{"timestamp": "2026-01-05T09:50:00-05:00", "open": 102.2, "high": 102.7, "low": 102.0, "close": 102.4, "volume": 1000},
|
|
{"timestamp": "2026-01-05T15:55:00-05:00", "open": 102.0, "high": 103.0, "low": 101.9, "close": 102.8, "volume": 1000},
|
|
]
|
|
all_intraday = {"2026-01-05": {"AAA": bars}}
|
|
enrichment = _enrichment_for("AAA")
|
|
base_kwargs = dict(
|
|
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,
|
|
min_abs_gap_pct=None,
|
|
max_candidates=5,
|
|
min_candidates_to_trade=1,
|
|
risk_per_trade_pct=0.1,
|
|
max_position_pct=1.0,
|
|
daily_max_loss_pct=1.0,
|
|
max_stops_per_day=99,
|
|
compound_returns=True,
|
|
daily_budget_reset=False,
|
|
settlement_days=0,
|
|
single_trade_loss_cap_pct=0.05,
|
|
)
|
|
grown_state = ORBSimulationState(equity=20_000.0, peak_equity=20_000.0)
|
|
|
|
initial_results, _ = run_orb_simulation_with_state(
|
|
all_intraday,
|
|
["2026-01-05"],
|
|
ORBStrategyParams(**base_kwargs, single_trade_loss_cap_basis="initial"),
|
|
enrichment,
|
|
state=grown_state,
|
|
)
|
|
equity_results, _ = run_orb_simulation_with_state(
|
|
all_intraday,
|
|
["2026-01-05"],
|
|
ORBStrategyParams(**base_kwargs, single_trade_loss_cap_basis="equity"),
|
|
enrichment,
|
|
state=grown_state,
|
|
)
|
|
|
|
assert equity_results[0].capital_deployed > initial_results[0].capital_deployed * 1.8
|
|
|
|
|
|
def test_chunked_orb_simulation_preserves_drawdown_governor_peak() -> 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.1,
|
|
max_position_pct=1.0,
|
|
daily_max_loss_pct=1.0,
|
|
max_stops_per_day=99,
|
|
settlement_days=0,
|
|
daily_budget_reset=True,
|
|
drawdown_governor_threshold=0.05,
|
|
drawdown_governor_min_scale=0.5,
|
|
)
|
|
|
|
def winner_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": 103.0, "low": 101.4, "close": 102.8, "volume": 1000},
|
|
]
|
|
|
|
def loser_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.0, "low": 99.8, "close": 100.0, "volume": 1000},
|
|
{"timestamp": f"{day}T09:40:00-05:00", "open": 100.0, "high": 100.1, "low": 98.8, "close": 99.0, "volume": 1000},
|
|
{"timestamp": f"{day}T15:55:00-05:00", "open": 99.0, "high": 99.2, "low": 98.9, "close": 99.1, "volume": 1000},
|
|
]
|
|
|
|
trading_days = ["2026-01-05", "2026-01-06", "2026-01-07"]
|
|
all_intraday = {
|
|
"2026-01-05": {"AAA": winner_bars("2026-01-05")},
|
|
"2026-01-06": {"AAA": loser_bars("2026-01-06")},
|
|
"2026-01-07": {"AAA": winner_bars("2026-01-07")},
|
|
}
|
|
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 round(combined[2].capital_deployed, 6) == round(single[2].capital_deployed, 6)
|
|
assert round(combined[2].daily_pnl, 6) == round(single[2].daily_pnl, 6)
|
|
|
|
|
|
def test_market_orb_quality_scaler_adjusts_day_sizing() -> None:
|
|
params = ORBStrategyParams(
|
|
engine_family="gainers_leader",
|
|
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,
|
|
min_abs_gap_pct=0.0,
|
|
min_premarket_dollar_vol=0.0,
|
|
max_gap_pct=None,
|
|
max_candidates=20,
|
|
min_candidates_to_trade=1,
|
|
risk_per_trade_pct=0.1,
|
|
max_position_pct=1.0,
|
|
daily_max_loss_pct=1.0,
|
|
max_stops_per_day=99,
|
|
daily_budget_reset=True,
|
|
market_orb_quality_ticker="SPY",
|
|
market_orb_quality_size_scale_low=0.4,
|
|
market_orb_quality_size_scale_high=0.6,
|
|
market_orb_quality_size_scale_min=0.8,
|
|
market_orb_quality_size_scale_max=1.2,
|
|
allow_doji_breakout=True,
|
|
)
|
|
|
|
aaa_bars = [
|
|
{"timestamp": "2026-01-05T09:30:00-05:00", "open": 100.0, "high": 101.0, "low": 99.8, "close": 100.9, "volume": 2000},
|
|
{"timestamp": "2026-01-05T09:35:00-05:00", "open": 100.9, "high": 101.4, "low": 100.8, "close": 101.3, "volume": 2000},
|
|
{"timestamp": "2026-01-05T09:40:00-05:00", "open": 101.3, "high": 101.5, "low": 101.1, "close": 101.4, "volume": 2000},
|
|
{"timestamp": "2026-01-05T09:45:00-05:00", "open": 101.4, "high": 101.6, "low": 101.2, "close": 101.5, "volume": 2000},
|
|
{"timestamp": "2026-01-05T15:55:00-05:00", "open": 101.5, "high": 103.0, "low": 101.4, "close": 102.8, "volume": 2000},
|
|
]
|
|
strong_spy = [
|
|
{"timestamp": "2026-01-05T09:30:00-05:00", "open": 500.0, "high": 501.0, "low": 499.5, "close": 500.9, "volume": 10000},
|
|
]
|
|
weak_spy = [
|
|
{"timestamp": "2026-01-05T09:30:00-05:00", "open": 500.0, "high": 501.0, "low": 499.5, "close": 499.8, "volume": 10000},
|
|
]
|
|
enrichment = _enrichment_for("AAA")
|
|
|
|
strong = simulate_orb_day(
|
|
{"AAA": aaa_bars, "SPY": strong_spy},
|
|
"2026-01-05",
|
|
params,
|
|
enrichment,
|
|
equity=10_000.0,
|
|
)
|
|
weak = simulate_orb_day(
|
|
{"AAA": aaa_bars, "SPY": weak_spy},
|
|
"2026-01-05",
|
|
params,
|
|
enrichment,
|
|
equity=10_000.0,
|
|
)
|
|
|
|
assert strong.market_orb_quality_scaler == pytest.approx(1.2)
|
|
assert weak.market_orb_quality_scaler == pytest.approx(0.8)
|
|
assert strong.market_orb_quality_close_location == pytest.approx((500.9 - 499.5) / (501.0 - 499.5))
|
|
assert weak.market_orb_quality_close_location == pytest.approx((499.8 - 499.5) / (501.0 - 499.5))
|
|
assert strong.capital_deployed > weak.capital_deployed
|
|
assert strong.daily_pnl > weak.daily_pnl
|
|
|
|
|
|
def test_conditional_confirmation_activates_only_on_red_market_open() -> None:
|
|
params = ORBStrategyParams(
|
|
engine_family="gainers_leader",
|
|
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,
|
|
min_abs_gap_pct=0.0,
|
|
min_premarket_dollar_vol=0.0,
|
|
max_gap_pct=None,
|
|
max_candidates=20,
|
|
min_candidates_to_trade=1,
|
|
risk_per_trade_pct=0.1,
|
|
max_position_pct=1.0,
|
|
daily_max_loss_pct=1.0,
|
|
max_stops_per_day=99,
|
|
daily_budget_reset=True,
|
|
allow_doji_breakout=True,
|
|
conditional_confirmation_ticker="QQQ",
|
|
conditional_confirmation_below_return_pct=0.0,
|
|
)
|
|
|
|
aaa_bars = [
|
|
{"timestamp": "2026-01-05T09:30:00-05:00", "open": 100.0, "high": 101.0, "low": 99.5, "close": 100.8, "volume": 2000},
|
|
{"timestamp": "2026-01-05T09:35:00-05:00", "open": 100.8, "high": 101.2, "low": 100.7, "close": 101.1, "volume": 2000},
|
|
{"timestamp": "2026-01-05T09:40:00-05:00", "open": 101.1, "high": 101.2, "low": 100.7, "close": 100.9, "volume": 2000},
|
|
{"timestamp": "2026-01-05T09:45:00-05:00", "open": 100.9, "high": 101.0, "low": 100.8, "close": 100.95, "volume": 2000},
|
|
{"timestamp": "2026-01-05T09:50:00-05:00", "open": 100.95, "high": 101.0, "low": 100.85, "close": 100.9, "volume": 2000},
|
|
{"timestamp": "2026-01-05T15:55:00-05:00", "open": 100.9, "high": 101.0, "low": 100.8, "close": 100.9, "volume": 2000},
|
|
]
|
|
green_qqq = [
|
|
{"timestamp": "2026-01-05T09:30:00-05:00", "open": 500.0, "high": 501.0, "low": 499.5, "close": 500.8, "volume": 10000},
|
|
]
|
|
red_qqq = [
|
|
{"timestamp": "2026-01-05T09:30:00-05:00", "open": 500.0, "high": 501.0, "low": 499.5, "close": 499.8, "volume": 10000},
|
|
]
|
|
enrichment = _enrichment_for("AAA")
|
|
|
|
green = simulate_orb_day(
|
|
{"AAA": aaa_bars, "QQQ": green_qqq},
|
|
"2026-01-05",
|
|
params,
|
|
enrichment,
|
|
equity=10_000.0,
|
|
)
|
|
red = simulate_orb_day(
|
|
{"AAA": aaa_bars, "QQQ": red_qqq},
|
|
"2026-01-05",
|
|
params,
|
|
enrichment,
|
|
equity=10_000.0,
|
|
)
|
|
|
|
assert green.conditional_confirmation_active is False
|
|
assert len(green.trades) == 1
|
|
assert red.conditional_confirmation_active is True
|
|
assert red.trades == []
|
|
|
|
|
|
def test_market_orb_quality_divergence_guard_reduces_day_sizing() -> None:
|
|
params = ORBStrategyParams(
|
|
engine_family="gainers_leader",
|
|
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,
|
|
min_abs_gap_pct=0.0,
|
|
min_premarket_dollar_vol=0.0,
|
|
max_gap_pct=None,
|
|
max_candidates=20,
|
|
min_candidates_to_trade=1,
|
|
risk_per_trade_pct=0.1,
|
|
max_position_pct=1.0,
|
|
daily_max_loss_pct=1.0,
|
|
max_stops_per_day=99,
|
|
daily_budget_reset=True,
|
|
allow_doji_breakout=True,
|
|
market_orb_quality_ticker="SPY",
|
|
market_orb_quality_secondary_ticker="QQQ",
|
|
market_orb_quality_primary_strong_above=0.75,
|
|
market_orb_quality_secondary_weak_below=0.5,
|
|
market_orb_quality_divergence_scale=0.5,
|
|
)
|
|
|
|
aaa_bars = [
|
|
{"timestamp": "2026-01-05T09:30:00-05:00", "open": 100.0, "high": 101.0, "low": 99.5, "close": 100.8, "volume": 2000},
|
|
{"timestamp": "2026-01-05T09:35:00-05:00", "open": 100.8, "high": 101.4, "low": 100.7, "close": 101.2, "volume": 2000},
|
|
{"timestamp": "2026-01-05T09:40:00-05:00", "open": 101.2, "high": 101.6, "low": 101.1, "close": 101.5, "volume": 2000},
|
|
{"timestamp": "2026-01-05T09:45:00-05:00", "open": 101.5, "high": 101.8, "low": 101.4, "close": 101.7, "volume": 2000},
|
|
{"timestamp": "2026-01-05T09:50:00-05:00", "open": 101.7, "high": 101.9, "low": 101.6, "close": 101.8, "volume": 2000},
|
|
{"timestamp": "2026-01-05T15:55:00-05:00", "open": 101.5, "high": 103.0, "low": 101.4, "close": 102.8, "volume": 2000},
|
|
]
|
|
strong_spy = [
|
|
{"timestamp": "2026-01-05T09:30:00-05:00", "open": 500.0, "high": 501.0, "low": 499.5, "close": 500.9, "volume": 10000},
|
|
]
|
|
weak_qqq = [
|
|
{"timestamp": "2026-01-05T09:30:00-05:00", "open": 400.0, "high": 400.8, "low": 399.9, "close": 400.1, "volume": 10000},
|
|
]
|
|
strong_qqq = [
|
|
{"timestamp": "2026-01-05T09:30:00-05:00", "open": 400.0, "high": 401.0, "low": 399.8, "close": 400.9, "volume": 10000},
|
|
]
|
|
enrichment = _enrichment_for("AAA")
|
|
|
|
aligned = simulate_orb_day(
|
|
{"AAA": aaa_bars, "SPY": strong_spy, "QQQ": strong_qqq},
|
|
"2026-01-05",
|
|
params,
|
|
enrichment,
|
|
equity=10_000.0,
|
|
)
|
|
diverged = simulate_orb_day(
|
|
{"AAA": aaa_bars, "SPY": strong_spy, "QQQ": weak_qqq},
|
|
"2026-01-05",
|
|
params,
|
|
enrichment,
|
|
equity=10_000.0,
|
|
)
|
|
|
|
assert aligned.market_orb_quality_divergence_active is False
|
|
assert diverged.market_orb_quality_divergence_active is True
|
|
assert diverged.market_orb_quality_scaler == pytest.approx(0.5)
|
|
assert diverged.capital_deployed < aligned.capital_deployed
|
|
|
|
|
|
def test_market_orb_quality_veto_can_feed_rolling_loss_governor() -> None:
|
|
params = ORBStrategyParams(
|
|
engine_family="gainers_leader",
|
|
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,
|
|
min_abs_gap_pct=0.0,
|
|
min_premarket_dollar_vol=0.0,
|
|
max_gap_pct=None,
|
|
max_candidates=20,
|
|
min_candidates_to_trade=1,
|
|
risk_per_trade_pct=0.1,
|
|
max_position_pct=1.0,
|
|
daily_max_loss_pct=1.0,
|
|
max_stops_per_day=99,
|
|
daily_budget_reset=True,
|
|
rolling_loss_days=1,
|
|
rolling_loss_threshold=-0.01,
|
|
market_orb_quality_ticker="SPY",
|
|
market_orb_quality_secondary_ticker="QQQ",
|
|
market_orb_quality_primary_strong_above=0.75,
|
|
market_orb_quality_secondary_weak_below=0.50,
|
|
market_orb_quality_divergence_scale=0.0,
|
|
market_orb_quality_veto_rolling_loss_pct=-0.05,
|
|
)
|
|
|
|
bars = [
|
|
{"timestamp": "2026-01-05T09:30:00-05:00", "open": 100.0, "high": 101.0, "low": 99.5, "close": 100.8, "volume": 2000},
|
|
{"timestamp": "2026-01-05T09:35:00-05:00", "open": 100.8, "high": 101.2, "low": 100.7, "close": 101.1, "volume": 2000},
|
|
{"timestamp": "2026-01-05T09:40:00-05:00", "open": 101.1, "high": 101.5, "low": 101.0, "close": 101.4, "volume": 2000},
|
|
{"timestamp": "2026-01-05T09:45:00-05:00", "open": 101.4, "high": 101.6, "low": 101.2, "close": 101.5, "volume": 2000},
|
|
{"timestamp": "2026-01-05T09:50:00-05:00", "open": 101.5, "high": 101.8, "low": 101.4, "close": 101.7, "volume": 2000},
|
|
{"timestamp": "2026-01-05T15:55:00-05:00", "open": 101.7, "high": 102.0, "low": 101.2, "close": 101.8, "volume": 2000},
|
|
]
|
|
day2_bars = [
|
|
{**bar, "timestamp": bar["timestamp"].replace("2026-01-05", "2026-01-06")}
|
|
for bar in bars
|
|
]
|
|
strong_spy = [
|
|
{"timestamp": "2026-01-05T09:30:00-05:00", "open": 500.0, "high": 501.0, "low": 499.5, "close": 500.9, "volume": 10000},
|
|
]
|
|
weak_qqq = [
|
|
{"timestamp": "2026-01-05T09:30:00-05:00", "open": 400.0, "high": 401.0, "low": 399.8, "close": 400.2, "volume": 10000},
|
|
]
|
|
calm_spy_day2 = [
|
|
{"timestamp": "2026-01-06T09:30:00-05:00", "open": 500.0, "high": 501.0, "low": 499.5, "close": 500.6, "volume": 10000},
|
|
]
|
|
calm_qqq_day2 = [
|
|
{"timestamp": "2026-01-06T09:30:00-05:00", "open": 400.0, "high": 401.0, "low": 399.8, "close": 400.6, "volume": 10000},
|
|
]
|
|
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 ("2026-01-05", "2026-01-06")
|
|
}
|
|
}
|
|
|
|
results = run_orb_simulation(
|
|
{
|
|
"2026-01-05": {"AAA": bars, "SPY": strong_spy, "QQQ": weak_qqq},
|
|
"2026-01-06": {"AAA": day2_bars, "SPY": calm_spy_day2, "QQQ": calm_qqq_day2},
|
|
},
|
|
["2026-01-05", "2026-01-06"],
|
|
params,
|
|
enrichment,
|
|
)
|
|
|
|
assert results[0].daily_pnl == 0.0
|
|
assert results[0].rolling_loss_synthetic_pnl == pytest.approx(-500.0)
|
|
assert results[1].skip_reason == "rolling_loss"
|
|
|
|
|
|
def test_market_orb_quality_divergence_guard_can_cap_total_trades() -> None:
|
|
params = ORBStrategyParams(
|
|
engine_family="gainers_leader",
|
|
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,
|
|
min_abs_gap_pct=0.0,
|
|
min_premarket_dollar_vol=0.0,
|
|
max_gap_pct=None,
|
|
max_candidates=20,
|
|
min_candidates_to_trade=1,
|
|
risk_per_trade_pct=0.1,
|
|
max_position_pct=1.0,
|
|
daily_max_loss_pct=1.0,
|
|
max_stops_per_day=99,
|
|
daily_budget_reset=True,
|
|
allow_doji_breakout=True,
|
|
market_orb_quality_ticker="SPY",
|
|
market_orb_quality_secondary_ticker="QQQ",
|
|
market_orb_quality_primary_strong_above=0.75,
|
|
market_orb_quality_secondary_weak_below=0.5,
|
|
market_orb_quality_divergence_max_trades=1,
|
|
)
|
|
|
|
bars = [
|
|
{"timestamp": "2026-01-05T09:30:00-05:00", "open": 100.0, "high": 101.0, "low": 99.5, "close": 100.8, "volume": 2000},
|
|
{"timestamp": "2026-01-05T09:35:00-05:00", "open": 100.8, "high": 101.4, "low": 100.7, "close": 101.2, "volume": 2000},
|
|
{"timestamp": "2026-01-05T09:40:00-05:00", "open": 101.2, "high": 101.6, "low": 101.1, "close": 101.5, "volume": 2000},
|
|
{"timestamp": "2026-01-05T09:45:00-05:00", "open": 101.5, "high": 101.8, "low": 101.4, "close": 101.7, "volume": 2000},
|
|
{"timestamp": "2026-01-05T09:50:00-05:00", "open": 101.7, "high": 101.9, "low": 101.6, "close": 101.8, "volume": 2000},
|
|
{"timestamp": "2026-01-05T15:55:00-05:00", "open": 101.8, "high": 102.5, "low": 101.7, "close": 102.2, "volume": 2000},
|
|
]
|
|
strong_spy = [
|
|
{"timestamp": "2026-01-05T09:30:00-05:00", "open": 500.0, "high": 501.0, "low": 499.5, "close": 500.9, "volume": 10000},
|
|
]
|
|
weak_qqq = [
|
|
{"timestamp": "2026-01-05T09:30:00-05:00", "open": 400.0, "high": 400.8, "low": 399.9, "close": 400.1, "volume": 10000},
|
|
]
|
|
strong_qqq = [
|
|
{"timestamp": "2026-01-05T09:30:00-05:00", "open": 400.0, "high": 401.0, "low": 399.8, "close": 400.9, "volume": 10000},
|
|
]
|
|
enrichment = _enrichment_for("AAA", "BBB")
|
|
|
|
aligned = simulate_orb_day(
|
|
{"AAA": bars, "BBB": bars, "SPY": strong_spy, "QQQ": strong_qqq},
|
|
"2026-01-05",
|
|
params,
|
|
enrichment,
|
|
equity=10_000.0,
|
|
)
|
|
diverged = simulate_orb_day(
|
|
{"AAA": bars, "BBB": bars, "SPY": strong_spy, "QQQ": weak_qqq},
|
|
"2026-01-05",
|
|
params,
|
|
enrichment,
|
|
equity=10_000.0,
|
|
)
|
|
|
|
assert aligned.market_orb_quality_divergence_max_trades_active is False
|
|
assert len(aligned.trades) == 2
|
|
assert diverged.market_orb_quality_divergence_max_trades_active is True
|
|
assert len(diverged.trades) == 1
|
|
|
|
|
|
def test_market_orb_quality_primary_weak_secondary_strong_guard_can_veto_day() -> None:
|
|
params = ORBStrategyParams(
|
|
engine_family="gainers_leader",
|
|
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,
|
|
min_abs_gap_pct=0.0,
|
|
min_premarket_dollar_vol=0.0,
|
|
max_gap_pct=None,
|
|
max_candidates=20,
|
|
min_candidates_to_trade=1,
|
|
risk_per_trade_pct=0.1,
|
|
max_position_pct=1.0,
|
|
daily_max_loss_pct=1.0,
|
|
max_stops_per_day=99,
|
|
daily_budget_reset=True,
|
|
allow_doji_breakout=True,
|
|
market_orb_quality_ticker="SPY",
|
|
market_orb_quality_secondary_ticker="QQQ",
|
|
market_orb_quality_primary_weak_above=0.20,
|
|
market_orb_quality_primary_weak_below=0.30,
|
|
market_orb_quality_secondary_strong_above=0.60,
|
|
market_orb_quality_primary_weak_secondary_strong_scale=0.0,
|
|
)
|
|
|
|
bars = [
|
|
{"timestamp": "2026-01-05T09:30:00-05:00", "open": 100.0, "high": 101.0, "low": 99.5, "close": 100.8, "volume": 2000},
|
|
{"timestamp": "2026-01-05T09:35:00-05:00", "open": 100.8, "high": 101.4, "low": 100.7, "close": 101.2, "volume": 2000},
|
|
{"timestamp": "2026-01-05T09:40:00-05:00", "open": 101.2, "high": 101.6, "low": 101.1, "close": 101.5, "volume": 2000},
|
|
{"timestamp": "2026-01-05T09:45:00-05:00", "open": 101.5, "high": 101.8, "low": 101.4, "close": 101.7, "volume": 2000},
|
|
{"timestamp": "2026-01-05T09:50:00-05:00", "open": 101.7, "high": 101.9, "low": 101.6, "close": 101.8, "volume": 2000},
|
|
{"timestamp": "2026-01-05T15:55:00-05:00", "open": 101.5, "high": 103.0, "low": 101.4, "close": 102.8, "volume": 2000},
|
|
]
|
|
neutral_spy = [
|
|
{"timestamp": "2026-01-05T09:30:00-05:00", "open": 500.0, "high": 501.0, "low": 499.0, "close": 500.6, "volume": 10000},
|
|
]
|
|
weak_spy = [
|
|
{"timestamp": "2026-01-05T09:30:00-05:00", "open": 500.0, "high": 501.0, "low": 499.0, "close": 499.5, "volume": 10000},
|
|
]
|
|
strong_qqq = [
|
|
{"timestamp": "2026-01-05T09:30:00-05:00", "open": 400.0, "high": 401.0, "low": 399.0, "close": 400.4, "volume": 10000},
|
|
]
|
|
enrichment = _enrichment_for("AAA")
|
|
|
|
aligned = simulate_orb_day(
|
|
{"AAA": bars, "SPY": neutral_spy, "QQQ": strong_qqq},
|
|
"2026-01-05",
|
|
params,
|
|
enrichment,
|
|
equity=10_000.0,
|
|
)
|
|
split_tape = simulate_orb_day(
|
|
{"AAA": bars, "SPY": weak_spy, "QQQ": strong_qqq},
|
|
"2026-01-05",
|
|
params,
|
|
enrichment,
|
|
equity=10_000.0,
|
|
)
|
|
|
|
assert aligned.market_orb_quality_primary_weak_secondary_strong_active is False
|
|
assert len(aligned.trades) == 1
|
|
assert split_tape.market_orb_quality_primary_weak_secondary_strong_active is True
|
|
assert split_tape.market_orb_quality_scaler == pytest.approx(0.0)
|
|
assert split_tape.trades == []
|
|
|
|
|
|
def test_market_orb_quality_primary_lag_secondary_lead_guard_can_veto_day() -> None:
|
|
params = ORBStrategyParams(
|
|
engine_family="gainers_leader",
|
|
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,
|
|
min_abs_gap_pct=0.0,
|
|
min_premarket_dollar_vol=0.0,
|
|
max_gap_pct=None,
|
|
max_candidates=20,
|
|
min_candidates_to_trade=1,
|
|
risk_per_trade_pct=0.1,
|
|
max_position_pct=1.0,
|
|
daily_max_loss_pct=1.0,
|
|
max_stops_per_day=99,
|
|
daily_budget_reset=True,
|
|
allow_doji_breakout=True,
|
|
market_orb_quality_ticker="SPY",
|
|
market_orb_quality_secondary_ticker="QQQ",
|
|
market_orb_quality_primary_lag_above=0.40,
|
|
market_orb_quality_primary_lag_below=0.50,
|
|
market_orb_quality_secondary_lead_above=0.70,
|
|
market_orb_quality_secondary_lead_below=0.80,
|
|
market_orb_quality_primary_lag_secondary_lead_scale=0.0,
|
|
)
|
|
|
|
bars = [
|
|
{"timestamp": "2026-01-05T09:30:00-05:00", "open": 100.0, "high": 101.0, "low": 99.5, "close": 100.8, "volume": 2000},
|
|
{"timestamp": "2026-01-05T09:35:00-05:00", "open": 100.8, "high": 101.2, "low": 100.7, "close": 101.1, "volume": 2000},
|
|
{"timestamp": "2026-01-05T09:40:00-05:00", "open": 101.1, "high": 101.5, "low": 101.0, "close": 101.4, "volume": 2000},
|
|
{"timestamp": "2026-01-05T09:45:00-05:00", "open": 101.4, "high": 101.6, "low": 101.2, "close": 101.5, "volume": 2000},
|
|
{"timestamp": "2026-01-05T09:50:00-05:00", "open": 101.5, "high": 101.8, "low": 101.4, "close": 101.7, "volume": 2000},
|
|
{"timestamp": "2026-01-05T15:55:00-05:00", "open": 101.7, "high": 102.0, "low": 101.2, "close": 101.8, "volume": 2000},
|
|
]
|
|
aligned_spy = [
|
|
{"timestamp": "2026-01-05T09:30:00-05:00", "open": 500.0, "high": 501.0, "low": 499.5, "close": 500.8, "volume": 10000},
|
|
]
|
|
aligned_qqq = [
|
|
{"timestamp": "2026-01-05T09:30:00-05:00", "open": 400.0, "high": 401.0, "low": 399.8, "close": 400.9, "volume": 10000},
|
|
]
|
|
lag_spy = [
|
|
{"timestamp": "2026-01-05T09:30:00-05:00", "open": 500.0, "high": 501.0, "low": 499.0, "close": 499.9, "volume": 10000},
|
|
]
|
|
lead_qqq = [
|
|
{"timestamp": "2026-01-05T09:30:00-05:00", "open": 400.0, "high": 401.0, "low": 399.0, "close": 400.5, "volume": 10000},
|
|
]
|
|
enrichment = _enrichment_for("AAA", "BBB")
|
|
|
|
aligned = simulate_orb_day(
|
|
{"AAA": bars, "BBB": bars, "SPY": aligned_spy, "QQQ": aligned_qqq},
|
|
"2026-01-05",
|
|
params,
|
|
enrichment,
|
|
equity=10_000.0,
|
|
)
|
|
split_tape = simulate_orb_day(
|
|
{"AAA": bars, "BBB": bars, "SPY": lag_spy, "QQQ": lead_qqq},
|
|
"2026-01-05",
|
|
params,
|
|
enrichment,
|
|
equity=10_000.0,
|
|
)
|
|
|
|
assert aligned.market_orb_quality_primary_lag_secondary_lead_active is False
|
|
assert len(aligned.trades) == 2
|
|
assert split_tape.market_orb_quality_primary_lag_secondary_lead_active is True
|
|
assert split_tape.market_orb_quality_scaler == pytest.approx(0.0)
|
|
assert split_tape.trades == []
|
|
|
|
|
|
def test_market_orb_quality_divergence_guard_can_target_mild_split_tape() -> None:
|
|
params = ORBStrategyParams(
|
|
engine_family="gainers_leader",
|
|
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,
|
|
min_abs_gap_pct=0.0,
|
|
min_premarket_dollar_vol=0.0,
|
|
max_gap_pct=None,
|
|
max_candidates=20,
|
|
min_candidates_to_trade=1,
|
|
risk_per_trade_pct=0.1,
|
|
max_position_pct=1.0,
|
|
daily_max_loss_pct=1.0,
|
|
max_stops_per_day=99,
|
|
daily_budget_reset=True,
|
|
allow_doji_breakout=True,
|
|
market_orb_quality_ticker="SPY",
|
|
market_orb_quality_secondary_ticker="QQQ",
|
|
market_orb_quality_primary_strong_above=0.75,
|
|
market_orb_quality_secondary_weak_above=0.4,
|
|
market_orb_quality_secondary_weak_below=0.5,
|
|
market_orb_quality_divergence_scale=0.5,
|
|
)
|
|
|
|
aaa_bars = [
|
|
{"timestamp": "2026-01-05T09:30:00-05:00", "open": 100.0, "high": 101.0, "low": 99.5, "close": 100.8, "volume": 2000},
|
|
{"timestamp": "2026-01-05T09:35:00-05:00", "open": 100.8, "high": 101.4, "low": 100.7, "close": 101.2, "volume": 2000},
|
|
{"timestamp": "2026-01-05T09:40:00-05:00", "open": 101.2, "high": 101.6, "low": 101.1, "close": 101.5, "volume": 2000},
|
|
{"timestamp": "2026-01-05T09:45:00-05:00", "open": 101.5, "high": 101.8, "low": 101.4, "close": 101.7, "volume": 2000},
|
|
{"timestamp": "2026-01-05T09:50:00-05:00", "open": 101.7, "high": 101.9, "low": 101.6, "close": 101.8, "volume": 2000},
|
|
{"timestamp": "2026-01-05T15:55:00-05:00", "open": 101.5, "high": 103.0, "low": 101.4, "close": 102.8, "volume": 2000},
|
|
]
|
|
strong_spy = [
|
|
{"timestamp": "2026-01-05T09:30:00-05:00", "open": 500.0, "high": 501.0, "low": 499.5, "close": 500.9, "volume": 10000},
|
|
]
|
|
mild_weak_qqq = [
|
|
{"timestamp": "2026-01-05T09:30:00-05:00", "open": 400.0, "high": 400.9, "low": 400.0, "close": 400.41, "volume": 10000},
|
|
]
|
|
deep_weak_qqq = [
|
|
{"timestamp": "2026-01-05T09:30:00-05:00", "open": 400.0, "high": 400.8, "low": 399.9, "close": 400.1, "volume": 10000},
|
|
]
|
|
enrichment = _enrichment_for("AAA")
|
|
|
|
mild = simulate_orb_day(
|
|
{"AAA": aaa_bars, "SPY": strong_spy, "QQQ": mild_weak_qqq},
|
|
"2026-01-05",
|
|
params,
|
|
enrichment,
|
|
equity=10_000.0,
|
|
)
|
|
deep = simulate_orb_day(
|
|
{"AAA": aaa_bars, "SPY": strong_spy, "QQQ": deep_weak_qqq},
|
|
"2026-01-05",
|
|
params,
|
|
enrichment,
|
|
equity=10_000.0,
|
|
)
|
|
|
|
assert mild.market_orb_quality_divergence_active is True
|
|
assert deep.market_orb_quality_divergence_active is False
|
|
|
|
|
|
def test_market_orb_quality_joint_weak_guard_can_require_confirmation() -> None:
|
|
params = ORBStrategyParams(
|
|
engine_family="gainers_leader",
|
|
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,
|
|
min_abs_gap_pct=0.0,
|
|
min_premarket_dollar_vol=0.0,
|
|
max_gap_pct=None,
|
|
max_candidates=20,
|
|
min_candidates_to_trade=1,
|
|
risk_per_trade_pct=0.1,
|
|
max_position_pct=1.0,
|
|
daily_max_loss_pct=1.0,
|
|
max_stops_per_day=99,
|
|
daily_budget_reset=True,
|
|
allow_doji_breakout=True,
|
|
market_orb_quality_ticker="SPY",
|
|
market_orb_quality_secondary_ticker="QQQ",
|
|
market_orb_quality_joint_weak_primary_below=0.2,
|
|
market_orb_quality_joint_weak_secondary_below=0.2,
|
|
market_orb_quality_joint_weak_require_confirmation=True,
|
|
)
|
|
|
|
aaa_bars = [
|
|
{"timestamp": "2026-01-05T09:30:00-05:00", "open": 100.0, "high": 101.0, "low": 99.5, "close": 100.8, "volume": 2000},
|
|
{"timestamp": "2026-01-05T09:35:00-05:00", "open": 100.8, "high": 101.2, "low": 100.7, "close": 101.1, "volume": 2000},
|
|
{"timestamp": "2026-01-05T09:40:00-05:00", "open": 101.1, "high": 101.2, "low": 100.7, "close": 100.9, "volume": 2000},
|
|
{"timestamp": "2026-01-05T09:45:00-05:00", "open": 100.9, "high": 101.0, "low": 100.8, "close": 100.95, "volume": 2000},
|
|
{"timestamp": "2026-01-05T09:50:00-05:00", "open": 100.95, "high": 101.0, "low": 100.85, "close": 100.9, "volume": 2000},
|
|
{"timestamp": "2026-01-05T15:55:00-05:00", "open": 100.9, "high": 101.0, "low": 100.8, "close": 100.9, "volume": 2000},
|
|
]
|
|
strong_spy = [
|
|
{"timestamp": "2026-01-05T09:30:00-05:00", "open": 500.0, "high": 501.0, "low": 499.5, "close": 500.8, "volume": 10000},
|
|
]
|
|
strong_qqq = [
|
|
{"timestamp": "2026-01-05T09:30:00-05:00", "open": 400.0, "high": 401.0, "low": 399.8, "close": 400.9, "volume": 10000},
|
|
]
|
|
weak_spy = [
|
|
{"timestamp": "2026-01-05T09:30:00-05:00", "open": 500.0, "high": 501.0, "low": 499.5, "close": 499.6, "volume": 10000},
|
|
]
|
|
weak_qqq = [
|
|
{"timestamp": "2026-01-05T09:30:00-05:00", "open": 400.0, "high": 400.8, "low": 399.9, "close": 399.95, "volume": 10000},
|
|
]
|
|
enrichment = _enrichment_for("AAA")
|
|
|
|
aligned = simulate_orb_day(
|
|
{"AAA": aaa_bars, "SPY": strong_spy, "QQQ": strong_qqq},
|
|
"2026-01-05",
|
|
params,
|
|
enrichment,
|
|
equity=10_000.0,
|
|
)
|
|
joint_weak = simulate_orb_day(
|
|
{"AAA": aaa_bars, "SPY": weak_spy, "QQQ": weak_qqq},
|
|
"2026-01-05",
|
|
params,
|
|
enrichment,
|
|
equity=10_000.0,
|
|
)
|
|
|
|
assert aligned.market_orb_quality_joint_weak_active is False
|
|
assert aligned.conditional_confirmation_active is False
|
|
assert len(aligned.trades) == 1
|
|
assert joint_weak.market_orb_quality_joint_weak_active is True
|
|
assert joint_weak.conditional_confirmation_active is True
|
|
assert joint_weak.trades == []
|
|
|
|
|
|
def test_market_orb_quality_joint_weak_guard_can_reduce_day_sizing() -> None:
|
|
params = ORBStrategyParams(
|
|
engine_family="gainers_leader",
|
|
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,
|
|
min_abs_gap_pct=0.0,
|
|
min_premarket_dollar_vol=0.0,
|
|
max_gap_pct=None,
|
|
max_candidates=20,
|
|
min_candidates_to_trade=1,
|
|
risk_per_trade_pct=0.1,
|
|
max_position_pct=1.0,
|
|
daily_max_loss_pct=1.0,
|
|
max_stops_per_day=99,
|
|
daily_budget_reset=True,
|
|
allow_doji_breakout=True,
|
|
market_orb_quality_ticker="SPY",
|
|
market_orb_quality_secondary_ticker="QQQ",
|
|
market_orb_quality_joint_weak_primary_below=0.2,
|
|
market_orb_quality_joint_weak_secondary_below=0.2,
|
|
market_orb_quality_joint_weak_scale=0.5,
|
|
)
|
|
|
|
aaa_bars = [
|
|
{"timestamp": "2026-01-05T09:30:00-05:00", "open": 100.0, "high": 101.0, "low": 99.5, "close": 100.8, "volume": 2000},
|
|
{"timestamp": "2026-01-05T09:35:00-05:00", "open": 100.8, "high": 101.4, "low": 100.7, "close": 101.2, "volume": 2000},
|
|
{"timestamp": "2026-01-05T09:40:00-05:00", "open": 101.2, "high": 101.6, "low": 101.1, "close": 101.5, "volume": 2000},
|
|
{"timestamp": "2026-01-05T09:45:00-05:00", "open": 101.5, "high": 101.8, "low": 101.4, "close": 101.7, "volume": 2000},
|
|
{"timestamp": "2026-01-05T09:50:00-05:00", "open": 101.7, "high": 101.9, "low": 101.6, "close": 101.8, "volume": 2000},
|
|
{"timestamp": "2026-01-05T15:55:00-05:00", "open": 101.5, "high": 103.0, "low": 101.4, "close": 102.8, "volume": 2000},
|
|
]
|
|
strong_spy = [
|
|
{"timestamp": "2026-01-05T09:30:00-05:00", "open": 500.0, "high": 501.0, "low": 499.5, "close": 500.8, "volume": 10000},
|
|
]
|
|
strong_qqq = [
|
|
{"timestamp": "2026-01-05T09:30:00-05:00", "open": 400.0, "high": 401.0, "low": 399.8, "close": 400.9, "volume": 10000},
|
|
]
|
|
weak_spy = [
|
|
{"timestamp": "2026-01-05T09:30:00-05:00", "open": 500.0, "high": 501.0, "low": 499.5, "close": 499.6, "volume": 10000},
|
|
]
|
|
weak_qqq = [
|
|
{"timestamp": "2026-01-05T09:30:00-05:00", "open": 400.0, "high": 400.8, "low": 399.9, "close": 399.95, "volume": 10000},
|
|
]
|
|
enrichment = _enrichment_for("AAA")
|
|
|
|
aligned = simulate_orb_day(
|
|
{"AAA": aaa_bars, "SPY": strong_spy, "QQQ": strong_qqq},
|
|
"2026-01-05",
|
|
params,
|
|
enrichment,
|
|
equity=10_000.0,
|
|
)
|
|
joint_weak = simulate_orb_day(
|
|
{"AAA": aaa_bars, "SPY": weak_spy, "QQQ": weak_qqq},
|
|
"2026-01-05",
|
|
params,
|
|
enrichment,
|
|
equity=10_000.0,
|
|
)
|
|
|
|
assert aligned.market_orb_quality_joint_weak_active is False
|
|
assert joint_weak.market_orb_quality_joint_weak_active is True
|
|
assert joint_weak.market_orb_quality_scaler == pytest.approx(0.5)
|
|
assert joint_weak.capital_deployed < aligned.capital_deployed
|
|
|
|
|
|
def test_market_orb_quality_joint_weak_guard_can_cap_total_trades() -> None:
|
|
params = ORBStrategyParams(
|
|
engine_family="gainers_leader",
|
|
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,
|
|
min_abs_gap_pct=0.0,
|
|
min_premarket_dollar_vol=0.0,
|
|
max_gap_pct=None,
|
|
max_candidates=20,
|
|
min_candidates_to_trade=1,
|
|
risk_per_trade_pct=0.1,
|
|
max_position_pct=1.0,
|
|
daily_max_loss_pct=1.0,
|
|
max_stops_per_day=99,
|
|
daily_budget_reset=True,
|
|
allow_doji_breakout=True,
|
|
market_orb_quality_ticker="SPY",
|
|
market_orb_quality_secondary_ticker="QQQ",
|
|
market_orb_quality_joint_weak_primary_below=0.2,
|
|
market_orb_quality_joint_weak_secondary_below=0.2,
|
|
market_orb_quality_joint_weak_max_trades=1,
|
|
)
|
|
|
|
bars = [
|
|
{"timestamp": "2026-01-05T09:30:00-05:00", "open": 100.0, "high": 101.0, "low": 99.5, "close": 100.8, "volume": 2000},
|
|
{"timestamp": "2026-01-05T09:35:00-05:00", "open": 100.8, "high": 101.4, "low": 100.7, "close": 101.2, "volume": 2000},
|
|
{"timestamp": "2026-01-05T09:40:00-05:00", "open": 101.2, "high": 101.6, "low": 101.1, "close": 101.5, "volume": 2000},
|
|
{"timestamp": "2026-01-05T09:45:00-05:00", "open": 101.5, "high": 101.8, "low": 101.4, "close": 101.7, "volume": 2000},
|
|
{"timestamp": "2026-01-05T09:50:00-05:00", "open": 101.7, "high": 101.9, "low": 101.6, "close": 101.8, "volume": 2000},
|
|
{"timestamp": "2026-01-05T15:55:00-05:00", "open": 101.8, "high": 102.5, "low": 101.7, "close": 102.2, "volume": 2000},
|
|
]
|
|
strong_spy = [
|
|
{"timestamp": "2026-01-05T09:30:00-05:00", "open": 500.0, "high": 501.0, "low": 499.5, "close": 500.8, "volume": 10000},
|
|
]
|
|
strong_qqq = [
|
|
{"timestamp": "2026-01-05T09:30:00-05:00", "open": 400.0, "high": 401.0, "low": 399.8, "close": 400.9, "volume": 10000},
|
|
]
|
|
weak_spy = [
|
|
{"timestamp": "2026-01-05T09:30:00-05:00", "open": 500.0, "high": 501.0, "low": 499.5, "close": 499.6, "volume": 10000},
|
|
]
|
|
weak_qqq = [
|
|
{"timestamp": "2026-01-05T09:30:00-05:00", "open": 400.0, "high": 400.8, "low": 399.9, "close": 399.95, "volume": 10000},
|
|
]
|
|
enrichment = _enrichment_for("AAA", "BBB")
|
|
|
|
aligned = simulate_orb_day(
|
|
{"AAA": bars, "BBB": bars, "SPY": strong_spy, "QQQ": strong_qqq},
|
|
"2026-01-05",
|
|
params,
|
|
enrichment,
|
|
equity=10_000.0,
|
|
)
|
|
joint_weak = simulate_orb_day(
|
|
{"AAA": bars, "BBB": bars, "SPY": weak_spy, "QQQ": weak_qqq},
|
|
"2026-01-05",
|
|
params,
|
|
enrichment,
|
|
equity=10_000.0,
|
|
)
|
|
|
|
assert aligned.market_orb_quality_joint_weak_max_trades_active is False
|
|
assert len(aligned.trades) == 2
|
|
assert joint_weak.market_orb_quality_joint_weak_active is True
|
|
assert joint_weak.market_orb_quality_joint_weak_max_trades_active is True
|
|
assert len(joint_weak.trades) == 1
|
|
|
|
|
|
def test_market_orb_quality_joint_weak_guard_can_target_mild_weak_band() -> None:
|
|
params = ORBStrategyParams(
|
|
engine_family="gainers_leader",
|
|
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,
|
|
min_abs_gap_pct=0.0,
|
|
min_premarket_dollar_vol=0.0,
|
|
max_gap_pct=None,
|
|
max_candidates=20,
|
|
min_candidates_to_trade=1,
|
|
risk_per_trade_pct=0.1,
|
|
max_position_pct=1.0,
|
|
daily_max_loss_pct=1.0,
|
|
max_stops_per_day=99,
|
|
daily_budget_reset=True,
|
|
allow_doji_breakout=True,
|
|
market_orb_quality_ticker="SPY",
|
|
market_orb_quality_secondary_ticker="QQQ",
|
|
market_orb_quality_joint_weak_primary_above=0.05,
|
|
market_orb_quality_joint_weak_primary_below=0.15,
|
|
market_orb_quality_joint_weak_secondary_above=0.10,
|
|
market_orb_quality_joint_weak_secondary_below=0.15,
|
|
market_orb_quality_joint_weak_max_trades=1,
|
|
)
|
|
|
|
bars = [
|
|
{"timestamp": "2026-01-05T09:30:00-05:00", "open": 100.0, "high": 101.0, "low": 99.5, "close": 100.8, "volume": 2000},
|
|
{"timestamp": "2026-01-05T09:35:00-05:00", "open": 100.8, "high": 101.4, "low": 100.7, "close": 101.2, "volume": 2000},
|
|
{"timestamp": "2026-01-05T09:40:00-05:00", "open": 101.2, "high": 101.6, "low": 101.1, "close": 101.5, "volume": 2000},
|
|
{"timestamp": "2026-01-05T09:45:00-05:00", "open": 101.5, "high": 101.8, "low": 101.4, "close": 101.7, "volume": 2000},
|
|
{"timestamp": "2026-01-05T09:50:00-05:00", "open": 101.7, "high": 101.9, "low": 101.6, "close": 101.8, "volume": 2000},
|
|
{"timestamp": "2026-01-05T15:55:00-05:00", "open": 101.8, "high": 102.5, "low": 101.7, "close": 102.2, "volume": 2000},
|
|
]
|
|
mild_weak_spy = [
|
|
{"timestamp": "2026-01-05T09:30:00-05:00", "open": 500.0, "high": 500.5, "low": 499.5, "close": 499.62, "volume": 10000},
|
|
]
|
|
mild_weak_qqq = [
|
|
{"timestamp": "2026-01-05T09:30:00-05:00", "open": 400.0, "high": 400.4, "low": 399.8, "close": 399.88, "volume": 10000},
|
|
]
|
|
panic_spy = [
|
|
{"timestamp": "2026-01-05T09:30:00-05:00", "open": 500.0, "high": 500.5, "low": 499.5, "close": 499.50, "volume": 10000},
|
|
]
|
|
panic_qqq = [
|
|
{"timestamp": "2026-01-05T09:30:00-05:00", "open": 400.0, "high": 400.4, "low": 399.8, "close": 399.82, "volume": 10000},
|
|
]
|
|
enrichment = _enrichment_for("AAA", "BBB")
|
|
|
|
mild = simulate_orb_day(
|
|
{"AAA": bars, "BBB": bars, "SPY": mild_weak_spy, "QQQ": mild_weak_qqq},
|
|
"2026-01-05",
|
|
params,
|
|
enrichment,
|
|
equity=10_000.0,
|
|
)
|
|
panic = simulate_orb_day(
|
|
{"AAA": bars, "BBB": bars, "SPY": panic_spy, "QQQ": panic_qqq},
|
|
"2026-01-05",
|
|
params,
|
|
enrichment,
|
|
equity=10_000.0,
|
|
)
|
|
|
|
assert mild.market_orb_quality_joint_weak_active is True
|
|
assert mild.market_orb_quality_joint_weak_max_trades_active is True
|
|
assert len(mild.trades) == 1
|
|
assert panic.market_orb_quality_joint_weak_active is False
|
|
assert panic.market_orb_quality_joint_weak_max_trades_active is False
|
|
assert len(panic.trades) == 2
|
|
|
|
|
|
def test_market_orb_quality_joint_panic_guard_can_require_confirmation() -> None:
|
|
params = ORBStrategyParams(
|
|
engine_family="gainers_leader",
|
|
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,
|
|
min_abs_gap_pct=0.0,
|
|
min_premarket_dollar_vol=0.0,
|
|
max_gap_pct=None,
|
|
max_candidates=20,
|
|
min_candidates_to_trade=1,
|
|
risk_per_trade_pct=0.1,
|
|
max_position_pct=1.0,
|
|
daily_max_loss_pct=1.0,
|
|
max_stops_per_day=99,
|
|
daily_budget_reset=True,
|
|
allow_doji_breakout=True,
|
|
market_orb_quality_ticker="SPY",
|
|
market_orb_quality_secondary_ticker="QQQ",
|
|
market_orb_quality_joint_panic_primary_below=0.1,
|
|
market_orb_quality_joint_panic_secondary_below=0.1,
|
|
market_orb_quality_joint_panic_require_confirmation=True,
|
|
)
|
|
|
|
aaa_bars = [
|
|
{"timestamp": "2026-01-05T09:30:00-05:00", "open": 100.0, "high": 101.0, "low": 99.5, "close": 100.8, "volume": 2000},
|
|
{"timestamp": "2026-01-05T09:35:00-05:00", "open": 100.8, "high": 101.2, "low": 100.7, "close": 101.1, "volume": 2000},
|
|
{"timestamp": "2026-01-05T09:40:00-05:00", "open": 101.1, "high": 101.2, "low": 100.7, "close": 100.9, "volume": 2000},
|
|
{"timestamp": "2026-01-05T09:45:00-05:00", "open": 100.9, "high": 101.0, "low": 100.8, "close": 100.95, "volume": 2000},
|
|
{"timestamp": "2026-01-05T15:55:00-05:00", "open": 100.95, "high": 101.0, "low": 100.85, "close": 100.9, "volume": 2000},
|
|
]
|
|
calm_spy = [
|
|
{"timestamp": "2026-01-05T09:30:00-05:00", "open": 500.0, "high": 501.0, "low": 499.5, "close": 500.8, "volume": 10000},
|
|
]
|
|
calm_qqq = [
|
|
{"timestamp": "2026-01-05T09:30:00-05:00", "open": 400.0, "high": 401.0, "low": 399.8, "close": 400.9, "volume": 10000},
|
|
]
|
|
panic_spy = [
|
|
{"timestamp": "2026-01-05T09:30:00-05:00", "open": 500.0, "high": 501.0, "low": 499.0, "close": 499.05, "volume": 10000},
|
|
]
|
|
panic_qqq = [
|
|
{"timestamp": "2026-01-05T09:30:00-05:00", "open": 400.0, "high": 401.0, "low": 399.0, "close": 399.1, "volume": 10000},
|
|
]
|
|
enrichment = _enrichment_for("AAA")
|
|
|
|
calm = simulate_orb_day(
|
|
{"AAA": aaa_bars, "SPY": calm_spy, "QQQ": calm_qqq},
|
|
"2026-01-05",
|
|
params,
|
|
enrichment,
|
|
equity=10_000.0,
|
|
)
|
|
panic = simulate_orb_day(
|
|
{"AAA": aaa_bars, "SPY": panic_spy, "QQQ": panic_qqq},
|
|
"2026-01-05",
|
|
params,
|
|
enrichment,
|
|
equity=10_000.0,
|
|
)
|
|
|
|
assert calm.market_orb_quality_joint_panic_active is False
|
|
assert calm.conditional_confirmation_active is False
|
|
assert len(calm.trades) == 1
|
|
assert panic.market_orb_quality_joint_panic_active is True
|
|
assert panic.conditional_confirmation_active is True
|
|
assert panic.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_gainers_leader_attention_news_weight_affects_ranking() -> None:
|
|
params = ORBStrategyParams(
|
|
engine_family="gainers_leader",
|
|
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=2,
|
|
weight_rvol=0.0,
|
|
weight_gap=0.0,
|
|
weight_dollar_vol=0.0,
|
|
weight_premarket_dollar_vol=0.0,
|
|
weight_attention_news=1.0,
|
|
)
|
|
bars = [
|
|
{"timestamp": "2026-01-05T09:30:00-05:00", "open": 100.0, "high": 102.0, "low": 99.8, "close": 101.5, "volume": 2000},
|
|
{"timestamp": "2026-01-05T09:35:00-05:00", "open": 101.5, "high": 102.1, "low": 101.4, "close": 102.0, "volume": 2000},
|
|
{"timestamp": "2026-01-05T09:40:00-05:00", "open": 102.0, "high": 102.2, "low": 101.9, "close": 102.1, "volume": 2000},
|
|
{"timestamp": "2026-01-05T09:45:00-05:00", "open": 102.1, "high": 102.3, "low": 102.0, "close": 102.2, "volume": 2000},
|
|
{"timestamp": "2026-01-05T09:50:00-05:00", "open": 102.2, "high": 102.4, "low": 102.1, "close": 102.3, "volume": 2000},
|
|
]
|
|
enrichment = _enrichment_for("LOW_NEWS", "HIGH_NEWS")
|
|
enrichment["LOW_NEWS"]["2026-01-05"]["attention_article_count_3d"] = 0
|
|
enrichment["HIGH_NEWS"]["2026-01-05"]["attention_article_count_3d"] = 8
|
|
|
|
candidates = compute_orb_candidates(
|
|
{"LOW_NEWS": bars, "HIGH_NEWS": bars},
|
|
"2026-01-05",
|
|
params,
|
|
enrichment,
|
|
)
|
|
|
|
assert [cand["ticker"] for cand in candidates] == ["HIGH_NEWS", "LOW_NEWS"]
|
|
|
|
|
|
def test_gainers_leader_ownership_initial_weight_affects_ranking() -> None:
|
|
params = ORBStrategyParams(
|
|
engine_family="gainers_leader",
|
|
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=2,
|
|
weight_rvol=0.0,
|
|
weight_gap=0.0,
|
|
weight_dollar_vol=0.0,
|
|
weight_premarket_dollar_vol=0.0,
|
|
ownership_13dg_lookback_days=180,
|
|
weight_ownership_initial_13dg=1.0,
|
|
)
|
|
bars = [
|
|
{"timestamp": "2026-01-05T09:30:00-05:00", "open": 100.0, "high": 102.0, "low": 99.8, "close": 101.5, "volume": 2000},
|
|
{"timestamp": "2026-01-05T09:35:00-05:00", "open": 101.5, "high": 102.1, "low": 101.4, "close": 102.0, "volume": 2000},
|
|
{"timestamp": "2026-01-05T09:40:00-05:00", "open": 102.0, "high": 102.2, "low": 101.9, "close": 102.1, "volume": 2000},
|
|
{"timestamp": "2026-01-05T09:45:00-05:00", "open": 102.1, "high": 102.3, "low": 102.0, "close": 102.2, "volume": 2000},
|
|
{"timestamp": "2026-01-05T09:50:00-05:00", "open": 102.2, "high": 102.4, "low": 102.1, "close": 102.3, "volume": 2000},
|
|
]
|
|
enrichment = _enrichment_for("NO_OWNER", "OWNER")
|
|
enrichment["OWNER"]["2026-01-05"].update(
|
|
{
|
|
"ownership_13dg_flag": True,
|
|
"ownership_13dg_initial_flag": True,
|
|
"ownership_13dg_days_since": 20,
|
|
"ownership_13dg_score": 1.0,
|
|
"ownership_13dg_initial_score": 1.0,
|
|
}
|
|
)
|
|
|
|
candidates = compute_orb_candidates(
|
|
{"NO_OWNER": bars, "OWNER": bars},
|
|
"2026-01-05",
|
|
params,
|
|
enrichment,
|
|
)
|
|
|
|
assert [cand["ticker"] for cand in candidates] == ["OWNER", "NO_OWNER"]
|
|
|
|
|
|
def test_ownership_initial_size_scale_requires_clean_initial_owner_setup() -> None:
|
|
params = ORBStrategyParams(
|
|
ownership_initial_size_scale=1.2,
|
|
ownership_initial_min_score_rank_pct=0.8,
|
|
ownership_initial_allowed_trigger_types=["orb"],
|
|
)
|
|
cand = {"ownership_13dg_initial_flag": True}
|
|
|
|
assert _orb_ownership_initial_size_scale(params, cand, 0.9, "orb", "long", (1.0,)) == 1.2
|
|
assert _orb_ownership_initial_size_scale(params, cand, 0.7, "orb", "long", (1.0,)) == 1.0
|
|
assert _orb_ownership_initial_size_scale(params, cand, 0.9, "vwap_reclaim", "long", (1.0,)) == 1.0
|
|
assert _orb_ownership_initial_size_scale(params, cand, 0.9, "orb", "long", (0.5,)) == 1.0
|
|
|
|
|
|
def test_form4_size_scale_requires_cluster_or_csuite_setup() -> None:
|
|
params = ORBStrategyParams(
|
|
form4_size_scale=1.2,
|
|
form4_min_total_value=100_000.0,
|
|
form4_min_owner_count=2,
|
|
form4_min_c_suite_count=1,
|
|
form4_require_cluster_or_csuite=True,
|
|
form4_allowed_trigger_types=["orb"],
|
|
)
|
|
cluster = {
|
|
"form4_flag": True,
|
|
"form4_total_value": 150_000.0,
|
|
"form4_owner_count": 2,
|
|
"form4_c_suite_count": 0,
|
|
}
|
|
csuite = {
|
|
"form4_flag": True,
|
|
"form4_total_value": 150_000.0,
|
|
"form4_owner_count": 1,
|
|
"form4_c_suite_count": 1,
|
|
}
|
|
weak = {
|
|
"form4_flag": True,
|
|
"form4_total_value": 150_000.0,
|
|
"form4_owner_count": 1,
|
|
"form4_c_suite_count": 0,
|
|
}
|
|
|
|
assert _orb_form4_size_scale(params, cluster, "orb", "long", (1.0,)) == 1.2
|
|
assert _orb_form4_size_scale(params, csuite, "orb", "long", (1.0,)) == 1.2
|
|
assert _orb_form4_size_scale(params, weak, "orb", "long", (1.0,)) == 1.0
|
|
assert _orb_form4_size_scale(params, cluster, "vwap_reclaim", "long", (1.0,)) == 1.0
|
|
assert _orb_form4_size_scale(params, cluster, "orb", "long", (0.5,)) == 1.0
|
|
|
|
|
|
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_sector_confirmation_can_promote_same_sector_cluster() -> 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=1,
|
|
min_candidates_to_trade=1,
|
|
sector_confirmation_enabled=True,
|
|
sector_confirmation_min_members=2,
|
|
sector_confirmation_score_weight=1.0,
|
|
)
|
|
|
|
def bars(volume: int) -> list[dict]:
|
|
return [
|
|
{"timestamp": "2026-01-05T09:30:00-05:00", "open": 100.0, "high": 102.0, "low": 99.8, "close": 101.8, "volume": volume},
|
|
{"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(
|
|
{
|
|
"LONE": bars(5000),
|
|
"TECH1": bars(4600),
|
|
"TECH2": bars(4500),
|
|
},
|
|
"2026-01-05",
|
|
params,
|
|
_enrichment_for("LONE", "TECH1", "TECH2"),
|
|
ticker_sectors={
|
|
"LONE": "Healthcare",
|
|
"TECH1": "Technology",
|
|
"TECH2": "Technology",
|
|
},
|
|
)
|
|
|
|
assert [cand["ticker"] for cand in candidates] == ["TECH1"]
|
|
assert candidates[0]["sector_confirmation_active"] is True
|
|
assert candidates[0]["sector_confirmation_member_count"] == 2
|
|
|
|
|
|
def test_sector_confirmation_size_scale_supports_liquidity_tiers() -> None:
|
|
params = ORBStrategyParams(
|
|
sector_confirmation_enabled=True,
|
|
sector_confirmation_size_scale=1.10,
|
|
sector_confirmation_liquid_min_premarket_dollar_vol=20_000_000,
|
|
sector_confirmation_liquid_size_scale=1.20,
|
|
sector_confirmation_illiquid_size_scale=1.0,
|
|
sector_confirmation_unconfirmed_size_scale=0.95,
|
|
)
|
|
|
|
liquid = {
|
|
"sector_confirmation_active": True,
|
|
"premarket_dollar_vol": 25_000_000,
|
|
}
|
|
thin = {
|
|
"sector_confirmation_active": True,
|
|
"premarket_dollar_vol": 5_000_000,
|
|
}
|
|
unconfirmed = {
|
|
"sector_confirmation_active": False,
|
|
"premarket_dollar_vol": 25_000_000,
|
|
}
|
|
|
|
assert _orb_sector_confirmation_size_scale(params, liquid) == 1.20
|
|
assert _orb_sector_confirmation_size_scale(params, thin) == 1.0
|
|
assert _orb_sector_confirmation_size_scale(params, unconfirmed) == 0.95
|
|
|
|
|
|
def test_opening_burst_liquid_size_scale_requires_time_liquidity_and_clean_risk() -> None:
|
|
params = ORBStrategyParams(
|
|
opening_burst_liquid_size_scale=1.4,
|
|
opening_burst_liquid_max_entry_minutes_after_open=5,
|
|
opening_burst_liquid_min_premarket_dollar_vol=100_000_000,
|
|
opening_burst_liquid_min_gap_pct=0.0,
|
|
opening_burst_liquid_min_candidate_score=0.65,
|
|
opening_burst_liquid_allowed_trigger_types=["orb"],
|
|
)
|
|
cand = {"premarket_dollar_vol": 150_000_000, "gap_pct": 0.02, "score": 0.70}
|
|
entry_ts = dt.datetime(
|
|
2026,
|
|
1,
|
|
5,
|
|
9,
|
|
35,
|
|
tzinfo=dt.timezone(dt.timedelta(hours=-5)),
|
|
)
|
|
late_ts = entry_ts + dt.timedelta(minutes=5)
|
|
|
|
assert (
|
|
_orb_opening_burst_liquid_size_scale(
|
|
params,
|
|
{"premarket_dollar_vol": 150_000_000, "gap_pct": 0.02, "score": 0.60},
|
|
date_str="2026-01-05",
|
|
entry_ts=entry_ts,
|
|
trigger_type="orb",
|
|
score_rank_pct=0.5,
|
|
direction_str="long",
|
|
risk_size_scales=(1.0, 1.0),
|
|
)
|
|
== 1.0
|
|
)
|
|
assert (
|
|
_orb_opening_burst_liquid_size_scale(
|
|
params,
|
|
cand,
|
|
date_str="2026-01-05",
|
|
entry_ts=entry_ts,
|
|
trigger_type="orb",
|
|
score_rank_pct=0.5,
|
|
direction_str="long",
|
|
risk_size_scales=(1.0, 1.0),
|
|
)
|
|
== 1.4
|
|
)
|
|
assert (
|
|
_orb_opening_burst_liquid_size_scale(
|
|
params,
|
|
{"premarket_dollar_vol": 150_000_000, "gap_pct": -0.01, "score": 0.70},
|
|
date_str="2026-01-05",
|
|
entry_ts=entry_ts,
|
|
trigger_type="orb",
|
|
score_rank_pct=0.5,
|
|
direction_str="long",
|
|
risk_size_scales=(1.0, 1.0),
|
|
)
|
|
== 1.0
|
|
)
|
|
assert (
|
|
_orb_opening_burst_liquid_size_scale(
|
|
params,
|
|
cand,
|
|
date_str="2026-01-05",
|
|
entry_ts=late_ts,
|
|
trigger_type="orb",
|
|
score_rank_pct=0.5,
|
|
direction_str="long",
|
|
risk_size_scales=(1.0, 1.0),
|
|
)
|
|
== 1.0
|
|
)
|
|
assert (
|
|
_orb_opening_burst_liquid_size_scale(
|
|
params,
|
|
cand,
|
|
date_str="2026-01-05",
|
|
entry_ts=entry_ts,
|
|
trigger_type="soft_day_vwap_reclaim",
|
|
score_rank_pct=0.5,
|
|
direction_str="long",
|
|
risk_size_scales=(1.0, 1.0),
|
|
)
|
|
== 1.0
|
|
)
|
|
assert (
|
|
_orb_opening_burst_liquid_size_scale(
|
|
params,
|
|
cand,
|
|
date_str="2026-01-05",
|
|
entry_ts=entry_ts,
|
|
trigger_type="orb",
|
|
score_rank_pct=0.5,
|
|
direction_str="long",
|
|
risk_size_scales=(0.5, 1.0),
|
|
)
|
|
== 1.0
|
|
)
|
|
|
|
|
|
def test_unboosted_primary_fragility_scales_only_unboosted_fragile_orb() -> None:
|
|
params = ORBStrategyParams(
|
|
unboosted_primary_fragility_size_scale=0.25,
|
|
unboosted_primary_fragility_allowed_trigger_types=["orb"],
|
|
unboosted_primary_fragility_crowded_min_gap_pct=0.02,
|
|
unboosted_primary_fragility_crowded_min_premarket_dollar_vol=50_000_000,
|
|
unboosted_primary_fragility_crowded_max_close_location=0.85,
|
|
unboosted_primary_fragility_weak_max_rvol=5.0,
|
|
unboosted_primary_fragility_weak_max_ret_5d=0.05,
|
|
)
|
|
crowded = {
|
|
"gap_pct": 0.03,
|
|
"premarket_dollar_vol": 75_000_000,
|
|
"close_location": 0.70,
|
|
"rvol": 8.0,
|
|
"ret_5d": 0.20,
|
|
}
|
|
weak_attention = {
|
|
"gap_pct": -0.03,
|
|
"premarket_dollar_vol": 10_000_000,
|
|
"close_location": 0.95,
|
|
"rvol": 4.5,
|
|
"ret_5d": -0.02,
|
|
}
|
|
clean = {
|
|
"gap_pct": 0.03,
|
|
"premarket_dollar_vol": 75_000_000,
|
|
"close_location": 0.95,
|
|
"rvol": 8.0,
|
|
"ret_5d": 0.20,
|
|
}
|
|
|
|
assert (
|
|
_orb_unboosted_primary_fragility_size_scale(
|
|
params,
|
|
crowded,
|
|
{},
|
|
trigger_type="orb",
|
|
direction_str="long",
|
|
high_conviction_size_scales=(1.0, 1.0),
|
|
)
|
|
== 0.25
|
|
)
|
|
assert (
|
|
_orb_unboosted_primary_fragility_size_scale(
|
|
params,
|
|
weak_attention,
|
|
{},
|
|
trigger_type="orb",
|
|
direction_str="long",
|
|
high_conviction_size_scales=(1.0, 1.0),
|
|
)
|
|
== 0.25
|
|
)
|
|
assert (
|
|
_orb_unboosted_primary_fragility_size_scale(
|
|
params,
|
|
crowded,
|
|
{},
|
|
trigger_type="orb",
|
|
direction_str="long",
|
|
high_conviction_size_scales=(1.5, 1.0),
|
|
)
|
|
== 1.0
|
|
)
|
|
assert (
|
|
_orb_unboosted_primary_fragility_size_scale(
|
|
params,
|
|
crowded,
|
|
{},
|
|
trigger_type="soft_day_vwap_reclaim",
|
|
direction_str="long",
|
|
high_conviction_size_scales=(1.0, 1.0),
|
|
)
|
|
== 1.0
|
|
)
|
|
assert (
|
|
_orb_unboosted_primary_fragility_size_scale(
|
|
params,
|
|
clean,
|
|
{},
|
|
trigger_type="orb",
|
|
direction_str="long",
|
|
high_conviction_size_scales=(1.0, 1.0),
|
|
)
|
|
== 1.0
|
|
)
|
|
|
|
|
|
def test_soft_day_sector_confirmation_override_requires_sector_and_reason() -> None:
|
|
params = ORBStrategyParams(
|
|
soft_day_sector_confirmation_override_enabled=True,
|
|
soft_day_sector_confirmation_override_min_score_pct=0.70,
|
|
soft_day_sector_confirmation_override_min_premarket_dollar_vol=20_000_000,
|
|
soft_day_sector_confirmation_override_allowed_reason_parts=["breadth"],
|
|
soft_day_sector_confirmation_override_allowed_trigger_types=["orb"],
|
|
)
|
|
cand = {
|
|
"sector_confirmation_active": True,
|
|
"premarket_dollar_vol": 25_000_000,
|
|
}
|
|
|
|
assert _orb_soft_day_sector_confirmation_override_allows(
|
|
params,
|
|
"market_regime+breadth",
|
|
cand,
|
|
score_rank_pct=0.75,
|
|
trigger_type="orb",
|
|
)
|
|
assert not _orb_soft_day_sector_confirmation_override_allows(
|
|
params,
|
|
"market_regime",
|
|
cand,
|
|
score_rank_pct=0.75,
|
|
trigger_type="orb",
|
|
)
|
|
assert not _orb_soft_day_sector_confirmation_override_allows(
|
|
params,
|
|
"market_regime+breadth",
|
|
{**cand, "sector_confirmation_active": False},
|
|
score_rank_pct=0.75,
|
|
trigger_type="orb",
|
|
)
|
|
assert not _orb_soft_day_sector_confirmation_override_allows(
|
|
params,
|
|
"market_regime+breadth",
|
|
cand,
|
|
score_rank_pct=0.65,
|
|
trigger_type="orb",
|
|
)
|
|
assert not _orb_soft_day_sector_confirmation_override_allows(
|
|
params,
|
|
"market_regime+breadth",
|
|
cand,
|
|
score_rank_pct=0.75,
|
|
trigger_type="soft_day_vwap_reclaim",
|
|
)
|
|
|
|
|
|
def test_soft_day_sector_confirmation_override_base_sizing_floor_is_active_only() -> None:
|
|
params = ORBStrategyParams(
|
|
soft_day_sector_confirmation_override_min_day_size_scale=0.08,
|
|
)
|
|
|
|
assert _orb_soft_day_sector_confirmation_override_base_sizing(
|
|
params,
|
|
active=False,
|
|
sizing_cap=10_000,
|
|
adjusted_sizing=0.0,
|
|
) == (0.0, None)
|
|
assert _orb_soft_day_sector_confirmation_override_base_sizing(
|
|
params,
|
|
active=True,
|
|
sizing_cap=10_000,
|
|
adjusted_sizing=0.0,
|
|
) == (800.0, 0.08)
|
|
assert _orb_soft_day_sector_confirmation_override_base_sizing(
|
|
params,
|
|
active=True,
|
|
sizing_cap=10_000,
|
|
adjusted_sizing=1_200.0,
|
|
) == (1_200.0, 0.08)
|
|
|
|
|
|
def test_candidates_can_prune_weak_tail_with_relative_quality_floor() -> None:
|
|
params = ORBStrategyParams(
|
|
weight_rvol=0.0,
|
|
weight_gap=0.0,
|
|
weight_dollar_vol=1.0,
|
|
min_price=10.0,
|
|
min_avg_dollar_volume=0.0,
|
|
min_atr_14=0.1,
|
|
min_rvol=0.1,
|
|
max_candidates=3,
|
|
min_candidates_to_trade=1,
|
|
basket_quality_relative_floor=0.6,
|
|
)
|
|
bars_by_ticker = {
|
|
"LEADER": [
|
|
{"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},
|
|
],
|
|
"MID": [
|
|
{"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},
|
|
],
|
|
"TAIL": [
|
|
{"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("LEADER", "MID", "TAIL"),
|
|
)
|
|
|
|
assert [cand["ticker"] for cand in candidates] == ["LEADER"]
|
|
|
|
|
|
def test_quality_floor_respects_min_count() -> None:
|
|
params = ORBStrategyParams(
|
|
weight_rvol=0.0,
|
|
weight_gap=0.0,
|
|
weight_dollar_vol=1.0,
|
|
min_price=10.0,
|
|
min_avg_dollar_volume=0.0,
|
|
min_atr_14=0.1,
|
|
min_rvol=0.1,
|
|
max_candidates=3,
|
|
min_candidates_to_trade=1,
|
|
basket_quality_relative_floor=0.95,
|
|
basket_quality_min_count=2,
|
|
)
|
|
bars_by_ticker = {
|
|
"LEADER": [
|
|
{"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},
|
|
],
|
|
"MID": [
|
|
{"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},
|
|
],
|
|
"TAIL": [
|
|
{"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("LEADER", "MID", "TAIL"),
|
|
)
|
|
|
|
assert [cand["ticker"] for cand in candidates] == ["LEADER", "MID"]
|
|
|
|
|
|
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_gainers_leader_can_conditionally_reject_high_gap_zscore_without_prior_trend_support() -> None:
|
|
params = ORBStrategyParams(
|
|
engine_family="gainers_leader",
|
|
entry_direction="long_only",
|
|
allow_red_to_green_breakout=True,
|
|
conditional_gap_zscore_reject_above=2.0,
|
|
conditional_gap_zscore_reject_ret_5d_below=0.0,
|
|
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 = [
|
|
{"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("BAD_HIGH_NEG", "GOOD_HIGH_POS", "GOOD_LOW_NEG")
|
|
for ticker in ("BAD_HIGH_NEG", "GOOD_HIGH_POS", "GOOD_LOW_NEG"):
|
|
enrichment[ticker]["2026-01-05"]["prev_close"] = 100.0
|
|
enrichment["BAD_HIGH_NEG"]["2026-01-05"].update({"gap_zscore_20d": 2.5, "ret_5d": -0.03})
|
|
enrichment["GOOD_HIGH_POS"]["2026-01-05"].update({"gap_zscore_20d": 2.5, "ret_5d": 0.08})
|
|
enrichment["GOOD_LOW_NEG"]["2026-01-05"].update({"gap_zscore_20d": 1.2, "ret_5d": -0.03})
|
|
|
|
candidates = compute_orb_candidates(
|
|
{"BAD_HIGH_NEG": bars, "GOOD_HIGH_POS": bars, "GOOD_LOW_NEG": bars},
|
|
"2026-01-05",
|
|
params,
|
|
enrichment,
|
|
)
|
|
|
|
assert [cand["ticker"] for cand in candidates] == ["GOOD_HIGH_POS", "GOOD_LOW_NEG"]
|
|
|
|
|
|
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"]
|
|
|
|
|
|
def test_vwap_reclaim_ranking_can_prefer_weaker_opening_structure() -> None:
|
|
params = ORBStrategyParams(
|
|
engine_family="vwap_reclaim_v1",
|
|
entry_direction="long_only",
|
|
allow_red_to_green_breakout=True,
|
|
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 = {
|
|
"DEEP_PULLBACK": [
|
|
{"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.3, "low": 103.9, "close": 104.1, "volume": 8_000},
|
|
{"timestamp": "2026-01-05T09:35:00-05:00", "open": 104.2, "high": 104.8, "low": 104.0, "close": 104.6, "volume": 4_000},
|
|
{"timestamp": "2026-01-05T09:40:00-05:00", "open": 104.6, "high": 104.8, "low": 104.4, "close": 104.7, "volume": 2_000},
|
|
{"timestamp": "2026-01-05T09:45:00-05:00", "open": 104.7, "high": 104.9, "low": 104.6, "close": 104.8, "volume": 2_000},
|
|
{"timestamp": "2026-01-05T09:50:00-05:00", "open": 104.8, "high": 105.0, "low": 104.7, "close": 104.9, "volume": 2_000},
|
|
],
|
|
"STRONG_OPEN": [
|
|
{"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.5, "low": 104.7, "close": 105.4, "volume": 8_000},
|
|
{"timestamp": "2026-01-05T09:35:00-05:00", "open": 105.4, "high": 105.8, "low": 105.2, "close": 105.7, "volume": 4_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": 106.0, "low": 105.7, "close": 105.9, "volume": 2_000},
|
|
{"timestamp": "2026-01-05T09:50:00-05:00", "open": 105.9, "high": 106.1, "low": 105.8, "close": 106.0, "volume": 2_000},
|
|
],
|
|
}
|
|
enrichment = _enrichment_for("DEEP_PULLBACK", "STRONG_OPEN")
|
|
enrichment["DEEP_PULLBACK"]["2026-01-05"]["prev_close"] = 104.0
|
|
enrichment["STRONG_OPEN"]["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] == ["DEEP_PULLBACK", "STRONG_OPEN"]
|
|
|
|
|
|
def test_orb_pullback_ranking_can_use_event_catalyst_score() -> None:
|
|
params = ORBStrategyParams(
|
|
engine_family="orb_pullback_v1",
|
|
entry_direction="long_only",
|
|
weight_rvol=0.0,
|
|
weight_gap=0.0,
|
|
weight_dollar_vol=0.0,
|
|
weight_premarket_dollar_vol=0.0,
|
|
weight_event_catalyst=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 = {
|
|
"PLAIN": [
|
|
{"timestamp": "2026-01-05T08:15:00-05:00", "open": 102.0, "high": 102.3, "low": 101.9, "close": 102.2, "volume": 12_000},
|
|
{"timestamp": "2026-01-05T09:30:00-05:00", "open": 105.0, "high": 105.5, "low": 104.4, "close": 105.2, "volume": 8_000},
|
|
{"timestamp": "2026-01-05T09:35:00-05:00", "open": 105.2, "high": 105.8, "low": 105.1, "close": 105.6, "volume": 5_000},
|
|
{"timestamp": "2026-01-05T09:40:00-05:00", "open": 105.6, "high": 105.8, "low": 105.4, "close": 105.7, "volume": 2_000},
|
|
{"timestamp": "2026-01-05T09:45:00-05:00", "open": 105.7, "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.7, "close": 105.9, "volume": 2_000},
|
|
],
|
|
"EVENTFUL": [
|
|
{"timestamp": "2026-01-05T08:15:00-05:00", "open": 102.0, "high": 102.3, "low": 101.9, "close": 102.2, "volume": 12_000},
|
|
{"timestamp": "2026-01-05T09:30:00-05:00", "open": 105.0, "high": 105.5, "low": 104.4, "close": 105.2, "volume": 8_000},
|
|
{"timestamp": "2026-01-05T09:35:00-05:00", "open": 105.2, "high": 105.8, "low": 105.1, "close": 105.6, "volume": 5_000},
|
|
{"timestamp": "2026-01-05T09:40:00-05:00", "open": 105.6, "high": 105.8, "low": 105.4, "close": 105.7, "volume": 2_000},
|
|
{"timestamp": "2026-01-05T09:45:00-05:00", "open": 105.7, "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.7, "close": 105.9, "volume": 2_000},
|
|
],
|
|
}
|
|
enrichment = _enrichment_for("PLAIN", "EVENTFUL")
|
|
enrichment["PLAIN"]["2026-01-05"]["prev_close"] = 104.0
|
|
enrichment["EVENTFUL"]["2026-01-05"]["prev_close"] = 104.0
|
|
enrichment["PLAIN"]["2026-01-05"]["event_score"] = 0.0
|
|
enrichment["EVENTFUL"]["2026-01-05"]["event_flag"] = True
|
|
enrichment["EVENTFUL"]["2026-01-05"]["event_score"] = 3.0
|
|
|
|
candidates = compute_orb_candidates(
|
|
bars_by_ticker,
|
|
"2026-01-05",
|
|
params,
|
|
enrichment,
|
|
)
|
|
|
|
assert [cand["ticker"] for cand in candidates] == ["EVENTFUL", "PLAIN"]
|
|
|
|
|
|
def test_vwap_reclaim_trade_waits_for_prior_dip_then_enters_on_reclaim() -> None:
|
|
bars = [
|
|
{"timestamp": "2026-01-05T09:30:00-05:00", "open": 105.0, "high": 105.2, "low": 103.8, "close": 104.0, "volume": 1_000},
|
|
{"timestamp": "2026-01-05T09:35:00-05:00", "open": 104.0, "high": 104.2, "low": 103.6, "close": 103.8, "volume": 1_000},
|
|
{"timestamp": "2026-01-05T09:40:00-05:00", "open": 103.8, "high": 104.0, "low": 103.5, "close": 103.7, "volume": 1_000},
|
|
{"timestamp": "2026-01-05T09:45:00-05:00", "open": 103.7, "high": 103.9, "low": 103.6, "close": 103.8, "volume": 1_000},
|
|
{"timestamp": "2026-01-05T09:50:00-05:00", "open": 103.8, "high": 104.8, "low": 103.7, "close": 104.7, "volume": 1_500},
|
|
{"timestamp": "2026-01-05T09:55:00-05:00", "open": 104.7, "high": 105.0, "low": 104.5, "close": 104.9, "volume": 1_200},
|
|
{"timestamp": "2026-01-05T15:55:00-05:00", "open": 104.9, "high": 105.1, "low": 104.8, "close": 105.0, "volume": 1_000},
|
|
]
|
|
params = ORBStrategyParams(
|
|
engine_family="vwap_reclaim_v1",
|
|
orb_minutes=5,
|
|
sim_bar_minutes=5,
|
|
vwap_reclaim_window_start_min=20,
|
|
vwap_reclaim_window_end_min=120,
|
|
vwap_reclaim_require_prior_dip=True,
|
|
vwap_reclaim_min_clearance_pct=0.0,
|
|
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.05,
|
|
params=params,
|
|
equity=10_000.0,
|
|
date_str="2026-01-05",
|
|
ticker="VWAP",
|
|
)
|
|
|
|
assert trade is not None
|
|
assert trade.entry_time == "2026-01-05T09:50:00-05:00"
|
|
assert trade.entry_price == pytest.approx(104.7)
|
|
|
|
|
|
def test_vwap_reclaim_trade_can_require_orb_open_retake_and_rel_volume() -> None:
|
|
bars = [
|
|
{"timestamp": "2026-01-05T09:30:00-05:00", "open": 105.0, "high": 105.2, "low": 103.8, "close": 104.0, "volume": 1_000},
|
|
{"timestamp": "2026-01-05T09:35:00-05:00", "open": 104.0, "high": 104.2, "low": 103.6, "close": 103.8, "volume": 1_000},
|
|
{"timestamp": "2026-01-05T09:40:00-05:00", "open": 103.8, "high": 104.0, "low": 103.5, "close": 103.7, "volume": 1_000},
|
|
{"timestamp": "2026-01-05T09:45:00-05:00", "open": 103.7, "high": 103.9, "low": 103.6, "close": 103.8, "volume": 1_000},
|
|
{"timestamp": "2026-01-05T09:50:00-05:00", "open": 103.8, "high": 104.9, "low": 103.7, "close": 104.7, "volume": 1_000},
|
|
{"timestamp": "2026-01-05T09:55:00-05:00", "open": 104.7, "high": 105.4, "low": 104.6, "close": 105.2, "volume": 1_500},
|
|
{"timestamp": "2026-01-05T15:55:00-05:00", "open": 105.2, "high": 105.4, "low": 105.1, "close": 105.3, "volume": 1_000},
|
|
]
|
|
params = ORBStrategyParams(
|
|
engine_family="vwap_reclaim_v1",
|
|
orb_minutes=5,
|
|
sim_bar_minutes=5,
|
|
vwap_reclaim_window_start_min=20,
|
|
vwap_reclaim_window_end_min=120,
|
|
vwap_reclaim_require_prior_dip=True,
|
|
vwap_reclaim_min_clearance_pct=0.0,
|
|
vwap_reclaim_require_orb_open_retake=True,
|
|
vwap_reclaim_confirm_rel_vol=1.2,
|
|
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.05,
|
|
params=params,
|
|
equity=10_000.0,
|
|
date_str="2026-01-05",
|
|
ticker="VWAP",
|
|
)
|
|
|
|
assert trade is not None
|
|
assert trade.entry_time == "2026-01-05T09:55:00-05:00"
|
|
assert trade.entry_price == pytest.approx(105.2)
|
|
|
|
|
|
def test_pullback_trade_waits_for_breakout_retake_and_reclaim_volume() -> None:
|
|
bars = [
|
|
{"timestamp": "2026-01-05T09:30:00-05:00", "open": 105.0, "high": 106.0, "low": 104.8, "close": 105.8, "volume": 1_000},
|
|
{"timestamp": "2026-01-05T09:35:00-05:00", "open": 105.8, "high": 106.3, "low": 105.7, "close": 106.1, "volume": 1_500},
|
|
{"timestamp": "2026-01-05T09:40:00-05:00", "open": 106.1, "high": 106.8, "low": 106.0, "close": 106.6, "volume": 1_300},
|
|
{"timestamp": "2026-01-05T09:45:00-05:00", "open": 106.6, "high": 106.7, "low": 105.8, "close": 106.0, "volume": 700},
|
|
{"timestamp": "2026-01-05T09:50:00-05:00", "open": 106.0, "high": 106.2, "low": 105.9, "close": 106.05, "volume": 700},
|
|
{"timestamp": "2026-01-05T09:55:00-05:00", "open": 106.05, "high": 106.5, "low": 106.0, "close": 106.35, "volume": 1_600},
|
|
{"timestamp": "2026-01-05T15:55:00-05:00", "open": 106.35, "high": 106.5, "low": 106.2, "close": 106.4, "volume": 1_000},
|
|
]
|
|
params = ORBStrategyParams(
|
|
engine_family="orb_pullback_v1",
|
|
orb_minutes=5,
|
|
sim_bar_minutes=5,
|
|
pullback_entry=True,
|
|
pullback_max_bars=6,
|
|
pullback_min_retracement_pct=0.25,
|
|
pullback_require_breakout_retake=True,
|
|
pullback_breakout_retake_clearance_pct=0.002,
|
|
pullback_reclaim_confirm_rel_vol=1.2,
|
|
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.05,
|
|
params=params,
|
|
equity=10_000.0,
|
|
date_str="2026-01-05",
|
|
ticker="PULL",
|
|
)
|
|
|
|
assert trade is not None
|
|
assert trade.entry_time == "2026-01-05T09:55:00-05:00"
|
|
assert trade.entry_price == pytest.approx(106.35)
|