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