"""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"