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.

189 lines
6.3 KiB
Python

"""Tests: VIX/FRED fetch fallback for paper trading engine.
Covers PaperTradingEngine._fetch_fred_series_with_fallback() — the helper
introduced to bridge transient Oracle 5xx without silently letting the
macro_vix gate pass on unknown VIX.
Behavior contract:
1. Fresh fetch OK -> cache value, log paper_engine_vix_fred_ok, return value.
2. Fresh fetch fails AND cached <24h -> use cached, log
paper_engine_vix_fred_stale_fallback, return cached value.
3. Fresh fetch fails AND no cache OR cache >=24h -> log
paper_engine_vix_fred_unavailable_blocking, return None (selector vetoes).
The helper is engine-instance-scoped; we instantiate the engine via
``object.__new__`` to bypass the heavy ``__init__`` and only set the
fields the helper actually touches. This keeps the unit truly unit-scope
and avoids a SessionRow / Alpaca / SnapshotStore stack.
"""
from __future__ import annotations
import datetime as dt
from unittest.mock import AsyncMock, MagicMock
import pytest
from apps.paper_trader.engine import (
PaperTradingEngine,
_FRED_FALLBACK_MAX_AGE_SEC,
)
def _bare_engine() -> PaperTradingEngine:
"""Build a minimally initialized engine for helper-only tests.
The cache and method under test do not depend on any other instance
state; bypassing __init__ keeps these tests fast and decoupled from
config/snapshot/manifest plumbing.
"""
eng = object.__new__(PaperTradingEngine)
eng._macro_fred_cache = {}
return eng
def _fred_obs(value: float | None, date: str = "2026-05-08") -> MagicMock:
obs = MagicMock()
obs.value = value
obs.date = date
return obs
def _fred_resp(observations: list[MagicMock]) -> MagicMock:
resp = MagicMock()
resp.observations = observations
return resp
@pytest.mark.asyncio
async def test_vix_fetch_success_uses_value():
"""Tier 1: a healthy fetch returns the latest non-null value and caches it."""
eng = _bare_engine()
fred_svc = MagicMock()
fred_svc.get_observations = AsyncMock(
return_value=_fred_resp([_fred_obs(15.5), _fred_obs(17.08)]),
)
result = await eng._fetch_fred_series_with_fallback(
fred_svc, "VIXCLS", dt.date(2026, 5, 1), dt.date(2026, 5, 8),
)
assert result == 17.08
assert "VIXCLS" in eng._macro_fred_cache
cached_value, cached_at = eng._macro_fred_cache["VIXCLS"]
assert cached_value == 17.08
assert cached_at.tzinfo is not None # tz-aware UTC
@pytest.mark.asyncio
async def test_vix_fetch_500_uses_last_known_within_24h():
"""Tier 2: with a recent cached value, a 5xx falls back to it (warning)."""
eng = _bare_engine()
# Pre-seed cache with a 6h-old value
six_hours_ago = dt.datetime.now(dt.timezone.utc) - dt.timedelta(hours=6)
eng._macro_fred_cache["VIXCLS"] = (16.42, six_hours_ago)
fred_svc = MagicMock()
fred_svc.get_observations = AsyncMock(
side_effect=Exception("Server error 500: /api/v1/fred/proxy/series/observations"),
)
result = await eng._fetch_fred_series_with_fallback(
fred_svc, "VIXCLS", dt.date(2026, 5, 1), dt.date(2026, 5, 8),
)
# Returns the cached value rather than None — strategy keeps trading.
assert result == 16.42
# Cache itself is not refreshed by a fallback (only success refreshes).
cached_value, cached_at = eng._macro_fred_cache["VIXCLS"]
assert cached_value == 16.42
assert cached_at == six_hours_ago
@pytest.mark.asyncio
async def test_vix_fetch_stale_more_than_24h_blocks_trades():
"""Tier 3a: a cached value older than 24h is treated as no cache -> None."""
eng = _bare_engine()
# Cache age > _FRED_FALLBACK_MAX_AGE_SEC
too_old = dt.datetime.now(dt.timezone.utc) - dt.timedelta(
seconds=_FRED_FALLBACK_MAX_AGE_SEC + 60,
)
eng._macro_fred_cache["VIXCLS"] = (12.0, too_old)
fred_svc = MagicMock()
fred_svc.get_observations = AsyncMock(
side_effect=Exception("Server error 500"),
)
result = await eng._fetch_fred_series_with_fallback(
fred_svc, "VIXCLS", dt.date(2026, 5, 1), dt.date(2026, 5, 8),
)
# None -> selector treats macro_vix as missing -> any engine with
# macro_vix_max set vetoes the candidate. STRICT-CONSERVATIVE.
assert result is None
@pytest.mark.asyncio
async def test_vix_fetch_no_cache_and_failure_blocks_trades():
"""Tier 3b: failure with empty cache returns None — never silently allows."""
eng = _bare_engine()
assert eng._macro_fred_cache == {}
fred_svc = MagicMock()
fred_svc.get_observations = AsyncMock(
side_effect=Exception("Server error 500"),
)
result = await eng._fetch_fred_series_with_fallback(
fred_svc, "VIXCLS", dt.date(2026, 5, 1), dt.date(2026, 5, 8),
)
assert result is None
# Failed fetch must NOT poison the cache with bogus values.
assert "VIXCLS" not in eng._macro_fred_cache
@pytest.mark.asyncio
async def test_vix_fetch_returns_empty_observations_does_not_silently_allow_trades():
"""200 OK with no usable observations is treated as outage, not as VIX=0.
Without this, an upstream regression returning an empty list could
flip the gate from "missing -> veto" to "0.0 -> always pass".
"""
eng = _bare_engine()
fred_svc = MagicMock()
# Observations exist but all values are None ('.' in raw FRED == missing).
fred_svc.get_observations = AsyncMock(
return_value=_fred_resp([_fred_obs(None), _fred_obs(None)]),
)
result = await eng._fetch_fred_series_with_fallback(
fred_svc, "VIXCLS", dt.date(2026, 5, 1), dt.date(2026, 5, 8),
)
assert result is None
assert "VIXCLS" not in eng._macro_fred_cache
@pytest.mark.asyncio
async def test_vix_fetch_success_after_failure_refreshes_cache():
"""A healthy fetch after a fallback overwrites the stale cache entry."""
eng = _bare_engine()
older = dt.datetime.now(dt.timezone.utc) - dt.timedelta(hours=12)
eng._macro_fred_cache["VIXCLS"] = (16.42, older)
fred_svc = MagicMock()
fred_svc.get_observations = AsyncMock(
return_value=_fred_resp([_fred_obs(17.08)]),
)
result = await eng._fetch_fred_series_with_fallback(
fred_svc, "VIXCLS", dt.date(2026, 5, 1), dt.date(2026, 5, 8),
)
assert result == 17.08
cached_value, cached_at = eng._macro_fred_cache["VIXCLS"]
assert cached_value == 17.08
# Cache timestamp moved forward.
assert cached_at > older