feat: Vanna-Volga FX volatility smile tab

Builds the implied-vol smile from the three standard FX market quotes
(ATM delta-neutral straddle, 25-delta risk reversal, 25-delta butterfly)
using Castagna & Mercurio's second-order Vanna-Volga approximation, and
reprices each portfolio leg off the smile vs the flat vol.

- backend: core/vanna_volga.py (delta->strike, DNS ATM, VV smile) and
  POST /api/smile
- frontend: new "Vol Smile" dashboard tab with quote inputs, smile
  chart, pillar table and smile-vs-flat leg repricing

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FpiqUMAyo4RL5DdQHMWbwE
This commit is contained in:
Claude
2026-09-28 16:45:11 +00:00
parent a6b0ea7fbb
commit 23a5a975ff
9 changed files with 355 additions and 2 deletions
+1
View File
@@ -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
+139
View File
@@ -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),
},
}
+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
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("/")
+14
View File
@@ -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))
+12
View File
@@ -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)
+1
View File
@@ -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
+184
View File
@@ -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}<br>σ %{y:.3f}%<extra></extra>',
},
{
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}<br>K %{x:.5f}<br>σ %{y:.3f}%<extra></extra>',
},
...(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}<br>σ %{y:.3f}%<extra></extra>',
}] : []),
] : []
return (
<div style={{ padding:16 }}>
<div style={{ display:'flex', alignItems:'flex-start', justifyContent:'space-between', marginBottom:12, gap:12, flexWrap:'wrap' }}>
<div>
<div className="section-title">FX Volatility Smile — Vanna-Volga ({pair})</div>
<div style={{ fontSize:9, color:'var(--text-muted)', marginTop:2 }}>
Built from ATM / 25Δ RR / 25Δ BF quotes · spot delta, DNS ATM · T={T}y
{result && <> · Fwd {result.forward}</>}
</div>
</div>
<div style={{ display:'flex', gap:8, flexWrap:'wrap' }}>
{QUOTE_FIELDS.map(({ key, label, hint }) => (
<label key={key} title={hint} className="glass"
style={{ display:'flex', alignItems:'center', gap:6, padding:'4px 10px', borderRadius:6, fontSize:10 }}>
<span style={{ color:'var(--text-muted)' }}>{label}</span>
<input type="number" step="0.05" value={Number.isFinite(quotes[key]) ? quotes[key] : ''}
onChange={e => setQuote(key, e.target.value)}
className="font-mono"
style={{ width:60, background:'transparent', border:'none', outline:'none',
color:'var(--cyan)', fontSize:11, fontWeight:600 }}/>
<span style={{ color:'var(--text-muted)' }}>%</span>
</label>
))}
</div>
</div>
{error && (
<div style={{ padding:10, borderRadius:6, marginBottom:10, background:'rgba(255,61,90,0.1)',
border:'1px solid #ff3d5a', color:'#ff3d5a', fontSize:11 }}>{error}</div>
)}
{!result && loading && <div className="skeleton" style={{ height:320, borderRadius:8 }}/>}
{result && (
<>
<div className="glass" style={{ borderRadius:8, padding:8, marginBottom:12, opacity: loading ? 0.6 : 1 }}>
<Plot data={traces}
layout={{
paper_bgcolor:'transparent', plot_bgcolor:'#080d18',
font:{ color:'#94a3b8', size:11 },
margin:{ l:52, r:16, t:16, b:50 },
xaxis:{ title:`Strike (${pair})`, gridcolor:'#1e293b', color:'#475569', zeroline:false },
yaxis:{ title:'Implied vol (%)', gridcolor:'#1e293b', color:'#475569', zeroline:false },
legend:{ bgcolor:'transparent', font:{ size:9 }, orientation:'h', x:0, y:-0.18 },
shapes:[{ type:'line', x0:S, x1:S, yref:'paper', y0:0, y1:1,
line:{ color:'rgba(255,215,0,0.35)', width:1, dash:'dot' } }],
hoverlabel:{ bgcolor:'#0f172a', font:{ color:'#e2e8f0' }, bordercolor:'rgba(0,212,255,0.15)' },
}}
config={{ responsive:true, displayModeBar:false }}
style={{ width:'100%', height:320 }} />
</div>
<div style={{ display:'grid', gridTemplateColumns:'repeat(auto-fit, minmax(280px, 1fr))', gap:12 }}>
<SmileTable title="Market Pillars"
headers={['Pillar', 'Strike', 'Vol']}
rows={result.pillars.map(p => [p.label, p.K.toFixed(5), pct(p.vol)])} />
{result.legs.length > 0 && (
<SmileTable title="Portfolio: Smile vs Flat Vol"
headers={['Leg', 'Smile σ', 'Flat value', 'Smile value', 'Smile adj.']}
rows={[
...result.legs.map(l => [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)],
]} />
)}
</div>
<div style={{ fontSize:9, color:'var(--text-muted)', marginTop:10 }}>
Castagna &amp; 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.
</div>
</>
)}
</div>
)
}
const signed = v => (
<span style={{ color: v >= 0 ? 'var(--green)' : 'var(--red)' }}>{(v >= 0 ? '+' : '') + v.toFixed(5)}</span>
)
function SmileTable({ title, headers, rows }) {
return (
<div>
<div className="section-title" style={{ fontSize:10, marginBottom:6 }}>{title}</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)' }}>
{headers.map((h, i) => (
<th key={h} style={{ padding:'7px 10px', fontWeight:600, fontSize:9, color:'var(--text-muted)',
letterSpacing:'0.05em', textAlign: i === 0 ? 'left' : 'right', whiteSpace:'nowrap' }}>{h}</th>
))}
</tr>
</thead>
<tbody>
{rows.map((row, r) => (
<tr key={r} style={{ borderTop:'1px solid var(--border)' }}>
{row.map((cell, i) => (
<td key={i} className={i ? 'font-mono' : undefined}
style={{ padding:'6px 10px', textAlign: i === 0 ? 'left' : 'right', whiteSpace:'nowrap' }}>{cell}</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</div>
)
}
+2
View File
@@ -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 },
Binary file not shown.

After

Width:  |  Height:  |  Size: 285 KiB