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.
837 lines
26 KiB
Python
837 lines
26 KiB
Python
from __future__ import annotations
|
|
|
|
from libs.intraday.domain import StrategyParams
|
|
from libs.intraday.simulator import _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_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_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_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_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_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"
|