mirror of
https://github.com/Mihirkansara/nexus-quant-terminal.git
synced 2026-11-02 09:36:11 +00:00
feat: AI Hedger — deep hedging (No-Transaction-Band net) under costs and jumps
Numpy-only deep hedging of a short call: a No-Transaction-Band network trained by back-propagation through time and a feed-forward deep hedge, both minimising CVaR, benchmarked out of sample against BS delta and a tuned Whalley-Wilmott band, with regime-shift tests (vol up, crash jumps). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FpiqUMAyo4RL5DdQHMWbwE
This commit is contained in:
@@ -42,6 +42,7 @@ NEXUS TERMINAL is a Bloomberg-style web application for professional forex optio
|
||||
| **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 |
|
||||
| **Risk Desk** | Market risk of the portfolio option book with full revaluation on joint spot × implied-vol history (Deribit DVOL, Cboe GVZ / VXSLV, else EWMA proxy): VaR 99% / ES 97.5% (FRTB) by historical, filtered-historical (EWMA-scaled), delta-gamma Cornish-Fisher, Student-t Monte Carlo and delta-normal; 1- and 10-day horizons; stressed window (worst 250 days); rolling backtest with Kupiec / Christoffersen tests and Basel traffic light; spot × vol ladder; worst-day and what-if P&L explain by Greek (Δ, Γ, vega, vanna, volga, θ, unexplained) |
|
||||
| **AI Hedger** | Deep hedging of a short option under proportional transaction costs and jumps, trained server-side in pure numpy (no GPU, no API key): a No-Transaction-Band network (Imaki et al. 2021, back-propagation through time) and a feed-forward deep hedge (Buehler et al. 2019) minimising CVaR, benchmarked out of sample against BS delta and a tuned Whalley-Wilmott band; regime-shift tests (vol +50%, crash jumps); learned band vs Whalley-Wilmott, P&L distributions, turnover, risk-adjusted price, training curves |
|
||||
| **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 |
|
||||
| **Jump Lab** | Merton and Bates (Heston + jumps, COS pricing) calibrated to every live Deribit BTC/ETH expiry: model-vs-market smiles, fit RMSE, jump intensity/size, jump share of variance, crash-jump odds, 30-day risk-neutral density vs lognormal; manual Merton for metals/FX event and gap risk; one-click hand-off of jump parameters to Hedge Lab |
|
||||
| **Event Vol** | Event & weekend "vol time" term structure: strips FOMC / CPI / NFP variance lumps and the weekend weight out of the ATM curve (live Deribit fit for BTC/ETH; assumptions or pasted broker quotes for XAU/XAG/EUR/any pair), event-implied moves and straddle breakevens, forward vols and calendar-arbitrage flags, fair vol for any expiry incl. 0DTE/1-day, built-in 2026 FOMC/CPI/NFP calendar merged with live Forex Factory events, realised day-of-week / weekend-weight / FOMC-lump study |
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
"""
|
||||
deep_hedge.py — Deep hedging of a short option under proportional transaction costs and
|
||||
jumps, trained in pure numpy (no deep-learning framework needed on the server).
|
||||
|
||||
A dealer is short one European option (premium = Black-Scholes price at the hedging vol)
|
||||
and trades the underlying at proportional cost c. Hedged P&L on a path:
|
||||
PnL = p0 − payoff(S_T) + Σ_t δ_t (S_{t+1} − S_t) − c Σ_t |δ_t − δ_{t−1}| S_t , δ_{−1} = 0.
|
||||
The hedge is chosen to minimise the expected shortfall of the loss, CVaR_α(−PnL)
|
||||
(Buehler, Gonon, Teichmann & Wood 2019, "Deep hedging", Quantitative Finance 19(8)).
|
||||
|
||||
Strategies compared on the same out-of-sample paths
|
||||
No hedge — δ ≡ 0.
|
||||
BS delta — rebalance to the Black-Scholes delta every step.
|
||||
Whalley-Wilmott — asymptotically optimal no-trade band Δ ± (3/2 · c S Γ² / γ)^{1/3}
|
||||
(Whalley & Wilmott 1997), risk aversion γ tuned on the training set.
|
||||
Deep NTB — No-Transaction-Band network (Imaki, Imajo, Ito, Minami & Nakagawa 2021,
|
||||
arXiv:2103.01775): an MLP of (moneyness, time, vol) outputs a band
|
||||
[Δ − w_l, Δ + w_u]; the position is the previous one clamped to the band.
|
||||
Trained by back-propagation through time (the clamp makes BPTT a scalar
|
||||
recursion).
|
||||
Deep FF — feed-forward deep hedge δ = Δ + MLP(·): no memory of the position, so it
|
||||
cannot learn a no-trade region — the failure mode reported in recent work.
|
||||
The network is trained on one market model and can be tested on another (regime fragility).
|
||||
"""
|
||||
|
||||
import numpy as np
|
||||
from scipy.stats import norm
|
||||
|
||||
|
||||
# ─── market simulation (normalised: S0 = 1) ──────────────────────────────────
|
||||
|
||||
def simulate(n_paths, n_steps, T, sigma, rng, jump=None):
|
||||
dt = T / n_steps
|
||||
z = rng.standard_normal((n_paths, n_steps))
|
||||
drift = -0.5 * sigma ** 2 * dt
|
||||
jumps = 0.0
|
||||
if jump and jump.get("lambda", 0) > 0:
|
||||
lam, mj, sj = jump["lambda"], jump["mean"], jump["sd"]
|
||||
drift -= lam * (np.exp(mj + 0.5 * sj ** 2) - 1) * dt
|
||||
n = rng.poisson(lam * dt, (n_paths, n_steps))
|
||||
jumps = n * mj + np.sqrt(n) * sj * rng.standard_normal((n_paths, n_steps))
|
||||
x = np.cumsum(drift + sigma * np.sqrt(dt) * z + jumps, axis=1)
|
||||
return np.exp(np.c_[np.zeros(n_paths), x]) # (P, N+1), martingale (r = 0)
|
||||
|
||||
|
||||
def bs_call(S, K, tau, sigma):
|
||||
tau = np.maximum(tau, 1e-12)
|
||||
sq = sigma * np.sqrt(tau)
|
||||
d1 = (np.log(S / K) + 0.5 * sq ** 2) / sq
|
||||
return S * norm.cdf(d1) - K * norm.cdf(d1 - sq), norm.cdf(d1), norm.pdf(d1) / (S * sq)
|
||||
|
||||
|
||||
def features(S, K, T, sigma, n_steps):
|
||||
"""Per-step inputs (P, N, 3) and BS delta / gamma (P, N) on the hedging dates."""
|
||||
tau = T - np.arange(n_steps) * T / n_steps
|
||||
St = S[:, :-1]
|
||||
_, delta, gamma = bs_call(St, K, tau[None, :], sigma)
|
||||
m = np.log(St / K) / (sigma * np.sqrt(T))
|
||||
X = np.stack([m, np.broadcast_to(np.sqrt(tau / T), m.shape), np.full_like(m, sigma)], axis=-1)
|
||||
return X, delta, gamma, tau
|
||||
|
||||
|
||||
# ─── P&L and its gradient ────────────────────────────────────────────────────
|
||||
|
||||
def pnl(S, delta_path, K, cost, p0, is_call=True):
|
||||
dS = np.diff(S, axis=1)
|
||||
prev = np.c_[np.zeros(len(S)), delta_path[:, :-1]]
|
||||
trade = np.abs(delta_path - prev)
|
||||
pay = np.maximum(S[:, -1] - K, 0) if is_call else np.maximum(K - S[:, -1], 0)
|
||||
tc = cost * (trade * S[:, :-1]).sum(axis=1)
|
||||
return p0 - pay + (delta_path * dS).sum(axis=1) - tc, trade.sum(axis=1), tc
|
||||
|
||||
|
||||
def _direct_grad(S, d, cost):
|
||||
"""∂PnL/∂δ_t holding the other δ fixed (gains + the two cost terms touching δ_t)."""
|
||||
dS = np.diff(S, axis=1)
|
||||
prev = np.c_[np.zeros(len(S)), d[:, :-1]]
|
||||
nxt = np.c_[d[:, 1:], d[:, -1:]]
|
||||
g = dS - cost * S[:, :-1] * np.sign(d - prev)
|
||||
g[:, :-1] += cost * S[:, 1:-1] * np.sign(nxt[:, :-1] - d[:, :-1])
|
||||
return g
|
||||
|
||||
|
||||
def cvar(loss, alpha):
|
||||
q = np.quantile(loss, alpha)
|
||||
tail = loss[loss >= q]
|
||||
return float(tail.mean()), q
|
||||
|
||||
|
||||
# ─── a small MLP with Adam (numpy) ───────────────────────────────────────────
|
||||
|
||||
class MLP:
|
||||
def __init__(self, sizes, rng, out_bias=0.0, out_scale=0.1):
|
||||
self.W, self.b = [], []
|
||||
for i, (a, c) in enumerate(zip(sizes[:-1], sizes[1:])):
|
||||
s = np.sqrt(1 / a) * (out_scale if i == len(sizes) - 2 else 1)
|
||||
self.W.append(rng.normal(0, s, (a, c)))
|
||||
self.b.append(np.full(c, out_bias if i == len(sizes) - 2 else 0.0))
|
||||
self.m = [np.zeros_like(p) for p in self.W + self.b]
|
||||
self.v = [np.zeros_like(p) for p in self.W + self.b]
|
||||
self.k = 0
|
||||
|
||||
def forward(self, X):
|
||||
self.acts = [X]
|
||||
h = X
|
||||
for i, (W, b) in enumerate(zip(self.W, self.b)):
|
||||
h = h @ W + b
|
||||
if i < len(self.W) - 1:
|
||||
h = np.tanh(h)
|
||||
self.acts.append(h)
|
||||
return h
|
||||
|
||||
def backward(self, grad_out):
|
||||
"""grad_out = ∂L/∂output (rows × out). Returns parameter gradients."""
|
||||
gW, gb = [None] * len(self.W), [None] * len(self.b)
|
||||
g = grad_out
|
||||
for i in reversed(range(len(self.W))):
|
||||
a_in = self.acts[i]
|
||||
gW[i] = a_in.T @ g
|
||||
gb[i] = g.sum(axis=0)
|
||||
if i > 0:
|
||||
g = (g @ self.W[i].T) * (1 - self.acts[i] ** 2)
|
||||
return gW + gb
|
||||
|
||||
def step(self, grads, lr=3e-3, b1=0.9, b2=0.999, eps=1e-8):
|
||||
self.k += 1
|
||||
params = self.W + self.b
|
||||
for j, (p, g) in enumerate(zip(params, grads)):
|
||||
self.m[j] = b1 * self.m[j] + (1 - b1) * g
|
||||
self.v[j] = b2 * self.v[j] + (1 - b2) * g * g
|
||||
mh = self.m[j] / (1 - b1 ** self.k)
|
||||
vh = self.v[j] / (1 - b2 ** self.k)
|
||||
p -= lr * mh / (np.sqrt(vh) + eps)
|
||||
|
||||
|
||||
def softplus(z):
|
||||
return np.logaddexp(0, z)
|
||||
|
||||
|
||||
def ntb_positions(net, X, delta):
|
||||
"""Forward pass of the No-Transaction-Band net. Returns positions and what BPTT needs."""
|
||||
P, N, _ = X.shape
|
||||
z = net.forward(X.reshape(-1, 3)).reshape(P, N, 2)
|
||||
lo = delta - softplus(z[..., 0])
|
||||
hi = delta + softplus(z[..., 1])
|
||||
d = np.empty((P, N))
|
||||
prev = np.zeros(P)
|
||||
below, above = np.empty((P, N), bool), np.empty((P, N), bool)
|
||||
for t in range(N):
|
||||
below[:, t], above[:, t] = prev < lo[:, t], prev > hi[:, t]
|
||||
prev = np.clip(prev, lo[:, t], hi[:, t])
|
||||
d[:, t] = prev
|
||||
return d, z, below, above
|
||||
|
||||
|
||||
def ntb_grad(net, S, X, delta, K, cost, p0, w, fwd=None):
|
||||
"""
|
||||
PnL per path and ∂(Σ_p w_p·PnL_p)/∂θ for the NTB net, by back-propagation through time.
|
||||
G_t = ∂PnL/∂δ_t (total) = g_t + G_{t+1}·1{δ_t inside band_{t+1}}; the clamp passes the
|
||||
gradient to the lower edge when δ_{t−1} < lo_t and to the upper edge when δ_{t−1} > hi_t.
|
||||
"""
|
||||
# fwd: a forward pass already run on X (the net keeps its activations), to avoid a second one.
|
||||
d, z, below, above = fwd if fwd is not None else ntb_positions(net, X, delta)
|
||||
pl, _, _ = pnl(S, d, K, cost, p0)
|
||||
g = _direct_grad(S, d, cost)
|
||||
n_steps = d.shape[1]
|
||||
G = np.empty_like(d)
|
||||
G[:, -1] = g[:, -1]
|
||||
for t in range(n_steps - 2, -1, -1):
|
||||
inside = ~(below[:, t + 1] | above[:, t + 1])
|
||||
G[:, t] = g[:, t] + G[:, t + 1] * inside
|
||||
up = G * w[:, None]
|
||||
sig = lambda v: 1 / (1 + np.exp(-v))
|
||||
gz = np.stack([-up * below * sig(z[..., 0]), up * above * sig(z[..., 1])], axis=-1)
|
||||
return pl, net.backward(gz.reshape(-1, 2))
|
||||
|
||||
|
||||
def train_ntb(S, K, T, sigma, cost, p0, n_steps, alpha=0.95, iters=250, batch=1024, seed=0, hidden=24):
|
||||
rng = np.random.default_rng(seed)
|
||||
net = MLP([3, hidden, hidden, 2], rng, out_bias=-3.0)
|
||||
X, delta, _, _ = features(S, K, T, sigma, n_steps)
|
||||
hist = []
|
||||
for it in range(iters):
|
||||
idx = rng.choice(len(S), batch, replace=False)
|
||||
Sb, Xb, Db = S[idx], X[idx], delta[idx]
|
||||
fwd = ntb_positions(net, Xb, Db)
|
||||
loss = -pnl(Sb, fwd[0], K, cost, p0)[0]
|
||||
cv, q = cvar(loss, alpha)
|
||||
hist.append(cv)
|
||||
w = -(loss >= q).astype(float) / max((loss >= q).sum(), 1) # ∂CVaR/∂PnL (tail average)
|
||||
_, grads = ntb_grad(net, Sb, Xb, Db, K, cost, p0, w, fwd)
|
||||
net.step(grads)
|
||||
return net, hist
|
||||
|
||||
|
||||
def train_ff(S, K, T, sigma, cost, p0, n_steps, alpha=0.95, iters=250, batch=1024, seed=1, hidden=24):
|
||||
rng = np.random.default_rng(seed)
|
||||
net = MLP([3, hidden, hidden, 1], rng)
|
||||
X, delta, _, _ = features(S, K, T, sigma, n_steps)
|
||||
hist = []
|
||||
for it in range(iters):
|
||||
idx = rng.choice(len(S), batch, replace=False)
|
||||
Sb, Xb, Db = S[idx], X[idx], delta[idx]
|
||||
d = Db + net.forward(Xb.reshape(-1, 3)).reshape(batch, n_steps)
|
||||
pl, _, _ = pnl(Sb, d, K, cost, p0)
|
||||
loss = -pl
|
||||
cv, q = cvar(loss, alpha)
|
||||
hist.append(cv)
|
||||
dL_dpnl = -(loss >= q).astype(float) / max((loss >= q).sum(), 1)
|
||||
up = _direct_grad(Sb, d, cost) * dL_dpnl[:, None]
|
||||
net.step(net.backward(up.reshape(-1, 1)))
|
||||
return net, hist
|
||||
|
||||
|
||||
def ww_positions(delta, gamma, S, cost, gamma_ra, tau):
|
||||
"""Whalley-Wilmott band hedge: move to the nearest band edge only when outside it."""
|
||||
h = (1.5 * cost * S[:, :-1] * gamma ** 2 / max(gamma_ra, 1e-12)) ** (1 / 3)
|
||||
P, N = delta.shape
|
||||
d = np.empty((P, N))
|
||||
prev = np.zeros(P)
|
||||
for t in range(N):
|
||||
prev = np.clip(prev, delta[:, t] - h[:, t], delta[:, t] + h[:, t])
|
||||
d[:, t] = prev
|
||||
return d
|
||||
|
||||
|
||||
# ─── report ──────────────────────────────────────────────────────────────────
|
||||
|
||||
def _summ(pl, turnover, tc, alpha):
|
||||
loss = -pl
|
||||
cv, q = cvar(loss, alpha)
|
||||
return {"mean": float(pl.mean()), "std": float(pl.std()), "cvar": cv, "var": float(q),
|
||||
"var_99": float(np.quantile(loss, 0.99)), "turnover": float(turnover.mean()),
|
||||
"cost": float(tc.mean()), "risk_price": None}
|
||||
|
||||
|
||||
def deep_hedge_report(S0, K, T, sigma, cost_bps, n_steps=30, alpha=0.95, jump=None, test_jump="same",
|
||||
test_sigma=None, n_train=10000, n_test=8000, iters=250, seed=7, is_call=True):
|
||||
"""Train on (sigma, jump); test out of sample on the same or a shifted regime."""
|
||||
k = K / S0
|
||||
cost = cost_bps / 1e4
|
||||
rng = np.random.default_rng(seed)
|
||||
S_tr = simulate(n_train, n_steps, T, sigma, rng, jump)
|
||||
tj = jump if test_jump == "same" else test_jump
|
||||
ts = test_sigma or sigma
|
||||
S_te = simulate(n_test, n_steps, T, ts, rng, tj)
|
||||
p0 = float(bs_call(1.0, k, T, sigma)[0])
|
||||
|
||||
ntb, h_ntb = train_ntb(S_tr, k, T, sigma, cost, p0, n_steps, alpha, iters)
|
||||
ff, h_ff = train_ff(S_tr, k, T, sigma, cost, p0, n_steps, alpha, iters)
|
||||
|
||||
# Tune Whalley-Wilmott risk aversion on the training paths (same objective).
|
||||
Xtr, Dtr, Gtr, tau = features(S_tr, k, T, sigma, n_steps)
|
||||
best = None
|
||||
for g in np.geomspace(0.1, 1e6, 36):
|
||||
c_ = cvar(-pnl(S_tr, ww_positions(Dtr, Gtr, S_tr, cost, g, tau), k, cost, p0)[0], alpha)[0]
|
||||
if best is None or c_ < best[0]:
|
||||
best = (c_, g)
|
||||
gamma_ra = best[1]
|
||||
|
||||
X, D, G, tau = features(S_te, k, T, sigma, n_steps)
|
||||
pos = {"No hedge": np.zeros_like(D), "BS delta": D,
|
||||
"Whalley-Wilmott": ww_positions(D, G, S_te, cost, gamma_ra, tau),
|
||||
"Deep NTB": ntb_positions(ntb, X, D)[0],
|
||||
"Deep FF": D + ff.forward(X.reshape(-1, 3)).reshape(D.shape)}
|
||||
rows = {}
|
||||
pls = {}
|
||||
for name, d in pos.items():
|
||||
pl, to, tc = pnl(S_te, d, k, cost, p0)
|
||||
pls[name] = pl
|
||||
rows[name] = _summ(pl * S0, to, tc * S0, alpha)
|
||||
rows[name]["risk_price"] = float(p0 * S0 + rows[name]["cvar"]) # premium making CVaR = 0
|
||||
# Learned band vs Whalley-Wilmott on a moneyness grid at a few times to expiry.
|
||||
grid_m = np.linspace(-2.0, 2.0, 61)
|
||||
bands = []
|
||||
for frac in (1.0, 0.5, 0.15):
|
||||
tt = T * frac
|
||||
Sg = k * np.exp(grid_m * sigma * np.sqrt(T))
|
||||
_, dg, gg = bs_call(Sg, k, tt, sigma)
|
||||
Xg = np.stack([grid_m, np.full_like(grid_m, np.sqrt(frac)), np.full_like(grid_m, sigma)], axis=-1)
|
||||
z = ntb.forward(Xg)
|
||||
hw = (1.5 * cost * Sg * gg ** 2 / gamma_ra) ** (1 / 3)
|
||||
bands.append({"tau_frac": frac, "spot": (Sg * S0).tolist(), "delta": dg.tolist(),
|
||||
"ntb_lo": (dg - softplus(z[:, 0])).tolist(), "ntb_hi": (dg + softplus(z[:, 1])).tolist(),
|
||||
"ww_lo": (dg - hw).tolist(), "ww_hi": (dg + hw).tolist()})
|
||||
i = int(np.argsort(np.abs(S_te[:, -1] - k))[len(S_te) // 50]) # a path ending near the strike
|
||||
sample = {"t": (np.arange(n_steps) * T / n_steps * 365).tolist(), "spot": (S_te[i, :-1] * S0).tolist(),
|
||||
"positions": {n: d[i].tolist() for n, d in pos.items() if n != "No hedge"}}
|
||||
lo = min(np.quantile(p, 0.005) for p in pls.values()) * S0
|
||||
hi = max(np.quantile(p, 0.995) for p in pls.values()) * S0
|
||||
edges = np.linspace(lo, hi, 51)
|
||||
hists = {n: np.histogram(np.clip(p * S0, lo, hi), bins=edges)[0].tolist() for n, p in pls.items() if n != "No hedge"}
|
||||
return {"premium": p0 * S0, "alpha": alpha, "cost_bps": cost_bps, "n_steps": n_steps,
|
||||
"gamma_ra": gamma_ra, "strategies": rows, "bands": bands, "sample": sample,
|
||||
"pnl_hist": {"centers": (0.5 * (edges[1:] + edges[:-1])).tolist(), "counts": hists},
|
||||
"training": {"ntb": [h * S0 for h in h_ntb], "ff": [h * S0 for h in h_ff]},
|
||||
"train_regime": {"sigma": sigma, "jump": jump},
|
||||
"test_regime": {"sigma": ts, "jump": tj}}
|
||||
+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, xasset, jumps, exotics, eventvol, risk, tarf
|
||||
from .routers import greeks, surface, market, montecarlo, scenarios, strategies, export, forex, institutional, news, livedata, smile, vol, hedge, xasset, jumps, exotics, eventvol, risk, tarf, deephedge
|
||||
|
||||
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, xasset, jumps, exotics, eventvol, risk, tarf]:
|
||||
for router in [greeks, surface, market, montecarlo, scenarios, strategies, export, forex, institutional, news, livedata, smile, vol, hedge, xasset, jumps, exotics, eventvol, risk, tarf, deephedge]:
|
||||
app.include_router(router.router, prefix="/api")
|
||||
|
||||
@app.get("/")
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
"""
|
||||
deephedge.py — AI Hedger: trains a No-Transaction-Band deep-hedging network and a
|
||||
feed-forward deep hedge (numpy, no API keys, no GPU) for a short call on any pair, and
|
||||
compares them with BS delta and Whalley-Wilmott out of sample, optionally under a shifted
|
||||
test regime (vol shock or jumps).
|
||||
"""
|
||||
|
||||
import numpy as np
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from ..core.deep_hedge import deep_hedge_report
|
||||
from .forex import PAIRS
|
||||
|
||||
router = APIRouter(prefix="/deephedge", tags=["deephedge"])
|
||||
_cache: dict = {}
|
||||
|
||||
|
||||
class Jump(BaseModel):
|
||||
intensity: float = Field(default=0.0, ge=0, le=100, description="Jumps per year")
|
||||
mean: float = Field(default=0.0, gt=-0.5, lt=0.5, description="Mean log jump")
|
||||
sd: float = Field(default=0.0, ge=0, lt=0.5)
|
||||
|
||||
|
||||
class DeepHedgeRequest(BaseModel):
|
||||
pair: str = "BTCUSD"
|
||||
S: float = Field(..., gt=0)
|
||||
strike_pct: float = Field(default=100.0, gt=50, lt=200)
|
||||
days: float = Field(default=30.0, ge=2, le=180)
|
||||
sigma: float = Field(..., gt=0.02, lt=2.5)
|
||||
cost_bps: float = Field(default=10.0, ge=0, le=200, description="Proportional cost per trade")
|
||||
n_steps: int = Field(default=30, ge=5, le=60)
|
||||
alpha: float = Field(default=0.95, ge=0.5, le=0.995)
|
||||
jump: Jump = Jump()
|
||||
test_regime: str = Field(default="same", pattern="^(same|vol_up|jumps)$")
|
||||
|
||||
|
||||
@router.post("/train")
|
||||
def train(req: DeepHedgeRequest):
|
||||
pair = req.pair.upper()
|
||||
if pair not in PAIRS:
|
||||
raise HTTPException(404, f"Unknown pair '{pair}'.")
|
||||
key = req.model_dump_json()
|
||||
if key in _cache:
|
||||
return _cache[key]
|
||||
jump = {"lambda": req.jump.intensity, "mean": req.jump.mean, "sd": req.jump.sd} if req.jump.intensity > 0 else None
|
||||
test_jump, test_sigma = "same", None
|
||||
if req.test_regime == "vol_up":
|
||||
test_sigma = req.sigma * 1.5
|
||||
elif req.test_regime == "jumps":
|
||||
test_jump = {"lambda": 12.0, "mean": -0.05, "sd": 0.06}
|
||||
T = req.days / 365
|
||||
rep = deep_hedge_report(req.S, req.S * req.strike_pct / 100, T, req.sigma, req.cost_bps, req.n_steps,
|
||||
req.alpha, jump=jump, test_jump=test_jump, test_sigma=test_sigma)
|
||||
out = _clean({"pair": pair, "strike": req.S * req.strike_pct / 100, "days": req.days,
|
||||
"test_regime_label": {"same": "Same as training", "vol_up": "Vol +50%",
|
||||
"jumps": "Crash jumps (12/yr, −5%)"}[req.test_regime], **rep})
|
||||
if len(_cache) > 32:
|
||||
_cache.clear()
|
||||
_cache[key] = out
|
||||
return out
|
||||
|
||||
|
||||
def _clean(obj):
|
||||
if isinstance(obj, (bool, np.bool_)):
|
||||
return bool(obj)
|
||||
if isinstance(obj, (float, np.floating)):
|
||||
x = float(obj)
|
||||
return round(x, 8) if np.isfinite(x) else None
|
||||
if isinstance(obj, dict):
|
||||
return {str(k): _clean(v) for k, v in obj.items()}
|
||||
if isinstance(obj, (list, tuple, np.ndarray)):
|
||||
return [_clean(v) for v in obj]
|
||||
return obj
|
||||
@@ -0,0 +1,72 @@
|
||||
"""AI Hedger: BPTT gradient check, P&L identities, learning beats BS delta under costs, endpoint."""
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.core.deep_hedge import (MLP, bs_call, cvar, deep_hedge_report, features, ntb_grad, ntb_positions,
|
||||
pnl, simulate, ww_positions)
|
||||
from app.main import app
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
|
||||
def test_ntb_bptt_gradient_matches_finite_differences():
|
||||
rng = np.random.default_rng(0)
|
||||
S = simulate(64, 10, 0.1, 0.5, rng)
|
||||
X, D, _, _ = features(S, 1.0, 0.1, 0.5, 10)
|
||||
p0 = float(bs_call(1.0, 1.0, 0.1, 0.5)[0])
|
||||
net = MLP([3, 8, 8, 2], np.random.default_rng(1), out_bias=-1.0, out_scale=1.0)
|
||||
w = -np.ones(64) / 64
|
||||
|
||||
def f():
|
||||
return float((w * pnl(S, ntb_positions(net, X, D)[0], 1.0, 0.002, p0)[0]).sum())
|
||||
|
||||
_, grads = ntb_grad(net, S, X, D, 1.0, 0.002, p0, w)
|
||||
for li, (i, j) in [(0, (0, 0)), (1, (2, 3)), (2, (4, 1)), (2, (5, 0))]:
|
||||
W, e = net.W[li], 1e-6
|
||||
old = W[i, j]
|
||||
W[i, j] = old + e
|
||||
fp = f()
|
||||
W[i, j] = old - e
|
||||
fm = f()
|
||||
W[i, j] = old
|
||||
assert grads[li][i, j] == pytest.approx((fp - fm) / (2 * e), rel=1e-5, abs=1e-12)
|
||||
|
||||
|
||||
def test_pnl_identities():
|
||||
rng = np.random.default_rng(3)
|
||||
S = simulate(20000, 50, 0.25, 0.2, rng)
|
||||
assert S[:, -1].mean() == pytest.approx(1.0, abs=0.01) # martingale
|
||||
p0 = float(bs_call(1.0, 1.0, 0.25, 0.2)[0])
|
||||
_, D, G, tau = features(S, 1.0, 0.25, 0.2, 50)
|
||||
pl, to, tc = pnl(S, D, 1.0, 0.0, p0)
|
||||
assert abs(pl.mean()) < 0.002 and pl.std() < 0.2 * p0 # discrete delta hedge ≈ replicates
|
||||
unhedged = pnl(S, np.zeros_like(D), 1.0, 0.0, p0)[0]
|
||||
assert unhedged.std() > 5 * pl.std()
|
||||
ww = ww_positions(D, G, S, 0.002, 1e13, tau) # huge risk aversion → band → 0
|
||||
assert np.allclose(ww, D, atol=1e-3)
|
||||
assert pnl(S, D, 1.0, 0.002, p0)[2].mean() == pytest.approx(0.002 * (to * 1.0).mean(), rel=0.05)
|
||||
|
||||
|
||||
def test_deep_hedgers_beat_bs_delta_under_costs():
|
||||
rep = deep_hedge_report(62000, 62000, 30 / 365, 0.6, cost_bps=20, n_steps=20, n_train=6000, n_test=6000,
|
||||
iters=200)
|
||||
s = rep["strategies"]
|
||||
assert s["Deep NTB"]["cvar"] < s["BS delta"]["cvar"]
|
||||
assert s["Deep NTB"]["turnover"] < s["BS delta"]["turnover"] # learns to trade less
|
||||
assert s["No hedge"]["cvar"] > 5 * s["BS delta"]["cvar"]
|
||||
assert rep["training"]["ntb"][0] > np.mean(rep["training"]["ntb"][-20:]) # loss decreases
|
||||
for b in rep["bands"]:
|
||||
assert all(lo <= d <= hi for lo, d, hi in zip(b["ntb_lo"], b["delta"], b["ntb_hi"]))
|
||||
|
||||
|
||||
def test_endpoint():
|
||||
body = {"pair": "XAUUSD", "S": 4400, "sigma": 0.2, "days": 20, "cost_bps": 15, "n_steps": 10,
|
||||
"test_regime": "jumps"}
|
||||
r = client.post("/api/deephedge/train", json=body)
|
||||
assert r.status_code == 200, r.text
|
||||
js = r.json()
|
||||
assert set(js["strategies"]) == {"No hedge", "BS delta", "Whalley-Wilmott", "Deep NTB", "Deep FF"}
|
||||
assert js["test_regime"]["jump"]["lambda"] == 12.0 and len(js["bands"]) == 3
|
||||
assert client.post("/api/deephedge/train", json={**body, "pair": "NOPE"}).status_code == 404
|
||||
@@ -63,3 +63,6 @@ export const explainPnl = (p) => api.post('/risk/explain', p).then(r=>r.data)
|
||||
|
||||
// TARF Desk
|
||||
export const priceTarf = (p) => api.post('/tarf/price', p).then(r=>r.data)
|
||||
|
||||
// AI Hedger (deep hedging)
|
||||
export const trainDeepHedge = (p) => api.post('/deephedge/train', p, { timeout: 120000 }).then(r=>r.data)
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
import { useState } from 'react'
|
||||
import _Plot from 'react-plotly.js'
|
||||
const Plot = _Plot?.default ?? _Plot
|
||||
import { usePortfolioStore, isCrypto, PAIR_META } from '../store/portfolio'
|
||||
import { trainDeepHedge } from '../api/client'
|
||||
|
||||
const LAYOUT = {
|
||||
paper_bgcolor:'transparent', plot_bgcolor:'#080d18',
|
||||
font:{ color:'#94a3b8', size:11 },
|
||||
margin:{ l:56, r:16, t:12, b:42 },
|
||||
legend:{ bgcolor:'transparent', font:{ size:9 }, orientation:'h', x:0, y:-0.22 },
|
||||
hoverlabel:{ bgcolor:'#0f172a', font:{ color:'#e2e8f0' }, bordercolor:'rgba(0,212,255,0.15)' },
|
||||
}
|
||||
const CFG = { responsive:true, displayModeBar:false }
|
||||
const axis = (title, extra = {}) => ({ title:{ text:title }, gridcolor:'#1e293b', color:'#475569', zeroline:false, ...extra })
|
||||
const num = (v, d = 2) => (v == null ? '—' : Number(v).toLocaleString(undefined, { minimumFractionDigits:d, maximumFractionDigits:d }))
|
||||
// Exponential moving average for noisy minibatch losses.
|
||||
const ema = (xs, a = 0.1) => { let m = xs[0]; return xs.map(x => (m = a * x + (1 - a) * m)) }
|
||||
const COLOR = { 'No hedge':'#475569', 'BS delta':'#94a3b8', 'Whalley-Wilmott':'#ffd700', 'Deep NTB':'#00d4ff', 'Deep FF':'#c084fc' }
|
||||
const NOTE = {
|
||||
'No hedge':'Naked short option',
|
||||
'BS delta':'Rebalance to Black-Scholes delta every step',
|
||||
'Whalley-Wilmott':'Analytic no-trade band, risk aversion tuned on training paths',
|
||||
'Deep NTB':'No-Transaction-Band network (Imaki et al. 2021) — learns its own band',
|
||||
'Deep FF':'Feed-forward deep hedge (Buehler et al. 2019) — no memory of position',
|
||||
}
|
||||
|
||||
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', gap:8, flexWrap:'wrap' }}>
|
||||
<span className="section-title" style={{ fontSize:10 }}>{title}</span>{right}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Kpis({ items }) {
|
||||
return (
|
||||
<div style={{ display:'flex', gap:8, marginBottom:12, flexWrap:'wrap' }}>
|
||||
{items.map(k => (
|
||||
<div key={k.label} className="glass" title={k.hint} 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>
|
||||
)
|
||||
}
|
||||
|
||||
const inputStyle = { background:'var(--bg3)', border:'1px solid var(--border)', borderRadius:4, color:'var(--cyan)', fontSize:11,
|
||||
padding:'4px 6px', fontFamily:'var(--font-mono)', width:'100%' }
|
||||
|
||||
function Field({ label, value, onChange, step, hint }) {
|
||||
return (
|
||||
<label title={hint} 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={inputStyle} />
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
export default function AiHedger() {
|
||||
const { pair } = usePortfolioStore()
|
||||
return <Hedger key={pair} pair={pair} />
|
||||
}
|
||||
|
||||
function Hedger({ pair }) {
|
||||
const { S, sigma, hedgeJump } = usePortfolioStore()
|
||||
const crypto = isCrypto(pair)
|
||||
const dp = PAIR_META[pair]?.dp ?? 4
|
||||
const [p, setP] = useState(() => ({
|
||||
strike_pct:100, days:30, sigma:+(sigma * 100).toFixed(1), cost_bps: crypto ? 10 : 5, n_steps:30, alpha:95,
|
||||
jumps: !!hedgeJump || crypto,
|
||||
jump: hedgeJump ?? (crypto ? { intensity:12, mean:-1, sd:5 } : { intensity:2, mean:-3, sd:4 }), test_regime:'same',
|
||||
}))
|
||||
const [res, setRes] = useState({ data:null, error:null, busy:false })
|
||||
const [bandIdx, setBandIdx] = useState(1)
|
||||
const set = (k) => (v) => setP(q => ({ ...q, [k]: v }))
|
||||
const setJ = (k) => (v) => setP(q => ({ ...q, jump:{ ...q.jump, [k]: v } }))
|
||||
|
||||
const train = () => {
|
||||
setRes(r => ({ ...r, busy:true, error:null }))
|
||||
trainDeepHedge({ pair, S, strike_pct:p.strike_pct, days:p.days, sigma:p.sigma / 100, cost_bps:p.cost_bps, n_steps:p.n_steps,
|
||||
alpha:p.alpha / 100, test_regime:p.test_regime,
|
||||
jump: p.jumps ? { intensity:p.jump.intensity, mean:p.jump.mean / 100, sd:p.jump.sd / 100 } : { intensity:0, mean:0, sd:0 } })
|
||||
.then(data => setRes({ data, error:null, busy:false }))
|
||||
.catch(e => setRes({ data:null, error: e?.response?.data?.detail?.toString() ?? 'Training failed — is the backend up?', busy:false }))
|
||||
}
|
||||
|
||||
const d = res.data
|
||||
const s = d?.strategies
|
||||
const gain = s ? 1 - s['Deep NTB'].cvar / s['BS delta'].cvar : null
|
||||
const turn = s ? 1 - s['Deep NTB'].turnover / s['BS delta'].turnover : null
|
||||
const band = d?.bands?.[bandIdx]
|
||||
|
||||
return (
|
||||
<div style={{ padding:16 }}>
|
||||
<div style={{ marginBottom:12 }}>
|
||||
<div className="section-title">AI Hedger — deep hedging under transaction costs & jumps ({pair})</div>
|
||||
<div style={{ fontSize:9, color:'var(--text-muted)', marginTop:2 }}>
|
||||
Trains a neural hedging policy on simulated paths (in-browser request, server-side numpy — no GPU, no API key) and benchmarks it
|
||||
against BS delta and the Whalley-Wilmott band on fresh paths
|
||||
</div>
|
||||
</div>
|
||||
<div className="glass" style={{ borderRadius:8, padding:10, marginBottom:12 }}>
|
||||
<div style={{ display:'grid', gridTemplateColumns:'repeat(auto-fill, minmax(110px, 1fr))', gap:10, alignItems:'end' }}>
|
||||
<Field label="Strike (% spot)" value={p.strike_pct} onChange={set('strike_pct')} step={1} />
|
||||
<Field label="Days to expiry" value={p.days} onChange={set('days')} step={1} />
|
||||
<Field label="Vol (%)" value={p.sigma} onChange={set('sigma')} step={1} />
|
||||
<Field label="Cost (bps / trade)" value={p.cost_bps} onChange={set('cost_bps')} step={1}
|
||||
hint="Proportional cost: spread + fees + slippage (Deribit perp taker ≈ 5 bp, OTC gold spot ≈ 2-10 bp)" />
|
||||
<Field label="Rebalances" value={p.n_steps} onChange={set('n_steps')} step={5} />
|
||||
<Field label="CVaR level (%)" value={p.alpha} onChange={set('alpha')} step={1} />
|
||||
<label style={{ display:'flex', flexDirection:'column', gap:3, fontSize:9, color:'var(--text-muted)' }}>Training market
|
||||
<select value={p.jumps ? 'jump' : 'gbm'} onChange={e => set('jumps')(e.target.value === 'jump')} style={inputStyle}>
|
||||
<option value="gbm">GBM</option><option value="jump">Merton jumps</option>
|
||||
</select>
|
||||
</label>
|
||||
{p.jumps && <>
|
||||
<Field label="Jumps / yr" value={p.jump.intensity} onChange={setJ('intensity')} step={1} />
|
||||
<Field label="Mean jump (%)" value={p.jump.mean} onChange={setJ('mean')} step={0.5} />
|
||||
<Field label="Jump sd (%)" value={p.jump.sd} onChange={setJ('sd')} step={0.5} />
|
||||
</>}
|
||||
<label style={{ display:'flex', flexDirection:'column', gap:3, fontSize:9, color:'var(--text-muted)' }}>Test market
|
||||
<select value={p.test_regime} onChange={e => set('test_regime')(e.target.value)} style={inputStyle}>
|
||||
<option value="same">Same as training</option><option value="vol_up">Vol +50% (regime shift)</option>
|
||||
<option value="jumps">Crash jumps (12/yr, −5%)</option>
|
||||
</select>
|
||||
</label>
|
||||
<button className="btn-primary" disabled={res.busy} onClick={train} style={{ padding:'6px 10px', fontSize:11 }}>
|
||||
{res.busy ? 'Training…' : d ? '↻ Retrain' : '▶ Train AI hedger'}
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ fontSize:9, color:'var(--text-muted)', marginTop:6, lineHeight:1.5 }}>
|
||||
The desk is short 1 call (premium = BS price) and minimises CVaR {p.alpha}% of the hedged loss. ≈ 5–10 s: 10k training paths,
|
||||
250 Adam steps per network. {hedgeJump ? 'Jump parameters pre-filled from Jump Lab.' : ''}
|
||||
</div>
|
||||
</div>
|
||||
{res.error && <div style={{ padding:8, borderRadius:6, fontSize:11, marginBottom:10, background:'rgba(255,61,90,0.1)', border:'1px solid #ff3d5a', color:'#ff3d5a' }}>{res.error}</div>}
|
||||
{!d && !res.busy && !res.error && <div style={{ fontSize:11, color:'var(--text-muted)', padding:12 }}>Set the trade and costs, then train.</div>}
|
||||
{d && (
|
||||
<div style={{ opacity: res.busy ? 0.5 : 1, transition:'opacity .2s' }}>
|
||||
<Kpis items={[
|
||||
{ label:'Premium (BS)', value:num(d.premium, dp), sub:`K ${num(d.strike, dp)} · ${d.days}d` },
|
||||
{ label:`CVaR ${Math.round(d.alpha * 100)}% · BS delta`, value:num(s['BS delta'].cvar, dp), color:'#94a3b8' },
|
||||
{ label:`CVaR ${Math.round(d.alpha * 100)}% · Deep NTB`, value:num(s['Deep NTB'].cvar, dp),
|
||||
color: gain > 0 ? 'var(--green)' : 'var(--red)', sub:`${gain >= 0 ? '−' : '+'}${Math.abs(gain * 100).toFixed(1)}% tail risk vs delta` },
|
||||
{ label:'Turnover vs delta', value:`${turn >= 0 ? '−' : '+'}${Math.abs(turn * 100).toFixed(0)}%`, color:'#ffd700',
|
||||
sub:`${s['Deep NTB'].turnover.toFixed(2)} vs ${s['BS delta'].turnover.toFixed(2)} units` },
|
||||
{ label:'Risk-adjusted price (NTB)', value:num(s['Deep NTB'].risk_price, dp),
|
||||
sub:`delta hedge needs ${num(s['BS delta'].risk_price, dp)}`, hint:'Premium at which the hedged CVaR is zero — what you must charge' },
|
||||
{ label:'Test market', value:d.test_regime_label, color: d.test_regime_label.startsWith('Same') ? 'var(--cyan)' : '#fb923c' },
|
||||
]} />
|
||||
<StrategyTable s={s} dp={dp} alpha={d.alpha} />
|
||||
<div style={{ display:'grid', gridTemplateColumns:'repeat(auto-fit, minmax(420px, 1fr))', gap:12, marginTop:12 }}>
|
||||
<Card title="Learned no-trade band vs Whalley-Wilmott"
|
||||
right={<select value={bandIdx} onChange={e => setBandIdx(+e.target.value)} style={{ ...inputStyle, width:'auto', fontSize:9 }}>
|
||||
{d.bands.map((b, i) => <option key={i} value={i}>{Math.round(b.tau_frac * d.days)}d to expiry</option>)}</select>}>
|
||||
<Plot data={[
|
||||
{ x:band.spot, y:band.ntb_hi, type:'scatter', mode:'lines', line:{ color:'#00d4ff', width:0 }, showlegend:false, hoverinfo:'skip' },
|
||||
{ x:band.spot, y:band.ntb_lo, type:'scatter', mode:'lines', name:'Deep NTB band', fill:'tonexty',
|
||||
fillcolor:'rgba(0,212,255,0.18)', line:{ color:'#00d4ff', width:1 } },
|
||||
{ x:band.spot, y:band.ww_hi, type:'scatter', mode:'lines', name:'Whalley-Wilmott band', line:{ color:'#ffd700', dash:'dash', width:1.2 } },
|
||||
{ x:band.spot, y:band.ww_lo, type:'scatter', mode:'lines', showlegend:false, line:{ color:'#ffd700', dash:'dash', width:1.2 } },
|
||||
{ x:band.spot, y:band.delta, type:'scatter', mode:'lines', name:'BS delta', line:{ color:'#e2e8f0', width:1.5 } },
|
||||
]}
|
||||
layout={{ ...LAYOUT, hovermode:'x unified', xaxis:axis('Spot'), yaxis:axis('Hedge (units of underlying)', { range:[-0.05, 1.05] }),
|
||||
shapes:[{ type:'line', x0:d.strike, x1:d.strike, yref:'paper', y0:0, y1:1, line:{ color:'rgba(255,61,90,0.5)', dash:'dot' } }] }}
|
||||
config={CFG} style={{ width:'100%', height:300 }} />
|
||||
<div style={{ fontSize:9, color:'var(--text-muted)', padding:'0 8px 4px' }}>
|
||||
Inside the band the AI holds its position; it only trades to the nearest edge. Widest where gamma is high and costs bite — what a desk does by feel.
|
||||
</div>
|
||||
</Card>
|
||||
<Card title="Hedged P&L distribution (out of sample)">
|
||||
<Plot data={Object.entries(d.pnl_hist.counts).map(([n, c]) => ({ x:d.pnl_hist.centers, y:c, type:'scatter', mode:'lines',
|
||||
name:n, line:{ color:COLOR[n], width: n.startsWith('Deep') ? 2.2 : 1.3, shape:'spline' } }))}
|
||||
layout={{ ...LAYOUT, hovermode:'x unified', xaxis:axis(`Hedged P&L (${pair.slice(3)})`), yaxis:axis('Paths') }}
|
||||
config={CFG} style={{ width:'100%', height:300 }} />
|
||||
</Card>
|
||||
<Card title="One test path — hedge positions">
|
||||
<Plot data={[
|
||||
...Object.entries(d.sample.positions).map(([n, y]) => ({ x:d.sample.t, y, type:'scatter', mode:'lines', name:n,
|
||||
line:{ color:COLOR[n], width: n.startsWith('Deep') ? 2 : 1.2, shape:'hv' } })),
|
||||
{ x:d.sample.t, y:d.sample.spot, type:'scatter', mode:'lines', name:'Spot (rhs)', yaxis:'y2', line:{ color:'rgba(255,255,255,0.25)', width:1 } },
|
||||
]}
|
||||
layout={{ ...LAYOUT, hovermode:'x unified', xaxis:axis('Day'), yaxis:axis('Units'),
|
||||
yaxis2:{ overlaying:'y', side:'right', showgrid:false, zeroline:false, color:'#475569' } }}
|
||||
config={CFG} style={{ width:'100%', height:300 }} />
|
||||
</Card>
|
||||
<Card title={`Training curve · CVaR ${Math.round(d.alpha * 100)}% per minibatch`}>
|
||||
<Plot data={[
|
||||
{ y:d.training.ntb, type:'scatter', mode:'lines', showlegend:false, hoverinfo:'skip', line:{ color:'rgba(0,212,255,0.15)', width:1 } },
|
||||
{ y:d.training.ff, type:'scatter', mode:'lines', showlegend:false, hoverinfo:'skip', line:{ color:'rgba(192,132,252,0.15)', width:1 } },
|
||||
{ y:ema(d.training.ntb), type:'scatter', mode:'lines', name:'Deep NTB (EMA)', line:{ color:'#00d4ff', width:2 } },
|
||||
{ y:ema(d.training.ff), type:'scatter', mode:'lines', name:'Deep FF (EMA)', line:{ color:'#c084fc', width:2 } },
|
||||
{ x:[0, d.training.ntb.length - 1], y:[s['BS delta'].cvar, s['BS delta'].cvar], type:'scatter', mode:'lines',
|
||||
name:'BS delta (test)', line:{ color:'#94a3b8', dash:'dash' } },
|
||||
]}
|
||||
layout={{ ...LAYOUT, xaxis:axis('Adam step'), yaxis:axis('CVaR') }}
|
||||
config={CFG} style={{ width:'100%', height:300 }} />
|
||||
</Card>
|
||||
</div>
|
||||
<div style={{ fontSize:9, color:'var(--text-muted)', marginTop:10, lineHeight:1.5 }}>
|
||||
Buehler, Gonon, Teichmann & Wood (2019) "Deep hedging"; Imaki et al. (2021) "No-transaction band network";
|
||||
Whalley & Wilmott (1997). CVaR minimised by its tail-average gradient (Rockafellar-Uryasev); NTB trained by back-propagation through time
|
||||
(gradient-checked against finite differences). Zero rates; costs on every trade incl. the initial hedge; no unwind at expiry (physical settlement).
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StrategyTable({ s, dp, alpha }) {
|
||||
const cols = [['mean', 'Mean P&L'], ['std', 'Std'], ['cvar', `CVaR ${Math.round(alpha * 100)}%`], ['var_99', 'VaR 99%'],
|
||||
['turnover', 'Turnover'], ['cost', 'Cost paid'], ['risk_price', 'Risk-adj. price']]
|
||||
const best = Math.min(...Object.values(s).map(v => v.cvar))
|
||||
return (
|
||||
<Card title="Out-of-sample comparison (fresh paths)">
|
||||
<div style={{ overflowX:'auto' }}>
|
||||
<table style={{ width:'100%', borderCollapse:'collapse', fontSize:11 }}>
|
||||
<thead><tr style={{ color:'var(--text-muted)', fontSize:9, textAlign:'right' }}>
|
||||
<th style={{ textAlign:'left', padding:'4px 8px' }}>Strategy</th>
|
||||
{cols.map(([, l]) => <th key={l} style={{ padding:'4px 8px' }}>{l}</th>)}</tr></thead>
|
||||
<tbody>{Object.entries(s).map(([n, v]) => (
|
||||
<tr key={n} title={NOTE[n]} style={{ borderTop:'1px solid var(--border)', textAlign:'right',
|
||||
background: v.cvar === best ? 'rgba(0,255,136,0.06)' : 'transparent' }}>
|
||||
<td style={{ textAlign:'left', padding:'5px 8px', color:COLOR[n] }}>{n}
|
||||
<div style={{ fontSize:8, color:'var(--text-muted)' }}>{NOTE[n]}</div></td>
|
||||
{cols.map(([k]) => <td key={k} className="font-mono" style={{ padding:'5px 8px',
|
||||
color: k === 'cvar' ? (v.cvar === best ? 'var(--green)' : 'var(--red)') : undefined }}>
|
||||
{k === 'turnover' ? v[k].toFixed(2) : num(v[k], dp)}</td>)}
|
||||
</tr>))}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import ExoticsDesk from '../components/ExoticsDesk'
|
||||
import EventVol from '../components/EventVol'
|
||||
import RiskDesk from '../components/RiskDesk'
|
||||
import TarfDesk from '../components/TarfDesk'
|
||||
import AiHedger from '../components/AiHedger'
|
||||
|
||||
const IVSurface = lazy(() => import('../components/IVSurface'))
|
||||
const WorldMap = lazy(() => import('../components/WorldMap'))
|
||||
@@ -53,6 +54,7 @@ const TABS = [
|
||||
{ id:'eventvol', icon:'📆', label:'Event Vol', component: EventVol },
|
||||
{ id:'hedgelab', icon:'⚖', label:'Hedge Lab', component: HedgeLab },
|
||||
{ id:'risk', icon:'🛡', label:'Risk Desk', component: RiskDesk },
|
||||
{ id:'aihedger', icon:'🧠', label:'AI Hedger', component: AiHedger },
|
||||
{ id:'jumps', icon:'⚡', label:'Jump Lab', component: JumpLab },
|
||||
{ id:'exotics', icon:'🧩', label:'Exotics Desk', component: ExoticsDesk },
|
||||
{ id:'tarf', icon:'🎯', label:'TARF Desk', component: TarfDesk },
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 468 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 466 KiB |
Reference in New Issue
Block a user