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:
@@ -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
|
||||
Reference in New Issue
Block a user