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.

156 lines
5.6 KiB
Python

"""Unit tests for libs/backtest/snapshot_store.py."""
from __future__ import annotations
import datetime as dt
import json
from pathlib import Path
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_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_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
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"},
]
result = SnapshotStore._compute_date_range(rows)
assert result == (dt.date(2026, 1, 5), 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