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.
330 lines
12 KiB
Python
330 lines
12 KiB
Python
"""Unit tests for libs/backtest/snapshot_store.py."""
|
|
from __future__ import annotations
|
|
|
|
import datetime as dt
|
|
import asyncio
|
|
import json
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
|
|
import pyarrow as pa
|
|
import pyarrow.parquet as pq
|
|
import pytest
|
|
|
|
|
|
def _build_store_from_fixture(tmp_path: Path) -> object:
|
|
"""Build a SnapshotStore directly from test data (no DB/HTTP)."""
|
|
from libs.backtest.snapshot_store import SnapshotStore
|
|
|
|
candidates = {
|
|
dt.date(2026, 1, 6): [
|
|
{
|
|
"event_id": "EVT::TEST::001",
|
|
"symbol": "AAPL",
|
|
"execution_date": dt.date(2026, 1, 6),
|
|
"entry_date": "2026-01-06",
|
|
"entry_price": 150.0,
|
|
"score": 0.8,
|
|
"sector": "Technology",
|
|
"event_type": "earnings",
|
|
"event_timestamp": "2026-01-05T21:00:00+00:00",
|
|
"filing_time_bucket": "post_market",
|
|
"reaction_date": "2026-01-05",
|
|
"avg_dollar_volume": 5_000_000.0,
|
|
"atr_14": 3.0,
|
|
}
|
|
],
|
|
dt.date(2026, 1, 7): [
|
|
{
|
|
"event_id": "EVT::TEST::002",
|
|
"symbol": "MSFT",
|
|
"execution_date": dt.date(2026, 1, 7),
|
|
"entry_date": "2026-01-07",
|
|
"entry_price": 300.0,
|
|
"score": 0.6,
|
|
"sector": "Technology",
|
|
"event_type": "guidance",
|
|
"event_timestamp": "2026-01-06T20:00:00+00:00",
|
|
"filing_time_bucket": "post_market",
|
|
"reaction_date": "2026-01-06",
|
|
"avg_dollar_volume": 10_000_000.0,
|
|
"atr_14": 5.0,
|
|
}
|
|
],
|
|
}
|
|
|
|
bars = {
|
|
"AAPL": {
|
|
dt.date(2026, 1, 6): {"date": dt.date(2026, 1, 6), "open": 150.0, "high": 155.0, "low": 148.0, "close": 152.0, "volume": 1_000_000},
|
|
dt.date(2026, 1, 7): {"date": dt.date(2026, 1, 7), "open": 152.0, "high": 162.0, "low": 150.0, "close": 159.0, "volume": 900_000},
|
|
},
|
|
"MSFT": {
|
|
dt.date(2026, 1, 7): {"date": dt.date(2026, 1, 7), "open": 300.0, "high": 310.0, "low": 295.0, "close": 305.0, "volume": 500_000},
|
|
},
|
|
}
|
|
|
|
return SnapshotStore(
|
|
candidates_by_exec_date=candidates,
|
|
bars_by_symbol_date=bars,
|
|
)
|
|
|
|
|
|
class TestSnapshotStoreQuery:
|
|
def test_get_candidates_for_date(self, tmp_path):
|
|
store = _build_store_from_fixture(tmp_path)
|
|
rows = store.get_candidates_for_date(dt.date(2026, 1, 6))
|
|
assert len(rows) == 1
|
|
assert rows[0]["symbol"] == "AAPL"
|
|
|
|
def test_get_candidates_empty_date(self, tmp_path):
|
|
store = _build_store_from_fixture(tmp_path)
|
|
rows = store.get_candidates_for_date(dt.date(2026, 1, 1))
|
|
assert rows == []
|
|
|
|
def test_get_candidates_for_reaction_date(self, tmp_path):
|
|
store = _build_store_from_fixture(tmp_path)
|
|
rows = store.get_candidates_for_reaction_date(dt.date(2026, 1, 5))
|
|
assert len(rows) == 1
|
|
assert rows[0]["symbol"] == "AAPL"
|
|
|
|
def test_no_lookahead(self, tmp_path):
|
|
"""Candidates for Jan 7 should NOT appear when querying Jan 6."""
|
|
store = _build_store_from_fixture(tmp_path)
|
|
rows = store.get_candidates_for_date(dt.date(2026, 1, 6))
|
|
symbols = [r["symbol"] for r in rows]
|
|
assert "MSFT" not in symbols
|
|
|
|
def test_get_bar_exists(self, tmp_path):
|
|
store = _build_store_from_fixture(tmp_path)
|
|
bar = store.get_bar("AAPL", dt.date(2026, 1, 6))
|
|
assert bar is not None
|
|
assert bar["open"] == 150.0
|
|
assert bar["close"] == 152.0
|
|
|
|
def test_get_bar_missing_returns_none(self, tmp_path):
|
|
store = _build_store_from_fixture(tmp_path)
|
|
bar = store.get_bar("AAPL", dt.date(2025, 12, 31))
|
|
assert bar is None
|
|
|
|
def test_get_bar_unknown_symbol_returns_none(self, tmp_path):
|
|
store = _build_store_from_fixture(tmp_path)
|
|
assert store.get_bar("UNKNOWN", dt.date(2026, 1, 6)) is None
|
|
|
|
def test_all_execution_dates_sorted(self, tmp_path):
|
|
store = _build_store_from_fixture(tmp_path)
|
|
dates = store.all_execution_dates()
|
|
assert dates == sorted(dates)
|
|
assert dt.date(2026, 1, 6) in dates
|
|
assert dt.date(2026, 1, 7) in dates
|
|
|
|
def test_all_reaction_dates_sorted(self, tmp_path):
|
|
store = _build_store_from_fixture(tmp_path)
|
|
dates = store.all_reaction_dates()
|
|
assert dates == [dt.date(2026, 1, 5), dt.date(2026, 1, 6)]
|
|
|
|
def test_macro_default_empty(self, tmp_path):
|
|
store = _build_store_from_fixture(tmp_path)
|
|
macro = store.get_macro_for_date(dt.date(2026, 1, 6))
|
|
assert macro == {}
|
|
|
|
def test_candidates_copy_returned(self, tmp_path):
|
|
"""Modifying returned list should not affect internal state."""
|
|
store = _build_store_from_fixture(tmp_path)
|
|
rows1 = store.get_candidates_for_date(dt.date(2026, 1, 6))
|
|
rows1.append({"extra": "data"})
|
|
rows2 = store.get_candidates_for_date(dt.date(2026, 1, 6))
|
|
assert len(rows2) == 1 # unchanged
|
|
|
|
def test_slice_by_date_range_filters_execution_and_reaction_dates(self, tmp_path):
|
|
store = _build_store_from_fixture(tmp_path)
|
|
sliced = store.slice_by_date_range(dt.date(2026, 1, 7), dt.date(2026, 1, 7))
|
|
assert sliced.all_execution_dates() == [dt.date(2026, 1, 7)]
|
|
assert sliced.get_candidates_for_date(dt.date(2026, 1, 7))[0]["symbol"] == "MSFT"
|
|
assert sliced.get_candidates_for_reaction_date(dt.date(2026, 1, 5)) == []
|
|
assert sliced.get_candidates_for_reaction_date(dt.date(2026, 1, 6)) == []
|
|
|
|
def test_slice_by_date_range_trims_macro(self, tmp_path):
|
|
from libs.backtest.snapshot_store import SnapshotStore
|
|
|
|
store = SnapshotStore(
|
|
candidates_by_exec_date={
|
|
dt.date(2026, 1, 6): [{"event_id": "E1", "symbol": "AAPL", "reaction_date": "2026-01-05"}],
|
|
dt.date(2026, 1, 7): [{"event_id": "E2", "symbol": "MSFT", "reaction_date": "2026-01-06"}],
|
|
},
|
|
bars_by_symbol_date={},
|
|
macro_by_date={
|
|
dt.date(2026, 1, 6): {"spy_close": 100.0},
|
|
dt.date(2026, 1, 7): {"spy_close": 101.0},
|
|
},
|
|
)
|
|
|
|
sliced = store.slice_by_date_range(dt.date(2026, 1, 7), dt.date(2026, 1, 7))
|
|
assert sliced.get_macro_for_date(dt.date(2026, 1, 6)) == {}
|
|
assert sliced.get_macro_for_date(dt.date(2026, 1, 7)) == {"spy_close": 101.0}
|
|
|
|
|
|
class TestSnapshotStoreLoadGuard:
|
|
def test_raises_in_running_event_loop(self, tmp_path):
|
|
"""load() should raise RuntimeError if called from a running event loop."""
|
|
import asyncio
|
|
|
|
from libs.backtest.snapshot_store import SnapshotStore
|
|
|
|
async def _test():
|
|
with pytest.raises(RuntimeError, match="running event loop"):
|
|
SnapshotStore.load(tmp_path, "train", "http://localhost", "postgres://")
|
|
|
|
asyncio.run(_test())
|
|
|
|
|
|
class TestSnapshotStoreFromParquet:
|
|
def test_compute_date_range(self, tmp_path):
|
|
from libs.backtest.snapshot_store import SnapshotStore
|
|
|
|
rows = [
|
|
{"entry_date": "2026-01-05"},
|
|
{"entry_date": "2026-01-10"},
|
|
{"entry_date": "2026-01-07"},
|
|
{"reaction_date": "2026-01-04"},
|
|
]
|
|
result = SnapshotStore._compute_date_range(rows)
|
|
assert result == (dt.date(2026, 1, 4), dt.date(2026, 1, 10))
|
|
|
|
def test_compute_date_range_empty(self, tmp_path):
|
|
from libs.backtest.snapshot_store import SnapshotStore
|
|
|
|
assert SnapshotStore._compute_date_range([]) is None
|
|
|
|
def test_async_load_falls_back_to_parquet_metadata_when_db_unavailable(self, tmp_path, monkeypatch):
|
|
from libs.backtest.snapshot_store import SnapshotStore
|
|
|
|
parquet_path = tmp_path / "train.parquet"
|
|
table = pa.table({
|
|
"event_id": ["EVT::ROW::001"],
|
|
"ticker": ["AAPL"],
|
|
"event_date": ["2026-01-05"],
|
|
"event_type": ["earnings_release"],
|
|
"reaction_date": ["2026-01-05"],
|
|
"entry_date": ["2026-01-06"],
|
|
"event_close": [150.0],
|
|
"entry_price": [151.0],
|
|
"atr_14": [3.0],
|
|
})
|
|
pq.write_table(table, parquet_path)
|
|
|
|
async def _fake_event_meta(*args, **kwargs):
|
|
return {}
|
|
|
|
async def _fake_price_data(*args, **kwargs):
|
|
return (
|
|
{
|
|
"AAPL": {
|
|
dt.date(2026, 1, 5): {
|
|
"date": dt.date(2026, 1, 5),
|
|
"open": 149.0,
|
|
"high": 153.0,
|
|
"low": 148.0,
|
|
"close": 150.0,
|
|
"volume": 1_000_000,
|
|
},
|
|
dt.date(2026, 1, 6): {
|
|
"date": dt.date(2026, 1, 6),
|
|
"open": 151.0,
|
|
"high": 156.0,
|
|
"low": 150.0,
|
|
"close": 155.0,
|
|
"volume": 900_000,
|
|
},
|
|
}
|
|
},
|
|
{"AAPL": 125_000_000.0},
|
|
)
|
|
|
|
async def _fake_sectors(*args, **kwargs):
|
|
return {"AAPL": "Technology"}
|
|
|
|
async def _fake_macro(*args, **kwargs):
|
|
return {}
|
|
|
|
monkeypatch.setattr(SnapshotStore, "_fetch_event_metadata", staticmethod(_fake_event_meta))
|
|
monkeypatch.setattr(SnapshotStore, "_fetch_price_data", staticmethod(_fake_price_data))
|
|
monkeypatch.setattr(SnapshotStore, "_fetch_sectors", staticmethod(_fake_sectors))
|
|
monkeypatch.setattr(SnapshotStore, "_fetch_macro", staticmethod(_fake_macro))
|
|
monkeypatch.setattr(SnapshotStore, "_fetch_spy_macro", staticmethod(_fake_macro))
|
|
|
|
data = asyncio.run(
|
|
SnapshotStore._async_load(
|
|
tmp_path,
|
|
"train",
|
|
oracle_url="http://localhost:18001",
|
|
db_dsn="postgresql+asyncpg://unused",
|
|
scoring_fn=lambda row: 0.77,
|
|
)
|
|
)
|
|
store = SnapshotStore(**data)
|
|
rows = store.get_candidates_for_date(dt.date(2026, 1, 6))
|
|
|
|
assert len(rows) == 1
|
|
assert rows[0]["symbol"] == "AAPL"
|
|
assert rows[0]["event_type"] == "earnings_release"
|
|
assert rows[0]["event_date"] == dt.date(2026, 1, 5)
|
|
assert rows[0]["score"] == pytest.approx(0.77)
|
|
assert rows[0]["event_timestamp"] is not None
|
|
|
|
|
|
class TestSnapshotStoreSectorFetch:
|
|
def test_fetch_sectors_falls_back_from_placeholder_oracle(self, tmp_path, monkeypatch):
|
|
import libs.oracle_client as oracle_mod
|
|
from libs.backtest.snapshot_store import SnapshotStore
|
|
|
|
class FakeOracleClient:
|
|
def __init__(self, base_url: str) -> None:
|
|
self.base_url = base_url
|
|
|
|
async def __aenter__(self):
|
|
return self
|
|
|
|
async def __aexit__(self, *args):
|
|
return None
|
|
|
|
class FakeCompanyService:
|
|
def __init__(self, client) -> None:
|
|
self.client = client
|
|
|
|
async def get_company(self, symbol: str):
|
|
if symbol == "BAX":
|
|
return SimpleNamespace(
|
|
sector="Technology",
|
|
industry="Software",
|
|
exchange=None,
|
|
market_cap=None,
|
|
)
|
|
return SimpleNamespace(
|
|
sector="Utilities",
|
|
industry="Utilities - Regulated Water",
|
|
exchange="NYSE",
|
|
market_cap=123.0,
|
|
)
|
|
|
|
monkeypatch.setattr(oracle_mod, "OracleClient", FakeOracleClient)
|
|
monkeypatch.setattr(oracle_mod, "CompanyService", FakeCompanyService)
|
|
monkeypatch.setattr(
|
|
SnapshotStore,
|
|
"_sector_cache_path",
|
|
staticmethod(lambda: tmp_path / "sector_cache.json"),
|
|
)
|
|
monkeypatch.setattr(
|
|
SnapshotStore,
|
|
"_fetch_sector_from_yfinance",
|
|
staticmethod(lambda symbol: "Healthcare" if symbol == "BAX" else "UNKNOWN"),
|
|
)
|
|
|
|
result = asyncio.run(
|
|
SnapshotStore._fetch_sectors(["BAX", "AWK"], oracle_url="http://unused")
|
|
)
|
|
|
|
assert result == {"BAX": "Healthcare", "AWK": "Utilities"}
|
|
cache = json.loads((tmp_path / "sector_cache.json").read_text())
|
|
assert cache["BAX"] == "Healthcare"
|
|
assert cache["AWK"] == "Utilities"
|