diff --git a/PRODUCT.md b/PRODUCT.md index 6e35866..075c512 100644 --- a/PRODUCT.md +++ b/PRODUCT.md @@ -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. diff --git a/README.md b/README.md index 1544754..216331a 100644 --- a/README.md +++ b/README.md @@ -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 | diff --git a/backend/app/core/variance.py b/backend/app/core/variance.py new file mode 100644 index 0000000..5a3bba5 --- /dev/null +++ b/backend/app/core/variance.py @@ -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 + ∫_{KF} (6 ln(K/F) − 3 ln²(K/F))/K² C dK − ∫_{KF} (12 ln²(K/F) − 4 ln³(K/F))/K² C dK + ∫_{K 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, + } diff --git a/backend/app/main.py b/backend/app/main.py index 72ba117..20c1ad4 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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("/") diff --git a/backend/app/routers/variance.py b/backend/app/routers/variance.py new file mode 100644 index 0000000..6d2b871 --- /dev/null +++ b/backend/app/routers/variance.py @@ -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 diff --git a/backend/tests/test_variance.py b/backend/tests/test_variance.py new file mode 100644 index 0000000..408ad16 --- /dev/null +++ b/backend/tests/test_variance.py @@ -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 diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js index 1191d5c..bc937e1 100644 --- a/frontend/src/api/client.js +++ b/frontend/src/api/client.js @@ -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) diff --git a/frontend/src/components/DeskIndex.jsx b/frontend/src/components/DeskIndex.jsx index 0d05fea..8e109cd 100644 --- a/frontend/src/components/DeskIndex.jsx +++ b/frontend/src/components/DeskIndex.jsx @@ -46,7 +46,7 @@ export default function DeskIndex({ onClose, onSelect, active }) {
diff --git a/frontend/src/components/VarianceDesk.jsx b/frontend/src/components/VarianceDesk.jsx new file mode 100644 index 0000000..8ea2c5a --- /dev/null +++ b/frontend/src/components/VarianceDesk.jsx @@ -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 }) => ( + +) + +function Card({ title, right, children }) { + return ( +
+
+ {title}{right} +
+ {children} +
+ ) +} + +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) => {h} +const td = (v, extra = {}) => {v} + +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 +} + +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 ( +
+
+
+
{pair}{live ? ' · Deribit' : ' · quotes'}
+
+ What variance costs, what the options say about the tails, and whether selling vol has paid +
+
+
+ {crypto && setMode('live')}>Live Deribit chain} + setMode('quotes')}>ATM / RR / BF quotes + {live && setNonce(n => n + 1)}>} +
+
+ {!live && ( +
+ + + {['Tenor (years)', 'ATM vol (%)', '25Δ RR (vol pts)', '25Δ BF (vol pts)', ''].map((h, i) => )} + + {tenors.map(t => ( + + {['T', 'atm', 'rr25', 'bf25'].map(k => ( + ))} + + ))} +
{h}
+ setT(t.id, k, +e.target.value)} style={inputStyle} /> + + {tenors.length > 1 && } +
+
+ 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) }])}> + + + + {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. + +
+
+ )} + {res.error &&
{res.error}
} + {!d && !res.error &&
Replicating variance…
} + {d && e.expiry).join()} d={d} loading={res.loading} />} +
+ ) +} + +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 ( +
+ + {arb.length > 0 &&
+ Calendar arbitrage: total variance falls between {arb.map(a => `${a.from_days.toFixed(0)}d → ${a.to_days.toFixed(0)}d`).join(', ')}.
} +
+ + + + {d.expiries.map(x => + setSel(x.expiry)}>{x.expiry.length > 6 ? x.expiry.slice(5) : x.expiry})}
}> + + +
+
+ +
+ + + {['Move', 'Level', 'Finish beyond', 'Touch (≈2×)', 'History, same horizon', 'Options vs history'].map(th)} + {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 ( + + {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 })} + ) + })} +
+
+
+ Finish beyond = P(ST 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%). +
+
+ {v && v.series?.length ? ( + + + + ) : ( + +
No implied-vol index history for {d.pair} (DVOL covers BTC/ETH; GVZ/VXSLV cover gold/silver), or the history source is offline.
+
+ )} +
+
+ +
+ + + {['Expiry', 'Days', 'Forward', 'ATM', '√K_var', 'Listed (Cboe)', 'Truncation', 'Vol swap', 'Fwd vol', 'Skew', 'Kurt', '5% / 95%', 'SVI RMSE'].map(th)} + {d.expiries.map((x, i) => { + const f = i > 0 ? d.forward_vols[i - 1] : null + return ( + 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)}`)} + ) + })} +
+
+
+
+
+ {d.source}. Variance swaps: Demeterfi, Derman, Kamal & Zou (1999); discrete index per the Cboe VIX methodology (as Deribit DVOL). + Vol swap ≈ √Kvar·e−ν²T/6 (lognormal realised variance, after Brockhaus & Long 2000; understates the adjustment when jumps dominate). + Moments: Bakshi, Kapadia & Madan (2003). Density: Breeden & Litzenberger (1978) on raw SVI (Gatheral 2004). + VRP P&L per unit vega notional: (Kvar − RV²)/(2√Kvar), before costs. +
+ + ) +} + +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 ( + 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 ( + ({ 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 ( + 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 }} /> + ) +} diff --git a/frontend/src/desks.js b/frontend/src/desks.js index aa945ce..d9ad66d 100644 --- a/frontend/src/desks.js +++ b/frontend/src/desks.js @@ -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' }, diff --git a/frontend/src/pages/Dashboard.jsx b/frontend/src/pages/Dashboard.jsx index 35588a5..5f517f0 100644 --- a/frontend/src/pages/Dashboard.jsx +++ b/frontend/src/pages/Dashboard.jsx @@ -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')), diff --git a/screenshots/variance-btc.png b/screenshots/variance-btc.png new file mode 100644 index 0000000..a00e859 Binary files /dev/null and b/screenshots/variance-btc.png differ diff --git a/screenshots/variance-eth.png b/screenshots/variance-eth.png new file mode 100644 index 0000000..2a0df09 Binary files /dev/null and b/screenshots/variance-eth.png differ diff --git a/screenshots/variance-xau.png b/screenshots/variance-xau.png new file mode 100644 index 0000000..da41361 Binary files /dev/null and b/screenshots/variance-xau.png differ