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.

941 lines
30 KiB
Python

from __future__ import annotations
import asyncio
from types import SimpleNamespace
from libs.intraday.cache import DailyBarCache, IntradayCache
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_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_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"]}
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_rows = [
{
"date": f"2026-02-{1 + i:02d}",
"open": 100 + i,
"high": 101 + i,
"low": 99 + i,
"close": 100.5 + i,
"volume": 1000 + i,
}
for i in range(25)
]
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_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_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_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"]