Add three-tier VIX/FRED fallback to paper trader (strict-conservative)
On 2026-05-08 19:45 UTC a transient Oracle FRED-proxy 5xx storm caused
paper_engine_vix_fred_unavailable to fire (3 sequential 500s; the 4th
attempt returned 200 OK with VIX=17.08). The pre-fix engine just left
VIXCLS missing from the macro dict, which the selector at
libs/backtest/selector.py:1171-1173 already treats strict-conservatively
(None → veto). So the 5/8 incident vetoed v7.356 PEAD candidates for
~1 minute with no money-loss exposure. But:
- Log severity was thin (info-level "unavailable", no escalation).
- No tolerance for short outages — every 500 cost the gate's signal.
- EventDetector PostgreSQL rows do not pre-populate macro_vix per
engine.py:2810-2812 comment, so live trading depends entirely on
the FRED fetch path.
Fix: in-memory session-scoped cache + 3-tier fallback in
PaperTradingEngine._fetch_macro:
Tier 1 fetch ok → cache (value, now_utc), log ..._ok (info)
Tier 2 fail, cache <24h → return cached value, log ..._stale_fallback
(warning) with staleness_sec
Tier 3 fail, cache stale → None, log ..._unavailable_blocking (error)
with reason={no_cache,cache_too_stale}
The None-veto path through the selector is preserved exactly, so no
silent-pass on unknown VIX. Empty/0 observations now treated as outage
to defend against an upstream regression flipping "missing→veto" into
"0→always-pass".
The thin libs/oracle_client/fred.py is intentionally untouched — fallback
policy belongs in the engine, not the generic client.
6 new tests in tests/unit/paper_trader/test_vix_fred_fallback.py
(success/cache-write, 500+stale-<24h, 500+stale->24h-blocks,
no-cache+500-blocks, empty-observations-blocks, recovery-refresh).
All 18 tests in -k "vix or fred" pass; full paper_trader suite 45/45.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
main
parent
261f67dfde
commit
cffb8d872d
@ -0,0 +1,188 @@
|
|||||||
|
"""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
|
||||||
Loading…
Reference in New Issue