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:
Claude
2026-10-17 05:27:03 +00:00
parent fb2b15c596
commit 8328114379
10 changed files with 692 additions and 2 deletions
+1
View File
@@ -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 |
+298
View File
@@ -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
View File
@@ -1,6 +1,6 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from .routers import greeks, surface, market, montecarlo, scenarios, strategies, export, forex, institutional, news, livedata, smile, vol, hedge, 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("/")
+74
View File
@@ -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
+72
View File
@@ -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
+3
View File
@@ -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)
+240
View File
@@ -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 &amp; 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 &amp; Wood (2019) &quot;Deep hedging&quot;; Imaki et al. (2021) &quot;No-transaction band network&quot;;
Whalley &amp; 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>
)
}
+2
View File
@@ -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