Files
nexus-quant-terminal/backend/tests/test_vol_analytics.py
T
Claude 88e4d48000 feat: BTC/ETH/XAG support and Vol Lab (live Deribit SVI surface, vol cone, VRP)
Volatile instruments
- Add XAGUSD (SI=F, silver lease rate as r_f), BTCUSD and ETHUSD end to end:
  pair metadata, live rates/OHLC/signals, CFTC positioning mappings
- Asset-class calendars: crypto annualised on 365 days, FX/metals on 252,
  threaded through the signal engine
- Pair switch loads a typical ATM vol, rescales legs by moneyness, and
  strategy templates use a ~1%-of-spot strike step

Vol Lab tab
- /api/vol/crypto/{BTC,ETH}: Deribit book summaries -> raw SVI fit per
  expiry with Durrleman butterfly-arbitrage check, ATM / 25d RR / 25d BF
  (Black-76 forward delta), DVOL, 30d constant-maturity IV, realised vol
  and vol risk premium; cached 60s, 503 when Deribit is unreachable
- /api/vol/cone/{pair}: Burghardt-Lane realised-vol cone on the asset's
  calendar
- UI: KPIs, cone with implied term-structure overlay, per-expiry smile
  (market vs SVI), term-structure table with 'Use' to load T/sigma/RR/BF
  into pricing and the Vol Smile tab

Fixes in the signal engine
- OU half-life divided by 252 twice; stationary sd used sqrt(252k) not sqrt(2k)
- Fallback realised vol returned decimals under *_pct keys

Tests: backend/tests (pytest) validate SVI recovery, exact 25d strikes,
cone calibration on both calendars and the endpoints with mocked feeds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FpiqUMAyo4RL5DdQHMWbwE
2026-09-29 05:17:14 +00:00

114 lines
4.9 KiB
Python

"""Validation of the Vol Lab analytics against a known synthetic SVI surface.
Run from backend/: python -m pytest tests -q
"""
import numpy as np
from fastapi.testclient import TestClient
from scipy.stats import norm
from app.core.quant_analysis import compute_signals
from app.core.vol_analytics import (build_crypto_surface, constant_maturity_atm,
parse_deribit_instrument, svi_total_variance, vol_cone)
from app.main import app
from app.routers import vol as vol_router
from tests.fixtures import NOW_MS, deribit_book
def _gbm(vol, n, ppy, seed=0, s0=100.0):
rng = np.random.default_rng(seed)
return s0 * np.exp(np.cumsum(rng.standard_normal(n) * vol / np.sqrt(ppy)))
def test_svi_fit_recovers_known_surface_and_25_delta_strikes():
rows, truth = deribit_book()
expiries = build_crypto_surface(rows, NOW_MS)
assert len(expiries) == len(truth)
for e in expiries:
p, T, F = truth[e["expiry"]]
assert abs(e["atm_vol"] - np.sqrt(svi_total_variance(0, *p) / T)) < 1e-4
assert e["fit_rmse"] < 1e-4 and e["butterfly_arb_free"]
d1 = lambda K, s: (np.log(F / K) + 0.5 * s * s * T) / (s * np.sqrt(T))
assert abs(norm.cdf(d1(e["K_25c"], e["vol_25c"])) - 0.25) < 1e-6
assert abs(norm.cdf(d1(e["K_25p"], e["vol_25p"])) - 0.75) < 1e-6
assert e["rr25"] < 0 # BTC-style put skew in the fixture
def test_noisy_quotes_fit_within_noise():
rows, _ = deribit_book(noise=0.02, rng=np.random.default_rng(7))
for e in build_crypto_surface(rows, NOW_MS):
assert e["fit_rmse"] < 0.015
def test_constant_maturity_interpolates_total_variance():
expiries = build_crypto_surface(deribit_book()[0], NOW_MS)
iv30 = constant_maturity_atm(expiries, 30)
near = min(expiries, key=lambda e: abs(e["days"] - 30))
assert abs(iv30 - near["atm_vol"]) < 2e-3
def test_parse_instrument():
exp, K, cp = parse_deribit_instrument("ETH-5OCT26-2650-P")
assert (exp.year, exp.month, exp.day, exp.hour, K, cp) == (2026, 10, 5, 8, 2650.0, "put")
assert parse_deribit_instrument("BTC-PERPETUAL") is None
def test_vol_cone_matches_true_vol_on_both_calendars():
btc = vol_cone(_gbm(0.50, 730, 365), 365)
fx = vol_cone(_gbm(0.07, 520, 252), 252)
assert abs(next(t for t in btc["tenors"] if t["tenor"] == "3M")["median"] - 0.50) < 0.03
assert abs(next(t for t in fx["tenors"] if t["tenor"] == "3M")["median"] - 0.07) < 0.005
assert next(t for t in fx["tenors"] if t["tenor"] == "1M")["window"] == 21
def test_signals_use_asset_calendar():
px = _gbm(0.50, 90, 365, s0=83000)
crypto = compute_signals(list(px), 0.039, 0.0, periods_per_year=365)
as_fx = compute_signals(list(px), 0.039, 0.0, periods_per_year=252)
ratio = crypto["volatility"]["ewma_forecast_pct"] / as_fx["volatility"]["ewma_forecast_pct"]
assert abs(ratio - np.sqrt(365 / 252)) < 1e-3 # outputs are rounded to 2dp
assert 20 < crypto["volatility"]["hv_20_pct"] < 90 # percent, not decimal
def test_endpoints_with_mocked_sources(monkeypatch):
rows, _ = deribit_book()
closes = list(_gbm(0.45, 730, 365, s0=83000))
def fake_deribit(method, **params):
return {
"get_book_summary_by_currency": rows,
"get_index_price": {"index_price": 83000.0},
"get_volatility_index_data": {"data": [[NOW_MS - i * 864e5, 40, 41, 37, 38.1]
for i in range(5, 0, -1)]},
"get_tradingview_chart_data": {"close": closes},
}[method]
monkeypatch.setattr(vol_router, "_deribit", fake_deribit)
monkeypatch.setattr(vol_router, "_yf_closes", lambda sym, period="2y": closes)
vol_router._cache.clear()
c = TestClient(app)
r = c.get("/api/vol/crypto/btc")
assert r.status_code == 200
d = r.json()
assert d["dvol"]["last"] == 0.381 and d["index_price"] == 83000.0
assert d["expiries"] and d["iv_30d"] and d["rv_30d"] and d["vrp_30d"] is not None
assert abs(d["vrp_30d"] - (d["iv_30d"] - d["rv_30d"])) < 1e-5
cone = c.get("/api/vol/cone/XAGUSD").json()
assert cone["asset_class"] == "metal" and cone["periods_per_year"] == 252
assert c.get("/api/vol/cone/BTCUSD").json()["periods_per_year"] == 365
assert c.get("/api/vol/crypto/DOGE").status_code == 404
assert c.get("/api/vol/cone/FOO").status_code == 404
pairs = {p["pair"]: p for p in c.get("/api/forex/pairs").json()}
assert {"XAGUSD", "BTCUSD", "ETHUSD"} <= set(pairs)
assert pairs["ETHUSD"]["asset_class"] == "crypto" and "sym" not in pairs["ETHUSD"]
def test_crypto_endpoint_fails_gracefully(monkeypatch):
def down(method, **params):
raise ConnectionError("deribit down")
monkeypatch.setattr(vol_router, "_deribit", down)
vol_router._cache.clear()
r = TestClient(app).get("/api/vol/crypto/ETH")
assert r.status_code == 503 and "Deribit data unavailable" in r.json()["detail"]