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.

548 lines
20 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,
_momentum_intraday_seed_candidates,
_momentum_strategy_uses_attention,
_momentum_strategy_uses_catalyst,
_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_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_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_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 = _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 = _augment_momentum_seed_candidates_with_liquid_overlay(
candidates,
daily_bars,
["2026-04-20"],
enrichment,
strategy,
)
assert augmented["2026-04-20"] == ["BASE", "CAR"]
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.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