diff --git a/README.md b/README.md index afe85f5..dd7d64d 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,7 @@ NEXUS TERMINAL is a Bloomberg-style web application for professional forex optio | **Scenario Analysis** | Stress tests across spot ±5%/±10% and vol ±1pp/±2pp shocks | | **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 | ### Live Intelligence diff --git a/backend/app/core/vanna_volga.py b/backend/app/core/vanna_volga.py new file mode 100644 index 0000000..59f4aad --- /dev/null +++ b/backend/app/core/vanna_volga.py @@ -0,0 +1,139 @@ +""" +vanna_volga.py — FX volatility smile from ATM / 25Δ risk reversal / 25Δ butterfly +quotes via the Vanna-Volga method. + +FX option markets quote each tenor with three liquid instruments: + ATM — delta-neutral straddle vol + RR25 — 25Δ risk reversal = σ(25Δ call) − σ(25Δ put) + BF25 — 25Δ butterfly ≈ (σ(25Δ call) + σ(25Δ put))/2 − σ(ATM) +Pillar vols (smile-strangle approximation, no broker-strangle iteration): + σ25C = ATM + BF25 + RR25/2 σ25P = ATM + BF25 − RR25/2 + +Strikes use unadjusted spot delta and the delta-neutral-straddle ATM: + F = S·e^((r_d − r_f)·T) + K_ATM = F·e^(σ²T/2) + K_Δ = F·e^(−α·σ·√T + σ²T/2), α = ±N⁻¹(|Δ|·e^(r_f·T)) (+ call, − put) + +The smile between/beyond the pillars uses Castagna & Mercurio's second-order +Vanna-Volga approximation, which reproduces the three pillar vols exactly. + +References: + Castagna, A. & Mercurio, F. (2007). "The vanna-volga method for implied + volatilities." Risk, January 2007, 106–111. + Reiswich, D. & Wystup, U. (2010). "A Guide to FX Options Quoting Conventions." + Journal of Derivatives, 18(2), 58–68. +""" + +import numpy as np +from scipy.stats import norm + +from .garman_kohlhagen import gk_price + + +def _forward(S, T, r_d, r_f): + return S * np.exp((r_d - r_f) * T) + + +def strike_from_delta(S, T, r_d, r_f, sigma, delta, option_type="call"): + """Strike for an unadjusted spot delta (|delta| in (0, e^(−r_f·T))).""" + F = _forward(S, T, r_d, r_f) + alpha = norm.ppf(abs(delta) * np.exp(r_f * T)) + if option_type == "put": + alpha = -alpha + sqrt_T = np.sqrt(T) + return float(F * np.exp(-alpha * sigma * sqrt_T + 0.5 * sigma ** 2 * T)) + + +def atm_dns_strike(S, T, r_d, r_f, sigma): + """Delta-neutral straddle ATM strike.""" + return float(_forward(S, T, r_d, r_f) * np.exp(0.5 * sigma ** 2 * T)) + + +def pillars(S, T, r_d, r_f, atm, rr25, bf25): + """Return the three (strike, vol) pillars: 25Δ put, ATM, 25Δ call.""" + vol_c = atm + bf25 + 0.5 * rr25 + vol_p = atm + bf25 - 0.5 * rr25 + if min(vol_c, vol_p, atm) <= 0: + raise ValueError("Quotes imply a non-positive pillar volatility.") + return [ + {"label": "25Δ Put", "K": strike_from_delta(S, T, r_d, r_f, vol_p, 0.25, "put"), "vol": vol_p}, + {"label": "ATM DNS", "K": atm_dns_strike(S, T, r_d, r_f, atm), "vol": atm}, + {"label": "25Δ Call", "K": strike_from_delta(S, T, r_d, r_f, vol_c, 0.25, "call"), "vol": vol_c}, + ] + + +def vv_vol(K, S, T, r_d, r_f, pillar_list): + """Second-order Vanna-Volga implied vol at strike K (Castagna-Mercurio 2007).""" + (K1, s1), (K2, s2), (K3, s3) = [(p["K"], p["vol"]) for p in pillar_list] + K = np.asarray(K, dtype=float) + F = _forward(S, T, r_d, r_f) + sqrt_T = np.sqrt(T) + + ln = np.log + y1 = ln(K2 / K) * ln(K3 / K) / (ln(K2 / K1) * ln(K3 / K1)) + y2 = ln(K / K1) * ln(K3 / K) / (ln(K2 / K1) * ln(K3 / K2)) + y3 = ln(K / K1) * ln(K / K2) / (ln(K3 / K1) * ln(K3 / K2)) + + def d1d2(x): + d1 = (ln(F / x) + 0.5 * s2 ** 2 * T) / (s2 * sqrt_T) + return d1, d1 - s2 * sqrt_T + + d1K, d2K = d1d2(K) + d1_1, d2_1 = d1d2(K1) + d1_3, d2_3 = d1d2(K3) + + first_order = y1 * s1 + y2 * s2 + y3 * s3 + D1 = first_order - s2 + D2 = y1 * d1_1 * d2_1 * (s1 - s2) ** 2 + y3 * d1_3 * d2_3 * (s3 - s2) ** 2 + dd = d1K * d2K + disc = s2 ** 2 + dd * (2 * s2 * D1 + D2) + + with np.errstate(invalid="ignore", divide="ignore"): + second = s2 + (-s2 + np.sqrt(disc)) / dd + # Fall back to first order where the root is undefined or d1·d2 ≈ 0. + ok = (disc >= 0) & (np.abs(dd) > 1e-10) & np.isfinite(second) + vol = np.where(ok, second, first_order) + return np.maximum(vol, 1e-4) + + +def build_smile(S, T, r_d, r_f, atm, rr25, bf25, options=None, flat_sigma=None, n_points=61): + """Smile curve, pillar table and (optionally) smile-vs-flat repricing of legs.""" + pl = pillars(S, T, r_d, r_f, atm, rr25, bf25) + + # Curve spans roughly the 5Δ put to 5Δ call strikes. + K_lo = strike_from_delta(S, T, r_d, r_f, pl[0]["vol"], 0.05, "put") + K_hi = strike_from_delta(S, T, r_d, r_f, pl[2]["vol"], 0.05, "call") + strikes = np.exp(np.linspace(np.log(K_lo), np.log(K_hi), n_points)) + vols = vv_vol(strikes, S, T, r_d, r_f, pl) + + flat = atm if flat_sigma is None else flat_sigma + legs = [] + total_flat = total_smile = 0.0 + for opt in options or []: + K, qty, otype = float(opt["K"]), float(opt["qty"]), opt["type"] + leg_T = float(opt.get("T", T)) + # The smile is built for the request tenor T; legs at other tenors reuse it. + smile_vol = float(vv_vol(K, S, T, r_d, r_f, pl)) + p_flat = gk_price(S, K, leg_T, r_d, r_f, flat, otype) * qty + p_smile = gk_price(S, K, leg_T, r_d, r_f, smile_vol, otype) * qty + total_flat += p_flat + total_smile += p_smile + legs.append({ + "label": f"{'+' if qty > 0 else ''}{qty:g} {otype.upper()} K={K}", + "K": K, "flat_vol": round(flat, 5), "smile_vol": round(smile_vol, 5), + "value_flat": round(p_flat, 6), "value_smile": round(p_smile, 6), + "smile_adj": round(p_smile - p_flat, 6), + }) + + return { + "forward": round(float(_forward(S, T, r_d, r_f)), 6), + "pillars": [{**p, "K": round(p["K"], 6), "vol": round(p["vol"], 5)} for p in pl], + "strikes": [round(float(k), 6) for k in strikes], + "vols": [round(float(v), 6) for v in vols], + "legs": legs, + "total": { + "value_flat": round(total_flat, 6), + "value_smile": round(total_smile, 6), + "smile_adj": round(total_smile - total_flat, 6), + }, + } diff --git a/backend/app/main.py b/backend/app/main.py index 4fc405b..45d825a 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,6 +1,6 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware -from .routers import greeks, surface, market, montecarlo, scenarios, strategies, export, forex, institutional, news, livedata +from .routers import greeks, surface, market, montecarlo, scenarios, strategies, export, forex, institutional, news, livedata, smile 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]: +for router in [greeks, surface, market, montecarlo, scenarios, strategies, export, forex, institutional, news, livedata, smile]: app.include_router(router.router, prefix="/api") @app.get("/") diff --git a/backend/app/routers/smile.py b/backend/app/routers/smile.py new file mode 100644 index 0000000..2730dec --- /dev/null +++ b/backend/app/routers/smile.py @@ -0,0 +1,14 @@ +from fastapi import APIRouter, HTTPException +from ..schemas import SmileRequest +from ..core.vanna_volga import build_smile + +router = APIRouter(prefix="/smile", tags=["smile"]) + +@router.post("") +def compute_smile(req: SmileRequest): + options = [o.model_dump() for o in req.options] + try: + return build_smile(req.S, req.T, req.r_d, req.r_f, req.atm, req.rr25, req.bf25, + options=options, flat_sigma=req.sigma) + except ValueError as e: + raise HTTPException(status_code=422, detail=str(e)) diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 6c00fdd..6fba352 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -64,3 +64,15 @@ class ScenarioRequest(BaseModel): ScenarioShock(label="Sharp Rally", dS_pct=0.015, dVol=-0.03), ScenarioShock(label="Breakout", dS_pct=0.03, dVol=-0.05), ]) + + +class SmileRequest(BaseModel): + S: float = Field(..., gt=0, description="Spot exchange rate") + T: float = Field(..., gt=0, description="Tenor in years") + r_d: float = Field(default=0.0525) + r_f: float = Field(default=0.0400) + atm: float = Field(..., gt=0, lt=5, description="ATM delta-neutral straddle vol") + rr25: float = Field(default=0.0, gt=-1, lt=1, description="25Δ risk reversal (call − put vol)") + bf25: float = Field(default=0.0, gt=-1, lt=1, description="25Δ butterfly") + sigma: float | None = Field(default=None, gt=0, lt=5, description="Flat vol to compare against (defaults to ATM)") + options: list[OptionLeg] = Field(default_factory=list) diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js index d6d4b5e..1aafca0 100644 --- a/frontend/src/api/client.js +++ b/frontend/src/api/client.js @@ -20,6 +20,7 @@ export const computeGreeks = (p) => api.post('/greeks', p).then(r= export const computeSurface = (p) => api.post('/surface', p).then(r=>r.data) 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) export const exportCSV = (p) => api.post('/export/csv', p, {responseType:'blob'}).then(r=>r.data) // Institutional flow diff --git a/frontend/src/components/VolSmile.jsx b/frontend/src/components/VolSmile.jsx new file mode 100644 index 0000000..890d560 --- /dev/null +++ b/frontend/src/components/VolSmile.jsx @@ -0,0 +1,184 @@ +import { useEffect, useState } from 'react' +import _Plot from 'react-plotly.js' +const Plot = _Plot?.default ?? _Plot +import { usePortfolioStore } from '../store/portfolio' +import { computeSmile } from '../api/client' + +// Quotes are entered in vol points (%) like on a dealer screen. +const QUOTE_FIELDS = [ + { key:'atm', label:'ATM σ', hint:'Delta-neutral straddle' }, + { key:'rr25', label:'25Δ RR', hint:'σ(25Δ call) − σ(25Δ put)' }, + { key:'bf25', label:'25Δ BF', hint:'Wing premium over ATM' }, +] + +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 }) + // 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 }) + + const { atm, rr25, bf25 } = quotes + const valid = [atm, rr25, bf25].every(Number.isFinite) && atm > 0 + const requestKey = valid ? JSON.stringify({ + S, T, r_d, r_f, sigma, + atm: atm / 100, rr25: rr25 / 100, bf25: bf25 / 100, + options: legs.map(({ type, K, T, qty }) => ({ type, K, T, qty })), + }) : null + + useEffect(() => { + if (!requestKey) return + let stale = false + computeSmile(JSON.parse(requestKey)) + .then(data => { if (!stale) setResponse({ key:requestKey, data, error:null }) }) + .catch(e => { if (!stale) setResponse(r => ({ ...r, key:requestKey, + error: e?.response?.data?.detail ?? 'Could not build smile' })) }) + return () => { stale = true } + }, [requestKey]) + + const result = response.data + const error = response.key === requestKey ? response.error : null + const loading = requestKey !== null && response.key !== requestKey + + const setQuote = (key, v) => setQuotes(q => ({ ...q, [key]: parseFloat(v) })) + const pct = v => (v * 100).toFixed(3) + '%' + + const traces = result ? [ + { + type:'scatter', mode:'lines', name:'Vanna-Volga smile', + x: result.strikes, y: result.vols.map(v => v * 100), + line:{ color:'#00d4ff', width:2.5 }, + hovertemplate:'K %{x:.5f}
σ %{y:.3f}%', + }, + { + type:'scatter', mode:'lines', name:`Flat σ ${(sigma * 100).toFixed(2)}%`, + x:[result.strikes[0], result.strikes.at(-1)], y:[sigma * 100, sigma * 100], + line:{ color:'rgba(255,255,255,0.25)', width:1, dash:'dash' }, hoverinfo:'skip', + }, + { + type:'scatter', mode:'markers+text', name:'Market pillars', + x: result.pillars.map(p => p.K), y: result.pillars.map(p => p.vol * 100), + text: result.pillars.map(p => p.label), textposition:'top center', + textfont:{ color:'#ffd700', size:9 }, + marker:{ color:'#ffd700', size:9, symbol:'diamond', line:{ color:'#0a0e1a', width:2 } }, + hovertemplate:'%{text}
K %{x:.5f}
σ %{y:.3f}%', + }, + ...(result.legs.length ? [{ + type:'scatter', mode:'markers', name:'Portfolio strikes', + x: result.legs.map(l => l.K), y: result.legs.map(l => l.smile_vol * 100), + marker:{ color:'#00ff88', size:8, symbol:'circle', line:{ color:'#0a0e1a', width:2 } }, + text: result.legs.map(l => l.label), + hovertemplate:'%{text}
σ %{y:.3f}%', + }] : []), + ] : [] + + return ( +
+
+
+
FX Volatility Smile — Vanna-Volga ({pair})
+
+ Built from ATM / 25Δ RR / 25Δ BF quotes · spot delta, DNS ATM · T={T}y + {result && <> · Fwd {result.forward}} +
+
+
+ {QUOTE_FIELDS.map(({ key, label, hint }) => ( + + ))} +
+
+ + {error && ( +
{error}
+ )} + + {!result && loading &&
} + + {result && ( + <> +
+ +
+ +
+ [p.label, p.K.toFixed(5), pct(p.vol)])} /> + {result.legs.length > 0 && ( + [l.label, pct(l.smile_vol), + l.value_flat.toFixed(5), l.value_smile.toFixed(5), signed(l.smile_adj)]), + ['Total', '', result.total.value_flat.toFixed(5), + result.total.value_smile.toFixed(5), signed(result.total.smile_adj)], + ]} /> + )} +
+
+ Castagna & Mercurio (2007) second-order approximation. Pillar vols use σ25 = ATM + BF ± RR/2 + (smile-strangle convention). All legs are priced off the smile for tenor T. +
+ + )} +
+ ) +} + +const signed = v => ( + = 0 ? 'var(--green)' : 'var(--red)' }}>{(v >= 0 ? '+' : '') + v.toFixed(5)} +) + +function SmileTable({ title, headers, rows }) { + return ( +
+
{title}
+
+ + + + {headers.map((h, i) => ( + + ))} + + + + {rows.map((row, r) => ( + + {row.map((cell, i) => ( + + ))} + + ))} + +
{h}
{cell}
+
+
+ ) +} diff --git a/frontend/src/pages/Dashboard.jsx b/frontend/src/pages/Dashboard.jsx index ab8dc36..50235db 100644 --- a/frontend/src/pages/Dashboard.jsx +++ b/frontend/src/pages/Dashboard.jsx @@ -8,6 +8,7 @@ import CandlestickChart from '../components/CandlestickChart' import QuantSignals from '../components/QuantSignals' import InstitutionalFlow from '../components/InstitutionalFlow' import EconomicCalendar from '../components/EconomicCalendar' +import VolSmile from '../components/VolSmile' const IVSurface = lazy(() => import('../components/IVSurface')) const WorldMap = lazy(() => import('../components/WorldMap')) @@ -38,6 +39,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:'smile', icon:'〰', label:'Vol Smile', component: VolSmile }, { id:'surfaces', icon:'🗻', label:'3D Surfaces', component: SurfacePlot }, { id:'breakeven', icon:'🎯', label:'Breakeven', component: BreakevenChart }, { id:'scenarios', icon:'⚠', label:'Scenarios', component: ScenarioTable }, diff --git a/screenshots/vol-smile-preview.png b/screenshots/vol-smile-preview.png new file mode 100644 index 0000000..1bbb4c5 Binary files /dev/null and b/screenshots/vol-smile-preview.png differ