mirror of
https://github.com/Mihirkansara/nexus-quant-terminal.git
synced 2026-11-02 09:36:11 +00:00
Merge pull request #3 from Mihirkansara/claude/daily-job-scheduling-pr27a6
Approved by the owner via email reply ("Approve"); merged manually because the reply parser missed the glued quoted text.
This commit is contained in:
@@ -38,6 +38,7 @@ NEXUS TERMINAL is a Bloomberg-style web application for professional forex optio
|
||||
| **Monte Carlo** | GBM path simulation — up to 5,000 paths, terminal P&L distribution, probability of profit |
|
||||
| **Strategy Library** | Pre-built multi-leg strategies: straddle, strangle, bull/bear spreads, iron condor, butterfly |
|
||||
| **Vol Smile (Vanna-Volga)** | Builds the FX smile from ATM / 25Δ risk reversal / 25Δ butterfly quotes (Castagna-Mercurio) and reprices each leg off the smile vs flat vol |
|
||||
| **Vol Lab** | Live Deribit BTC/ETH surface with a raw-SVI fit per expiry (Durrleman butterfly-arb check), ATM / 25Δ RR / 25Δ BF term structure, DVOL, vol risk premium, and a Burghardt-Lane realised-vol cone for every pair. Tests: `cd backend && python -m pytest tests` |
|
||||
|
||||
### Live Intelligence
|
||||
|
||||
|
||||
@@ -59,8 +59,8 @@ def _hurst_exponent(prices: np.ndarray) -> float:
|
||||
return 0.5
|
||||
|
||||
|
||||
def _estimate_ou_params(prices: np.ndarray) -> dict:
|
||||
"""OLS AR(1) → OU parameters. (Uhlenbeck & Ornstein 1930)"""
|
||||
def _estimate_ou_params(prices: np.ndarray, ppy: int = 252) -> dict:
|
||||
"""OLS AR(1) → OU parameters, annualised with ppy observations/year. (Uhlenbeck & Ornstein 1930)"""
|
||||
X = np.array(prices, dtype=float)
|
||||
Xl, Xc = X[:-1], X[1:]
|
||||
n = len(Xl)
|
||||
@@ -68,21 +68,25 @@ def _estimate_ou_params(prices: np.ndarray) -> dict:
|
||||
b_den = n * np.dot(Xl, Xl) - Xl.sum() ** 2
|
||||
b = float(np.clip(b_num / b_den if b_den else 0.999, 0.001, 0.9999))
|
||||
a = float(Xc.mean() - b * Xl.mean())
|
||||
resid_sigma = float(np.std(Xc - (a + b * Xl)) * np.sqrt(252))
|
||||
kappa = float(-np.log(b) * 252)
|
||||
resid_sigma = float(np.std(Xc - (a + b * Xl)) * np.sqrt(ppy))
|
||||
kappa = float(-np.log(b) * ppy)
|
||||
theta = float(a / (1 - b))
|
||||
half_life = float(np.log(2) / max(kappa, 1e-6) / 252 * 365)
|
||||
half_life = float(np.log(2) / max(kappa, 1e-6) * 365) # years → calendar days
|
||||
return {"kappa": round(kappa, 4), "theta": round(theta, 6),
|
||||
"sigma": round(resid_sigma, 6), "half_life_days": round(half_life, 1)}
|
||||
|
||||
|
||||
# ─── main signal engine ───────────────────────────────────────────────────────
|
||||
|
||||
def compute_signals(prices: list, r_d: float = 0.05, r_f: float = 0.04) -> dict:
|
||||
def compute_signals(prices: list, r_d: float = 0.05, r_f: float = 0.04,
|
||||
periods_per_year: int = 252) -> dict:
|
||||
"""
|
||||
Full quant signal suite — core analytics via gs-quant (Goldman Sachs),
|
||||
extended with OU, Hurst, VaR and carry trade signals.
|
||||
|
||||
periods_per_year: 252 for 24/5 FX & metals, 365 for 24/7 crypto.
|
||||
"""
|
||||
ppy = periods_per_year
|
||||
px_raw = np.array(prices, dtype=float)
|
||||
n = len(px_raw)
|
||||
if n < 10:
|
||||
@@ -95,10 +99,13 @@ def compute_signals(prices: list, r_d: float = 0.05, r_f: float = 0.04) -> dict:
|
||||
|
||||
# ── 1. Volatility (gs-quant econometrics.volatility) ────────────────────
|
||||
# GS implementation: annualized realized vol over rolling window
|
||||
hv20_s = gseco.volatility(px, w20) if GS_QUANT_AVAILABLE else None
|
||||
hv60_s = gseco.volatility(px, w60) if GS_QUANT_AVAILABLE else None
|
||||
hv20 = _safe_last(hv20_s, np.std(rets_raw[-w20:]) * np.sqrt(252))
|
||||
hv60 = _safe_last(hv60_s, np.std(rets_raw[-w60:]) * np.sqrt(252))
|
||||
# gs-quant returns percent annualised on 252 days; rescale for other calendars.
|
||||
# The fallback is expressed in percent too, so hv_*_pct is consistent.
|
||||
gs_scale = np.sqrt(ppy / 252)
|
||||
hv20_s = gseco.volatility(px, w20) * gs_scale if GS_QUANT_AVAILABLE else None
|
||||
hv60_s = gseco.volatility(px, w60) * gs_scale if GS_QUANT_AVAILABLE else None
|
||||
hv20 = _safe_last(hv20_s, np.std(rets_raw[-w20:]) * np.sqrt(ppy) * 100)
|
||||
hv60 = _safe_last(hv60_s, np.std(rets_raw[-w60:]) * np.sqrt(ppy) * 100)
|
||||
vol_regime = ("HIGH" if hv20 > hv60 * 1.25 else
|
||||
"LOW" if hv20 < hv60 * 0.80 else "NORMAL")
|
||||
|
||||
@@ -106,7 +113,7 @@ def compute_signals(prices: list, r_d: float = 0.05, r_f: float = 0.04) -> dict:
|
||||
lam, ewma_var = 0.94, float(np.var(rets_raw[-w20:]))
|
||||
for r in rets_raw[-w20:]:
|
||||
ewma_var = lam * ewma_var + (1 - lam) * r ** 2
|
||||
ewma_vol = float(np.sqrt(ewma_var * 252))
|
||||
ewma_vol = float(np.sqrt(ewma_var * ppy))
|
||||
|
||||
# GS max-drawdown (institutional risk metric)
|
||||
dd_s = gseco.max_drawdown(px, w60) if GS_QUANT_AVAILABLE else None
|
||||
@@ -128,8 +135,8 @@ def compute_signals(prices: list, r_d: float = 0.05, r_f: float = 0.04) -> dict:
|
||||
"RANDOM WALK": "No structural edge — vol strategies apply"}[hurst_regime]
|
||||
|
||||
# ── 4. Ornstein-Uhlenbeck (custom — Uhlenbeck & Ornstein 1930) ──────────
|
||||
ou = _estimate_ou_params(px_raw[-w60:])
|
||||
ou_std = ou["sigma"] / np.sqrt(max(ou["kappa"], 0.01) * 252)
|
||||
ou = _estimate_ou_params(px_raw[-w60:], ppy)
|
||||
ou_std = ou["sigma"] / np.sqrt(max(ou["kappa"], 0.01) * 2)
|
||||
|
||||
# Z-score via gs-quant statistics (institutional grade)
|
||||
z_s = gsstat.zscores(px, w20) if GS_QUANT_AVAILABLE else None
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
"""
|
||||
vol_analytics.py — Realised-vs-implied volatility toolkit for volatile assets
|
||||
(BTC, ETH, XAU, XAG, FX).
|
||||
|
||||
1. Volatility cone (Burghardt & Lane 1990): distribution of rolling close-to-close
|
||||
realised vol for several horizons, so a vol level can be judged against what
|
||||
the asset has actually delivered over comparable windows.
|
||||
σ_RV(n) = sqrt(ppy / n · Σ r_i²) (zero-mean estimator, r = log returns)
|
||||
ppy = 365 for 24/7 crypto, 252 for 24/5 FX & metals.
|
||||
|
||||
2. Listed-option surface (Deribit BTC/ETH): each expiry's mark IVs are fitted with
|
||||
Gatheral's raw SVI total-variance smile
|
||||
w(k) = a + b·(ρ·(k − m) + sqrt((k − m)² + σ²)), k = ln(K/F), w = σ_imp²·T
|
||||
and checked for butterfly arbitrage with Durrleman's condition g(k) ≥ 0
|
||||
(Gatheral & Jacquier 2014). ATM (k = 0), 25Δ risk reversal and 25Δ butterfly are
|
||||
then read off the fit using Black-76 forward deltas (Deribit options are
|
||||
quoted on the forward/future, delta not premium-adjusted here).
|
||||
|
||||
3. Vol risk premium: 30-day constant-maturity ATM IV (linear in total variance
|
||||
across expiries) minus 30-day realised vol.
|
||||
|
||||
References:
|
||||
Burghardt, G. & Lane, M. (1990). "How to tell if options are cheap."
|
||||
Journal of Portfolio Management, 16(2), 72–78.
|
||||
Gatheral, J. (2004). "A parsimonious arbitrage-free implied volatility
|
||||
parameterization..." Global Derivatives & Risk Management, Madrid.
|
||||
Gatheral, J. & Jacquier, A. (2014). "Arbitrage-free SVI volatility surfaces."
|
||||
Quantitative Finance, 14(1), 59–71.
|
||||
Carr, P. & Wu, L. (2009). "Variance risk premiums." Review of Financial
|
||||
Studies, 22(3), 1311–1341.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import numpy as np
|
||||
from scipy.optimize import brentq, least_squares
|
||||
from scipy.stats import norm
|
||||
|
||||
# Cone horizons in calendar days → bars via the asset's observation calendar.
|
||||
CONE_TENORS = [("1W", 7), ("2W", 14), ("1M", 30), ("2M", 60), ("3M", 91), ("6M", 182)]
|
||||
_MONTHS = {m: i + 1 for i, m in enumerate(
|
||||
["JAN", "FEB", "MAR", "APR", "MAY", "JUN", "JUL", "AUG", "SEP", "OCT", "NOV", "DEC"])}
|
||||
_YEAR_MS = 365.0 * 24 * 3600 * 1000
|
||||
|
||||
|
||||
# ─── 1. Realised volatility cone ──────────────────────────────────────────────
|
||||
|
||||
def rolling_realised_vol(log_returns: np.ndarray, window: int, ppy: int) -> np.ndarray:
|
||||
"""Annualised zero-mean rolling realised vol for every full window."""
|
||||
r2 = np.asarray(log_returns, dtype=float) ** 2
|
||||
if len(r2) < window:
|
||||
return np.array([])
|
||||
csum = np.concatenate([[0.0], np.cumsum(r2)])
|
||||
return np.sqrt((csum[window:] - csum[:-window]) * ppy / window)
|
||||
|
||||
|
||||
def vol_cone(closes, ppy: int = 252) -> dict:
|
||||
"""Percentile bands of rolling realised vol per horizon plus the current reading."""
|
||||
px = np.asarray(closes, dtype=float)
|
||||
px = px[np.isfinite(px) & (px > 0)]
|
||||
rets = np.diff(np.log(px))
|
||||
tenors = []
|
||||
for label, days in CONE_TENORS:
|
||||
window = max(2, int(round(days * ppy / 365)))
|
||||
rv = rolling_realised_vol(rets, window, ppy)
|
||||
if len(rv) < 20: # need a meaningful distribution
|
||||
continue
|
||||
pct = np.percentile(rv, [0, 10, 25, 50, 75, 90, 100])
|
||||
current = float(rv[-1])
|
||||
tenors.append({
|
||||
"tenor": label, "days": days, "window": window,
|
||||
"min": pct[0], "p10": pct[1], "p25": pct[2], "median": pct[3],
|
||||
"p75": pct[4], "p90": pct[5], "max": pct[6],
|
||||
"current": current,
|
||||
"current_percentile": float((rv < current).mean() * 100),
|
||||
})
|
||||
# EWMA (RiskMetrics λ=0.94) one-step forecast for context.
|
||||
ewma = None
|
||||
if len(rets) >= 30:
|
||||
var = float(np.var(rets[:30]))
|
||||
for r in rets[30:]:
|
||||
var = 0.94 * var + 0.06 * r * r
|
||||
ewma = float(np.sqrt(var * ppy))
|
||||
return {
|
||||
"periods_per_year": ppy,
|
||||
"n_returns": int(len(rets)),
|
||||
"ewma_vol": ewma,
|
||||
"tenors": [{k: (round(v, 6) if isinstance(v, float) else v) for k, v in t.items()}
|
||||
for t in tenors],
|
||||
}
|
||||
|
||||
|
||||
# ─── 2. Deribit option surface ────────────────────────────────────────────────
|
||||
|
||||
def parse_deribit_instrument(name: str):
|
||||
"""'BTC-27DEC24-60000-C' → (expiry datetime 08:00 UTC, strike, 'call'|'put') or None."""
|
||||
try:
|
||||
_, exp, strike, cp = name.split("-")
|
||||
day, mon, yr = int(exp[:-5]), _MONTHS[exp[-5:-2]], 2000 + int(exp[-2:])
|
||||
expiry = datetime(yr, mon, day, 8, 0, tzinfo=timezone.utc)
|
||||
strike = float(strike.replace("d", "."))
|
||||
return expiry, strike, ("call" if cp.upper() == "C" else "put")
|
||||
except (ValueError, KeyError):
|
||||
return None
|
||||
|
||||
|
||||
def svi_total_variance(k, a, b, rho, m, sig):
|
||||
k = np.asarray(k, dtype=float)
|
||||
return a + b * (rho * (k - m) + np.sqrt((k - m) ** 2 + sig ** 2))
|
||||
|
||||
|
||||
def svi_butterfly_ok(params, k_lo=-1.5, k_hi=1.5, n=301) -> bool:
|
||||
"""Durrleman's condition g(k) ≥ 0 on a grid (no butterfly arbitrage) and w > 0."""
|
||||
a, b, rho, m, sig = params
|
||||
k = np.linspace(k_lo, k_hi, n)
|
||||
x = k - m
|
||||
root = np.sqrt(x ** 2 + sig ** 2)
|
||||
w = a + b * (rho * x + root)
|
||||
w1 = b * (rho + x / root)
|
||||
w2 = b * sig ** 2 / root ** 3
|
||||
if np.any(w <= 0):
|
||||
return False
|
||||
g = (1 - k * w1 / (2 * w)) ** 2 - (w1 ** 2 / 4) * (1 / w + 0.25) + w2 / 2
|
||||
return bool(np.all(g >= -1e-9))
|
||||
|
||||
|
||||
def fit_svi(k, iv, T):
|
||||
"""Least-squares raw-SVI fit in implied-vol space. Returns (params, rmse_vol)."""
|
||||
k = np.asarray(k, dtype=float)
|
||||
iv = np.asarray(iv, dtype=float)
|
||||
w_mkt = iv ** 2 * T
|
||||
wmax = float(w_mkt.max())
|
||||
kspan = float(max(k.max() - k.min(), 0.05))
|
||||
|
||||
def resid(p):
|
||||
a, b, rho, m, sig = p
|
||||
w = svi_total_variance(k, a, b, rho, m, sig)
|
||||
model_iv = np.sqrt(np.maximum(w, 1e-10) / T)
|
||||
# Penalise a negative variance minimum: a + b·σ·sqrt(1−ρ²) ≥ 0.
|
||||
floor = min(0.0, a + b * sig * np.sqrt(1 - rho ** 2))
|
||||
return np.append(model_iv - iv, 10.0 * floor)
|
||||
|
||||
lo = [-wmax, 0.0, -0.999, float(k.min()) - kspan, 1e-3]
|
||||
hi = [wmax, 10.0 * wmax / max(kspan, 1e-3) + 1.0, 0.999, float(k.max()) + kspan, 2.0]
|
||||
atm_w = float(np.interp(0.0, np.sort(k), w_mkt[np.argsort(k)]))
|
||||
best = None
|
||||
for rho0 in (-0.5, 0.0, 0.5):
|
||||
for sig0 in (0.05, 0.2):
|
||||
x0 = np.clip([0.5 * atm_w, 0.1 * wmax / sig0 + 1e-4, rho0, 0.0, sig0], lo, hi)
|
||||
try:
|
||||
res = least_squares(resid, x0, bounds=(lo, hi), method="trf", max_nfev=2000)
|
||||
except ValueError:
|
||||
continue
|
||||
if best is None or res.cost < best.cost:
|
||||
best = res
|
||||
params = tuple(float(v) for v in best.x)
|
||||
rmse = float(np.sqrt(np.mean(best.fun[:-1] ** 2)))
|
||||
return params, rmse
|
||||
|
||||
|
||||
def _svi_vol(params, k, T):
|
||||
return float(np.sqrt(max(svi_total_variance(k, *params), 1e-10) / T))
|
||||
|
||||
|
||||
def _k_for_forward_delta(params, T, target):
|
||||
"""Log-moneyness where the Black-76 forward call delta N(d1) equals target."""
|
||||
def f(k):
|
||||
s = _svi_vol(params, k, T)
|
||||
d1 = (-k + 0.5 * s * s * T) / (s * np.sqrt(T))
|
||||
return norm.cdf(d1) - target
|
||||
return brentq(f, -5.0, 5.0)
|
||||
|
||||
|
||||
def build_crypto_surface(summaries, now_ms: float, min_points: int = 5,
|
||||
min_days: float = 1.0) -> list:
|
||||
"""
|
||||
Group Deribit option book summaries by expiry and fit an SVI smile to each.
|
||||
Uses out-of-the-money mark IVs only (calls K ≥ F, puts K < F).
|
||||
"""
|
||||
by_exp = {}
|
||||
for row in summaries:
|
||||
parsed = parse_deribit_instrument(row.get("instrument_name", ""))
|
||||
iv = row.get("mark_iv")
|
||||
F = row.get("underlying_price")
|
||||
if not parsed or not iv or not F or iv <= 0 or F <= 0:
|
||||
continue
|
||||
expiry, K, cp = parsed
|
||||
by_exp.setdefault(expiry, []).append((K, cp, iv / 100.0, float(F),
|
||||
float(row.get("open_interest") or 0)))
|
||||
|
||||
expiries = []
|
||||
for expiry, rows in sorted(by_exp.items()):
|
||||
T = (expiry.timestamp() * 1000 - now_ms) / _YEAR_MS
|
||||
if T * 365 < min_days:
|
||||
continue
|
||||
F = float(np.median([r[3] for r in rows]))
|
||||
otm = {}
|
||||
for K, cp, iv, _, oi in rows:
|
||||
if (cp == "call" and K >= F) or (cp == "put" and K < F):
|
||||
otm[K] = (iv, oi)
|
||||
if len(otm) < min_points:
|
||||
continue
|
||||
strikes = np.array(sorted(otm))
|
||||
ivs = np.array([otm[K][0] for K in strikes])
|
||||
k = np.log(strikes / F)
|
||||
try:
|
||||
params, rmse = fit_svi(k, ivs, T)
|
||||
except Exception:
|
||||
continue
|
||||
atm = _svi_vol(params, 0.0, T)
|
||||
try:
|
||||
k25c = _k_for_forward_delta(params, T, 0.25)
|
||||
k25p = _k_for_forward_delta(params, T, 0.75)
|
||||
v25c, v25p = _svi_vol(params, k25c, T), _svi_vol(params, k25p, T)
|
||||
rr25, bf25 = v25c - v25p, 0.5 * (v25c + v25p) - atm
|
||||
K25c, K25p = F * np.exp(k25c), F * np.exp(k25p)
|
||||
except ValueError:
|
||||
v25c = v25p = rr25 = bf25 = K25c = K25p = None
|
||||
k_grid = np.linspace(k.min(), k.max(), 60)
|
||||
expiries.append({
|
||||
"expiry": expiry.strftime("%Y-%m-%d"),
|
||||
"label": expiry.strftime("%d%b%y").upper(),
|
||||
"days": round(T * 365, 2), "T": round(T, 6), "forward": round(F, 4),
|
||||
"atm_vol": atm, "rr25": rr25, "bf25": bf25,
|
||||
"vol_25c": v25c, "vol_25p": v25p, "K_25c": K25c, "K_25p": K25p,
|
||||
"svi": dict(zip(["a", "b", "rho", "m", "sigma"], params)),
|
||||
"fit_rmse": rmse,
|
||||
"butterfly_arb_free": svi_butterfly_ok(params),
|
||||
"open_interest": float(sum(v[1] for v in otm.values())),
|
||||
"market": {"strikes": strikes.tolist(), "iv": ivs.tolist()},
|
||||
"fit": {"strikes": (F * np.exp(k_grid)).tolist(),
|
||||
"iv": [_svi_vol(params, x, T) for x in k_grid]},
|
||||
})
|
||||
return expiries
|
||||
|
||||
|
||||
def constant_maturity_atm(expiries: list, days: float):
|
||||
"""ATM vol at a fixed tenor, linear in total variance between listed expiries."""
|
||||
pts = sorted((e["T"], e["atm_vol"] ** 2 * e["T"]) for e in expiries)
|
||||
if not pts:
|
||||
return None
|
||||
T = days / 365.0
|
||||
Ts, ws = zip(*pts)
|
||||
if T <= Ts[0]:
|
||||
return float(np.sqrt(ws[0] / Ts[0]))
|
||||
if T >= Ts[-1]:
|
||||
return float(np.sqrt(ws[-1] / Ts[-1]))
|
||||
return float(np.sqrt(np.interp(T, Ts, ws) / T))
|
||||
|
||||
|
||||
def _round_floats(obj, nd=6):
|
||||
if isinstance(obj, float):
|
||||
return round(obj, nd)
|
||||
if isinstance(obj, dict):
|
||||
return {k: _round_floats(v, nd) for k, v in obj.items()}
|
||||
if isinstance(obj, list):
|
||||
return [_round_floats(v, nd) for v in obj]
|
||||
return obj
|
||||
|
||||
|
||||
def crypto_vol_report(summaries, now_ms, index_price=None, dvol_series=None,
|
||||
closes=None) -> dict:
|
||||
"""Full surface + term structure + cone + VRP for BTC or ETH."""
|
||||
expiries = build_crypto_surface(summaries, now_ms)
|
||||
cone = vol_cone(closes, 365) if closes is not None and len(closes) > 40 else None
|
||||
iv30 = constant_maturity_atm(expiries, 30)
|
||||
rv30 = None
|
||||
if cone:
|
||||
rv30 = next((t["current"] for t in cone["tenors"] if t["tenor"] == "1M"), None)
|
||||
term = [{"tenor": lbl, "days": d, "atm_vol": constant_maturity_atm(expiries, d)}
|
||||
for lbl, d in CONE_TENORS] if expiries else []
|
||||
dvol = None
|
||||
if dvol_series:
|
||||
dvol = {"last": dvol_series[-1][4] / 100.0,
|
||||
"series": [[p[0], p[4] / 100.0] for p in dvol_series]}
|
||||
return _round_floats({
|
||||
"index_price": index_price,
|
||||
"dvol": dvol,
|
||||
"iv_30d": iv30,
|
||||
"rv_30d": rv30,
|
||||
"vrp_30d": (iv30 - rv30) if iv30 is not None and rv30 is not None else None,
|
||||
"atm_term_structure": term,
|
||||
"expiries": expiries,
|
||||
"cone": cone,
|
||||
})
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from .routers import greeks, surface, market, montecarlo, scenarios, strategies, export, forex, institutional, news, livedata, smile
|
||||
from .routers import greeks, surface, market, montecarlo, scenarios, strategies, export, forex, institutional, news, livedata, smile, vol
|
||||
|
||||
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]:
|
||||
for router in [greeks, surface, market, montecarlo, scenarios, strategies, export, forex, institutional, news, livedata, smile, vol]:
|
||||
app.include_router(router.router, prefix="/api")
|
||||
|
||||
@app.get("/")
|
||||
|
||||
@@ -9,7 +9,10 @@ from ..core.quant_analysis import compute_signals
|
||||
|
||||
router = APIRouter(prefix="/forex", tags=["forex"])
|
||||
|
||||
# Supported pairs: yfinance symbol → display label + default rates
|
||||
# Supported pairs: yfinance symbol → display label + default rates.
|
||||
# asset_class drives conventions: "crypto" trades 24/7 (365 obs/year),
|
||||
# FX and metals trade 24/5 (252 obs/year). For metals r_f is the bullion
|
||||
# lease rate; for crypto r_f≈0 and the futures basis enters through r_d.
|
||||
PAIRS = {
|
||||
"EURUSD": {"sym": "EURUSD=X", "r_d": 0.0525, "r_f": 0.0400, "pip": 0.0001},
|
||||
"GBPUSD": {"sym": "GBPUSD=X", "r_d": 0.0525, "r_f": 0.0525, "pip": 0.0001},
|
||||
@@ -21,8 +24,14 @@ PAIRS = {
|
||||
"EURJPY": {"sym": "EURJPY=X", "r_d": 0.0010, "r_f": 0.0400, "pip": 0.01},
|
||||
"GBPJPY": {"sym": "GBPJPY=X", "r_d": 0.0010, "r_f": 0.0525, "pip": 0.01},
|
||||
"EURGBP": {"sym": "EURGBP=X", "r_d": 0.0525, "r_f": 0.0400, "pip": 0.0001},
|
||||
"XAUUSD": {"sym": "GC=F", "r_d": 0.0525, "r_f": 0.0000, "pip": 0.01},
|
||||
"XAUUSD": {"sym": "GC=F", "r_d": 0.0525, "r_f": 0.0000, "pip": 0.01, "asset_class": "metal"},
|
||||
"XAGUSD": {"sym": "SI=F", "r_d": 0.0390, "r_f": 0.0200, "pip": 0.001, "asset_class": "metal"},
|
||||
"BTCUSD": {"sym": "BTC-USD", "r_d": 0.0390, "r_f": 0.0000, "pip": 1.0, "asset_class": "crypto"},
|
||||
"ETHUSD": {"sym": "ETH-USD", "r_d": 0.0390, "r_f": 0.0000, "pip": 0.1, "asset_class": "crypto"},
|
||||
}
|
||||
for _meta in PAIRS.values():
|
||||
_meta.setdefault("asset_class", "fx")
|
||||
_meta["periods_per_year"] = 365 if _meta["asset_class"] == "crypto" else 252
|
||||
|
||||
|
||||
def _fetch_rate(sym: str) -> dict:
|
||||
@@ -137,7 +146,8 @@ def get_signals(pair: str):
|
||||
except Exception:
|
||||
pass
|
||||
closes = [float(v) for v in hist["Close"].dropna()]
|
||||
signals = compute_signals(closes, r_d=meta["r_d"], r_f=meta["r_f"])
|
||||
signals = compute_signals(closes, r_d=meta["r_d"], r_f=meta["r_f"],
|
||||
periods_per_year=meta["periods_per_year"])
|
||||
signals["pair"] = pair
|
||||
signals["current_price"] = round(closes[-1], 5)
|
||||
return signals
|
||||
|
||||
@@ -33,6 +33,9 @@ COT_MAP = {
|
||||
"GBPJPY": {"cot": "BRITISH POUND STERLING - CHICAGO MERCANTILE EXCHANGE", "sym": "GBPJPY=X"},
|
||||
"EURGBP": {"cot": "EURO FX - CHICAGO MERCANTILE EXCHANGE", "sym": "EURGBP=X"},
|
||||
"XAUUSD": {"cot": "GOLD - COMMODITY EXCHANGE INC.", "sym": "GC=F"},
|
||||
"XAGUSD": {"cot": "SILVER - COMMODITY EXCHANGE INC.", "sym": "SI=F"},
|
||||
"BTCUSD": {"cot": "BITCOIN - CHICAGO MERCANTILE EXCHANGE", "sym": "BTC-USD"},
|
||||
"ETHUSD": {"cot": "ETHER CASH SETTLED - CHICAGO MERCANTILE EXCHANGE", "sym": "ETH-USD"},
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
"""
|
||||
vol.py — Vol Lab endpoints: realised-vol cone for any pair and a live Deribit
|
||||
BTC/ETH implied-vol surface (SVI per expiry), ATM/RR/BF term structure,
|
||||
DVOL and vol risk premium. All sources are free and need no API key.
|
||||
"""
|
||||
|
||||
import time
|
||||
|
||||
import httpx
|
||||
import numpy as np
|
||||
import yfinance as yf
|
||||
from fastapi import APIRouter, HTTPException
|
||||
|
||||
from ..core.vol_analytics import crypto_vol_report, vol_cone
|
||||
from .forex import PAIRS
|
||||
|
||||
router = APIRouter(prefix="/vol", tags=["vol"])
|
||||
|
||||
DERIBIT = "https://www.deribit.com/api/v2/public"
|
||||
_DAY_MS = 24 * 3600 * 1000
|
||||
_cache: dict = {}
|
||||
|
||||
|
||||
def _cached(key, ttl, fn):
|
||||
hit = _cache.get(key)
|
||||
if hit and time.time() - hit[0] < ttl:
|
||||
return hit[1]
|
||||
value = fn()
|
||||
_cache[key] = (time.time(), value)
|
||||
return value
|
||||
|
||||
|
||||
def _deribit(method: str, **params):
|
||||
r = httpx.get(f"{DERIBIT}/{method}", params=params, timeout=15)
|
||||
r.raise_for_status()
|
||||
return r.json()["result"]
|
||||
|
||||
|
||||
def _yf_closes(sym: str, period: str = "2y") -> list:
|
||||
hist = yf.download(sym, period=period, interval="1d", progress=False, auto_adjust=True)
|
||||
if hist.empty:
|
||||
return []
|
||||
if hasattr(hist.columns, "droplevel"):
|
||||
try:
|
||||
hist.columns = hist.columns.droplevel(1)
|
||||
except Exception:
|
||||
pass
|
||||
return [float(v) for v in hist["Close"].dropna()]
|
||||
|
||||
|
||||
def _crypto_closes(currency: str) -> list:
|
||||
"""Daily closes of the Deribit perpetual; falls back to yfinance spot."""
|
||||
now = int(time.time() * 1000)
|
||||
try:
|
||||
res = _deribit("get_tradingview_chart_data", instrument_name=f"{currency}-PERPETUAL",
|
||||
start_timestamp=now - 730 * _DAY_MS, end_timestamp=now, resolution="1D")
|
||||
closes = [float(c) for c in res.get("close", []) if c]
|
||||
if len(closes) > 60:
|
||||
return closes
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
return _yf_closes(f"{currency}-USD")
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
@router.get("/cone/{pair}")
|
||||
def get_vol_cone(pair: str):
|
||||
"""Burghardt-Lane realised volatility cone (2y of daily closes)."""
|
||||
pair = pair.upper()
|
||||
if pair not in PAIRS:
|
||||
raise HTTPException(404, f"Unknown pair '{pair}'.")
|
||||
meta = PAIRS[pair]
|
||||
|
||||
def load():
|
||||
if meta["asset_class"] == "crypto":
|
||||
return _crypto_closes(pair[:3])
|
||||
return _yf_closes(meta["sym"])
|
||||
|
||||
try:
|
||||
closes = _cached(f"closes:{pair}", 900, load)
|
||||
except Exception as e:
|
||||
raise HTTPException(503, f"Price history unavailable: {e}")
|
||||
if len(closes) < 60:
|
||||
_cache.pop(f"closes:{pair}", None)
|
||||
raise HTTPException(503, "Not enough price history for a volatility cone.")
|
||||
cone = vol_cone(closes, meta["periods_per_year"])
|
||||
return {"pair": pair, "asset_class": meta["asset_class"],
|
||||
"last_price": round(closes[-1], 6), **cone}
|
||||
|
||||
|
||||
@router.get("/crypto/{currency}")
|
||||
def get_crypto_vol(currency: str):
|
||||
"""Live Deribit option surface for BTC or ETH with SVI fits, term structure and VRP."""
|
||||
currency = currency.upper()
|
||||
if currency not in ("BTC", "ETH"):
|
||||
raise HTTPException(404, "Deribit surface is available for BTC and ETH.")
|
||||
|
||||
def load():
|
||||
now = int(time.time() * 1000)
|
||||
summaries = _deribit("get_book_summary_by_currency", currency=currency, kind="option")
|
||||
try:
|
||||
index_price = _deribit("get_index_price",
|
||||
index_name=f"{currency.lower()}_usd")["index_price"]
|
||||
except Exception:
|
||||
index_price = None
|
||||
try:
|
||||
dvol = _deribit("get_volatility_index_data", currency=currency,
|
||||
start_timestamp=now - 90 * _DAY_MS, end_timestamp=now,
|
||||
resolution="1D").get("data", [])
|
||||
except Exception:
|
||||
dvol = []
|
||||
closes = _cached(f"closes:{currency}USD", 900, lambda: _crypto_closes(currency))
|
||||
report = crypto_vol_report(summaries, now, index_price, dvol, closes)
|
||||
if not report["expiries"]:
|
||||
raise ValueError("no expiries with enough quotes to fit")
|
||||
return report
|
||||
|
||||
try:
|
||||
report = _cached(f"crypto:{currency}", 60, load)
|
||||
except Exception as e:
|
||||
_cache.pop(f"crypto:{currency}", None)
|
||||
raise HTTPException(503, f"Deribit data unavailable: {e}")
|
||||
return {"currency": currency, "source": "Deribit public API", **report}
|
||||
@@ -0,0 +1,29 @@
|
||||
import numpy as np
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from app.core.vol_analytics import svi_total_variance
|
||||
NOW = datetime(2026, 9, 29, 5, 0, tzinfo=timezone.utc)
|
||||
NOW_MS = NOW.timestamp() * 1000
|
||||
MON = ["JAN","FEB","MAR","APR","MAY","JUN","JUL","AUG","SEP","OCT","NOV","DEC"]
|
||||
def deribit_book(spot=83000.0, r=0.04, ccy="BTC", rng=None, noise=0.0):
|
||||
"""Deribit-shaped book summaries from a known SVI surface (BTC-like skew)."""
|
||||
rng = rng or np.random.default_rng(1)
|
||||
rows, truth = [], {}
|
||||
for days in [2, 9, 30, 58, 93, 184, 275]:
|
||||
exp = (NOW + timedelta(days=days)).replace(hour=8, minute=0)
|
||||
T = (exp - NOW).total_seconds() / (365*86400)
|
||||
F = spot * np.exp(r * T)
|
||||
atm = 0.38 + 0.04 * np.sqrt(T) # upward term structure
|
||||
p = (atm**2*T*0.8, 0.12*np.sqrt(T), -0.25, 0.02, 0.15) # a,b,rho,m,sig
|
||||
# rescale a so w(0)=atm^2 T
|
||||
w0 = svi_total_variance(0, *p); p = (p[0] + atm**2*T - w0,) + p[1:]
|
||||
truth[exp.strftime("%Y-%m-%d")] = (p, T, F)
|
||||
step = 1000 if ccy == "BTC" else 50
|
||||
for K in np.arange(round(F*0.55/step)*step, F*1.8, step * (1 if T > 0.1 else 1)):
|
||||
k = np.log(K/F); iv = np.sqrt(svi_total_variance(k, *p)/T)
|
||||
if abs(k) > 3.5*atm*np.sqrt(T) + 0.1: continue
|
||||
for cp in "CP":
|
||||
name = f"{ccy}-{exp.day}{MON[exp.month-1]}{exp.strftime('%y')}-{int(K)}-{cp}"
|
||||
rows.append({"instrument_name": name, "mark_iv": round(100*iv*(1+noise*rng.standard_normal()), 2),
|
||||
"underlying_price": F, "underlying_index": f"{ccy}-{exp.strftime('%d%b%y').upper()}",
|
||||
"open_interest": float(rng.integers(0, 500)), "mark_price": 0.01})
|
||||
return rows, truth
|
||||
@@ -0,0 +1,113 @@
|
||||
"""Validation of the Vol Lab analytics against a known synthetic SVI surface.
|
||||
|
||||
Run from backend/: python -m pytest tests -q
|
||||
"""
|
||||
import numpy as np
|
||||
from fastapi.testclient import TestClient
|
||||
from scipy.stats import norm
|
||||
|
||||
from app.core.quant_analysis import compute_signals
|
||||
from app.core.vol_analytics import (build_crypto_surface, constant_maturity_atm,
|
||||
parse_deribit_instrument, svi_total_variance, vol_cone)
|
||||
from app.main import app
|
||||
from app.routers import vol as vol_router
|
||||
from tests.fixtures import NOW_MS, deribit_book
|
||||
|
||||
|
||||
def _gbm(vol, n, ppy, seed=0, s0=100.0):
|
||||
rng = np.random.default_rng(seed)
|
||||
return s0 * np.exp(np.cumsum(rng.standard_normal(n) * vol / np.sqrt(ppy)))
|
||||
|
||||
|
||||
def test_svi_fit_recovers_known_surface_and_25_delta_strikes():
|
||||
rows, truth = deribit_book()
|
||||
expiries = build_crypto_surface(rows, NOW_MS)
|
||||
assert len(expiries) == len(truth)
|
||||
for e in expiries:
|
||||
p, T, F = truth[e["expiry"]]
|
||||
assert abs(e["atm_vol"] - np.sqrt(svi_total_variance(0, *p) / T)) < 1e-4
|
||||
assert e["fit_rmse"] < 1e-4 and e["butterfly_arb_free"]
|
||||
d1 = lambda K, s: (np.log(F / K) + 0.5 * s * s * T) / (s * np.sqrt(T))
|
||||
assert abs(norm.cdf(d1(e["K_25c"], e["vol_25c"])) - 0.25) < 1e-6
|
||||
assert abs(norm.cdf(d1(e["K_25p"], e["vol_25p"])) - 0.75) < 1e-6
|
||||
assert e["rr25"] < 0 # BTC-style put skew in the fixture
|
||||
|
||||
|
||||
def test_noisy_quotes_fit_within_noise():
|
||||
rows, _ = deribit_book(noise=0.02, rng=np.random.default_rng(7))
|
||||
for e in build_crypto_surface(rows, NOW_MS):
|
||||
assert e["fit_rmse"] < 0.015
|
||||
|
||||
|
||||
def test_constant_maturity_interpolates_total_variance():
|
||||
expiries = build_crypto_surface(deribit_book()[0], NOW_MS)
|
||||
iv30 = constant_maturity_atm(expiries, 30)
|
||||
near = min(expiries, key=lambda e: abs(e["days"] - 30))
|
||||
assert abs(iv30 - near["atm_vol"]) < 2e-3
|
||||
|
||||
|
||||
def test_parse_instrument():
|
||||
exp, K, cp = parse_deribit_instrument("ETH-5OCT26-2650-P")
|
||||
assert (exp.year, exp.month, exp.day, exp.hour, K, cp) == (2026, 10, 5, 8, 2650.0, "put")
|
||||
assert parse_deribit_instrument("BTC-PERPETUAL") is None
|
||||
|
||||
|
||||
def test_vol_cone_matches_true_vol_on_both_calendars():
|
||||
btc = vol_cone(_gbm(0.50, 730, 365), 365)
|
||||
fx = vol_cone(_gbm(0.07, 520, 252), 252)
|
||||
assert abs(next(t for t in btc["tenors"] if t["tenor"] == "3M")["median"] - 0.50) < 0.03
|
||||
assert abs(next(t for t in fx["tenors"] if t["tenor"] == "3M")["median"] - 0.07) < 0.005
|
||||
assert next(t for t in fx["tenors"] if t["tenor"] == "1M")["window"] == 21
|
||||
|
||||
|
||||
def test_signals_use_asset_calendar():
|
||||
px = _gbm(0.50, 90, 365, s0=83000)
|
||||
crypto = compute_signals(list(px), 0.039, 0.0, periods_per_year=365)
|
||||
as_fx = compute_signals(list(px), 0.039, 0.0, periods_per_year=252)
|
||||
ratio = crypto["volatility"]["ewma_forecast_pct"] / as_fx["volatility"]["ewma_forecast_pct"]
|
||||
assert abs(ratio - np.sqrt(365 / 252)) < 1e-3 # outputs are rounded to 2dp
|
||||
assert 20 < crypto["volatility"]["hv_20_pct"] < 90 # percent, not decimal
|
||||
|
||||
|
||||
def test_endpoints_with_mocked_sources(monkeypatch):
|
||||
rows, _ = deribit_book()
|
||||
closes = list(_gbm(0.45, 730, 365, s0=83000))
|
||||
|
||||
def fake_deribit(method, **params):
|
||||
return {
|
||||
"get_book_summary_by_currency": rows,
|
||||
"get_index_price": {"index_price": 83000.0},
|
||||
"get_volatility_index_data": {"data": [[NOW_MS - i * 864e5, 40, 41, 37, 38.1]
|
||||
for i in range(5, 0, -1)]},
|
||||
"get_tradingview_chart_data": {"close": closes},
|
||||
}[method]
|
||||
|
||||
monkeypatch.setattr(vol_router, "_deribit", fake_deribit)
|
||||
monkeypatch.setattr(vol_router, "_yf_closes", lambda sym, period="2y": closes)
|
||||
vol_router._cache.clear()
|
||||
c = TestClient(app)
|
||||
|
||||
r = c.get("/api/vol/crypto/btc")
|
||||
assert r.status_code == 200
|
||||
d = r.json()
|
||||
assert d["dvol"]["last"] == 0.381 and d["index_price"] == 83000.0
|
||||
assert d["expiries"] and d["iv_30d"] and d["rv_30d"] and d["vrp_30d"] is not None
|
||||
assert abs(d["vrp_30d"] - (d["iv_30d"] - d["rv_30d"])) < 1e-5
|
||||
|
||||
cone = c.get("/api/vol/cone/XAGUSD").json()
|
||||
assert cone["asset_class"] == "metal" and cone["periods_per_year"] == 252
|
||||
assert c.get("/api/vol/cone/BTCUSD").json()["periods_per_year"] == 365
|
||||
assert c.get("/api/vol/crypto/DOGE").status_code == 404
|
||||
assert c.get("/api/vol/cone/FOO").status_code == 404
|
||||
pairs = {p["pair"]: p for p in c.get("/api/forex/pairs").json()}
|
||||
assert {"XAGUSD", "BTCUSD", "ETHUSD"} <= set(pairs)
|
||||
assert pairs["ETHUSD"]["asset_class"] == "crypto" and "sym" not in pairs["ETHUSD"]
|
||||
|
||||
|
||||
def test_crypto_endpoint_fails_gracefully(monkeypatch):
|
||||
def down(method, **params):
|
||||
raise ConnectionError("deribit down")
|
||||
monkeypatch.setattr(vol_router, "_deribit", down)
|
||||
vol_router._cache.clear()
|
||||
r = TestClient(app).get("/api/vol/crypto/ETH")
|
||||
assert r.status_code == 503 and "Deribit data unavailable" in r.json()["detail"]
|
||||
@@ -21,6 +21,10 @@ export const computeSurface = (p) => api.post('/surface', p).then(r
|
||||
export const computeMonteCarlo = (p) => api.post('/montecarlo', p).then(r=>r.data)
|
||||
export const computeScenarios = (p) => api.post('/scenarios', p).then(r=>r.data)
|
||||
export const computeSmile = (p) => api.post('/smile', p).then(r=>r.data)
|
||||
|
||||
// Vol Lab
|
||||
export const fetchVolCone = (pair) => api.get(`/vol/cone/${pair}`).then(r=>r.data)
|
||||
export const fetchCryptoVol = (currency) => api.get(`/vol/crypto/${currency}`).then(r=>r.data)
|
||||
export const exportCSV = (p) => api.post('/export/csv', p, {responseType:'blob'}).then(r=>r.data)
|
||||
|
||||
// Institutional flow
|
||||
|
||||
@@ -68,7 +68,7 @@ export default function PortfolioBuilder() {
|
||||
background:'rgba(0,212,255,0.03)' }}>
|
||||
<div className="label-upper" style={{ marginBottom:4 }}>Spot Rate</div>
|
||||
<div className="font-mono neon-cyan" style={{ fontSize:22, fontWeight:700 }}>
|
||||
{S.toFixed(meta.pip === 0.01 ? 3 : 5)}
|
||||
{S.toFixed(meta.dp ?? (meta.pip === 0.01 ? 3 : 5))}
|
||||
</div>
|
||||
<div style={{ fontSize:9, color:'var(--text-muted)', marginTop:2 }}>
|
||||
{pair.slice(0,3)} / {pair.slice(3)} · pip={meta.pip}
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import _Plot from 'react-plotly.js'
|
||||
const Plot = _Plot?.default ?? _Plot
|
||||
import { usePortfolioStore, PAIR_META, isCrypto } from '../store/portfolio'
|
||||
import { fetchVolCone, fetchCryptoVol } from '../api/client'
|
||||
|
||||
const pct = (v, d = 1) => (v == null ? '—' : (v * 100).toFixed(d) + '%')
|
||||
const pts = (v, d = 2) => (v == null ? '—' : (v >= 0 ? '+' : '') + (v * 100).toFixed(d))
|
||||
const money = (v, dp) => (v == null ? '—' : v.toLocaleString(undefined, { maximumFractionDigits: dp }))
|
||||
|
||||
const LAYOUT = {
|
||||
paper_bgcolor:'transparent', plot_bgcolor:'#080d18',
|
||||
font:{ color:'#94a3b8', size:11 },
|
||||
margin:{ l:52, r:16, t:12, b:44 },
|
||||
legend:{ bgcolor:'transparent', font:{ size:9 }, orientation:'h', x:0, y:-0.2 },
|
||||
hoverlabel:{ bgcolor:'#0f172a', font:{ color:'#e2e8f0' }, bordercolor:'rgba(0,212,255,0.15)' },
|
||||
}
|
||||
const axis = (title, extra = {}) => ({ title:{ text:title }, gridcolor:'#1e293b', color:'#475569', zeroline:false, ...extra })
|
||||
|
||||
async function loadVol(pair) {
|
||||
const crypto = isCrypto(pair)
|
||||
const [cone, surface] = await Promise.allSettled([
|
||||
fetchVolCone(pair),
|
||||
crypto ? fetchCryptoVol(pair.slice(0, 3)) : Promise.resolve(null),
|
||||
])
|
||||
const errMsg = r => r.reason?.response?.data?.detail ?? r.reason?.message ?? 'unavailable'
|
||||
return {
|
||||
cone: cone.status === 'fulfilled' ? cone.value : null,
|
||||
surface: surface.status === 'fulfilled' ? surface.value : null,
|
||||
errors: [
|
||||
cone.status === 'rejected' && `Realised vol: ${errMsg(cone)}`,
|
||||
surface.status === 'rejected' && `Deribit surface: ${errMsg(surface)}`,
|
||||
].filter(Boolean),
|
||||
}
|
||||
}
|
||||
|
||||
export default function VolLab() {
|
||||
const { pair, setT, setSigma, setS, setSmileQuotes } = usePortfolioStore()
|
||||
const [nonce, setNonce] = useState(0)
|
||||
const [response, setResponse] = useState({ key:null, data:null })
|
||||
const [expiryIdx, setExpiryIdx] = useState(null)
|
||||
const [toast, setToast] = useState(null)
|
||||
const requestKey = `${pair}:${nonce}`
|
||||
|
||||
useEffect(() => {
|
||||
let stale = false
|
||||
loadVol(pair).then(data => { if (!stale) setResponse({ key:requestKey, data }) })
|
||||
return () => { stale = true }
|
||||
}, [pair, requestKey])
|
||||
|
||||
const loading = response.key !== requestKey
|
||||
const data = response.key?.startsWith(`${pair}:`) ? response.data : null
|
||||
const cone = data?.cone
|
||||
const surface = data?.surface
|
||||
const expiries = surface?.expiries ?? []
|
||||
// Default smile: the listed expiry closest to 30 days.
|
||||
const defaultIdx = expiries.length
|
||||
? expiries.reduce((b, e, i) => Math.abs(e.days - 30) < Math.abs(expiries[b].days - 30) ? i : b, 0) : 0
|
||||
const sel = expiries[expiryIdx ?? defaultIdx]
|
||||
const dp = PAIR_META[pair]?.dp ?? 4
|
||||
|
||||
const useInPricing = (e) => {
|
||||
setT(+e.T.toFixed(4))
|
||||
setSigma(+e.atm_vol.toFixed(4))
|
||||
if (surface?.index_price) setS(surface.index_price)
|
||||
setSmileQuotes({ atm:+(e.atm_vol * 100).toFixed(2), rr25:+((e.rr25 ?? 0) * 100).toFixed(2),
|
||||
bf25:+((e.bf25 ?? 0) * 100).toFixed(2) })
|
||||
setToast(`${e.label}: T, ATM σ and RR/BF loaded — open the Vol Smile tab to price legs off this smile`)
|
||||
setTimeout(() => setToast(null), 4000)
|
||||
}
|
||||
|
||||
const tenor1M = cone?.tenors?.find(t => t.tenor === '1M')
|
||||
const kpis = surface ? [
|
||||
{ label:'Index', value: money(surface.index_price, dp) },
|
||||
{ label:'DVOL', value: pct(surface.dvol?.last) },
|
||||
{ label:'ATM IV 30d', value: pct(surface.iv_30d) },
|
||||
{ label:'RV 30d', value: pct(surface.rv_30d) },
|
||||
{ label:'VRP (IV−RV)', value: pts(surface.vrp_30d, 1) + ' vol',
|
||||
color: surface.vrp_30d == null ? undefined : surface.vrp_30d >= 0 ? 'var(--amber)' : 'var(--green)',
|
||||
hint: surface.vrp_30d >= 0 ? 'Options rich vs realised' : 'Options cheap vs realised' },
|
||||
{ label:'RV 1M pctile', value: tenor1M ? `${tenor1M.current_percentile.toFixed(0)}th` : '—' },
|
||||
] : cone ? [
|
||||
{ label:'Last', value: money(cone.last_price, dp) },
|
||||
{ label:'RV 1M', value: pct(tenor1M?.current) },
|
||||
{ label:'RV 1M pctile', value: tenor1M ? `${tenor1M.current_percentile.toFixed(0)}th` : '—' },
|
||||
{ label:'1M median', value: pct(tenor1M?.median) },
|
||||
{ label:'EWMA σ', value: pct(cone.ewma_vol) },
|
||||
] : []
|
||||
|
||||
return (
|
||||
<div style={{ padding:16 }}>
|
||||
<div style={{ display:'flex', alignItems:'flex-start', justifyContent:'space-between', gap:12, marginBottom:12, flexWrap:'wrap' }}>
|
||||
<div>
|
||||
<div className="section-title">Vol Lab — {pair}</div>
|
||||
<div style={{ fontSize:9, color:'var(--text-muted)', marginTop:2 }}>
|
||||
Realised-vol cone (Burghardt-Lane) · {isCrypto(pair)
|
||||
? 'live Deribit surface, SVI per expiry, 365-day vol'
|
||||
: `${cone?.periods_per_year ?? 252}-day annualisation · no free listed-option feed for this pair`}
|
||||
</div>
|
||||
</div>
|
||||
<button className="btn-ghost" onClick={() => setNonce(n => n + 1)} disabled={loading} style={{ fontSize:10 }}>
|
||||
{loading ? '⟳ Loading' : '↻ Refresh'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{data?.errors?.map(e => (
|
||||
<div key={e} style={{ padding:8, borderRadius:6, marginBottom:8, fontSize:11,
|
||||
background:'rgba(255,61,90,0.1)', border:'1px solid #ff3d5a', color:'#ff3d5a' }}>{e}</div>
|
||||
))}
|
||||
{toast && (
|
||||
<div style={{ padding:8, borderRadius:6, marginBottom:8, fontSize:11,
|
||||
background:'rgba(0,255,136,0.08)', border:'1px solid rgba(0,255,136,0.4)', color:'var(--green)' }}>{toast}</div>
|
||||
)}
|
||||
|
||||
{!data && loading && <div className="skeleton" style={{ height:360, borderRadius:8 }}/>}
|
||||
|
||||
{kpis.length > 0 && (
|
||||
<div style={{ display:'flex', gap:8, marginBottom:12, flexWrap:'wrap', opacity: loading ? 0.6 : 1 }}>
|
||||
{kpis.map(k => (
|
||||
<div key={k.label} className="glass" title={k.hint} style={{ padding:'6px 12px', borderRadius:6, fontSize:11 }}>
|
||||
<div style={{ color:'var(--text-muted)', fontSize:9, letterSpacing:'0.05em' }}>{k.label}</div>
|
||||
<div className="font-mono" style={{ color: k.color ?? 'var(--cyan)', fontWeight:700, fontSize:14 }}>{k.value}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{cone?.tenors?.length > 0 && <ConeChart cone={cone} surface={surface} />}
|
||||
|
||||
{sel && (
|
||||
<div style={{ display:'grid', gridTemplateColumns:'repeat(auto-fit, minmax(340px, 1fr))', gap:12, marginTop:12 }}>
|
||||
<div>
|
||||
<div style={{ display:'flex', gap:4, flexWrap:'wrap', marginBottom:6 }}>
|
||||
{expiries.map((e, i) => (
|
||||
<button key={e.expiry} onClick={() => setExpiryIdx(i)} className="font-mono"
|
||||
style={{ padding:'3px 8px', borderRadius:4, fontSize:9, cursor:'pointer',
|
||||
border:'1px solid ' + (e === sel ? 'var(--cyan)' : 'var(--border)'),
|
||||
background: e === sel ? 'rgba(0,212,255,0.1)' : 'transparent',
|
||||
color: e === sel ? 'var(--cyan)' : 'var(--text-muted)' }}>{e.label}</button>
|
||||
))}
|
||||
</div>
|
||||
<SmileChart e={sel} pair={pair} />
|
||||
</div>
|
||||
<TermTable expiries={expiries} sel={sel} onSelect={setExpiryIdx} onUse={useInPricing} dp={dp} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ fontSize:9, color:'var(--text-muted)', marginTop:10, lineHeight:1.5 }}>
|
||||
Cone: percentiles of rolling close-to-close realised vol over 2y (Burghardt & Lane 1990). Implied ATM term structure
|
||||
(crypto) is interpolated linearly in total variance. Smiles: raw SVI (Gatheral 2004) fitted to Deribit OTM mark IVs,
|
||||
butterfly-arbitrage check via Durrleman's condition (Gatheral & Jacquier 2014). RR/BF use Black-76 forward 25Δ.
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ConeChart({ cone, surface }) {
|
||||
const t = cone.tenors
|
||||
const x = t.map(r => r.days)
|
||||
const band = (lo, hi, name, color) => ([
|
||||
{ x, y:t.map(r => r[lo] * 100), type:'scatter', mode:'lines', line:{ width:0 }, hoverinfo:'skip', showlegend:false },
|
||||
{ x, y:t.map(r => r[hi] * 100), type:'scatter', mode:'lines', line:{ width:0 }, fill:'tonexty',
|
||||
fillcolor:color, name, hoverinfo:'skip' },
|
||||
])
|
||||
const traces = [
|
||||
...band('p10', 'p90', '10–90th pct', 'rgba(0,212,255,0.08)'),
|
||||
...band('p25', 'p75', '25–75th pct', 'rgba(0,212,255,0.18)'),
|
||||
{ x, y:t.map(r => r.max * 100), type:'scatter', mode:'lines', name:'Max / Min',
|
||||
line:{ color:'rgba(148,163,184,0.5)', width:1, dash:'dot' } },
|
||||
{ x, y:t.map(r => r.min * 100), type:'scatter', mode:'lines', showlegend:false,
|
||||
line:{ color:'rgba(148,163,184,0.5)', width:1, dash:'dot' } },
|
||||
{ x, y:t.map(r => r.median * 100), type:'scatter', mode:'lines', name:'Median RV',
|
||||
line:{ color:'#00d4ff', width:1.5, dash:'dash' } },
|
||||
{ x, y:t.map(r => r.current * 100), type:'scatter', mode:'lines+markers', name:'Current RV',
|
||||
line:{ color:'#ffd700', width:2.5 }, marker:{ size:7 },
|
||||
customdata:t.map(r => r.current_percentile),
|
||||
hovertemplate:'%{y:.1f}% · %{customdata:.0f}th pct<extra>Current RV</extra>' },
|
||||
]
|
||||
const ts = surface?.atm_term_structure?.filter(r => r.atm_vol != null) ?? []
|
||||
if (ts.length) traces.push({
|
||||
x:ts.map(r => r.days), y:ts.map(r => r.atm_vol * 100), type:'scatter', mode:'lines+markers',
|
||||
name:'Implied ATM (Deribit)', line:{ color:'#00ff88', width:2.5 }, marker:{ size:8, symbol:'diamond' },
|
||||
})
|
||||
return (
|
||||
<div className="glass" style={{ borderRadius:8, padding:8 }}>
|
||||
<div className="section-title" style={{ fontSize:10, margin:'2px 0 4px 6px' }}>
|
||||
Volatility Cone{ts.length ? ' vs Implied Term Structure' : ''}
|
||||
</div>
|
||||
<Plot data={traces}
|
||||
layout={{ ...LAYOUT, hovermode:'x unified',
|
||||
xaxis:axis('Horizon', { tickvals:x, ticktext:t.map(r => r.tenor) }),
|
||||
yaxis:axis('Annualised vol (%)') }}
|
||||
config={{ responsive:true, displayModeBar:false }}
|
||||
style={{ width:'100%', height:300 }} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SmileChart({ e, pair }) {
|
||||
const traces = [
|
||||
{ x:e.market.strikes, y:e.market.iv.map(v => v * 100), type:'scatter', mode:'markers', name:'Deribit mark IV',
|
||||
marker:{ color:'#ffd700', size:6, line:{ color:'#0a0e1a', width:1 } } },
|
||||
{ x:e.fit.strikes, y:e.fit.iv.map(v => v * 100), type:'scatter', mode:'lines', name:'SVI fit',
|
||||
line:{ color:'#00d4ff', width:2.5 } },
|
||||
]
|
||||
if (e.K_25p) traces.push({
|
||||
x:[e.K_25p, e.forward, e.K_25c], y:[e.vol_25p, e.atm_vol, e.vol_25c].map(v => v * 100),
|
||||
type:'scatter', mode:'markers+text', name:'25Δ P · ATMF · 25Δ C',
|
||||
text:['25ΔP', 'ATM', '25ΔC'], textposition:'top center', textfont:{ size:9, color:'#00ff88' },
|
||||
marker:{ color:'#00ff88', size:9, symbol:'diamond', line:{ color:'#0a0e1a', width:2 } },
|
||||
})
|
||||
return (
|
||||
<div className="glass" style={{ borderRadius:8, padding:8 }}>
|
||||
<div className="section-title" style={{ fontSize:10, margin:'2px 0 4px 6px' }}>
|
||||
{pair.slice(0, 3)} smile · {e.label} · {e.days.toFixed(1)}d · fit RMSE {(e.fit_rmse * 100).toFixed(2)} vol
|
||||
</div>
|
||||
<Plot data={traces}
|
||||
layout={{ ...LAYOUT,
|
||||
xaxis:axis('Strike (USD)'), yaxis:axis('Implied vol (%)'),
|
||||
shapes:[{ type:'line', x0:e.forward, x1:e.forward, yref:'paper', y0:0, y1:1,
|
||||
line:{ color:'rgba(255,215,0,0.35)', width:1, dash:'dot' } }] }}
|
||||
config={{ responsive:true, displayModeBar:false }}
|
||||
style={{ width:'100%', height:300 }} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TermTable({ expiries, sel, onSelect, onUse, dp }) {
|
||||
const head = ['Expiry', 'Days', 'Forward', 'ATM', '25Δ RR', '25Δ BF', 'Fit', 'Arb', '']
|
||||
return (
|
||||
<div>
|
||||
<div className="section-title" style={{ fontSize:10, marginBottom:6 }}>Term Structure (SVI-implied)</div>
|
||||
<div style={{ overflowX:'auto', borderRadius:8, border:'1px solid var(--border)' }}>
|
||||
<table style={{ width:'100%', borderCollapse:'collapse', fontSize:10 }}>
|
||||
<thead>
|
||||
<tr style={{ background:'var(--bg3)' }}>
|
||||
{head.map((h, i) => (
|
||||
<th key={i} style={{ padding:'7px 8px', fontWeight:600, fontSize:9, color:'var(--text-muted)',
|
||||
textAlign: i === 0 ? 'left' : 'right', whiteSpace:'nowrap' }}>{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{expiries.map((e, i) => (
|
||||
<tr key={e.expiry} onClick={() => onSelect(i)} style={{ borderTop:'1px solid var(--border)', cursor:'pointer',
|
||||
background: e === sel ? 'rgba(0,212,255,0.06)' : undefined }}>
|
||||
<td className="font-mono" style={{ padding:'5px 8px' }}>{e.label}</td>
|
||||
<Num v={e.days.toFixed(1)} />
|
||||
<Num v={money(e.forward, dp)} />
|
||||
<Num v={pct(e.atm_vol, 2)} />
|
||||
<Num v={pts(e.rr25)} color={e.rr25 == null ? undefined : e.rr25 < 0 ? 'var(--red)' : 'var(--green)'} />
|
||||
<Num v={pts(e.bf25)} />
|
||||
<Num v={(e.fit_rmse * 100).toFixed(2)} />
|
||||
<Num v={e.butterfly_arb_free ? '✓' : '✗'} color={e.butterfly_arb_free ? 'var(--green)' : 'var(--red)'} />
|
||||
<td style={{ padding:'3px 6px', textAlign:'right' }}>
|
||||
<button onClick={ev => { ev.stopPropagation(); onUse(e) }} title="Load T, ATM σ and RR/BF into pricing & Vol Smile"
|
||||
style={{ padding:'2px 6px', borderRadius:4, fontSize:9, cursor:'pointer', whiteSpace:'nowrap',
|
||||
background:'rgba(0,212,255,0.1)', border:'1px solid rgba(0,212,255,0.4)', color:'var(--cyan)' }}>Use →</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div style={{ fontSize:9, color:'var(--text-muted)', marginTop:6 }}>
|
||||
RR/BF in vol points. Negative RR = puts over calls (downside skew). Fit = RMSE in vol points.
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const Num = ({ v, color }) => (
|
||||
<td className="font-mono" style={{ padding:'5px 8px', textAlign:'right', whiteSpace:'nowrap', color }}>{v}</td>
|
||||
)
|
||||
@@ -12,8 +12,8 @@ const QUOTE_FIELDS = [
|
||||
]
|
||||
|
||||
export default function VolSmile() {
|
||||
const { legs, S, sigma, T, r_d, r_f, pair } = usePortfolioStore()
|
||||
const [quotes, setQuotes] = useState({ atm: +(sigma * 100).toFixed(2), rr25: -0.40, bf25: 0.20 })
|
||||
const { legs, S, sigma, T, r_d, r_f, pair, smileQuotes } = usePortfolioStore()
|
||||
const [quotes, setQuotes] = useState(() => smileQuotes ?? { atm: +(sigma * 100).toFixed(2), rr25: -0.40, bf25: 0.20 })
|
||||
// Each response is tagged with the request it answers, so "loading" is derived
|
||||
// (latest request != last answered one) instead of set inside the effect.
|
||||
const [response, setResponse] = useState({ key:null, data:null, error:null })
|
||||
|
||||
@@ -9,6 +9,7 @@ import QuantSignals from '../components/QuantSignals'
|
||||
import InstitutionalFlow from '../components/InstitutionalFlow'
|
||||
import EconomicCalendar from '../components/EconomicCalendar'
|
||||
import VolSmile from '../components/VolSmile'
|
||||
import VolLab from '../components/VolLab'
|
||||
|
||||
const IVSurface = lazy(() => import('../components/IVSurface'))
|
||||
const WorldMap = lazy(() => import('../components/WorldMap'))
|
||||
@@ -39,6 +40,7 @@ const TABS = [
|
||||
{ id:'greeks', icon:'Δ', label:'Greeks', component: GreeksDashboard },
|
||||
{ id:'chart', icon:'📈', label:'Chart', component: CandlestickChart },
|
||||
{ id:'signals', icon:'⚡', label:'AI Signals', component: QuantSignals },
|
||||
{ id:'vollab', icon:'🔬', label:'Vol Lab', component: VolLab },
|
||||
{ id:'smile', icon:'〰', label:'Vol Smile', component: VolSmile },
|
||||
{ id:'surfaces', icon:'🗻', label:'3D Surfaces', component: SurfacePlot },
|
||||
{ id:'breakeven', icon:'🎯', label:'Breakeven', component: BreakevenChart },
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { create } from 'zustand'
|
||||
|
||||
// Default interest rates per pair (r_d = domestic/quote, r_f = foreign/base)
|
||||
// Default interest rates per pair (r_d = domestic/quote, r_f = foreign/base).
|
||||
// Metals: r_f is the bullion lease rate. Crypto: r_f≈0, 24/7 so vols use 365 days.
|
||||
// sigma = typical ATM vol loaded on pair switch; strikeStep ≈ 1% of spot for templates.
|
||||
export const PAIR_META = {
|
||||
EURUSD: { r_d:0.0525, r_f:0.0400, pip:0.0001, defaultS:1.0850 },
|
||||
GBPUSD: { r_d:0.0525, r_f:0.0525, pip:0.0001, defaultS:1.2700 },
|
||||
@@ -12,9 +14,15 @@ export const PAIR_META = {
|
||||
EURJPY: { r_d:0.0010, r_f:0.0400, pip:0.01, defaultS:168.00 },
|
||||
GBPJPY: { r_d:0.0010, r_f:0.0525, pip:0.01, defaultS:197.00 },
|
||||
EURGBP: { r_d:0.0525, r_f:0.0400, pip:0.0001, defaultS:0.8550 },
|
||||
XAUUSD: { r_d:0.0525, r_f:0.0000, pip:0.01, defaultS:2350.0 },
|
||||
XAUUSD: { r_d:0.0525, r_f:0.0000, pip:0.01, defaultS:4400.0, sigma:0.22, strikeStep:40, dp:2, assetClass:'metal' },
|
||||
XAGUSD: { r_d:0.0390, r_f:0.0200, pip:0.001, defaultS:64.50, sigma:0.40, strikeStep:0.6, dp:3, assetClass:'metal' },
|
||||
BTCUSD: { r_d:0.0390, r_f:0.0000, pip:1, defaultS:83000, sigma:0.40, strikeStep:800, dp:0, assetClass:'crypto' },
|
||||
ETHUSD: { r_d:0.0390, r_f:0.0000, pip:0.1, defaultS:2650, sigma:0.60, strikeStep:25, dp:1, assetClass:'crypto' },
|
||||
}
|
||||
|
||||
export const isCrypto = (pair) => PAIR_META[pair]?.assetClass === 'crypto'
|
||||
const strikeStep = (pair) => PAIR_META[pair]?.strikeStep ?? (PAIR_META[pair]?.pip ?? 0.0001) * 100
|
||||
|
||||
const DEFAULT_PAIR = 'EURUSD'
|
||||
const meta = PAIR_META[DEFAULT_PAIR]
|
||||
|
||||
@@ -34,9 +42,17 @@ export const usePortfolioStore = create((set, get) => ({
|
||||
legs: DEFAULT_LEGS,
|
||||
nextId: 3,
|
||||
|
||||
// Vol quotes (ATM / 25Δ RR / 25Δ BF, in vol points) handed from Vol Lab to Vol Smile.
|
||||
smileQuotes: null,
|
||||
setSmileQuotes: (smileQuotes) => set({ smileQuotes }),
|
||||
|
||||
setPair: (pair) => {
|
||||
const m = PAIR_META[pair] || meta
|
||||
set({ pair, S: m.defaultS, r_d: m.r_d, r_f: m.r_f })
|
||||
// Keep each leg's moneyness (K/S) so strikes stay meaningful across very different price levels.
|
||||
set(s => ({
|
||||
pair, S: m.defaultS, r_d: m.r_d, r_f: m.r_f, sigma: m.sigma ?? s.sigma, smileQuotes: null,
|
||||
legs: s.legs.map(l => ({ ...l, K: +(l.K / s.S * m.defaultS).toPrecision(6) })),
|
||||
}))
|
||||
},
|
||||
setS: (S) => set({ S: parseFloat(S) }),
|
||||
setSigma: (sigma) => set({ sigma: parseFloat(sigma) }),
|
||||
@@ -56,7 +72,7 @@ export const usePortfolioStore = create((set, get) => ({
|
||||
loadStrategy: (strategy) => set(s => ({
|
||||
legs: strategy.legs.map((leg, i) => ({
|
||||
id: i+1, type: leg.type,
|
||||
K: parseFloat((s.S + (leg.K_offset || 0) * PAIR_META[s.pair]?.pip * 100 || 0).toFixed(5)),
|
||||
K: parseFloat((s.S + (leg.K_offset || 0) * strikeStep(s.pair)).toFixed(5)),
|
||||
T: s.T, qty: leg.qty,
|
||||
})),
|
||||
nextId: strategy.legs.length + 1,
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 415 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 185 KiB |
Reference in New Issue
Block a user