mirror of
https://github.com/Mihirkansara/nexus-quant-terminal.git
synced 2026-11-02 09:36:11 +00:00
- core/jump_models.py: Black-76 helpers and implied vol; Merton series in
forward form (Poisson(lambda T) weights, F_n = F e^{-lambda k T}(1+k)^n);
Bates characteristic function in the 'little Heston trap' form with COS
pricing (puts + parity) and risk-neutral density; vega-weighted bounded
least-squares calibration across expiries; jump statistics (crash-jump
odds, jump share of variance) and density vs lognormal.
- GET /api/jumps/calibrate/{BTC,ETH}: fits both models to every Deribit
expiry from the Vol Lab feed (cached 30 min, 503 when Deribit is down).
- POST /api/jumps/merton: manual Merton smile/density for metals and FX
event and gap risk.
- Jump Lab tab: fit KPIs, market vs Merton vs Bates smiles per expiry,
30-day density, parameter table, and a hand-off of the calibrated jumps
to Hedge Lab (store.hedgeJump -> Hedge Lab defaults to the jump model).
- 5 tests: Black/Merton/Bates limits, parity, density mass and martingale,
exact-MC Merton and Euler-MC Bates checks, parameter recovery (clean and
noisy), Merton-vs-Bates misspecification, endpoints.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FpiqUMAyo4RL5DdQHMWbwE
122 lines
5.5 KiB
Python
122 lines
5.5 KiB
Python
"""Validation of Merton / Bates pricing and calibration.
|
|
|
|
Run from backend/: python -m pytest tests -q
|
|
"""
|
|
import numpy as np
|
|
from fastapi.testclient import TestClient
|
|
|
|
from app.core.jump_models import (BATES_NAMES, MERTON_NAMES, bates_density, bates_price, black,
|
|
calibrate, implied_vol, jump_stats, merton_as_bates,
|
|
merton_price, model_smile)
|
|
from app.core.vol_analytics import build_crypto_surface
|
|
from app.main import app
|
|
from app.routers import jumps as jumps_router
|
|
from app.routers import vol as vol_router
|
|
from tests.fixtures import NOW_MS, deribit_book
|
|
|
|
F, T = 83000.0, 30 / 365
|
|
K = np.linspace(60000, 110000, 11)
|
|
BATES = (0.20, 2.0, 0.25, 1.2, -0.4, 4.0, -0.06, 0.10)
|
|
|
|
|
|
def test_limits_parity_and_density():
|
|
assert np.allclose(merton_price(F, K, T, 0.45, 0, 0, 0.1), black(F, K, T, 0.45))
|
|
m = merton_price(F, K, T, 0.40, 6, -0.03, 0.08)
|
|
assert np.allclose(bates_price(F, K, T, merton_as_bates(0.40, 6, -0.03, 0.08)), m, rtol=1e-5)
|
|
assert np.allclose(bates_price(F, K, T, (0.16, 5, 0.16, 1e-4, 0, 0, 0, 0.1)), black(F, K, T, 0.4), rtol=1e-4)
|
|
c, p = bates_price(F, K, T, BATES, True), bates_price(F, K, T, BATES, False)
|
|
assert np.allclose(c - p, F - K, atol=1e-6)
|
|
x = np.linspace(-1.5, 1.2, 2001)
|
|
d = bates_density(T, BATES, x)
|
|
assert abs(np.trapezoid(d, x) - 1) < 1e-6 and abs(np.trapezoid(d * np.exp(x), x) - 1) < 1e-6
|
|
assert np.allclose(implied_vol(black(F, K, T, 0.5), F, K, T), 0.5, atol=1e-7)
|
|
|
|
|
|
def test_merton_matches_exact_monte_carlo():
|
|
rng = np.random.default_rng(1)
|
|
n, lam, mu, dl, sig = 400_000, 6.0, -0.03, 0.08, 0.40
|
|
k = np.exp(mu + 0.5 * dl ** 2) - 1
|
|
N = rng.poisson(lam * T, n)
|
|
x = (-lam * k * T - 0.5 * sig ** 2 * T + sig * np.sqrt(T) * rng.standard_normal(n)
|
|
+ N * mu + np.sqrt(N) * dl * rng.standard_normal(n))
|
|
ST = F * np.exp(x)
|
|
for KK in (70000, 83000, 100000):
|
|
pay = np.maximum(ST - KK, 0)
|
|
assert abs(merton_price(F, [KK], T, sig, lam, mu, dl)[0] - pay.mean()) < 4 * pay.std() / np.sqrt(n)
|
|
|
|
|
|
def test_bates_matches_monte_carlo():
|
|
rng = np.random.default_rng(0)
|
|
n, steps = 100_000, 100
|
|
dt = T / steps
|
|
v0, ka, th, xi, rho, lam, mu, dl = BATES
|
|
k = np.exp(mu + 0.5 * dl ** 2) - 1
|
|
v, x = np.full(n, v0), np.zeros(n)
|
|
for _ in range(steps):
|
|
z1 = rng.standard_normal(n)
|
|
z2 = rho * z1 + np.sqrt(1 - rho ** 2) * rng.standard_normal(n)
|
|
vp = np.maximum(v, 0)
|
|
N = rng.poisson(lam * dt, n)
|
|
x += -0.5 * vp * dt - lam * k * dt + np.sqrt(vp * dt) * z1 + N * mu + np.sqrt(N) * dl * rng.standard_normal(n)
|
|
v += ka * (th - vp) * dt + xi * np.sqrt(vp * dt) * z2
|
|
ST = F * np.exp(x)
|
|
for KK in (70000, 83000, 95000):
|
|
pay = np.maximum(ST - KK, 0)
|
|
se = pay.std() / np.sqrt(n)
|
|
assert abs(bates_price(F, [KK], T, BATES)[0] - pay.mean()) < 4 * se + 0.005 * pay.mean()
|
|
|
|
|
|
def _surface(model, p, noise=0.0, seed=0):
|
|
rng = np.random.default_rng(seed)
|
|
names = MERTON_NAMES if model == "merton" else BATES_NAMES
|
|
out = []
|
|
for days in (7, 14, 30, 60, 91, 182):
|
|
t = days / 365
|
|
f = F * np.exp(0.04 * t)
|
|
sd = 0.45 * np.sqrt(t)
|
|
strikes = f * np.exp(np.linspace(-2.2 * sd, 2.2 * sd, 25))
|
|
iv = model_smile(model, dict(zip(names, p)), f, t, strikes)
|
|
out.append({"T": t, "forward": f, "strikes": strikes.tolist(),
|
|
"iv": (iv * (1 + noise * rng.standard_normal(len(iv)))).tolist()})
|
|
return out
|
|
|
|
|
|
def test_calibration_recovers_parameters():
|
|
m = calibrate(_surface("merton", (0.38, 8.0, -0.04, 0.09)), "merton")
|
|
assert m["rmse_vol"] < 1e-4
|
|
assert np.allclose(list(m["params"].values()), [0.38, 8.0, -0.04, 0.09], rtol=1e-3, atol=1e-4)
|
|
b = calibrate(_surface("bates", BATES), "bates")
|
|
assert b["rmse_vol"] < 1e-3
|
|
noisy = calibrate(_surface("bates", BATES, noise=0.01, seed=3), "bates")
|
|
assert noisy["rmse_vol"] < 0.008 and abs(noisy["params"]["rho"] - BATES[4]) < 0.15
|
|
# A pure-jump model cannot fit a stochastic-vol surface as well.
|
|
assert calibrate(_surface("bates", BATES), "merton")["rmse_vol"] > 5 * b["rmse_vol"]
|
|
s = jump_stats(b["params"])
|
|
assert 0 < s["p_crash_jump"] < 1 and 0 < s["jump_variance_share"] < 1
|
|
|
|
|
|
def test_endpoints(monkeypatch):
|
|
rows, _ = deribit_book(spot=83000)
|
|
exps = build_crypto_surface(rows, NOW_MS)
|
|
monkeypatch.setattr(vol_router, "get_crypto_vol", lambda ccy: {"expiries": exps, "index_price": 83000.0})
|
|
jumps_router._cache.clear()
|
|
c = TestClient(app)
|
|
d = c.get("/api/jumps/calibrate/btc").json()
|
|
assert set(d["fits"]) == {"merton", "bates"}
|
|
assert d["fits"]["bates"]["rmse_vol"] <= d["fits"]["merton"]["rmse_vol"] + 1e-6
|
|
assert len(d["smiles"]) >= 5 and len(d["smiles"][0]["bates"]) == 40
|
|
assert 0 < d["density_30d"]["p_down_20"] < 1
|
|
assert c.get("/api/jumps/calibrate/DOGE").status_code == 404
|
|
body = {"pair": "XAGUSD", "S": 61.2, "T": 0.25, "r_d": 0.039, "r_f": 0.02, "sigma": 0.30,
|
|
"jump_intensity": 3, "jump_mean": -0.05, "jump_sd": 0.06}
|
|
r = c.post("/api/jumps/merton", json=body).json()
|
|
assert len(r["iv"]) == 41 and r["rr_approx"] < 0 # down-jumps → put skew
|
|
assert c.post("/api/jumps/merton", json={**body, "pair": "NOPE"}).status_code == 404
|
|
|
|
def boom(ccy):
|
|
from fastapi import HTTPException
|
|
raise HTTPException(503, "Deribit down")
|
|
monkeypatch.setattr(vol_router, "get_crypto_vol", boom)
|
|
jumps_router._cache.clear()
|
|
assert c.get("/api/jumps/calibrate/ETH").status_code == 503
|