Merge pull request #11 from Mihirkansara/claude/daily-job-scheduling-pr27a6

Daily: Variance Desk — var/vol swaps by Cboe-DVOL replication, BKM implied moments, implied density & VRP
This commit is contained in:
github-actions[bot]
2026-10-29 17:26:24 +00:00
committed by GitHub
14 changed files with 979 additions and 5 deletions
+1 -1
View File
@@ -28,7 +28,7 @@ Desk-grade models with their working shown: full-revaluation VaR/ES with backtes
## Capabilities and Constraints
- 28 modules today: Greeks, Chart, AI Signals, Vol Lab, Vol Forecast, Event Vol, Positioning, RV Scanner, Hedge Lab, Risk Desk, Margin Desk, AI Hedger, Jump Lab, Exotics Desk, TARF Desk, Cross-Asset, Vol Smile, Smile Dynamics, 3D Surfaces, Breakeven, Scenarios, Monte Carlo, Institutional, Calendar, Live Map, Live Feeds, Volume Profile, News Center.
- 29 modules today: Greeks, Chart, AI Signals, Vol Lab, Vol Forecast, Event Vol, Variance Desk, Positioning, RV Scanner, Hedge Lab, Risk Desk, Margin Desk, AI Hedger, Jump Lab, Exotics Desk, TARF Desk, Cross-Asset, Vol Smile, Smile Dynamics, 3D Surfaces, Breakeven, Scenarios, Monte Carlo, Institutional, Calendar, Live Map, Live Feeds, Volume Profile, News Center.
- Stack: React 19 + Vite 8 frontend (Zustand store, Plotly charts, Leaflet map, Tailwind 4 available), FastAPI backend. Frontend deploys to Vercel; backend to a Procfile host.
- Instruments: FX pairs, BTC/ETH, XAU/XAG; some modules are crypto-only (Deribit-backed).
- Every feature, model and data path must survive any redesign.
+1
View File
@@ -48,6 +48,7 @@ NEXUS TERMINAL is a Bloomberg-style web application for professional forex optio
| **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 |
| **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 |
| **Variance Desk** | Model-free variance and the implied distribution: variance-swap fair strikes per expiry by continuous replication on the SVI-smoothed smile (Demeterfi-Derman-Kamal-Zou) and by the discrete Cboe VIX / Deribit DVOL formula on the listed strikes (truncation gap shown), a replicated 30-day index vs live DVOL / GVZ / VXSLV, vol-swap strikes with a vol-of-vol convexity adjustment, forward variance and calendar-arbitrage flags; Bakshi-Kapadia-Madan risk-neutral skew and kurtosis; Breeden-Litzenberger density vs lognormal and demeaned history with finish-beyond / touch probability ladders; variance risk premium history (implied 30d vs the 30 days that followed, hit rate and short-variance P&L). Live Deribit for BTC/ETH, ATM / RR / BF quotes per tenor for XAU/XAG/FX |
| **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 |
+296
View File
@@ -0,0 +1,296 @@
"""
variance.py — Variance swaps, the implied distribution and the variance risk premium.
Variance-swap fair strike (model-free replication, Demeterfi, Derman, Kamal & Zou 1999)
K_var = (2/T) ∫ Q(K)/K² dK (forward measure, undiscounted OTM prices Q, put below F)
Discrete, on the listed strikes, exactly as the Cboe VIX / Deribit DVOL methodology:
σ² = (2/T) Σ ΔK_i/K_i² · Q(K_i) − (1/T)(F/K₀ − 1)²
K₀ = first listed strike at or below F; Q(K₀) = mean of call and put; ΔK_i = half the distance
between the neighbouring strikes (one-sided at the ends); strikes beyond two consecutive
zero bids are dropped. 30-day constant maturity by the Cboe linear interpolation in T·σ².
The gap between the continuous (smoothed smile over wide strikes) and discrete (listed)
strikes is the truncation / discretisation error of the index.
Volatility-swap fair strike (convexity)
With realised variance V lognormal and vol-of-vol ν (instantaneous vol driftless lognormal,
so Var ln V ≈ 4ν²T/3 after time-averaging), E[√V] = √E[V]·exp(−Var ln V / 8)
K_vol ≈ √K_var · exp(−ν²T/6)
in the spirit of Brockhaus & Long (2000); an approximation that understates the adjustment
when jumps dominate (Broadie & Jain 2008).
Implied moments of the log return (Bakshi, Kapadia & Madan 2003), forward measure:
V = ∫_{K>F} 2(1 − ln(K/F))/K² C dK + ∫_{K<F} 2(1 + ln(F/K))/K² P dK
W = ∫_{K>F} (6 ln(K/F) − 3 ln²(K/F))/K² C dK − ∫_{K<F} (6 ln(F/K) + 3 ln²(F/K))/K² P dK
X = ∫_{K>F} (12 ln²(K/F) − 4 ln³(K/F))/K² C dK + ∫_{K<F} (12 ln²(F/K) + 4 ln³(F/K))/K² P dK
μ = E[ln S_T/F] = −(T/2)·K_var exactly (log contract) — BKM's series −V/2 − W/6 − X/24 biases kurtosis; skew = (W − 3μV + 2μ³)/(V − μ²)^{3/2};
kurt = (X − 4μW + 6μ²V − 3μ⁴)/(V − μ²)²
Black-Scholes gives skew 0 and kurtosis 3 (log return normal).
Risk-neutral density (Breeden & Litzenberger 1978): f(K) = ∂²C/∂K² on undiscounted calls.
Prices come from a smooth, arbitrage-checked smile (raw SVI, Gatheral 2004), so the density
is not the noise of differencing raw quotes. P(S_T > K) = −∂C/∂K. One-touch odds use the
driftless reflection approximation P(touch) ≈ 2·P(S_T beyond K), capped at 1.
Variance risk premium: implied 30-day variance (vol index²) at t against the variance realised
over the following 30 days (zero-mean squared returns, as a variance swap settles); the
short-variance P&L in vol points is (K_var − RV²)/(2√K_var) per unit vega notional.
"""
import numpy as np
from scipy.stats import norm
from .vol_analytics import svi_total_variance
def black76(F, K, T, sigma, call=True):
"""Undiscounted Black-76 price (forward measure)."""
K = np.asarray(K, float)
sigma = np.asarray(sigma, float)
sq = sigma * np.sqrt(T)
d1 = (np.log(F / K) + 0.5 * sq * sq) / sq
d2 = d1 - sq
if call:
return F * norm.cdf(d1) - K * norm.cdf(d2)
return K * norm.cdf(-d2) - F * norm.cdf(-d1)
# ─── variance swaps ───────────────────────────────────────────────────────────
def cboe_variance(strikes, calls, puts, F, T, call_bids=None, put_bids=None):
"""Cboe VIX-style discrete variance on listed strikes (undiscounted USD prices).
Returns {variance, vol, k0, n_strikes, k_min, k_max} or None if too few strikes survive.
"""
K = np.asarray(strikes, float)
order = np.argsort(K)
K = K[order]
C = np.asarray(calls, float)[order]
P = np.asarray(puts, float)[order]
cb = None if call_bids is None else np.asarray(call_bids, float)[order]
pb = None if put_bids is None else np.asarray(put_bids, float)[order]
below = np.where(K <= F)[0]
if not len(below) or below[-1] + 1 >= len(K):
return None
i0 = below[-1]
keep = {i0: 0.5 * (C[i0] + P[i0])}
def walk(idx, prices, bids):
zeros = 0
for i in idx:
bid = None if bids is None else bids[i]
if bid is not None and not (bid > 0):
zeros += 1
if zeros >= 2:
break
continue
zeros = 0
if np.isfinite(prices[i]) and prices[i] > 0:
keep[i] = prices[i]
walk(range(i0 - 1, -1, -1), P, pb)
walk(range(i0 + 1, len(K)), C, cb)
idx = sorted(keep)
if len(idx) < 5:
return None
Ks = K[idx]
Q = np.array([keep[i] for i in idx])
dK = np.empty_like(Ks)
dK[1:-1] = (Ks[2:] - Ks[:-2]) / 2
dK[0] = Ks[1] - Ks[0]
dK[-1] = Ks[-1] - Ks[-2]
var = 2 / T * np.sum(dK / Ks ** 2 * Q) - (F / K[i0] - 1) ** 2 / T
return {"variance": float(var), "vol": float(np.sqrt(max(var, 0))), "k0": float(K[i0]),
"n_strikes": len(idx), "k_min": float(Ks[0]), "k_max": float(Ks[-1])}
def constant_maturity(T1, v1, T2, v2, target=30 / 365):
"""Cboe interpolation of variance in T·σ² to a constant maturity (years in, variance out)."""
if T2 == T1:
return v1
w1 = (T2 - target) / (T2 - T1)
return (T1 * v1 * w1 + T2 * v2 * (1 - w1)) / target
def vol_swap_strike(k_var, T, volvol):
"""√K_var · exp(−ν²T/6): lognormal realised variance with time-averaged vol-of-vol ν."""
if k_var is None or k_var <= 0:
return None
return float(np.sqrt(k_var) * np.exp(-(volvol or 0.0) ** 2 * T / 6))
def forward_vols(rows):
"""Forward vol between consecutive expiries from total variance; None flags calendar arbitrage."""
out = []
for a, b in zip(rows, rows[1:]):
dv = b["T"] * b["k_var"] - a["T"] * a["k_var"]
out.append({"from_days": a["days"], "to_days": b["days"],
"fwd_vol": float(np.sqrt(dv / (b["T"] - a["T"]))) if dv > 0 else None,
"calendar_arbitrage": bool(dv <= 0)})
return out
# ─── implied distribution ─────────────────────────────────────────────────────
def strike_grid(F, T, atm, n=1201, width=7.0):
"""Log-uniform strike grid spanning ±width ATM standard deviations (at least ±25%), F on the grid."""
half = max(width * atm * np.sqrt(T), 0.25)
up = half * 0.9 + 0.1
n_dn = int(n * half / (half + up))
x = np.concatenate([np.linspace(-half, 0, n_dn), np.linspace(0, up, n - n_dn + 1)[1:]])
return F * np.exp(x)
def chain_from_vol(vol_fn, F, T, K):
"""Undiscounted call and put prices on K from a smile σ(K)."""
v = np.clip(vol_fn(K), 1e-3, 5.0)
return black76(F, K, T, v, True), black76(F, K, T, v, False), v
def continuous_variance(K, C, P, F, T):
"""K_var = (2/T) ∫ OTM/K² dK by the trapezoid rule on a fine grid."""
Q = np.where(K < F, P, C)
return float(2 / T * np.trapezoid(Q / K ** 2, K))
def bkm_moments(K, C, P, F, T):
"""Risk-neutral variance (annualised), skewness and kurtosis of ln(S_T/F)."""
up, dn = K >= F, K <= F # F sits on the grid (strike_grid)
x_up, x_dn = np.log(K[up] / F), np.log(F / K[dn])
Cu, Pd, Ku, Kd = C[up], P[dn], K[up], K[dn]
V = np.trapezoid(2 * (1 - x_up) / Ku ** 2 * Cu, Ku) + np.trapezoid(2 * (1 + x_dn) / Kd ** 2 * Pd, Kd)
W = (np.trapezoid((6 * x_up - 3 * x_up ** 2) / Ku ** 2 * Cu, Ku)
- np.trapezoid((6 * x_dn + 3 * x_dn ** 2) / Kd ** 2 * Pd, Kd))
X = (np.trapezoid((12 * x_up ** 2 - 4 * x_up ** 3) / Ku ** 2 * Cu, Ku)
+ np.trapezoid((12 * x_dn ** 2 + 4 * x_dn ** 3) / Kd ** 2 * Pd, Kd))
Q = np.where(K < F, P, C)
mu = -np.trapezoid(Q / K ** 2, K) # E[ln S_T/F], log-contract replication
var = V - mu ** 2
if var <= 0:
return {"variance": None, "vol": None, "skew": None, "kurtosis": None, "mean_log": float(mu)}
return {"variance": float(var / T), "vol": float(np.sqrt(var / T)),
"skew": float((W - 3 * mu * V + 2 * mu ** 3) / var ** 1.5),
"kurtosis": float((X - 4 * mu * W + 6 * mu ** 2 * V - 3 * mu ** 4) / var ** 2),
"mean_log": float(mu)}
def density(K, C):
"""Breeden-Litzenberger density and CDF from undiscounted calls on a (non-uniform) grid."""
dC = np.gradient(C, K)
f = np.clip(np.gradient(dC, K), 0, None)
cdf = np.clip(1 + dC, 0, 1)
mass = float(np.trapezoid(f, K))
return f, cdf, mass
def probability_ladder(K, cdf, F, moves=(-0.3, -0.2, -0.15, -0.1, -0.05, 0.05, 0.1, 0.15, 0.2, 0.3), physical=None):
"""P(S_T below / above F·(1+m)) and reflection one-touch odds; optional physical probabilities."""
rows = []
for m in moves:
lvl = F * (1 + m)
p_below = float(np.interp(lvl, K, cdf))
terminal = p_below if m < 0 else 1 - p_below
row = {"move": m, "level": lvl, "p_below": p_below, "p_above": 1 - p_below,
"p_terminal_beyond": terminal, "p_touch": min(1.0, 2 * terminal)}
if physical is not None and len(physical):
r = np.log(1 + m)
row["p_physical_beyond"] = float(np.mean(physical <= r) if m < 0 else np.mean(physical >= r))
rows.append(row)
return rows
def quantiles(K, cdf, F, qs=(0.01, 0.05, 0.25, 0.5, 0.75, 0.95, 0.99)):
c = np.maximum.accumulate(cdf)
out = {}
for q in qs:
lvl = float(np.interp(q, c + np.arange(len(c)) * 1e-12, K))
out[f"{q:g}"] = {"level": lvl, "move": lvl / F - 1}
return out
def physical_returns(closes, horizon):
"""Overlapping demeaned log returns over `horizon` observations (drift removed to compare shape)."""
c = np.asarray(closes, float)
c = c[np.isfinite(c) & (c > 0)]
if horizon < 1 or len(c) < horizon + 60:
return np.array([])
r = np.log(c[horizon:] / c[:-horizon])
return r - r.mean()
def expiry_report(vol_fn, F, T, atm, volvol=None, listed=None, physical=None, n_density=160):
"""Everything the desk shows for one expiry, from a smooth smile σ(K) (+ optional listed chain)."""
K = strike_grid(F, T, atm)
C, P, _ = chain_from_vol(vol_fn, F, T, K)
k_var = continuous_variance(K, C, P, F, T)
mom = bkm_moments(K, C, P, F, T)
f, cdf, mass = density(K, C)
straddle = float(2 * black76(F, F, T, float(np.clip(vol_fn(np.array([F]))[0], 1e-3, 5)), True))
disc = None
if listed:
disc = cboe_variance(listed["strikes"], listed["calls"], listed["puts"], F, T,
listed.get("call_bids"), listed.get("put_bids"))
lo, hi = np.searchsorted(cdf, 0.0005), np.searchsorted(cdf, 0.9995)
sl = slice(max(lo - 2, 0), min(hi + 3, len(K)))
sub = np.linspace(sl.start, sl.stop - 1, n_density).astype(int)
lognormal = norm.pdf(np.log(K[sub] / F), -0.5 * atm ** 2 * T, atm * np.sqrt(T)) / K[sub]
phys = None
if physical is not None and len(physical) > 50:
from scipy.stats import gaussian_kde
x = np.log(K[sub] / F)
phys = (gaussian_kde(physical)(x) / K[sub]).tolist()
return {
"T": T, "days": T * 365, "forward": F, "atm_iv": atm,
"k_var": k_var, "var_vol": float(np.sqrt(max(k_var, 0))),
"vol_swap": vol_swap_strike(k_var, T, volvol),
"listed": disc, "truncation_gap": (disc["vol"] - np.sqrt(k_var)) if disc else None,
"moments": mom, "density_mass": mass,
"expected_move": straddle, "expected_move_pct": straddle / F,
"quantiles": quantiles(K, cdf, F),
"ladder": probability_ladder(K, cdf, F, physical=physical),
"density": {"strikes": K[sub].tolist(), "rnd": f[sub].tolist(), "lognormal": lognormal.tolist(),
"physical": phys},
}
def svi_vol_fn(params, F, T):
"""Vectorised σ(K) from raw-SVI total variance."""
return lambda K: np.sqrt(np.maximum(svi_total_variance(np.log(np.asarray(K, float) / F), *params), 1e-10) / T)
# ─── variance risk premium ────────────────────────────────────────────────────
def vrp_history(index_dates, index_levels, price_dates, closes, horizon, ppy, keep=730):
"""Implied 30d vol (index, %) at t vs zero-mean realised vol over the next `horizon` returns."""
px = {str(d): float(c) for d, c in zip(price_dates, closes) if c and c > 0}
pd_sorted = sorted(px)
pos = {d: i for i, d in enumerate(pd_sorted)}
lr = np.diff(np.log([px[d] for d in pd_sorted]))
rows = []
for d, lvl in zip(index_dates, index_levels):
d = str(d)
if d not in pos or not lvl or lvl <= 0:
continue
i = pos[d]
iv = lvl / 100
if i + horizon <= len(lr):
rv = float(np.sqrt(np.mean(lr[i:i + horizon] ** 2) * ppy))
rows.append({"date": d, "implied": iv, "realised_fwd": rv, "vrp": iv - rv,
"short_var_pnl": (iv ** 2 - rv ** 2) / (2 * iv)})
else:
rows.append({"date": d, "implied": iv, "realised_fwd": None, "vrp": None, "short_var_pnl": None})
rows = rows[-keep:]
done = [r for r in rows if r["vrp"] is not None]
# Vol-of-vol over the swap horizon: the index mean-reverts, so annualising daily changes overstates it.
lv = np.log([lvl for lvl in index_levels if lvl and lvl > 0])
volvol = float(np.std(lv[horizon:] - lv[:-horizon], ddof=1) * np.sqrt(ppy / horizon)) if len(lv) > 3 * horizon else None
trailing = float(np.sqrt(np.mean(lr[-horizon:] ** 2) * ppy)) if len(lr) >= horizon else None
pnl = np.array([r["short_var_pnl"] for r in done]) if done else np.array([])
return {
"series": rows, "n": len(done),
"hit_rate": float(np.mean([r["vrp"] > 0 for r in done])) if done else None,
"mean_vrp": float(np.mean([r["vrp"] for r in done])) if done else None,
"mean_short_var_pnl": float(pnl.mean()) if len(pnl) else None,
"worst_short_var_pnl": float(pnl.min()) if len(pnl) else None,
"pnl_5pct": float(np.percentile(pnl, 5)) if len(pnl) else None,
"volvol": volvol, "current_implied": rows[-1]["implied"] if rows else None,
"trailing_realised": trailing,
}
+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, smiledyn, margin, rvscan, volumeprofile, newscenter
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, margin, rvscan, volumeprofile, newscenter, variance
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, smiledyn, margin, rvscan, volumeprofile, newscenter]:
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, margin, rvscan, volumeprofile, newscenter, variance]:
app.include_router(router.router, prefix="/api")
@app.get("/")
+230
View File
@@ -0,0 +1,230 @@
"""
variance.py — Variance Desk: model-free variance-swap strikes (continuous and Cboe/DVOL-style
discrete on the listed strikes), a replicated 30-day index against the live vol index,
vol-swap convexity, forward variance and calendar checks, risk-neutral moments (BKM), the
Breeden-Litzenberger density with probability / touch ladders against history, and the
variance risk premium (implied vs subsequently realised).
BTC / ETH: live Deribit book summaries (60 s cache), DVOL history. Other pairs: a vanna-volga
smile per tenor from ATM / 25Δ RR / 25Δ BF quotes, smoothed by raw SVI; GVZ / VXSLV history for
metals. Every source is free and keyless; a 503 when the live source is down.
"""
import time
from collections import defaultdict
import numpy as np
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, Field
from ..core.variance import (constant_maturity, expiry_report, forward_vols, physical_returns,
svi_vol_fn, vrp_history)
from ..core.vanna_volga import pillars, strike_from_delta, vv_vol
from ..core.vol_analytics import fit_svi, parse_deribit_instrument
from .forex import PAIRS
router = APIRouter(prefix="/variance", tags=["variance"])
YEAR_MS = 365 * 86400 * 1000
def _chain(currency):
"""(option summaries, index price, now ms, latest DVOL or None) from Deribit."""
from .vol import _cached, _deribit
def load():
now = int(time.time() * 1000)
opts = _deribit("get_book_summary_by_currency", currency=currency, kind="option")
idx = float(_deribit("get_index_price", index_name=f"{currency.lower()}_usd")["index_price"])
try:
rows = _deribit("get_volatility_index_data", currency=currency, start_timestamp=now - 3 * 3600_000,
end_timestamp=now, resolution="60").get("data", [])
dvol = float(rows[-1][4]) if rows else None
except Exception:
dvol = None
return opts, idx, now, dvol
return _cached(f"variance:{currency}", 60, load)
def _history(pair):
"""(price dates, closes, index dates, index levels, index label); any part may be None."""
dates = closes = None
try:
from .eventvol import _load_history
data = _load_history(pair)
if data and len(data["c"]) > 120:
dates, closes = data["dates"], list(map(float, data["c"]))
except Exception:
pass
try:
from .risk import _iv_history
d, lv, label = _iv_history(pair)
except Exception:
d, lv, label = None, None, None
return dates, closes, d, lv, label
def _context(pair):
"""VRP history and the physical-return sampler for one pair (empty pieces when offline)."""
ppy = PAIRS[pair]["periods_per_year"]
dates, closes, d, lv, label = _history(pair)
vrp = None
if closes and d:
vrp = vrp_history(d, lv, dates, closes, 30 if ppy == 365 else 21, ppy)
vrp["index"] = label
trailing = None
if closes and len(closes) > 31:
h = 30 if ppy == 365 else 21
r = np.diff(np.log(closes[-h - 1:]))
trailing = float(np.sqrt(np.mean(r ** 2) * ppy))
return ppy, closes, vrp, trailing
def _assemble(pair, rows, ppy, closes, vrp, trailing, extra):
rows.sort(key=lambda r: r["T"])
for r in rows:
r["vrp_vs_trailing"] = r["var_vol"] - trailing if trailing else None
fwd = forward_vols(rows)
cm = None
if len(rows) >= 2:
tgt = 30 / 365
lo = [r for r in rows if r["T"] <= tgt]
hi = [r for r in rows if r["T"] > tgt]
a, b = (lo[-1], hi[0]) if lo and hi else (rows[0], rows[1]) if not lo else (rows[-2], rows[-1])
cont = constant_maturity(a["T"], a["k_var"], b["T"], b["k_var"], tgt)
disc = None
if a.get("listed") and b.get("listed"):
disc = constant_maturity(a["T"], a["listed"]["variance"], b["T"], b["listed"]["variance"], tgt)
cm = {"from_days": a["days"], "to_days": b["days"], "continuous_vol": float(np.sqrt(max(cont, 0))),
"listed_vol": float(np.sqrt(max(disc, 0))) if disc is not None else None,
"extrapolated": not (lo and hi)}
return _clean({"pair": pair, "periods_per_year": ppy, "expiries": rows, "forward_vols": fwd,
"constant_maturity_30d": cm, "vrp": vrp, "trailing_realised": trailing,
"history_points": len(closes) if closes else 0, **extra})
def _physical(closes, days, ppy):
if not closes:
return None
h = max(1, int(round(days * ppy / 365)))
r = physical_returns(closes, h)
return r if len(r) else None
@router.get("/crypto/{currency}")
def crypto(currency: str, max_days: int = 370):
"""Variance swaps, moments and densities across the live Deribit expiries."""
currency = currency.upper()
if currency not in ("BTC", "ETH"):
raise HTTPException(404, "Live variance replication covers BTC and ETH (Deribit).")
try:
opts, index, now_ms, dvol = _chain(currency)
except Exception as e:
raise HTTPException(503, f"Deribit data unavailable: {e}")
pair = f"{currency}USD"
ppy, closes, vrp, trailing = _context(pair)
volvol = (vrp or {}).get("volvol") or 0.9
by_exp = defaultdict(list)
for o in opts:
p = parse_deribit_instrument(o.get("instrument_name", ""))
F, iv = o.get("underlying_price"), o.get("mark_iv")
if not p or not F or not iv:
continue
exp, K, cp = p
T = (exp.timestamp() * 1000 - now_ms) / YEAR_MS
if T * 365 < 1.5 or T * 365 > max_days:
continue
by_exp[exp.strftime("%Y-%m-%d")].append({"K": float(K), "call": cp == "call", "F": float(F), "T": T,
"iv": float(iv) / 100, "mark": o.get("mark_price"),
"bid": o.get("bid_price")})
rows = []
for e in sorted(by_exp):
xs = by_exp[e]
F = float(np.median([x["F"] for x in xs]))
T = xs[0]["T"]
otm = [x for x in xs if (x["call"] and x["K"] >= F) or (not x["call"] and x["K"] < F)]
if len(otm) < 5:
continue
k = np.log(np.array([x["K"] for x in otm]) / F)
params, rmse = fit_svi(k, np.array([x["iv"] for x in otm]), T)
vol_fn = svi_vol_fn(params, F, T)
atm = float(vol_fn(np.array([F]))[0])
legs = defaultdict(dict)
for x in xs:
if x["mark"]:
legs[x["K"]]["C" if x["call"] else "P"] = x
ks = sorted(K for K, lp in legs.items() if "C" in lp and "P" in lp)
listed = None
if len(ks) >= 5:
listed = {"strikes": ks,
"calls": [legs[K]["C"]["mark"] * F for K in ks], "puts": [legs[K]["P"]["mark"] * F for K in ks],
"call_bids": [legs[K]["C"]["bid"] or 0.0 for K in ks],
"put_bids": [legs[K]["P"]["bid"] or 0.0 for K in ks]}
rep = expiry_report(vol_fn, F, T, atm, volvol, listed, _physical(closes, T * 365, ppy))
rows.append({"expiry": e, "svi_rmse": rmse, "n_quotes": len(otm), **rep})
if not rows:
raise HTTPException(503, "No Deribit expiries with enough quotes.")
return _assemble(pair, rows, ppy, closes, vrp, trailing,
{"source": "Deribit public API (book summaries, DVOL), 60 s cache", "spot": index,
"live_index": dvol, "live_index_label": "Deribit DVOL", "volvol": volvol})
class Tenor(BaseModel):
T: float = Field(..., gt=0.004, le=3)
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)
class SmileRequest(BaseModel):
pair: str = "XAUUSD"
S: float = Field(..., gt=0)
r_d: float = 0.04
r_f: float = 0.0
tenors: list[Tenor] = Field(..., min_length=1, max_length=12)
volvol: float | None = Field(default=None, ge=0, le=5)
@router.post("/smile")
def from_smile(req: SmileRequest):
"""The same desk for FX and metals from ATM / 25Δ RR / 25Δ BF quotes per tenor."""
pair = req.pair.upper()
if pair not in PAIRS:
raise HTTPException(404, f"Unknown pair '{pair}'.")
ppy, closes, vrp, trailing = _context(pair)
volvol = req.volvol if req.volvol is not None else ((vrp or {}).get("volvol") or 0.8)
rows = []
for t in req.tenors:
try:
pl = pillars(req.S, t.T, req.r_d, req.r_f, t.atm, t.rr25, t.bf25)
except ValueError as e:
raise HTTPException(422, f"Tenor {t.T:g}y: {e}")
F = req.S * np.exp((req.r_d - req.r_f) * t.T)
lo = strike_from_delta(req.S, t.T, req.r_d, req.r_f, pl[0]["vol"], 0.05, "put")
hi = strike_from_delta(req.S, t.T, req.r_d, req.r_f, pl[2]["vol"], 0.05, "call")
K = np.exp(np.linspace(np.log(lo), np.log(hi), 17))
iv = vv_vol(K, req.S, t.T, req.r_d, req.r_f, pl)
params, rmse = fit_svi(np.log(K / F), np.asarray(iv, float), t.T)
vol_fn = svi_vol_fn(params, F, t.T)
rep = expiry_report(vol_fn, F, t.T, float(vol_fn(np.array([F]))[0]), volvol, None,
_physical(closes, t.T * 365, ppy))
rows.append({"expiry": f"{t.T * 12:.1f}M" if t.T >= 0.08 else f"{t.T * 52:.1f}W",
"svi_rmse": rmse, "quotes": t.model_dump(), **rep})
return _assemble(pair, rows, ppy, closes, vrp, trailing,
{"source": "Vanna-volga smile from ATM / 25Δ RR / 25Δ BF, raw-SVI smoothed", "spot": req.S,
"live_index": (vrp or {}).get("current_implied") and vrp["current_implied"] * 100,
"live_index_label": (vrp or {}).get("index"), "volvol": volvol})
def _clean(obj):
if isinstance(obj, (bool, np.bool_)):
return bool(obj)
if isinstance(obj, np.integer):
return int(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
+136
View File
@@ -0,0 +1,136 @@
from datetime import date, timedelta
import numpy as np
from fastapi.testclient import TestClient
from app.core.jump_models import merton_price
from app.core.variance import (black76, bkm_moments, cboe_variance, constant_maturity, continuous_variance,
expiry_report, forward_vols, strike_grid, vol_swap_strike, vrp_history)
from app.main import app
from app.routers import variance as var_router
from tests.rv_book import NOW_MS, book
client = TestClient(app)
F, T = 83000.0, 30 / 365
def flat(s):
return lambda K: np.full_like(np.asarray(K, float), s)
def test_black_scholes_limits():
rep = expiry_report(flat(0.6), F, T, 0.6, volvol=1.0)
assert abs(rep["var_vol"] - 0.6) < 1e-4 # K_var = σ² under flat vol
m = rep["moments"]
assert abs(m["vol"] - 0.6) < 1e-4 and abs(m["skew"]) < 1e-3 and abs(m["kurtosis"] - 3) < 1e-3
assert abs(m["mean_log"] + 0.5 * 0.36 * T) < 1e-6 # E[ln S_T/F] = −σ²T/2
assert abs(rep["density_mass"] - 1) < 1e-4
assert abs(rep["expected_move_pct"] - 0.6 * np.sqrt(T) * np.sqrt(2 / np.pi)) < 5e-4
assert abs(rep["vol_swap"] - rep["var_vol"] * np.exp(-T / 6)) < 1e-9 and rep["vol_swap"] < rep["var_vol"]
q = rep["quantiles"]
assert abs(q["0.5"]["level"] / F - np.exp(-0.18 * T)) < 2e-3 # lognormal median
lad = {r["move"]: r for r in rep["ladder"]}
assert lad[0.1]["p_touch"] == min(1, 2 * lad[0.1]["p_terminal_beyond"])
assert lad[-0.2]["p_below"] < lad[-0.1]["p_below"] < lad[0.1]["p_below"]
def test_merton_variance_and_moments_match_cumulants():
sig, lam, mj, d = 0.45, 3.0, -0.08, 0.12
K = strike_grid(F, T, 0.6, n=2001, width=9)
C, P = merton_price(F, K, T, sig, lam, mj, d, True), merton_price(F, K, T, sig, lam, mj, d, False)
kv = continuous_variance(K, C, P, F, T)
assert abs(kv - (sig ** 2 + 2 * lam * (np.exp(mj + d * d / 2) - 1 - mj))) < 1e-4
k2 = sig ** 2 * T + lam * T * (mj ** 2 + d ** 2)
k3 = lam * T * (mj ** 3 + 3 * mj * d ** 2)
k4 = lam * T * (mj ** 4 + 6 * mj ** 2 * d ** 2 + 3 * d ** 4)
m = bkm_moments(K, C, P, F, T)
assert abs(m["variance"] - k2 / T) < 1e-3
assert abs(m["skew"] - k3 / k2 ** 1.5) < 2e-3 and abs(m["kurtosis"] - (3 + k4 / k2 ** 2)) < 5e-3
def test_cboe_discrete_and_truncation():
Ks = np.arange(40000, 140001, 500.0)
c, p = black76(F, Ks, T, 0.6, True), black76(F, Ks, T, 0.6, False)
assert abs(cboe_variance(Ks, c, p, F, T)["vol"] - 0.6) < 2e-4
narrow = np.arange(72000, 96001, 2000.0)
short = cboe_variance(narrow, black76(F, narrow, T, 0.6, True), black76(F, narrow, T, 0.6, False), F, T)
assert short["vol"] < 0.58 and short["k0"] == 82000.0 # truncation biases the index down
bids = np.where((Ks < 60000) & (Ks > 58000), 0.0, 1.0) # two zero bids at 58.5k and 59k...
bids[(Ks == 58500) | (Ks == 59000)] = 0.0
cut = cboe_variance(Ks, c, p, F, T, call_bids=np.ones_like(Ks), put_bids=bids)
assert cut["k_min"] >= 59500 # ...stop the put wing there
def test_constant_maturity_forward_vols_and_swaps():
assert abs(constant_maturity(20 / 365, 0.25, 40 / 365, 0.25) - 0.25) < 1e-12
v = constant_maturity(20 / 365, 0.16, 40 / 365, 0.36)
assert 0.16 < v < 0.36
rows = [{"T": 0.1, "days": 36.5, "k_var": 0.36}, {"T": 0.2, "days": 73, "k_var": 0.25},
{"T": 0.3, "days": 109.5, "k_var": 0.1}]
fv = forward_vols(rows)
assert abs(fv[0]["fwd_vol"] - np.sqrt((0.05 - 0.036) / 0.1)) < 1e-12 and fv[1]["calendar_arbitrage"]
assert vol_swap_strike(0.36, 1.0, 0.0) == 0.6 and vol_swap_strike(None, 1, 1) is None
def test_vrp_history_short_variance_pays_when_implied_exceeds_realised():
rng = np.random.default_rng(3)
n = 700
d0 = date(2024, 1, 1)
dates = [d0 + timedelta(days=i) for i in range(n)]
closes = 50000 * np.exp(np.cumsum(rng.normal(0, 0.4 / np.sqrt(365), n)))
idx_dates = [x.isoformat() for x in dates]
rep = vrp_history(idx_dates, [50.0] * n, dates, closes, 30, 365)
assert rep["n"] == n - 30 and rep["hit_rate"] > 0.9
assert abs(rep["mean_vrp"] - 0.1) < 0.03 and rep["mean_short_var_pnl"] > 0
assert rep["series"][-1]["realised_fwd"] is None and rep["current_implied"] == 0.5
assert rep["volvol"] == 0.0
def _patch(monkeypatch):
opts, futs = book(days_list=(9, 30, 58))
monkeypatch.setattr(var_router, "_chain", lambda ccy: (opts, 83000.0, NOW_MS, 46.0))
rng = np.random.default_rng(1)
dates = [date(2024, 1, 1) + timedelta(days=i) for i in range(1000)]
closes = list(83000 * np.exp(np.cumsum(rng.normal(0, 0.4 / np.sqrt(365), 1000))))
lv = list(45 + 5 * np.sin(np.arange(1000) / 40))
monkeypatch.setattr(var_router, "_history", lambda pair: (dates, closes, [d.isoformat() for d in dates], lv, "DVOL"))
def test_crypto_endpoint(monkeypatch):
_patch(monkeypatch)
r = client.get("/api/variance/crypto/BTC")
assert r.status_code == 200
d = r.json()
assert [round(e["days"]) for e in d["expiries"]] == [9, 30, 58]
for e in d["expiries"]:
assert e["listed"] and abs(e["listed"]["vol"] - e["var_vol"]) < 0.04 # listed strikes vs wide integral
assert e["var_vol"] > e["atm_iv"] # skewed smile: K_var above ATM
assert e["moments"]["skew"] < 0 and e["moments"]["kurtosis"] > 3
assert e["density"]["physical"] and abs(e["density_mass"] - 1) < 1e-3
cm = d["constant_maturity_30d"]
assert cm and not cm["extrapolated"] and cm["listed_vol"] and d["live_index"] == 46.0
assert d["vrp"]["n"] == 700 and len(d["vrp"]["series"]) == 730 and d["vrp"]["index"] == "DVOL" and d["trailing_realised"]
assert all(not f["calendar_arbitrage"] for f in d["forward_vols"])
assert client.get("/api/variance/crypto/XRP").status_code == 404
def test_crypto_endpoint_offline(monkeypatch):
def boom(ccy):
raise RuntimeError("down")
monkeypatch.setattr(var_router, "_chain", boom)
assert client.get("/api/variance/crypto/ETH").status_code == 503
def test_smile_endpoint_for_metals(monkeypatch):
monkeypatch.setattr(var_router, "_history", lambda pair: (None, None, None, None, None))
body = {"pair": "XAUUSD", "S": 4100, "r_d": 0.04, "r_f": 0.0,
"tenors": [{"T": 1 / 12, "atm": 0.2, "rr25": 0.0, "bf25": 0.0},
{"T": 0.25, "atm": 0.22, "rr25": 0.015, "bf25": 0.004}]}
r = client.post("/api/variance/smile", json=body)
assert r.status_code == 200
d = r.json()
e1, e3 = d["expiries"]
assert abs(e1["var_vol"] - 0.2) < 2e-3 and abs(e1["moments"]["skew"]) < 0.02 # flat quotes → BS
assert e3["moments"]["skew"] > 0 and e3["var_vol"] > 0.22 # call skew (gold RR > 0)
assert d["forward_vols"][0]["fwd_vol"] > 0.22 and d["vrp"] is None and d["volvol"] == 0.8
assert client.post("/api/variance/smile", json={**body, "pair": "XXXYYY"}).status_code == 404
+4
View File
@@ -75,6 +75,10 @@ export const fetchPositioning = (ccy, convention = 'classic', maxDays = 120) =>
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)
// Variance Desk (var / vol swaps, implied moments and density, VRP)
export const fetchVarianceCrypto = ({ ccy }) => api.get(`/variance/crypto/${ccy}`, { timeout: 60000 }).then(r=>r.data)
export const fetchVarianceSmile = (p) => api.post('/variance/smile', p, { timeout: 60000 }).then(r=>r.data)
// Margin Desk
export const fetchMarginBook = (p) => api.post('/margin/book', p, { timeout: 60000 }).then(r=>r.data)
+1 -1
View File
@@ -46,7 +46,7 @@ export default function DeskIndex({ onClose, onSelect, active }) {
<div className="nx-index-search">
<Search size={17} color="var(--text-muted)" aria-hidden="true" />
<input autoFocus value={q} onChange={e => { setQ(e.target.value); setCursor(0) }}
placeholder="Search 28 desks, models or instruments" aria-label="Search desks"
placeholder={`Search ${DESKS.length} desks, models or instruments`} aria-label="Search desks"
role="combobox" aria-expanded="true" aria-controls="nx-index-list" />
<span className="nx-kbd">Esc</span>
</div>
+305
View File
@@ -0,0 +1,305 @@
import { useEffect, useState } from 'react'
import { Plus, RefreshCw, X } from 'lucide-react'
import _Plot from 'react-plotly.js'
const Plot = _Plot?.default ?? _Plot
import { usePortfolioStore, isCrypto, PAIR_META } from '../store/portfolio'
import { fetchVarianceCrypto, fetchVarianceSmile } from '../api/client'
import KpiStrip from './KpiStrip'
const LAYOUT = {
paper_bgcolor:'transparent', plot_bgcolor:'#0a1511',
font:{ family:"'Geist Variable', sans-serif", color:'#9eab9f', size:11 },
margin:{ l:56, r:16, t:12, b:42 },
legend:{ bgcolor:'transparent', font:{ size:10.5 }, orientation:'h', x:0, y:-0.22 },
hoverlabel:{ bgcolor:'#0f1d18', font:{ color:'#ebe5d3' }, bordercolor:'rgba(169,154,255,0.15)' },
}
const CFG = { responsive:true, displayModeBar:false }
const axis = (title, extra = {}) => ({ title:{ text:title }, gridcolor:'#1c2e26', color:'#7e8f84', zeroline:false, ...extra })
const BONE = '#e4dcc3', BLUE = '#86abea', MAUVE = '#d58fc0', AXIS = '#7e8f84', WARN = '#f0914a'
const minus = (s) => s.replace(/^-/, '−')
const pct = (v, d = 1) => (v == null ? '—' : minus((v * 100).toFixed(d)) + '%')
const pts = (v, d = 1) => (v == null ? '—' : minus(`${v >= 0 ? '+' : ''}${(v * 100).toFixed(d)}`) + ' vol')
const prob = (v) => (v == null ? '—' : `${(v * 100).toFixed(v < 0.1 ? 1 : 0)}%`)
const px = (v) => (v == null ? '—' : Number(v).toLocaleString(undefined, { maximumFractionDigits: v > 1000 ? 0 : v > 10 ? 2 : 4 }))
const Chip = ({ active, onClick, children }) => (
<button onClick={onClick} className="font-mono" style={{ padding:'3px 9px', borderRadius:4, fontSize:10.5, cursor:'pointer',
display:'inline-flex', alignItems:'center', gap:4,
border:'1px solid ' + (active ? 'var(--cyan)' : 'var(--border)'), background: active ? 'rgba(169,154,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:11 }}>{title}</span>{right}
</div>
{children}
</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%' }
const th = (h, i) => <th key={h} style={{ padding:'4px 8px', textAlign: i ? 'right' : 'left', fontWeight:500 }}>{h}</th>
const td = (v, extra = {}) => <td style={{ padding:'5px 8px', ...extra }}>{v}</td>
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 defaultTenors = (pair, sigma, smileQuotes) => {
const metal = PAIR_META[pair]?.assetClass === 'metal'
const atm = smileQuotes?.atm ?? +(sigma * 100).toFixed(2)
const rr = smileQuotes?.rr25 ?? (metal ? 1.0 : -0.5)
const bf = smileQuotes?.bf25 ?? 0.3
return [1 / 12, 0.25, 0.5].map((T, i) => ({ id:i, T:+T.toFixed(4), atm, rr25:rr, bf25:bf }))
}
export default function VarianceDesk() {
const { pair } = usePortfolioStore()
return <Desk key={pair} pair={pair} />
}
function Desk({ pair }) {
const { S, sigma, r_d, r_f, smileQuotes } = usePortfolioStore()
const crypto = isCrypto(pair)
const [mode, setMode] = useState(crypto ? 'live' : 'quotes')
const [nonce, setNonce] = useState(0)
const [tenors, setTenors] = useState(() => defaultTenors(pair, sigma, smileQuotes))
const [volvol, setVolvol] = useState('')
const live = mode === 'live' && crypto
const liveRes = useLoad(fetchVarianceCrypto, JSON.stringify({ ccy: pair.slice(0, 3), nonce }), live)
const body = { pair, S, r_d, r_f, tenors: tenors.filter(t => t.T > 0 && t.atm > 0)
.map(t => ({ T:t.T, atm:t.atm / 100, rr25:t.rr25 / 100, bf25:t.bf25 / 100 })), ...(volvol !== '' ? { volvol:+volvol } : {}) }
const quoteRes = useLoad(fetchVarianceSmile, JSON.stringify(body), !live && body.tenors.length > 0)
const res = live ? liveRes : quoteRes
const d = res.data
const setT = (id, k, v) => setTenors(ts => ts.map(t => (t.id === id ? { ...t, [k]: v } : t)))
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" style={{ color:'var(--text-muted)', letterSpacing:'0.08em' }}>{pair}{live ? ' · Deribit' : ' · quotes'}</div>
<div style={{ fontSize:10.5, color:'var(--text-muted)', marginTop:2 }}>
What variance costs, what the options say about the tails, and whether selling vol has paid
</div>
</div>
<div style={{ display:'flex', gap:4, alignItems:'center', flexWrap:'wrap' }}>
{crypto && <Chip active={live} onClick={() => setMode('live')}>Live Deribit chain</Chip>}
<Chip active={!live} onClick={() => setMode('quotes')}>ATM / RR / BF quotes</Chip>
{live && <Chip onClick={() => setNonce(n => n + 1)}><RefreshCw size={13} aria-hidden="true" />Refresh</Chip>}
</div>
</div>
{!live && (
<div className="glass" style={{ borderRadius:8, padding:10, marginBottom:12 }}>
<table style={{ width:'100%', borderCollapse:'collapse', fontSize:11 }}>
<thead><tr style={{ color:'var(--text-muted)', fontSize:10.5 }}>
{['Tenor (years)', 'ATM vol (%)', '25Δ RR (vol pts)', '25Δ BF (vol pts)', ''].map((h, i) => <th key={i} style={{ textAlign:'left', padding:'2px 4px', fontWeight:500 }}>{h}</th>)}
</tr></thead>
<tbody>{tenors.map(t => (
<tr key={t.id}>
{['T', 'atm', 'rr25', 'bf25'].map(k => (
<td key={k} style={{ padding:'2px 4px' }}>
<input type="number" aria-label={`${k} for tenor ${t.T}`} value={t[k]} step={k === 'T' ? 0.05 : k === 'atm' ? 0.5 : 0.1}
onChange={e => setT(t.id, k, +e.target.value)} style={inputStyle} />
</td>))}
<td style={{ width:28 }}>
{tenors.length > 1 && <button aria-label="Remove tenor" onClick={() => setTenors(ts => ts.filter(x => x.id !== t.id))}
style={{ background:'transparent', border:'none', color:'var(--text-muted)', cursor:'pointer' }}><X size={14} /></button>}
</td>
</tr>))}</tbody>
</table>
<div style={{ display:'flex', gap:10, alignItems:'center', marginTop:8, flexWrap:'wrap' }}>
<Chip onClick={() => setTenors(ts => [...ts, { ...ts[ts.length - 1], id: Math.max(...ts.map(x => x.id)) + 1, T: +(ts[ts.length - 1].T * 2).toFixed(4) }])}>
<Plus size={13} aria-hidden="true" />Tenor</Chip>
<label style={{ fontSize:10.5, color:'var(--text-muted)', display:'flex', gap:6, alignItems:'center' }}>Vol of vol ν
<input type="number" step={0.05} placeholder="from index" value={volvol} onChange={e => setVolvol(e.target.value)} style={{ ...inputStyle, width:90 }} />
</label>
<span style={{ fontSize:10.5, color:'var(--text-muted)' }}>
{smileQuotes ? 'Pre-filled from Vol Smile / Vol Lab. ' : 'Enter your desk quotes per tenor. '}Each tenor's vanna-volga smile is smoothed with raw SVI before replication.
</span>
</div>
</div>
)}
{res.error && <div style={{ padding:8, borderRadius:6, fontSize:11, marginBottom:10, background:'rgba(238,107,91,0.1)', border:'1px solid #ee6b5b', color:'#ee6b5b' }}>{res.error}</div>}
{!d && !res.error && <div style={{ fontSize:11, color:'var(--text-muted)', padding:12 }}>Replicating variance…</div>}
{d && <Body key={d.expiries.map(e => e.expiry).join()} d={d} loading={res.loading} />}
</div>
)
}
function Body({ d, loading }) {
const ref = d.expiries.reduce((a, e) => (Math.abs(e.days - 30) < Math.abs(a.days - 30) ? e : a), d.expiries[0])
const [sel, setSel] = useState(ref.expiry)
const e = d.expiries.find(x => x.expiry === sel) ?? ref
const cm = d.constant_maturity_30d
const v = d.vrp
const m = e.moments
const replicated = cm ? (cm.listed_vol ?? cm.continuous_vol) : null
const arb = d.forward_vols.filter(f => f.calendar_arbitrage)
return (
<div style={{ opacity: loading ? 0.6 : 1, transition:'opacity .2s' }}>
<KpiStrip items={[
cm && { label: e.listed ? '30d variance, replicated' : '30d variance strike', value:pct(replicated, 2),
sub: d.live_index != null ? `${d.live_index_label} ${d.live_index.toFixed(2)} · gap ${pts(replicated - d.live_index / 100, 2)}` : `${cm.from_days.toFixed(0)}d / ${cm.to_days.toFixed(0)}d interpolated`,
hint:'Cboe VIX method on the listed strikes, interpolated to 30 days' },
{ label:`Var-swap strike · ${e.expiry}`, value:pct(e.var_vol, 2), sub:`ATM ${pct(e.atm_iv, 2)} · skew premium ${pts(e.var_vol - e.atm_iv, 2)}` },
{ label:'Vol-swap strike', value:pct(e.vol_swap, 2), sub:`convexity ${pts(e.vol_swap - e.var_vol, 2)} · ν ${d.volvol?.toFixed(2)}` },
{ label:'Implied skew / kurtosis', value:`${minus(m.skew?.toFixed(2) ?? '—')} / ${m.kurtosis?.toFixed(2) ?? '—'}`,
sub:'log return, BKM (normal = 0 / 3)', color: m.skew < -0.3 ? WARN : undefined },
{ label:'Expected move to expiry', value:`±${pct(e.expected_move_pct, 2)}`, sub:`±${px(e.expected_move)} · ATM straddle` },
d.trailing_realised != null && { label:'Realised 30d vs var strike', value:pct(d.trailing_realised, 1),
sub:`premium ${pts(e.var_vol - d.trailing_realised, 1)}`, color: e.var_vol - d.trailing_realised < 0 ? 'var(--red)' : undefined },
v && v.hit_rate != null && { label:`Short 30d variance won`, value:prob(v.hit_rate),
sub:`${v.n} days · avg ${pts(v.mean_vrp, 1)} · worst ${pts(v.worst_short_var_pnl, 1)}`, hint:`${v.index}: implied at t vs realised over the next 30 days` },
]} />
{arb.length > 0 && <div style={{ fontSize:11, margin:'10px 0', padding:'6px 10px', borderRadius:6, border:`1px solid ${WARN}`, color:WARN }}>
Calendar arbitrage: total variance falls between {arb.map(a => `${a.from_days.toFixed(0)}d → ${a.to_days.toFixed(0)}d`).join(', ')}.</div>}
<div style={{ display:'grid', gridTemplateColumns:'repeat(auto-fit, minmax(440px, 1fr))', gap:12, marginTop:12 }}>
<Card title="Term structure — ATM, variance, vol swap, forward vol">
<TermChart d={d} />
</Card>
<Card title={`Implied distribution · ${e.expiry} (${e.days.toFixed(0)}d)`}
right={<div style={{ display:'flex', gap:4, flexWrap:'wrap' }}>{d.expiries.map(x =>
<Chip key={x.expiry} active={x.expiry === e.expiry} onClick={() => setSel(x.expiry)}>{x.expiry.length > 6 ? x.expiry.slice(5) : x.expiry}</Chip>)}</div>}>
<DensityChart e={e} />
</Card>
</div>
<div style={{ display:'grid', gridTemplateColumns:'repeat(auto-fit, minmax(440px, 1fr))', gap:12, marginTop:12 }}>
<Card title={`Probability ladder · ${e.expiry} · F ${px(e.forward)}`}>
<div style={{ overflowX:'auto' }}>
<table style={{ width:'100%', borderCollapse:'collapse', fontSize:11 }}>
<thead><tr style={{ color:'var(--text-muted)', fontSize:10.5 }}>
{['Move', 'Level', 'Finish beyond', 'Touch (≈2×)', 'History, same horizon', 'Options vs history'].map(th)}</tr></thead>
<tbody>{e.ladder.map(r => {
const ratio = r.p_physical_beyond >= 0.01 ? r.p_terminal_beyond / r.p_physical_beyond : null // too few samples below 1%
return (
<tr key={r.move} className="font-mono" style={{ borderTop:'1px solid var(--border)', textAlign:'right' }}>
{td(minus(`${r.move > 0 ? '+' : ''}${(r.move * 100).toFixed(0)}%`), { textAlign:'left' })}
{td(px(r.level))}{td(prob(r.p_terminal_beyond))}{td(prob(r.p_touch))}
{td(prob(r.p_physical_beyond))}
{td(ratio == null || !isFinite(ratio) ? '—' : `${ratio.toFixed(2)}×`, { color: ratio > 1.5 ? WARN : undefined })}
</tr>)
})}</tbody>
</table>
</div>
<div style={{ fontSize:10.5, color:'var(--text-muted)', padding:'6px 8px', lineHeight:1.5 }}>
Finish beyond = P(S<sub>T</sub> past the level) from the Breeden-Litzenberger density. Touch uses the driftless reflection rule.
History counts demeaned overlapping returns of the same length; a ratio above 1 means the options pay more for that tail than history delivered (shown when history has at least 1%).
</div>
</Card>
{v && v.series?.length ? (
<Card title={`Variance risk premium · ${v.index} vs the 30 days that followed`}>
<VrpChart v={v} />
</Card>
) : (
<Card title="Variance risk premium">
<div style={{ fontSize:11, color:'var(--text-muted)', padding:12 }}>No implied-vol index history for {d.pair} (DVOL covers BTC/ETH; GVZ/VXSLV cover gold/silver), or the history source is offline.</div>
</Card>
)}
</div>
<div style={{ marginTop:12 }}>
<Card title="Expiry ladder — variance replication and implied moments">
<div style={{ overflowX:'auto' }}>
<table style={{ width:'100%', borderCollapse:'collapse', fontSize:11 }}>
<thead><tr style={{ color:'var(--text-muted)', fontSize:10.5 }}>
{['Expiry', 'Days', 'Forward', 'ATM', '√K_var', 'Listed (Cboe)', 'Truncation', 'Vol swap', 'Fwd vol', 'Skew', 'Kurt', '5% / 95%', 'SVI RMSE'].map(th)}</tr></thead>
<tbody>{d.expiries.map((x, i) => {
const f = i > 0 ? d.forward_vols[i - 1] : null
return (
<tr key={x.expiry} className="font-mono" onClick={() => setSel(x.expiry)}
style={{ borderTop:'1px solid var(--border)', textAlign:'right', cursor:'pointer', background: x.expiry === e.expiry ? 'rgba(169,154,255,0.06)' : undefined }}>
{td(x.expiry, { textAlign:'left' })}{td(x.days.toFixed(0))}{td(px(x.forward))}{td(pct(x.atm_iv, 1))}
{td(pct(x.var_vol, 2))}{td(x.listed ? `${pct(x.listed.vol, 2)} · ${x.listed.n_strikes}K` : '—')}
{td(x.truncation_gap == null ? '—' : pts(x.truncation_gap, 2))}{td(pct(x.vol_swap, 2))}
{td(f ? (f.calendar_arbitrage ? 'arb' : pct(f.fwd_vol, 1)) : '—', { color: f?.calendar_arbitrage ? WARN : undefined })}
{td(minus(x.moments.skew?.toFixed(2) ?? '—'))}{td(x.moments.kurtosis?.toFixed(2) ?? '—')}
{td(`${pct(x.quantiles['0.05'].move, 1)} / ${pct(x.quantiles['0.95'].move, 1)}`)}
{td(`${(x.svi_rmse * 100).toFixed(2)}`)}
</tr>)
})}</tbody>
</table>
</div>
</Card>
</div>
<div style={{ fontSize:10.5, color:'var(--text-muted)', marginTop:10, lineHeight:1.5 }}>
{d.source}. Variance swaps: Demeterfi, Derman, Kamal &amp; Zou (1999); discrete index per the Cboe VIX methodology (as Deribit DVOL).
Vol swap ≈ √K<sub>var</sub>·e<sup>−ν²T/6</sup> (lognormal realised variance, after Brockhaus &amp; Long 2000; understates the adjustment when jumps dominate).
Moments: Bakshi, Kapadia &amp; Madan (2003). Density: Breeden &amp; Litzenberger (1978) on raw SVI (Gatheral 2004).
VRP P&amp;L per unit vega notional: (K<sub>var</sub> − RV²)/(2√K<sub>var</sub>), before costs.
</div>
</div>
)
}
function TermChart({ d }) {
const xs = d.expiries.map(e => e.days)
const fwdX = d.forward_vols.flatMap(f => [f.from_days, f.to_days, null])
const fwdY = d.forward_vols.flatMap(f => [f.fwd_vol, f.fwd_vol, null].map(v => (v == null ? null : v * 100)))
return (
<Plot data={[
{ x:xs, y:d.expiries.map(e => e.atm_iv * 100), type:'scatter', mode:'lines+markers', name:'ATM', line:{ color:AXIS, width:1.5 } },
{ x:xs, y:d.expiries.map(e => e.var_vol * 100), type:'scatter', mode:'lines+markers', name:'√K_var (wide strikes)', line:{ color:BONE, width:2 } },
{ x:xs, y:d.expiries.map(e => (e.listed ? e.listed.vol * 100 : null)), type:'scatter', mode:'markers', name:'Listed strikes (Cboe)',
marker:{ color:BONE, symbol:'diamond-open', size:9 } },
{ x:xs, y:d.expiries.map(e => (e.vol_swap ?? 0) * 100), type:'scatter', mode:'lines', name:'Vol swap', line:{ color:MAUVE, width:1.5, dash:'dash' } },
{ x:fwdX, y:fwdY, type:'scatter', mode:'lines', name:'Forward vol', line:{ color:BLUE, width:1.5 } },
...(d.live_index != null ? [{ x:[30], y:[d.live_index], type:'scatter', mode:'markers', name:d.live_index_label ?? 'Vol index',
marker:{ color:BLUE, symbol:'star', size:12 } }] : []),
...(d.trailing_realised != null ? [{ x:[Math.min(...xs), Math.max(...xs)], y:[d.trailing_realised * 100, d.trailing_realised * 100], type:'scatter',
mode:'lines', name:'Realised 30d', line:{ color:AXIS, dash:'dot', width:1 } }] : []),
]}
layout={{ ...LAYOUT, hovermode:'x unified', margin:{ ...LAYOUT.margin, b:36 }, legend:{ ...LAYOUT.legend, y:-0.3 },
xaxis:axis('Days to expiry', { title:{ text:'Days to expiry', standoff:4 } }), yaxis:axis('Vol (%)') }}
config={CFG} style={{ width:'100%', height:340 }} />
)
}
function DensityChart({ e }) {
const den = e.density
const q = e.quantiles
return (
<Plot data={[
{ x:den.strikes, y:den.rnd, type:'scatter', mode:'lines', name:'Risk-neutral (options)', line:{ color:BONE, width:2 },
fill:'tozeroy', fillcolor:'rgba(228,220,195,0.07)' },
{ x:den.strikes, y:den.lognormal, type:'scatter', mode:'lines', name:'Lognormal, ATM vol', line:{ color:BLUE, width:1.3, dash:'dash' } },
...(den.physical ? [{ x:den.strikes, y:den.physical, type:'scatter', mode:'lines', name:'History (demeaned)', line:{ color:MAUVE, width:1.3 } }] : []),
]}
layout={{ ...LAYOUT, hovermode:'x unified', xaxis:axis('Price at expiry'), yaxis:axis('Density', { showticklabels:false }),
shapes:[e.forward, q['0.05'].level, q['0.95'].level].map((x, i) => ({ type:'line', x0:x, x1:x, yref:'paper', y0:0, y1:1,
line:{ color: i ? 'rgba(126,143,132,0.6)' : 'rgba(235,229,211,0.45)', dash: i ? 'dot' : 'dash', width:1 } })),
annotations:[['5%', q['0.05'].level], ['95%', q['0.95'].level]].map(([t, x]) => ({ x, yref:'paper', y:1, text:t, showarrow:false,
font:{ size:10, color:AXIS }, yanchor:'bottom' })) }}
config={CFG} style={{ width:'100%', height:340 }} />
)
}
function VrpChart({ v }) {
const s = v.series
return (
<Plot data={[
{ x:s.map(r => r.date), y:s.map(r => r.implied * 100), type:'scatter', mode:'lines', name:'Implied 30d (at t)', line:{ color:BLUE, width:1.5 } },
{ x:s.map(r => r.date), y:s.map(r => (r.realised_fwd == null ? null : r.realised_fwd * 100)), type:'scatter', mode:'lines',
name:'Realised over next 30d', line:{ color:BONE, width:1.3 } },
{ x:s.map(r => r.date), y:s.map(r => (r.vrp == null ? null : r.vrp * 100)), type:'bar', name:'Premium (vol pts)', yaxis:'y2',
marker:{ color:s.map(r => (r.vrp == null ? 'transparent' : r.vrp >= 0 ? 'rgba(79,198,144,0.35)' : 'rgba(238,107,91,0.45)')) } },
]}
layout={{ ...LAYOUT, hovermode:'x unified', bargap:0, xaxis:axis('', { type:'date' }), yaxis:axis('Vol (%)'),
yaxis2:{ overlaying:'y', side:'right', showgrid:false, zeroline:true, zerolinecolor:'#2a4137', color:AXIS, title:{ text:'Premium' } } }}
config={CFG} style={{ width:'100%', height:300 }} />
)
}
+2 -1
View File
@@ -1,7 +1,7 @@
import {
Sigma, ChartCandlestick, Zap, FlaskConical, BrainCircuit, CalendarClock, Magnet, ScanSearch,
Scale, ShieldCheck, Landmark, Bot, Activity, Puzzle, Target, Link2, Spline, WavesHorizontal,
Mountain, Crosshair, TriangleAlert, Dices, Building, CalendarDays, Earth, Radio, ChartColumn, Newspaper,
Mountain, Crosshair, TriangleAlert, Dices, Building, CalendarDays, Earth, Radio, ChartColumn, Newspaper, SquareSigma,
} from 'lucide-react'
// Desk registry: one source for the rail, the desk index (Ctrl K), routing and desk headers.
@@ -27,6 +27,7 @@ export const DESKS = [
{ id: 'smile', group: 'vol', label: 'Vol Smile', icon: Spline, meta: 'Vanna-Volga smile from ATM / 25Δ RR / 25Δ BF · Castagna-Mercurio' },
{ id: 'smiledyn', group: 'vol', label: 'Smile Dynamics', icon: WavesHorizontal, meta: 'SABR fits · sticky / Bartlett deltas · spot-vol regime' },
{ id: 'eventvol', group: 'vol', label: 'Event Vol', icon: CalendarClock, meta: 'Event and weekend vol time · forward vols · event moves' },
{ id: 'variance', group: 'vol', label: 'Variance Desk', icon: SquareSigma, meta: 'Var / vol swaps (Cboe-DVOL replication) · BKM moments · implied density · VRP' },
{ id: 'risk', group: 'risk', label: 'Risk Desk', icon: ShieldCheck, meta: 'Full-revaluation VaR / ES · stressed window · backtest · P&L explain' },
{ id: 'margin', group: 'risk', label: 'Margin Desk', icon: Landmark, meta: 'SPAN-style scan risk · portfolio margin · hike shocks · call odds' },
+1
View File
@@ -10,6 +10,7 @@ const COMPONENTS = {
vollab: lazy(() => import('../components/VolLab')),
volforecast: lazy(() => import('../components/VolForecast')),
eventvol: lazy(() => import('../components/EventVol')),
variance: lazy(() => import('../components/VarianceDesk')),
positioning: lazy(() => import('../components/Positioning')),
rvscan: lazy(() => import('../components/RvScanner')),
hedgelab: lazy(() => import('../components/HedgeLab')),
Binary file not shown.

After

Width:  |  Height:  |  Size: 378 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 372 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 356 KiB