feat: Cross-Asset Desk — correlations, gold/silver ratio, Margrabe outperformance options

- core/xasset.py: common-calendar returns (yields as changes), rolling and
  EWMA correlation matrices, correlation-break monitor (z of 30D corr vs its
  own history); gold/silver ratio stats, OU half-life, Engle-Granger ADF
  cointegration (MacKinnon CVs), Kalman random-walk hedge ratio with centred
  regressor and data-scaled noise; Margrabe exchange option with deltas,
  ratio/leg vegas and analytic correlation sensitivity, plus an MC check.
- GET /api/xasset (yfinance, cached 30 min, 503 on failure) and
  POST /api/xasset/margrabe (ATM outperformance on notional, premium vs rho).
- Cross-Asset tab: GSR KPIs, ratio chart with 1Y bands + Kalman z, heatmap,
  break monitor, rolling correlations, Margrabe pricer for XAG/XAU, BTC/XAU,
  ETH/BTC.
- 5 tests: Margrabe vs MC / BS limit / parity / d rho, EG power and size, OU
  half-life, Kalman tracking, reports and endpoints on a synthetic market.

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-10-05 05:25:22 +00:00
parent a4e6f324bd
commit 042a11dc3f
9 changed files with 763 additions and 2 deletions
+1
View File
@@ -41,6 +41,7 @@ NEXUS TERMINAL is a Bloomberg-style web application for professional forex optio
| **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` |
| **Vol Forecast & Regime (AI)** | HAR-RV (weekend-aware for crypto), GJR-GARCH and gradient-boosting forecasts of 1D/1W/1M vol, rolling out-of-sample QLIKE leaderboard with an inverse-QLIKE ensemble, 2-state HMM regime detection, and a forecast-vs-Deribit-implied rich/cheap signal |
| **Hedge Lab** | Monte Carlo delta-hedging / gamma-scalping P&L of your portfolio: realised vs implied vol, GBM / Merton jumps / historical block bootstrap, weekend gaps for 24/5 markets, time or delta-band hedging, transaction costs and carry, with Derman-Kamal and Leland benchmarks and a hedge-frequency risk-vs-cost sweep |
| **Cross-Asset Desk** | Gold · silver · BTC · ETH · EURUSD · DXY · US10Y · WTI correlation matrices (30D/90D/1Y/EWMA) with a correlation-break monitor; gold/silver ratio z-scores, OU half-life, Engle-Granger cointegration and Kalman hedge ratio; Margrabe outperformance-option pricer with correlation sensitivity |
### Live Intelligence
+239
View File
@@ -0,0 +1,239 @@
"""
xasset.py — Cross-asset correlation desk, gold/silver ratio analytics and
Margrabe outperformance options.
Correlations
Daily returns on the common trading calendar (crypto closes are sampled on
the same dates, so Fri→Mon crypto moves are aggregated). Yields enter as daily
changes, everything else as log returns.
• Rolling Pearson correlation over a window, and an EWMA (RiskMetrics λ = 0.94)
correlation from the EWMA covariance recursion (J.P. Morgan 1996).
• Correlation-break monitor: z-score of the current 30-day correlation of each
pair against the distribution of its own rolling 30-day correlation.
Gold/silver ratio (GSR = XAU / XAG)
• Level, z-scores and percentiles versus 1y/3y history.
• Ornstein-Uhlenbeck fit of log GSR by AR(1) OLS: half-life = ln 2 / κ.
• Engle-Granger (1987) cointegration of log XAU on log XAG: OLS residual tested
with an ADF regression (constant, one lagged difference); 2-variable
MacKinnon (2010) critical values −3.90 / −3.34 / −3.04 (1/5/10 %).
• Dynamic hedge ratio by Kalman filter: log XAU_t = α_t + β_t·log XAG_t + v_t with
random-walk (α, β); the standardised innovation e_t / √Q_t is the tradeable
spread z-score (Chan 2013; QuantStart).
• Ratio vol identity: σ_ratio² = σ_1² + σ_2² − 2ρσ_1σ_2.
Margrabe (1978) exchange / outperformance option
Payoff max(n_1·S_1(T) − n_2·S_2(T), 0), assets paying continuous yields q_i
(metal lease rates; ≈0 for crypto):
σ = √(σ_1² + σ_2² − 2ρσ_1σ_2)
d_1 = [ln(n_1 S_1 e^{−q_1 T} / (n_2 S_2 e^{−q_2 T})) + σ²T/2] / (σ√T), d_2 = d_1 − σ√T
V = n_1 S_1 e^{−q_1 T} N(d_1) − n_2 S_2 e^{−q_2 T} N(d_2)
No domestic rate enters: asset 2 is the numeraire. With n_i = N / S_i(0) it is
an at-the-money outperformance option on notional N (Derman 1992).
References:
Margrabe, W. (1978). J. Finance, 33(1), 177–186.
Derman, E. (1992). "Outperformance options." Goldman Sachs QS research notes.
Engle, R. & Granger, C. (1987). Econometrica, 55(2), 251–276.
MacKinnon, J. (2010). "Critical values for cointegration tests." QED WP 1227.
Chan, E. (2013). Algorithmic Trading, ch. 3 (Kalman filter hedge ratio).
"""
import numpy as np
import pandas as pd
from scipy.stats import norm
ADF_CV_EG2 = {"1%": -3.90, "5%": -3.34, "10%": -3.04}
PAIRS_OF_INTEREST = [("XAUUSD", "XAGUSD"), ("BTCUSD", "XAUUSD"), ("BTCUSD", "ETHUSD"),
("XAUUSD", "US10Y"), ("XAUUSD", "DXY"), ("BTCUSD", "US10Y"),
("EURUSD", "XAUUSD"), ("XAUUSD", "WTI")]
# ─── returns & correlations ───────────────────────────────────────────────────
def returns_frame(closes: pd.DataFrame, yield_cols=("US10Y",)) -> pd.DataFrame:
"""Daily log returns (yield columns: changes in percentage points) on common dates."""
px = closes.dropna(how="any")
out = {}
for col in px.columns:
s = px[col].astype(float)
out[col] = s.diff() if col in yield_cols else np.log(s).diff()
return pd.DataFrame(out).dropna(how="any")
def ewma_corr(rets: pd.DataFrame, lam: float = 0.94) -> pd.DataFrame:
x = rets.to_numpy()
cov = np.cov(x[:30].T) if len(x) > 30 else np.cov(x.T)
for row in x:
cov = lam * cov + (1 - lam) * np.outer(row, row)
sd = np.sqrt(np.diag(cov))
return pd.DataFrame(cov / np.outer(sd, sd), index=rets.columns, columns=rets.columns)
def correlation_report(rets: pd.DataFrame, windows=(30, 90, 250), short=30, hist_days=500):
cols = list(rets.columns)
mats = {f"{w}d": rets.tail(w).corr().round(4).values.tolist() for w in windows if len(rets) >= w}
mats["ewma"] = ewma_corr(rets).round(4).values.tolist()
rolling, breaks = {}, []
tail = rets.tail(hist_days + short)
for a, b in PAIRS_OF_INTEREST:
if a not in cols or b not in cols:
continue
rc = tail[a].rolling(short).corr(tail[b]).dropna()
r90 = tail[a].rolling(90).corr(tail[b]).dropna()
key = f"{a}/{b}"
rolling[key] = {"dates": [d.strftime("%Y-%m-%d") for d in rc.index],
f"corr_{short}d": rc.round(4).tolist(),
"corr_90d": r90.reindex(rc.index).round(4).tolist()}
now, mu, sd = float(rc.iloc[-1]), float(rc.mean()), float(rc.std())
z = (now - mu) / sd if sd > 0 else 0.0
breaks.append({"pair": key, "corr_now": now, "corr_90d": float(r90.iloc[-1]),
"mean": mu, "z": z, "percentile": float((rc < now).mean() * 100),
"flag": "BREAK" if abs(z) >= 2 else "WATCH" if abs(z) >= 1.5 else ""})
vols = {c: float(rets[c].tail(63).std() * np.sqrt(252)) for c in cols}
return {"assets": cols, "matrices": mats, "rolling": rolling,
"breaks": sorted(breaks, key=lambda r: -abs(r["z"])), "vol_3m": vols,
"n_obs": int(len(rets)), "last_date": rets.index[-1].strftime("%Y-%m-%d")}
# ─── gold/silver ratio ────────────────────────────────────────────────────────
def ou_fit(x: np.ndarray, ppy: int = 252) -> dict:
"""AR(1) OLS on a level series → OU κ (per year), long-run mean, half-life (days)."""
x = np.asarray(x, dtype=float)
X = np.column_stack([np.ones(len(x) - 1), x[:-1]])
(a, b), *_ = np.linalg.lstsq(X, x[1:], rcond=None)
b = float(np.clip(b, 1e-6, 0.999999))
kappa = -np.log(b) * ppy
return {"kappa": float(kappa), "mean": float(a / (1 - b)),
"half_life_days": float(np.log(2) / kappa * ppy)}
def adf_stat(e: np.ndarray, lags: int = 1) -> float:
"""t-statistic of γ in Δe_t = c + γ e_{t−1} + Σ φ_i Δe_{t−i} + ε."""
e = np.asarray(e, dtype=float)
de = np.diff(e)
y = de[lags:]
cols = [np.ones(len(y)), e[lags:-1]] + [de[lags - i:-i] for i in range(1, lags + 1)]
X = np.column_stack(cols)
beta, *_ = np.linalg.lstsq(X, y, rcond=None)
resid = y - X @ beta
s2 = resid @ resid / (len(y) - X.shape[1])
se = np.sqrt(s2 * np.linalg.inv(X.T @ X)[1, 1])
return float(beta[1] / se)
def engle_granger(y: np.ndarray, x: np.ndarray) -> dict:
X = np.column_stack([np.ones(len(x)), x])
(a, b), *_ = np.linalg.lstsq(X, y, rcond=None)
resid = y - a - b * x
stat = adf_stat(resid)
return {"alpha": float(a), "beta": float(b), "adf_stat": stat, "critical": ADF_CV_EG2,
"cointegrated_5pct": bool(stat < ADF_CV_EG2["5%"])}
def kalman_hedge(y: np.ndarray, x: np.ndarray, delta: float = 1e-2, ve: float | None = None,
calib: int = 120):
"""Random-walk (α, β) Kalman filter for y = α + βx + v. Returns α, β, innovation z.
The observation noise ve defaults to the OLS residual variance over the first
`calib` points, so the innovation z-scores are on a unit scale."""
n = len(y)
m = min(n, calib)
x_bar = float(np.mean(x[:m]))
xc = np.asarray(x, dtype=float) - x_bar # centring decouples α from β
if ve is None:
X = np.column_stack([np.ones(m), xc[:m]])
coef, *_ = np.linalg.lstsq(X, y[:m], rcond=None)
ve = float(np.var(y[:m] - X @ coef)) or 1e-8
# State noise relative to observation noise: delta sets how fast (α, β) may drift.
Vw = delta / (1 - delta) * ve * np.diag([1.0, 1.0 / max(float(np.var(xc[:m])), 1e-12)])
theta = np.zeros(2)
R = np.zeros((2, 2))
P = None
alpha, beta, zs = np.empty(n), np.empty(n), np.empty(n)
for t in range(n):
F = np.array([1.0, xc[t]])
R = (P + Vw) if P is not None else R
yhat = F @ theta
Q = F @ R @ F + ve
e = y[t] - yhat
K = R @ F / Q
theta = theta + K * e
P = R - np.outer(K, F) @ R
alpha[t], beta[t], zs[t] = theta[0], theta[1], e / np.sqrt(Q)
return alpha - beta * x_bar, beta, zs # α back on the uncentred scale
def gsr_report(xau: pd.Series, xag: pd.Series, burn_in: int = 60) -> dict:
df = pd.concat([xau, xag], axis=1, keys=["xau", "xag"]).dropna()
gsr = df["xau"] / df["xag"]
lg = np.log(gsr.to_numpy())
y, x = np.log(df["xau"].to_numpy()), np.log(df["xag"].to_numpy())
def window_stats(n):
w = gsr.tail(n)
return {"mean": float(w.mean()), "sd": float(w.std()),
"z": float((gsr.iloc[-1] - w.mean()) / w.std()) if w.std() > 0 else 0.0,
"percentile": float((w < gsr.iloc[-1]).mean() * 100),
"min": float(w.min()), "max": float(w.max())}
ou = ou_fit(lg[-min(len(lg), 750):])
eg = engle_granger(y[-min(len(y), 750):], x[-min(len(x), 750):])
a_k, b_k, z_k = kalman_hedge(y, x)
r = np.diff(lg)
tail = gsr.tail(500)
dates = [d.strftime("%Y-%m-%d") for d in tail.index]
m1y = gsr.rolling(252, min_periods=60).mean().reindex(tail.index)
s1y = gsr.rolling(252, min_periods=60).std().reindex(tail.index)
k = len(tail)
return {
"current": float(gsr.iloc[-1]),
"xau": float(df["xau"].iloc[-1]), "xag": float(df["xag"].iloc[-1]),
"stats_1y": window_stats(252), "stats_3y": window_stats(min(len(gsr), 756)),
"ou": {**ou, "mean_level": float(np.exp(ou["mean"]))},
"engle_granger": eg,
"kalman": {"beta_now": float(b_k[-1]), "alpha_now": float(a_k[-1]),
"z_now": float(z_k[-1]),
"beta": b_k[-k:].round(5).tolist(), "z": z_k[-k:].round(4).tolist(),
"valid_from": max(0, burn_in - (len(gsr) - k))},
"ratio_vol_3m": float(np.std(r[-63:]) * np.sqrt(252)),
"series": {"dates": dates, "gsr": tail.round(4).tolist(),
"mean_1y": m1y.round(4).tolist(), "upper_1y": (m1y + 2 * s1y).round(4).tolist(),
"lower_1y": (m1y - 2 * s1y).round(4).tolist()},
}
# ─── Margrabe outperformance option ───────────────────────────────────────────
def margrabe(S1, S2, n1, n2, sigma1, sigma2, rho, T, q1=0.0, q2=0.0):
"""Price and Greeks of max(n1·S1(T) − n2·S2(T), 0)."""
sig = float(np.sqrt(max(sigma1 ** 2 + sigma2 ** 2 - 2 * rho * sigma1 * sigma2, 1e-12)))
F1 = n1 * S1 * np.exp(-q1 * T)
F2 = n2 * S2 * np.exp(-q2 * T)
sq = sig * np.sqrt(T)
d1 = (np.log(F1 / F2) + 0.5 * sq ** 2) / sq
d2 = d1 - sq
price = F1 * norm.cdf(d1) - F2 * norm.cdf(d2)
vega = F1 * norm.pdf(d1) * np.sqrt(T) # ∂V/∂σ (ratio vol)
return {
"price": float(price), "ratio_vol": sig, "d1": float(d1), "d2": float(d2),
"delta_1": float(n1 * np.exp(-q1 * T) * norm.cdf(d1)), # ∂V/∂S1
"delta_2": float(-n2 * np.exp(-q2 * T) * norm.cdf(d2)), # ∂V/∂S2
"vega_ratio": float(vega),
"vega_1": float(vega * (sigma1 - rho * sigma2) / sig), # ∂V/∂σ1
"vega_2": float(vega * (sigma2 - rho * sigma1) / sig), # ∂V/∂σ2
"corr_sens": float(-vega * sigma1 * sigma2 / sig), # ∂V/∂ρ
"prob_exercise": float(norm.cdf(d2)),
}
def margrabe_mc(S1, S2, n1, n2, sigma1, sigma2, rho, T, q1=0.0, q2=0.0, n=200_000, seed=1):
"""Monte Carlo check under the asset-2 measure-free formulation (r cancels)."""
rng = np.random.default_rng(seed)
z1 = rng.standard_normal(n)
z2 = rho * z1 + np.sqrt(1 - rho ** 2) * rng.standard_normal(n)
r = 0.03 # any r: it cancels exactly
s1 = S1 * np.exp((r - q1 - 0.5 * sigma1 ** 2) * T + sigma1 * np.sqrt(T) * z1)
s2 = S2 * np.exp((r - q2 - 0.5 * sigma2 ** 2) * T + sigma2 * np.sqrt(T) * z2)
pay = np.exp(-r * T) * np.maximum(n1 * s1 - n2 * s2, 0)
return float(pay.mean()), float(pay.std() / np.sqrt(n))
+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, vol, hedge
from .routers import greeks, surface, market, montecarlo, scenarios, strategies, export, forex, institutional, news, livedata, smile, vol, hedge, xasset
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, vol, hedge]:
for router in [greeks, surface, market, montecarlo, scenarios, strategies, export, forex, institutional, news, livedata, smile, vol, hedge, xasset]:
app.include_router(router.router, prefix="/api")
@app.get("/")
+104
View File
@@ -0,0 +1,104 @@
"""
xasset.py — Cross-asset desk: correlation matrices and breaks, gold/silver ratio
analytics (OU, Engle-Granger, Kalman hedge ratio) and a Margrabe outperformance
option pricer. Free yfinance data, no keys.
"""
import time
import numpy as np
import pandas as pd
import yfinance as yf
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, Field
from ..core.xasset import correlation_report, gsr_report, margrabe, returns_frame
router = APIRouter(prefix="/xasset", tags=["xasset"])
UNIVERSE = {
"XAUUSD": "GC=F", "XAGUSD": "SI=F", "BTCUSD": "BTC-USD", "ETHUSD": "ETH-USD",
"EURUSD": "EURUSD=X", "DXY": "DX-Y.NYB", "US10Y": "^TNX", "WTI": "CL=F",
}
LEASE = {"XAUUSD": 0.003, "XAGUSD": 0.02} # default lease rates (q) for metals
_cache: dict = {}
def _load_closes(period: str = "3y") -> pd.DataFrame:
hist = yf.download(list(UNIVERSE.values()), period=period, interval="1d",
progress=False, auto_adjust=True, group_by="column")
if hist is None or hist.empty:
raise ValueError("no data returned")
close = hist["Close"] if "Close" in hist.columns.get_level_values(0) else hist
inv = {v: k for k, v in UNIVERSE.items()}
close = close.rename(columns=inv)
keep = [c for c in UNIVERSE if c in close.columns and close[c].notna().sum() > 300]
if not {"XAUUSD", "XAGUSD"} <= set(keep):
raise ValueError("gold/silver history unavailable")
close = close[keep]
close.index = pd.to_datetime(close.index).tz_localize(None).normalize()
return close.groupby(level=0).last().ffill(limit=3)
def _report():
hit = _cache.get("report")
if hit and time.time() - hit[0] < 1800:
return hit[1]
closes = _load_closes()
rets = returns_frame(closes.dropna(how="any"))
if len(rets) < 300:
raise ValueError("not enough overlapping history")
rep = {"correlations": correlation_report(rets),
"gsr": gsr_report(closes["XAUUSD"], closes["XAGUSD"]),
"spot": {c: float(closes[c].dropna().iloc[-1]) for c in closes.columns},
"lease_defaults": LEASE}
_cache["report"] = (time.time(), _clean(rep))
return _cache["report"][1]
def _clean(obj):
if isinstance(obj, (float, np.floating)):
x = float(obj)
return round(x, 6) if np.isfinite(x) else None
if isinstance(obj, dict):
return {k: _clean(v) for k, v in obj.items()}
if isinstance(obj, (list, tuple)):
return [_clean(v) for v in obj]
return obj
@router.get("")
def get_cross_asset():
"""Correlation desk + gold/silver ratio analytics (cached 30 min)."""
try:
return _report()
except Exception as e:
_cache.pop("report", None)
raise HTTPException(503, f"Cross-asset data unavailable: {e}")
class MargrabeRequest(BaseModel):
S1: float = Field(..., gt=0, description="Spot of the asset received (outperformer)")
S2: float = Field(..., gt=0, description="Spot of the asset delivered")
notional: float = Field(default=1_000_000, gt=0, description="Notional in USD per leg at inception")
sigma1: float = Field(..., gt=0, lt=5)
sigma2: float = Field(..., gt=0, lt=5)
rho: float = Field(..., ge=-0.999, le=0.999)
T: float = Field(..., gt=0, le=10)
q1: float = Field(default=0.0, gt=-1, lt=1, description="Yield / lease rate of asset 1")
q2: float = Field(default=0.0, gt=-1, lt=1, description="Yield / lease rate of asset 2")
strike_pct: float = Field(default=100.0, gt=0, lt=1000,
description="Asset-2 leg size as % of notional (100 = at-the-money outperformance)")
@router.post("/margrabe")
def price_margrabe(req: MargrabeRequest):
"""Outperformance option: pays max(N·S1(T)/S1(0) − k·N·S2(T)/S2(0), 0)."""
n1 = req.notional / req.S1
n2 = req.notional * req.strike_pct / 100 / req.S2
res = margrabe(req.S1, req.S2, n1, n2, req.sigma1, req.sigma2, req.rho, req.T, req.q1, req.q2)
grid = np.linspace(-0.95, 0.95, 39)
curve = [margrabe(req.S1, req.S2, n1, n2, req.sigma1, req.sigma2, r, req.T, req.q1, req.q2)["price"]
for r in grid]
return _clean({**res, "price_pct": res["price"] / req.notional * 100, "n1": n1, "n2": n2,
"rho_grid": grid.tolist(), "price_vs_rho": curve})
+118
View File
@@ -0,0 +1,118 @@
"""Validation of the cross-asset desk: Margrabe, cointegration, Kalman, correlations.
Run from backend/: python -m pytest tests -q
"""
import numpy as np
import pandas as pd
from fastapi.testclient import TestClient
from app.core.garman_kohlhagen import gk_price
from app.core.xasset import (adf_stat, engle_granger, ewma_corr, gsr_report, kalman_hedge,
margrabe, margrabe_mc, ou_fit, returns_frame)
from app.main import app
from app.routers import xasset as xasset_router
S1, S2, N, T = 61.2, 4190.0, 1e6, 0.25
def test_margrabe_matches_monte_carlo_limits_and_parity():
n1, n2 = N / S1, N / S2
m = margrabe(S1, S2, n1, n2, 0.45, 0.20, 0.75, T, 0.02, 0.003)
mc, se = margrabe_mc(S1, S2, n1, n2, 0.45, 0.20, 0.75, T, 0.02, 0.003)
assert abs(m["price"] - mc) < 4 * se
# σ2 → 0 collapses to a Black-Scholes call on asset 1 struck at the asset-2 leg (r = 0).
bs = gk_price(n1 * S1, n2 * S2, T, 0.0, 0.02, 0.45, "call")
assert abs(margrabe(S1, S2, n1, n2, 0.45, 1e-9, 0.0, T, 0.02, 0.0)["price"] - bs) < 1e-4
# Exchange-option parity: V(1→2) − V(2→1) = F1 − F2.
rev = margrabe(S2, S1, n2, n1, 0.20, 0.45, 0.75, T, 0.003, 0.02)["price"]
fwd = n1 * S1 * np.exp(-0.02 * T) - n2 * S2 * np.exp(-0.003 * T)
assert abs(m["price"] - rev - fwd) < 1e-6
# Analytic correlation sensitivity vs finite difference; higher ρ → cheaper.
up = margrabe(S1, S2, n1, n2, 0.45, 0.20, 0.7505, T, 0.02, 0.003)["price"]
assert abs((up - m["price"]) / 0.0005 - m["corr_sens"]) < 0.01 * abs(m["corr_sens"])
assert m["corr_sens"] < 0
assert abs(m["ratio_vol"] - np.sqrt(0.45**2 + 0.2**2 - 2 * 0.75 * 0.45 * 0.2)) < 1e-12
def test_cointegration_and_ou():
rng = np.random.default_rng(0)
x = np.cumsum(rng.standard_normal(750) * 0.01)
e = np.zeros(750)
for t in range(1, 750):
e[t] = 0.95 * e[t - 1] + 0.01 * rng.standard_normal()
assert engle_granger(1.1 * x + e, x)["cointegrated_5pct"]
hits = sum(engle_granger(np.cumsum(np.random.default_rng(s).standard_normal(750) * 0.01), x)
["cointegrated_5pct"] for s in range(1, 21))
assert hits <= 4 # ≈5% false-positive rate
hls = []
for s in range(20):
r = np.random.default_rng(s)
z = np.zeros(750)
for t in range(1, 750):
z[t] = 0.95 * z[t - 1] + 0.01 * r.standard_normal()
hls.append(ou_fit(z)["half_life_days"])
assert abs(np.median(hls) - np.log(2) / -np.log(0.95)) < 2.5
assert adf_stat(np.cumsum(rng.standard_normal(500))) > -3.34
def test_kalman_tracks_drifting_hedge_ratio():
rng = np.random.default_rng(0)
bt = np.linspace(0.8, 1.2, 1000)
x = np.cumsum(rng.standard_normal(1000) * 0.02) + 4.1
y = 8.3 + bt * (x - x[:120].mean()) + rng.standard_normal(1000) * 0.004
_, b, z = kalman_hedge(y, x)
assert np.abs(b[200:] - bt[200:]).mean() < 0.03
assert 0.6 < z[200:].std() < 1.3
def _fake_closes(n=900, seed=3):
rng = np.random.default_rng(seed)
idx = pd.bdate_range("2023-06-01", periods=n)
C = np.array([[1, .8, .3], [.8, 1, .3], [.3, .3, 1]])
z = rng.standard_normal((n, 3)) @ np.linalg.cholesky(C).T * np.array([.010, .020, .030])
gold = 4190 * np.exp(np.cumsum(z[:, 0]))
# Silver cointegrated with gold: log ratio is a mean-reverting AR(1).
lr = np.zeros(n)
for t in range(1, n):
lr[t] = 0.97 * lr[t - 1] + 0.01 * rng.standard_normal()
silver = gold / (67 * np.exp(lr))
btc = 83000 * np.exp(np.cumsum(z[:, 2]))
return pd.DataFrame({"XAUUSD": gold, "XAGUSD": silver, "BTCUSD": btc,
"ETHUSD": btc / 31 * np.exp(np.cumsum(rng.standard_normal(n) * 0.01)),
"US10Y": 4.2 + np.cumsum(rng.standard_normal(n) * 0.05)}, index=idx)
def test_reports_on_synthetic_market():
closes = _fake_closes()
rets = returns_frame(closes)
assert abs(rets["US10Y"].iloc[-1] - (closes["US10Y"].iloc[-1] - closes["US10Y"].iloc[-2])) < 1e-12
e = ewma_corr(rets)
assert np.allclose(np.diag(e), 1) and np.allclose(e, e.T)
g = gsr_report(closes["XAUUSD"], closes["XAGUSD"])
assert abs(g["current"] - closes["XAUUSD"].iloc[-1] / closes["XAGUSD"].iloc[-1]) < 1e-9
assert g["engle_granger"]["cointegrated_5pct"]
assert 10 < g["ou"]["half_life_days"] < 60 # true ≈ 22.8 days
assert abs(g["ou"]["mean_level"] - 67) < 3
def test_endpoints(monkeypatch):
monkeypatch.setattr(xasset_router, "_load_closes", lambda period="3y": _fake_closes())
xasset_router._cache.clear()
c = TestClient(app)
d = c.get("/api/xasset").json()
assert set(d["correlations"]["assets"]) == {"XAUUSD", "XAGUSD", "BTCUSD", "ETHUSD", "US10Y"}
m90 = np.array(d["correlations"]["matrices"]["90d"])
assert np.allclose(np.diag(m90), 1) and m90.shape == (5, 5)
assert any(b["pair"] == "XAUUSD/XAGUSD" for b in d["correlations"]["breaks"])
assert len(d["gsr"]["series"]["gsr"]) == 500 and len(d["gsr"]["kalman"]["z"]) == 500
body = {"S1": S1, "S2": S2, "sigma1": .45, "sigma2": .2, "rho": .75, "T": T, "q1": .02, "q2": .003}
r = c.post("/api/xasset/margrabe", json=body).json()
assert 0 < r["price_pct"] < 20 and len(r["price_vs_rho"]) == 39
assert all(a > b for a, b in zip(r["price_vs_rho"], r["price_vs_rho"][1:]))
assert c.post("/api/xasset/margrabe", json={**body, "rho": 1.5}).status_code == 422
def boom(period="3y"):
raise ValueError("yahoo down")
monkeypatch.setattr(xasset_router, "_load_closes", boom)
xasset_router._cache.clear()
assert c.get("/api/xasset").status_code == 503
+4
View File
@@ -29,6 +29,10 @@ export const fetchVolForecast = (pair) => api.get(`/vol/forecast/${pair}`).the
// Hedge Lab
export const simulateHedge = (p) => api.post('/hedge/simulate', p).then(r=>r.data)
// Cross-asset desk
export const fetchCrossAsset = () => api.get('/xasset').then(r=>r.data)
export const priceMargrabe = (p) => api.post('/xasset/margrabe', p).then(r=>r.data)
export const exportCSV = (p) => api.post('/export/csv', p, {responseType:'blob'}).then(r=>r.data)
// Institutional flow
+293
View File
@@ -0,0 +1,293 @@
import { useEffect, useState } from 'react'
import _Plot from 'react-plotly.js'
const Plot = _Plot?.default ?? _Plot
import { fetchCrossAsset, priceMargrabe } from '../api/client'
const LAYOUT = {
paper_bgcolor:'transparent', plot_bgcolor:'#080d18',
font:{ color:'#94a3b8', size:11 },
margin:{ l:52, r:16, t:12, b:40 },
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 })
const WINDOWS = [['30d', '30D'], ['90d', '90D'], ['250d', '1Y'], ['ewma', 'EWMA']]
const LABEL = { XAUUSD:'Gold', XAGUSD:'Silver', BTCUSD:'BTC', ETHUSD:'ETH', EURUSD:'EURUSD', DXY:'DXY', US10Y:'US 10Y', WTI:'WTI' }
const OPT_PAIRS = [['XAGUSD', 'XAUUSD'], ['XAUUSD', 'XAGUSD'], ['BTCUSD', 'XAUUSD'], ['ETHUSD', 'BTCUSD'], ['BTCUSD', 'ETHUSD']]
const fmt = (v, dp = 2) => (v == null ? '—' : v.toLocaleString(undefined, { maximumFractionDigits: dp, minimumFractionDigits: dp }))
export default function CrossAsset() {
const [nonce, setNonce] = useState(0)
const [response, setResponse] = useState({ key:null, data:null, error:null })
const [win, setWin] = useState('90d')
const [pairKey, setPairKey] = useState('BTCUSD/XAUUSD')
const key = String(nonce)
useEffect(() => {
let stale = false
fetchCrossAsset()
.then(data => { if (!stale) setResponse({ key, data, error:null }) })
.catch(e => { if (!stale) setResponse({ key, data:null, error: e?.response?.data?.detail ?? e?.message ?? 'Unavailable' }) })
return () => { stale = true }
}, [key])
const loading = response.key !== key
const d = response.data
return (
<div style={{ padding:16 }}>
<div style={{ display:'flex', justifyContent:'space-between', alignItems:'flex-start', gap:12, marginBottom:12, flexWrap:'wrap' }}>
<div>
<div className="section-title">Cross-Asset Desk — correlations, gold/silver ratio &amp; outperformance options</div>
<div style={{ fontSize:9, color:'var(--text-muted)', marginTop:2 }}>
Gold · Silver · BTC · ETH · EURUSD · DXY · US 10Y · WTI on common trading days{d && <> · data to {d.correlations.last_date}</>}
</div>
</div>
<button className="btn-ghost" onClick={() => setNonce(n => n + 1)} disabled={loading} style={{ fontSize:10 }}>
{loading ? '⟳ Loading' : '↻ Refresh'}
</button>
</div>
{response.error && response.key === key && (
<div style={{ padding:8, borderRadius:6, marginBottom:8, fontSize:11, background:'rgba(255,61,90,0.1)',
border:'1px solid #ff3d5a', color:'#ff3d5a' }}>{response.error}</div>
)}
{!d && loading && <div className="skeleton" style={{ height:360, borderRadius:8 }}/>}
{d && (
<div style={{ opacity: loading ? 0.6 : 1 }}>
<GsrKpis g={d.gsr} />
<div style={{ display:'grid', gridTemplateColumns:'repeat(auto-fit, minmax(420px, 1fr))', gap:12, marginBottom:12 }}>
<GsrChart g={d.gsr} />
<Heatmap c={d.correlations} win={win} setWin={setWin} />
</div>
<div style={{ display:'grid', gridTemplateColumns:'repeat(auto-fit, minmax(420px, 1fr))', gap:12, marginBottom:12 }}>
<Breaks c={d.correlations} pairKey={pairKey} setPairKey={setPairKey} />
<RollingCorr c={d.correlations} pairKey={pairKey} />
</div>
<Margrabe d={d} />
<div style={{ fontSize:9, color:'var(--text-muted)', marginTop:10, lineHeight:1.5 }}>
Returns on common dates (yields as changes). EWMA λ = 0.94 (RiskMetrics). Breaks: z-score of today&apos;s 30D correlation
vs its own history. GSR: OU half-life from AR(1) on log ratio; Engle-Granger ADF vs MacKinnon 5% −3.34; Kalman
random-walk hedge ratio with unit-scaled innovation z. Margrabe (1978) exchange option; ratio vol
√(σ₁²+σ₂²−2ρσ₁σ₂) — short correlation (Derman 1992).
</div>
</div>
)}
</div>
)
}
function Card({ title, right, children }) {
return (
<div className="glass" style={{ borderRadius:8, padding:8 }}>
<div style={{ display:'flex', justifyContent:'space-between', alignItems:'center', margin:'2px 6px 4px' }}>
<span className="section-title" style={{ fontSize:10 }}>{title}</span>{right}
</div>
{children}
</div>
)
}
const Chip = ({ active, onClick, children }) => (
<button onClick={onClick} className="font-mono" style={{ padding:'2px 7px', borderRadius:4, fontSize:9, cursor:'pointer',
border:'1px solid ' + (active ? 'var(--cyan)' : 'var(--border)'), background: active ? 'rgba(0,212,255,0.1)' : 'transparent',
color: active ? 'var(--cyan)' : 'var(--text-muted)' }}>{children}</button>
)
function GsrKpis({ g }) {
const eg = g.engle_granger
const items = [
{ label:'Gold / Silver ratio', value: fmt(g.current, 2), sub:`XAU ${fmt(g.xau, 0)} / XAG ${fmt(g.xag, 2)}` },
{ label:'z-score (1Y)', value: fmt(g.stats_1y.z, 2), color: Math.abs(g.stats_1y.z) > 2 ? 'var(--amber)' : undefined,
sub:`1Y mean ${fmt(g.stats_1y.mean, 1)}` },
{ label:'Percentile (3Y)', value: `${g.stats_3y.percentile.toFixed(0)}th`, sub:`range ${fmt(g.stats_3y.min, 0)}–${fmt(g.stats_3y.max, 0)}` },
{ label:'OU half-life', value: `${g.ou.half_life_days.toFixed(0)}d`, sub:`equilibrium ${fmt(g.ou.mean_level, 1)}` },
{ label:'Engle-Granger', value: eg.cointegrated_5pct ? 'COINTEGRATED' : 'NOT AT 5%',
color: eg.cointegrated_5pct ? 'var(--green)' : 'var(--amber)', sub:`ADF ${fmt(eg.adf_stat)} vs ${eg.critical['5%']}` },
{ label:'Kalman hedge β (log XAU on log XAG)', value: fmt(g.kalman.beta_now, 3), sub:`spread z ${fmt(g.kalman.z_now)}`,
color: Math.abs(g.kalman.z_now) > 2 ? 'var(--amber)' : undefined },
{ label:'Ratio vol (3M)', value: `${(g.ratio_vol_3m * 100).toFixed(1)}%` },
]
return (
<div style={{ display:'flex', gap:8, marginBottom:12, flexWrap:'wrap' }}>
{items.map(k => (
<div key={k.label} className="glass" 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>
{k.sub && <div className="font-mono" style={{ fontSize:9, color:'var(--text-muted)' }}>{k.sub}</div>}
</div>
))}
</div>
)
}
function GsrChart({ g }) {
const s = g.series, k = g.kalman
const x = s.dates
const zx = x.slice(k.valid_from), z = k.z.slice(k.valid_from)
return (
<Card title="Gold/Silver ratio · 1Y mean ±2σ · Kalman spread z">
<Plot data={[
{ x, y:s.upper_1y, type:'scatter', mode:'lines', line:{ width:0 }, hoverinfo:'skip', showlegend:false },
{ x, y:s.lower_1y, type:'scatter', mode:'lines', line:{ width:0 }, fill:'tonexty', fillcolor:'rgba(0,212,255,0.08)', name:'±2σ (1Y)' },
{ x, y:s.mean_1y, type:'scatter', mode:'lines', name:'1Y mean', line:{ color:'rgba(0,212,255,0.6)', dash:'dash', width:1 } },
{ x, y:s.gsr, type:'scatter', mode:'lines', name:'GSR', line:{ color:'#ffd700', width:2 } },
{ x:zx, y:z, type:'scatter', mode:'lines', name:'Kalman z', yaxis:'y2', line:{ color:'#c084fc', width:1.2 } },
]}
layout={{ ...LAYOUT, hovermode:'x unified', xaxis:axis(''),
yaxis:axis('GSR', { domain:[0.38, 1] }), yaxis2:axis('z', { domain:[0, 0.3], range:[-4, 4] }),
shapes:[-2, 2].map(v => ({ type:'line', xref:'paper', x0:0, x1:1, yref:'y2', y0:v, y1:v,
line:{ color:'rgba(255,61,90,0.4)', dash:'dot', width:1 } })) }}
config={{ responsive:true, displayModeBar:false }} style={{ width:'100%', height:330 }} />
</Card>
)
}
function Heatmap({ c, win, setWin }) {
const m = c.matrices[win] ?? c.matrices['90d']
const labels = c.assets.map(a => LABEL[a] ?? a)
return (
<Card title="Correlation matrix" right={
<div style={{ display:'flex', gap:4 }}>{WINDOWS.filter(([k]) => c.matrices[k]).map(([k, l]) =>
<Chip key={k} active={k === win} onClick={() => setWin(k)}>{l}</Chip>)}</div>}>
<Plot data={[{ z:m, x:labels, y:labels, type:'heatmap', zmin:-1, zmax:1,
colorscale:[[0, '#ff3d5a'], [0.5, '#0b1220'], [1, '#00d4ff']],
text:m.map(r => r.map(v => v.toFixed(2))), texttemplate:'%{text}', textfont:{ size:9 },
hovertemplate:'%{y} / %{x}: %{z:.2f}<extra></extra>', colorbar:{ thickness:8, tickfont:{ size:8 } } }]}
layout={{ ...LAYOUT, margin:{ l:60, r:10, t:8, b:50 }, yaxis:{ autorange:'reversed', color:'#94a3b8' },
xaxis:{ color:'#94a3b8' } }}
config={{ responsive:true, displayModeBar:false }} style={{ width:'100%', height:330 }} />
</Card>
)
}
const pairLabel = p => p.split('/').map(a => LABEL[a] ?? a).join(' / ')
function Breaks({ c, pairKey, setPairKey }) {
return (
<Card title="Correlation-break monitor (30D vs its own history)">
<div style={{ overflowX:'auto' }}>
<table style={{ width:'100%', borderCollapse:'collapse', fontSize:10 }}>
<thead><tr style={{ background:'var(--bg3)' }}>
{['Pair', '30D', '90D', 'Hist. mean', 'z', 'Pctile', ''].map((h, i) => (
<th key={i} style={{ padding:'6px 8px', fontSize:9, color:'var(--text-muted)', textAlign: i ? 'right' : 'left' }}>{h}</th>
))}
</tr></thead>
<tbody>{c.breaks.map(b => (
<tr key={b.pair} onClick={() => setPairKey(b.pair)} style={{ cursor:'pointer', borderTop:'1px solid var(--border)',
background: b.pair === pairKey ? 'rgba(0,212,255,0.06)' : undefined }}>
<td style={{ padding:'5px 8px' }}>{pairLabel(b.pair)}</td>
{[b.corr_now, b.corr_90d, b.mean].map((v, i) => (
<td key={i} className="font-mono" style={{ padding:'5px 8px', textAlign:'right', color: v >= 0 ? 'var(--cyan)' : 'var(--red)' }}>{fmt(v)}</td>
))}
<td className="font-mono" style={{ padding:'5px 8px', textAlign:'right' }}>{fmt(b.z)}</td>
<td className="font-mono" style={{ padding:'5px 8px', textAlign:'right' }}>{b.percentile.toFixed(0)}</td>
<td style={{ padding:'5px 8px', textAlign:'right', fontWeight:700, fontSize:9,
color: b.flag === 'BREAK' ? 'var(--red)' : 'var(--amber)' }}>{b.flag}</td>
</tr>
))}</tbody>
</table>
</div>
</Card>
)
}
function RollingCorr({ c, pairKey }) {
const r = c.rolling[pairKey] ?? Object.values(c.rolling)[0]
if (!r) return null
return (
<Card title={`Rolling correlation · ${pairLabel(pairKey)}`}>
<Plot data={[
{ x:r.dates, y:r.corr_30d, type:'scatter', mode:'lines', name:'30D', line:{ color:'#00d4ff', width:1.5 } },
{ x:r.dates, y:r.corr_90d, type:'scatter', mode:'lines', name:'90D', line:{ color:'#ffd700', width:2 } },
]}
layout={{ ...LAYOUT, hovermode:'x unified', xaxis:axis(''), yaxis:axis('ρ', { range:[-1, 1] }),
shapes:[{ type:'line', xref:'paper', x0:0, x1:1, y0:0, y1:0, line:{ color:'rgba(255,255,255,0.2)', dash:'dot', width:1 } }] }}
config={{ responsive:true, displayModeBar:false }} style={{ width:'100%', height:280 }} />
</Card>
)
}
function Margrabe({ d }) {
const [pi, setPi] = useState(0)
const [notional, setNotional] = useState(1000000)
const [days, setDays] = useState(91)
const [strike, setStrike] = useState(100)
const [over, setOver] = useState({})
const [res, setRes] = useState({ key:null, data:null, error:null })
const available = OPT_PAIRS.map((pr, i) => [pr, i]).filter(([pr]) => pr.every(a => d.spot[a] != null))
const [a1, a2] = (available.find(([, i]) => i === pi) ?? available[0] ?? [OPT_PAIRS[0]])[0]
const c = d.correlations
const i1 = c.assets.indexOf(a1), i2 = c.assets.indexOf(a2)
const rhoDefault = i1 >= 0 && i2 >= 0 ? c.matrices['90d'][i1][i2] : 0.5
const p = {
sigma1: over.sigma1 ?? +(c.vol_3m[a1] * 100).toFixed(1), sigma2: over.sigma2 ?? +(c.vol_3m[a2] * 100).toFixed(1),
rho: over.rho ?? +rhoDefault.toFixed(2),
q1: over.q1 ?? +((d.lease_defaults[a1] ?? 0) * 100).toFixed(2), q2: over.q2 ?? +((d.lease_defaults[a2] ?? 0) * 100).toFixed(2),
}
const body = { S1:d.spot[a1], S2:d.spot[a2], notional, T:days / 365, strike_pct:strike,
sigma1:p.sigma1 / 100, sigma2:p.sigma2 / 100, rho:p.rho, q1:p.q1 / 100, q2:p.q2 / 100 }
const key = JSON.stringify(body)
useEffect(() => {
let stale = false
priceMargrabe(JSON.parse(key))
.then(data => { if (!stale) setRes({ key, data, error:null }) })
.catch(e => { if (!stale) setRes({ key, data:null, error: e?.response?.data?.detail?.toString() ?? 'Pricing failed' }) })
return () => { stale = true }
}, [key])
const r = res.data
const set = (k, v) => setOver(o => ({ ...o, [k]: v }))
const num = (label, value, onChange, step = 0.1) => (
<label style={{ display:'flex', flexDirection:'column', gap:3, fontSize:9, color:'var(--text-muted)' }}>{label}
<input type="number" value={value} step={step} onChange={e => onChange(+e.target.value)}
style={{ background:'var(--bg3)', border:'1px solid var(--border)', borderRadius:4, color:'var(--cyan)',
fontSize:11, padding:'4px 6px', fontFamily:'var(--font-mono)', width:'100%' }} />
</label>
)
return (
<Card title={`Outperformance option (Margrabe): receive ${LABEL[a1]}, deliver ${LABEL[a2]}`}
right={<div style={{ display:'flex', gap:4 }}>{available.map(([[x, y], i]) =>
<Chip key={i} active={i === pi} onClick={() => { setPi(i); setOver({}) }}>{LABEL[x]} vs {LABEL[y]}</Chip>)}</div>}>
<div style={{ display:'grid', gridTemplateColumns:'minmax(260px, 1fr) minmax(320px, 1.4fr)', gap:12, padding:'4px 6px' }}>
<div>
<div style={{ display:'grid', gridTemplateColumns:'repeat(2, 1fr)', gap:8 }}>
{num('Notional (USD)', notional, setNotional, 100000)}
{num('Tenor (days)', days, setDays, 1)}
{num(`σ ${LABEL[a1]} (%)`, p.sigma1, v => set('sigma1', v), 0.5)}
{num(`σ ${LABEL[a2]} (%)`, p.sigma2, v => set('sigma2', v), 0.5)}
{num('Correlation ρ', p.rho, v => set('rho', v), 0.05)}
{num('Strike (% of notional)', strike, setStrike, 1)}
{num(`Yield/lease ${LABEL[a1]} (%)`, p.q1, v => set('q1', v), 0.1)}
{num(`Yield/lease ${LABEL[a2]} (%)`, p.q2, v => set('q2', v), 0.1)}
</div>
<div style={{ fontSize:9, color:'var(--text-muted)', marginTop:6 }}>
Pays max(N·S₁(T)/S₁(0) − k·N·S₂(T)/S₂(0), 0). Vols default to 3M realised, ρ to 90D correlation.
</div>
</div>
<div>
{res.error && <div style={{ color:'var(--red)', fontSize:11 }}>{res.error}</div>}
{r && (
<>
<div style={{ display:'flex', gap:8, flexWrap:'wrap', marginBottom:6 }}>
{[['Premium', `${fmt(r.price, 0)} USD`], ['% of notional', `${fmt(r.price_pct, 2)}%`],
['Ratio vol', `${(r.ratio_vol * 100).toFixed(1)}%`], ['P(exercise)', `${(r.prob_exercise * 100).toFixed(0)}%`],
[`Δ ${LABEL[a1]}`, `${fmt(r.delta_1, 2)} units`], [`Δ ${LABEL[a2]}`, `${fmt(r.delta_2, 4)} units`],
['Vega / 1 vol pt (ratio)', fmt(r.vega_ratio / 100, 0)], ['∂V per +0.10 ρ', fmt(r.corr_sens * 0.1, 0)]].map(([l, v]) => (
<div key={l} className="glass" style={{ padding:'4px 10px', borderRadius:6 }}>
<div style={{ fontSize:9, color:'var(--text-muted)' }}>{l}</div>
<div className="font-mono" style={{ fontSize:12, color:'var(--cyan)', fontWeight:700 }}>{v}</div>
</div>
))}
</div>
<Plot data={[{ x:r.rho_grid, y:r.price_vs_rho.map(v => v / notional * 100), type:'scatter', mode:'lines',
line:{ color:'#00d4ff', width:2 }, name:'Premium' },
{ x:[p.rho], y:[r.price_pct], type:'scatter', mode:'markers', marker:{ color:'#ffd700', size:9 }, name:'Current ρ' }]}
layout={{ ...LAYOUT, showlegend:false, margin:{ l:46, r:10, t:6, b:36 },
xaxis:axis('Correlation ρ'), yaxis:axis('Premium (% notional)') }}
config={{ responsive:true, displayModeBar:false }} style={{ width:'100%', height:190 }} />
</>
)}
</div>
</div>
</Card>
)
}
+2
View File
@@ -12,6 +12,7 @@ import VolSmile from '../components/VolSmile'
import VolLab from '../components/VolLab'
import VolForecast from '../components/VolForecast'
import HedgeLab from '../components/HedgeLab'
import CrossAsset from '../components/CrossAsset'
const IVSurface = lazy(() => import('../components/IVSurface'))
const WorldMap = lazy(() => import('../components/WorldMap'))
@@ -45,6 +46,7 @@ const TABS = [
{ id:'vollab', icon:'🔬', label:'Vol Lab', component: VolLab },
{ id:'volforecast', icon:'🤖', label:'Vol Forecast', component: VolForecast },
{ id:'hedgelab', icon:'⚖', label:'Hedge Lab', component: HedgeLab },
{ id:'xasset', icon:'🔗', label:'Cross-Asset', component: CrossAsset },
{ id:'smile', icon:'〰', label:'Vol Smile', component: VolSmile },
{ id:'surfaces', icon:'🗻', label:'3D Surfaces', component: SurfacePlot },
{ id:'breakeven', icon:'🎯', label:'Breakeven', component: BreakevenChart },
Binary file not shown.

After

Width:  |  Height:  |  Size: 453 KiB