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.
681 lines
26 KiB
Python
681 lines
26 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import pickle
|
|
from datetime import datetime
|
|
from types import SimpleNamespace
|
|
from zoneinfo import ZoneInfo
|
|
|
|
import apps.intraday_bt.run as run_mod
|
|
from apps.intraday_bt.run import (
|
|
_augment_momentum_seed_candidates_with_liquid_overlay,
|
|
_chunk_trading_days_by_pairs,
|
|
_fetch_vix_by_day,
|
|
_latest_backtest_date,
|
|
_latest_completed_trading_day,
|
|
_load_vix_from_local_macro_snapshots,
|
|
_normalize_candidate_map,
|
|
_momentum_intraday_seed_candidates,
|
|
_momentum_strategy_uses_candidate_stage_catalyst,
|
|
_momentum_strategy_uses_daily_enrichment,
|
|
_momentum_strategy_requires_regime_ticker_daily,
|
|
_momentum_strategy_uses_attention,
|
|
_momentum_strategy_uses_catalyst,
|
|
_momentum_strategy_uses_sector_labels,
|
|
_momentum_strategy_uses_sector_proxies,
|
|
_retain_recent_intraday_shortlist,
|
|
_recent_intraday_first_candidates,
|
|
_strategy_for_recent_live_scan,
|
|
_should_use_recent_live_scan,
|
|
_should_use_recent_intraday_first_scan,
|
|
apply_cli_overrides,
|
|
get_trading_days,
|
|
load_config,
|
|
)
|
|
from libs.intraday.domain import StrategyParams
|
|
|
|
|
|
def test_get_trading_days_uses_local_calendar_for_explicit_range() -> None:
|
|
days = asyncio.run(get_trading_days(None, "2026-01-01", "2026-01-10", lookback=0))
|
|
|
|
assert days == [
|
|
"2026-01-02",
|
|
"2026-01-05",
|
|
"2026-01-06",
|
|
"2026-01-07",
|
|
"2026-01-08",
|
|
"2026-01-09",
|
|
]
|
|
|
|
|
|
def test_get_trading_days_trims_to_lookback_when_start_not_pinned() -> None:
|
|
days = asyncio.run(get_trading_days(None, None, "2026-01-10", lookback=3))
|
|
|
|
assert days == [
|
|
"2026-01-07",
|
|
"2026-01-08",
|
|
"2026-01-09",
|
|
]
|
|
|
|
|
|
def test_chunk_trading_days_by_pairs_keeps_days_contiguous_and_bounded() -> None:
|
|
trading_days = [
|
|
"2026-01-05",
|
|
"2026-01-06",
|
|
"2026-01-07",
|
|
"2026-01-08",
|
|
]
|
|
candidates = {
|
|
"2026-01-05": ["A"] * 2000,
|
|
"2026-01-06": ["B"] * 2000,
|
|
"2026-01-07": ["C"] * 1500,
|
|
"2026-01-08": ["D"] * 2500,
|
|
}
|
|
|
|
chunks = _chunk_trading_days_by_pairs(trading_days, candidates, max_pairs_per_chunk=3500)
|
|
|
|
assert chunks == [
|
|
["2026-01-05"],
|
|
["2026-01-06", "2026-01-07"],
|
|
["2026-01-08"],
|
|
]
|
|
|
|
|
|
def test_normalize_candidate_map_unwraps_tuple_payload() -> None:
|
|
payload = ({"2026-01-05": ["A", "B"], "2026-01-06": []}, {"2026-01-05": {"A"}})
|
|
|
|
normalized = _normalize_candidate_map(payload)
|
|
|
|
assert normalized == {"2026-01-05": ["A", "B"]}
|
|
|
|
|
|
def test_latest_backtest_date_excludes_today_before_close() -> None:
|
|
now_et = datetime(2026, 4, 14, 12, 0, tzinfo=ZoneInfo("America/New_York"))
|
|
|
|
latest = _latest_backtest_date(now_et)
|
|
|
|
assert latest.isoformat() == "2026-04-13"
|
|
|
|
|
|
def test_latest_backtest_date_includes_today_after_close() -> None:
|
|
now_et = datetime(2026, 4, 14, 16, 1, tzinfo=ZoneInfo("America/New_York"))
|
|
|
|
latest = _latest_backtest_date(now_et)
|
|
|
|
assert latest.isoformat() == "2026-04-14"
|
|
|
|
|
|
def test_latest_backtest_date_uses_last_session_on_weekend() -> None:
|
|
now_et = datetime(2026, 4, 18, 10, 0, tzinfo=ZoneInfo("America/New_York"))
|
|
|
|
latest = _latest_backtest_date(now_et)
|
|
|
|
assert latest.isoformat() == "2026-04-18"
|
|
|
|
|
|
def test_latest_completed_trading_day_walks_back_on_weekend() -> None:
|
|
now_et = datetime(2026, 4, 18, 10, 0, tzinfo=ZoneInfo("America/New_York"))
|
|
|
|
latest = _latest_completed_trading_day(now_et)
|
|
|
|
assert latest.isoformat() == "2026-04-17"
|
|
|
|
|
|
def test_load_vix_from_local_macro_snapshots_uses_covering_file(tmp_path, monkeypatch) -> None:
|
|
parquet_dir = tmp_path / "parquet" / "sample"
|
|
parquet_dir.mkdir(parents=True, exist_ok=True)
|
|
payload = {
|
|
datetime(2025, 1, 2).date(): {"VIXCLS": 17.1},
|
|
datetime(2025, 1, 3).date(): {"VIXCLS": 18.2},
|
|
datetime(2025, 1, 6).date(): {"VIXCLS": 19.3},
|
|
}
|
|
path = parquet_dir / "macro_window_2025-01-01_2025-01-10.pkl"
|
|
with path.open("wb") as fh:
|
|
pickle.dump(payload, fh, protocol=pickle.HIGHEST_PROTOCOL)
|
|
|
|
monkeypatch.setattr(
|
|
run_mod,
|
|
"get_settings",
|
|
lambda: SimpleNamespace(data_root=str(tmp_path)),
|
|
)
|
|
|
|
result = _load_vix_from_local_macro_snapshots(["2025-01-02", "2025-01-03", "2025-01-06"])
|
|
|
|
assert result == {
|
|
"2025-01-02": 17.1,
|
|
"2025-01-03": 18.2,
|
|
"2025-01-06": 19.3,
|
|
}
|
|
|
|
|
|
def test_fetch_vix_by_day_uses_local_snapshot_when_health_check_fails(tmp_path, monkeypatch) -> None:
|
|
parquet_dir = tmp_path / "parquet" / "sample"
|
|
parquet_dir.mkdir(parents=True, exist_ok=True)
|
|
payload = {
|
|
datetime(2025, 1, 2).date(): {"VIXCLS": 17.1},
|
|
datetime(2025, 1, 3).date(): {"VIXCLS": 18.2},
|
|
}
|
|
path = parquet_dir / "macro_window_2025-01-01_2025-01-10.pkl"
|
|
with path.open("wb") as fh:
|
|
pickle.dump(payload, fh, protocol=pickle.HIGHEST_PROTOCOL)
|
|
|
|
monkeypatch.setattr(
|
|
run_mod,
|
|
"get_settings",
|
|
lambda: SimpleNamespace(data_root=str(tmp_path)),
|
|
)
|
|
|
|
class _Client:
|
|
async def health_check_fast(self, timeout: float = 3.0) -> bool:
|
|
return False
|
|
|
|
result = asyncio.run(_fetch_vix_by_day(_Client(), ["2025-01-02", "2025-01-03"]))
|
|
|
|
assert result == {
|
|
"2025-01-02": 17.1,
|
|
"2025-01-03": 18.2,
|
|
}
|
|
|
|
|
|
def test_load_ticker_sectors_with_oracle_backfills_missing_cache(tmp_path, monkeypatch) -> None:
|
|
cache_path = tmp_path / "cache" / "sector_cache.json"
|
|
cache_path.parent.mkdir(parents=True, exist_ok=True)
|
|
cache_path.write_text('{"TSLA": "Consumer Cyclical"}')
|
|
|
|
monkeypatch.setattr(
|
|
run_mod,
|
|
"get_settings",
|
|
lambda: SimpleNamespace(data_root=str(tmp_path)),
|
|
)
|
|
|
|
class FakeCompanyService:
|
|
def __init__(self, client) -> None:
|
|
self.client = client
|
|
|
|
async def get_company(self, symbol: str):
|
|
if symbol == "CPRX":
|
|
return SimpleNamespace(
|
|
sector="Healthcare",
|
|
industry="Biotechnology",
|
|
exchange="NASDAQ",
|
|
market_cap=2_979_108_098.0,
|
|
)
|
|
raise AssertionError(f"unexpected symbol {symbol}")
|
|
|
|
monkeypatch.setattr(run_mod, "CompanyService", FakeCompanyService)
|
|
|
|
result = asyncio.run(
|
|
run_mod._load_ticker_sectors_with_oracle(["TSLA", "CPRX"], client=object())
|
|
)
|
|
|
|
assert result == {
|
|
"TSLA": "Consumer Cyclical",
|
|
"CPRX": "Healthcare",
|
|
}
|
|
assert cache_path.exists()
|
|
assert '"CPRX": "Healthcare"' in cache_path.read_text()
|
|
|
|
|
|
def test_load_ticker_sectors_with_oracle_skips_placeholder_metadata(tmp_path, monkeypatch) -> None:
|
|
cache_path = tmp_path / "cache" / "sector_cache.json"
|
|
cache_path.parent.mkdir(parents=True, exist_ok=True)
|
|
cache_path.write_text("{}")
|
|
|
|
monkeypatch.setattr(
|
|
run_mod,
|
|
"get_settings",
|
|
lambda: SimpleNamespace(data_root=str(tmp_path)),
|
|
)
|
|
|
|
class FakeCompanyService:
|
|
def __init__(self, client) -> None:
|
|
self.client = client
|
|
|
|
async def get_company(self, symbol: str):
|
|
return SimpleNamespace(
|
|
sector="Technology",
|
|
industry="Software",
|
|
exchange=None,
|
|
market_cap=None,
|
|
)
|
|
|
|
monkeypatch.setattr(run_mod, "CompanyService", FakeCompanyService)
|
|
|
|
result = asyncio.run(
|
|
run_mod._load_ticker_sectors_with_oracle(["AVGO"], client=object())
|
|
)
|
|
|
|
assert result == {"AVGO": "UNKNOWN"}
|
|
assert cache_path.read_text() == "{}"
|
|
|
|
|
|
def test_should_use_recent_live_scan_only_for_small_recent_windows(monkeypatch) -> None:
|
|
monkeypatch.setattr(
|
|
run_mod,
|
|
"_latest_backtest_date",
|
|
lambda now_et=None: datetime(2026, 4, 16, tzinfo=ZoneInfo("America/New_York")).date(),
|
|
)
|
|
strategy = StrategyParams(recent_live_scan_days=5)
|
|
|
|
assert _should_use_recent_live_scan(strategy, ["2026-04-15", "2026-04-16"]) is True
|
|
assert _should_use_recent_live_scan(
|
|
strategy,
|
|
["2026-04-09", "2026-04-10", "2026-04-13", "2026-04-14", "2026-04-15", "2026-04-16"],
|
|
) is False
|
|
assert _should_use_recent_live_scan(strategy, ["2026-03-01"]) is False
|
|
|
|
|
|
def test_momentum_strategy_uses_intraday_event_weight_for_fetch_activation() -> None:
|
|
strategy = StrategyParams(candidate_intraday_weight_event_score=0.05)
|
|
|
|
assert _momentum_strategy_uses_catalyst(strategy) is True
|
|
|
|
|
|
def test_momentum_strategy_uses_seed_event_overlay_for_candidate_stage_catalyst() -> None:
|
|
strategy = StrategyParams(candidate_seed_event_overlay_slots=2)
|
|
|
|
assert _momentum_strategy_uses_catalyst(strategy) is True
|
|
assert _momentum_strategy_uses_candidate_stage_catalyst(strategy) is True
|
|
|
|
|
|
def test_momentum_strategy_uses_candidate_event_type_filter_for_fetch_activation() -> None:
|
|
strategy = StrategyParams(candidate_allowed_event_types=["earnings_release"])
|
|
|
|
assert _momentum_strategy_uses_catalyst(strategy) is True
|
|
assert _momentum_strategy_uses_candidate_stage_catalyst(strategy) is True
|
|
|
|
|
|
def test_momentum_strategy_uses_event_reserve_and_event_sleeve_for_fetch_activation() -> None:
|
|
reserve_strategy = StrategyParams(candidate_intraday_event_reserve_slots=1)
|
|
sleeve_strategy = StrategyParams(use_event_sleeve=True, event_weight=0.1)
|
|
event_day_liquid_strategy = StrategyParams(
|
|
use_event_day_liquid_sleeve=True,
|
|
event_day_liquid_capital_fraction=0.1,
|
|
event_day_liquid_max_positions=1,
|
|
)
|
|
|
|
assert _momentum_strategy_uses_catalyst(reserve_strategy) is True
|
|
assert _momentum_strategy_uses_candidate_stage_catalyst(reserve_strategy) is False
|
|
assert _momentum_strategy_uses_catalyst(sleeve_strategy) is True
|
|
assert _momentum_strategy_uses_catalyst(event_day_liquid_strategy) is True
|
|
|
|
|
|
def test_momentum_strategy_uses_intraday_attention_weight_for_fetch_activation() -> None:
|
|
strategy = StrategyParams(candidate_intraday_weight_attention_news=0.03)
|
|
|
|
assert _momentum_strategy_uses_attention(strategy) is True
|
|
|
|
|
|
def test_momentum_strategy_requires_regime_ticker_daily_for_gap_meta_layer() -> None:
|
|
strategy = StrategyParams(regime_size_scale_low=-0.01)
|
|
|
|
assert _momentum_strategy_requires_regime_ticker_daily(strategy) is True
|
|
|
|
|
|
def test_momentum_strategy_uses_sector_metadata_and_proxy_fetch_for_overlay_engines() -> None:
|
|
cluster_strategy = StrategyParams(use_liquid_cluster_engine=True)
|
|
etf_strategy = StrategyParams(
|
|
use_sector_etf_sleeve=True,
|
|
sector_etf_capital_fraction=0.2,
|
|
sector_etf_max_positions=1,
|
|
)
|
|
|
|
assert _momentum_strategy_uses_daily_enrichment(cluster_strategy) is True
|
|
assert _momentum_strategy_uses_sector_labels(cluster_strategy) is True
|
|
assert _momentum_strategy_uses_sector_proxies(cluster_strategy) is False
|
|
assert _momentum_strategy_uses_sector_labels(etf_strategy) is True
|
|
assert _momentum_strategy_uses_sector_proxies(etf_strategy) is True
|
|
|
|
|
|
def test_momentum_intraday_seed_candidates_only_apply_signal_filters_in_final_pass() -> None:
|
|
daily_bars = {
|
|
"AAA": [
|
|
{"date": "2026-01-05", "open": 10.0},
|
|
],
|
|
"BBB": [
|
|
{"date": "2026-01-05", "open": 10.0},
|
|
],
|
|
}
|
|
enrichment = {
|
|
"AAA": {
|
|
"2026-01-05": {
|
|
"gap_pct": 0.03,
|
|
"event_flag": True,
|
|
"event_score": 1.0,
|
|
"ret_5d": 0.01,
|
|
"entropy_20d": 0.4,
|
|
"avg_dollar_vol_30d": 1_000_000.0,
|
|
"atr_14": 1.0,
|
|
}
|
|
},
|
|
"BBB": {
|
|
"2026-01-05": {
|
|
"gap_pct": 0.04,
|
|
"event_flag": False,
|
|
"event_score": 0.0,
|
|
"ret_5d": 0.01,
|
|
"entropy_20d": 0.4,
|
|
"avg_dollar_vol_30d": 1_000_000.0,
|
|
"atr_14": 1.0,
|
|
}
|
|
},
|
|
}
|
|
strategy = StrategyParams(
|
|
candidate_source_mode="intraday_first",
|
|
candidate_seed_threshold=0.02,
|
|
candidate_seed_max_per_day=1,
|
|
candidate_require_event_flag=True,
|
|
candidate_weight_event_score=1.0,
|
|
)
|
|
|
|
preliminary = _momentum_intraday_seed_candidates(
|
|
daily_bars,
|
|
["2026-01-05"],
|
|
enrichment,
|
|
strategy,
|
|
default_threshold=0.02,
|
|
use_signal_features=False,
|
|
)
|
|
final_seed = _momentum_intraday_seed_candidates(
|
|
daily_bars,
|
|
["2026-01-05"],
|
|
enrichment,
|
|
strategy,
|
|
default_threshold=0.02,
|
|
use_signal_features=True,
|
|
)
|
|
|
|
assert preliminary == {"2026-01-05": ["BBB"]}
|
|
assert final_seed == {"2026-01-05": ["AAA"]}
|
|
|
|
|
|
def test_recent_intraday_first_candidates_uses_intraday_leaders_without_static_universe() -> None:
|
|
strategy = StrategyParams(
|
|
entry_minutes_after_open=10,
|
|
confirmation_minutes_after_entry=5,
|
|
min_entry_volume=250000,
|
|
min_entry_dollar_volume=2_000_000,
|
|
top_n=8,
|
|
recent_live_scan_max_candidates_per_day=10,
|
|
)
|
|
day = "2026-04-16"
|
|
bars = {
|
|
day: {
|
|
"XNDU": [
|
|
{"timestamp": "2026-04-16T13:30:00+00:00", "open": 2.00, "high": 2.06, "low": 1.98, "close": 2.05, "volume": 150000},
|
|
{"timestamp": "2026-04-16T13:35:00+00:00", "open": 2.05, "high": 2.12, "low": 2.04, "close": 2.11, "volume": 175000},
|
|
{"timestamp": "2026-04-16T13:40:00+00:00", "open": 2.11, "high": 2.15, "low": 2.10, "close": 2.14, "volume": 180000},
|
|
{"timestamp": "2026-04-16T13:45:00+00:00", "open": 2.14, "high": 2.20, "low": 2.13, "close": 2.19, "volume": 200000},
|
|
{"timestamp": "2026-04-16T13:50:00+00:00", "open": 2.19, "high": 2.24, "low": 2.18, "close": 2.22, "volume": 210000},
|
|
],
|
|
"SLOW": [
|
|
{"timestamp": "2026-04-16T13:30:00+00:00", "open": 20.00, "high": 20.01, "low": 19.95, "close": 19.98, "volume": 50000},
|
|
{"timestamp": "2026-04-16T13:35:00+00:00", "open": 19.98, "high": 20.00, "low": 19.90, "close": 19.95, "volume": 50000},
|
|
{"timestamp": "2026-04-16T13:40:00+00:00", "open": 19.95, "high": 19.99, "low": 19.92, "close": 19.97, "volume": 50000},
|
|
{"timestamp": "2026-04-16T13:45:00+00:00", "open": 19.97, "high": 19.98, "low": 19.94, "close": 19.96, "volume": 50000},
|
|
{"timestamp": "2026-04-16T13:50:00+00:00", "open": 19.96, "high": 19.97, "low": 19.93, "close": 19.95, "volume": 50000},
|
|
{"timestamp": "2026-04-16T13:55:00+00:00", "open": 19.95, "high": 19.96, "low": 19.92, "close": 19.94, "volume": 50000},
|
|
],
|
|
"LIQUID": [
|
|
{"timestamp": "2026-04-16T13:30:00+00:00", "open": 400.00, "high": 401.00, "low": 399.00, "close": 400.20, "volume": 80000},
|
|
{"timestamp": "2026-04-16T13:35:00+00:00", "open": 400.20, "high": 401.20, "low": 400.10, "close": 400.80, "volume": 90000},
|
|
{"timestamp": "2026-04-16T13:40:00+00:00", "open": 400.80, "high": 402.50, "low": 400.70, "close": 402.20, "volume": 120000},
|
|
{"timestamp": "2026-04-16T13:45:00+00:00", "open": 402.20, "high": 403.20, "low": 401.90, "close": 402.80, "volume": 130000},
|
|
{"timestamp": "2026-04-16T13:50:00+00:00", "open": 402.80, "high": 404.00, "low": 402.70, "close": 403.60, "volume": 140000},
|
|
{"timestamp": "2026-04-16T13:55:00+00:00", "open": 403.60, "high": 404.20, "low": 403.40, "close": 404.00, "volume": 150000},
|
|
],
|
|
"MEGA": [
|
|
{"timestamp": "2026-04-16T13:30:00+00:00", "open": 500.00, "high": 500.80, "low": 499.50, "close": 500.10, "volume": 120000},
|
|
{"timestamp": "2026-04-16T13:35:00+00:00", "open": 500.10, "high": 500.90, "low": 500.00, "close": 500.40, "volume": 140000},
|
|
{"timestamp": "2026-04-16T13:40:00+00:00", "open": 500.40, "high": 501.10, "low": 500.20, "close": 500.70, "volume": 150000},
|
|
{"timestamp": "2026-04-16T13:45:00+00:00", "open": 500.70, "high": 501.30, "low": 500.50, "close": 500.90, "volume": 160000},
|
|
{"timestamp": "2026-04-16T13:50:00+00:00", "open": 500.90, "high": 501.50, "low": 500.80, "close": 501.20, "volume": 180000},
|
|
{"timestamp": "2026-04-16T13:55:00+00:00", "open": 501.20, "high": 501.70, "low": 501.00, "close": 501.40, "volume": 190000},
|
|
],
|
|
}
|
|
}
|
|
|
|
candidates = _recent_intraday_first_candidates(bars, [day], strategy)
|
|
|
|
assert "XNDU" in candidates[day]
|
|
assert "LIQUID" in candidates[day]
|
|
assert "MEGA" in candidates[day]
|
|
assert "SLOW" not in candidates[day]
|
|
|
|
|
|
def test_recent_intraday_first_scan_only_applies_to_latest_safe_day(monkeypatch) -> None:
|
|
assert _should_use_recent_intraday_first_scan(["2026-04-16"]) is True
|
|
assert _should_use_recent_intraday_first_scan(["2026-04-15"]) is True
|
|
|
|
|
|
def test_recent_intraday_first_scan_treats_latest_completed_weekday_as_recent(monkeypatch) -> None:
|
|
assert _should_use_recent_intraday_first_scan(["2026-04-17"]) is True
|
|
|
|
|
|
def test_recent_intraday_first_scan_false_for_empty_window() -> None:
|
|
assert _should_use_recent_intraday_first_scan([]) is False
|
|
|
|
|
|
def test_liquid_seed_overlay_adds_liquid_name_without_replacing_base_seed() -> None:
|
|
strategy = StrategyParams(
|
|
candidate_seed_liquid_overlay_slots=1,
|
|
candidate_seed_liquid_min_gap_pct=0.005,
|
|
candidate_seed_liquid_max_gap_pct=0.03,
|
|
candidate_seed_liquid_min_avg_dollar_vol_30d=500_000_000.0,
|
|
candidate_seed_liquid_min_ret_5d=0.0,
|
|
candidate_seed_liquid_max_entropy_20d=0.90,
|
|
)
|
|
daily_bars = {
|
|
"BASE": [{"date": "2026-04-15", "open": 50.0}],
|
|
"TSLA": [{"date": "2026-04-15", "open": 250.0}],
|
|
"NOPE": [{"date": "2026-04-15", "open": 30.0}],
|
|
}
|
|
enrichment = {
|
|
"BASE": {"2026-04-15": {"gap_pct": 0.03, "avg_dollar_vol_30d": 200_000_000.0, "ret_5d": 0.02, "entropy_20d": 0.40}},
|
|
"TSLA": {"2026-04-15": {"gap_pct": 0.007, "avg_dollar_vol_30d": 1_200_000_000.0, "ret_5d": 0.05, "entropy_20d": 0.70}},
|
|
"NOPE": {"2026-04-15": {"gap_pct": 0.009, "avg_dollar_vol_30d": 200_000_000.0, "ret_5d": 0.05, "entropy_20d": 0.50}},
|
|
}
|
|
candidates = {"2026-04-15": ["BASE"]}
|
|
|
|
augmented, overlay = _augment_momentum_seed_candidates_with_liquid_overlay(
|
|
candidates,
|
|
daily_bars,
|
|
["2026-04-15"],
|
|
enrichment,
|
|
strategy,
|
|
)
|
|
|
|
assert augmented["2026-04-15"] == ["BASE", "TSLA"]
|
|
|
|
|
|
def test_strategy_for_recent_live_scan_applies_only_recent_overrides() -> None:
|
|
strategy = StrategyParams(
|
|
top_n=8,
|
|
min_morning_gain_pct=0.015,
|
|
max_morning_gain_pct=0.06,
|
|
min_confirmation_return_pct=0.005,
|
|
max_gap_pct=0.055,
|
|
use_slow_ignite_sleeve=False,
|
|
recent_live_scan_top_n=10,
|
|
recent_live_scan_min_morning_gain_pct=0.01,
|
|
recent_live_scan_max_morning_gain_pct=0.05,
|
|
recent_live_scan_min_confirmation_return_pct=0.001,
|
|
recent_live_scan_max_gap_pct=0.04,
|
|
recent_live_scan_max_entropy_20d=0.9,
|
|
recent_live_scan_use_slow_ignite_sleeve=True,
|
|
recent_live_scan_slow_ignite_weight=0.2,
|
|
recent_live_scan_use_liquid_largecap_sleeve=True,
|
|
recent_live_scan_liquid_largecap_weight=0.3,
|
|
recent_live_scan_liquid_largecap_min_gain_pct=0.004,
|
|
recent_live_scan_liquid_largecap_max_gain_pct=0.02,
|
|
recent_live_scan_liquid_largecap_min_confirmation_return_pct=0.0005,
|
|
recent_live_scan_liquid_largecap_min_entry_dollar_volume=50_000_000.0,
|
|
recent_live_scan_liquid_largecap_min_avg_dollar_vol_30d=500_000_000.0,
|
|
recent_live_scan_liquid_largecap_max_entropy_20d=0.9,
|
|
)
|
|
|
|
unchanged = _strategy_for_recent_live_scan(strategy, recent_live_scan=False)
|
|
recent = _strategy_for_recent_live_scan(strategy, recent_live_scan=True)
|
|
|
|
assert unchanged.top_n == 8
|
|
assert unchanged.min_morning_gain_pct == 0.015
|
|
assert unchanged.max_morning_gain_pct == 0.06
|
|
assert unchanged.max_gap_pct == 0.055
|
|
assert unchanged.use_slow_ignite_sleeve is False
|
|
assert recent.top_n == 10
|
|
assert recent.min_morning_gain_pct == 0.01
|
|
assert recent.max_morning_gain_pct == 0.05
|
|
assert recent.min_confirmation_return_pct == 0.001
|
|
assert recent.max_gap_pct == 0.04
|
|
assert recent.max_entropy_20d == 0.9
|
|
assert recent.use_slow_ignite_sleeve is True
|
|
assert recent.use_liquid_largecap_sleeve is True
|
|
assert recent.liquid_largecap_weight == 0.3
|
|
assert recent.liquid_largecap_min_gain_pct == 0.004
|
|
assert recent.liquid_largecap_max_gain_pct == 0.02
|
|
assert recent.liquid_largecap_min_confirmation_return_pct == 0.0005
|
|
assert recent.slow_ignite_weight == 0.2
|
|
|
|
|
|
def test_augment_momentum_seed_candidates_with_leader_overlay_adds_negative_gap_continuation_name() -> None:
|
|
strategy = StrategyParams(
|
|
candidate_seed_leader_overlay_slots=1,
|
|
candidate_seed_leader_min_gap_pct=-0.01,
|
|
candidate_seed_leader_max_gap_pct=0.01,
|
|
candidate_seed_leader_min_avg_dollar_vol_30d=500_000_000.0,
|
|
candidate_seed_leader_min_ret_5d=0.15,
|
|
candidate_seed_leader_min_atr_pct=0.05,
|
|
candidate_seed_leader_max_entropy_20d=0.75,
|
|
)
|
|
daily_bars = {
|
|
"BASE": [{"date": "2026-04-20", "open": 50.0}],
|
|
"CAR": [{"date": "2026-04-20", "open": 491.26}],
|
|
"NOPE": [{"date": "2026-04-20", "open": 300.0}],
|
|
}
|
|
enrichment = {
|
|
"BASE": {"2026-04-20": {"gap_pct": 0.03, "avg_dollar_vol_30d": 200_000_000.0, "ret_5d": 0.02, "entropy_20d": 0.40, "atr_14": 2.0}},
|
|
"CAR": {"2026-04-20": {"gap_pct": -0.005, "avg_dollar_vol_30d": 730_000_000.0, "ret_5d": 0.33, "entropy_20d": 0.44, "atr_14": 51.8}},
|
|
"NOPE": {"2026-04-20": {"gap_pct": 0.0, "avg_dollar_vol_30d": 450_000_000.0, "ret_5d": 0.18, "entropy_20d": 0.55, "atr_14": 5.0}},
|
|
}
|
|
candidates = {"2026-04-20": ["BASE"]}
|
|
|
|
augmented, overlay = _augment_momentum_seed_candidates_with_liquid_overlay(
|
|
candidates,
|
|
daily_bars,
|
|
["2026-04-20"],
|
|
enrichment,
|
|
strategy,
|
|
)
|
|
|
|
assert augmented["2026-04-20"] == ["BASE", "CAR"]
|
|
|
|
|
|
def test_augment_momentum_seed_candidates_with_moderate_liquid_overlay_adds_followthrough_name() -> None:
|
|
strategy = StrategyParams(
|
|
candidate_seed_moderate_liquid_overlay_slots=1,
|
|
candidate_seed_moderate_liquid_min_gap_pct=0.005,
|
|
candidate_seed_moderate_liquid_max_gap_pct=0.025,
|
|
candidate_seed_moderate_liquid_min_avg_dollar_vol_30d=250_000_000.0,
|
|
candidate_seed_moderate_liquid_max_avg_dollar_vol_30d=2_000_000_000.0,
|
|
candidate_seed_moderate_liquid_max_entropy_20d=0.86,
|
|
)
|
|
daily_bars = {
|
|
"BASE": [{"date": "2026-03-13", "open": 50.0}],
|
|
"TER": [{"date": "2026-03-13", "open": 100.0}],
|
|
"MEGA": [{"date": "2026-03-13", "open": 250.0}],
|
|
}
|
|
enrichment = {
|
|
"BASE": {"2026-03-13": {"gap_pct": 0.04, "avg_dollar_vol_30d": 150_000_000.0, "ret_5d": 0.02, "entropy_20d": 0.40}},
|
|
"TER": {"2026-03-13": {"gap_pct": 0.012, "avg_dollar_vol_30d": 750_000_000.0, "ret_5d": -0.02, "entropy_20d": 0.79}},
|
|
"MEGA": {"2026-03-13": {"gap_pct": 0.010, "avg_dollar_vol_30d": 5_000_000_000.0, "ret_5d": 0.01, "entropy_20d": 0.50}},
|
|
}
|
|
candidates = {"2026-03-13": ["BASE"]}
|
|
|
|
augmented, overlay = _augment_momentum_seed_candidates_with_liquid_overlay(
|
|
candidates,
|
|
daily_bars,
|
|
["2026-03-13"],
|
|
enrichment,
|
|
strategy,
|
|
)
|
|
|
|
assert augmented["2026-03-13"] == ["BASE", "TER"]
|
|
assert overlay == {"2026-03-13": {"TER"}}
|
|
assert enrichment["TER"]["2026-03-13"]["candidate_seed_moderate_liquid_overlay"] is True
|
|
|
|
|
|
def test_augment_momentum_seed_candidates_with_event_overlay_adds_actual_catalyst_name() -> None:
|
|
strategy = StrategyParams(
|
|
candidate_seed_event_overlay_slots=1,
|
|
candidate_seed_event_min_score=0.95,
|
|
candidate_seed_event_min_gap_pct=-0.02,
|
|
candidate_seed_event_max_gap_pct=0.08,
|
|
candidate_seed_event_min_avg_dollar_vol_30d=100_000_000.0,
|
|
candidate_seed_event_max_entropy_20d=0.80,
|
|
)
|
|
daily_bars = {
|
|
"BASE": [{"date": "2026-02-10", "open": 50.0}],
|
|
"CAT": [{"date": "2026-02-10", "open": 42.0}],
|
|
"WEAK": [{"date": "2026-02-10", "open": 30.0}],
|
|
}
|
|
enrichment = {
|
|
"BASE": {"2026-02-10": {"gap_pct": 0.04, "avg_dollar_vol_30d": 200_000_000.0, "ret_5d": 0.03, "entropy_20d": 0.40}},
|
|
"CAT": {"2026-02-10": {"event_flag": True, "event_score": 1.0, "gap_pct": 0.01, "avg_dollar_vol_30d": 350_000_000.0, "ret_5d": 0.08, "entropy_20d": 0.55}},
|
|
"WEAK": {"2026-02-10": {"event_flag": True, "event_score": 0.60, "gap_pct": 0.015, "avg_dollar_vol_30d": 120_000_000.0, "ret_5d": 0.02, "entropy_20d": 0.50}},
|
|
}
|
|
candidates = {"2026-02-10": ["BASE"]}
|
|
|
|
augmented, overlay = _augment_momentum_seed_candidates_with_liquid_overlay(
|
|
candidates,
|
|
daily_bars,
|
|
["2026-02-10"],
|
|
enrichment,
|
|
strategy,
|
|
)
|
|
|
|
assert augmented["2026-02-10"] == ["BASE", "CAT"]
|
|
assert overlay == {"2026-02-10": {"CAT"}}
|
|
|
|
|
|
def test_retain_recent_intraday_shortlist_preserves_intraday_candidates() -> None:
|
|
candidates = {"2026-04-15": ["TSLA", "XNDU", "AXTI"]}
|
|
daily_bars = {"TSLA": [{"date": "2026-04-15"}], "AXTI": [{"date": "2026-04-15"}]}
|
|
|
|
retained = _retain_recent_intraday_shortlist(
|
|
candidates,
|
|
daily_bars,
|
|
require_daily_features=True,
|
|
)
|
|
|
|
assert retained == {"2026-04-15": ["TSLA", "AXTI"]}
|
|
|
|
|
|
def test_momentum_strategy_defaults_to_simple_returns_and_cli_can_override() -> None:
|
|
config = load_config(
|
|
"configs/intraday/strategies/leader_intraday_momentum_high_wr_intraday_first.yaml"
|
|
)
|
|
|
|
assert config.strategy.compound_returns is False
|
|
|
|
args = SimpleNamespace(
|
|
days=None,
|
|
start=None,
|
|
end=None,
|
|
universe=None,
|
|
top_n=None,
|
|
stop_loss=None,
|
|
entry_min=None,
|
|
exit_min=None,
|
|
min_gain=None,
|
|
no_cache=False,
|
|
verbose=False,
|
|
output_dir=None,
|
|
strategy="momentum",
|
|
compound_returns=True,
|
|
initial_capital=None,
|
|
)
|
|
|
|
updated = apply_cli_overrides(config, args)
|
|
|
|
assert updated.strategy.compound_returns is True
|