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:
Claude
2026-10-07 05:27:12 +00:00
parent 042a11dc3f
commit cb1723b6f2
12 changed files with 840 additions and 9 deletions
+1
View File
@@ -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 | | **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 | | **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 | | **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 ### Live Intelligence
+297
View File
@@ -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
View File
@@ -1,6 +1,6 @@
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware 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", app = FastAPI(title="QuantRisk FX Terminal API",
description="Forex options risk analytics — Garman-Kohlhagen model.", 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=["*"], app.add_middleware(CORSMiddleware, allow_origins=["*"],
allow_credentials=True, allow_methods=["*"], allow_headers=["*"]) 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.include_router(router.router, prefix="/api")
@app.get("/") @app.get("/")
+119
View File
@@ -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
+121
View File
@@ -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
+4
View File
@@ -33,6 +33,10 @@ export const simulateHedge = (p) => api.post('/hedge/simulate', p).then(r=>r.dat
// Cross-asset desk // Cross-asset desk
export const fetchCrossAsset = () => api.get('/xasset').then(r=>r.data) export const fetchCrossAsset = () => api.get('/xasset').then(r=>r.data)
export const priceMargrabe = (p) => api.post('/xasset/margrabe', p).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) export const exportCSV = (p) => api.post('/export/csv', p, {responseType:'blob'}).then(r=>r.data)
// Institutional flow // Institutional flow
+6 -6
View File
@@ -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 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 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), sigma_realised: +(sigma * 100).toFixed(2),
model: isCrypto(pair) ? 'jump' : 'gbm', model: hedgeJump || isCrypto(pair) ? 'jump' : 'gbm',
steps_per_day: isCrypto(pair) ? 4 : 1, steps_per_day: isCrypto(pair) ? 4 : 1,
rule: 'time', band: 0.05, cost_bps: isCrypto(pair) ? 3 : 1, n_paths: 2000, 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, weekend_gap: PAIR_META[pair]?.assetClass === 'metal' ? 1.0 : 0.3,
rescale_bootstrap: true, rescale_bootstrap: true,
}) })
export default function HedgeLab() { export default function HedgeLab() {
const { legs, S, sigma, r_d, r_f, pair } = usePortfolioStore() const { legs, S, sigma, r_d, r_f, pair, hedgeJump } = usePortfolioStore()
const [cfg, setCfg] = useState(() => ({ pair, ...defaultsFor(pair, sigma) })) const [cfg, setCfg] = useState(() => ({ pair, ...defaultsFor(pair, sigma, hedgeJump) }))
const [run, setRun] = useState(0) const [run, setRun] = useState(0)
const [response, setResponse] = useState({ key:null, data:null, error:null }) const [response, setResponse] = useState({ key:null, data:null, error:null })
const [fcNote, setFcNote] = useState(null) const [fcNote, setFcNote] = useState(null)
// Reset scenario defaults when the instrument changes (derived, no effect needed). // 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 set = (k, v) => setCfg({ ...params, [k]: v })
const crypto = isCrypto(pair) const crypto = isCrypto(pair)
const maxT = legs.length ? Math.max(...legs.map(l => +l.T)) : 0 const maxT = legs.length ? Math.max(...legs.map(l => +l.T)) : 0
+284
View File
@@ -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 &amp; 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 &amp; Oosterlee 2008) on the &quot;little Heston
trap&quot; 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&apos;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>
)
}
+2
View File
@@ -13,6 +13,7 @@ import VolLab from '../components/VolLab'
import VolForecast from '../components/VolForecast' import VolForecast from '../components/VolForecast'
import HedgeLab from '../components/HedgeLab' import HedgeLab from '../components/HedgeLab'
import CrossAsset from '../components/CrossAsset' import CrossAsset from '../components/CrossAsset'
import JumpLab from '../components/JumpLab'
const IVSurface = lazy(() => import('../components/IVSurface')) const IVSurface = lazy(() => import('../components/IVSurface'))
const WorldMap = lazy(() => import('../components/WorldMap')) const WorldMap = lazy(() => import('../components/WorldMap'))
@@ -46,6 +47,7 @@ const TABS = [
{ id:'vollab', icon:'🔬', label:'Vol Lab', component: VolLab }, { id:'vollab', icon:'🔬', label:'Vol Lab', component: VolLab },
{ id:'volforecast', icon:'🤖', label:'Vol Forecast', component: VolForecast }, { id:'volforecast', icon:'🤖', label:'Vol Forecast', component: VolForecast },
{ id:'hedgelab', icon:'⚖', label:'Hedge Lab', component: HedgeLab }, { 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:'xasset', icon:'🔗', label:'Cross-Asset', component: CrossAsset },
{ id:'smile', icon:'〰', label:'Vol Smile', component: VolSmile }, { id:'smile', icon:'〰', label:'Vol Smile', component: VolSmile },
{ id:'surfaces', icon:'🗻', label:'3D Surfaces', component: SurfacePlot }, { id:'surfaces', icon:'🗻', label:'3D Surfaces', component: SurfacePlot },
+4 -1
View File
@@ -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. // Vol quotes (ATM / 25Δ RR / 25Δ BF, in vol points) handed from Vol Lab to Vol Smile.
smileQuotes: null, smileQuotes: null,
setSmileQuotes: (smileQuotes) => set({ smileQuotes }), 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) => { setPair: (pair) => {
const m = PAIR_META[pair] || meta const m = PAIR_META[pair] || meta
// Keep each leg's moneyness (K/S) so strikes stay meaningful across very different price levels. // Keep each leg's moneyness (K/S) so strikes stay meaningful across very different price levels.
set(s => ({ 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) })), 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