from __future__ import annotations from concurrent.futures import ThreadPoolExecutor import multiprocessing from pathlib import Path import threading import time import pyarrow as pa import pyarrow.parquet as pq from libs.intraday.cache import DailyBarCache, IntradayCache, LayeredDailyBarCache def _daily_cache_put_worker( cache_dir: str, start_date: str, end_date: str, bars: list[dict[str, float | str]], start_event, read_delay_s: float, ) -> None: cache = DailyBarCache(cache_dir) target_path = str(Path(cache_dir) / "AAPL.parquet") original_read_table = pq.read_table def delayed_read_table(path, *args, **kwargs): table = original_read_table(path, *args, **kwargs) if str(path) == target_path: time.sleep(read_delay_s) return table pq.read_table = delayed_read_table start_event.wait(timeout=5.0) cache.put("AAPL", start_date, end_date, bars) 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_treats_egregiously_partial_warmup_file_as_miss_without_deleting(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 True cached_rows, tail_start = cache.get_with_tail("BLD", "2026-01-20", "2026-04-20") assert cached_rows is None assert tail_start is None def test_daily_cache_put_tightens_sparse_fallback_coverage_to_actual_rows(tmp_path) -> None: cache = DailyBarCache(str(tmp_path)) rows = [ { "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, } for i in range(11) ] cache.put("BLD", "2026-01-20", "2026-04-20", rows) cache_path = tmp_path / "BLD.parquet" metadata = pq.read_metadata(str(cache_path)).schema.to_arrow_schema().metadata or {} assert metadata[b"daily_cache_coverage_start"] == b"2026-04-06" assert metadata[b"daily_cache_coverage_end"] == b"2026-04-16" cached_rows, tail_start = cache.get_with_tail("BLD", "2026-01-20", "2026-04-20") assert cached_rows is None assert tail_start is None cached_rows, tail_start = cache.get_with_tail("BLD", "2026-04-06", "2026-04-16") assert cached_rows == rows assert tail_start is None def test_daily_cache_accepts_late_start_when_history_is_sufficient(tmp_path) -> None: cache = DailyBarCache(str(tmp_path)) rows = [ { "date": f"2026-04-{1 + i:02d}", "open": 100.0 + i, "high": 101.0 + i, "low": 99.0 + i, "close": 100.5 + i, "volume": 1_000.0 + i, } for i in range(20) ] cache.put("IPO", "2026-01-20", "2026-04-20", rows) assert cache.get("IPO", "2026-01-20", "2026-04-20") == rows cached_rows, tail_start = cache.get_with_tail("IPO", "2026-01-20", "2026-04-20") assert cached_rows == rows assert tail_start is None def test_layered_daily_cache_reads_snapshot_and_writes_overlay(tmp_path) -> None: snapshot_cache = DailyBarCache(str(tmp_path / "snapshot")) overlay_cache = DailyBarCache(str(tmp_path / "overlay")) layered = LayeredDailyBarCache(snapshot_cache, overlay_cache) rows = [ { "date": "2026-01-02", "open": 100.0, "high": 101.0, "low": 99.0, "close": 100.5, "volume": 1000.0, } ] snapshot_cache.put("AAPL", "2026-01-01", "2026-01-10", rows) assert layered.get("AAPL", "2026-01-01", "2026-01-10") == rows extended_rows = rows + [ { "date": "2026-01-11", "open": 101.0, "high": 102.0, "low": 100.0, "close": 101.5, "volume": 1100.0, } ] layered.put("AAPL", "2026-01-01", "2026-01-11", extended_rows) assert snapshot_cache.get("AAPL", "2026-01-01", "2026-01-10") == rows assert snapshot_cache.get("AAPL", "2026-01-01", "2026-01-11") is None assert overlay_cache.get("AAPL", "2026-01-01", "2026-01-11") == extended_rows def test_daily_cache_merges_concurrent_puts_without_losing_coverage(tmp_path, monkeypatch) -> None: cache = DailyBarCache(str(tmp_path)) cache.put( "AAPL", "2026-01-01", "2026-01-05", [ { "date": "2026-01-02", "open": 100.0, "high": 101.0, "low": 99.0, "close": 100.5, "volume": 1000.0, } ], ) target_path = str((tmp_path / "AAPL.parquet")) original_read_table = pq.read_table read_count = 0 read_count_lock = threading.Lock() allow_reads_to_continue = threading.Event() def delayed_read_table(path, *args, **kwargs): nonlocal read_count table = original_read_table(path, *args, **kwargs) if str(path) == target_path: with read_count_lock: read_count += 1 if read_count >= 2: allow_reads_to_continue.set() allow_reads_to_continue.wait(timeout=0.2) return table monkeypatch.setattr(pq, "read_table", delayed_read_table) first_extension = [ { "date": "2026-01-06", "open": 101.0, "high": 102.0, "low": 100.0, "close": 101.5, "volume": 1100.0, } ] second_extension = [ { "date": "2026-01-07", "open": 102.0, "high": 103.0, "low": 101.0, "close": 102.5, "volume": 1200.0, } ] with ThreadPoolExecutor(max_workers=2) as pool: futures = [ pool.submit(cache.put, "AAPL", "2026-01-01", "2026-01-06", first_extension), pool.submit(cache.put, "AAPL", "2026-01-01", "2026-01-07", second_extension), ] for future in futures: future.result() assert cache.get("AAPL", "2026-01-01", "2026-01-07") == [ { "date": "2026-01-02", "open": 100.0, "high": 101.0, "low": 99.0, "close": 100.5, "volume": 1000.0, }, { "date": "2026-01-06", "open": 101.0, "high": 102.0, "low": 100.0, "close": 101.5, "volume": 1100.0, }, { "date": "2026-01-07", "open": 102.0, "high": 103.0, "low": 101.0, "close": 102.5, "volume": 1200.0, }, ] def test_daily_cache_merges_cross_process_puts_without_losing_coverage(tmp_path) -> None: cache = DailyBarCache(str(tmp_path)) cache.put( "AAPL", "2026-01-01", "2026-01-05", [ { "date": "2026-01-02", "open": 100.0, "high": 101.0, "low": 99.0, "close": 100.5, "volume": 1000.0, } ], ) first_extension = [ { "date": "2026-01-06", "open": 101.0, "high": 102.0, "low": 100.0, "close": 101.5, "volume": 1100.0, } ] second_extension = [ { "date": "2026-01-07", "open": 102.0, "high": 103.0, "low": 101.0, "close": 102.5, "volume": 1200.0, } ] ctx = multiprocessing.get_context("spawn") start_event = ctx.Event() processes = [ ctx.Process( target=_daily_cache_put_worker, args=( str(tmp_path), "2026-01-01", "2026-01-06", first_extension, start_event, 0.3, ), ), ctx.Process( target=_daily_cache_put_worker, args=( str(tmp_path), "2026-01-01", "2026-01-07", second_extension, start_event, 0.3, ), ), ] for process in processes: process.start() start_event.set() for process in processes: process.join(timeout=10.0) assert process.exitcode == 0 assert cache.get("AAPL", "2026-01-01", "2026-01-07") == [ { "date": "2026-01-02", "open": 100.0, "high": 101.0, "low": 99.0, "close": 100.5, "volume": 1000.0, }, { "date": "2026-01-06", "open": 101.0, "high": 102.0, "low": 100.0, "close": 101.5, "volume": 1100.0, }, { "date": "2026-01-07", "open": 102.0, "high": 103.0, "low": 101.0, "close": 102.5, "volume": 1200.0, }, ]