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.
138 lines
5.7 KiB
Python
138 lines
5.7 KiB
Python
"""Tests for TGTC V2 1-minute bar cache (libs/tgtc/cache_1m.py)."""
|
|
import datetime as dt
|
|
import tempfile
|
|
from pathlib import Path
|
|
from unittest.mock import patch, MagicMock
|
|
|
|
import pytest
|
|
|
|
from libs.intraday.cache import IntradayCache
|
|
|
|
|
|
# ── IntradayCache interval_minutes parameter ──────────────────────────────────
|
|
|
|
def test_intraday_cache_default_interval():
|
|
"""Default IntradayCache still writes/reads 5-min metadata."""
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
cache = IntradayCache(cache_dir=tmp, interval_minutes=5)
|
|
meta = cache._cache_metadata
|
|
assert meta[b"intraday_cache_interval"] == b"5min"
|
|
|
|
|
|
def test_intraday_cache_1min_interval():
|
|
"""IntradayCache with interval_minutes=1 writes 1min metadata."""
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
cache = IntradayCache(cache_dir=tmp, interval_minutes=1)
|
|
meta = cache._cache_metadata
|
|
assert meta[b"intraday_cache_interval"] == b"1min"
|
|
|
|
|
|
def test_intraday_cache_1min_round_trip():
|
|
"""Writing and reading 1-min bars through IntradayCache round-trips correctly."""
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
cache = IntradayCache(cache_dir=tmp, interval_minutes=1)
|
|
bars = [
|
|
{"timestamp": f"2025-09-12T{9+h:02d}:{m:02d}:00+00:00",
|
|
"open": 100.0, "high": 101.0, "low": 99.5, "close": 100.5,
|
|
"volume": 1000.0, "vwap": 100.2}
|
|
for h in range(0, 7) for m in range(0, 60, 1)
|
|
][:60] # 60 bars = 1 hour of 1-min bars
|
|
cache.put("TEST", "2025-09-12", bars)
|
|
|
|
readback = cache.get("TEST", "2025-09-12")
|
|
assert readback is not None
|
|
assert len(readback) == 60
|
|
assert readback[0]["timestamp"] == bars[0]["timestamp"]
|
|
|
|
|
|
def test_intraday_1min_cache_does_not_validate_5min_files():
|
|
"""A 5-min cache file should NOT be valid in a 1-min cache (metadata mismatch)."""
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
# Write a file via 5-min cache
|
|
cache_5m = IntradayCache(cache_dir=tmp, interval_minutes=5)
|
|
bars = [
|
|
{"timestamp": f"2025-09-12T{9+h:02d}:{m:02d}:00+00:00",
|
|
"open": 100.0, "high": 101.0, "low": 99.5, "close": 100.5,
|
|
"volume": 1000.0, "vwap": 100.2}
|
|
for h in range(0, 2) for m in range(0, 60, 5)
|
|
] # 24 bars
|
|
cache_5m.put("TEST", "2025-09-12", bars)
|
|
|
|
# 1-min cache should NOT see it as valid (interval mismatch)
|
|
cache_1m = IntradayCache(cache_dir=tmp, interval_minutes=1)
|
|
result = cache_1m.get("TEST", "2025-09-12")
|
|
assert result is None # invalid metadata → miss
|
|
|
|
|
|
# ── ensure_1m_bars (mocked Oracle) ────────────────────────────────────────────
|
|
|
|
def _make_bars(n: int) -> list[dict]:
|
|
return [
|
|
{"timestamp": f"2025-09-12T14:{m:02d}:00+00:00",
|
|
"open": 100.0, "high": 101.0, "low": 99.5, "close": 100.5,
|
|
"volume": 500.0, "vwap": 100.2}
|
|
for m in range(n)
|
|
]
|
|
|
|
|
|
def test_ensure_1m_bars_cache_miss_fetches_from_api():
|
|
"""On cache miss, ensure_1m_bars fetches from Oracle and caches the result."""
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
with patch("libs.tgtc.cache_1m._CACHE_DIR", tmp), \
|
|
patch("libs.tgtc.cache_1m._cache", None), \
|
|
patch("libs.oracle_client.alpaca.get_multi_intraday_bars",
|
|
return_value={"AAPL": _make_bars(390)}) as mock_fetch:
|
|
from libs.tgtc import cache_1m
|
|
cache_1m._cache = None # reset singleton
|
|
|
|
result = cache_1m.ensure_1m_bars(["AAPL"], dt.date(2025, 9, 12))
|
|
assert "AAPL" in result
|
|
assert len(result["AAPL"]) == 390
|
|
mock_fetch.assert_called_once()
|
|
|
|
# Second call should be served from cache (mock not called again)
|
|
result2 = cache_1m.ensure_1m_bars(["AAPL"], dt.date(2025, 9, 12))
|
|
assert "AAPL" in result2
|
|
mock_fetch.assert_called_once() # still only once
|
|
|
|
|
|
def test_ensure_1m_bars_negative_cache_on_empty():
|
|
"""Symbols with no API bars get a negative cache entry."""
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
with patch("libs.tgtc.cache_1m._CACHE_DIR", tmp), \
|
|
patch("libs.tgtc.cache_1m._cache", None), \
|
|
patch("libs.oracle_client.alpaca.get_multi_intraday_bars",
|
|
return_value={}):
|
|
from libs.tgtc import cache_1m
|
|
cache_1m._cache = None
|
|
|
|
result = cache_1m.ensure_1m_bars(["EMPTY"], dt.date(2025, 9, 12))
|
|
assert "EMPTY" not in result
|
|
|
|
# Second call should not fetch from API again
|
|
with patch("libs.oracle_client.alpaca.get_multi_intraday_bars") as mock2:
|
|
cache_1m.ensure_1m_bars(["EMPTY"], dt.date(2025, 9, 12))
|
|
mock2.assert_not_called()
|
|
|
|
|
|
# ── DST-aware ET→UTC conversion ────────────────────────────────────────────────
|
|
|
|
def test_et_to_utc_naive_edt():
|
|
"""In EDT (summer), 10:00 ET = 14:00 UTC."""
|
|
from libs.tgtc.gainers_reconstruct import _et_to_utc_naive
|
|
import datetime as dt
|
|
date = dt.date(2025, 9, 12) # September = EDT
|
|
utc = _et_to_utc_naive(date, 10, 0)
|
|
assert utc.hour == 14
|
|
assert utc.minute == 0
|
|
|
|
|
|
def test_et_to_utc_naive_est():
|
|
"""In EST (winter), 10:00 ET = 15:00 UTC."""
|
|
from libs.tgtc.gainers_reconstruct import _et_to_utc_naive
|
|
import datetime as dt
|
|
date = dt.date(2025, 1, 15) # January = EST
|
|
utc = _et_to_utc_naive(date, 10, 0)
|
|
assert utc.hour == 15
|
|
assert utc.minute == 0
|