mirror of
https://github.com/Mihirkansara/nexus-quant-terminal.git
synced 2026-11-02 09:36:11 +00:00
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:
@@ -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 |
|
||||
|
||||
|
||||
@@ -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
@@ -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("/")
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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 & 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 ρ < 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 & Woodward (2002) with Obłój (2008) leading term. Bartlett (2006), "Hedging under SABR";
|
||||
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 }} />
|
||||
)
|
||||
}
|
||||
@@ -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 |
Reference in New Issue
Block a user