mirror of
https://github.com/Mihirkansara/nexus-quant-terminal.git
synced 2026-11-02 09:36:11 +00:00
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:
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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("/")
|
||||
|
||||
@@ -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))
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 & 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>
|
||||
)
|
||||
}
|
||||
@@ -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 |
Reference in New Issue
Block a user