feat: Smile Dynamics — SABR fits, Bartlett/sticky deltas, spot-vol regime

SABR (Hagan with Obloj leading term) per Deribit expiry or from ATM/RR/BF
quotes; forward deltas under sticky strike, sticky delta, SABR and Bartlett;
backbones vs the realised spot-vol beta from DVOL/GVZ/VXSLV with a regime
call and rolling correlation.

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-21 05:11:24 +00:00
parent 3a29553495
commit e957475a66
10 changed files with 642 additions and 2 deletions
+1
View File
@@ -47,6 +47,7 @@ NEXUS TERMINAL is a Bloomberg-style web application for professional forex optio
| **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 |
| **Event Vol** | Event & weekend "vol time" term structure: strips FOMC / CPI / NFP variance lumps and the weekend weight out of the ATM curve (live Deribit fit for BTC/ETH; assumptions or pasted broker quotes for XAU/XAG/EUR/any pair), event-implied moves and straddle breakevens, forward vols and calendar-arbitrage flags, fair vol for any expiry incl. 0DTE/1-day, built-in 2026 FOMC/CPI/NFP calendar merged with live Forex Factory events, realised day-of-week / weekend-weight / FOMC-lump study |
| **Positioning** | Live BTC / ETH options positioning from Deribit open interest: dealer gamma exposure (USD per 1% move) with switchable dealer-side convention, gamma profile across spot and gamma-flip level, call / put walls, vanna and charm flows, dealer delta, put/call, and per-expiry max pain, ATM IV and implied 1σ move |
| **Smile Dynamics** | SABR (Hagan 2002 + Obłój 2008) fitted per live Deribit expiry or to ATM / RR / BF quotes for any pair; hedge ratios under sticky strike, sticky delta, SABR and Bartlett (variance-optimal) dynamics at 10Δ–10Δ; model backbones (ATM vol change per 1% spot) vs the realised spot-vol beta from DVOL / GVZ / VXSLV with rolling correlation, and a regime call; ρ / ν term structure |
| **Exotics Desk** | Structured-products pricer: all eight single barriers (Reiner-Rubinstein/Haug, rebates) with Broadie-Glasserman-Kou discrete-monitoring correction, barrier price/delta profile through the barrier, touch ladder of hit probabilities, smile-consistent digitals (−∂C/∂K on the vanna-volga smile); Dual Currency Investment / crypto "Dual Investment" fair-APR ladder with an offer audit (platform margin, implied vol you are paid); KO accumulator Monte Carlo with zero-cost strike, KO odds and geared-loss tail, optional Merton jumps |
| **TARF Desk** | Target redemption forwards (importer buys / exporter sells, leverage, full / exact / no-payout target) priced side by side under flat vol, Dupire local vol from an arbitrage-free SSVI surface calibrated to ATM / RR / BF, and Merton jumps: zero-cost strike, model-risk spread, knock-out timing and survival, expected cash flows per fixing, client P&L distribution and ES vs the capped gain, delta / vega |
+167
View File
@@ -0,0 +1,167 @@
"""
smile_dynamics.py — SABR smiles and smile-consistent hedge ratios.
When spot moves, how does the smile move? The answer sets the hedge ratio of every option:
sticky strike — σ(K) fixed: Δ = Δ_BS(σ(K))
sticky delta — σ fixed in moneyness K/F: Δ = Δ_BS − vega · σ'(K) · K/F
SABR — α fixed, F moves: Δ = Δ_BS + vega · ∂σ/∂F
Bartlett — α co-moves with F (ρ): Δ = Δ_BS + vega · (∂σ/∂F + ∂σ/∂α · ρν/F^β)
Bartlett's delta (Bartlett 2006, Wilmott) uses the correlated part of the vol move and is the
variance-optimal hedge in SABR (Keller-Ressel 2022, arXiv:2207.13573); it is also nearly
independent of β (Hagan & Lesniewski 2017).
Each rule implies a different "backbone" — how ATM vol moves per 1 % spot move — which is
compared with the empirical spot-vol beta from the implied-vol index (Deribit DVOL, Cboe GVZ /
VXSLV): d ln IV = a + b · d ln S. A negative b with a put skew is the equity-like regime BTC
moved into in 2025–26; a positive b is the 2023–24 "spot-up, vol-up" regime.
SABR implied vol (Hagan, Kumar, Lesniewski & Woodward 2002) with the Obłój (2008) leading term:
σ(K) = ν ln(F/K) / x(z) · [1 + ((1−β)² α² / (24 (FK)^{1−β}) + ρβνα / (4 (FK)^{(1−β)/2})
+ (2 − 3ρ²) ν² / 24) T]
z = ν (F^{1−β} − K^{1−β}) / (α (1−β)) (β<1), z = ν ln(F/K) / α (β=1)
x(z) = ln[(√(1 − 2ρz + z²) + z − ρ) / (1 − ρ)]
"""
import numpy as np
from scipy.optimize import least_squares
from scipy.stats import norm
def sabr_vol(K, F, T, alpha, beta, rho, nu):
K = np.asarray(K, float)
lfk = np.log(F / K)
if abs(1 - beta) < 1e-12:
z = nu * lfk / alpha
else:
z = nu * (F ** (1 - beta) - K ** (1 - beta)) / (alpha * (1 - beta))
sq = np.sqrt(1 - 2 * rho * z + z * z)
xz = np.log((sq + z - rho) / (1 - rho))
small = np.abs(z) < 1e-7
with np.errstate(divide="ignore", invalid="ignore"):
lead = np.where(small, 0.0, nu * lfk / np.where(small, 1.0, xz))
# ATM / small-z limit: ν ln(F/K)/x(z) → α · ln(F/K)/(F^{1−β} − K^{1−β}) · (1−β) → α / F^{1−β}
if abs(1 - beta) < 1e-12:
atm_lead = np.full_like(K, alpha)
else:
fk = (F * K) ** ((1 - beta) / 2)
atm_lead = alpha / fk
lead = np.where(small, atm_lead * (1 + rho * z / 2), lead)
fkb = (F * K) ** (1 - beta)
corr = 1 + ((1 - beta) ** 2 * alpha ** 2 / (24 * fkb) + rho * beta * nu * alpha / (4 * np.sqrt(fkb))
+ (2 - 3 * rho ** 2) * nu ** 2 / 24) * T
return lead * corr
def calibrate_sabr(strikes, vols, F, T, beta=1.0, weights=None):
"""Least-squares fit of (α, ρ, ν) for fixed β. Returns params and fit error (vol)."""
K = np.asarray(strikes, float)
v = np.asarray(vols, float)
w = np.ones_like(v) if weights is None else np.asarray(weights, float)
atm = float(np.interp(F, K[np.argsort(K)], v[np.argsort(K)]))
a0 = atm * F ** (1 - beta)
def resid(p):
a, r, n = p
return np.sqrt(w) * (sabr_vol(K, F, T, a, beta, r, n) - v)
best = None
for r0 in (-0.5, 0.0, 0.4):
for n0 in (0.3, 1.0, 2.0):
sol = least_squares(resid, [a0, r0, n0], bounds=([a0 * 0.2, -0.999, 1e-3], [a0 * 5, 0.999, 10.0]))
if best is None or sol.cost < best.cost:
best = sol
a, r, n = best.x
err = sabr_vol(K, F, T, a, beta, r, n) - v
return {"alpha": float(a), "beta": float(beta), "rho": float(r), "nu": float(n),
"rmse_vol": float(np.sqrt(np.mean(err ** 2))), "max_err_vol": float(np.max(np.abs(err)))}
def _bs(F, K, T, sig, call):
sq = sig * np.sqrt(T)
d1 = (np.log(F / K) + 0.5 * sq * sq) / sq
delta = norm.cdf(d1) if call else norm.cdf(d1) - 1
vega = F * norm.pdf(d1) * np.sqrt(T)
return delta, vega
def hedge_ratios(K, F, T, p, call=True):
"""Forward deltas under the four smile-dynamics rules (per unit of forward)."""
a, b, r, n = p["alpha"], p["beta"], p["rho"], p["nu"]
sig = float(sabr_vol(K, F, T, a, b, r, n))
d_bs, vega = _bs(F, K, T, sig, call)
hF, hK, ha = F * 1e-4, K * 1e-4, a * 1e-4
dsdF = (sabr_vol(K, F + hF, T, a, b, r, n) - sabr_vol(K, F - hF, T, a, b, r, n)) / (2 * hF)
dsdK = (sabr_vol(K + hK, F, T, a, b, r, n) - sabr_vol(K - hK, F, T, a, b, r, n)) / (2 * hK)
dsda = (sabr_vol(K, F, T, a + ha, b, r, n) - sabr_vol(K, F, T, a - ha, b, r, n)) / (2 * ha)
sticky_delta = d_bs - vega * dsdK * K / F
sabr = d_bs + vega * dsdF
bartlett = sabr + vega * dsda * r * n / F ** b
return {"strike": float(K), "vol": sig, "bs_sticky_strike": float(d_bs), "sticky_delta": float(sticky_delta),
"sabr": float(sabr), "bartlett": float(bartlett), "vega": float(vega),
"bartlett_minus_bs": float(bartlett - d_bs)}
def strike_for_delta(F, T, p, target, call=True):
"""Strike with BS forward delta = target on the SABR smile (bisection on log-moneyness)."""
lo, hi = np.log(F) - 8 * p["alpha"] * F ** (p["beta"] - 1) * np.sqrt(T), np.log(F) + 8 * p["alpha"] * F ** (p["beta"] - 1) * np.sqrt(T)
for _ in range(80):
mid = 0.5 * (lo + hi)
K = np.exp(mid)
d, _ = _bs(F, K, T, float(sabr_vol(K, F, T, p["alpha"], p["beta"], p["rho"], p["nu"])), call)
# Call delta falls as K rises; |put delta| rises as K rises.
if (d > target) if call else (abs(d) < target):
lo = mid
else:
hi = mid
return float(np.exp(0.5 * (lo + hi)))
def backbone(F, T, p):
"""ATM-vol change (vol points) per +1 % forward move under each rule."""
a, b, r, n = p["alpha"], p["beta"], p["rho"], p["nu"]
atm = float(sabr_vol(F, F, T, a, b, r, n))
F1 = F * 1.01
sticky_strike = float(sabr_vol(F1, F, T, a, b, r, n)) - atm # old smile read at the new ATM strike
sabr = float(sabr_vol(F1, F1, T, a, b, r, n)) - atm # α fixed
# Bartlett: α co-moves with F, E[dα | dF] = ρ ν dF / F^β (from dF = α F^β dW, dα = ν α dZ).
a1 = a + r * n / F ** b * (F1 - F)
bartlett = float(sabr_vol(F1, F1, T, a1, b, r, n)) - atm
return {"atm_vol": atm, "sticky_strike": sticky_strike * 100, "sticky_delta": 0.0,
"sabr": sabr * 100, "bartlett": bartlett * 100}
def empirical_beta(dates, closes, iv_dates, iv_levels, window=90):
"""Regress d ln IV on d ln S (daily, common dates). Returns full-sample and rolling stats."""
iv = {str(d)[:10]: float(v) for d, v in zip(iv_dates, iv_levels) if v and v > 0}
px = {str(d)[:10]: float(c) for d, c in zip(dates, closes)}
common = sorted(set(iv) & set(px))
if len(common) < window + 10:
return None
s = np.log([px[d] for d in common])
v = np.log([iv[d] for d in common])
r, dv = np.diff(s), np.diff(v)
iv_last = np.exp(v[-1])
def fit(x, y):
b = np.cov(x, y)[0, 1] / np.var(x, ddof=1)
return float(b), float(np.corrcoef(x, y)[0, 1])
b_all, c_all = fit(r, dv)
b_w, c_w = fit(r[-window:], dv[-window:])
roll = []
for i in range(window, len(r) + 1, 5):
b, c = fit(r[i - window:i], dv[i - window:i])
roll.append({"date": common[i], "beta": b, "corr": c})
# Vol points of the index per +1 % spot move (beta is in log terms).
return {"beta_full": b_all, "corr_full": c_all, "beta_recent": b_w, "corr_recent": c_w,
"volpts_per_1pct_recent": float(b_w * 0.01 * iv_last), "iv_last": float(iv_last),
"window": window, "rolling": roll, "n": int(len(r))}
def classify_regime(model_bb, emp_volpts):
"""Which smile-dynamics rule the realised spot-vol beta is closest to."""
if emp_volpts is None:
return None
cands = {k: model_bb[k] for k in ("sticky_strike", "sticky_delta", "bartlett")}
best = min(cands, key=lambda k: abs(cands[k] - emp_volpts))
return {"closest": best, "distance_volpts": float(abs(cands[best] - emp_volpts))}
+2 -2
View File
@@ -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, jumps, exotics, eventvol, risk, tarf, deephedge, positioning
from .routers import greeks, surface, market, montecarlo, scenarios, strategies, export, forex, institutional, news, livedata, smile, vol, hedge, xasset, jumps, exotics, eventvol, risk, tarf, deephedge, positioning, smiledyn
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, jumps, exotics, eventvol, risk, tarf, deephedge, positioning]:
for router in [greeks, surface, market, montecarlo, scenarios, strategies, export, forex, institutional, news, livedata, smile, vol, hedge, xasset, jumps, exotics, eventvol, risk, tarf, deephedge, positioning, smiledyn]:
app.include_router(router.router, prefix="/api")
@app.get("/")
+122
View File
@@ -0,0 +1,122 @@
"""
smiledyn.py — Smile Dynamics desk: SABR fits (live Deribit per expiry for BTC/ETH, or built
from ATM / 25Δ RR / BF quotes for any pair), smile-consistent hedge ratios (sticky strike,
sticky delta, SABR, Bartlett) and the empirical spot-vol beta from the implied-vol index.
"""
import numpy as np
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, Field
from ..core.smile_dynamics import (backbone, calibrate_sabr, classify_regime, empirical_beta,
hedge_ratios, sabr_vol, strike_for_delta)
from ..core.vanna_volga import pillars, vv_vol
from .forex import PAIRS
router = APIRouter(prefix="/smiledyn", tags=["smiledyn"])
DELTAS = [(0.10, False, "10Δ put"), (0.25, False, "25Δ put"), (0.50, True, "ATM (50Δ)"),
(0.25, True, "25Δ call"), (0.10, True, "10Δ call")]
def _empirical(pair):
"""Spot-vol beta from the vol index (DVOL / GVZ / VXSLV); None if unavailable."""
try:
from .eventvol import _load_history
from .risk import _iv_history
data = _load_history(pair)
d, lv, label = _iv_history(pair)
if not data or d is None:
return None
emp = empirical_beta(data["dates"], data["c"], d, lv)
if emp:
emp["index"] = label
return emp
except Exception:
return None
def _expiry_report(F, T, strikes, vols, beta, label, days):
p = calibrate_sabr(strikes, vols, F, T, beta)
grid = F * np.exp(np.linspace(np.log(min(strikes) / F), np.log(max(strikes) / F), 60))
rows = []
for d, call, name in DELTAS:
K = strike_for_delta(F, T, p, d, call)
rows.append({"label": name, "call": call, **hedge_ratios(K, F, T, p, call)})
return {"label": label, "days": days, "T": T, "forward": F, "params": p,
"market": {"strikes": list(map(float, strikes)), "iv": list(map(float, vols))},
"fit": {"strikes": grid.tolist(), "iv": sabr_vol(grid, F, T, p["alpha"], p["beta"], p["rho"], p["nu"]).tolist()},
"deltas": rows, "backbone": backbone(F, T, p)}
@router.get("/crypto/{currency}")
def crypto(currency: str, beta: float = 1.0):
currency = currency.upper()
if currency not in ("BTC", "ETH"):
raise HTTPException(404, "Live SABR fits are available for BTC and ETH (Deribit).")
if not 0 <= beta <= 1:
raise HTTPException(422, "beta must be in [0, 1].")
from .vol import get_crypto_vol
try:
surf = get_crypto_vol(currency)
except HTTPException as e:
raise HTTPException(503, f"Deribit surface unavailable: {e.detail}")
exps = [e for e in surf["expiries"] if 2 <= e["days"] <= 200 and len(e["market"]["strikes"]) >= 5][:6]
if not exps:
raise HTTPException(503, "No Deribit expiries with enough quotes.")
out = [_expiry_report(e["forward"], e["T"], e["market"]["strikes"], e["market"]["iv"], beta, e["label"], e["days"])
for e in exps]
emp = _empirical(f"{currency}USD")
ref = min(out, key=lambda r: abs(r["days"] - 30))
return _clean({"pair": f"{currency}USD", "source": "Deribit mark IVs (OTM) per expiry", "beta": beta,
"expiries": out, "reference": ref["label"], "empirical": emp,
"regime": classify_regime(ref["backbone"], emp["volpts_per_1pct_recent"] if emp else None),
"spot": surf.get("index_price")})
class QuoteRequest(BaseModel):
pair: str = "XAUUSD"
S: float = Field(..., gt=0)
T: float = Field(default=0.25, gt=0.005, le=3)
r_d: float = 0.04
r_f: float = 0.0
atm: float = Field(..., gt=0.005, lt=3)
rr25: float = Field(default=0.0, gt=-0.5, lt=0.5)
bf25: float = Field(default=0.003, ge=-0.05, lt=0.5)
beta: float = Field(default=1.0, ge=0, le=1)
@router.post("/quotes")
def from_quotes(req: QuoteRequest):
"""SABR fitted to a vanna-volga smile built from ATM / RR / BF, for any pair."""
pair = req.pair.upper()
if pair not in PAIRS:
raise HTTPException(404, f"Unknown pair '{pair}'.")
try:
pl = pillars(req.S, req.T, req.r_d, req.r_f, req.atm, req.rr25, req.bf25)
except ValueError as e:
raise HTTPException(422, str(e))
F = req.S * np.exp((req.r_d - req.r_f) * req.T)
lo, hi = pl[0]["K"], pl[2]["K"]
span = np.log(hi / lo)
strikes = np.exp(np.linspace(np.log(lo) - 0.6 * span, np.log(hi) + 0.6 * span, 13))
vols = vv_vol(strikes, req.S, req.T, req.r_d, req.r_f, pl)
rep = _expiry_report(F, req.T, strikes, vols, req.beta, f"{req.T * 12:.1f}M", req.T * 365)
emp = _empirical(pair)
return _clean({"pair": pair, "source": "Vanna-volga smile from ATM / 25Δ RR / 25Δ BF quotes", "beta": req.beta,
"expiries": [rep], "reference": rep["label"], "empirical": emp,
"regime": classify_regime(rep["backbone"], emp["volpts_per_1pct_recent"] if emp else None),
"spot": req.S})
def _clean(obj):
if isinstance(obj, (bool, np.bool_)):
return bool(obj)
if isinstance(obj, (float, np.floating)):
x = float(obj)
return round(x, 8) if np.isfinite(x) else None
if isinstance(obj, dict):
return {str(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
+97
View File
@@ -0,0 +1,97 @@
"""Smile Dynamics: SABR formula limits & MC check, calibration, delta identities, backbone, regime, endpoints."""
from datetime import date, timedelta
import numpy as np
import pytest
from fastapi.testclient import TestClient
from scipy.stats import norm
from app.core.smile_dynamics import (backbone, calibrate_sabr, classify_regime, empirical_beta, hedge_ratios,
sabr_vol, strike_for_delta)
from app.core.vol_analytics import build_crypto_surface
from app.main import app
from app.routers import eventvol as ev_router
from app.routers import risk as risk_router
from app.routers import vol as vol_router
from tests.fixtures import NOW_MS, deribit_book
client = TestClient(app)
def test_sabr_limits():
K = np.array([80.0, 100, 120])
assert np.allclose(sabr_vol(K, 100, 1, 0.2, 1.0, -0.3, 1e-8), 0.2, atol=1e-7) # ν → 0: flat lognormal
near = sabr_vol(np.array([100 * (1 - 1e-7), 100.0, 100 * (1 + 1e-7)]), 100, 0.5, 0.3, 0.5, -0.4, 0.8)
assert np.ptp(near) < 1e-6 # continuous at the money
skew = sabr_vol(K, 100, 1, 0.2, 1.0, -0.5, 0.6)
assert skew[0] > skew[2] # ρ < 0 → put skew
def test_sabr_matches_monte_carlo():
rng = np.random.default_rng(0)
n, steps, T, a, r, nu, F0 = 150000, 200, 0.25, 0.5, -0.3, 0.6, 60000.0
dt = T / steps
f, al = np.full(n, F0), np.full(n, a)
for _ in range(steps):
z1 = rng.standard_normal(n)
z2 = r * z1 + np.sqrt(1 - r * r) * rng.standard_normal(n)
f *= np.exp(al * np.sqrt(dt) * z1 - 0.5 * al * al * dt)
al *= np.exp(nu * np.sqrt(dt) * z2 - 0.5 * nu * nu * dt)
for K in (48000.0, 60000.0, 72000.0):
sv = float(sabr_vol(K, F0, T, a, 1.0, r, nu))
sq = sv * np.sqrt(T)
d1 = (np.log(F0 / K) + 0.5 * sq * sq) / sq
assert np.maximum(f - K, 0).mean() == pytest.approx(F0 * norm.cdf(d1) - K * norm.cdf(d1 - sq), rel=0.01)
def test_calibration_roundtrip_and_delta_identities():
F, T = 60000.0, 0.25
K = F * np.exp(np.linspace(-0.5, 0.5, 15))
p = calibrate_sabr(K, sabr_vol(K, F, T, 0.55, 1.0, -0.25, 1.3), F, T)
assert (p["alpha"], p["rho"], p["nu"]) == pytest.approx((0.55, -0.25, 1.3), rel=1e-4)
Kc = strike_for_delta(F, T, p, 0.25, True)
h = hedge_ratios(Kc, F, T, p, True)
assert h["bs_sticky_strike"] == pytest.approx(0.25, abs=1e-6)
assert h["sabr"] == pytest.approx(h["sticky_delta"], abs=1e-6) # β = 1 SABR is sticky-moneyness
assert h["bartlett"] < h["sabr"] # ρ < 0 lowers call deltas
pz = {**p, "rho": 0.0}
hz = hedge_ratios(Kc, F, T, pz, True)
assert hz["bartlett"] == pytest.approx(hz["sabr"], abs=1e-9) # ρ = 0 → no α-correction
bb = backbone(F, T, p)
assert bb["sabr"] == pytest.approx(0, abs=1e-9) and bb["bartlett"] < 0 and bb["sticky_strike"] < 0
def test_empirical_beta_and_regime():
rng = np.random.default_rng(5)
n = 600
r = rng.normal(0, 0.03, n)
dlv = -0.8 * r + rng.normal(0, 0.02, n) # equity-like: vol up on sell-offs
dates = [(date(2024, 1, 1) + timedelta(days=i)).isoformat() for i in range(n + 1)]
emp = empirical_beta(dates, 60000 * np.exp(np.r_[0, np.cumsum(r)]), dates, 50 * np.exp(np.r_[0, np.cumsum(dlv)]))
assert emp["beta_full"] == pytest.approx(-0.8, abs=0.1) and emp["corr_full"] < -0.5
reg = classify_regime({"sticky_strike": -0.2, "sticky_delta": 0.0, "bartlett": -0.4}, -0.38)
assert reg["closest"] == "bartlett"
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})
monkeypatch.setattr(ev_router, "_load_history", lambda pair: None)
r = client.get("/api/smiledyn/crypto/BTC")
assert r.status_code == 200, r.text
js = r.json()
assert 3 <= len(js["expiries"]) <= 6 and js["empirical"] is None
for e in js["expiries"]:
assert e["params"]["rmse_vol"] < 0.02 and len(e["deltas"]) == 5
rng = np.random.default_rng(1)
d = [(date(2024, 1, 1) + timedelta(days=i)).isoformat() for i in range(500)]
monkeypatch.setattr(ev_router, "_load_history", lambda pair: {"dates": d, "c": 4400 * np.exp(np.cumsum(rng.normal(0, .01, 500)))})
monkeypatch.setattr(risk_router, "_iv_history", lambda pair: (d, (20 * np.exp(np.cumsum(rng.normal(0, .03, 500)))).tolist(), "GVZ"))
q = client.post("/api/smiledyn/quotes", json={"pair": "XAUUSD", "S": 4400, "atm": 0.2, "rr25": 0.015, "bf25": 0.004})
assert q.status_code == 200, q.text
qj = q.json()
assert qj["expiries"][0]["params"]["rho"] > 0 and qj["empirical"]["index"] == "GVZ" and qj["regime"] is not None
assert client.get("/api/smiledyn/crypto/XRP").status_code == 404
assert client.post("/api/smiledyn/quotes", json={"pair": "NOPE", "S": 1, "atm": 0.1}).status_code == 404
+4
View File
@@ -70,3 +70,7 @@ export const trainDeepHedge = (p) => api.post('/deephedge/train', p, { timeout:
// Positioning Desk (Deribit OI)
export const fetchPositioning = (ccy, convention = 'classic', maxDays = 120) =>
api.get(`/positioning/${ccy}`, { params: { convention, max_days: maxDays } }).then(r=>r.data)
// Smile Dynamics (SABR / Bartlett)
export const fetchSmileDynCrypto = ({ ccy, beta }) => api.get(`/smiledyn/crypto/${ccy}`, { params: { beta } }).then(r=>r.data)
export const fetchSmileDynQuotes = (p) => api.post('/smiledyn/quotes', p).then(r=>r.data)
+247
View File
@@ -0,0 +1,247 @@
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 { fetchSmileDynCrypto, fetchSmileDynQuotes } from '../api/client'
const LAYOUT = {
paper_bgcolor:'transparent', plot_bgcolor:'#080d18',
font:{ color:'#94a3b8', size:11 },
margin:{ l:56, 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 CFG = { responsive:true, displayModeBar:false }
const axis = (title, extra = {}) => ({ title:{ text:title }, gridcolor:'#1e293b', color:'#475569', zeroline:false, ...extra })
const RULES = [['bs_sticky_strike', 'Sticky strike (BS)', '#94a3b8'], ['sticky_delta', 'Sticky delta', '#ffd700'],
['sabr', 'SABR (α fixed)', '#c084fc'], ['bartlett', 'Bartlett (SABR, ρ-adjusted)', '#00d4ff']]
const REGIME_NAME = { sticky_strike:'Sticky strike', sticky_delta:'Sticky delta', bartlett:'SABR / Bartlett' }
function useLoad(fn, key, enabled = true) {
const [res, setRes] = useState({ key:null, data:null, error:null })
useEffect(() => {
if (!enabled) return
let stale = false
const t = setTimeout(() => {
fn(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() ?? 'Request failed — is the backend up?' }) })
}, 300)
return () => { stale = true; clearTimeout(t) }
}, [fn, key, enabled])
return { ...res, loading: res.key !== key }
}
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>
)
}
const inputStyle = { background:'var(--bg3)', border:'1px solid var(--border)', borderRadius:4, color:'var(--cyan)', fontSize:11,
padding:'4px 6px', fontFamily:'var(--font-mono)', width:'100%' }
function Field({ label, value, onChange, step }) {
return (
<label style={{ display:'flex', flexDirection:'column', gap:3, fontSize:9, color:'var(--text-muted)' }}>{label}
<input type="number" value={value} step={step} onChange={e => onChange(+e.target.value)} style={inputStyle} />
</label>
)
}
export default function SmileDynamics() {
const { pair } = usePortfolioStore()
return <Desk key={pair} pair={pair} />
}
function Desk({ pair }) {
const { S, sigma, T, r_d, r_f, smileQuotes } = usePortfolioStore()
const crypto = isCrypto(pair)
const [mode, setMode] = useState(crypto ? 'live' : 'quotes')
const [beta, setBeta] = useState(1)
const [q, setQ] = useState(() => ({ T:+T, atm: smileQuotes?.atm ?? +(sigma * 100).toFixed(2),
rr25: smileQuotes?.rr25 ?? (PAIR_META[pair]?.assetClass === 'metal' ? 1.0 : -0.5), bf25: smileQuotes?.bf25 ?? 0.3 }))
const setF = (k) => (v) => setQ(o => ({ ...o, [k]: v }))
const live = mode === 'live' && crypto
const liveRes = useLoad(fetchSmileDynCrypto, JSON.stringify({ ccy: pair.slice(0, 3), beta }), live)
const quoteRes = useLoad(fetchSmileDynQuotes, JSON.stringify({ pair, S, T:q.T, r_d, r_f, atm:q.atm / 100, rr25:q.rr25 / 100,
bf25:q.bf25 / 100, beta }), !live && q.atm > 0 && q.T > 0)
const res = live ? liveRes : quoteRes
const d = res.data
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">Smile Dynamics — SABR, smile-consistent deltas &amp; spot-vol regime ({pair})</div>
<div style={{ fontSize:9, color:'var(--text-muted)', marginTop:2 }}>
How the smile moves when spot moves decides your hedge ratio · Bartlett delta is the variance-optimal hedge under SABR
</div>
</div>
<div style={{ display:'flex', gap:4, alignItems:'center', flexWrap:'wrap' }}>
{crypto && <Chip active={mode === 'live'} onClick={() => setMode('live')}>Live Deribit smiles</Chip>}
<Chip active={!live} onClick={() => setMode('quotes')}>ATM / RR / BF quotes</Chip>
<span style={{ width:8 }} />
<span style={{ fontSize:9, color:'var(--text-muted)' }}>β</span>
{[1, 0.5, 0].map(b => <Chip key={b} active={beta === b} onClick={() => setBeta(b)}>{b}</Chip>)}
</div>
</div>
{!live && (
<div className="glass" style={{ borderRadius:8, padding:10, marginBottom:12 }}>
<div style={{ display:'grid', gridTemplateColumns:'repeat(auto-fill, minmax(120px, 1fr))', gap:10 }}>
<Field label="Tenor (years)" value={q.T} onChange={setF('T')} step={0.05} />
<Field label="ATM vol (%)" value={q.atm} onChange={setF('atm')} step={0.5} />
<Field label="25Δ RR (vol pts)" value={q.rr25} onChange={setF('rr25')} step={0.1} />
<Field label="25Δ BF (vol pts)" value={q.bf25} onChange={setF('bf25')} step={0.1} />
</div>
<div style={{ fontSize:9, color:'var(--text-muted)', marginTop:6 }}>
{smileQuotes ? 'Pre-filled from Vol Smile / Vol Lab. ' : ''}A vanna-volga smile is built from the quotes, then SABR is fitted to it.
</div>
</div>
)}
{res.error && <div style={{ padding:8, borderRadius:6, fontSize:11, marginBottom:10, background:'rgba(255,61,90,0.1)', border:'1px solid #ff3d5a', color:'#ff3d5a' }}>{res.error}</div>}
{!d && !res.error && <div style={{ fontSize:11, color:'var(--text-muted)', padding:12 }}>Fitting SABR…</div>}
{d && <Body key={d.reference + beta} d={d} loading={res.loading} />}
</div>
)
}
function Body({ d, loading }) {
const [sel, setSel] = useState(d.reference)
const e = d.expiries.find(x => x.label === sel) ?? d.expiries[0]
const p = e.params
const emp = d.empirical
const bb = e.backbone
const atmRow = e.deltas.find(r => r.label.startsWith('ATM'))
return (
<div style={{ opacity: loading ? 0.6 : 1, transition:'opacity .2s' }}>
{d.expiries.length > 1 && (
<div style={{ display:'flex', gap:4, marginBottom:10, flexWrap:'wrap' }}>
{d.expiries.map(x => <Chip key={x.label} active={x.label === e.label} onClick={() => setSel(x.label)}>{x.label} · {x.days.toFixed(0)}d</Chip>)}
</div>
)}
<Kpis items={[
{ label:'SABR ATM vol', value:`${(bb.atm_vol * 100).toFixed(1)}%`, sub:`α ${p.alpha.toPrecision(3)} · β ${p.beta}` },
{ label:'ρ (spot-vol corr.)', value:p.rho.toFixed(2), color: p.rho < 0 ? 'var(--red)' : 'var(--green)' },
{ label:'ν (vol of vol)', value:p.nu.toFixed(2), color:'#c084fc' },
{ label:'Fit RMSE', value:`${(p.rmse_vol * 100).toFixed(2)} vol`, sub:`max ${(p.max_err_vol * 100).toFixed(2)}` },
{ label:'ATM Δ: Bartlett vs BS', value:`${((atmRow.bartlett - atmRow.bs_sticky_strike) * 100).toFixed(1)}% notional`,
color:'#00d4ff', hint:'Hedge-ratio correction for the ATM call' },
...(emp ? [{ label:`Realised spot-vol β (${emp.window}d)`, value:`${emp.volpts_per_1pct_recent >= 0 ? '+' : ''}${emp.volpts_per_1pct_recent.toFixed(2)} vol / +1%`,
sub:`corr ${emp.corr_recent.toFixed(2)} · ${emp.index}`, color: emp.volpts_per_1pct_recent < 0 ? 'var(--red)' : 'var(--green)' }] : []),
...(d.regime ? [{ label:'Market behaves like', value:REGIME_NAME[d.regime.closest], color:'#ffd700',
sub:`${d.regime.distance_volpts.toFixed(2)} vol pts from model (${d.reference})` }] : []),
]} />
<div style={{ display:'grid', gridTemplateColumns:'repeat(auto-fit, minmax(420px, 1fr))', gap:12 }}>
<Card title={`SABR fit · ${e.label}`}>
<Plot data={[
{ x:e.market.strikes, y:e.market.iv.map(v => v * 100), type:'scatter', mode:'markers', name:'Market', marker:{ color:'#ffd700', size:6 } },
{ x:e.fit.strikes, y:e.fit.iv.map(v => v * 100), type:'scatter', mode:'lines', name:`SABR β=${p.beta}`, line:{ color:'#00d4ff', width:2 } },
]}
layout={{ ...LAYOUT, xaxis:axis('Strike'), yaxis:axis('Implied vol (%)'),
shapes:[{ type:'line', x0:e.forward, x1:e.forward, yref:'paper', y0:0, y1:1, line:{ color:'rgba(255,255,255,0.3)', dash:'dot' } }] }}
config={CFG} style={{ width:'100%', height:290 }} />
</Card>
<Card title="Hedge ratio by smile rule (forward delta)">
<Plot data={RULES.map(([k, n, c]) => ({ x:e.deltas.map(r => r.label), y:e.deltas.map(r => Math.abs(r[k])), type:'bar', name:n, marker:{ color:c } }))}
layout={{ ...LAYOUT, barmode:'group', xaxis:axis('', { type:'category' }), yaxis:axis('|Δ|') }}
config={CFG} style={{ width:'100%', height:290 }} />
</Card>
<Card title="Backbone — ATM vol change per +1% spot move">
<Plot data={[
{ x:['Sticky strike', 'Sticky delta', 'SABR (α fixed)', 'Bartlett'], y:[bb.sticky_strike, bb.sticky_delta, bb.sabr, bb.bartlett], type:'bar',
name:'Model', marker:{ color:['#94a3b8', '#ffd700', '#c084fc', '#00d4ff'] } },
...(emp ? [{ x:['Sticky strike', 'Sticky delta', 'SABR (α fixed)', 'Bartlett'], y:Array(4).fill(emp.volpts_per_1pct_recent), type:'scatter',
mode:'lines', name:`Realised (${emp.index}, ${emp.window}d)`, line:{ color:'#ff3d5a', dash:'dash', width:2 } }] : []),
]}
layout={{ ...LAYOUT, xaxis:axis('', { type:'category' }), yaxis:axis('Vol points', { zeroline:true, zerolinecolor:'#334155' }) }}
config={CFG} style={{ width:'100%', height:290 }} />
<div style={{ fontSize:9, color:'var(--text-muted)', padding:'0 8px 4px' }}>
The rule whose backbone matches the realised spot-vol beta is the one your delta should assume.
</div>
</Card>
{emp ? (
<Card title={`Rolling ${emp.window}-day spot-vol relationship (${emp.index})`}>
<Plot data={[
{ x:emp.rolling.map(r => r.date), y:emp.rolling.map(r => r.corr), type:'scatter', mode:'lines', name:'Correlation', line:{ color:'#00d4ff', width:1.8 } },
{ x:emp.rolling.map(r => r.date), y:emp.rolling.map(r => r.beta), type:'scatter', mode:'lines', name:'β (d ln IV / d ln S)', yaxis:'y2',
line:{ color:'#c084fc', width:1.2 } },
]}
layout={{ ...LAYOUT, hovermode:'x unified', xaxis:axis('', { type:'date' }), yaxis:axis('Correlation', { range:[-1, 1], zeroline:true, zerolinecolor:'#334155' }),
yaxis2:{ overlaying:'y', side:'right', showgrid:false, zeroline:false, color:'#c084fc' } }}
config={CFG} style={{ width:'100%', height:290 }} />
</Card>
) : (
<Card title="Term structure of SABR ρ and ν">
<TermChart d={d} />
</Card>
)}
</div>
{emp && d.expiries.length > 1 && <div style={{ marginTop:12 }}><Card title="Term structure of SABR ρ and ν"><TermChart d={d} /></Card></div>}
<div style={{ marginTop:12 }}>
<Card title={`Delta table · ${e.label} · F ${e.forward.toLocaleString(undefined, { maximumFractionDigits:2 })}`}>
<div style={{ overflowX:'auto' }}>
<table style={{ width:'100%', borderCollapse:'collapse', fontSize:11 }}>
<thead><tr style={{ color:'var(--text-muted)', fontSize:9, textAlign:'right' }}>
{['Option', 'Strike', 'Vol', 'Sticky strike', 'Sticky delta', 'SABR', 'Bartlett', 'Bartlett − BS'].map((h, i) =>
<th key={h} style={{ padding:'4px 8px', textAlign: i ? 'right' : 'left' }}>{h}</th>)}</tr></thead>
<tbody>{e.deltas.map(r => (
<tr key={r.label} className="font-mono" style={{ borderTop:'1px solid var(--border)', textAlign:'right' }}>
<td style={{ textAlign:'left', padding:'5px 8px' }}>{r.label}</td>
<td style={{ padding:'5px 8px' }}>{r.strike.toLocaleString(undefined, { maximumFractionDigits: r.strike > 100 ? 0 : 4 })}</td>
<td style={{ padding:'5px 8px' }}>{(r.vol * 100).toFixed(2)}%</td>
{RULES.map(([k, , c]) => <td key={k} style={{ padding:'5px 8px', color:c }}>{r[k].toFixed(3)}</td>)}
<td style={{ padding:'5px 8px', color: Math.abs(r.bartlett_minus_bs) > 0.03 ? '#ffd700' : undefined }}>
{r.bartlett_minus_bs >= 0 ? '+' : ''}{(r.bartlett_minus_bs * 100).toFixed(1)}%</td>
</tr>))}</tbody>
</table>
</div>
<div style={{ fontSize:9, color:'var(--text-muted)', padding:'6px 8px' }}>
Bartlett − BS is the extra hedge per unit notional: with ρ &lt; 0, spot rallies come with lower vol, so you need less delta on calls and more on puts.
</div>
</Card>
</div>
<div style={{ fontSize:9, color:'var(--text-muted)', marginTop:10, lineHeight:1.5 }}>
{d.source}. SABR: Hagan, Kumar, Lesniewski &amp; Woodward (2002) with Obłój (2008) leading term. Bartlett (2006), &quot;Hedging under SABR&quot;;
variance-optimality: Keller-Ressel (2022). Realised β: OLS of daily d ln IV on d ln S (DVOL / GVZ / VXSLV).
</div>
</div>
)
}
function TermChart({ d }) {
return (
<Plot data={[
{ x:d.expiries.map(e => e.days), y:d.expiries.map(e => e.params.rho), type:'scatter', mode:'lines+markers', name:'ρ', line:{ color:'#ff3d5a', width:2 } },
{ x:d.expiries.map(e => e.days), y:d.expiries.map(e => e.params.nu), type:'scatter', mode:'lines+markers', name:'ν', yaxis:'y2', line:{ color:'#c084fc', width:2 } },
]}
layout={{ ...LAYOUT, hovermode:'x unified', xaxis:axis('Days to expiry'), yaxis:axis('ρ', { range:[-1, 1] }),
yaxis2:{ overlaying:'y', side:'right', showgrid:false, zeroline:false, color:'#c084fc', title:{ text:'ν' } } }}
config={CFG} style={{ width:'100%', height:260 }} />
)
}
+2
View File
@@ -20,6 +20,7 @@ import RiskDesk from '../components/RiskDesk'
import TarfDesk from '../components/TarfDesk'
import AiHedger from '../components/AiHedger'
import Positioning from '../components/Positioning'
import SmileDynamics from '../components/SmileDynamics'
const IVSurface = lazy(() => import('../components/IVSurface'))
const WorldMap = lazy(() => import('../components/WorldMap'))
@@ -62,6 +63,7 @@ const TABS = [
{ id:'tarf', icon:'🎯', label:'TARF Desk', component: TarfDesk },
{ id:'xasset', icon:'🔗', label:'Cross-Asset', component: CrossAsset },
{ id:'smile', icon:'〰', label:'Vol Smile', component: VolSmile },
{ id:'smiledyn', icon:'🌊', label:'Smile Dynamics', component: SmileDynamics },
{ id:'surfaces', icon:'🗻', label:'3D Surfaces', component: SurfacePlot },
{ id:'breakeven', icon:'🎯', label:'Breakeven', component: BreakevenChart },
{ id:'scenarios', icon:'⚠', label:'Scenarios', component: ScenarioTable },
Binary file not shown.

After

Width:  |  Height:  |  Size: 404 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 438 KiB