mirror of
https://github.com/Mihirkansara/nexus-quant-terminal.git
synced 2026-11-02 09:36:11 +00:00
feat: Jump Lab — Merton & Bates calibrated to the Deribit smile
- 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
This commit is contained in:
@@ -42,6 +42,7 @@ NEXUS TERMINAL is a Bloomberg-style web application for professional forex optio
|
||||
| **Vol Forecast & Regime (AI)** | HAR-RV (weekend-aware for crypto), GJR-GARCH and gradient-boosting forecasts of 1D/1W/1M vol, rolling out-of-sample QLIKE leaderboard with an inverse-QLIKE ensemble, 2-state HMM regime detection, and a forecast-vs-Deribit-implied rich/cheap signal |
|
||||
| **Hedge Lab** | Monte Carlo delta-hedging / gamma-scalping P&L of your portfolio: realised vs implied vol, GBM / Merton jumps / historical block bootstrap, weekend gaps for 24/5 markets, time or delta-band hedging, transaction costs and carry, with Derman-Kamal and Leland benchmarks and a hedge-frequency risk-vs-cost sweep |
|
||||
| **Cross-Asset Desk** | Gold · silver · BTC · ETH · EURUSD · DXY · US10Y · WTI correlation matrices (30D/90D/1Y/EWMA) with a correlation-break monitor; gold/silver ratio z-scores, OU half-life, Engle-Granger cointegration and Kalman hedge ratio; Margrabe outperformance-option pricer with correlation sensitivity |
|
||||
| **Jump Lab** | Merton and Bates (Heston + jumps, COS pricing) calibrated to every live Deribit BTC/ETH expiry: model-vs-market smiles, fit RMSE, jump intensity/size, jump share of variance, crash-jump odds, 30-day risk-neutral density vs lognormal; manual Merton for metals/FX event and gap risk; one-click hand-off of jump parameters to Hedge Lab |
|
||||
|
||||
### Live Intelligence
|
||||
|
||||
|
||||
@@ -0,0 +1,297 @@
|
||||
"""
|
||||
jump_models.py — Merton jump-diffusion and Bates (stochastic vol + jumps) pricing,
|
||||
calibrated to a listed option smile, with risk-neutral densities and jump-risk
|
||||
statistics.
|
||||
|
||||
All prices are forward (undiscounted, "Black-76") prices, so the same code works for
|
||||
Deribit coin-margined options (quoted on the forward) and for FX / metals once the
|
||||
forward F = S·e^{(r_d − r_f)T} is supplied.
|
||||
|
||||
Merton (1976): log-normal jumps, intensity λ, log-jump ~ N(μ_J, δ²), k = e^{μ_J+δ²/2} − 1.
|
||||
C = Σ_n e^{−λT}(λT)^n/n! · Black(F_n, K, σ_n, T),
|
||||
σ_n² = σ² + nδ²/T, F_n = E[S_T | n jumps] = F·e^{−λkT}(1+k)^n
|
||||
(forward form of Merton's series; the familiar λ' = λ(1+k) weights belong to the
|
||||
spot form with the adjusted rate r_n).
|
||||
|
||||
Bates (1996): Heston (1993) variance + Merton jumps. Characteristic function of
|
||||
x = ln(S_T/F) in the rotation-count-safe form (Albrecher et al. 2007, "little Heston trap"):
|
||||
d = √((ρξiu − κ)² + ξ²(iu + u²)), g = (κ − ρξiu − d)/(κ − ρξiu + d)
|
||||
C = κθ/ξ²·[(κ − ρξiu − d)T − 2 ln((1 − g e^{−dT})/(1 − g))]
|
||||
D = (κ − ρξiu − d)/ξ² · (1 − e^{−dT})/(1 − g e^{−dT})
|
||||
J = λT(e^{iuμ_J − u²δ²/2} − 1) − iu·λkT
|
||||
φ(u) = exp(C + D·v₀ + J)
|
||||
Puts are priced with the COS method (Fang & Oosterlee 2008), calls by parity; the
|
||||
same expansion gives the risk-neutral density.
|
||||
|
||||
Calibration minimises vega-weighted price errors (≈ implied-vol errors) across all
|
||||
expiries with bounded least squares from several starts.
|
||||
|
||||
References:
|
||||
Merton, R. (1976). J. Financial Economics, 3, 125–144.
|
||||
Heston, S. (1993). Review of Financial Studies, 6(2), 327–343.
|
||||
Bates, D. (1996). Review of Financial Studies, 9(1), 69–107.
|
||||
Albrecher, H., Mayer, P., Schoutens, W. & Tistaert, J. (2007). Wilmott Magazine, Jan.
|
||||
Fang, F. & Oosterlee, C. (2008). SIAM J. Sci. Comput., 31(2), 826–848.
|
||||
"""
|
||||
|
||||
import numpy as np
|
||||
from scipy.optimize import brentq, least_squares
|
||||
from scipy.special import gammaln
|
||||
from scipy.stats import norm
|
||||
|
||||
N_COS = 256
|
||||
|
||||
|
||||
# ─── Black-76 on the forward ──────────────────────────────────────────────────
|
||||
|
||||
def black(F, K, T, sigma, is_call=True):
|
||||
F, K, sigma = np.asarray(F, float), np.asarray(K, float), np.asarray(sigma, float)
|
||||
sq = np.maximum(sigma * np.sqrt(T), 1e-12)
|
||||
d1 = (np.log(F / K) + 0.5 * sq ** 2) / sq
|
||||
d2 = d1 - sq
|
||||
c = F * norm.cdf(d1) - K * norm.cdf(d2)
|
||||
return c if is_call else c - (F - K)
|
||||
|
||||
|
||||
def black_vega(F, K, T, sigma):
|
||||
sq = np.maximum(sigma * np.sqrt(T), 1e-12)
|
||||
d1 = (np.log(F / K) + 0.5 * sq ** 2) / sq
|
||||
return F * norm.pdf(d1) * np.sqrt(T)
|
||||
|
||||
|
||||
def implied_vol(price, F, K, T, is_call=True):
|
||||
"""Black-76 implied vol by bracketing root search (NaN outside no-arbitrage bounds)."""
|
||||
Ks = np.atleast_1d(np.asarray(K, float))
|
||||
out = np.full(Ks.shape, np.nan)
|
||||
for i, (p, k) in enumerate(zip(np.broadcast_to(np.atleast_1d(price), Ks.shape), Ks)):
|
||||
intrinsic = max(F - k, 0.0) if is_call else max(k - F, 0.0)
|
||||
if not (intrinsic + 1e-12 * F < p < (F if is_call else k)):
|
||||
continue
|
||||
try:
|
||||
out[i] = brentq(lambda s: float(black(F, k, T, s, is_call)) - p, 1e-4, 10.0, xtol=1e-8)
|
||||
except ValueError:
|
||||
pass
|
||||
return out
|
||||
|
||||
|
||||
# ─── Merton ───────────────────────────────────────────────────────────────────
|
||||
|
||||
def merton_price(F, K, T, sigma, lam, mu_j, delta, is_call=True, n_terms=60):
|
||||
K = np.asarray(K, float)
|
||||
k = np.exp(mu_j + 0.5 * delta ** 2) - 1
|
||||
lt = lam * T
|
||||
n = np.arange(n_terms)
|
||||
logw = -lt + n * np.log(max(lt, 1e-300)) - gammaln(n + 1) if lt > 0 else np.where(n == 0, 0.0, -np.inf)
|
||||
w = np.exp(logw)
|
||||
sig_n = np.sqrt(sigma ** 2 + n * delta ** 2 / T)
|
||||
F_n = F * np.exp(-lam * k * T + n * np.log1p(k))
|
||||
prices = black(F_n[:, None], K[None, :], T, sig_n[:, None], True)
|
||||
c = (w[:, None] * prices).sum(axis=0)
|
||||
return c if is_call else c - (F - K)
|
||||
|
||||
|
||||
# ─── Bates characteristic function & COS ─────────────────────────────────────
|
||||
|
||||
def bates_cf(u, T, v0, kappa, theta, xi, rho, lam, mu_j, delta):
|
||||
"""φ(u) = E[e^{iu·ln(S_T/F)}] under the forward measure."""
|
||||
u = np.asarray(u, dtype=complex)
|
||||
iu = 1j * u
|
||||
b = kappa - rho * xi * iu
|
||||
d = np.sqrt(b ** 2 + xi ** 2 * (iu + u ** 2))
|
||||
g = (b - d) / (b + d)
|
||||
edt = np.exp(-d * T)
|
||||
C = kappa * theta / xi ** 2 * ((b - d) * T - 2 * np.log((1 - g * edt) / (1 - g)))
|
||||
D = (b - d) / xi ** 2 * (1 - edt) / (1 - g * edt)
|
||||
k = np.exp(mu_j + 0.5 * delta ** 2) - 1
|
||||
J = lam * T * (np.exp(iu * mu_j - 0.5 * u ** 2 * delta ** 2) - 1) - iu * lam * k * T
|
||||
return np.exp(C + D * v0 + J)
|
||||
|
||||
|
||||
def _cos_range(T, v0, theta, lam, mu_j, delta, L=12.0):
|
||||
var = max(v0, theta) * T + lam * T * (mu_j ** 2 + delta ** 2)
|
||||
c1 = -0.5 * var + lam * T * mu_j
|
||||
sd = np.sqrt(max(var, 1e-8))
|
||||
return c1 - L * sd, c1 + L * sd
|
||||
|
||||
|
||||
def bates_price(F, K, T, params, is_call=True, N=N_COS):
|
||||
"""Forward prices of European options on many strikes via COS (puts + parity)."""
|
||||
v0, kappa, theta, xi, rho, lam, mu_j, delta = params
|
||||
K = np.atleast_1d(np.asarray(K, float))
|
||||
a, b = _cos_range(T, v0, theta, lam, mu_j, delta)
|
||||
k = np.arange(N)
|
||||
u = k * np.pi / (b - a)
|
||||
phi = bates_cf(u, T, v0, kappa, theta, xi, rho, lam, mu_j, delta)
|
||||
# Put payoff K(1 − e^y)^+ on y = ln(S_T/K) ∈ [a', 0]; work in x = ln(S_T/F) shifted by x0.
|
||||
x0 = np.log(F / K) # per strike
|
||||
# Cosine coefficients of the put payoff on [a, b] in y = x0 + x (Fang & Oosterlee eq. 24/25).
|
||||
lo, hi = a, 0.0
|
||||
|
||||
def chi(c, d):
|
||||
uk = u
|
||||
return (np.cos(uk * (d - a)) * np.exp(d) - np.cos(uk * (c - a)) * np.exp(c)
|
||||
+ uk * np.sin(uk * (d - a)) * np.exp(d) - uk * np.sin(uk * (c - a)) * np.exp(c)) / (1 + uk ** 2)
|
||||
|
||||
def psi(c, d):
|
||||
out = np.empty_like(u)
|
||||
out[0] = d - c
|
||||
out[1:] = (np.sin(u[1:] * (d - a)) - np.sin(u[1:] * (c - a))) / u[1:]
|
||||
return out
|
||||
|
||||
Vk = 2.0 / (b - a) * (-chi(lo, hi) + psi(lo, hi)) # per unit strike
|
||||
terms = (phi[None, :] * np.exp(1j * u[None, :] * (x0[:, None] - a))).real * Vk[None, :]
|
||||
terms[:, 0] *= 0.5
|
||||
put = K * terms.sum(axis=1)
|
||||
put = np.maximum(put, np.maximum(K - F, 0.0))
|
||||
return put + (F - K) if is_call else put
|
||||
|
||||
|
||||
def bates_density(T, params, x_grid, N=N_COS):
|
||||
"""Risk-neutral density of x = ln(S_T/F) on x_grid (COS series)."""
|
||||
v0, kappa, theta, xi, rho, lam, mu_j, delta = params
|
||||
a, b = _cos_range(T, v0, theta, lam, mu_j, delta)
|
||||
u = np.arange(N) * np.pi / (b - a)
|
||||
phi = bates_cf(u, T, v0, kappa, theta, xi, rho, lam, mu_j, delta)
|
||||
coef = 2.0 / (b - a) * (phi * np.exp(-1j * u * a)).real
|
||||
coef[0] *= 0.5
|
||||
dens = (coef[None, :] * np.cos(u[None, :] * (np.asarray(x_grid)[:, None] - a))).sum(axis=1)
|
||||
return np.maximum(dens, 0.0)
|
||||
|
||||
|
||||
def merton_as_bates(sigma, lam, mu_j, delta):
|
||||
"""Bates parameters that reproduce Merton (constant variance, negligible vol-of-vol)."""
|
||||
return (sigma ** 2, 5.0, sigma ** 2, 1e-4, 0.0, lam, mu_j, delta)
|
||||
|
||||
|
||||
# ─── calibration ──────────────────────────────────────────────────────────────
|
||||
|
||||
MERTON_BOUNDS = ([0.05, 0.0, -0.5, 0.005], [3.0, 50.0, 0.3, 0.6])
|
||||
BATES_BOUNDS = ([0.004, 0.2, 0.004, 0.05, -0.95, 0.0, -0.5, 0.005],
|
||||
[4.0, 20.0, 4.0, 5.0, 0.95, 30.0, 0.3, 0.6])
|
||||
BATES_NAMES = ["v0", "kappa", "theta", "xi", "rho", "lambda", "mu_j", "delta"]
|
||||
MERTON_NAMES = ["sigma", "lambda", "mu_j", "delta"]
|
||||
|
||||
|
||||
def _otm(F, K, T, iv):
|
||||
"""OTM forward price (calls above F, puts below) and Black vega for weighting."""
|
||||
is_call = K >= F
|
||||
p = np.where(is_call, black(F, K, T, iv, True), black(F, K, T, iv, False))
|
||||
return p, is_call, np.maximum(black_vega(F, K, T, iv), 1e-6 * F)
|
||||
|
||||
|
||||
def _model_prices(model, p, F, K, T, is_call):
|
||||
if model == "merton":
|
||||
c = merton_price(F, K, T, *p)
|
||||
else:
|
||||
c = bates_price(F, K, T, p)
|
||||
return np.where(is_call, c, c - (F - K))
|
||||
|
||||
|
||||
def calibrate(expiries, model="bates", starts=None):
|
||||
"""
|
||||
expiries: list of {"T", "forward", "strikes", "iv"} (iv as decimals).
|
||||
Returns fitted params (dict), RMSE in vol points per expiry and overall.
|
||||
"""
|
||||
data = []
|
||||
for e in expiries:
|
||||
F, T = float(e["forward"]), float(e["T"])
|
||||
K, iv = np.asarray(e["strikes"], float), np.asarray(e["iv"], float)
|
||||
mny = np.abs(np.log(K / F)) / (np.median(iv) * np.sqrt(T))
|
||||
keep = mny < 3.0 # drop far wings beyond ~3 sd
|
||||
if keep.sum() < 4:
|
||||
continue
|
||||
p, is_call, vega = _otm(F, K[keep], T, iv[keep])
|
||||
data.append((F, T, K[keep], iv[keep], p, is_call, vega))
|
||||
if not data:
|
||||
raise ValueError("no usable expiries")
|
||||
|
||||
def resid(x):
|
||||
out = []
|
||||
for F, T, K, iv, p, is_call, vega in data:
|
||||
out.append((_model_prices(model, x, F, K, T, is_call) - p) / vega)
|
||||
return np.concatenate(out)
|
||||
|
||||
atm = float(np.median([np.interp(0, np.log(d[2] / d[0]), d[3]) for d in data]))
|
||||
if model == "merton":
|
||||
lb, ub = MERTON_BOUNDS
|
||||
starts = starts or [[atm * 0.9, 1.0, -0.05, 0.10], [atm * 0.7, 5.0, -0.02, 0.08],
|
||||
[atm * 0.8, 0.5, -0.15, 0.15]]
|
||||
else:
|
||||
lb, ub = BATES_BOUNDS
|
||||
v = atm ** 2
|
||||
starts = starts or [[v, 2.0, v, 1.0, -0.3, 1.0, -0.05, 0.10],
|
||||
[v, 5.0, v, 2.0, 0.0, 3.0, -0.03, 0.08],
|
||||
[v, 1.0, v * 1.2, 0.6, -0.6, 0.3, -0.15, 0.15]]
|
||||
best = None
|
||||
for x0 in starts:
|
||||
x0 = np.clip(x0, np.array(lb) + 1e-6, np.array(ub) - 1e-6)
|
||||
try:
|
||||
res = least_squares(resid, x0, bounds=(lb, ub), method="trf", x_scale="jac",
|
||||
max_nfev=400 if model == "bates" else 600)
|
||||
except (ValueError, FloatingPointError):
|
||||
continue
|
||||
if best is None or res.cost < best.cost:
|
||||
best = res
|
||||
if best is None:
|
||||
raise ValueError("calibration failed")
|
||||
x = best.x
|
||||
|
||||
per_exp, all_err = [], []
|
||||
for F, T, K, iv, p, is_call, vega in data:
|
||||
mp = _model_prices(model, x, F, K, T, is_call)
|
||||
miv = np.array([implied_vol(m, F, k, T, c)[0] for m, k, c in zip(mp, K, is_call)])
|
||||
err = miv - iv
|
||||
err = err[np.isfinite(err)]
|
||||
all_err.extend(err.tolist())
|
||||
per_exp.append({"T": T, "rmse_vol": float(np.sqrt(np.mean(err ** 2))) if len(err) else None})
|
||||
names = MERTON_NAMES if model == "merton" else BATES_NAMES
|
||||
return {"model": model, "params": dict(zip(names, map(float, x))),
|
||||
"rmse_vol": float(np.sqrt(np.mean(np.square(all_err)))) if all_err else None,
|
||||
"per_expiry": per_exp, "n_quotes": int(sum(len(d[2]) for d in data))}
|
||||
|
||||
|
||||
def model_smile(model, params, F, T, strikes):
|
||||
"""Model implied vols on a strike grid (OTM prices inverted)."""
|
||||
K = np.asarray(strikes, float)
|
||||
p = [params[n] for n in (MERTON_NAMES if model == "merton" else BATES_NAMES)]
|
||||
is_call = K >= F
|
||||
mp = _model_prices(model, p, F, K, T, is_call)
|
||||
return np.array([implied_vol(m, F, k, T, c)[0] for m, k, c in zip(mp, K, is_call)])
|
||||
|
||||
|
||||
def jump_stats(params, T=30 / 365, crash=-0.10, model="bates"):
|
||||
"""Jump-risk read-outs: jumps/yr, P(≥1 jump worse than `crash` within T), jump share of variance."""
|
||||
lam, mu, d = params["lambda"], params["mu_j"], params["delta"]
|
||||
p_bad = norm.cdf((np.log1p(crash) - mu) / d) if d > 0 else float(mu < np.log1p(crash))
|
||||
jump_var = lam * (mu ** 2 + d ** 2)
|
||||
diff_var = params["sigma"] ** 2 if model == "merton" else params["theta"]
|
||||
return {
|
||||
"jumps_per_year": lam,
|
||||
"mean_jump_pct": float((np.exp(mu + 0.5 * d ** 2) - 1) * 100),
|
||||
"jump_sd_pct": float(d * 100),
|
||||
"p_crash_jump": float(1 - np.exp(-lam * T * p_bad)),
|
||||
"jump_variance_share": float(jump_var / (jump_var + diff_var)),
|
||||
"horizon_days": round(T * 365, 1),
|
||||
"crash_threshold_pct": crash * 100,
|
||||
}
|
||||
|
||||
|
||||
def density_report(params, model, T, F, n=241):
|
||||
"""Risk-neutral density of S_T vs a lognormal with the same ATM variance."""
|
||||
p = [params[k] for k in (MERTON_NAMES if model == "merton" else BATES_NAMES)]
|
||||
bp = merton_as_bates(*p) if model == "merton" else p
|
||||
v = bp[0] * T + bp[5] * T * (bp[6] ** 2 + bp[7] ** 2)
|
||||
sd = np.sqrt(max(v, 1e-8))
|
||||
x = np.linspace(-5 * sd, 4 * sd, n)
|
||||
f = bates_density(T, bp, x)
|
||||
dx = x[1] - x[0]
|
||||
lognormal = norm.pdf(x, -0.5 * v, sd)
|
||||
cdf = np.cumsum(f) * dx
|
||||
return {
|
||||
"spot": (F * np.exp(x)).tolist(), "density": (f / (F * np.exp(x))).tolist(),
|
||||
"lognormal": (lognormal / (F * np.exp(x))).tolist(),
|
||||
"p_down_20": float(np.interp(np.log(0.8), x, cdf)),
|
||||
"p_down_20_lognormal": float(norm.cdf(np.log(0.8), -0.5 * v, sd)),
|
||||
"p_up_20": float(1 - np.interp(np.log(1.2), x, cdf)),
|
||||
"p_up_20_lognormal": float(1 - norm.cdf(np.log(1.2), -0.5 * v, sd)),
|
||||
}
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from .routers import greeks, surface, market, montecarlo, scenarios, strategies, export, forex, institutional, news, livedata, smile, vol, hedge, xasset
|
||||
from .routers import greeks, surface, market, montecarlo, scenarios, strategies, export, forex, institutional, news, livedata, smile, vol, hedge, xasset, jumps
|
||||
|
||||
app = FastAPI(title="QuantRisk FX Terminal API",
|
||||
description="Forex options risk analytics — Garman-Kohlhagen model.",
|
||||
@@ -9,7 +9,7 @@ app = FastAPI(title="QuantRisk FX Terminal API",
|
||||
app.add_middleware(CORSMiddleware, allow_origins=["*"],
|
||||
allow_credentials=True, allow_methods=["*"], allow_headers=["*"])
|
||||
|
||||
for router in [greeks, surface, market, montecarlo, scenarios, strategies, export, forex, institutional, news, livedata, smile, vol, hedge, xasset]:
|
||||
for router in [greeks, surface, market, montecarlo, scenarios, strategies, export, forex, institutional, news, livedata, smile, vol, hedge, xasset, jumps]:
|
||||
app.include_router(router.router, prefix="/api")
|
||||
|
||||
@app.get("/")
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
"""
|
||||
jumps.py — Jump-diffusion lab: Merton and Bates calibrated to the live Deribit
|
||||
BTC/ETH surface (via the Vol Lab feed), plus a manual Merton pricer for metals/FX
|
||||
event and gap risk.
|
||||
"""
|
||||
|
||||
import time
|
||||
|
||||
import numpy as np
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from ..core.jump_models import (calibrate, density_report, jump_stats, merton_price,
|
||||
implied_vol, model_smile)
|
||||
from .forex import PAIRS
|
||||
|
||||
router = APIRouter(prefix="/jumps", tags=["jumps"])
|
||||
_cache: dict = {}
|
||||
|
||||
|
||||
def _surface_expiries(currency):
|
||||
from .vol import get_crypto_vol # reuses the cached Deribit fetch + SVI parse
|
||||
surf = get_crypto_vol(currency)
|
||||
exps = [e for e in surf["expiries"] if 3 <= e["days"] <= 370 and len(e["market"]["strikes"]) >= 5]
|
||||
if len(exps) < 2:
|
||||
raise ValueError("not enough Deribit expiries to calibrate")
|
||||
return surf, exps
|
||||
|
||||
|
||||
def calibration_report(exps, index_price=None):
|
||||
data = [{"T": e["T"], "forward": e["forward"], "strikes": e["market"]["strikes"],
|
||||
"iv": e["market"]["iv"], "label": e["label"], "days": e["days"]} for e in exps]
|
||||
fits = {m: calibrate(data, m) for m in ("merton", "bates")}
|
||||
smiles = []
|
||||
for d, rm, rb in zip(data, fits["merton"]["per_expiry"], fits["bates"]["per_expiry"]):
|
||||
F, T = d["forward"], d["T"]
|
||||
K = np.asarray(d["strikes"], float)
|
||||
grid = np.exp(np.linspace(np.log(K.min()), np.log(K.max()), 40))
|
||||
smiles.append({
|
||||
"label": d["label"], "days": d["days"], "T": T, "forward": F,
|
||||
"market": {"strikes": d["strikes"], "iv": d["iv"]},
|
||||
"grid": grid.tolist(),
|
||||
"merton": model_smile("merton", fits["merton"]["params"], F, T, grid).tolist(),
|
||||
"bates": model_smile("bates", fits["bates"]["params"], F, T, grid).tolist(),
|
||||
"rmse_merton": rm["rmse_vol"], "rmse_bates": rb["rmse_vol"],
|
||||
})
|
||||
F30 = min(data, key=lambda d: abs(d["days"] - 30))["forward"]
|
||||
return {
|
||||
"index_price": index_price,
|
||||
"fits": {m: {k: v for k, v in f.items() if k != "per_expiry"} for m, f in fits.items()},
|
||||
"stats": {m: jump_stats(fits[m]["params"], model=m) for m in fits},
|
||||
"density_30d": density_report(fits["bates"]["params"], "bates", 30 / 365, F30),
|
||||
"smiles": smiles,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/calibrate/{currency}")
|
||||
def calibrate_crypto(currency: str):
|
||||
"""Merton + Bates calibrated to the live Deribit smile for BTC or ETH (cached 30 min)."""
|
||||
currency = currency.upper()
|
||||
if currency not in ("BTC", "ETH"):
|
||||
raise HTTPException(404, "Calibration is available for BTC and ETH (Deribit).")
|
||||
hit = _cache.get(currency)
|
||||
if hit and time.time() - hit[0] < 1800:
|
||||
return hit[1]
|
||||
try:
|
||||
surf, exps = _surface_expiries(currency)
|
||||
rep = {"currency": currency, **_clean(calibration_report(exps, surf.get("index_price")))}
|
||||
except HTTPException as e:
|
||||
raise HTTPException(503, f"Deribit surface unavailable: {e.detail}")
|
||||
except Exception as e:
|
||||
raise HTTPException(503, f"Calibration failed: {e}")
|
||||
_cache[currency] = (time.time(), rep)
|
||||
return rep
|
||||
|
||||
|
||||
class MertonRequest(BaseModel):
|
||||
pair: str = "XAUUSD"
|
||||
S: float = Field(..., gt=0)
|
||||
T: float = Field(..., gt=0, le=3)
|
||||
r_d: float = 0.039
|
||||
r_f: float = 0.0
|
||||
sigma: float = Field(..., gt=0.005, lt=3, description="Diffusion vol")
|
||||
jump_intensity: float = Field(default=2.0, ge=0, le=100, description="Jumps per year")
|
||||
jump_mean: float = Field(default=-0.03, gt=-0.8, lt=0.5, description="Mean log jump")
|
||||
jump_sd: float = Field(default=0.04, ge=0.001, lt=1, description="Log jump sd")
|
||||
|
||||
|
||||
@router.post("/merton")
|
||||
def merton_smile(req: MertonRequest):
|
||||
"""Merton smile, jump statistics and 30-day density for any pair (manual parameters)."""
|
||||
if req.pair.upper() not in PAIRS:
|
||||
raise HTTPException(404, f"Unknown pair '{req.pair}'.")
|
||||
F = req.S * np.exp((req.r_d - req.r_f) * req.T)
|
||||
params = {"sigma": req.sigma, "lambda": req.jump_intensity, "mu_j": req.jump_mean, "delta": req.jump_sd}
|
||||
tot = np.sqrt(req.sigma ** 2 + req.jump_intensity * (req.jump_mean ** 2 + req.jump_sd ** 2))
|
||||
K = F * np.exp(np.linspace(-2.5, 2.5, 41) * tot * np.sqrt(req.T))
|
||||
iv = model_smile("merton", params, F, req.T, K)
|
||||
atm = float(implied_vol(merton_price(F, [F], req.T, req.sigma, req.jump_intensity,
|
||||
req.jump_mean, req.jump_sd), F, [F], req.T)[0])
|
||||
# 25-delta-ish wings at ±0.674 sd for a quick RR / BF read.
|
||||
kp, kc = F * np.exp(-0.674 * tot * np.sqrt(req.T)), F * np.exp(0.674 * tot * np.sqrt(req.T))
|
||||
vp, vc = model_smile("merton", params, F, req.T, [kp, kc])
|
||||
return _clean({"pair": req.pair.upper(), "forward": F, "strikes": K.tolist(), "iv": iv.tolist(),
|
||||
"atm_vol": atm, "total_vol": float(tot), "rr_approx": float(vc - vp),
|
||||
"bf_approx": float(0.5 * (vc + vp) - atm),
|
||||
"stats": jump_stats(params, T=min(req.T, 30 / 365), model="merton"),
|
||||
"density": density_report(params, "merton", req.T, F)})
|
||||
|
||||
|
||||
def _clean(obj):
|
||||
if isinstance(obj, (float, np.floating)):
|
||||
x = float(obj)
|
||||
return round(x, 6) if np.isfinite(x) else None
|
||||
if isinstance(obj, dict):
|
||||
return {k: _clean(v) for k, v in obj.items()}
|
||||
if isinstance(obj, (list, tuple, np.ndarray)):
|
||||
return [_clean(v) for v in obj]
|
||||
return obj
|
||||
@@ -0,0 +1,121 @@
|
||||
"""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
|
||||
@@ -33,6 +33,10 @@ export const simulateHedge = (p) => api.post('/hedge/simulate', p).then(r=>r.dat
|
||||
// Cross-asset desk
|
||||
export const fetchCrossAsset = () => api.get('/xasset').then(r=>r.data)
|
||||
export const priceMargrabe = (p) => api.post('/xasset/margrabe', p).then(r=>r.data)
|
||||
|
||||
// Jump Lab
|
||||
export const calibrateJumps = (ccy) => api.get(`/jumps/calibrate/${ccy}`).then(r=>r.data)
|
||||
export const priceMerton = (p) => api.post('/jumps/merton', p).then(r=>r.data)
|
||||
export const exportCSV = (p) => api.post('/export/csv', p, {responseType:'blob'}).then(r=>r.data)
|
||||
|
||||
// Institutional flow
|
||||
|
||||
@@ -15,24 +15,24 @@ const axis = (title, extra = {}) => ({ title:{ text:title }, gridcolor:'#1e293b'
|
||||
const FREQS = [[1, 'Daily'], [2, '2× / day'], [4, '4× / day'], [8, '8× / day'], [24, 'Hourly']]
|
||||
const MODELS = [['gbm', 'Lognormal (GBM)'], ['jump', 'Jump-diffusion (Merton)'], ['bootstrap', 'Historical bootstrap']]
|
||||
|
||||
const defaultsFor = (pair, sigma) => ({
|
||||
const defaultsFor = (pair, sigma, hedgeJump) => ({
|
||||
sigma_realised: +(sigma * 100).toFixed(2),
|
||||
model: isCrypto(pair) ? 'jump' : 'gbm',
|
||||
model: hedgeJump || isCrypto(pair) ? 'jump' : 'gbm',
|
||||
steps_per_day: isCrypto(pair) ? 4 : 1,
|
||||
rule: 'time', band: 0.05, cost_bps: isCrypto(pair) ? 3 : 1, n_paths: 2000,
|
||||
jump: { intensity: 12, mean: -1, sd: 5 }, // % units in the UI
|
||||
jump: hedgeJump ?? { intensity: 12, mean: -1, sd: 5 }, // % units in the UI; Jump Lab can supply it
|
||||
weekend_gap: PAIR_META[pair]?.assetClass === 'metal' ? 1.0 : 0.3,
|
||||
rescale_bootstrap: true,
|
||||
})
|
||||
|
||||
export default function HedgeLab() {
|
||||
const { legs, S, sigma, r_d, r_f, pair } = usePortfolioStore()
|
||||
const [cfg, setCfg] = useState(() => ({ pair, ...defaultsFor(pair, sigma) }))
|
||||
const { legs, S, sigma, r_d, r_f, pair, hedgeJump } = usePortfolioStore()
|
||||
const [cfg, setCfg] = useState(() => ({ pair, ...defaultsFor(pair, sigma, hedgeJump) }))
|
||||
const [run, setRun] = useState(0)
|
||||
const [response, setResponse] = useState({ key:null, data:null, error:null })
|
||||
const [fcNote, setFcNote] = useState(null)
|
||||
// Reset scenario defaults when the instrument changes (derived, no effect needed).
|
||||
const params = cfg.pair === pair ? cfg : { pair, ...defaultsFor(pair, sigma) }
|
||||
const params = cfg.pair === pair ? cfg : { pair, ...defaultsFor(pair, sigma, hedgeJump) }
|
||||
const set = (k, v) => setCfg({ ...params, [k]: v })
|
||||
const crypto = isCrypto(pair)
|
||||
const maxT = legs.length ? Math.max(...legs.map(l => +l.T)) : 0
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import _Plot from 'react-plotly.js'
|
||||
const Plot = _Plot?.default ?? _Plot
|
||||
import { usePortfolioStore, isCrypto, PAIR_META } from '../store/portfolio'
|
||||
import { calibrateJumps, priceMerton } from '../api/client'
|
||||
|
||||
const LAYOUT = {
|
||||
paper_bgcolor:'transparent', plot_bgcolor:'#080d18',
|
||||
font:{ color:'#94a3b8', size:11 },
|
||||
margin:{ l:52, r:16, t:12, b:42 },
|
||||
legend:{ bgcolor:'transparent', font:{ size:9 }, orientation:'h', x:0, y:-0.22 },
|
||||
hoverlabel:{ bgcolor:'#0f172a', font:{ color:'#e2e8f0' }, bordercolor:'rgba(0,212,255,0.15)' },
|
||||
}
|
||||
const axis = (title, extra = {}) => ({ title:{ text:title }, gridcolor:'#1e293b', color:'#475569', zeroline:false, ...extra })
|
||||
const pct = (v, d = 1) => (v == null ? '—' : (v * 100).toFixed(d) + '%')
|
||||
const fmt = (v, d = 3) => (v == null ? '—' : Number(v).toFixed(d))
|
||||
|
||||
// Default event/gap jump assumptions for manual mode (per year, log-jump mean & sd).
|
||||
const MANUAL_DEFAULTS = {
|
||||
metal: { jump_intensity: 2, jump_mean: -8, jump_sd: 8 }, // 2026 metals crashes: rare, large down-jumps
|
||||
fx: { jump_intensity: 2, jump_mean: -0.5, jump_sd: 1 },
|
||||
crypto: { jump_intensity: 12, jump_mean: -2, jump_sd: 6 },
|
||||
}
|
||||
|
||||
export default function JumpLab() {
|
||||
const { pair } = usePortfolioStore()
|
||||
const [mode, setMode] = useState(null)
|
||||
const live = mode ?? (isCrypto(pair) ? 'live' : 'manual')
|
||||
return (
|
||||
<div style={{ padding:16 }}>
|
||||
<div style={{ display:'flex', justifyContent:'space-between', alignItems:'flex-start', gap:12, marginBottom:12, flexWrap:'wrap' }}>
|
||||
<div>
|
||||
<div className="section-title">Jump Lab — Merton & Bates jump-diffusion ({pair})</div>
|
||||
<div style={{ fontSize:9, color:'var(--text-muted)', marginTop:2 }}>
|
||||
How much of the smile is jump risk vs stochastic vol · risk-neutral crash odds · feeds Hedge Lab
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display:'flex', gap:4 }}>
|
||||
{isCrypto(pair) && <Chip active={live === 'live'} onClick={() => setMode('live')}>Calibrate to Deribit</Chip>}
|
||||
<Chip active={live === 'manual'} onClick={() => setMode('manual')}>Manual Merton (event / gap risk)</Chip>
|
||||
</div>
|
||||
</div>
|
||||
{live === 'live' && isCrypto(pair) ? <Calibrated pair={pair} /> : <Manual key={pair} pair={pair} />}
|
||||
<div style={{ fontSize:9, color:'var(--text-muted)', marginTop:10, lineHeight:1.5 }}>
|
||||
Merton (1976) lognormal jumps priced by the Poisson-weighted Black series on the forward. Bates (1996) = Heston
|
||||
stochastic variance + Merton jumps, priced with the COS method (Fang & Oosterlee 2008) on the "little Heston
|
||||
trap" characteristic function (Albrecher et al. 2007). Calibration: bounded least squares on vega-weighted OTM
|
||||
prices across all Deribit expiries (≈ implied-vol errors). Densities from the same COS expansion.
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const Chip = ({ active, onClick, children }) => (
|
||||
<button onClick={onClick} className="font-mono" style={{ padding:'3px 9px', borderRadius:4, fontSize:9, cursor:'pointer',
|
||||
border:'1px solid ' + (active ? 'var(--cyan)' : 'var(--border)'), background: active ? 'rgba(0,212,255,0.1)' : 'transparent',
|
||||
color: active ? 'var(--cyan)' : 'var(--text-muted)' }}>{children}</button>
|
||||
)
|
||||
|
||||
function Card({ title, right, children }) {
|
||||
return (
|
||||
<div className="glass" style={{ borderRadius:8, padding:8 }}>
|
||||
<div style={{ display:'flex', justifyContent:'space-between', alignItems:'center', margin:'2px 6px 4px', gap:8, flexWrap:'wrap' }}>
|
||||
<span className="section-title" style={{ fontSize:10 }}>{title}</span>{right}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Kpis({ items }) {
|
||||
return (
|
||||
<div style={{ display:'flex', gap:8, marginBottom:12, flexWrap:'wrap' }}>
|
||||
{items.map(k => (
|
||||
<div key={k.label} className="glass" title={k.hint} style={{ padding:'6px 12px', borderRadius:6, fontSize:11 }}>
|
||||
<div style={{ color:'var(--text-muted)', fontSize:9, letterSpacing:'0.05em' }}>{k.label}</div>
|
||||
<div className="font-mono" style={{ color: k.color ?? 'var(--cyan)', fontWeight:700, fontSize:14 }}>{k.value}</div>
|
||||
{k.sub && <div className="font-mono" style={{ fontSize:9, color:'var(--text-muted)' }}>{k.sub}</div>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SendToHedge({ jump }) {
|
||||
const setHedgeJump = usePortfolioStore(s => s.setHedgeJump)
|
||||
const [done, setDone] = useState(false)
|
||||
return (
|
||||
<button className="btn-ghost" style={{ fontSize:10 }} onClick={() => {
|
||||
setHedgeJump({ intensity:+jump.lambda.toFixed(2), mean:+(jump.mu_j * 100).toFixed(2), sd:+(jump.delta * 100).toFixed(2) })
|
||||
setDone(true)
|
||||
}}>{done ? '✓ Sent — open Hedge Lab' : '⚖ Use these jumps in Hedge Lab'}</button>
|
||||
)
|
||||
}
|
||||
|
||||
function DensityChart({ d, title }) {
|
||||
return (
|
||||
<Card title={title}>
|
||||
<Plot data={[
|
||||
{ x:d.spot, y:d.density, type:'scatter', mode:'lines', name:'Model (risk-neutral)', fill:'tozeroy',
|
||||
line:{ color:'#00d4ff', width:2 }, fillcolor:'rgba(0,212,255,0.08)' },
|
||||
{ x:d.spot, y:d.lognormal, type:'scatter', mode:'lines', name:'Lognormal, same variance', line:{ color:'#ffd700', width:1.2, dash:'dash' } },
|
||||
]}
|
||||
layout={{ ...LAYOUT, hovermode:'x unified', xaxis:axis('Price at horizon'), yaxis:axis('Density', { showticklabels:false }) }}
|
||||
config={{ responsive:true, displayModeBar:false }} style={{ width:'100%', height:280 }} />
|
||||
<div className="font-mono" style={{ fontSize:10, color:'var(--text-muted)', padding:'0 8px 4px' }}>
|
||||
P(−20%): <span style={{ color:'var(--red)' }}>{pct(d.p_down_20)}</span> vs {pct(d.p_down_20_lognormal)} lognormal ·
|
||||
P(+20%): <span style={{ color:'var(--green)' }}>{pct(d.p_up_20)}</span> vs {pct(d.p_up_20_lognormal)} lognormal
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function Calibrated({ pair }) {
|
||||
const ccy = pair.slice(0, 3)
|
||||
const [nonce, setNonce] = useState(0)
|
||||
const [res, setRes] = useState({ key:null, data:null, error:null })
|
||||
const [idx, setIdx] = useState(null)
|
||||
const key = `${ccy}:${nonce}`
|
||||
useEffect(() => {
|
||||
let stale = false
|
||||
calibrateJumps(ccy)
|
||||
.then(data => { if (!stale) setRes({ key, data, error:null }) })
|
||||
.catch(e => { if (!stale) setRes({ key, data:null, error: e?.response?.data?.detail ?? e?.message ?? 'Calibration failed' }) })
|
||||
return () => { stale = true }
|
||||
}, [ccy, key])
|
||||
const loading = res.key !== key
|
||||
const d = res.data?.currency === ccy ? res.data : null
|
||||
if (res.error && !loading) return <ErrorBox msg={res.error} />
|
||||
if (!d) return (
|
||||
<div>
|
||||
<div style={{ fontSize:11, color:'var(--text-muted)', marginBottom:8 }}>Calibrating Merton and Bates to every Deribit {ccy} expiry…</div>
|
||||
<div className="skeleton" style={{ height:360, borderRadius:8 }}/>
|
||||
</div>
|
||||
)
|
||||
const b = d.fits.bates, m = d.fits.merton, sb = d.stats.bates
|
||||
const smiles = d.smiles
|
||||
const def = smiles.reduce((best, s, i) => Math.abs(s.days - 30) < Math.abs(smiles[best].days - 30) ? i : best, 0)
|
||||
const sel = smiles[idx ?? def]
|
||||
return (
|
||||
<div style={{ opacity: loading ? 0.6 : 1 }}>
|
||||
<Kpis items={[
|
||||
{ label:'Bates fit RMSE', value:`${(b.rmse_vol * 100).toFixed(2)} vol`, sub:`${b.n_quotes} quotes · all expiries` },
|
||||
{ label:'Merton fit RMSE', value:`${(m.rmse_vol * 100).toFixed(2)} vol`, color: m.rmse_vol > 2 * b.rmse_vol ? 'var(--amber)' : undefined,
|
||||
sub: m.rmse_vol > 2 * b.rmse_vol ? 'jumps alone miss the term structure' : 'jumps explain most of the smile' },
|
||||
{ label:'Jumps / year (Bates)', value:fmt(sb.jumps_per_year, 1), sub:`mean ${sb.mean_jump_pct.toFixed(1)}% · sd ${sb.jump_sd_pct.toFixed(1)}%` },
|
||||
{ label:'Jump share of variance', value:pct(sb.jump_variance_share, 0) },
|
||||
{ label:'P(≥1 jump < −10%, 30d)', value:pct(sb.p_crash_jump, 1), color:'var(--red)' },
|
||||
{ label:'Vol-of-vol ξ · spot-vol ρ', value:`${fmt(b.params.xi, 2)} · ${fmt(b.params.rho, 2)}`,
|
||||
sub:`κ ${fmt(b.params.kappa, 2)} · √θ ${pct(Math.sqrt(b.params.theta))}` },
|
||||
]} />
|
||||
<div style={{ display:'grid', gridTemplateColumns:'repeat(auto-fit, minmax(420px, 1fr))', gap:12, marginBottom:12 }}>
|
||||
<Card title={`${ccy} smile · ${sel.label} · ${sel.days.toFixed(1)}d`} right={
|
||||
<div style={{ display:'flex', gap:4, flexWrap:'wrap' }}>{smiles.map((s, i) =>
|
||||
<Chip key={s.label} active={s === sel} onClick={() => setIdx(i)}>{s.label}</Chip>)}</div>}>
|
||||
<Plot data={[
|
||||
{ x:sel.market.strikes, y:sel.market.iv.map(v => v * 100), type:'scatter', mode:'markers', name:'Deribit mark IV',
|
||||
marker:{ color:'#ffd700', size:6, line:{ color:'#0a0e1a', width:1 } } },
|
||||
{ x:sel.grid, y:sel.bates.map(v => v == null ? null : v * 100), type:'scatter', mode:'lines', name:`Bates (${(sel.rmse_bates * 100).toFixed(2)})`, line:{ color:'#00d4ff', width:2.5 } },
|
||||
{ x:sel.grid, y:sel.merton.map(v => v == null ? null : v * 100), type:'scatter', mode:'lines', name:`Merton (${(sel.rmse_merton * 100).toFixed(2)})`, line:{ color:'#c084fc', width:1.5, dash:'dash' } },
|
||||
]}
|
||||
layout={{ ...LAYOUT, xaxis:axis('Strike'), yaxis:axis('Implied vol (%)'),
|
||||
shapes:[{ type:'line', x0:sel.forward, x1:sel.forward, yref:'paper', y0:0, y1:1, line:{ color:'rgba(255,215,0,0.35)', dash:'dot', width:1 } }] }}
|
||||
config={{ responsive:true, displayModeBar:false }} style={{ width:'100%', height:300 }} />
|
||||
</Card>
|
||||
<DensityChart d={d.density_30d} title="30-day risk-neutral density (Bates) vs lognormal" />
|
||||
</div>
|
||||
<div style={{ display:'grid', gridTemplateColumns:'repeat(auto-fit, minmax(420px, 1fr))', gap:12 }}>
|
||||
<ParamTable b={b} m={m} smiles={smiles} />
|
||||
<Card title="Hand off" right={<button className="btn-ghost" style={{ fontSize:10 }} onClick={() => setNonce(n => n + 1)}>↻ Recalibrate</button>}>
|
||||
<div style={{ padding:'4px 8px', fontSize:11, color:'var(--text-muted)', lineHeight:1.6 }}>
|
||||
Send the calibrated Bates jump component (λ = {fmt(b.params.lambda, 2)}/yr, μ = {(b.params.mu_j * 100).toFixed(1)}%,
|
||||
δ = {(b.params.delta * 100).toFixed(1)}%) to Hedge Lab to stress your delta hedge with the jumps the options market is pricing.
|
||||
<div style={{ marginTop:8 }}><SendToHedge jump={b.params} /></div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ParamTable({ b, m, smiles }) {
|
||||
const rows = [
|
||||
['σ / √v₀ (diffusion vol)', pct(m.params.sigma), pct(Math.sqrt(b.params.v0))],
|
||||
['√θ (long-run vol)', '—', pct(Math.sqrt(b.params.theta))],
|
||||
['κ (mean reversion)', '—', fmt(b.params.kappa, 2)],
|
||||
['ξ (vol of vol)', '—', fmt(b.params.xi, 2)],
|
||||
['ρ (spot-vol corr)', '—', fmt(b.params.rho, 2)],
|
||||
['λ (jumps / yr)', fmt(m.params.lambda, 2), fmt(b.params.lambda, 2)],
|
||||
['μ_J (mean log jump)', pct(m.params.mu_j), pct(b.params.mu_j)],
|
||||
['δ (jump sd)', pct(m.params.delta), pct(b.params.delta)],
|
||||
['Fit RMSE (vol pts)', (m.rmse_vol * 100).toFixed(2), (b.rmse_vol * 100).toFixed(2)],
|
||||
]
|
||||
return (
|
||||
<Card title="Calibrated parameters">
|
||||
<table style={{ width:'100%', borderCollapse:'collapse', fontSize:10 }}>
|
||||
<thead><tr style={{ background:'var(--bg3)' }}>
|
||||
{['Parameter', 'Merton', 'Bates'].map((h, i) => <th key={h} style={{ padding:'6px 8px', fontSize:9, color:'var(--text-muted)', textAlign: i ? 'right' : 'left' }}>{h}</th>)}
|
||||
</tr></thead>
|
||||
<tbody>{rows.map(r => (
|
||||
<tr key={r[0]} style={{ borderTop:'1px solid var(--border)' }}>
|
||||
<td style={{ padding:'5px 8px' }}>{r[0]}</td>
|
||||
<td className="font-mono" style={{ padding:'5px 8px', textAlign:'right', color:'#c084fc' }}>{r[1]}</td>
|
||||
<td className="font-mono" style={{ padding:'5px 8px', textAlign:'right', color:'var(--cyan)' }}>{r[2]}</td>
|
||||
</tr>
|
||||
))}</tbody>
|
||||
</table>
|
||||
<div style={{ fontSize:9, color:'var(--text-muted)', padding:'6px 8px' }}>
|
||||
Per-expiry RMSE (Merton → Bates): {smiles.map(s => `${s.label} ${(s.rmse_merton * 100).toFixed(1)}→${(s.rmse_bates * 100).toFixed(1)}`).join(' · ')}
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function ErrorBox({ msg }) {
|
||||
return <div style={{ padding:8, borderRadius:6, fontSize:11, background:'rgba(255,61,90,0.1)', border:'1px solid #ff3d5a', color:'#ff3d5a' }}>{msg}</div>
|
||||
}
|
||||
|
||||
function Manual({ pair }) {
|
||||
const { S, sigma, T, r_d, r_f } = usePortfolioStore()
|
||||
const cls = PAIR_META[pair]?.assetClass ?? 'fx'
|
||||
const [p, setP] = useState(() => ({ sigma: +(sigma * 100 * 0.9).toFixed(2), T: +T, ...MANUAL_DEFAULTS[cls] }))
|
||||
const [res, setRes] = useState({ key:null, data:null, error:null })
|
||||
const body = { pair, S, r_d, r_f, T:p.T, sigma:p.sigma / 100, jump_intensity:p.jump_intensity,
|
||||
jump_mean:p.jump_mean / 100, jump_sd:p.jump_sd / 100 }
|
||||
const key = JSON.stringify(body)
|
||||
useEffect(() => {
|
||||
let stale = false
|
||||
priceMerton(JSON.parse(key))
|
||||
.then(data => { if (!stale) setRes({ key, data, error:null }) })
|
||||
.catch(e => { if (!stale) setRes({ key, data:null, error: e?.response?.data?.detail?.toString() ?? 'Pricing failed' }) })
|
||||
return () => { stale = true }
|
||||
}, [key])
|
||||
const d = res.data
|
||||
const field = (label, k, step) => (
|
||||
<label key={k} style={{ display:'flex', flexDirection:'column', gap:3, fontSize:9, color:'var(--text-muted)' }}>{label}
|
||||
<input type="number" value={p[k]} step={step} onChange={e => setP(q => ({ ...q, [k]: +e.target.value }))}
|
||||
style={{ background:'var(--bg3)', border:'1px solid var(--border)', borderRadius:4, color:'var(--cyan)', fontSize:11,
|
||||
padding:'4px 6px', fontFamily:'var(--font-mono)', width:'100%' }} />
|
||||
</label>
|
||||
)
|
||||
return (
|
||||
<div>
|
||||
<div className="glass" style={{ borderRadius:8, padding:10, marginBottom:12 }}>
|
||||
<div style={{ display:'grid', gridTemplateColumns:'repeat(auto-fill, minmax(130px, 1fr))', gap:10, alignItems:'end' }}>
|
||||
{field('Diffusion σ (%)', 'sigma', 0.5)}
|
||||
{field('Tenor (years)', 'T', 0.05)}
|
||||
{field('Jumps / year', 'jump_intensity', 0.5)}
|
||||
{field('Mean jump (%)', 'jump_mean', 0.5)}
|
||||
{field('Jump sd (%)', 'jump_sd', 0.5)}
|
||||
{d && <SendToHedge jump={{ lambda:p.jump_intensity, mu_j:p.jump_mean / 100, delta:p.jump_sd / 100 }} />}
|
||||
</div>
|
||||
<div style={{ fontSize:9, color:'var(--text-muted)', marginTop:6 }}>
|
||||
Model event and gap risk (CPI/FOMC days, weekend gaps, squeezes) that no listed-option feed is available for —
|
||||
e.g. silver's 2026 −25% day. Spot, rates and tenor come from the portfolio.
|
||||
</div>
|
||||
</div>
|
||||
{res.error && <ErrorBox msg={res.error} />}
|
||||
{d && (
|
||||
<>
|
||||
<Kpis items={[
|
||||
{ label:'ATM vol', value:pct(d.atm_vol, 2), sub:`total vol ${pct(d.total_vol)}` },
|
||||
{ label:'≈25Δ risk reversal', value:`${(d.rr_approx * 100).toFixed(2)} vol`, color: d.rr_approx < 0 ? 'var(--red)' : 'var(--green)' },
|
||||
{ label:'≈25Δ butterfly', value:`${(d.bf_approx * 100).toFixed(2)} vol` },
|
||||
{ label:`P(≥1 jump < −10%, ${d.stats.horizon_days}d)`, value:pct(d.stats.p_crash_jump, 1), color:'var(--red)' },
|
||||
{ label:'Jump share of variance', value:pct(d.stats.jump_variance_share, 0) },
|
||||
]} />
|
||||
<div style={{ display:'grid', gridTemplateColumns:'repeat(auto-fit, minmax(420px, 1fr))', gap:12 }}>
|
||||
<Card title={`Merton smile · ${pair} · T = ${p.T}y`}>
|
||||
<Plot data={[{ x:d.strikes, y:d.iv.map(v => v == null ? null : v * 100), type:'scatter', mode:'lines',
|
||||
line:{ color:'#00d4ff', width:2.5 }, name:'Merton' },
|
||||
{ x:[d.strikes[0], d.strikes.at(-1)], y:[p.sigma, p.sigma], type:'scatter', mode:'lines',
|
||||
line:{ color:'rgba(255,255,255,0.25)', dash:'dash', width:1 }, name:'Diffusion σ' }]}
|
||||
layout={{ ...LAYOUT, xaxis:axis('Strike'), yaxis:axis('Implied vol (%)'),
|
||||
shapes:[{ type:'line', x0:d.forward, x1:d.forward, yref:'paper', y0:0, y1:1, line:{ color:'rgba(255,215,0,0.35)', dash:'dot', width:1 } }] }}
|
||||
config={{ responsive:true, displayModeBar:false }} style={{ width:'100%', height:280 }} />
|
||||
</Card>
|
||||
<DensityChart d={d.density} title={`Risk-neutral density at T = ${p.T}y vs lognormal`} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import VolLab from '../components/VolLab'
|
||||
import VolForecast from '../components/VolForecast'
|
||||
import HedgeLab from '../components/HedgeLab'
|
||||
import CrossAsset from '../components/CrossAsset'
|
||||
import JumpLab from '../components/JumpLab'
|
||||
|
||||
const IVSurface = lazy(() => import('../components/IVSurface'))
|
||||
const WorldMap = lazy(() => import('../components/WorldMap'))
|
||||
@@ -46,6 +47,7 @@ const TABS = [
|
||||
{ id:'vollab', icon:'🔬', label:'Vol Lab', component: VolLab },
|
||||
{ id:'volforecast', icon:'🤖', label:'Vol Forecast', component: VolForecast },
|
||||
{ id:'hedgelab', icon:'⚖', label:'Hedge Lab', component: HedgeLab },
|
||||
{ id:'jumps', icon:'⚡', label:'Jump Lab', component: JumpLab },
|
||||
{ id:'xasset', icon:'🔗', label:'Cross-Asset', component: CrossAsset },
|
||||
{ id:'smile', icon:'〰', label:'Vol Smile', component: VolSmile },
|
||||
{ id:'surfaces', icon:'🗻', label:'3D Surfaces', component: SurfacePlot },
|
||||
|
||||
@@ -45,12 +45,15 @@ export const usePortfolioStore = create((set, get) => ({
|
||||
// Vol quotes (ATM / 25Δ RR / 25Δ BF, in vol points) handed from Vol Lab to Vol Smile.
|
||||
smileQuotes: null,
|
||||
setSmileQuotes: (smileQuotes) => set({ smileQuotes }),
|
||||
// Jump parameters (intensity /yr, mean %, sd %) handed from Jump Lab to Hedge Lab.
|
||||
hedgeJump: null,
|
||||
setHedgeJump: (hedgeJump) => set({ hedgeJump }),
|
||||
|
||||
setPair: (pair) => {
|
||||
const m = PAIR_META[pair] || meta
|
||||
// Keep each leg's moneyness (K/S) so strikes stay meaningful across very different price levels.
|
||||
set(s => ({
|
||||
pair, S: m.defaultS, r_d: m.r_d, r_f: m.r_f, sigma: m.sigma ?? s.sigma, smileQuotes: null,
|
||||
pair, S: m.defaultS, r_d: m.r_d, r_f: m.r_f, sigma: m.sigma ?? s.sigma, smileQuotes: null, hedgeJump: null,
|
||||
legs: s.legs.map(l => ({ ...l, K: +(l.K / s.S * m.defaultS).toPrecision(6) })),
|
||||
}))
|
||||
},
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 382 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 223 KiB |
Reference in New Issue
Block a user