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.
1249 lines
44 KiB
Python
1249 lines
44 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import pickle
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
from zoneinfo import ZoneInfo
|
|
|
|
import pyarrow as pa
|
|
import pyarrow.parquet as pq
|
|
import pytest
|
|
|
|
import apps.intraday_bt.run as run_mod
|
|
from apps.intraday_bt.run import (
|
|
_apply_orb_prior_event_decay_features,
|
|
_append_synthetic_today_daily_rows,
|
|
_augment_candidate_map_with_support_tickers,
|
|
_augment_momentum_seed_candidates_with_liquid_overlay,
|
|
_build_daily_cache,
|
|
_build_intraday_data_provenance,
|
|
_chunk_trading_days_by_pairs,
|
|
_fetch_vix_by_day,
|
|
_latest_backtest_date,
|
|
_latest_completed_trading_day,
|
|
_load_orb_form4_features,
|
|
_load_vix_from_local_macro_snapshots,
|
|
_merge_candidate_maps,
|
|
_normalize_candidate_map,
|
|
_orb_intraday_support_tickers,
|
|
_load_orb_ownership_13dg_features,
|
|
_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.catalyst import PriorEventFeatureSnapshotCache
|
|
from libs.intraday.cache import DailyBarCache, LayeredDailyBarCache, ReadOnlyDailyBarCache
|
|
from libs.intraday.domain import CacheParams, IntradayConfig, ORBStrategyParams, 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_orb_intraday_support_tickers_includes_market_quality_ticker() -> None:
|
|
params = ORBStrategyParams(
|
|
market_orb_quality_ticker="SPY",
|
|
market_orb_quality_secondary_ticker="QQQ",
|
|
market_orb_quality_size_scale_low=0.4,
|
|
market_orb_quality_size_scale_high=0.65,
|
|
conditional_confirmation_ticker="IWM",
|
|
)
|
|
|
|
support = _orb_intraday_support_tickers(params)
|
|
|
|
assert support == ["IWM", "QQQ", "SPY"]
|
|
|
|
|
|
def test_append_synthetic_today_daily_rows_enables_same_day_enrichment(monkeypatch) -> None:
|
|
monkeypatch.setattr(
|
|
run_mod,
|
|
"utc_now",
|
|
lambda: datetime(2026, 5, 1, 16, 0, tzinfo=ZoneInfo("UTC")),
|
|
)
|
|
daily_bars = {
|
|
"AAA": [
|
|
{"date": "2026-04-29", "open": 10.0, "high": 11.0, "low": 9.5, "close": 10.5, "volume": 1000.0},
|
|
{"date": "2026-04-30", "open": 11.0, "high": 12.0, "low": 10.5, "close": 11.5, "volume": 1200.0},
|
|
],
|
|
"BBB": [
|
|
{"date": "2026-05-01", "open": 20.0, "high": 21.0, "low": 19.5, "close": 20.5, "volume": 1500.0},
|
|
],
|
|
}
|
|
|
|
added = _append_synthetic_today_daily_rows(daily_bars, ["2026-05-01"])
|
|
|
|
assert added == 1
|
|
assert daily_bars["AAA"][-1] == {
|
|
"date": "2026-05-01",
|
|
"open": 11.5,
|
|
"high": 11.5,
|
|
"low": 11.5,
|
|
"close": 11.5,
|
|
"volume": 0.0,
|
|
"synthetic_today_daily": True,
|
|
}
|
|
assert daily_bars["BBB"][-1]["open"] == 20.0
|
|
|
|
|
|
def test_augment_candidate_map_with_support_tickers_appends_without_duplicates() -> None:
|
|
candidates = {
|
|
"2026-01-05": ["AAA", "SPY"],
|
|
"2026-01-06": ["BBB"],
|
|
}
|
|
|
|
augmented = _augment_candidate_map_with_support_tickers(candidates, ["SPY"])
|
|
|
|
assert augmented == {
|
|
"2026-01-05": ["AAA", "SPY"],
|
|
"2026-01-06": ["BBB", "SPY"],
|
|
}
|
|
|
|
|
|
def test_load_orb_ownership_13dg_features_is_pit_safe(tmp_path) -> None:
|
|
path = tmp_path / "ownership.parquet"
|
|
table = pa.table(
|
|
{
|
|
"symbol": ["AAA", "AAA", "BBB"],
|
|
"filing_date": ["2026-01-03", "2026-01-05", "2026-01-02"],
|
|
"form_type": ["SC 13G", "SC 13D", "SC 13G"],
|
|
"is_initial_for_owner": [True, True, False],
|
|
"is_13g_to_13d_transition": [False, True, False],
|
|
"activist_flag": [False, True, False],
|
|
"ownership_strength_score": [3, 5, 2],
|
|
}
|
|
)
|
|
pq.write_table(table, path)
|
|
params = ORBStrategyParams(
|
|
ownership_13dg_lookback_days=3,
|
|
ownership_13dg_reference_path=str(path),
|
|
weight_ownership_initial_13dg=0.1,
|
|
)
|
|
|
|
features = _load_orb_ownership_13dg_features(
|
|
["AAA", "BBB"],
|
|
["2026-01-05", "2026-01-06"],
|
|
params,
|
|
)
|
|
|
|
assert features["AAA"]["2026-01-05"]["ownership_13dg_initial_flag"] is True
|
|
assert features["AAA"]["2026-01-05"]["ownership_13dg_days_since"] == 2
|
|
assert features["AAA"]["2026-01-06"]["ownership_13dg_active_13d_flag"] is True
|
|
assert "2026-01-05" in features["BBB"]
|
|
assert "2026-01-06" not in features["BBB"]
|
|
|
|
|
|
def test_load_orb_form4_features_is_pit_safe_and_aggregates_clusters(tmp_path) -> None:
|
|
path = tmp_path / "form4.parquet"
|
|
table = pa.table(
|
|
{
|
|
"symbol": ["AAA", "AAA", "BBB"],
|
|
"filing_date": ["2026-01-03", "2026-01-05", "2026-01-02"],
|
|
"total_value": [100_000.0, 1_000_000.0, 50_000.0],
|
|
"owner_count": [2, 1, 1],
|
|
"transaction_count": [3, 1, 1],
|
|
"c_suite_count": [0, 1, 0],
|
|
"role_weight_score": [1.0, 2.5, 1.0],
|
|
"has_officer_or_director": [True, True, False],
|
|
}
|
|
)
|
|
pq.write_table(table, path)
|
|
params = ORBStrategyParams(
|
|
form4_lookback_days=3,
|
|
form4_reference_path=str(path),
|
|
form4_size_scale=1.2,
|
|
)
|
|
|
|
features = _load_orb_form4_features(
|
|
["AAA", "BBB"],
|
|
["2026-01-05", "2026-01-06"],
|
|
params,
|
|
)
|
|
|
|
assert features["AAA"]["2026-01-05"]["form4_days_since"] == 2
|
|
assert features["AAA"]["2026-01-06"]["form4_total_value"] == 1_100_000.0
|
|
assert features["AAA"]["2026-01-06"]["form4_owner_count"] == 2
|
|
assert features["AAA"]["2026-01-06"]["form4_c_suite_count"] == 1
|
|
assert "2026-01-05" in features["BBB"]
|
|
assert "2026-01-06" not in features["BBB"]
|
|
|
|
|
|
def test_merge_candidate_maps_unions_days_without_duplicates() -> None:
|
|
merged = _merge_candidate_maps(
|
|
[
|
|
{"2026-01-05": ["AAA", "BBB"], "2026-01-06": ["CCC"]},
|
|
{"2026-01-05": ["BBB", "SPY"], "2026-01-07": ["DDD"]},
|
|
]
|
|
)
|
|
|
|
assert merged == {
|
|
"2026-01-05": ["AAA", "BBB", "SPY"],
|
|
"2026-01-06": ["CCC"],
|
|
"2026-01-07": ["DDD"],
|
|
}
|
|
|
|
|
|
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_prefetch_prior_event_features_db_reuses_local_snapshot(tmp_path, monkeypatch) -> None:
|
|
calls = {"count": 0}
|
|
cache = PriorEventFeatureSnapshotCache(str(tmp_path))
|
|
|
|
async def fake_fetch_live(
|
|
tickers: list[str],
|
|
trading_days: list[str],
|
|
lookback_calendar_days: int = 7,
|
|
event_types: tuple[str, ...] = ("earnings_release", "guidance_update"),
|
|
) -> dict[str, dict[str, dict]]:
|
|
calls["count"] += 1
|
|
assert tickers == ["ABC"]
|
|
assert trading_days == ["2026-01-05", "2026-01-06"]
|
|
assert lookback_calendar_days == 10
|
|
assert event_types == ("earnings_release", "guidance_update")
|
|
return {
|
|
"ABC": {
|
|
"2026-01-06": {
|
|
"event_flag": True,
|
|
"event_score": 1.0,
|
|
}
|
|
}
|
|
}
|
|
|
|
monkeypatch.setattr(run_mod, "_fetch_prior_event_features_db_live", fake_fetch_live)
|
|
|
|
first = asyncio.run(
|
|
run_mod._prefetch_prior_event_features_db(
|
|
["ABC"],
|
|
["2026-01-05", "2026-01-06"],
|
|
lookback_calendar_days=10,
|
|
event_types=("guidance_update", "earnings_release"),
|
|
snapshot_cache=cache,
|
|
)
|
|
)
|
|
second = asyncio.run(
|
|
run_mod._prefetch_prior_event_features_db(
|
|
["ABC"],
|
|
["2026-01-05", "2026-01-06"],
|
|
lookback_calendar_days=10,
|
|
event_types=("earnings_release", "guidance_update"),
|
|
snapshot_cache=cache,
|
|
)
|
|
)
|
|
|
|
assert first == second
|
|
assert calls["count"] == 1
|
|
|
|
|
|
def test_prefetch_prior_event_features_db_refetches_when_decay_needs_details(
|
|
tmp_path,
|
|
monkeypatch,
|
|
) -> None:
|
|
calls = {"count": 0}
|
|
cache = PriorEventFeatureSnapshotCache(str(tmp_path))
|
|
tickers = ["ABC"]
|
|
trading_days = ["2026-01-05", "2026-01-06"]
|
|
event_types = ("earnings_release",)
|
|
cache.put(
|
|
tickers,
|
|
trading_days,
|
|
10,
|
|
event_types,
|
|
{"ABC": {"2026-01-06": {"event_flag": True, "event_score": 1.0}}},
|
|
)
|
|
|
|
async def fake_fetch_live(
|
|
tickers: list[str],
|
|
trading_days: list[str],
|
|
lookback_calendar_days: int = 7,
|
|
event_types: tuple[str, ...] = ("earnings_release", "guidance_update"),
|
|
) -> dict[str, dict[str, dict]]:
|
|
calls["count"] += 1
|
|
return {
|
|
"ABC": {
|
|
"2026-01-06": {
|
|
"event_flag": True,
|
|
"event_score": 1.0,
|
|
"prior_event_candidates": [
|
|
{
|
|
"event_date": "2026-01-05",
|
|
"event_type": "earnings_release",
|
|
"days_ago": 1,
|
|
}
|
|
],
|
|
}
|
|
}
|
|
}
|
|
|
|
monkeypatch.setattr(run_mod, "_fetch_prior_event_features_db_live", fake_fetch_live)
|
|
|
|
result = asyncio.run(
|
|
run_mod._prefetch_prior_event_features_db(
|
|
tickers,
|
|
trading_days,
|
|
lookback_calendar_days=10,
|
|
event_types=event_types,
|
|
snapshot_cache=cache,
|
|
require_event_details=True,
|
|
)
|
|
)
|
|
|
|
assert calls["count"] == 1
|
|
assert result["ABC"]["2026-01-06"]["prior_event_candidates"][0]["days_ago"] == 1
|
|
|
|
|
|
def test_apply_orb_prior_event_decay_features_scores_recency_and_type() -> None:
|
|
params = ORBStrategyParams(
|
|
prior_event_decay_half_life_days=2,
|
|
prior_event_guidance_score_scale=0.5,
|
|
)
|
|
features = {
|
|
"ABC": {
|
|
"2026-01-08": {
|
|
"event_flag": True,
|
|
"event_score": 1.0,
|
|
"prior_event_candidates": [
|
|
{
|
|
"event_date": "2026-01-07",
|
|
"event_type": "guidance_update",
|
|
"days_ago": 1,
|
|
},
|
|
{
|
|
"event_date": "2026-01-06",
|
|
"event_type": "earnings_release",
|
|
"days_ago": 2,
|
|
},
|
|
],
|
|
}
|
|
}
|
|
}
|
|
|
|
result = _apply_orb_prior_event_decay_features(features, params)
|
|
|
|
payload = result["ABC"]["2026-01-08"]
|
|
assert payload["event_flag"] is True
|
|
assert payload["event_score"] == 0.707107
|
|
assert payload["prior_event_decay_score"] == 0.707107
|
|
assert payload["prior_event_best_event_type"] == "earnings_release"
|
|
assert payload["prior_event_best_days_ago"] == 2
|
|
|
|
|
|
def test_apply_orb_prior_event_decay_features_can_gate_weak_stale_events() -> None:
|
|
params = ORBStrategyParams(
|
|
prior_event_decay_half_life_days=2,
|
|
prior_event_decay_min_score=0.8,
|
|
)
|
|
features = {
|
|
"ABC": {
|
|
"2026-01-08": {
|
|
"event_flag": True,
|
|
"event_score": 1.0,
|
|
"prior_event_candidates": [
|
|
{
|
|
"event_date": "2026-01-05",
|
|
"event_type": "earnings_release",
|
|
"days_ago": 3,
|
|
}
|
|
],
|
|
}
|
|
}
|
|
}
|
|
|
|
result = _apply_orb_prior_event_decay_features(features, params)
|
|
|
|
payload = result["ABC"]["2026-01-08"]
|
|
assert payload["event_flag"] is False
|
|
assert payload["event_score"] == 0.0
|
|
assert payload["prior_event_decay_score"] == 0.5
|
|
|
|
|
|
def test_build_intraday_data_provenance_reports_prior_event_snapshot_id() -> None:
|
|
config = IntradayConfig(
|
|
strategy_mode="orb",
|
|
orb_strategy=ORBStrategyParams(
|
|
weight_event_catalyst=0.12,
|
|
prior_event_lookback_days=10,
|
|
prior_event_types=["earnings_release", "guidance_update"],
|
|
daily_bar_snapshot_id="orb_daily_v46_20260423",
|
|
prior_event_snapshot_id="orb_pead_d10_v46_20260423",
|
|
),
|
|
)
|
|
|
|
provenance = _build_intraday_data_provenance(config)
|
|
|
|
assert provenance == {
|
|
"daily_bars": {
|
|
"enabled": True,
|
|
"snapshot_id": "orb_daily_v46_20260423",
|
|
"overlay_enabled": False,
|
|
},
|
|
"prior_event": {
|
|
"enabled": True,
|
|
"snapshot_id": "orb_pead_d10_v46_20260423",
|
|
"lookback_calendar_days": 10,
|
|
"event_types": ["earnings_release", "guidance_update"],
|
|
}
|
|
}
|
|
|
|
|
|
def test_build_daily_cache_uses_read_only_snapshot_by_default(tmp_path) -> None:
|
|
config = IntradayConfig(
|
|
strategy_mode="orb",
|
|
orb_strategy=ORBStrategyParams(daily_bar_snapshot_id="snap1"),
|
|
cache=CacheParams(dir=str(tmp_path / "intraday")),
|
|
)
|
|
|
|
cache = _build_daily_cache(config)
|
|
|
|
assert isinstance(cache, ReadOnlyDailyBarCache)
|
|
assert not isinstance(cache, LayeredDailyBarCache)
|
|
assert str(cache._cache._root).endswith("daily_snapshots/snap1")
|
|
|
|
|
|
def test_build_daily_cache_uses_overlay_only_when_enabled(tmp_path) -> None:
|
|
config = IntradayConfig(
|
|
strategy_mode="orb",
|
|
orb_strategy=ORBStrategyParams(
|
|
daily_bar_snapshot_id="snap1",
|
|
daily_bar_snapshot_overlay_enabled=True,
|
|
),
|
|
cache=CacheParams(dir=str(tmp_path / "intraday")),
|
|
)
|
|
|
|
cache = _build_daily_cache(config)
|
|
|
|
assert isinstance(cache, LayeredDailyBarCache)
|
|
|
|
|
|
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_augment_momentum_seed_candidates_with_ownership_overlay_adds_pit_owner_name() -> None:
|
|
strategy = StrategyParams(
|
|
candidate_seed_ownership_overlay_slots=1,
|
|
candidate_seed_ownership_initial_only=True,
|
|
candidate_seed_ownership_min_strength_score=3.0,
|
|
candidate_seed_ownership_min_gap_pct=-0.02,
|
|
candidate_seed_ownership_max_gap_pct=0.08,
|
|
candidate_seed_ownership_min_avg_dollar_vol_30d=100_000_000.0,
|
|
candidate_seed_ownership_max_entropy_20d=0.85,
|
|
)
|
|
daily_bars = {
|
|
"BASE": [{"date": "2026-02-10", "open": 50.0}],
|
|
"OWNER": [{"date": "2026-02-10", "open": 42.0}],
|
|
"AMEND": [{"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,
|
|
}
|
|
},
|
|
"OWNER": {
|
|
"2026-02-10": {
|
|
"ownership_13dg_flag": True,
|
|
"ownership_13dg_initial_flag": True,
|
|
"ownership_13dg_strength_score": 4.0,
|
|
"ownership_13dg_days_since": 12,
|
|
"gap_pct": 0.01,
|
|
"avg_dollar_vol_30d": 350_000_000.0,
|
|
"ret_5d": 0.08,
|
|
"entropy_20d": 0.55,
|
|
}
|
|
},
|
|
"AMEND": {
|
|
"2026-02-10": {
|
|
"ownership_13dg_flag": True,
|
|
"ownership_13dg_initial_flag": False,
|
|
"ownership_13dg_strength_score": 5.0,
|
|
"ownership_13dg_days_since": 3,
|
|
"gap_pct": 0.015,
|
|
"avg_dollar_vol_30d": 400_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", "OWNER"]
|
|
assert overlay == {"2026-02-10": {"OWNER"}}
|
|
assert enrichment["OWNER"]["2026-02-10"]["candidate_seed_ownership_overlay"] is True
|
|
|
|
|
|
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_load_config_rejects_extends(tmp_path) -> None:
|
|
base = tmp_path / "base.yaml"
|
|
child = tmp_path / "child.yaml"
|
|
base.write_text(
|
|
"""
|
|
strategy_mode: orb
|
|
orb_strategy:
|
|
engine_family: gainers_leader
|
|
min_rvol: 3.0
|
|
nofill_vwap_reclaim_enabled: true
|
|
universe:
|
|
source: broad
|
|
backtest:
|
|
lookback_trading_days: 200
|
|
""",
|
|
encoding="utf-8",
|
|
)
|
|
child.write_text(
|
|
"""
|
|
extends: base.yaml
|
|
orb_strategy:
|
|
min_rvol: 4.0
|
|
soft_day_vwap_reclaim_allowed_reason_parts:
|
|
- hard_breadth
|
|
""",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
with pytest.raises(ValueError, match="extends.*no longer supported"):
|
|
load_config(str(child))
|
|
|
|
|
|
def test_checked_in_intraday_strategies_do_not_use_extends() -> None:
|
|
strategy_dir = Path("configs/intraday/strategies")
|
|
offenders = []
|
|
for path in sorted(strategy_dir.glob("*.yaml")):
|
|
text = path.read_text(encoding="utf-8")
|
|
if any(line.startswith("extends:") for line in text.splitlines()):
|
|
offenders.append(path.name)
|
|
|
|
assert offenders == []
|
|
|
|
|
|
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
|
|
|
|
|
|
def test_orb_cli_compound_override_disables_daily_budget_reset_by_default() -> None:
|
|
config = IntradayConfig(
|
|
strategy_mode="orb",
|
|
strategy=StrategyParams(),
|
|
orb_strategy=ORBStrategyParams(
|
|
compound_returns=False,
|
|
daily_budget_reset=True,
|
|
),
|
|
)
|
|
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="orb",
|
|
compound_returns=True,
|
|
daily_budget_reset=None,
|
|
initial_capital=None,
|
|
)
|
|
|
|
updated = apply_cli_overrides(config, args)
|
|
|
|
assert updated.orb_strategy is not None
|
|
assert updated.orb_strategy.compound_returns is True
|
|
assert updated.orb_strategy.daily_budget_reset is False
|
|
assert updated.strategy.compound_returns is True
|
|
assert updated.strategy.daily_budget_reset is False
|
|
|
|
|
|
def test_orb_cli_daily_reset_override_disables_compound_by_default() -> None:
|
|
config = IntradayConfig(
|
|
strategy_mode="orb",
|
|
strategy=StrategyParams(),
|
|
orb_strategy=ORBStrategyParams(
|
|
compound_returns=True,
|
|
daily_budget_reset=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="orb",
|
|
compound_returns=None,
|
|
daily_budget_reset=True,
|
|
initial_capital=None,
|
|
)
|
|
|
|
updated = apply_cli_overrides(config, args)
|
|
|
|
assert updated.orb_strategy is not None
|
|
assert updated.orb_strategy.compound_returns is False
|
|
assert updated.orb_strategy.daily_budget_reset is True
|
|
assert updated.strategy.compound_returns is False
|
|
assert updated.strategy.daily_budget_reset is True
|