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.
186 lines
5.8 KiB
Python
186 lines
5.8 KiB
Python
from __future__ import annotations
|
|
|
|
import pyarrow as pa
|
|
import pyarrow.parquet as pq
|
|
|
|
from libs.intraday.cache import DailyBarCache, IntradayCache
|
|
|
|
|
|
def test_legacy_cache_file_is_treated_as_miss_and_removed(tmp_path) -> None:
|
|
cache = IntradayCache(str(tmp_path))
|
|
cache_path = tmp_path / "AAPL" / "2026-01-05.parquet"
|
|
cache_path.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
legacy = pa.table({
|
|
"timestamp": ["2026-01-05T13:30:00Z"],
|
|
"open": [100.0],
|
|
"high": [101.0],
|
|
"low": [99.5],
|
|
"close": [100.5],
|
|
"volume": [1000.0],
|
|
"vwap": [100.4],
|
|
})
|
|
pq.write_table(legacy, str(cache_path))
|
|
|
|
assert cache.has("AAPL", "2026-01-05") is False
|
|
assert cache.get("AAPL", "2026-01-05") is None
|
|
assert cache_path.exists() is False
|
|
|
|
|
|
def test_cache_put_writes_metadata_valid_file(tmp_path) -> None:
|
|
cache = IntradayCache(str(tmp_path))
|
|
bars = []
|
|
for i in range(10):
|
|
bars.append({
|
|
"timestamp": f"2026-01-05T13:{30 + i:02d}:00Z",
|
|
"open": 100.0,
|
|
"high": 101.0,
|
|
"low": 99.5,
|
|
"close": 100.5,
|
|
"volume": 1000.0,
|
|
"vwap": 100.4,
|
|
})
|
|
|
|
cache.put("AAPL", "2026-01-05", bars)
|
|
|
|
assert cache.has("AAPL", "2026-01-05") is True
|
|
assert cache.get("AAPL", "2026-01-05") == bars
|
|
|
|
|
|
def test_intraday_cache_rejects_too_few_rows(tmp_path) -> None:
|
|
cache = IntradayCache(str(tmp_path))
|
|
cache_path = tmp_path / "AAPL" / "2026-01-05.parquet"
|
|
cache_path.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
tiny = pa.table({
|
|
"timestamp": [f"2026-01-05T13:{30+i:02d}:00Z" for i in range(5)],
|
|
"open": [100.0] * 5,
|
|
"high": [101.0] * 5,
|
|
"low": [99.5] * 5,
|
|
"close": [100.5] * 5,
|
|
"volume": [1000.0] * 5,
|
|
"vwap": [100.4] * 5,
|
|
}, schema=pa.schema([
|
|
pa.field("timestamp", pa.string()),
|
|
pa.field("open", pa.float64()),
|
|
pa.field("high", pa.float64()),
|
|
pa.field("low", pa.float64()),
|
|
pa.field("close", pa.float64()),
|
|
pa.field("volume", pa.float64()),
|
|
pa.field("vwap", pa.float64()),
|
|
]).with_metadata({
|
|
b"intraday_cache_version": b"3",
|
|
b"intraday_cache_source": b"api_v1_alpaca_intraday",
|
|
b"intraday_cache_interval": b"5min",
|
|
}))
|
|
pq.write_table(tiny, str(cache_path))
|
|
|
|
assert cache.has("AAPL", "2026-01-05") is False
|
|
assert cache_path.exists() is False
|
|
|
|
|
|
def test_intraday_cache_accepts_existing_positive_file_without_kind_metadata(tmp_path) -> None:
|
|
cache = IntradayCache(str(tmp_path))
|
|
cache_path = tmp_path / "AAPL" / "2026-01-05.parquet"
|
|
cache_path.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
rows = 12
|
|
table = pa.table({
|
|
"timestamp": [f"2026-01-05T13:{30+i:02d}:00Z" for i in range(rows)],
|
|
"open": [100.0] * rows,
|
|
"high": [101.0] * rows,
|
|
"low": [99.5] * rows,
|
|
"close": [100.5] * rows,
|
|
"volume": [1000.0] * rows,
|
|
"vwap": [100.4] * rows,
|
|
}, schema=pa.schema([
|
|
pa.field("timestamp", pa.string()),
|
|
pa.field("open", pa.float64()),
|
|
pa.field("high", pa.float64()),
|
|
pa.field("low", pa.float64()),
|
|
pa.field("close", pa.float64()),
|
|
pa.field("volume", pa.float64()),
|
|
pa.field("vwap", pa.float64()),
|
|
]).with_metadata({
|
|
b"intraday_cache_version": b"3",
|
|
b"intraday_cache_source": b"api_v1_alpaca_intraday",
|
|
b"intraday_cache_interval": b"5min",
|
|
}))
|
|
pq.write_table(table, str(cache_path))
|
|
|
|
assert cache.has("AAPL", "2026-01-05") is True
|
|
assert len(cache.get("AAPL", "2026-01-05") or []) == rows
|
|
|
|
|
|
def test_intraday_cache_negative_entry_suppresses_rereads(tmp_path) -> None:
|
|
cache = IntradayCache(str(tmp_path))
|
|
|
|
cache.put_negative("AAPL", "2026-01-05", reason="sparse")
|
|
|
|
assert cache.has("AAPL", "2026-01-05") is True
|
|
assert cache.get("AAPL", "2026-01-05") == []
|
|
|
|
|
|
def test_daily_cache_put_and_get_uses_requested_coverage_range(tmp_path) -> None:
|
|
cache = DailyBarCache(str(tmp_path))
|
|
bars = [
|
|
{
|
|
"date": "2026-01-02",
|
|
"open": 100.0,
|
|
"high": 101.0,
|
|
"low": 99.0,
|
|
"close": 100.5,
|
|
"volume": 1000.0,
|
|
},
|
|
{
|
|
"date": "2026-01-05",
|
|
"open": 101.0,
|
|
"high": 102.0,
|
|
"low": 100.0,
|
|
"close": 101.5,
|
|
"volume": 1200.0,
|
|
},
|
|
]
|
|
|
|
cache.put("AAPL", "2026-01-01", "2026-01-10", bars)
|
|
|
|
assert cache.get("AAPL", "2026-01-01", "2026-01-10") == bars
|
|
assert cache.get("AAPL", "2026-01-02", "2026-01-05") == bars
|
|
assert cache.get("AAPL", "2025-12-31", "2026-01-10") is None
|
|
|
|
|
|
def test_daily_cache_rejects_egregiously_partial_warmup_file(tmp_path) -> None:
|
|
cache = DailyBarCache(str(tmp_path))
|
|
cache_path = tmp_path / "BLD.parquet"
|
|
rows = []
|
|
for i in range(11):
|
|
rows.append(
|
|
{
|
|
"date": f"2026-04-{6 + i:02d}",
|
|
"open": 100.0 + i,
|
|
"high": 101.0 + i,
|
|
"low": 99.0 + i,
|
|
"close": 100.5 + i,
|
|
"volume": 1_000.0 + i,
|
|
}
|
|
)
|
|
|
|
schema = pa.schema([
|
|
pa.field("date", pa.string()),
|
|
pa.field("open", pa.float64()),
|
|
pa.field("high", pa.float64()),
|
|
pa.field("low", pa.float64()),
|
|
pa.field("close", pa.float64()),
|
|
pa.field("volume", pa.float64()),
|
|
]).with_metadata({
|
|
b"daily_cache_version": b"1",
|
|
b"daily_cache_source": b"api_v1_price_data",
|
|
b"daily_cache_interval": b"1d",
|
|
b"daily_cache_coverage_start": b"2026-01-20",
|
|
b"daily_cache_coverage_end": b"2026-04-20",
|
|
})
|
|
pq.write_table(pa.Table.from_pylist(rows, schema=schema), str(cache_path))
|
|
|
|
assert cache.get("BLD", "2026-01-20", "2026-04-20") is None
|
|
assert cache_path.exists() is False
|