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.
1901 lines
68 KiB
Python
1901 lines
68 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
from datetime import datetime, timedelta
|
|
from types import SimpleNamespace
|
|
from zoneinfo import ZoneInfo
|
|
|
|
from libs.intraday.cache import DailyBarCache, IntradayCache, ReadOnlyDailyBarCache
|
|
from libs.intraday import screener as screener_module
|
|
from libs.intraday.domain import StrategyParams, UniverseParams
|
|
from libs.intraday.screener import (
|
|
fetch_daily_bars_bulk,
|
|
momentum_intraday_first_candidates,
|
|
momentum_pre_screen_candidates,
|
|
orb_pre_screen_candidates,
|
|
pre_screen_candidates,
|
|
resolve_universe,
|
|
)
|
|
|
|
|
|
def test_pre_screen_candidates_uses_opening_gap_not_same_day_high() -> None:
|
|
daily_bars = {
|
|
"AAA": [
|
|
{"date": "2026-01-02", "open": 100.0, "high": 102.0, "low": 99.0, "close": 100.0, "volume": 1_000},
|
|
{"date": "2026-01-05", "open": 100.0, "high": 130.0, "low": 95.0, "close": 96.0, "volume": 2_000},
|
|
],
|
|
"BBB": [
|
|
{"date": "2026-01-02", "open": 50.0, "high": 51.0, "low": 49.0, "close": 50.0, "volume": 1_000},
|
|
{"date": "2026-01-05", "open": 51.5, "high": 53.0, "low": 51.0, "close": 52.0, "volume": 2_000},
|
|
],
|
|
}
|
|
|
|
result = pre_screen_candidates(
|
|
daily_bars,
|
|
["2026-01-05"],
|
|
threshold=0.02,
|
|
max_per_day=30,
|
|
)
|
|
|
|
assert result == {"2026-01-05": ["BBB"]}
|
|
|
|
|
|
def test_pre_screen_candidates_can_use_precomputed_gap_enrichment() -> None:
|
|
daily_bars = {
|
|
"AAA": [
|
|
{"date": "2026-01-02", "open": 100.0, "high": 102.0, "low": 99.0, "close": 100.0, "volume": 1_000},
|
|
{"date": "2026-01-05", "open": 100.0, "high": 130.0, "low": 95.0, "close": 96.0, "volume": 2_000},
|
|
],
|
|
"BBB": [
|
|
{"date": "2026-01-02", "open": 50.0, "high": 51.0, "low": 49.0, "close": 50.0, "volume": 1_000},
|
|
{"date": "2026-01-05", "open": 51.5, "high": 53.0, "low": 51.0, "close": 52.0, "volume": 2_000},
|
|
],
|
|
}
|
|
enrichment = {
|
|
"AAA": {"2026-01-05": {"gap_pct": 0.0}},
|
|
"BBB": {"2026-01-05": {"gap_pct": 0.03}},
|
|
}
|
|
|
|
result = pre_screen_candidates(
|
|
daily_bars,
|
|
["2026-01-05"],
|
|
threshold=0.02,
|
|
max_per_day=30,
|
|
enrichment=enrichment,
|
|
)
|
|
|
|
assert result == {"2026-01-05": ["BBB"]}
|
|
|
|
|
|
def test_momentum_pre_screen_candidates_ranks_by_gap_then_prior_features() -> None:
|
|
daily_bars = {
|
|
"AAA": [
|
|
{"date": "2026-01-02", "open": 10.0, "high": 10.2, "low": 9.8, "close": 10.0, "volume": 1_000},
|
|
{"date": "2026-01-05", "open": 10.3, "high": 10.8, "low": 10.2, "close": 10.6, "volume": 2_000},
|
|
],
|
|
"BBB": [
|
|
{"date": "2026-01-02", "open": 10.0, "high": 10.1, "low": 9.9, "close": 10.0, "volume": 1_000},
|
|
{"date": "2026-01-05", "open": 10.3, "high": 10.5, "low": 10.1, "close": 10.2, "volume": 2_000},
|
|
],
|
|
"CCC": [
|
|
{"date": "2026-01-02", "open": 10.0, "high": 10.1, "low": 9.9, "close": 10.0, "volume": 1_000},
|
|
{"date": "2026-01-05", "open": 10.5, "high": 10.7, "low": 10.4, "close": 10.6, "volume": 2_000},
|
|
],
|
|
}
|
|
enrichment = {
|
|
"AAA": {
|
|
"2026-01-05": {
|
|
"gap_pct": 0.03,
|
|
"ret_5d": 0.10,
|
|
"entropy_20d": 0.50,
|
|
"avg_dollar_vol_30d": 50_000_000.0,
|
|
"atr_14": 1.5,
|
|
}
|
|
},
|
|
"BBB": {
|
|
"2026-01-05": {
|
|
"gap_pct": 0.03,
|
|
"ret_5d": 0.02,
|
|
"entropy_20d": 0.70,
|
|
"avg_dollar_vol_30d": 30_000_000.0,
|
|
"atr_14": 1.0,
|
|
}
|
|
},
|
|
"CCC": {
|
|
"2026-01-05": {
|
|
"gap_pct": 0.05,
|
|
"ret_5d": -0.01,
|
|
"entropy_20d": 0.80,
|
|
"avg_dollar_vol_30d": 10_000_000.0,
|
|
"atr_14": 0.8,
|
|
}
|
|
},
|
|
}
|
|
|
|
result = momentum_pre_screen_candidates(
|
|
daily_bars,
|
|
["2026-01-05"],
|
|
enrichment,
|
|
threshold=0.02,
|
|
max_per_day=3,
|
|
)
|
|
|
|
assert result == {"2026-01-05": ["CCC", "AAA", "BBB"]}
|
|
|
|
|
|
def test_momentum_pre_screen_candidates_can_require_event_and_attention() -> None:
|
|
daily_bars = {
|
|
"AAA": [
|
|
{"date": "2026-01-02", "open": 10.0, "high": 10.2, "low": 9.8, "close": 10.0, "volume": 1_000},
|
|
{"date": "2026-01-05", "open": 10.3, "high": 10.8, "low": 10.2, "close": 10.6, "volume": 2_000},
|
|
],
|
|
"BBB": [
|
|
{"date": "2026-01-02", "open": 10.0, "high": 10.1, "low": 9.9, "close": 10.0, "volume": 1_000},
|
|
{"date": "2026-01-05", "open": 10.4, "high": 10.6, "low": 10.3, "close": 10.5, "volume": 2_000},
|
|
],
|
|
}
|
|
enrichment = {
|
|
"AAA": {
|
|
"2026-01-05": {
|
|
"gap_pct": 0.03,
|
|
"ret_5d": 0.02,
|
|
"entropy_20d": 0.70,
|
|
"avg_dollar_vol_30d": 20_000_000.0,
|
|
"atr_14": 1.0,
|
|
"event_flag": True,
|
|
"event_score": 1.0,
|
|
"attention_wiki_spike_10d": 2.0,
|
|
"attention_article_count_3d": 5,
|
|
"attention_us_article_count_3d": 4,
|
|
"attention_resolver_confidence": 0.9,
|
|
}
|
|
},
|
|
"BBB": {
|
|
"2026-01-05": {
|
|
"gap_pct": 0.04,
|
|
"ret_5d": 0.04,
|
|
"entropy_20d": 0.50,
|
|
"avg_dollar_vol_30d": 25_000_000.0,
|
|
"atr_14": 1.2,
|
|
"event_flag": False,
|
|
"event_score": 0.0,
|
|
"attention_wiki_spike_10d": 0.2,
|
|
"attention_article_count_3d": 0,
|
|
"attention_us_article_count_3d": 0,
|
|
"attention_resolver_confidence": 0.2,
|
|
}
|
|
},
|
|
}
|
|
strategy = StrategyParams(
|
|
candidate_require_event_flag=True,
|
|
candidate_min_attention_wiki_spike_10d=1.0,
|
|
candidate_weight_event_score=1.0,
|
|
candidate_weight_attention_wiki=1.0,
|
|
candidate_weight_attention_news=1.0,
|
|
)
|
|
|
|
result = momentum_pre_screen_candidates(
|
|
daily_bars,
|
|
["2026-01-05"],
|
|
enrichment,
|
|
threshold=0.02,
|
|
max_per_day=5,
|
|
strategy=strategy,
|
|
)
|
|
|
|
assert result == {"2026-01-05": ["AAA"]}
|
|
|
|
|
|
def test_momentum_pre_screen_candidates_can_filter_event_types() -> None:
|
|
daily_bars = {
|
|
"AAA": [
|
|
{"date": "2026-01-02", "open": 10.0, "high": 10.2, "low": 9.8, "close": 10.0, "volume": 1_000},
|
|
{"date": "2026-01-05", "open": 10.3, "high": 10.8, "low": 10.2, "close": 10.6, "volume": 2_000},
|
|
],
|
|
"BBB": [
|
|
{"date": "2026-01-02", "open": 10.0, "high": 10.2, "low": 9.8, "close": 10.0, "volume": 1_000},
|
|
{"date": "2026-01-05", "open": 10.4, "high": 10.9, "low": 10.3, "close": 10.7, "volume": 2_000},
|
|
],
|
|
}
|
|
enrichment = {
|
|
"AAA": {
|
|
"2026-01-05": {
|
|
"gap_pct": 0.03,
|
|
"ret_5d": 0.03,
|
|
"entropy_20d": 0.60,
|
|
"avg_dollar_vol_30d": 20_000_000.0,
|
|
"atr_14": 1.0,
|
|
"event_flag": True,
|
|
"event_score": 1.0,
|
|
"event_types": ["earnings_release"],
|
|
}
|
|
},
|
|
"BBB": {
|
|
"2026-01-05": {
|
|
"gap_pct": 0.04,
|
|
"ret_5d": 0.04,
|
|
"entropy_20d": 0.50,
|
|
"avg_dollar_vol_30d": 25_000_000.0,
|
|
"atr_14": 1.2,
|
|
"event_flag": True,
|
|
"event_score": 1.0,
|
|
"event_types": ["management_change"],
|
|
}
|
|
},
|
|
}
|
|
strategy = StrategyParams(
|
|
candidate_require_event_flag=True,
|
|
candidate_allowed_event_types=["earnings_release"],
|
|
)
|
|
|
|
result = momentum_pre_screen_candidates(
|
|
daily_bars,
|
|
["2026-01-05"],
|
|
enrichment,
|
|
threshold=0.02,
|
|
max_per_day=5,
|
|
strategy=strategy,
|
|
)
|
|
|
|
assert result == {"2026-01-05": ["AAA"]}
|
|
|
|
|
|
def test_momentum_pre_screen_candidates_event_type_filter_does_not_block_non_event_names() -> None:
|
|
daily_bars = {
|
|
"AAA": [
|
|
{"date": "2026-01-02", "open": 10.0, "high": 10.2, "low": 9.8, "close": 10.0, "volume": 1_000},
|
|
{"date": "2026-01-05", "open": 10.3, "high": 10.8, "low": 10.2, "close": 10.6, "volume": 2_000},
|
|
],
|
|
"BBB": [
|
|
{"date": "2026-01-02", "open": 10.0, "high": 10.2, "low": 9.8, "close": 10.0, "volume": 1_000},
|
|
{"date": "2026-01-05", "open": 10.4, "high": 10.9, "low": 10.3, "close": 10.7, "volume": 2_000},
|
|
],
|
|
}
|
|
enrichment = {
|
|
"AAA": {
|
|
"2026-01-05": {
|
|
"gap_pct": 0.03,
|
|
"ret_5d": 0.03,
|
|
"entropy_20d": 0.60,
|
|
"avg_dollar_vol_30d": 20_000_000.0,
|
|
"atr_14": 1.0,
|
|
"event_flag": True,
|
|
"event_score": 1.0,
|
|
"event_types": ["management_change"],
|
|
}
|
|
},
|
|
"BBB": {
|
|
"2026-01-05": {
|
|
"gap_pct": 0.04,
|
|
"ret_5d": 0.04,
|
|
"entropy_20d": 0.50,
|
|
"avg_dollar_vol_30d": 25_000_000.0,
|
|
"atr_14": 1.2,
|
|
"event_flag": False,
|
|
"event_score": 0.0,
|
|
}
|
|
},
|
|
}
|
|
strategy = StrategyParams(
|
|
candidate_allowed_event_types=["earnings_release"],
|
|
)
|
|
|
|
result = momentum_pre_screen_candidates(
|
|
daily_bars,
|
|
["2026-01-05"],
|
|
enrichment,
|
|
threshold=0.02,
|
|
max_per_day=5,
|
|
strategy=strategy,
|
|
)
|
|
|
|
assert result == {"2026-01-05": ["BBB", "AAA"]}
|
|
|
|
|
|
def test_momentum_intraday_first_candidates_uses_entry_time_info_only() -> None:
|
|
strategy = StrategyParams(
|
|
candidate_source_mode="intraday_first",
|
|
entry_minutes_after_open=10,
|
|
confirmation_minutes_after_entry=5,
|
|
min_confirmation_return_pct=0.0,
|
|
min_morning_gain_pct=0.01,
|
|
min_entry_volume=50_000,
|
|
candidate_final_max_per_day=2,
|
|
candidate_require_event_flag=True,
|
|
)
|
|
all_intraday = {
|
|
"2026-01-05": {
|
|
"AAA": [
|
|
{"timestamp": "2026-01-05T14:30:00+00:00", "open": 10.0, "high": 10.2, "low": 9.9, "close": 10.1, "volume": 30_000},
|
|
{"timestamp": "2026-01-05T14:35:00+00:00", "open": 10.1, "high": 10.3, "low": 10.0, "close": 10.2, "volume": 30_000},
|
|
{"timestamp": "2026-01-05T14:40:00+00:00", "open": 10.2, "high": 10.5, "low": 10.1, "close": 10.4, "volume": 30_000},
|
|
{"timestamp": "2026-01-05T14:45:00+00:00", "open": 10.4, "high": 10.7, "low": 10.3, "close": 10.6, "volume": 30_000},
|
|
{"timestamp": "2026-01-05T14:50:00+00:00", "open": 10.6, "high": 10.8, "low": 10.5, "close": 10.7, "volume": 30_000},
|
|
],
|
|
"BBB": [
|
|
{"timestamp": "2026-01-05T14:30:00+00:00", "open": 20.0, "high": 20.1, "low": 19.9, "close": 20.0, "volume": 40_000},
|
|
{"timestamp": "2026-01-05T14:35:00+00:00", "open": 20.0, "high": 20.2, "low": 19.9, "close": 20.1, "volume": 40_000},
|
|
{"timestamp": "2026-01-05T14:40:00+00:00", "open": 20.1, "high": 20.7, "low": 20.0, "close": 20.5, "volume": 40_000},
|
|
{"timestamp": "2026-01-05T14:45:00+00:00", "open": 20.5, "high": 21.0, "low": 20.4, "close": 20.9, "volume": 40_000},
|
|
{"timestamp": "2026-01-05T14:50:00+00:00", "open": 20.9, "high": 21.3, "low": 20.8, "close": 21.1, "volume": 40_000},
|
|
],
|
|
}
|
|
}
|
|
daily_enrichment = {
|
|
"AAA": {"2026-01-05": {"event_flag": False, "gap_pct": 0.01, "avg_daily_vol_14d": 1_000_000.0}},
|
|
"BBB": {"2026-01-05": {"event_flag": True, "event_score": 1.0, "gap_pct": 0.01, "avg_daily_vol_14d": 1_000_000.0}},
|
|
}
|
|
|
|
result = momentum_intraday_first_candidates(
|
|
all_intraday,
|
|
["2026-01-05"],
|
|
strategy,
|
|
daily_enrichment=daily_enrichment,
|
|
max_per_day=2,
|
|
)
|
|
|
|
assert result == {"2026-01-05": ["BBB"]}
|
|
|
|
|
|
def test_momentum_intraday_first_candidates_can_filter_event_types() -> None:
|
|
strategy = StrategyParams(
|
|
candidate_source_mode="intraday_first",
|
|
entry_minutes_after_open=10,
|
|
confirmation_minutes_after_entry=5,
|
|
min_confirmation_return_pct=0.0,
|
|
min_morning_gain_pct=0.01,
|
|
min_entry_volume=50_000,
|
|
candidate_final_max_per_day=2,
|
|
candidate_require_event_flag=True,
|
|
candidate_allowed_event_types=["earnings_release"],
|
|
)
|
|
all_intraday = {
|
|
"2026-01-05": {
|
|
"AAA": [
|
|
{"timestamp": "2026-01-05T14:30:00+00:00", "open": 10.0, "high": 10.2, "low": 9.9, "close": 10.1, "volume": 30_000},
|
|
{"timestamp": "2026-01-05T14:35:00+00:00", "open": 10.1, "high": 10.3, "low": 10.0, "close": 10.2, "volume": 30_000},
|
|
{"timestamp": "2026-01-05T14:40:00+00:00", "open": 10.2, "high": 10.5, "low": 10.1, "close": 10.4, "volume": 30_000},
|
|
{"timestamp": "2026-01-05T14:45:00+00:00", "open": 10.4, "high": 10.7, "low": 10.3, "close": 10.6, "volume": 30_000},
|
|
{"timestamp": "2026-01-05T14:50:00+00:00", "open": 10.6, "high": 10.8, "low": 10.5, "close": 10.7, "volume": 30_000},
|
|
],
|
|
"BBB": [
|
|
{"timestamp": "2026-01-05T14:30:00+00:00", "open": 20.0, "high": 20.1, "low": 19.9, "close": 20.0, "volume": 40_000},
|
|
{"timestamp": "2026-01-05T14:35:00+00:00", "open": 20.0, "high": 20.2, "low": 19.9, "close": 20.1, "volume": 40_000},
|
|
{"timestamp": "2026-01-05T14:40:00+00:00", "open": 20.1, "high": 20.7, "low": 20.0, "close": 20.5, "volume": 40_000},
|
|
{"timestamp": "2026-01-05T14:45:00+00:00", "open": 20.5, "high": 21.0, "low": 20.4, "close": 20.9, "volume": 40_000},
|
|
{"timestamp": "2026-01-05T14:50:00+00:00", "open": 20.9, "high": 21.3, "low": 20.8, "close": 21.1, "volume": 40_000},
|
|
],
|
|
}
|
|
}
|
|
daily_enrichment = {
|
|
"AAA": {
|
|
"2026-01-05": {
|
|
"event_flag": True,
|
|
"event_score": 1.0,
|
|
"event_types": ["management_change"],
|
|
"gap_pct": 0.01,
|
|
"avg_daily_vol_14d": 1_000_000.0,
|
|
}
|
|
},
|
|
"BBB": {
|
|
"2026-01-05": {
|
|
"event_flag": True,
|
|
"event_score": 1.0,
|
|
"event_types": ["earnings_release"],
|
|
"gap_pct": 0.01,
|
|
"avg_daily_vol_14d": 1_000_000.0,
|
|
}
|
|
},
|
|
}
|
|
|
|
result = momentum_intraday_first_candidates(
|
|
all_intraday,
|
|
["2026-01-05"],
|
|
strategy,
|
|
daily_enrichment=daily_enrichment,
|
|
max_per_day=2,
|
|
)
|
|
|
|
assert result == {"2026-01-05": ["BBB"]}
|
|
|
|
|
|
def test_momentum_intraday_first_candidates_can_use_weighted_ranking() -> None:
|
|
strategy = StrategyParams(
|
|
candidate_source_mode="intraday_first",
|
|
candidate_intraday_rank_mode="weighted",
|
|
candidate_intraday_weight_gain=0.2,
|
|
candidate_intraday_weight_confirmation=0.5,
|
|
candidate_intraday_weight_volume_ratio=0.2,
|
|
candidate_intraday_weight_entry_dollar_volume=0.1,
|
|
entry_minutes_after_open=10,
|
|
confirmation_minutes_after_entry=5,
|
|
min_confirmation_return_pct=0.0,
|
|
min_morning_gain_pct=0.01,
|
|
candidate_final_max_per_day=1,
|
|
)
|
|
all_intraday = {
|
|
"2026-01-05": {
|
|
"AAA": [
|
|
{"timestamp": "2026-01-05T14:30:00+00:00", "open": 10.0, "high": 10.1, "low": 9.9, "close": 10.0, "volume": 80_000},
|
|
{"timestamp": "2026-01-05T14:35:00+00:00", "open": 10.0, "high": 10.2, "low": 10.0, "close": 10.1, "volume": 80_000},
|
|
{"timestamp": "2026-01-05T14:40:00+00:00", "open": 10.1, "high": 10.4, "low": 10.0, "close": 10.2, "volume": 80_000},
|
|
{"timestamp": "2026-01-05T14:45:00+00:00", "open": 10.2, "high": 10.8, "low": 10.2, "close": 10.7, "volume": 80_000},
|
|
{"timestamp": "2026-01-05T14:50:00+00:00", "open": 10.7, "high": 10.9, "low": 10.6, "close": 10.8, "volume": 80_000},
|
|
],
|
|
"BBB": [
|
|
{"timestamp": "2026-01-05T14:30:00+00:00", "open": 20.0, "high": 20.2, "low": 19.9, "close": 20.1, "volume": 70_000},
|
|
{"timestamp": "2026-01-05T14:35:00+00:00", "open": 20.1, "high": 20.4, "low": 20.0, "close": 20.3, "volume": 70_000},
|
|
{"timestamp": "2026-01-05T14:40:00+00:00", "open": 20.3, "high": 21.0, "low": 20.2, "close": 20.9, "volume": 70_000},
|
|
{"timestamp": "2026-01-05T14:45:00+00:00", "open": 20.9, "high": 21.0, "low": 20.7, "close": 20.95, "volume": 70_000},
|
|
{"timestamp": "2026-01-05T14:50:00+00:00", "open": 20.95, "high": 21.1, "low": 20.8, "close": 21.0, "volume": 70_000},
|
|
],
|
|
}
|
|
}
|
|
daily_enrichment = {
|
|
"AAA": {
|
|
"2026-01-05": {
|
|
"gap_pct": 0.01,
|
|
"avg_daily_vol_14d": 1_000_000.0,
|
|
}
|
|
},
|
|
"BBB": {
|
|
"2026-01-05": {
|
|
"gap_pct": 0.01,
|
|
"avg_daily_vol_14d": 1_000_000.0,
|
|
}
|
|
},
|
|
}
|
|
|
|
result = momentum_intraday_first_candidates(
|
|
all_intraday,
|
|
["2026-01-05"],
|
|
strategy,
|
|
daily_enrichment=daily_enrichment,
|
|
max_per_day=1,
|
|
)
|
|
|
|
assert result == {"2026-01-05": ["AAA"]}
|
|
|
|
|
|
def test_momentum_intraday_first_candidates_weighted_ranking_can_use_prior_dollar_volume() -> None:
|
|
strategy = StrategyParams(
|
|
candidate_source_mode="intraday_first",
|
|
candidate_intraday_rank_mode="weighted",
|
|
candidate_intraday_weight_gain=0.2,
|
|
candidate_intraday_weight_confirmation=0.4,
|
|
candidate_intraday_weight_volume_ratio=0.2,
|
|
candidate_intraday_weight_entry_dollar_volume=0.1,
|
|
candidate_intraday_weight_avg_dollar_vol_30d=0.5,
|
|
entry_minutes_after_open=10,
|
|
confirmation_minutes_after_entry=5,
|
|
min_confirmation_return_pct=0.0,
|
|
min_morning_gain_pct=0.005,
|
|
candidate_final_max_per_day=1,
|
|
)
|
|
all_intraday = {
|
|
"2026-01-05": {
|
|
"AAA": [
|
|
{"timestamp": "2026-01-05T14:30:00+00:00", "open": 10.0, "high": 10.1, "low": 9.9, "close": 10.0, "volume": 400_000},
|
|
{"timestamp": "2026-01-05T14:35:00+00:00", "open": 10.0, "high": 10.2, "low": 10.0, "close": 10.1, "volume": 400_000},
|
|
{"timestamp": "2026-01-05T14:40:00+00:00", "open": 10.1, "high": 10.2, "low": 10.0, "close": 10.1, "volume": 400_000},
|
|
{"timestamp": "2026-01-05T14:45:00+00:00", "open": 10.1, "high": 10.3, "low": 10.1, "close": 10.2, "volume": 400_000},
|
|
{"timestamp": "2026-01-05T14:50:00+00:00", "open": 10.2, "high": 10.4, "low": 10.1, "close": 10.3, "volume": 400_000},
|
|
],
|
|
"BBB": [
|
|
{"timestamp": "2026-01-05T14:30:00+00:00", "open": 20.0, "high": 20.1, "low": 19.9, "close": 20.0, "volume": 200_000},
|
|
{"timestamp": "2026-01-05T14:35:00+00:00", "open": 20.0, "high": 20.2, "low": 20.0, "close": 20.1, "volume": 200_000},
|
|
{"timestamp": "2026-01-05T14:40:00+00:00", "open": 20.1, "high": 20.2, "low": 20.0, "close": 20.1, "volume": 200_000},
|
|
{"timestamp": "2026-01-05T14:45:00+00:00", "open": 20.1, "high": 20.3, "low": 20.1, "close": 20.2, "volume": 200_000},
|
|
{"timestamp": "2026-01-05T14:50:00+00:00", "open": 20.2, "high": 20.4, "low": 20.1, "close": 20.3, "volume": 200_000},
|
|
],
|
|
}
|
|
}
|
|
daily_enrichment = {
|
|
"AAA": {
|
|
"2026-01-05": {
|
|
"gap_pct": 0.01,
|
|
"avg_daily_vol_14d": 5_000_000.0,
|
|
"avg_dollar_vol_30d": 200_000_000.0,
|
|
}
|
|
},
|
|
"BBB": {
|
|
"2026-01-05": {
|
|
"gap_pct": 0.01,
|
|
"avg_daily_vol_14d": 5_000_000.0,
|
|
"avg_dollar_vol_30d": 8_000_000_000.0,
|
|
}
|
|
},
|
|
}
|
|
|
|
result = momentum_intraday_first_candidates(
|
|
all_intraday,
|
|
["2026-01-05"],
|
|
strategy,
|
|
daily_enrichment=daily_enrichment,
|
|
max_per_day=1,
|
|
)
|
|
|
|
assert result == {"2026-01-05": ["BBB"]}
|
|
|
|
|
|
def test_momentum_intraday_first_candidates_weighted_ranking_can_use_sector_thrust() -> None:
|
|
strategy = StrategyParams(
|
|
candidate_source_mode="intraday_first",
|
|
candidate_intraday_rank_mode="weighted",
|
|
candidate_intraday_weight_gain=0.2,
|
|
candidate_intraday_weight_confirmation=0.2,
|
|
candidate_intraday_weight_entry_dollar_volume=0.1,
|
|
candidate_intraday_weight_sector_thrust=1.0,
|
|
entry_minutes_after_open=10,
|
|
confirmation_minutes_after_entry=5,
|
|
min_confirmation_return_pct=0.0,
|
|
min_morning_gain_pct=0.01,
|
|
candidate_final_max_per_day=1,
|
|
use_sector_thrust_sleeve=True,
|
|
sector_thrust_min_members=2,
|
|
sector_thrust_min_gain_pct=0.01,
|
|
sector_thrust_min_confirmation_return_pct=0.003,
|
|
sector_thrust_min_entry_dollar_volume=50_000_000.0,
|
|
sector_thrust_min_avg_dollar_vol_30d=500_000_000.0,
|
|
sector_thrust_min_sector_avg_confirmation_return_pct=0.003,
|
|
sector_thrust_min_sector_total_entry_dollar_volume=120_000_000.0,
|
|
)
|
|
all_intraday = {
|
|
"2026-01-05": {
|
|
"ALLY_A": [
|
|
{"timestamp": "2026-01-05T14:30:00+00:00", "open": 100.0, "high": 100.2, "low": 99.9, "close": 100.0, "volume": 180_000},
|
|
{"timestamp": "2026-01-05T14:35:00+00:00", "open": 100.0, "high": 100.8, "low": 99.9, "close": 100.6, "volume": 180_000},
|
|
{"timestamp": "2026-01-05T14:40:00+00:00", "open": 100.6, "high": 101.2, "low": 100.5, "close": 101.0, "volume": 180_000},
|
|
{"timestamp": "2026-01-05T14:45:00+00:00", "open": 101.0, "high": 101.7, "low": 100.9, "close": 101.5, "volume": 180_000},
|
|
{"timestamp": "2026-01-05T14:50:00+00:00", "open": 101.5, "high": 101.8, "low": 101.4, "close": 101.6, "volume": 180_000},
|
|
],
|
|
"ALLY_B": [
|
|
{"timestamp": "2026-01-05T14:30:00+00:00", "open": 80.0, "high": 80.1, "low": 79.9, "close": 80.0, "volume": 170_000},
|
|
{"timestamp": "2026-01-05T14:35:00+00:00", "open": 80.0, "high": 80.6, "low": 79.9, "close": 80.4, "volume": 170_000},
|
|
{"timestamp": "2026-01-05T14:40:00+00:00", "open": 80.4, "high": 80.9, "low": 80.3, "close": 80.8, "volume": 170_000},
|
|
{"timestamp": "2026-01-05T14:45:00+00:00", "open": 80.8, "high": 81.4, "low": 80.7, "close": 81.2, "volume": 170_000},
|
|
{"timestamp": "2026-01-05T14:50:00+00:00", "open": 81.2, "high": 81.5, "low": 81.1, "close": 81.3, "volume": 170_000},
|
|
],
|
|
"SOLO": [
|
|
{"timestamp": "2026-01-05T14:30:00+00:00", "open": 20.0, "high": 20.3, "low": 19.9, "close": 20.1, "volume": 300_000},
|
|
{"timestamp": "2026-01-05T14:35:00+00:00", "open": 20.1, "high": 20.8, "low": 20.0, "close": 20.6, "volume": 300_000},
|
|
{"timestamp": "2026-01-05T14:40:00+00:00", "open": 20.6, "high": 21.1, "low": 20.5, "close": 20.9, "volume": 300_000},
|
|
{"timestamp": "2026-01-05T14:45:00+00:00", "open": 20.9, "high": 21.3, "low": 20.8, "close": 21.1, "volume": 300_000},
|
|
{"timestamp": "2026-01-05T14:50:00+00:00", "open": 21.1, "high": 21.3, "low": 21.0, "close": 21.2, "volume": 300_000},
|
|
],
|
|
}
|
|
}
|
|
daily_enrichment = {
|
|
"ALLY_A": {"2026-01-05": {"gap_pct": 0.01, "avg_daily_vol_14d": 5_000_000.0, "avg_dollar_vol_30d": 900_000_000.0}},
|
|
"ALLY_B": {"2026-01-05": {"gap_pct": 0.01, "avg_daily_vol_14d": 5_000_000.0, "avg_dollar_vol_30d": 850_000_000.0}},
|
|
"SOLO": {"2026-01-05": {"gap_pct": 0.01, "avg_daily_vol_14d": 10_000_000.0, "avg_dollar_vol_30d": 1_200_000_000.0}},
|
|
}
|
|
|
|
result = momentum_intraday_first_candidates(
|
|
all_intraday,
|
|
["2026-01-05"],
|
|
strategy,
|
|
daily_enrichment=daily_enrichment,
|
|
ticker_sectors={
|
|
"ALLY_A": "Technology",
|
|
"ALLY_B": "Technology",
|
|
"SOLO": "Energy",
|
|
},
|
|
max_per_day=1,
|
|
)
|
|
|
|
assert result == {"2026-01-05": ["ALLY_A"]}
|
|
|
|
|
|
def test_momentum_intraday_first_candidates_can_use_liquid_continuation_rank_mode() -> None:
|
|
strategy = StrategyParams(
|
|
candidate_source_mode="intraday_first",
|
|
candidate_intraday_rank_mode="liquid_continuation",
|
|
entry_minutes_after_open=10,
|
|
confirmation_minutes_after_entry=5,
|
|
min_confirmation_return_pct=0.0,
|
|
min_morning_gain_pct=0.004,
|
|
candidate_final_max_per_day=1,
|
|
use_liquid_largecap_sleeve=True,
|
|
liquid_largecap_min_gain_pct=0.004,
|
|
liquid_largecap_max_gain_pct=0.03,
|
|
liquid_largecap_min_confirmation_return_pct=0.0005,
|
|
liquid_largecap_min_entry_dollar_volume=50_000_000.0,
|
|
liquid_largecap_min_avg_dollar_vol_30d=2_000_000_000.0,
|
|
liquid_largecap_max_entropy_20d=0.90,
|
|
use_moderate_gap_liquid_sleeve=True,
|
|
moderate_gap_liquid_min_gap_pct=0.002,
|
|
moderate_gap_liquid_max_gap_pct=0.04,
|
|
moderate_gap_liquid_min_gain_pct=0.005,
|
|
moderate_gap_liquid_max_gain_pct=0.04,
|
|
moderate_gap_liquid_min_confirmation_return_pct=0.001,
|
|
moderate_gap_liquid_min_entry_dollar_volume=25_000_000.0,
|
|
moderate_gap_liquid_min_avg_dollar_vol_30d=250_000_000.0,
|
|
moderate_gap_liquid_max_avg_dollar_vol_30d=4_000_000_000.0,
|
|
moderate_gap_liquid_min_volume_ratio_14d=0.02,
|
|
moderate_gap_liquid_max_entropy_20d=0.88,
|
|
)
|
|
all_intraday = {
|
|
"2026-01-05": {
|
|
"LIQ": [
|
|
{"timestamp": "2026-01-05T14:30:00+00:00", "open": 100.0, "high": 100.8, "low": 99.9, "close": 100.5, "volume": 220_000},
|
|
{"timestamp": "2026-01-05T14:35:00+00:00", "open": 100.5, "high": 101.2, "low": 100.4, "close": 101.0, "volume": 220_000},
|
|
{"timestamp": "2026-01-05T14:40:00+00:00", "open": 101.0, "high": 101.8, "low": 100.9, "close": 101.5, "volume": 220_000},
|
|
{"timestamp": "2026-01-05T14:45:00+00:00", "open": 101.5, "high": 102.2, "low": 101.4, "close": 102.0, "volume": 220_000},
|
|
{"timestamp": "2026-01-05T14:50:00+00:00", "open": 102.0, "high": 102.4, "low": 101.9, "close": 102.2, "volume": 220_000},
|
|
],
|
|
"HOT": [
|
|
{"timestamp": "2026-01-05T14:30:00+00:00", "open": 10.0, "high": 10.4, "low": 9.9, "close": 10.3, "volume": 120_000},
|
|
{"timestamp": "2026-01-05T14:35:00+00:00", "open": 10.3, "high": 10.7, "low": 10.2, "close": 10.6, "volume": 120_000},
|
|
{"timestamp": "2026-01-05T14:40:00+00:00", "open": 10.6, "high": 10.9, "low": 10.5, "close": 10.8, "volume": 120_000},
|
|
{"timestamp": "2026-01-05T14:45:00+00:00", "open": 10.8, "high": 11.0, "low": 10.7, "close": 10.9, "volume": 120_000},
|
|
{"timestamp": "2026-01-05T14:50:00+00:00", "open": 10.9, "high": 11.1, "low": 10.8, "close": 11.0, "volume": 120_000},
|
|
],
|
|
}
|
|
}
|
|
daily_enrichment = {
|
|
"LIQ": {
|
|
"2026-01-05": {
|
|
"gap_pct": 0.01,
|
|
"avg_daily_vol_14d": 5_000_000.0,
|
|
"avg_dollar_vol_30d": 3_000_000_000.0,
|
|
"entropy_20d": 0.70,
|
|
}
|
|
},
|
|
"HOT": {
|
|
"2026-01-05": {
|
|
"gap_pct": 0.02,
|
|
"avg_daily_vol_14d": 4_000_000.0,
|
|
"avg_dollar_vol_30d": 50_000_000.0,
|
|
"entropy_20d": 0.82,
|
|
}
|
|
},
|
|
}
|
|
|
|
result = momentum_intraday_first_candidates(
|
|
all_intraday,
|
|
["2026-01-05"],
|
|
strategy,
|
|
daily_enrichment=daily_enrichment,
|
|
max_per_day=2,
|
|
)
|
|
|
|
assert result == {"2026-01-05": ["LIQ"]}
|
|
|
|
|
|
def test_momentum_intraday_first_candidates_can_replace_tail_with_event_reserve() -> None:
|
|
strategy = StrategyParams(
|
|
candidate_source_mode="intraday_first",
|
|
candidate_intraday_rank_mode="weighted",
|
|
candidate_intraday_weight_gain=0.2,
|
|
candidate_intraday_weight_confirmation=0.5,
|
|
candidate_intraday_weight_volume_ratio=0.2,
|
|
candidate_intraday_weight_entry_dollar_volume=0.1,
|
|
candidate_intraday_event_reserve_slots=1,
|
|
candidate_intraday_event_reserve_min_score=1.0,
|
|
entry_minutes_after_open=10,
|
|
confirmation_minutes_after_entry=5,
|
|
min_confirmation_return_pct=0.0,
|
|
min_morning_gain_pct=0.01,
|
|
candidate_final_max_per_day=2,
|
|
)
|
|
all_intraday = {
|
|
"2026-01-05": {
|
|
"AAA": [
|
|
{"timestamp": "2026-01-05T14:30:00+00:00", "open": 10.0, "high": 10.1, "low": 9.9, "close": 10.0, "volume": 120_000},
|
|
{"timestamp": "2026-01-05T14:35:00+00:00", "open": 10.0, "high": 10.2, "low": 10.0, "close": 10.1, "volume": 120_000},
|
|
{"timestamp": "2026-01-05T14:40:00+00:00", "open": 10.1, "high": 10.6, "low": 10.0, "close": 10.5, "volume": 120_000},
|
|
{"timestamp": "2026-01-05T14:45:00+00:00", "open": 10.5, "high": 10.9, "low": 10.4, "close": 10.8, "volume": 120_000},
|
|
{"timestamp": "2026-01-05T14:50:00+00:00", "open": 10.8, "high": 11.0, "low": 10.7, "close": 10.9, "volume": 120_000},
|
|
],
|
|
"BBB": [
|
|
{"timestamp": "2026-01-05T14:30:00+00:00", "open": 20.0, "high": 20.2, "low": 19.9, "close": 20.0, "volume": 110_000},
|
|
{"timestamp": "2026-01-05T14:35:00+00:00", "open": 20.0, "high": 20.3, "low": 20.0, "close": 20.2, "volume": 110_000},
|
|
{"timestamp": "2026-01-05T14:40:00+00:00", "open": 20.2, "high": 20.7, "low": 20.1, "close": 20.6, "volume": 110_000},
|
|
{"timestamp": "2026-01-05T14:45:00+00:00", "open": 20.6, "high": 20.8, "low": 20.5, "close": 20.7, "volume": 110_000},
|
|
{"timestamp": "2026-01-05T14:50:00+00:00", "open": 20.7, "high": 20.9, "low": 20.6, "close": 20.8, "volume": 110_000},
|
|
],
|
|
"CAT": [
|
|
{"timestamp": "2026-01-05T14:30:00+00:00", "open": 30.0, "high": 30.1, "low": 29.9, "close": 30.0, "volume": 100_000},
|
|
{"timestamp": "2026-01-05T14:35:00+00:00", "open": 30.0, "high": 30.2, "low": 30.0, "close": 30.1, "volume": 100_000},
|
|
{"timestamp": "2026-01-05T14:40:00+00:00", "open": 30.1, "high": 30.5, "low": 30.0, "close": 30.4, "volume": 100_000},
|
|
{"timestamp": "2026-01-05T14:45:00+00:00", "open": 30.4, "high": 30.6, "low": 30.3, "close": 30.45, "volume": 100_000},
|
|
{"timestamp": "2026-01-05T14:50:00+00:00", "open": 30.45, "high": 30.7, "low": 30.4, "close": 30.5, "volume": 100_000},
|
|
],
|
|
}
|
|
}
|
|
daily_enrichment = {
|
|
"AAA": {"2026-01-05": {"gap_pct": 0.01, "avg_daily_vol_14d": 1_000_000.0}},
|
|
"BBB": {"2026-01-05": {"gap_pct": 0.01, "avg_daily_vol_14d": 1_000_000.0}},
|
|
"CAT": {
|
|
"2026-01-05": {
|
|
"gap_pct": 0.01,
|
|
"avg_daily_vol_14d": 1_000_000.0,
|
|
"event_flag": True,
|
|
"event_score": 1.5,
|
|
}
|
|
},
|
|
}
|
|
|
|
result = momentum_intraday_first_candidates(
|
|
all_intraday,
|
|
["2026-01-05"],
|
|
strategy,
|
|
daily_enrichment=daily_enrichment,
|
|
max_per_day=2,
|
|
)
|
|
|
|
assert result == {"2026-01-05": ["AAA", "CAT"]}
|
|
|
|
|
|
def test_momentum_intraday_first_candidates_can_reserve_moderate_liquid_followthrough() -> None:
|
|
strategy = StrategyParams(
|
|
candidate_source_mode="intraday_first",
|
|
candidate_intraday_rank_mode="weighted",
|
|
candidate_intraday_weight_gain=1.0,
|
|
entry_minutes_after_open=10,
|
|
confirmation_minutes_after_entry=5,
|
|
min_confirmation_return_pct=0.01,
|
|
min_morning_gain_pct=0.04,
|
|
candidate_final_max_per_day=2,
|
|
use_moderate_gap_liquid_sleeve=True,
|
|
moderate_gap_liquid_min_gap_pct=0.005,
|
|
moderate_gap_liquid_max_gap_pct=0.025,
|
|
moderate_gap_liquid_min_gain_pct=0.015,
|
|
moderate_gap_liquid_max_gain_pct=0.04,
|
|
moderate_gap_liquid_min_confirmation_return_pct=0.005,
|
|
moderate_gap_liquid_min_entry_dollar_volume=40_000_000.0,
|
|
moderate_gap_liquid_min_avg_dollar_vol_30d=250_000_000.0,
|
|
moderate_gap_liquid_max_avg_dollar_vol_30d=2_000_000_000.0,
|
|
moderate_gap_liquid_max_entropy_20d=0.86,
|
|
candidate_intraday_moderate_liquid_reserve_slots=1,
|
|
)
|
|
all_intraday = {
|
|
"2026-03-13": {
|
|
"AAA": [
|
|
{"timestamp": "2026-03-13T13:30:00+00:00", "open": 10.0, "high": 10.1, "low": 9.9, "close": 10.0, "volume": 80_000},
|
|
{"timestamp": "2026-03-13T13:35:00+00:00", "open": 10.0, "high": 10.5, "low": 10.0, "close": 10.4, "volume": 80_000},
|
|
{"timestamp": "2026-03-13T13:40:00+00:00", "open": 10.4, "high": 10.9, "low": 10.3, "close": 10.8, "volume": 80_000},
|
|
{"timestamp": "2026-03-13T13:45:00+00:00", "open": 10.8, "high": 11.1, "low": 10.7, "close": 11.0, "volume": 80_000},
|
|
{"timestamp": "2026-03-13T13:50:00+00:00", "open": 11.0, "high": 11.1, "low": 10.9, "close": 11.0, "volume": 80_000},
|
|
],
|
|
"BBB": [
|
|
{"timestamp": "2026-03-13T13:30:00+00:00", "open": 20.0, "high": 20.1, "low": 19.9, "close": 20.0, "volume": 80_000},
|
|
{"timestamp": "2026-03-13T13:35:00+00:00", "open": 20.0, "high": 20.7, "low": 20.0, "close": 20.5, "volume": 80_000},
|
|
{"timestamp": "2026-03-13T13:40:00+00:00", "open": 20.5, "high": 21.2, "low": 20.4, "close": 21.0, "volume": 80_000},
|
|
{"timestamp": "2026-03-13T13:45:00+00:00", "open": 21.0, "high": 21.3, "low": 20.9, "close": 21.2, "volume": 80_000},
|
|
{"timestamp": "2026-03-13T13:50:00+00:00", "open": 21.2, "high": 21.3, "low": 21.1, "close": 21.2, "volume": 80_000},
|
|
],
|
|
"TER": [
|
|
{"timestamp": "2026-03-13T13:30:00+00:00", "open": 100.0, "high": 100.2, "low": 99.8, "close": 100.0, "volume": 150_000},
|
|
{"timestamp": "2026-03-13T13:35:00+00:00", "open": 100.0, "high": 100.8, "low": 100.0, "close": 100.4, "volume": 150_000},
|
|
{"timestamp": "2026-03-13T13:40:00+00:00", "open": 100.4, "high": 101.4, "low": 100.3, "close": 101.2, "volume": 150_000},
|
|
{"timestamp": "2026-03-13T13:45:00+00:00", "open": 101.2, "high": 102.0, "low": 101.1, "close": 101.9, "volume": 150_000},
|
|
{"timestamp": "2026-03-13T13:50:00+00:00", "open": 101.9, "high": 102.1, "low": 101.8, "close": 102.0, "volume": 150_000},
|
|
],
|
|
}
|
|
}
|
|
daily_enrichment = {
|
|
"AAA": {"2026-03-13": {"gap_pct": 0.04, "avg_daily_vol_14d": 1_000_000.0, "avg_dollar_vol_30d": 100_000_000.0, "entropy_20d": 0.50}},
|
|
"BBB": {"2026-03-13": {"gap_pct": 0.04, "avg_daily_vol_14d": 1_000_000.0, "avg_dollar_vol_30d": 100_000_000.0, "entropy_20d": 0.50}},
|
|
"TER": {"2026-03-13": {"gap_pct": 0.012, "avg_daily_vol_14d": 8_000_000.0, "avg_dollar_vol_30d": 750_000_000.0, "entropy_20d": 0.79}},
|
|
}
|
|
|
|
result = momentum_intraday_first_candidates(
|
|
all_intraday,
|
|
["2026-03-13"],
|
|
strategy,
|
|
daily_enrichment=daily_enrichment,
|
|
max_per_day=2,
|
|
)
|
|
|
|
assert result == {"2026-03-13": ["AAA", "TER"]}
|
|
|
|
|
|
def test_momentum_intraday_moderate_liquid_reserve_can_be_sparse_only() -> None:
|
|
strategy = StrategyParams(
|
|
candidate_source_mode="intraday_first",
|
|
candidate_intraday_rank_mode="weighted",
|
|
candidate_intraday_weight_gain=1.0,
|
|
entry_minutes_after_open=10,
|
|
confirmation_minutes_after_entry=5,
|
|
min_confirmation_return_pct=0.0,
|
|
min_morning_gain_pct=0.01,
|
|
candidate_final_max_per_day=2,
|
|
top_n=2,
|
|
use_moderate_gap_liquid_sleeve=True,
|
|
moderate_gap_liquid_min_gap_pct=0.005,
|
|
moderate_gap_liquid_max_gap_pct=0.025,
|
|
moderate_gap_liquid_min_gain_pct=0.015,
|
|
moderate_gap_liquid_max_gain_pct=0.04,
|
|
moderate_gap_liquid_min_confirmation_return_pct=0.005,
|
|
moderate_gap_liquid_min_entry_dollar_volume=40_000_000.0,
|
|
moderate_gap_liquid_min_avg_dollar_vol_30d=250_000_000.0,
|
|
moderate_gap_liquid_max_avg_dollar_vol_30d=2_000_000_000.0,
|
|
moderate_gap_liquid_max_entropy_20d=0.86,
|
|
candidate_intraday_moderate_liquid_reserve_slots=1,
|
|
candidate_intraday_moderate_liquid_reserve_trigger_below=2,
|
|
)
|
|
bars = [
|
|
{"timestamp": "2026-03-13T13:30:00+00:00", "open": 100.0, "high": 100.2, "low": 99.8, "close": 100.0, "volume": 150_000},
|
|
{"timestamp": "2026-03-13T13:35:00+00:00", "open": 100.0, "high": 101.0, "low": 100.0, "close": 100.8, "volume": 150_000},
|
|
{"timestamp": "2026-03-13T13:40:00+00:00", "open": 100.8, "high": 102.0, "low": 100.7, "close": 101.7, "volume": 150_000},
|
|
{"timestamp": "2026-03-13T13:45:00+00:00", "open": 101.7, "high": 102.5, "low": 101.6, "close": 102.2, "volume": 150_000},
|
|
{"timestamp": "2026-03-13T13:50:00+00:00", "open": 102.2, "high": 102.4, "low": 102.0, "close": 102.3, "volume": 150_000},
|
|
]
|
|
all_intraday = {
|
|
"2026-03-13": {
|
|
"AAA": bars,
|
|
"BBB": [
|
|
{**bar, "open": bar["open"] * 2, "high": bar["high"] * 2, "low": bar["low"] * 2, "close": bar["close"] * 2}
|
|
for bar in bars
|
|
],
|
|
"TER": [
|
|
{**bar, "open": bar["open"] * 3, "high": bar["high"] * 3, "low": bar["low"] * 3, "close": bar["close"] * 3}
|
|
for bar in bars
|
|
],
|
|
}
|
|
}
|
|
daily_enrichment = {
|
|
"AAA": {"2026-03-13": {"gap_pct": 0.04, "avg_daily_vol_14d": 1_000_000.0, "avg_dollar_vol_30d": 100_000_000.0, "entropy_20d": 0.50}},
|
|
"BBB": {"2026-03-13": {"gap_pct": 0.04, "avg_daily_vol_14d": 1_000_000.0, "avg_dollar_vol_30d": 100_000_000.0, "entropy_20d": 0.50}},
|
|
"TER": {
|
|
"2026-03-13": {
|
|
"gap_pct": 0.012,
|
|
"avg_daily_vol_14d": 8_000_000.0,
|
|
"avg_dollar_vol_30d": 750_000_000.0,
|
|
"entropy_20d": 0.79,
|
|
"candidate_seed_moderate_liquid_overlay": True,
|
|
}
|
|
},
|
|
}
|
|
|
|
result = momentum_intraday_first_candidates(
|
|
all_intraday,
|
|
["2026-03-13"],
|
|
strategy,
|
|
daily_enrichment=daily_enrichment,
|
|
max_per_day=2,
|
|
)
|
|
|
|
assert set(result["2026-03-13"]) == {"AAA", "BBB"}
|
|
|
|
|
|
class _StubOracleClient:
|
|
def __init__(self, responses: dict[str, object], *, health_ok: bool = True) -> None:
|
|
self._responses = responses
|
|
self._health_ok = health_ok
|
|
self.calls: list[tuple[str, dict | None]] = []
|
|
|
|
async def get(self, path: str, params: dict | None = None) -> object:
|
|
self.calls.append((path, params))
|
|
response = self._responses[path]
|
|
if isinstance(response, Exception):
|
|
raise response
|
|
if callable(response):
|
|
return response(params)
|
|
return response
|
|
|
|
async def health_check_fast(self, timeout: float = 3.0) -> bool:
|
|
return self._health_ok
|
|
|
|
|
|
def test_orb_pre_screen_candidates_returns_full_ranked_universe_when_uncapped() -> None:
|
|
daily_bars = {
|
|
"AAA": [{"date": "2026-01-05", "open": 10.0}],
|
|
"BBB": [{"date": "2026-01-05", "open": 20.0}],
|
|
"CCC": [{"date": "2026-01-05", "open": 40.0}],
|
|
}
|
|
enrichment = {
|
|
"AAA": {"2026-01-05": {"atr_14": 4.0, "avg_dollar_vol_30d": 50_000_000.0}},
|
|
"BBB": {"2026-01-05": {"atr_14": 3.0, "avg_dollar_vol_30d": 50_000_000.0}},
|
|
"CCC": {"2026-01-05": {"atr_14": 1.0, "avg_dollar_vol_30d": 50_000_000.0}},
|
|
}
|
|
|
|
capped = orb_pre_screen_candidates(
|
|
daily_bars,
|
|
["2026-01-05"],
|
|
enrichment,
|
|
max_per_day=2,
|
|
)
|
|
uncapped = orb_pre_screen_candidates(
|
|
daily_bars,
|
|
["2026-01-05"],
|
|
enrichment,
|
|
max_per_day=None,
|
|
)
|
|
|
|
assert capped["2026-01-05"] == ["AAA", "BBB"]
|
|
assert uncapped["2026-01-05"] == ["AAA", "BBB", "CCC"]
|
|
|
|
|
|
def test_fetch_daily_bars_bulk_uses_bulk_endpoint_by_default() -> None:
|
|
client = _StubOracleClient(
|
|
{
|
|
"/api/v1/price/data": {
|
|
"bars": {
|
|
"AAA": [
|
|
{
|
|
"date": "2026-01-02",
|
|
"open": 10,
|
|
"high": 11,
|
|
"low": 9,
|
|
"close": 10.5,
|
|
"volume": 1000,
|
|
}
|
|
],
|
|
"BBB": [
|
|
{
|
|
"date": "2026-01-02",
|
|
"open": 20,
|
|
"high": 21,
|
|
"low": 19,
|
|
"close": 20.5,
|
|
"volume": 2000,
|
|
}
|
|
],
|
|
}
|
|
}
|
|
}
|
|
)
|
|
|
|
bars = asyncio.run(
|
|
fetch_daily_bars_bulk(
|
|
["AAA", "BBB"], "2026-01-02", "2026-01-02", client, concurrency=2
|
|
)
|
|
)
|
|
|
|
assert list(bars) == ["AAA", "BBB"]
|
|
assert len(client.calls) == 1
|
|
assert client.calls[0][0] == "/api/v1/price/data"
|
|
assert client.calls[0][1] == {
|
|
"tickers": "AAA,BBB",
|
|
"start_date": "2026-01-02",
|
|
"end_date": "2026-01-02",
|
|
}
|
|
|
|
|
|
def test_fetch_daily_bars_bulk_falls_back_to_single_ticker_on_chunk_failure() -> None:
|
|
client = _StubOracleClient(
|
|
{
|
|
"/api/v1/price/data": RuntimeError("bulk failed"),
|
|
"/api/v1/price/data/AAA": {
|
|
"ticker": "AAA",
|
|
"data": [
|
|
{
|
|
"date": "2026-01-02",
|
|
"open": 10,
|
|
"high": 11,
|
|
"low": 9,
|
|
"close": 10.5,
|
|
"volume": 1000,
|
|
}
|
|
],
|
|
},
|
|
"/api/v1/price/data/BBB": {
|
|
"ticker": "BBB",
|
|
"data": [
|
|
{
|
|
"date": "2026-01-02",
|
|
"open": 20,
|
|
"high": 21,
|
|
"low": 19,
|
|
"close": 20.5,
|
|
"volume": 2000,
|
|
}
|
|
],
|
|
},
|
|
}
|
|
)
|
|
|
|
bars = asyncio.run(
|
|
fetch_daily_bars_bulk(
|
|
["AAA", "BBB"], "2026-01-02", "2026-01-02", client, concurrency=2
|
|
)
|
|
)
|
|
|
|
assert list(bars) == ["AAA", "BBB"]
|
|
assert [call[0] for call in client.calls] == [
|
|
"/api/v1/price/data",
|
|
"/api/v1/price/data/AAA",
|
|
"/api/v1/price/data/BBB",
|
|
]
|
|
|
|
|
|
def test_fetch_daily_bars_bulk_repairs_suspiciously_short_bulk_responses() -> None:
|
|
short_rows = [
|
|
{
|
|
"date": f"2026-04-{6 + i:02d}",
|
|
"open": 100 + i,
|
|
"high": 101 + i,
|
|
"low": 99 + i,
|
|
"close": 100.5 + i,
|
|
"volume": 1000 + i,
|
|
}
|
|
for i in range(11)
|
|
]
|
|
full_row_dates = [
|
|
(datetime(2026, 1, 20) + timedelta(days=i * 3)).date().isoformat()
|
|
for i in range(24)
|
|
] + ["2026-04-20"]
|
|
full_rows = [
|
|
{
|
|
"date": day,
|
|
"open": 100 + i,
|
|
"high": 101 + i,
|
|
"low": 99 + i,
|
|
"close": 100.5 + i,
|
|
"volume": 1000 + i,
|
|
}
|
|
for i, day in enumerate(full_row_dates)
|
|
]
|
|
client = _StubOracleClient(
|
|
{
|
|
"/api/v1/price/data": {
|
|
"bars": {
|
|
"AAA": short_rows,
|
|
}
|
|
},
|
|
"/api/v1/price/data/AAA": {
|
|
"ticker": "AAA",
|
|
"data": full_rows,
|
|
},
|
|
}
|
|
)
|
|
|
|
bars = asyncio.run(
|
|
fetch_daily_bars_bulk(
|
|
["AAA"],
|
|
"2026-01-20",
|
|
"2026-04-20",
|
|
client,
|
|
concurrency=1,
|
|
)
|
|
)
|
|
|
|
assert len(bars["AAA"]) == 25
|
|
assert [call[0] for call in client.calls] == [
|
|
"/api/v1/price/data",
|
|
"/api/v1/price/data/AAA",
|
|
]
|
|
|
|
|
|
def test_fetch_daily_bars_bulk_hits_daily_cache_on_repeat_range(tmp_path) -> None:
|
|
cache = DailyBarCache(str(tmp_path))
|
|
client = _StubOracleClient(
|
|
{
|
|
"/api/v1/price/data": {
|
|
"bars": {
|
|
"AAA": [
|
|
{
|
|
"date": "2026-01-02",
|
|
"open": 10,
|
|
"high": 11,
|
|
"low": 9,
|
|
"close": 10.5,
|
|
"volume": 1000,
|
|
}
|
|
],
|
|
"BBB": [
|
|
{
|
|
"date": "2026-01-02",
|
|
"open": 20,
|
|
"high": 21,
|
|
"low": 19,
|
|
"close": 20.5,
|
|
"volume": 2000,
|
|
}
|
|
],
|
|
}
|
|
}
|
|
}
|
|
)
|
|
|
|
first = asyncio.run(
|
|
fetch_daily_bars_bulk(
|
|
["AAA", "BBB"],
|
|
"2026-01-01",
|
|
"2026-01-10",
|
|
client,
|
|
cache=cache,
|
|
concurrency=2,
|
|
)
|
|
)
|
|
assert list(first) == ["AAA", "BBB"]
|
|
assert len(client.calls) == 1
|
|
|
|
client.calls.clear()
|
|
second = asyncio.run(
|
|
fetch_daily_bars_bulk(
|
|
["AAA", "BBB"],
|
|
"2026-01-01",
|
|
"2026-01-10",
|
|
client,
|
|
cache=cache,
|
|
concurrency=2,
|
|
)
|
|
)
|
|
|
|
assert second == first
|
|
assert client.calls == []
|
|
|
|
|
|
def test_fetch_daily_bars_bulk_strict_snapshot_does_not_repair_misses(tmp_path) -> None:
|
|
base_cache = DailyBarCache(str(tmp_path))
|
|
cache = ReadOnlyDailyBarCache(base_cache)
|
|
base_cache.put(
|
|
"AAA",
|
|
"2026-01-01",
|
|
"2026-01-10",
|
|
[
|
|
{
|
|
"date": "2026-01-02",
|
|
"open": 10,
|
|
"high": 11,
|
|
"low": 9,
|
|
"close": 10.5,
|
|
"volume": 1000,
|
|
}
|
|
],
|
|
)
|
|
client = _StubOracleClient(
|
|
{
|
|
"/api/v1/price/data": {
|
|
"bars": {
|
|
"BBB": [
|
|
{
|
|
"date": "2026-01-02",
|
|
"open": 20,
|
|
"high": 21,
|
|
"low": 19,
|
|
"close": 20.5,
|
|
"volume": 2000,
|
|
}
|
|
],
|
|
}
|
|
}
|
|
}
|
|
)
|
|
|
|
bars = asyncio.run(
|
|
fetch_daily_bars_bulk(
|
|
["AAA", "BBB"],
|
|
"2026-01-01",
|
|
"2026-01-10",
|
|
client,
|
|
cache=cache,
|
|
concurrency=2,
|
|
)
|
|
)
|
|
|
|
assert list(bars) == ["AAA"]
|
|
assert client.calls == []
|
|
assert base_cache.get("BBB", "2026-01-01", "2026-01-10") is None
|
|
|
|
|
|
def test_fetch_daily_bars_bulk_drops_stale_tail_cache_when_oracle_has_no_tail(tmp_path) -> None:
|
|
cache = DailyBarCache(str(tmp_path))
|
|
cached_rows = [
|
|
{
|
|
"date": "2026-01-20",
|
|
"open": 100.0,
|
|
"high": 101.0,
|
|
"low": 99.0,
|
|
"close": 100.5,
|
|
"volume": 1000.0,
|
|
},
|
|
{
|
|
"date": "2026-02-01",
|
|
"open": 101.0,
|
|
"high": 102.0,
|
|
"low": 100.0,
|
|
"close": 101.5,
|
|
"volume": 1100.0,
|
|
},
|
|
]
|
|
cache.put("AAA", "2026-01-20", "2026-02-01", cached_rows)
|
|
client = _StubOracleClient({"/api/v1/price/data": {"bars": {"AAA": []}}})
|
|
|
|
bars = asyncio.run(
|
|
fetch_daily_bars_bulk(
|
|
["AAA"],
|
|
"2026-01-20",
|
|
"2026-04-20",
|
|
client,
|
|
cache=cache,
|
|
concurrency=1,
|
|
)
|
|
)
|
|
|
|
assert bars == {}
|
|
assert client.calls == [
|
|
(
|
|
"/api/v1/price/data",
|
|
{
|
|
"tickers": "AAA",
|
|
"start_date": "2026-02-02",
|
|
"end_date": "2026-04-20",
|
|
},
|
|
)
|
|
]
|
|
|
|
|
|
def test_fetch_intraday_bulk_recovers_from_bulk_chunk_failure_by_splitting() -> None:
|
|
from libs.intraday.screener import fetch_intraday_bulk
|
|
|
|
def intraday_response(params: dict | None) -> dict:
|
|
tickers = (params or {}).get("tickers", "")
|
|
if "," in tickers:
|
|
raise RuntimeError("chunk failed")
|
|
ticker = tickers
|
|
return {
|
|
"bars": {
|
|
ticker: [
|
|
{
|
|
"timestamp": f"2026-01-02T14:{30 + i:02d}:00+00:00",
|
|
"open": 10.0,
|
|
"high": 11.0,
|
|
"low": 9.5,
|
|
"close": 10.5,
|
|
"volume": 1000.0,
|
|
"vwap": 10.4,
|
|
}
|
|
for i in range(10)
|
|
]
|
|
}
|
|
}
|
|
|
|
client = _StubOracleClient({"/api/v1/alpaca/intraday": intraday_response})
|
|
|
|
bars = asyncio.run(
|
|
fetch_intraday_bulk(
|
|
{"2026-01-02": ["AAA", "BBB"]},
|
|
client,
|
|
cache=None,
|
|
concurrency=2,
|
|
)
|
|
)
|
|
|
|
assert list(bars["2026-01-02"]) == ["AAA", "BBB"]
|
|
assert [call[1]["tickers"] for call in client.calls] == ["AAA,BBB", "AAA", "BBB"]
|
|
|
|
|
|
def test_fetch_intraday_bulk_negative_caches_sparse_responses(tmp_path) -> None:
|
|
from libs.intraday.cache import IntradayCache
|
|
from libs.intraday.screener import fetch_intraday_bulk
|
|
|
|
def intraday_response(params: dict | None) -> dict:
|
|
tickers = (params or {}).get("tickers", "")
|
|
result = {"bars": {}}
|
|
for ticker in tickers.split(","):
|
|
if ticker == "AAA":
|
|
result["bars"][ticker] = [
|
|
{
|
|
"timestamp": "2026-01-02T14:30:00+00:00",
|
|
"open": 10.0,
|
|
"high": 11.0,
|
|
"low": 9.5,
|
|
"close": 10.5,
|
|
"volume": 1000.0,
|
|
"vwap": 10.4,
|
|
}
|
|
]
|
|
else:
|
|
result["bars"][ticker] = [
|
|
{
|
|
"timestamp": f"2026-01-02T14:{30 + i:02d}:00+00:00",
|
|
"open": 20.0,
|
|
"high": 21.0,
|
|
"low": 19.5,
|
|
"close": 20.5,
|
|
"volume": 1000.0,
|
|
"vwap": 20.4,
|
|
}
|
|
for i in range(10)
|
|
]
|
|
return result
|
|
|
|
client = _StubOracleClient({"/api/v1/alpaca/intraday": intraday_response})
|
|
cache = IntradayCache(str(tmp_path))
|
|
|
|
first = asyncio.run(
|
|
fetch_intraday_bulk(
|
|
{"2026-01-02": ["AAA", "BBB"]},
|
|
client,
|
|
cache=cache,
|
|
concurrency=2,
|
|
)
|
|
)
|
|
|
|
assert list(first["2026-01-02"]) == ["BBB"]
|
|
assert cache.has("AAA", "2026-01-02") is True
|
|
assert cache.get("AAA", "2026-01-02") == []
|
|
|
|
client.calls.clear()
|
|
second = asyncio.run(
|
|
fetch_intraday_bulk(
|
|
{"2026-01-02": ["AAA", "BBB"]},
|
|
client,
|
|
cache=cache,
|
|
concurrency=2,
|
|
)
|
|
)
|
|
|
|
assert list(second["2026-01-02"]) == ["BBB"]
|
|
assert client.calls == []
|
|
|
|
|
|
def test_fetch_intraday_bulk_uses_today_endpoint_without_caching_live_bars(tmp_path, monkeypatch) -> None:
|
|
from libs.intraday.cache import IntradayCache
|
|
from libs.intraday.screener import fetch_intraday_bulk
|
|
|
|
today = "2026-05-01"
|
|
|
|
class MarketHoursDateTime(datetime):
|
|
@classmethod
|
|
def now(cls, tz=None):
|
|
value = datetime(2026, 5, 1, 10, 0, tzinfo=ZoneInfo("America/New_York"))
|
|
return value.astimezone(tz) if tz else value
|
|
|
|
monkeypatch.setattr(screener_module, "datetime", MarketHoursDateTime)
|
|
|
|
def intraday_today_response(params: dict | None) -> dict:
|
|
ticker = (params or {}).get("tickers", "")
|
|
return {
|
|
"bars": {
|
|
ticker: [
|
|
{
|
|
"timestamp": (
|
|
datetime.fromisoformat(f"{today}T13:30:00+00:00")
|
|
+ timedelta(minutes=5 * i)
|
|
).isoformat(),
|
|
"open": 10.0 + i * 0.1,
|
|
"high": 10.2 + i * 0.1,
|
|
"low": 9.9 + i * 0.1,
|
|
"close": 10.1 + i * 0.1,
|
|
"volume": 1000.0,
|
|
"vwap": 10.05 + i * 0.1,
|
|
}
|
|
for i in range(78)
|
|
]
|
|
}
|
|
}
|
|
|
|
client = _StubOracleClient({"/api/v1/alpaca/intraday/today": intraday_today_response})
|
|
cache = IntradayCache(str(tmp_path))
|
|
|
|
bars = asyncio.run(
|
|
fetch_intraday_bulk(
|
|
{today: ["AAA"]},
|
|
client,
|
|
cache=cache,
|
|
concurrency=1,
|
|
)
|
|
)
|
|
|
|
assert list(bars[today]) == ["AAA"]
|
|
assert client.calls == [
|
|
(
|
|
"/api/v1/alpaca/intraday/today",
|
|
{"tickers": "AAA", "interval": "5m"},
|
|
)
|
|
]
|
|
assert cache.has("AAA", today) is False
|
|
|
|
|
|
def test_fetch_intraday_bulk_uses_historical_today_after_close_and_caches(tmp_path, monkeypatch) -> None:
|
|
from libs.intraday.cache import IntradayCache
|
|
from libs.intraday.screener import fetch_intraday_bulk
|
|
|
|
today = "2026-05-01"
|
|
|
|
class AfterCloseDateTime(datetime):
|
|
@classmethod
|
|
def now(cls, tz=None):
|
|
value = datetime(2026, 5, 1, 21, 15, tzinfo=ZoneInfo("America/New_York"))
|
|
return value.astimezone(tz) if tz else value
|
|
|
|
monkeypatch.setattr(screener_module, "datetime", AfterCloseDateTime)
|
|
|
|
def historical_response(params: dict | None) -> dict:
|
|
ticker = (params or {}).get("tickers", "")
|
|
return {
|
|
"bars": {
|
|
ticker: [
|
|
{
|
|
"timestamp": (
|
|
datetime.fromisoformat(f"{today}T13:30:00+00:00")
|
|
+ timedelta(minutes=5 * i)
|
|
).isoformat(),
|
|
"open": 10.0 + i * 0.1,
|
|
"high": 10.2 + i * 0.1,
|
|
"low": 9.9 + i * 0.1,
|
|
"close": 10.1 + i * 0.1,
|
|
"volume": 1000.0,
|
|
"vwap": 10.05 + i * 0.1,
|
|
}
|
|
for i in range(78)
|
|
]
|
|
}
|
|
}
|
|
|
|
client = _StubOracleClient({"/api/v1/alpaca/intraday": historical_response})
|
|
cache = IntradayCache(str(tmp_path))
|
|
|
|
bars = asyncio.run(
|
|
fetch_intraday_bulk(
|
|
{today: ["AAA"]},
|
|
client,
|
|
cache=cache,
|
|
concurrency=1,
|
|
)
|
|
)
|
|
|
|
assert list(bars[today]) == ["AAA"]
|
|
assert client.calls == [
|
|
(
|
|
"/api/v1/alpaca/intraday",
|
|
{"tickers": "AAA", "interval": "5m", "start_date": today, "end_date": today},
|
|
)
|
|
]
|
|
assert cache.has("AAA", today) is True
|
|
|
|
|
|
def test_fetch_intraday_bulk_ignores_existing_today_cache_after_close(tmp_path, monkeypatch) -> None:
|
|
from libs.intraday.cache import IntradayCache
|
|
from libs.intraday.screener import fetch_intraday_bulk
|
|
|
|
today = "2026-05-01"
|
|
|
|
class AfterCloseDateTime(datetime):
|
|
@classmethod
|
|
def now(cls, tz=None):
|
|
value = datetime(2026, 5, 1, 21, 15, tzinfo=ZoneInfo("America/New_York"))
|
|
return value.astimezone(tz) if tz else value
|
|
|
|
monkeypatch.setattr(screener_module, "datetime", AfterCloseDateTime)
|
|
|
|
cache = IntradayCache(str(tmp_path))
|
|
cache.put(
|
|
"AAA",
|
|
today,
|
|
[
|
|
{
|
|
"timestamp": (
|
|
datetime.fromisoformat(f"{today}T13:30:00+00:00")
|
|
+ timedelta(minutes=5 * i)
|
|
).isoformat(),
|
|
"open": 10.0,
|
|
"high": 10.1,
|
|
"low": 9.9,
|
|
"close": 10.0,
|
|
"volume": 1000.0,
|
|
"vwap": 10.0,
|
|
}
|
|
for i in range(10)
|
|
],
|
|
)
|
|
|
|
def historical_response(params: dict | None) -> dict:
|
|
ticker = (params or {}).get("tickers", "")
|
|
return {
|
|
"bars": {
|
|
ticker: [
|
|
{
|
|
"timestamp": (
|
|
datetime.fromisoformat(f"{today}T13:30:00+00:00")
|
|
+ timedelta(minutes=5 * i)
|
|
).isoformat(),
|
|
"open": 20.0,
|
|
"high": 20.2,
|
|
"low": 19.9,
|
|
"close": 20.1,
|
|
"volume": 2000.0,
|
|
"vwap": 20.05,
|
|
}
|
|
for i in range(78)
|
|
]
|
|
}
|
|
}
|
|
|
|
client = _StubOracleClient({"/api/v1/alpaca/intraday": historical_response})
|
|
|
|
bars = asyncio.run(
|
|
fetch_intraday_bulk(
|
|
{today: ["AAA"]},
|
|
client,
|
|
cache=cache,
|
|
concurrency=1,
|
|
)
|
|
)
|
|
|
|
assert client.calls == [
|
|
(
|
|
"/api/v1/alpaca/intraday",
|
|
{"tickers": "AAA", "interval": "5m", "start_date": today, "end_date": today},
|
|
)
|
|
]
|
|
assert bars[today]["AAA"][0]["open"] == 20.0
|
|
|
|
|
|
def test_fetch_intraday_bulk_rejects_truncated_today_after_close(tmp_path, monkeypatch) -> None:
|
|
from libs.intraday.cache import IntradayCache
|
|
from libs.intraday.screener import fetch_intraday_bulk
|
|
|
|
today = "2026-05-01"
|
|
|
|
class AfterCloseDateTime(datetime):
|
|
@classmethod
|
|
def now(cls, tz=None):
|
|
value = datetime(2026, 5, 1, 21, 15, tzinfo=ZoneInfo("America/New_York"))
|
|
return value.astimezone(tz) if tz else value
|
|
|
|
monkeypatch.setattr(screener_module, "datetime", AfterCloseDateTime)
|
|
|
|
def truncated_response(params: dict | None) -> dict:
|
|
ticker = (params or {}).get("tickers", "")
|
|
return {
|
|
"bars": {
|
|
ticker: [
|
|
{
|
|
"timestamp": (
|
|
datetime.fromisoformat(f"{today}T13:30:00+00:00")
|
|
+ timedelta(minutes=5 * i)
|
|
).isoformat(),
|
|
"open": 10.0,
|
|
"high": 10.2,
|
|
"low": 9.9,
|
|
"close": 10.1,
|
|
"volume": 1000.0,
|
|
"vwap": 10.05,
|
|
}
|
|
for i in range(20)
|
|
]
|
|
}
|
|
}
|
|
|
|
client = _StubOracleClient(
|
|
{
|
|
"/api/v1/alpaca/intraday": truncated_response,
|
|
"/api/v1/alpaca/intraday/today": truncated_response,
|
|
}
|
|
)
|
|
cache = IntradayCache(str(tmp_path))
|
|
|
|
bars = asyncio.run(
|
|
fetch_intraday_bulk(
|
|
{today: ["AAA"]},
|
|
client,
|
|
cache=cache,
|
|
concurrency=1,
|
|
)
|
|
)
|
|
|
|
assert "AAA" not in bars.get(today, {})
|
|
assert cache.has("AAA", today) is False
|
|
assert client.calls == [
|
|
(
|
|
"/api/v1/alpaca/intraday",
|
|
{"tickers": "AAA", "interval": "5m", "start_date": today, "end_date": today},
|
|
),
|
|
(
|
|
"/api/v1/alpaca/intraday/today",
|
|
{"tickers": "AAA", "interval": "5m"},
|
|
),
|
|
]
|
|
|
|
|
|
def test_fetch_daily_bars_bulk_rebuilds_from_intraday_cache_when_oracle_unavailable(tmp_path) -> None:
|
|
daily_cache = DailyBarCache(str(tmp_path / "daily"))
|
|
intraday_cache = IntradayCache(str(tmp_path / "intraday"))
|
|
|
|
intraday_cache.put(
|
|
"AAA",
|
|
"2026-01-02",
|
|
[
|
|
{
|
|
"timestamp": "2026-01-02T14:30:00+00:00",
|
|
"open": 10.0,
|
|
"high": 10.5,
|
|
"low": 9.9,
|
|
"close": 10.4,
|
|
"volume": 100.0,
|
|
"vwap": 10.2,
|
|
},
|
|
{
|
|
"timestamp": "2026-01-02T14:35:00+00:00",
|
|
"open": 10.4,
|
|
"high": 11.0,
|
|
"low": 10.2,
|
|
"close": 10.8,
|
|
"volume": 150.0,
|
|
"vwap": 10.7,
|
|
},
|
|
]
|
|
* 5,
|
|
)
|
|
intraday_cache.put(
|
|
"AAA",
|
|
"2026-01-03",
|
|
[
|
|
{
|
|
"timestamp": "2026-01-03T14:30:00+00:00",
|
|
"open": 11.0,
|
|
"high": 11.2,
|
|
"low": 10.8,
|
|
"close": 11.1,
|
|
"volume": 120.0,
|
|
"vwap": 11.0,
|
|
},
|
|
{
|
|
"timestamp": "2026-01-03T14:35:00+00:00",
|
|
"open": 11.1,
|
|
"high": 11.4,
|
|
"low": 11.0,
|
|
"close": 11.3,
|
|
"volume": 180.0,
|
|
"vwap": 11.2,
|
|
},
|
|
]
|
|
* 5,
|
|
)
|
|
|
|
client = _StubOracleClient({"/api/v1/price/data": RuntimeError("oracle down")})
|
|
bars = asyncio.run(
|
|
fetch_daily_bars_bulk(
|
|
["AAA"],
|
|
"2026-01-02",
|
|
"2026-01-03",
|
|
client,
|
|
cache=daily_cache,
|
|
intraday_cache_fallback=intraday_cache,
|
|
concurrency=1,
|
|
)
|
|
)
|
|
|
|
assert list(bars) == ["AAA"]
|
|
assert bars["AAA"] == [
|
|
{
|
|
"date": "2026-01-02",
|
|
"open": 10.0,
|
|
"high": 11.0,
|
|
"low": 9.9,
|
|
"close": 10.8,
|
|
"volume": 1250.0,
|
|
},
|
|
{
|
|
"date": "2026-01-03",
|
|
"open": 11.0,
|
|
"high": 11.4,
|
|
"low": 10.8,
|
|
"close": 11.3,
|
|
"volume": 1500.0,
|
|
},
|
|
]
|
|
assert daily_cache.get("AAA", "2026-01-02", "2026-01-03") == bars["AAA"]
|
|
|
|
|
|
def test_fetch_daily_bars_bulk_prefers_intraday_fallback_without_oracle_calls(tmp_path) -> None:
|
|
intraday_cache = IntradayCache(str(tmp_path / "intraday"))
|
|
intraday_cache.put(
|
|
"AAA",
|
|
"2026-01-02",
|
|
[
|
|
{
|
|
"timestamp": f"2026-01-02T14:{30 + i:02d}:00+00:00",
|
|
"open": 10.0,
|
|
"high": 10.0 + i * 0.1,
|
|
"low": 9.8,
|
|
"close": 10.0 + i * 0.1,
|
|
"volume": 100.0 + i,
|
|
"vwap": 10.0 + i * 0.05,
|
|
}
|
|
for i in range(10)
|
|
],
|
|
)
|
|
client = _StubOracleClient({"/api/v1/price/data": RuntimeError("should not call")})
|
|
|
|
bars = asyncio.run(
|
|
fetch_daily_bars_bulk(
|
|
["AAA"],
|
|
"2026-01-02",
|
|
"2026-01-02",
|
|
client,
|
|
intraday_cache_fallback=intraday_cache,
|
|
prefer_intraday_fallback=True,
|
|
concurrency=1,
|
|
)
|
|
)
|
|
|
|
assert list(bars) == ["AAA"]
|
|
assert client.calls == []
|
|
|
|
|
|
def test_fetch_daily_bars_bulk_repairs_sparse_intraday_fallback_before_using_it(tmp_path) -> None:
|
|
intraday_cache = IntradayCache(str(tmp_path / "intraday"))
|
|
for day in [f"2026-04-{6 + i:02d}" for i in range(11)]:
|
|
intraday_cache.put(
|
|
"AAA",
|
|
day,
|
|
[
|
|
{
|
|
"timestamp": f"{day}T14:{30 + i:02d}:00+00:00",
|
|
"open": 10.0,
|
|
"high": 10.2,
|
|
"low": 9.8,
|
|
"close": 10.1,
|
|
"volume": 100.0,
|
|
"vwap": 10.0,
|
|
}
|
|
for i in range(10)
|
|
],
|
|
)
|
|
daily_cache = DailyBarCache(str(tmp_path / "daily"))
|
|
row_dates = [
|
|
(datetime(2026, 1, 20) + timedelta(days=i * 3)).date().isoformat()
|
|
for i in range(24)
|
|
] + ["2026-04-20"]
|
|
full_rows = [
|
|
{
|
|
"date": day,
|
|
"open": 100 + i,
|
|
"high": 101 + i,
|
|
"low": 99 + i,
|
|
"close": 100.5 + i,
|
|
"volume": 1000 + i,
|
|
}
|
|
for i, day in enumerate(row_dates)
|
|
]
|
|
client = _StubOracleClient(
|
|
{
|
|
"/api/v1/price/data": {
|
|
"bars": {
|
|
"AAA": full_rows,
|
|
}
|
|
}
|
|
}
|
|
)
|
|
|
|
bars = asyncio.run(
|
|
fetch_daily_bars_bulk(
|
|
["AAA"],
|
|
"2026-01-20",
|
|
"2026-04-20",
|
|
client,
|
|
cache=daily_cache,
|
|
intraday_cache_fallback=intraday_cache,
|
|
prefer_intraday_fallback=True,
|
|
concurrency=1,
|
|
)
|
|
)
|
|
|
|
assert len(bars["AAA"]) == len(full_rows)
|
|
assert [call[0] for call in client.calls] == ["/api/v1/price/data"]
|
|
assert daily_cache.get("AAA", "2026-01-20", "2026-04-20") == [
|
|
{
|
|
"date": row["date"],
|
|
"open": float(row["open"]),
|
|
"high": float(row["high"]),
|
|
"low": float(row["low"]),
|
|
"close": float(row["close"]),
|
|
"volume": float(row["volume"]),
|
|
}
|
|
for row in full_rows
|
|
]
|
|
|
|
|
|
def test_fetch_daily_bars_bulk_skips_oracle_misses_when_health_check_fails(tmp_path) -> None:
|
|
intraday_cache = IntradayCache(str(tmp_path / "intraday"))
|
|
intraday_cache.put(
|
|
"AAA",
|
|
"2026-01-02",
|
|
[
|
|
{
|
|
"timestamp": f"2026-01-02T14:{30 + i:02d}:00+00:00",
|
|
"open": 10.0,
|
|
"high": 10.2,
|
|
"low": 9.8,
|
|
"close": 10.1,
|
|
"volume": 100.0,
|
|
"vwap": 10.0,
|
|
}
|
|
for i in range(10)
|
|
],
|
|
)
|
|
client = _StubOracleClient({"/api/v1/price/data": RuntimeError("should not call")}, health_ok=False)
|
|
|
|
bars = asyncio.run(
|
|
fetch_daily_bars_bulk(
|
|
["AAA", "BBB"],
|
|
"2026-01-02",
|
|
"2026-01-02",
|
|
client,
|
|
intraday_cache_fallback=intraday_cache,
|
|
prefer_intraday_fallback=True,
|
|
skip_oracle_when_unhealthy=True,
|
|
concurrency=1,
|
|
)
|
|
)
|
|
|
|
assert list(bars) == ["AAA"]
|
|
assert client.calls == []
|
|
|
|
|
|
def test_fetch_intraday_bulk_skips_uncached_pairs_when_oracle_health_fails(tmp_path) -> None:
|
|
from libs.intraday.screener import fetch_intraday_bulk
|
|
|
|
cache = IntradayCache(str(tmp_path))
|
|
cache.put(
|
|
"AAA",
|
|
"2026-01-02",
|
|
[
|
|
{
|
|
"timestamp": f"2026-01-02T14:{30 + i:02d}:00+00:00",
|
|
"open": 10.0,
|
|
"high": 10.5,
|
|
"low": 9.8,
|
|
"close": 10.3,
|
|
"volume": 1000.0,
|
|
"vwap": 10.2,
|
|
}
|
|
for i in range(10)
|
|
],
|
|
)
|
|
client = _StubOracleClient({"/api/v1/alpaca/intraday": RuntimeError("should not call")}, health_ok=False)
|
|
|
|
bars = asyncio.run(
|
|
fetch_intraday_bulk(
|
|
{"2026-01-02": ["AAA", "BBB"]},
|
|
client,
|
|
cache=cache,
|
|
skip_oracle_when_unhealthy=True,
|
|
concurrency=1,
|
|
)
|
|
)
|
|
|
|
assert list(bars["2026-01-02"]) == ["AAA"]
|
|
assert client.calls == []
|
|
|
|
|
|
def test_resolve_universe_screener_uses_snapshot_fallback(tmp_path, monkeypatch) -> None:
|
|
monkeypatch.setattr(
|
|
screener_module,
|
|
"get_settings",
|
|
lambda: SimpleNamespace(data_root=str(tmp_path)),
|
|
)
|
|
|
|
params = UniverseParams(
|
|
source="screener",
|
|
market_cap_min=100_000_000.0,
|
|
avg_volume_min=200_000,
|
|
min_price=2.0,
|
|
)
|
|
|
|
async def first_search(self, **kwargs):
|
|
return [
|
|
SimpleNamespace(symbol="TSLA"),
|
|
SimpleNamespace(symbol="NVDA"),
|
|
SimpleNamespace(symbol="TSLA"),
|
|
]
|
|
|
|
async def failing_search(self, **kwargs):
|
|
raise RuntimeError("screener down")
|
|
|
|
monkeypatch.setattr(screener_module.ScreenerService, "search_all_stocks", first_search)
|
|
first = asyncio.run(resolve_universe(params, client=object()))
|
|
assert first == ["NVDA", "TSLA"]
|
|
|
|
monkeypatch.setattr(screener_module.ScreenerService, "search_all_stocks", failing_search)
|
|
second = asyncio.run(resolve_universe(params, client=object()))
|
|
assert second == ["NVDA", "TSLA"]
|
|
|
|
|
|
def test_resolve_universe_broad_uses_named_yaml_snapshot() -> None:
|
|
params = UniverseParams(source="broad")
|
|
|
|
symbols = asyncio.run(resolve_universe(params, client=object()))
|
|
|
|
assert "AAPL" in symbols
|
|
assert "TSLA" in symbols
|
|
assert len(symbols) > 3000
|