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:
Mihir.K
2026-09-29 11:18:59 +05:30
committed by GitHub
18 changed files with 894 additions and 25 deletions
+1
View File
@@ -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
+20 -13
View File
@@ -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
+285
View File
@@ -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
View File
@@ -1,6 +1,6 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from .routers import greeks, surface, market, montecarlo, scenarios, strategies, export, forex, institutional, news, livedata, smile
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("/")
+13 -3
View File
@@ -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
+3
View File
@@ -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"},
}
+125
View File
@@ -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}
View File
+29
View File
@@ -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
+113
View File
@@ -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"]
+4
View File
@@ -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
+1 -1
View File
@@ -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}
+274
View File
@@ -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 &amp; 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&apos;s condition (Gatheral &amp; 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>
)
+2 -2
View File
@@ -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 })
+2
View File
@@ -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 },
+20 -4
View File
@@ -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