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.

2256 lines
72 KiB
Python

from __future__ import annotations
from libs.intraday.domain import StrategyParams
from libs.intraday.simulator import (
_same_day_support_score,
_select_momentum_sleeves,
compute_morning_gains,
run_simulation,
simulate_day,
)
def _bars(day: str, *, open_price: float, closes: list[float], volumes: list[int]) -> list[dict]:
times = ["09:30:00", "09:35:00", "09:40:00", "09:45:00", "09:50:00", "15:55:00"]
bars: list[dict] = []
prev = open_price
for idx, close in enumerate(closes):
ts = f"{day}T{times[idx]}-05:00"
high = max(prev, close) + 0.2
low = min(prev, close) - 0.2
bars.append(
{
"timestamp": ts,
"open": prev,
"high": high,
"low": low,
"close": close,
"volume": volumes[idx],
}
)
prev = close
return bars
def test_compute_morning_gains_respects_vix_gate() -> None:
strategy = StrategyParams(
entry_minutes_after_open=10,
min_morning_gain_pct=0.01,
max_vix=25.0,
)
bars_by_ticker = {
"AAA": _bars(
"2026-01-05",
open_price=10.0,
closes=[10.2, 10.5, 10.7, 10.8, 10.9, 11.0],
volumes=[100_000, 100_000, 100_000, 100_000, 100_000, 100_000],
)
}
gains = compute_morning_gains(
bars_by_ticker,
strategy,
"2026-01-05",
vix_value=30.0,
)
assert gains == {}
def test_simulate_day_applies_gap_regime_scaler_and_soft_day_trade_cap() -> None:
strategy = StrategyParams(
entry_minutes_after_open=10,
min_morning_gain_pct=0.01,
top_n=2,
slippage_bps=0.0,
daily_budget_reset=True,
initial_capital=10_000.0,
market_regime_gap_threshold=-0.03,
market_regime_gap_ticker="SPY",
regime_size_scale_low=-0.02,
regime_size_scale_high=0.0,
regime_size_scale_min=0.5,
soft_day_scaler_threshold=0.8,
soft_day_max_trades=1,
)
bars_by_ticker = {
"AAA": _bars("2026-01-05", open_price=10.0, closes=[10.2, 10.6, 10.8, 11.0, 11.1, 11.2], volumes=[120_000] * 6),
"BBB": _bars("2026-01-05", open_price=20.0, closes=[20.3, 20.8, 21.1, 21.2, 21.3, 21.4], volumes=[120_000] * 6),
}
day = simulate_day(
bars_by_ticker,
"2026-01-05",
strategy,
daily_features_by_ticker={
"AAA": {"prev_close": 9.8, "today_open": 10.0},
"BBB": {"prev_close": 19.7, "today_open": 20.0},
"SPY": {"prev_close": 100.0, "today_open": 99.0},
},
)
assert day.regime_scaler == 0.75
assert day.is_soft_day is True
assert len(day.trades) == 1
assert round(day.capital_deployed, 2) == 7500.0
def test_simulate_day_skips_on_candidate_breadth() -> None:
strategy = StrategyParams(
entry_minutes_after_open=10,
min_morning_gain_pct=0.01,
top_n=2,
min_candidate_breadth=0.75,
)
bars_by_ticker = {
"UP": _bars("2026-01-05", open_price=10.0, closes=[10.2, 10.6, 10.8, 10.9, 11.0, 11.1], volumes=[120_000] * 6),
"DOWN": _bars("2026-01-05", open_price=20.0, closes=[20.2, 20.6, 20.8, 20.9, 21.0, 21.1], volumes=[120_000] * 6),
}
day = simulate_day(
bars_by_ticker,
"2026-01-05",
strategy,
daily_features_by_ticker={
"UP": {"prev_close": 9.5, "today_open": 10.0},
"DOWN": {"prev_close": 21.0, "today_open": 20.0},
},
)
assert day.skip_reason == "breadth"
assert day.trades == []
def test_simulate_day_applies_sector_concentration_scaler() -> None:
strategy = StrategyParams(
entry_minutes_after_open=10,
min_morning_gain_pct=0.01,
top_n=3,
slippage_bps=0.0,
daily_budget_reset=True,
initial_capital=10_000.0,
sector_concentration_scale_low=0.34,
sector_concentration_scale_high=0.67,
sector_concentration_scale_min=0.75,
soft_day_scaler_threshold=0.8,
use_five_sleeves=False,
)
bars_by_ticker = {
"TECH_A": _bars("2026-01-05", open_price=10.0, closes=[10.2, 10.6, 10.8, 11.0, 11.1, 11.2], volumes=[120_000] * 6),
"TECH_B": _bars("2026-01-05", open_price=20.0, closes=[20.3, 20.8, 21.0, 21.2, 21.3, 21.4], volumes=[120_000] * 6),
"HEALTH": _bars("2026-01-05", open_price=30.0, closes=[30.3, 30.8, 31.0, 31.2, 31.3, 31.4], volumes=[120_000] * 6),
}
day = simulate_day(
bars_by_ticker,
"2026-01-05",
strategy,
ticker_sectors={
"TECH_A": "Technology",
"TECH_B": "Technology",
"HEALTH": "Healthcare",
},
)
assert round(day.sector_scaler or 0.0, 2) == 0.75
assert day.is_soft_day is True
assert round(day.capital_deployed, 2) == 7525.25
def test_simulate_day_uses_five_sleeves_and_tags_trade_sleeve() -> None:
strategy = StrategyParams(
entry_minutes_after_open=10,
min_morning_gain_pct=0.01,
top_n=3,
use_five_sleeves=True,
min_entry_volume=50_000,
)
bars_by_ticker = {
"CORE": _bars("2026-01-05", open_price=10.0, closes=[10.3, 10.8, 11.0, 11.2, 11.3, 11.5], volumes=[200_000] * 6),
"GAP": _bars("2026-01-05", open_price=20.0, closes=[20.2, 20.6, 20.8, 20.9, 21.0, 21.1], volumes=[180_000] * 6),
"VOL": _bars("2026-01-05", open_price=30.0, closes=[30.2, 30.7, 31.0, 31.2, 31.3, 31.4], volumes=[500_000] * 6),
"ENT": _bars("2026-01-05", open_price=40.0, closes=[40.3, 40.8, 41.2, 41.4, 41.5, 41.7], volumes=[160_000] * 6),
"TRND": _bars("2026-01-05", open_price=50.0, closes=[50.4, 51.0, 51.4, 51.5, 51.7, 52.0], volumes=[150_000] * 6),
}
daily_features = {
"CORE": {"gap_pct": 0.01, "avg_daily_vol_14d": 1_000_000.0, "ret_5d": 0.02, "entropy_20d": 0.50},
"GAP": {"gap_pct": 0.07, "avg_daily_vol_14d": 1_000_000.0, "ret_5d": 0.01, "entropy_20d": 0.60},
"VOL": {"gap_pct": 0.02, "avg_daily_vol_14d": 600_000.0, "ret_5d": 0.00, "entropy_20d": 0.55},
"ENT": {"gap_pct": 0.02, "avg_daily_vol_14d": 1_000_000.0, "ret_5d": 0.03, "entropy_20d": 0.20},
"TRND": {"gap_pct": 0.03, "avg_daily_vol_14d": 1_000_000.0, "ret_5d": 0.12, "entropy_20d": 0.45},
}
day = simulate_day(
bars_by_ticker,
"2026-01-05",
strategy,
daily_features_by_ticker=daily_features,
)
assert len(day.trades) == 3
sleeves = {trade.trade_sleeve for trade in day.trades}
assert sleeves <= {"core", "gap", "volume", "entropy", "trend", "blend"}
assert all(trade.trade_sleeve is not None for trade in day.trades)
def test_run_simulation_applies_entropy_filter_and_scaler() -> None:
strategy = StrategyParams(
entry_minutes_after_open=10,
min_morning_gain_pct=0.01,
top_n=2,
max_entropy_20d=0.80,
entropy_size_scale_low=0.40,
entropy_size_scale_high=0.80,
entropy_size_scale_min=0.50,
)
all_intraday = {
"2026-01-05": {
"LOW": _bars("2026-01-05", open_price=10.0, closes=[10.2, 10.8, 11.2, 11.3, 11.4, 11.5], volumes=[120_000] * 6),
"HIGH": _bars("2026-01-05", open_price=15.0, closes=[15.2, 15.8, 16.2, 16.3, 16.4, 16.5], volumes=[120_000] * 6),
}
}
enrichment = {
"LOW": {"2026-01-05": {"gap_pct": 0.02, "avg_daily_vol_14d": 1_000_000.0, "ret_5d": 0.05, "entropy_20d": 0.40}},
"HIGH": {"2026-01-05": {"gap_pct": 0.02, "avg_daily_vol_14d": 1_000_000.0, "ret_5d": 0.05, "entropy_20d": 0.90}},
}
results = run_simulation(
all_intraday,
["2026-01-05"],
strategy,
daily_enrichment=enrichment,
)
assert len(results) == 1
assert [trade.ticker for trade in results[0].trades] == ["LOW"]
def test_simulate_day_reports_portfolio_return_after_sparse_scaling() -> None:
strategy = StrategyParams(
entry_minutes_after_open=10,
min_morning_gain_pct=0.01,
top_n=1,
slippage_bps=0.0,
daily_budget_reset=True,
initial_capital=10_000.0,
full_size_positions_threshold=2,
sparse_day_size_floor=0.5,
)
bars_by_ticker = {
"AAA": _bars(
"2026-01-05",
open_price=10.0,
closes=[10.2, 10.5, 10.8, 11.0, 11.1, 11.2],
volumes=[120_000] * 6,
)
}
day = simulate_day(
bars_by_ticker,
"2026-01-05",
strategy,
)
assert len(day.trades) == 1
assert day.capital_deployed == 5_000.0
assert round(day.daily_pnl, 2) == 185.19
assert round(day.daily_return_pct, 4) == 0.0185
def test_run_simulation_applies_rolling_loss_pause() -> None:
strategy = StrategyParams(
entry_minutes_after_open=10,
min_morning_gain_pct=0.01,
top_n=1,
stop_loss_pct=None,
slippage_bps=0.0,
daily_budget_reset=True,
initial_capital=10_000.0,
rolling_loss_days=1,
rolling_loss_threshold=-0.05,
)
all_intraday = {
"2026-01-05": {
"LOSER": _bars(
"2026-01-05",
open_price=10.0,
closes=[10.2, 10.6, 10.8, 9.8, 9.2, 9.0],
volumes=[120_000] * 6,
),
},
"2026-01-06": {
"WINNER": _bars(
"2026-01-06",
open_price=10.0,
closes=[10.2, 10.6, 10.9, 11.0, 11.1, 11.2],
volumes=[120_000] * 6,
),
},
}
results = run_simulation(all_intraday, ["2026-01-05", "2026-01-06"], strategy)
assert len(results) == 2
assert results[0].daily_return_pct < -0.05
assert results[1].skip_reason == "rolling_loss"
assert results[1].trades == []
def test_select_momentum_sleeves_respects_weights_and_force_count() -> None:
strategy = StrategyParams(
top_n=2,
use_five_sleeves=True,
five_sleeve_force_count=1,
five_sleeve_core_weight=0.05,
five_sleeve_gap_weight=0.0,
five_sleeve_volume_weight=0.0,
five_sleeve_entropy_weight=0.90,
five_sleeve_trend_weight=0.05,
)
morning_gains = {
"CORE": {
"gain_pct": 0.09,
"entry_volume": 150_000,
"volume_ratio_14d": 0.03,
"gap_pct": 0.01,
"ret_5d": 0.02,
"entropy_20d": 0.60,
},
"ENT": {
"gain_pct": 0.05,
"entry_volume": 140_000,
"volume_ratio_14d": 0.02,
"gap_pct": 0.01,
"ret_5d": 0.03,
"entropy_20d": 0.10,
},
"TRND": {
"gain_pct": 0.04,
"entry_volume": 130_000,
"volume_ratio_14d": 0.02,
"gap_pct": 0.01,
"ret_5d": 0.15,
"entropy_20d": 0.55,
},
}
picks = _select_momentum_sleeves(morning_gains, strategy)
assert picks[0] == ("ENT", "entropy")
assert len(picks) == 2
def test_select_momentum_sleeves_respects_sector_cap() -> None:
strategy = StrategyParams(
top_n=3,
use_five_sleeves=False,
max_positions_per_sector=1,
)
morning_gains = {
"TECH_A": {"gain_pct": 0.10, "entry_volume": 300_000},
"TECH_B": {"gain_pct": 0.09, "entry_volume": 280_000},
"HEALTH": {"gain_pct": 0.08, "entry_volume": 260_000},
"FIN": {"gain_pct": 0.07, "entry_volume": 250_000},
}
picks = _select_momentum_sleeves(
morning_gains,
strategy,
ticker_sectors={
"TECH_A": "Technology",
"TECH_B": "Technology",
"HEALTH": "Healthcare",
"FIN": "Financial Services",
},
)
assert [ticker for ticker, _ in picks] == ["TECH_A", "HEALTH", "FIN"]
def test_select_momentum_sleeves_prunes_weak_tail_with_quality_floor() -> None:
strategy = StrategyParams(
top_n=3,
use_five_sleeves=True,
five_sleeve_force_count=0,
five_sleeve_core_weight=1.0,
five_sleeve_gap_weight=0.0,
five_sleeve_volume_weight=0.0,
five_sleeve_entropy_weight=0.0,
five_sleeve_trend_weight=0.0,
basket_quality_relative_floor=0.5,
basket_quality_min_count=2,
)
morning_gains = {
"STRONG": {
"gain_pct": 0.10,
"entry_volume": 300_000,
"volume_ratio_14d": 0.08,
"gap_pct": 0.03,
"ret_5d": 0.12,
"entropy_20d": 0.30,
},
"MID": {
"gain_pct": 0.06,
"entry_volume": 250_000,
"volume_ratio_14d": 0.05,
"gap_pct": 0.02,
"ret_5d": 0.08,
"entropy_20d": 0.40,
},
"WEAK": {
"gain_pct": 0.025,
"entry_volume": 200_000,
"volume_ratio_14d": 0.02,
"gap_pct": 0.005,
"ret_5d": 0.01,
"entropy_20d": 0.70,
},
}
picks = _select_momentum_sleeves(morning_gains, strategy)
assert [ticker for ticker, _ in picks] == ["STRONG", "MID"]
def test_select_momentum_sleeves_prunes_only_blend_tail_when_configured() -> None:
strategy = StrategyParams(
top_n=3,
use_five_sleeves=True,
five_sleeve_force_count=1,
five_sleeve_core_weight=1.0,
five_sleeve_gap_weight=0.0,
five_sleeve_volume_weight=0.0,
five_sleeve_entropy_weight=0.0,
five_sleeve_trend_weight=0.0,
basket_quality_relative_floor=0.5,
basket_quality_min_count=2,
basket_quality_prune_blend_only=True,
)
morning_gains = {
"FORCED": {
"gain_pct": 0.10,
"entry_volume": 300_000,
"volume_ratio_14d": 0.08,
"gap_pct": 0.03,
"ret_5d": 0.12,
"entropy_20d": 0.30,
},
"MID": {
"gain_pct": 0.06,
"entry_volume": 250_000,
"volume_ratio_14d": 0.05,
"gap_pct": 0.02,
"ret_5d": 0.08,
"entropy_20d": 0.40,
},
"WEAK": {
"gain_pct": 0.025,
"entry_volume": 200_000,
"volume_ratio_14d": 0.02,
"gap_pct": 0.005,
"ret_5d": 0.01,
"entropy_20d": 0.70,
},
}
picks = _select_momentum_sleeves(morning_gains, strategy)
assert picks[0] == ("FORCED", "core")
assert [ticker for ticker, _ in picks] == ["FORCED", "MID"]
def test_compute_morning_gains_applies_confirmation_filter_and_later_entry() -> None:
strategy = StrategyParams(
entry_minutes_after_open=10,
confirmation_minutes_after_entry=5,
min_confirmation_return_pct=0.0,
min_morning_gain_pct=0.01,
)
bars_by_ticker = {
"KEEP": _bars(
"2026-01-13",
open_price=10.0,
closes=[10.2, 10.6, 10.8, 11.0, 11.1, 11.2],
volumes=[100_000] * 6,
),
"DROP": _bars(
"2026-01-13",
open_price=10.0,
closes=[10.2, 10.6, 10.8, 10.7, 10.6, 10.5],
volumes=[100_000] * 6,
),
}
gains = compute_morning_gains(
bars_by_ticker,
strategy,
"2026-01-13",
)
assert list(gains) == ["KEEP"]
assert gains["KEEP"]["entry_bar"]["timestamp"].endswith("09:45:00-05:00")
def test_compute_morning_gains_allows_slow_ignite_below_primary_gain_floor() -> None:
strategy = StrategyParams(
entry_minutes_after_open=10,
confirmation_minutes_after_entry=5,
min_confirmation_return_pct=0.0005,
min_morning_gain_pct=0.015,
use_slow_ignite_sleeve=True,
slow_ignite_weight=0.08,
slow_ignite_min_gain_pct=0.005,
slow_ignite_max_gain_pct=0.015,
slow_ignite_min_entry_dollar_volume=50_000_000,
slow_ignite_min_volume_ratio_14d=0.08,
slow_ignite_min_ret_5d=0.03,
slow_ignite_max_entropy_20d=0.80,
)
bars_by_ticker = {
"SLOW": _bars(
"2026-01-13",
open_price=100.0,
closes=[100.2, 100.4, 100.6, 101.2, 101.3, 101.5],
volumes=[250_000, 250_000, 250_000, 250_000, 250_000, 250_000],
),
}
gains = compute_morning_gains(
bars_by_ticker,
strategy,
"2026-01-13",
daily_features_by_ticker={
"SLOW": {
"avg_daily_vol_14d": 10_000_000.0,
"ret_5d": 0.06,
"entropy_20d": 0.70,
"gap_pct": 0.01,
}
},
)
assert list(gains) == ["SLOW"]
assert gains["SLOW"]["is_slow_ignite"] is True
assert gains["SLOW"]["gain_pct"] < strategy.min_morning_gain_pct
def test_select_momentum_sleeves_can_force_slow_ignite_pick() -> None:
strategy = StrategyParams(
top_n=2,
use_five_sleeves=True,
use_slow_ignite_sleeve=True,
five_sleeve_core_weight=0.0,
five_sleeve_gap_weight=0.0,
five_sleeve_volume_weight=0.0,
five_sleeve_entropy_weight=0.0,
five_sleeve_trend_weight=0.0,
slow_ignite_weight=1.0,
five_sleeve_force_count=1,
)
morning_gains = {
"FAST": {
"gain_pct": 0.06,
"entry_volume": 300_000,
"entry_dollar_volume": 20_000_000.0,
"volume_ratio_14d": 0.03,
"gap_pct": 0.01,
"ret_5d": 0.02,
"entropy_20d": 0.55,
"confirmation_return_pct": 0.001,
"is_slow_ignite": False,
},
"SLOW": {
"gain_pct": 0.009,
"entry_volume": 1_000_000,
"entry_dollar_volume": 150_000_000.0,
"volume_ratio_14d": 0.12,
"gap_pct": 0.008,
"ret_5d": 0.08,
"entropy_20d": 0.70,
"confirmation_return_pct": 0.006,
"is_slow_ignite": True,
},
}
picks = _select_momentum_sleeves(morning_gains, strategy)
assert ("SLOW", "slow_ignite") in picks
def test_compute_morning_gains_allows_gap_reclaim_candidates() -> None:
strategy = StrategyParams(
entry_minutes_after_open=10,
confirmation_minutes_after_entry=5,
min_morning_gain_pct=0.015,
min_confirmation_return_pct=0.005,
max_gap_pct=0.055,
use_gap_reclaim_sleeve=True,
gap_reclaim_weight=0.05,
gap_reclaim_min_gap_pct=0.10,
gap_reclaim_min_gain_pct=-0.03,
gap_reclaim_max_gain_pct=0.003,
gap_reclaim_min_confirmation_return_pct=0.0015,
gap_reclaim_min_entry_dollar_volume=100_000_000,
gap_reclaim_min_recovery_from_opening_low_pct=0.005,
)
bars_by_ticker = {
"RECLAIM": _bars(
"2026-01-13",
open_price=100.0,
closes=[96.0, 95.0, 97.8, 98.0, 98.5, 101.0],
volumes=[400_000, 400_000, 400_000, 400_000, 400_000, 400_000],
),
}
gains = compute_morning_gains(
bars_by_ticker,
strategy,
"2026-01-13",
daily_features_by_ticker={
"RECLAIM": {
"avg_daily_vol_14d": 5_000_000.0,
"avg_dollar_vol_30d": 800_000_000.0,
"ret_5d": 0.02,
"entropy_20d": None,
"gap_pct": 0.18,
}
},
)
assert list(gains) == ["RECLAIM"]
assert gains["RECLAIM"]["is_gap_reclaim"] is True
assert gains["RECLAIM"]["gain_pct"] < 0.0
assert gains["RECLAIM"]["recovery_from_opening_low_pct"] > 0.005
def test_select_momentum_sleeves_can_force_gap_reclaim_pick() -> None:
strategy = StrategyParams(
top_n=1,
use_five_sleeves=True,
use_gap_reclaim_sleeve=True,
five_sleeve_core_weight=0.0,
five_sleeve_gap_weight=0.0,
five_sleeve_volume_weight=0.0,
five_sleeve_entropy_weight=0.0,
five_sleeve_trend_weight=0.0,
gap_reclaim_weight=1.0,
five_sleeve_force_count=1,
)
morning_gains = {
"FAST": {
"gain_pct": 0.05,
"entry_volume": 200_000,
"entry_dollar_volume": 10_000_000.0,
"gap_pct": 0.02,
"confirmation_return_pct": 0.002,
"recovery_from_opening_low_pct": 0.002,
"is_gap_reclaim": False,
},
"RECLAIM": {
"gain_pct": -0.015,
"entry_volume": 900_000,
"entry_dollar_volume": 220_000_000.0,
"gap_pct": 0.21,
"confirmation_return_pct": 0.003,
"recovery_from_opening_low_pct": 0.01,
"is_gap_reclaim": True,
},
}
picks = _select_momentum_sleeves(morning_gains, strategy)
assert ("RECLAIM", "gap_reclaim") in picks
def test_compute_morning_gains_allows_liquid_largecap_candidates() -> None:
strategy = StrategyParams(
entry_minutes_after_open=10,
confirmation_minutes_after_entry=5,
min_morning_gain_pct=0.015,
min_confirmation_return_pct=0.0005,
use_liquid_largecap_sleeve=True,
liquid_largecap_weight=0.3,
liquid_largecap_min_gain_pct=0.004,
liquid_largecap_max_gain_pct=0.02,
liquid_largecap_min_confirmation_return_pct=0.0005,
liquid_largecap_min_entry_dollar_volume=50_000_000,
liquid_largecap_min_avg_dollar_vol_30d=500_000_000,
liquid_largecap_max_entropy_20d=0.90,
)
bars_by_ticker = {
"LQ": _bars(
"2026-01-13",
open_price=100.0,
closes=[100.2, 100.5, 100.8, 101.4, 101.6, 101.8],
volumes=[500_000, 500_000, 500_000, 500_000, 500_000, 500_000],
),
}
gains = compute_morning_gains(
bars_by_ticker,
strategy,
"2026-01-13",
daily_features_by_ticker={
"LQ": {
"avg_daily_vol_14d": 10_000_000.0,
"avg_dollar_vol_30d": 1_000_000_000.0,
"ret_5d": 0.01,
"entropy_20d": 0.88,
"gap_pct": 0.01,
}
},
)
assert list(gains) == ["LQ"]
assert gains["LQ"]["is_liquid_largecap"] is True
def test_compute_morning_gains_can_relax_global_entropy_for_liquid_largecap() -> None:
strategy = StrategyParams(
entry_minutes_after_open=10,
confirmation_minutes_after_entry=5,
min_morning_gain_pct=0.015,
min_confirmation_return_pct=0.0005,
max_entropy_20d=0.86,
use_liquid_largecap_sleeve=True,
liquid_largecap_weight=0.3,
liquid_largecap_min_gain_pct=0.004,
liquid_largecap_max_gain_pct=0.02,
liquid_largecap_min_confirmation_return_pct=0.0005,
liquid_largecap_min_entry_dollar_volume=50_000_000,
liquid_largecap_min_avg_dollar_vol_30d=500_000_000,
liquid_largecap_max_entropy_20d=0.87,
)
bars_by_ticker = {
"LQ": _bars(
"2026-01-13",
open_price=100.0,
closes=[100.2, 100.5, 100.8, 101.4, 101.6, 101.8],
volumes=[500_000, 500_000, 500_000, 500_000, 500_000, 500_000],
),
}
gains = compute_morning_gains(
bars_by_ticker,
strategy,
"2026-01-13",
daily_features_by_ticker={
"LQ": {
"avg_daily_vol_14d": 10_000_000.0,
"avg_dollar_vol_30d": 1_000_000_000.0,
"ret_5d": 0.01,
"entropy_20d": 0.865,
"gap_pct": 0.01,
}
},
)
assert list(gains) == ["LQ"]
assert gains["LQ"]["is_liquid_largecap"] is True
def test_select_momentum_sleeves_can_force_liquid_largecap_pick() -> None:
strategy = StrategyParams(
top_n=1,
use_five_sleeves=True,
use_liquid_largecap_sleeve=True,
five_sleeve_core_weight=0.0,
five_sleeve_gap_weight=0.0,
five_sleeve_volume_weight=0.0,
five_sleeve_entropy_weight=0.0,
five_sleeve_trend_weight=0.0,
liquid_largecap_weight=1.0,
five_sleeve_force_count=1,
)
morning_gains = {
"FAST": {
"gain_pct": 0.06,
"entry_volume": 150_000,
"entry_dollar_volume": 5_000_000.0,
"avg_dollar_vol_30d": 100_000_000.0,
"confirmation_return_pct": 0.003,
"volume_ratio_14d": 0.03,
"gap_pct": 0.01,
"ret_5d": 0.02,
"entropy_20d": 0.50,
"is_liquid_largecap": False,
},
"LQ": {
"gain_pct": 0.008,
"entry_volume": 500_000,
"entry_dollar_volume": 80_000_000.0,
"avg_dollar_vol_30d": 1_500_000_000.0,
"confirmation_return_pct": 0.001,
"volume_ratio_14d": 0.12,
"gap_pct": 0.01,
"ret_5d": 0.01,
"entropy_20d": 0.85,
"is_liquid_largecap": True,
},
}
picks = _select_momentum_sleeves(morning_gains, strategy)
assert ("LQ", "liquid_largecap") in picks
def test_compute_morning_gains_allows_moderate_gap_liquid_followthrough() -> None:
strategy = StrategyParams(
entry_minutes_after_open=10,
confirmation_minutes_after_entry=5,
min_confirmation_return_pct=0.01,
min_morning_gain_pct=0.04,
use_moderate_gap_liquid_sleeve=True,
moderate_gap_liquid_weight=0.2,
moderate_gap_liquid_min_gap_pct=0.005,
moderate_gap_liquid_max_gap_pct=0.025,
moderate_gap_liquid_min_gain_pct=0.015,
moderate_gap_liquid_max_gain_pct=0.04,
moderate_gap_liquid_min_confirmation_return_pct=0.005,
moderate_gap_liquid_min_entry_dollar_volume=40_000_000,
moderate_gap_liquid_min_avg_dollar_vol_30d=250_000_000,
moderate_gap_liquid_max_avg_dollar_vol_30d=2_000_000_000,
moderate_gap_liquid_max_entropy_20d=0.86,
)
bars_by_ticker = {
"TER": _bars(
"2026-01-13",
open_price=100.0,
closes=[100.4, 100.8, 101.2, 101.9, 102.0, 102.5],
volumes=[150_000, 150_000, 150_000, 150_000, 150_000, 150_000],
),
}
gains = compute_morning_gains(
bars_by_ticker,
strategy,
"2026-01-13",
daily_features_by_ticker={
"TER": {
"gap_pct": 0.012,
"avg_daily_vol_14d": 8_000_000.0,
"avg_dollar_vol_30d": 750_000_000.0,
"entropy_20d": 0.79,
"atr_14": 5.0,
}
},
)
assert list(gains) == ["TER"]
assert gains["TER"]["is_moderate_gap_liquid"] is True
assert gains["TER"]["gain_pct"] < strategy.min_morning_gain_pct
def test_select_momentum_sleeves_can_force_moderate_gap_liquid_pick() -> None:
strategy = StrategyParams(
top_n=1,
use_five_sleeves=True,
use_moderate_gap_liquid_sleeve=True,
five_sleeve_core_weight=0.0,
five_sleeve_gap_weight=0.0,
five_sleeve_volume_weight=0.0,
five_sleeve_entropy_weight=0.0,
five_sleeve_trend_weight=0.0,
moderate_gap_liquid_weight=1.0,
five_sleeve_force_count=1,
)
morning_gains = {
"FAST": {
"gain_pct": 0.06,
"entry_volume": 150_000,
"entry_dollar_volume": 5_000_000.0,
"avg_dollar_vol_30d": 100_000_000.0,
"confirmation_return_pct": 0.003,
"volume_ratio_14d": 0.03,
"gap_pct": 0.04,
"entropy_20d": 0.50,
"is_moderate_gap_liquid": False,
},
"TER": {
"gain_pct": 0.019,
"entry_volume": 500_000,
"entry_dollar_volume": 55_000_000.0,
"avg_dollar_vol_30d": 750_000_000.0,
"confirmation_return_pct": 0.011,
"volume_ratio_14d": 0.08,
"gap_pct": 0.012,
"entropy_20d": 0.79,
"is_moderate_gap_liquid": True,
},
}
picks = _select_momentum_sleeves(morning_gains, strategy)
assert picks == [("TER", "moderate_gap_liquid")]
def test_select_momentum_sleeves_can_force_sector_thrust_pick() -> None:
strategy = StrategyParams(
top_n=1,
use_five_sleeves=True,
use_sector_thrust_sleeve=True,
five_sleeve_core_weight=0.0,
five_sleeve_gap_weight=0.0,
five_sleeve_volume_weight=0.0,
five_sleeve_entropy_weight=0.0,
five_sleeve_trend_weight=0.0,
sector_thrust_weight=1.0,
five_sleeve_force_count=1,
sector_thrust_min_members=2,
sector_thrust_min_gain_pct=0.015,
sector_thrust_min_confirmation_return_pct=0.004,
sector_thrust_min_entry_dollar_volume=50_000_000.0,
sector_thrust_min_avg_dollar_vol_30d=500_000_000.0,
sector_thrust_min_sector_avg_confirmation_return_pct=0.004,
sector_thrust_min_sector_total_entry_dollar_volume=120_000_000.0,
)
morning_gains = {
"ALLY_A": {
"gain_pct": 0.03,
"entry_volume": 600_000,
"entry_dollar_volume": 80_000_000.0,
"avg_dollar_vol_30d": 900_000_000.0,
"confirmation_return_pct": 0.006,
},
"ALLY_B": {
"gain_pct": 0.028,
"entry_volume": 500_000,
"entry_dollar_volume": 70_000_000.0,
"avg_dollar_vol_30d": 850_000_000.0,
"confirmation_return_pct": 0.005,
},
"SOLO": {
"gain_pct": 0.05,
"entry_volume": 550_000,
"entry_dollar_volume": 90_000_000.0,
"avg_dollar_vol_30d": 1_000_000_000.0,
"confirmation_return_pct": 0.007,
},
}
picks = _select_momentum_sleeves(
morning_gains,
strategy,
ticker_sectors={
"ALLY_A": "Technology",
"ALLY_B": "Technology",
"SOLO": "Energy",
},
)
assert picks == [("ALLY_A", "sector_thrust")]
def test_select_momentum_sleeves_can_use_liquid_continuation_selection_mode() -> None:
strategy = StrategyParams(
top_n=2,
momentum_selection_mode="liquid_continuation",
)
morning_gains = {
"HOT": {
"gain_pct": 0.05,
"confirmation_return_pct": 0.002,
"entry_dollar_volume": 4_000_000.0,
"avg_dollar_vol_30d": 30_000_000.0,
"entropy_20d": 0.82,
"is_liquid_largecap": False,
"is_moderate_gap_liquid": False,
"is_sector_thrust": False,
},
"LIQ": {
"gain_pct": 0.015,
"confirmation_return_pct": 0.006,
"entry_dollar_volume": 90_000_000.0,
"avg_dollar_vol_30d": 3_000_000_000.0,
"entropy_20d": 0.72,
"is_liquid_largecap": True,
"is_moderate_gap_liquid": True,
"is_sector_thrust": False,
},
}
picks = _select_momentum_sleeves(morning_gains, strategy)
assert picks == [("LIQ", "liquid_continuation_core")]
def test_simulate_day_records_sector_thrust_trade_diagnostics() -> None:
strategy = StrategyParams(
entry_minutes_after_open=10,
confirmation_minutes_after_entry=5,
min_confirmation_return_pct=0.0,
min_morning_gain_pct=0.01,
top_n=1,
slippage_bps=0.0,
use_five_sleeves=True,
use_sector_thrust_sleeve=True,
five_sleeve_core_weight=0.0,
five_sleeve_gap_weight=0.0,
five_sleeve_volume_weight=0.0,
five_sleeve_entropy_weight=0.0,
five_sleeve_trend_weight=0.0,
sector_thrust_weight=1.0,
five_sleeve_force_count=1,
sector_thrust_min_members=2,
sector_thrust_min_gain_pct=0.015,
sector_thrust_min_confirmation_return_pct=0.004,
sector_thrust_min_entry_dollar_volume=50_000_000.0,
sector_thrust_min_avg_dollar_vol_30d=500_000_000.0,
)
bars_by_ticker = {
"ALLY_A": _bars(
"2026-01-13",
open_price=100.0,
closes=[100.2, 100.8, 101.2, 101.8, 102.0, 102.5],
volumes=[180_000, 180_000, 180_000, 180_000, 180_000, 180_000],
),
"ALLY_B": _bars(
"2026-01-13",
open_price=80.0,
closes=[80.2, 80.7, 81.0, 81.4, 81.6, 81.9],
volumes=[170_000, 170_000, 170_000, 170_000, 170_000, 170_000],
),
"SOLO": _bars(
"2026-01-13",
open_price=50.0,
closes=[50.2, 50.8, 51.2, 51.7, 51.8, 52.0],
volumes=[220_000, 220_000, 220_000, 220_000, 220_000, 220_000],
),
}
day = simulate_day(
bars_by_ticker,
"2026-01-13",
strategy,
daily_features_by_ticker={
"ALLY_A": {"avg_daily_vol_14d": 2_000_000.0, "avg_dollar_vol_30d": 900_000_000.0},
"ALLY_B": {"avg_daily_vol_14d": 2_000_000.0, "avg_dollar_vol_30d": 850_000_000.0},
"SOLO": {"avg_daily_vol_14d": 2_000_000.0, "avg_dollar_vol_30d": 1_200_000_000.0},
},
ticker_sectors={
"ALLY_A": "Technology",
"ALLY_B": "Technology",
"SOLO": "Energy",
},
)
assert len(day.trades) == 1
assert day.trades[0].trade_sleeve == "sector_thrust"
assert day.trades[0].is_sector_thrust is True
assert day.trades[0].sector_thrust_member_count == 2
assert day.trades[0].sector_thrust_total_entry_dollar_volume is not None
assert day.trades[0].sector_thrust_total_entry_dollar_volume > 120_000_000.0
def test_simulate_day_can_enable_event_sleeve_only_on_soft_days() -> None:
strategy = StrategyParams(
entry_minutes_after_open=10,
min_morning_gain_pct=0.01,
top_n=1,
use_five_sleeves=True,
use_event_sleeve=True,
event_weight=1.0,
event_min_score=1.0,
event_sleeve_soft_day_only=True,
five_sleeve_core_weight=0.2,
five_sleeve_gap_weight=0.0,
five_sleeve_volume_weight=0.0,
five_sleeve_entropy_weight=0.0,
five_sleeve_trend_weight=0.0,
five_sleeve_force_count=1,
min_entry_volume=50_000,
market_regime_gap_threshold=-0.03,
market_regime_gap_ticker="SPY",
regime_size_scale_low=-0.02,
regime_size_scale_high=0.0,
regime_size_scale_min=0.5,
soft_day_scaler_threshold=0.8,
)
bars_by_ticker = {
"FAST": _bars(
"2026-01-16",
open_price=10.0,
closes=[10.2, 10.5, 10.8, 10.9, 11.0, 11.1],
volumes=[120_000] * 6,
),
"CAT": _bars(
"2026-01-16",
open_price=20.0,
closes=[20.15, 20.35, 20.45, 20.55, 20.6, 20.7],
volumes=[120_000] * 6,
),
}
day = simulate_day(
bars_by_ticker,
"2026-01-16",
strategy,
daily_features_by_ticker={
"FAST": {"gap_pct": 0.01, "avg_daily_vol_14d": 1_000_000.0, "ret_5d": 0.01, "entropy_20d": 0.45},
"CAT": {
"gap_pct": 0.01,
"avg_daily_vol_14d": 1_000_000.0,
"ret_5d": 0.01,
"entropy_20d": 0.45,
"event_flag": True,
"event_score": 1.5,
},
"SPY": {"prev_close": 100.0, "today_open": 99.0},
},
)
assert day.is_soft_day is True
assert [trade.ticker for trade in day.trades] == ["CAT"]
assert [trade.trade_sleeve for trade in day.trades] == ["event"]
def test_simulate_day_can_keep_event_sleeve_off_when_soft_day_basket_is_still_strong() -> None:
strategy = StrategyParams(
entry_minutes_after_open=10,
min_morning_gain_pct=0.01,
top_n=1,
use_five_sleeves=True,
use_event_sleeve=True,
event_weight=1.0,
event_min_score=1.0,
event_sleeve_soft_day_only=True,
event_sleeve_soft_day_max_avg_quality=0.01,
five_sleeve_core_weight=0.2,
five_sleeve_gap_weight=0.0,
five_sleeve_volume_weight=0.0,
five_sleeve_entropy_weight=0.0,
five_sleeve_trend_weight=0.0,
five_sleeve_force_count=1,
min_entry_volume=50_000,
market_regime_gap_threshold=-0.03,
market_regime_gap_ticker="SPY",
regime_size_scale_low=-0.02,
regime_size_scale_high=0.0,
regime_size_scale_min=0.5,
soft_day_scaler_threshold=0.8,
)
bars_by_ticker = {
"FAST": _bars(
"2026-01-16",
open_price=10.0,
closes=[10.2, 10.5, 10.8, 10.9, 11.0, 11.1],
volumes=[120_000] * 6,
),
"CAT": _bars(
"2026-01-16",
open_price=20.0,
closes=[20.15, 20.35, 20.45, 20.55, 20.6, 20.7],
volumes=[120_000] * 6,
),
}
day = simulate_day(
bars_by_ticker,
"2026-01-16",
strategy,
daily_features_by_ticker={
"FAST": {"gap_pct": 0.01, "avg_daily_vol_14d": 1_000_000.0, "ret_5d": 0.01, "entropy_20d": 0.45},
"CAT": {
"gap_pct": 0.01,
"avg_daily_vol_14d": 1_000_000.0,
"ret_5d": 0.01,
"entropy_20d": 0.45,
"event_flag": True,
"event_score": 1.5,
},
"SPY": {"prev_close": 100.0, "today_open": 99.0},
},
)
assert day.is_soft_day is True
assert [trade.ticker for trade in day.trades] == ["FAST"]
assert [trade.trade_sleeve for trade in day.trades] == ["core"]
def test_simulate_day_can_apply_tail_risk_day_scaler() -> None:
strategy = StrategyParams(
entry_minutes_after_open=10,
min_morning_gain_pct=0.01,
top_n=1,
slippage_bps=0.0,
daily_budget_reset=True,
initial_capital=10_000.0,
tail_risk_day_max_trades=1,
tail_risk_day_min_max_gain_pct=0.04,
tail_risk_day_max_avg_quality=5.0,
tail_risk_day_require_no_event=True,
tail_risk_day_exempt_largecap=True,
tail_risk_day_scale=0.5,
)
bars_by_ticker = {
"TAIL": _bars(
"2026-01-21",
open_price=10.0,
closes=[10.1, 10.35, 10.5, 10.55, 10.45, 10.4],
volumes=[120_000] * 6,
),
}
day = simulate_day(
bars_by_ticker,
"2026-01-21",
strategy,
daily_features_by_ticker={
"TAIL": {
"gap_pct": 0.01,
"avg_daily_vol_14d": 1_000_000.0,
"avg_dollar_vol_30d": 100_000_000.0,
"ret_5d": 0.01,
"entropy_20d": 0.45,
},
},
)
assert day.tail_risk_scaler == 0.5
assert round(day.capital_deployed, 2) == 5000.0
assert len(day.trades) == 1
def test_same_day_support_score_prefers_prior_and_entry_liquidity_over_weak_spike() -> None:
strong_liquidity = _same_day_support_score(
{
"avg_dollar_vol_30d": 90_000_000.0,
"entry_dollar_volume": 12_000_000.0,
"attention_wiki_spike_10d": 0.0,
"attention_article_count_3d": 0,
"attention_us_article_count_3d": 0,
"event_score": 0.0,
}
)
weak_single_name = _same_day_support_score(
{
"avg_dollar_vol_30d": 50_000_000.0,
"entry_dollar_volume": 18_000_000.0,
"attention_wiki_spike_10d": 0.8,
"attention_article_count_3d": 0,
"attention_us_article_count_3d": 0,
"event_score": 0.0,
}
)
assert round(strong_liquidity, 3) == 0.38
assert round(weak_single_name, 3) == 0.222
assert strong_liquidity > weak_single_name
def test_simulate_day_can_apply_tail_risk_scaler_on_weak_support_single_name() -> None:
strategy = StrategyParams(
entry_minutes_after_open=15,
confirmation_minutes_after_entry=5,
min_morning_gain_pct=0.01,
top_n=1,
slippage_bps=0.0,
daily_budget_reset=True,
initial_capital=10_000.0,
tail_risk_day_max_trades=1,
tail_risk_day_min_max_gain_pct=0.03,
tail_risk_day_max_support_score=0.25,
tail_risk_day_min_max_entropy_20d=0.80,
tail_risk_day_min_max_confirmation_return_pct=0.01,
tail_risk_day_exempt_largecap=True,
tail_risk_day_scale=0.6,
)
day = simulate_day(
{
"TAIL": _bars(
"2026-01-13",
open_price=72.64,
closes=[73.75, 73.02, 73.52, 76.235, 77.1, 70.965],
volumes=[56_506, 51_911, 16_479, 120_666, 78_459, 33_740],
),
},
"2026-01-13",
strategy,
daily_features_by_ticker={
"TAIL": {
"gap_pct": 0.0308,
"avg_daily_vol_14d": 853_044.0,
"avg_dollar_vol_30d": 51_257_180.0,
"ret_5d": -0.0023,
"entropy_20d": 0.8333,
"attention_wiki_spike_10d": 0.806,
},
},
)
assert day.tail_risk_scaler == 0.6
assert round(day.capital_deployed, 2) == 6000.0
assert len(day.trades) == 1
def test_tail_risk_event_exemption_requires_support_when_configured() -> None:
strategy = StrategyParams(
entry_minutes_after_open=10,
confirmation_minutes_after_entry=5,
min_morning_gain_pct=0.01,
top_n=1,
slippage_bps=0.0,
daily_budget_reset=True,
initial_capital=10_000.0,
use_event_sleeve=True,
event_min_score=1.0,
tail_risk_day_max_trades=1,
tail_risk_day_min_max_gain_pct=0.03,
tail_risk_day_max_support_score=0.35,
tail_risk_day_min_max_confirmation_return_pct=0.01,
tail_risk_day_require_no_event=True,
tail_risk_day_event_exemption_min_support_score=0.35,
tail_risk_day_scale=0.5,
)
day = simulate_day(
{
"WEAK_EVENT": _bars(
"2026-02-18",
open_price=10.0,
closes=[10.1, 10.35, 10.5, 10.7, 10.6, 10.2],
volumes=[120_000] * 6,
),
},
"2026-02-18",
strategy,
daily_features_by_ticker={
"WEAK_EVENT": {
"gap_pct": 0.04,
"avg_daily_vol_14d": 1_000_000.0,
"avg_dollar_vol_30d": 25_000_000.0,
"ret_5d": 0.07,
"entropy_20d": 0.82,
"event_flag": True,
"event_score": 1.0,
},
},
)
assert day.tail_risk_scaler == 0.5
assert round(day.capital_deployed, 2) == 5000.0
assert day.trades[0].support_score == 0.2
def test_tail_risk_event_exemption_keeps_supported_event_full_size() -> None:
strategy = StrategyParams(
entry_minutes_after_open=10,
confirmation_minutes_after_entry=5,
min_morning_gain_pct=0.01,
top_n=1,
slippage_bps=0.0,
daily_budget_reset=True,
initial_capital=10_000.0,
use_event_sleeve=True,
event_min_score=1.0,
tail_risk_day_max_trades=1,
tail_risk_day_min_max_gain_pct=0.03,
tail_risk_day_max_support_score=0.60,
tail_risk_day_min_max_confirmation_return_pct=0.01,
tail_risk_day_require_no_event=True,
tail_risk_day_event_exemption_min_support_score=0.35,
tail_risk_day_scale=0.5,
)
day = simulate_day(
{
"SUPPORTED_EVENT": _bars(
"2026-02-18",
open_price=10.0,
closes=[10.1, 10.35, 10.5, 10.7, 10.6, 10.2],
volumes=[500_000] * 6,
),
},
"2026-02-18",
strategy,
daily_features_by_ticker={
"SUPPORTED_EVENT": {
"gap_pct": 0.04,
"avg_daily_vol_14d": 3_000_000.0,
"avg_dollar_vol_30d": 100_000_000.0,
"ret_5d": 0.07,
"entropy_20d": 0.82,
"event_flag": True,
"event_score": 1.0,
},
},
)
assert day.tail_risk_scaler == 1.0
assert round(day.capital_deployed, 2) == 10000.0
assert day.trades[0].support_score is not None
assert day.trades[0].support_score >= 0.35
def test_low_momentum_single_name_scaler_reduces_weak_single_pick() -> None:
strategy = StrategyParams(
entry_minutes_after_open=10,
confirmation_minutes_after_entry=5,
min_morning_gain_pct=0.01,
top_n=1,
slippage_bps=0.0,
daily_budget_reset=True,
initial_capital=10_000.0,
low_momentum_single_name_max_gain_pct=0.025,
low_momentum_single_name_require_no_event=True,
low_momentum_single_name_exempt_largecap=True,
low_momentum_single_name_scale=0.55,
)
day = simulate_day(
{
"LOW": _bars(
"2025-12-15",
open_price=100.0,
closes=[100.8, 101.1, 101.4, 101.8, 101.7, 97.0],
volumes=[100_000] * 6,
),
},
"2025-12-15",
strategy,
daily_features_by_ticker={
"LOW": {
"gap_pct": 0.02,
"avg_daily_vol_14d": 1_000_000.0,
"avg_dollar_vol_30d": 150_000_000.0,
"ret_5d": 0.02,
"entropy_20d": 0.75,
"event_flag": False,
"event_score": 0.0,
},
},
)
assert day.tail_risk_scaler == 0.55
assert round(day.capital_deployed, 2) == 5500.0
assert len(day.trades) == 1
def test_soft_day_sparse_scaler_reduces_unsupported_soft_basket() -> None:
strategy = StrategyParams(
entry_minutes_after_open=10,
confirmation_minutes_after_entry=5,
min_morning_gain_pct=0.01,
top_n=1,
slippage_bps=0.0,
daily_budget_reset=True,
initial_capital=10_000.0,
market_regime_gap_threshold=-0.03,
market_regime_gap_ticker="SPY",
regime_size_scale_low=-0.02,
regime_size_scale_high=0.0,
regime_size_scale_min=0.5,
soft_day_scaler_threshold=0.8,
soft_day_sparse_max_trades=2,
soft_day_sparse_require_no_event=True,
soft_day_sparse_exempt_largecap=True,
soft_day_sparse_exempt_moderate_gap_liquid=True,
soft_day_sparse_scale=0.7,
)
day = simulate_day(
{
"FRAGILE": _bars(
"2026-01-28",
open_price=20.0,
closes=[20.1, 20.3, 20.5, 20.7, 20.9, 19.8],
volumes=[100_000] * 6,
),
},
"2026-01-28",
strategy,
daily_features_by_ticker={
"SPY": {"prev_close": 100.0, "today_open": 98.0},
"FRAGILE": {
"gap_pct": 0.015,
"avg_daily_vol_14d": 1_000_000.0,
"avg_dollar_vol_30d": 120_000_000.0,
"ret_5d": 0.01,
"entropy_20d": 0.75,
"event_flag": False,
"event_score": 0.0,
},
},
)
assert day.is_soft_day is True
assert day.soft_day_sparse_scaler == 0.7
assert day.tail_risk_scaler == 0.7
assert round(day.capital_deployed, 2) == 3500.0
def test_soft_day_sparse_scaler_keeps_supported_moderate_liquid_basket_full_size() -> None:
strategy = StrategyParams(
entry_minutes_after_open=10,
confirmation_minutes_after_entry=5,
min_morning_gain_pct=0.01,
top_n=1,
slippage_bps=0.0,
daily_budget_reset=True,
initial_capital=10_000.0,
market_regime_gap_threshold=-0.03,
market_regime_gap_ticker="SPY",
regime_size_scale_low=-0.02,
regime_size_scale_high=0.0,
regime_size_scale_min=0.5,
soft_day_scaler_threshold=0.8,
soft_day_sparse_max_trades=2,
soft_day_sparse_require_no_event=True,
soft_day_sparse_exempt_largecap=True,
soft_day_sparse_exempt_moderate_gap_liquid=True,
soft_day_sparse_scale=0.7,
use_moderate_gap_liquid_sleeve=True,
moderate_gap_liquid_min_gap_pct=0.005,
moderate_gap_liquid_max_gap_pct=0.025,
moderate_gap_liquid_min_gain_pct=0.015,
moderate_gap_liquid_max_gain_pct=0.04,
moderate_gap_liquid_min_confirmation_return_pct=0.005,
moderate_gap_liquid_min_entry_dollar_volume=20_000_000.0,
moderate_gap_liquid_min_avg_dollar_vol_30d=250_000_000.0,
moderate_gap_liquid_max_avg_dollar_vol_30d=2_000_000_000.0,
moderate_gap_liquid_min_volume_ratio_14d=0.04,
moderate_gap_liquid_max_entropy_20d=0.86,
)
day = simulate_day(
{
"TER": _bars(
"2026-01-29",
open_price=100.0,
closes=[100.4, 101.0, 101.7, 102.3, 102.7, 103.0],
volumes=[500_000] * 6,
),
},
"2026-01-29",
strategy,
daily_features_by_ticker={
"SPY": {"prev_close": 100.0, "today_open": 98.0},
"TER": {
"gap_pct": 0.012,
"avg_daily_vol_14d": 8_000_000.0,
"avg_dollar_vol_30d": 750_000_000.0,
"ret_5d": 0.03,
"entropy_20d": 0.79,
"event_flag": False,
"event_score": 0.0,
},
},
)
assert day.is_soft_day is True
assert day.soft_day_sparse_scaler == 1.0
assert day.tail_risk_scaler == 1.0
assert round(day.capital_deployed, 2) == 5000.0
assert day.trades[0].is_moderate_gap_liquid is True
def test_simulate_day_keeps_full_size_for_supported_single_name() -> None:
strategy = StrategyParams(
entry_minutes_after_open=15,
confirmation_minutes_after_entry=5,
min_morning_gain_pct=0.01,
top_n=1,
slippage_bps=0.0,
daily_budget_reset=True,
initial_capital=10_000.0,
tail_risk_day_max_trades=1,
tail_risk_day_min_max_gain_pct=0.03,
tail_risk_day_max_support_score=0.25,
tail_risk_day_min_max_entropy_20d=0.80,
tail_risk_day_min_max_confirmation_return_pct=0.01,
tail_risk_day_scale=0.6,
)
day = simulate_day(
{
"FLY": _bars(
"2026-01-27",
open_price=25.61,
closes=[25.59, 25.673, 26.4162, 26.65, 26.5381, 29.455],
volumes=[98_085, 69_040, 108_146, 159_489, 167_075, 60_723],
),
},
"2026-01-27",
strategy,
daily_features_by_ticker={
"FLY": {
"gap_pct": 0.0163,
"avg_daily_vol_14d": 3_455_439.0,
"avg_dollar_vol_30d": 86_573_309.1,
"ret_5d": -0.1696,
"entropy_20d": 0.7976,
},
},
)
assert day.tail_risk_scaler == 1.0
assert round(day.capital_deployed, 2) == 10000.0
assert len(day.trades) == 1
def test_select_momentum_sleeves_can_add_liquid_largecap_fallback_when_sparse() -> None:
strategy = StrategyParams(
top_n=3,
use_five_sleeves=True,
five_sleeve_force_count=0,
five_sleeve_core_weight=1.0,
five_sleeve_gap_weight=0.0,
five_sleeve_volume_weight=0.0,
five_sleeve_entropy_weight=0.0,
five_sleeve_trend_weight=0.0,
fallback_liquid_largecap_slots=1,
fallback_liquid_largecap_trigger_below=2,
)
morning_gains = {
"FAST": {
"gain_pct": 0.06,
"entry_volume": 150_000,
"entry_dollar_volume": 5_000_000.0,
"avg_dollar_vol_30d": 100_000_000.0,
"confirmation_return_pct": 0.003,
"volume_ratio_14d": 0.03,
"gap_pct": 0.01,
"ret_5d": 0.02,
"entropy_20d": 0.50,
"is_liquid_largecap": False,
},
"LQ": {
"gain_pct": 0.008,
"entry_volume": 500_000,
"entry_dollar_volume": 80_000_000.0,
"avg_dollar_vol_30d": 1_500_000_000.0,
"confirmation_return_pct": 0.005,
"volume_ratio_14d": 0.12,
"gap_pct": 0.01,
"ret_5d": 0.01,
"entropy_20d": 0.85,
"is_liquid_largecap": True,
},
}
picks = _select_momentum_sleeves(morning_gains, strategy)
assert ("FAST", "blend") in picks
assert ("LQ", "liquid_largecap_fallback") in picks
def test_simulate_day_can_add_liquid_cluster_engine_without_disturbing_base_basket() -> None:
strategy = StrategyParams(
entry_minutes_after_open=10,
confirmation_minutes_after_entry=5,
min_morning_gain_pct=0.04,
min_confirmation_return_pct=0.003,
top_n=1,
slippage_bps=0.0,
daily_budget_reset=True,
initial_capital=10_000.0,
use_liquid_cluster_engine=True,
liquid_cluster_capital_fraction=0.25,
liquid_cluster_max_positions=1,
liquid_cluster_min_members=2,
liquid_cluster_min_gain_pct=0.015,
liquid_cluster_max_gain_pct=0.04,
liquid_cluster_min_confirmation_return_pct=0.003,
liquid_cluster_min_entry_dollar_volume=30_000_000.0,
liquid_cluster_min_avg_dollar_vol_30d=300_000_000.0,
liquid_cluster_min_volume_ratio_14d=0.10,
liquid_cluster_max_entropy_20d=0.80,
liquid_cluster_min_sector_avg_confirmation_return_pct=0.003,
liquid_cluster_min_sector_total_entry_dollar_volume=100_000_000.0,
)
day = simulate_day(
{
"CORE": _bars(
"2026-02-03",
open_price=100.0,
closes=[100.5, 102.0, 103.0, 106.0, 106.4, 107.0],
volumes=[250_000] * 6,
),
"CL_A": _bars(
"2026-02-03",
open_price=50.0,
closes=[50.2, 50.7, 50.9, 51.2, 51.3, 51.4],
volumes=[400_000] * 6,
),
"CL_B": _bars(
"2026-02-03",
open_price=60.0,
closes=[60.2, 60.7, 60.9, 61.3, 61.4, 61.5],
volumes=[350_000] * 6,
),
},
"2026-02-03",
strategy,
daily_features_by_ticker={
"CORE": {
"gap_pct": 0.03,
"avg_daily_vol_14d": 2_000_000.0,
"avg_dollar_vol_30d": 900_000_000.0,
"ret_5d": 0.12,
"entropy_20d": 0.40,
},
"CL_A": {
"gap_pct": 0.015,
"avg_daily_vol_14d": 3_000_000.0,
"avg_dollar_vol_30d": 800_000_000.0,
"ret_5d": 0.05,
"entropy_20d": 0.45,
},
"CL_B": {
"gap_pct": 0.012,
"avg_daily_vol_14d": 2_500_000.0,
"avg_dollar_vol_30d": 750_000_000.0,
"ret_5d": 0.04,
"entropy_20d": 0.48,
},
},
ticker_sectors={
"CORE": "Energy",
"CL_A": "Technology",
"CL_B": "Technology",
},
)
assert day.trades[0].ticker == "CORE"
assert day.trades[0].trade_sleeve == "core"
assert day.trades[1].ticker in {"CL_A", "CL_B"}
assert day.trades[1].trade_sleeve == "liquid_cluster_engine"
assert day.trades[1].is_liquid_cluster is True
assert day.trades[1].liquid_cluster_sector == "Technology"
assert day.capital_deployed == 10_000.0
def test_simulate_day_can_add_event_day_liquid_sleeve_without_changing_base_basket() -> None:
strategy = StrategyParams(
entry_minutes_after_open=10,
confirmation_minutes_after_entry=5,
min_morning_gain_pct=0.04,
min_confirmation_return_pct=0.003,
top_n=1,
use_five_sleeves=False,
slippage_bps=0.0,
daily_budget_reset=True,
initial_capital=10_000.0,
candidate_allowed_event_types=["earnings_release"],
use_event_day_liquid_sleeve=True,
event_day_liquid_capital_fraction=0.25,
event_day_liquid_max_positions=1,
event_day_liquid_min_event_names=1,
event_day_liquid_min_event_score=1.0,
event_day_liquid_min_event_support_score=0.2,
event_day_liquid_min_gain_pct=0.005,
event_day_liquid_max_gain_pct=0.03,
event_day_liquid_min_confirmation_return_pct=0.003,
event_day_liquid_min_entry_dollar_volume=30_000_000.0,
event_day_liquid_min_avg_dollar_vol_30d=500_000_000.0,
event_day_liquid_max_entropy_20d=0.80,
event_day_liquid_min_support_score=0.50,
liquid_largecap_min_gain_pct=0.005,
liquid_largecap_max_gain_pct=0.03,
liquid_largecap_min_confirmation_return_pct=0.003,
liquid_largecap_min_entry_dollar_volume=30_000_000.0,
liquid_largecap_min_avg_dollar_vol_30d=500_000_000.0,
liquid_largecap_max_entropy_20d=0.80,
)
day = simulate_day(
{
"CORE": _bars(
"2026-02-05",
open_price=100.0,
closes=[100.5, 102.0, 103.2, 105.0, 105.6, 106.0],
volumes=[250_000] * 6,
),
"EVT": _bars(
"2026-02-05",
open_price=50.0,
closes=[50.3, 51.0, 51.8, 52.4, 52.7, 53.0],
volumes=[250_000] * 6,
),
"LQ": _bars(
"2026-02-05",
open_price=200.0,
closes=[200.4, 201.0, 201.7, 202.6, 202.9, 203.5],
volumes=[250_000] * 6,
),
},
"2026-02-05",
strategy,
daily_features_by_ticker={
"CORE": {
"gap_pct": 0.02,
"avg_daily_vol_14d": 2_000_000.0,
"avg_dollar_vol_30d": 1_000_000_000.0,
"ret_5d": 0.12,
"entropy_20d": 0.40,
},
"EVT": {
"gap_pct": 0.03,
"avg_daily_vol_14d": 1_500_000.0,
"avg_dollar_vol_30d": 600_000_000.0,
"ret_5d": 0.08,
"entropy_20d": 0.45,
"event_flag": True,
"event_score": 2.0,
"event_types": ["earnings_release"],
},
"LQ": {
"gap_pct": 0.01,
"avg_daily_vol_14d": 3_000_000.0,
"avg_dollar_vol_30d": 2_000_000_000.0,
"ret_5d": 0.03,
"entropy_20d": 0.40,
},
},
)
assert [trade.ticker for trade in day.trades] == ["CORE", "LQ"]
assert [trade.trade_sleeve for trade in day.trades] == ["core", "event_day_liquid"]
assert day.capital_deployed == 10_000.0
def test_event_day_liquid_activation_can_use_broader_raw_event_types_than_core_event_filters() -> None:
strategy = StrategyParams(
entry_minutes_after_open=10,
confirmation_minutes_after_entry=5,
min_morning_gain_pct=0.04,
min_confirmation_return_pct=0.003,
top_n=1,
use_five_sleeves=False,
slippage_bps=0.0,
daily_budget_reset=True,
initial_capital=10_000.0,
candidate_allowed_event_types=["earnings_release"],
use_event_day_liquid_sleeve=True,
event_day_liquid_capital_fraction=0.25,
event_day_liquid_max_positions=1,
event_day_liquid_allowed_event_types=["unknown"],
event_day_liquid_min_event_names=1,
event_day_liquid_min_event_score=1.0,
event_day_liquid_min_gain_pct=0.005,
event_day_liquid_max_gain_pct=0.03,
event_day_liquid_min_confirmation_return_pct=0.003,
event_day_liquid_min_entry_dollar_volume=30_000_000.0,
event_day_liquid_min_avg_dollar_vol_30d=500_000_000.0,
event_day_liquid_max_entropy_20d=0.80,
event_day_liquid_min_support_score=0.50,
liquid_largecap_min_gain_pct=0.005,
liquid_largecap_max_gain_pct=0.03,
liquid_largecap_min_confirmation_return_pct=0.003,
liquid_largecap_min_entry_dollar_volume=30_000_000.0,
liquid_largecap_min_avg_dollar_vol_30d=500_000_000.0,
liquid_largecap_max_entropy_20d=0.80,
)
day = simulate_day(
{
"CORE": _bars(
"2026-02-06",
open_price=100.0,
closes=[100.5, 102.0, 103.2, 105.0, 105.6, 106.0],
volumes=[250_000] * 6,
),
"RAW_EVT": _bars(
"2026-02-06",
open_price=50.0,
closes=[50.2, 50.9, 51.6, 52.2, 52.5, 52.9],
volumes=[250_000] * 6,
),
"LQ": _bars(
"2026-02-06",
open_price=200.0,
closes=[200.4, 201.0, 201.7, 202.6, 202.9, 203.5],
volumes=[250_000] * 6,
),
},
"2026-02-06",
strategy,
daily_features_by_ticker={
"CORE": {
"gap_pct": 0.02,
"avg_daily_vol_14d": 2_000_000.0,
"avg_dollar_vol_30d": 1_000_000_000.0,
"ret_5d": 0.12,
"entropy_20d": 0.40,
},
"RAW_EVT": {
"gap_pct": 0.03,
"avg_daily_vol_14d": 1_500_000.0,
"avg_dollar_vol_30d": 600_000_000.0,
"ret_5d": 0.08,
"entropy_20d": 0.45,
"event_flag": True,
"event_score": 2.0,
"event_types": ["unknown"],
},
"LQ": {
"gap_pct": 0.01,
"avg_daily_vol_14d": 3_000_000.0,
"avg_dollar_vol_30d": 2_000_000_000.0,
"ret_5d": 0.03,
"entropy_20d": 0.40,
},
},
)
assert [trade.ticker for trade in day.trades] == ["CORE", "LQ"]
assert [trade.trade_sleeve for trade in day.trades] == ["core", "event_day_liquid"]
def test_simulate_day_can_add_sector_etf_proxy_sleeve_from_liquid_cluster() -> None:
strategy = StrategyParams(
entry_minutes_after_open=10,
confirmation_minutes_after_entry=5,
min_morning_gain_pct=0.04,
min_confirmation_return_pct=0.003,
top_n=1,
slippage_bps=0.0,
daily_budget_reset=True,
initial_capital=10_000.0,
use_sector_etf_sleeve=True,
sector_etf_capital_fraction=0.25,
sector_etf_max_positions=1,
sector_etf_min_sector_score=0.20,
liquid_cluster_min_members=2,
liquid_cluster_min_gain_pct=0.015,
liquid_cluster_max_gain_pct=0.04,
liquid_cluster_min_confirmation_return_pct=0.003,
liquid_cluster_min_entry_dollar_volume=30_000_000.0,
liquid_cluster_min_avg_dollar_vol_30d=300_000_000.0,
liquid_cluster_min_volume_ratio_14d=0.10,
liquid_cluster_max_entropy_20d=0.80,
liquid_cluster_min_sector_avg_confirmation_return_pct=0.003,
liquid_cluster_min_sector_total_entry_dollar_volume=100_000_000.0,
)
day = simulate_day(
{
"CORE": _bars(
"2026-02-04",
open_price=100.0,
closes=[100.5, 102.0, 103.0, 106.0, 106.4, 107.0],
volumes=[250_000] * 6,
),
"CL_A": _bars(
"2026-02-04",
open_price=50.0,
closes=[50.2, 50.7, 50.9, 51.2, 51.3, 51.4],
volumes=[400_000] * 6,
),
"CL_B": _bars(
"2026-02-04",
open_price=60.0,
closes=[60.2, 60.7, 60.9, 61.3, 61.4, 61.5],
volumes=[350_000] * 6,
),
},
"2026-02-04",
strategy,
daily_features_by_ticker={
"CORE": {
"gap_pct": 0.03,
"avg_daily_vol_14d": 2_000_000.0,
"avg_dollar_vol_30d": 900_000_000.0,
"ret_5d": 0.12,
"entropy_20d": 0.40,
},
"CL_A": {
"gap_pct": 0.015,
"avg_daily_vol_14d": 3_000_000.0,
"avg_dollar_vol_30d": 800_000_000.0,
"ret_5d": 0.05,
"entropy_20d": 0.45,
},
"CL_B": {
"gap_pct": 0.012,
"avg_daily_vol_14d": 2_500_000.0,
"avg_dollar_vol_30d": 750_000_000.0,
"ret_5d": 0.04,
"entropy_20d": 0.48,
},
},
ticker_sectors={
"CORE": "Energy",
"CL_A": "Technology",
"CL_B": "Technology",
},
sector_proxy_bars_by_ticker={
"XLK": _bars(
"2026-02-04",
open_price=200.0,
closes=[200.3, 201.0, 201.3, 202.0, 202.1, 202.6],
volumes=[150_000] * 6,
),
},
)
assert [trade.ticker for trade in day.trades] == ["CORE", "XLK"]
assert [trade.trade_sleeve for trade in day.trades] == ["core", "sector_etf"]
assert day.trades[1].liquid_cluster_sector == "Technology"
assert day.trades[1].sector_proxy_ticker == "XLK"
assert day.capital_deployed == 10_000.0
def test_compute_morning_gains_applies_entry_dollar_volume_filter() -> None:
strategy = StrategyParams(
entry_minutes_after_open=10,
min_morning_gain_pct=0.01,
min_entry_volume=100_000,
min_entry_dollar_volume=2_000_000,
)
bars_by_ticker = {
"CHEAP": _bars(
"2026-01-14",
open_price=5.0,
closes=[5.1, 5.3, 5.4, 5.5, 5.6, 5.7],
volumes=[60_000, 60_000, 60_000, 60_000, 60_000, 60_000],
),
"RICH": _bars(
"2026-01-14",
open_price=20.0,
closes=[20.2, 20.8, 21.0, 21.2, 21.3, 21.4],
volumes=[60_000, 60_000, 60_000, 60_000, 60_000, 60_000],
),
}
gains = compute_morning_gains(
bars_by_ticker,
strategy,
"2026-01-14",
)
assert list(gains) == ["RICH"]
def test_compute_morning_gains_allows_liquid_largecap_candidates_for_fallback() -> None:
strategy = StrategyParams(
entry_minutes_after_open=10,
confirmation_minutes_after_entry=5,
min_morning_gain_pct=0.015,
min_confirmation_return_pct=0.005,
fallback_liquid_largecap_slots=1,
fallback_liquid_largecap_trigger_below=2,
liquid_largecap_min_gain_pct=0.004,
liquid_largecap_max_gain_pct=0.02,
liquid_largecap_min_confirmation_return_pct=0.0005,
liquid_largecap_min_entry_dollar_volume=50_000_000,
liquid_largecap_min_avg_dollar_vol_30d=500_000_000,
liquid_largecap_max_entropy_20d=0.90,
)
bars_by_ticker = {
"LQ": _bars(
"2026-01-13",
open_price=100.0,
closes=[100.2, 100.5, 100.8, 101.4, 101.6, 101.8],
volumes=[500_000, 500_000, 500_000, 500_000, 500_000, 500_000],
),
}
gains = compute_morning_gains(
bars_by_ticker,
strategy,
"2026-01-13",
daily_features_by_ticker={
"LQ": {
"avg_daily_vol_14d": 10_000_000.0,
"avg_dollar_vol_30d": 1_000_000_000.0,
"ret_5d": 0.01,
"entropy_20d": 0.88,
"gap_pct": 0.01,
}
},
)
assert list(gains) == ["LQ"]
assert gains["LQ"]["is_liquid_largecap"] is True
def test_simulate_day_skips_sparse_baskets_when_min_positions_required() -> None:
strategy = StrategyParams(
entry_minutes_after_open=10,
min_morning_gain_pct=0.01,
top_n=3,
min_positions_to_trade=2,
)
bars_by_ticker = {
"ONLY": _bars(
"2026-01-15",
open_price=10.0,
closes=[10.2, 10.7, 10.9, 11.0, 11.1, 11.2],
volumes=[120_000] * 6,
),
}
day = simulate_day(
bars_by_ticker,
"2026-01-15",
strategy,
)
assert day.candidates_found == 1
assert day.trades == []
assert day.daily_pnl == 0.0
def test_simulate_day_scales_sparse_days_when_threshold_set() -> None:
base = StrategyParams(
entry_minutes_after_open=10,
min_morning_gain_pct=0.01,
top_n=3,
initial_capital=9_000.0,
exit_minutes_before_close=5,
)
scaled = StrategyParams(
entry_minutes_after_open=10,
min_morning_gain_pct=0.01,
top_n=3,
initial_capital=9_000.0,
exit_minutes_before_close=5,
full_size_positions_threshold=4,
sparse_day_size_floor=0.5,
)
bars_by_ticker = {
"ONLY": _bars(
"2026-01-16",
open_price=10.0,
closes=[10.2, 10.8, 11.0, 11.1, 11.2, 11.4],
volumes=[120_000] * 6,
),
}
base_day = simulate_day(bars_by_ticker, "2026-01-16", base)
scaled_day = simulate_day(bars_by_ticker, "2026-01-16", scaled)
assert len(base_day.trades) == 1
assert len(scaled_day.trades) == 1
assert round(scaled_day.daily_pnl, 2) == round(base_day.daily_pnl * 0.5, 2)
def test_simulate_day_tightens_trailing_for_overextended_leaders() -> None:
base = StrategyParams(
entry_minutes_after_open=10,
min_morning_gain_pct=0.01,
trailing_stop_pct=-0.075,
exit_minutes_before_close=5,
)
tightened = StrategyParams(
entry_minutes_after_open=10,
min_morning_gain_pct=0.01,
trailing_stop_pct=-0.075,
overextended_trailing_gain_pct=0.05,
overextended_trailing_stop_pct=-0.065,
exit_minutes_before_close=5,
)
bars_by_ticker = {
"HOT": _bars(
"2026-01-20",
open_price=10.0,
closes=[10.3, 10.8, 10.9, 10.4, 10.3, 10.2],
volumes=[120_000] * 6,
),
}
base_day = simulate_day(bars_by_ticker, "2026-01-20", base)
tightened_day = simulate_day(bars_by_ticker, "2026-01-20", tightened)
assert len(base_day.trades) == 1
assert len(tightened_day.trades) == 1
assert tightened_day.trades[0].exit_reason == "trailing_stop"
assert base_day.trades[0].exit_reason == "trailing_stop"
assert tightened_day.trades[0].exit_price > base_day.trades[0].exit_price
assert tightened_day.daily_pnl > base_day.daily_pnl
def test_simulate_day_can_use_atr_catastrophic_stop_without_trailing() -> None:
strategy = StrategyParams(
entry_minutes_after_open=10,
min_morning_gain_pct=0.01,
exit_minutes_before_close=5,
atr_stop_multiplier=0.5,
trailing_stop_pct=None,
)
bars_by_ticker = {
"ATR": _bars(
"2026-01-21",
open_price=10.0,
closes=[10.2, 10.8, 11.0, 10.6, 10.5, 10.4],
volumes=[120_000] * 6,
),
}
day = simulate_day(
bars_by_ticker,
"2026-01-21",
strategy,
daily_features_by_ticker={
"ATR": {
"gap_pct": 0.01,
"avg_daily_vol_14d": 1_000_000.0,
"atr_14": 1.0,
}
},
)
assert len(day.trades) == 1
assert day.trades[0].exit_reason == "stop_loss"
def test_simulate_day_delays_trailing_until_gain_threshold() -> None:
strategy = StrategyParams(
entry_minutes_after_open=10,
min_morning_gain_pct=0.01,
exit_minutes_before_close=5,
atr_stop_multiplier=0.5,
trailing_stop_pct=-0.05,
trailing_activation_gain_pct=0.03,
)
bars_by_ticker = {
"TRAIL": _bars(
"2026-01-22",
open_price=10.0,
closes=[10.2, 10.8, 11.0, 11.3, 11.0, 10.9],
volumes=[120_000] * 6,
),
}
day = simulate_day(
bars_by_ticker,
"2026-01-22",
strategy,
daily_features_by_ticker={
"TRAIL": {
"gap_pct": 0.01,
"avg_daily_vol_14d": 1_000_000.0,
"atr_14": 1.0,
}
},
)
assert len(day.trades) == 1
assert day.trades[0].exit_reason == "trailing_stop"
def test_simulate_day_can_use_opening_range_catastrophic_stop() -> None:
strategy = StrategyParams(
entry_minutes_after_open=10,
min_morning_gain_pct=0.01,
exit_minutes_before_close=5,
opening_range_stop_multiplier=1.0,
trailing_stop_pct=None,
)
bars_by_ticker = {
"OR": _bars(
"2026-01-23",
open_price=10.0,
closes=[10.15, 10.25, 10.3, 9.6, 9.5, 9.4],
volumes=[120_000] * 6,
),
}
day = simulate_day(
bars_by_ticker,
"2026-01-23",
strategy,
daily_features_by_ticker={
"OR": {
"gap_pct": 0.01,
"avg_daily_vol_14d": 1_000_000.0,
}
},
)
assert len(day.trades) == 1
assert day.trades[0].exit_reason == "stop_loss"