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