mirror of
https://github.com/Mihirkansara/nexus-quant-terminal.git
synced 2026-11-02 09:36:11 +00:00
feat: Hedge Lab — Monte Carlo delta-hedging / gamma-scalping P&L
- core/hedge_sim.py: vectorised Garman-Kohlhagen, self-financing delta hedge of the whole portfolio (legs settle at their own expiries) with domestic financing, foreign/lease carry and proportional costs; time-based or delta-band rebalancing. - Path models: GBM, Merton jump-diffusion at equal total variance, and a stationary block bootstrap of the asset's own daily returns (Brownian-bridge intraday steps); Monday-open weekend gaps for 24/5 assets. - Benchmarks: theoretical edge V(sigma_r)-V(sigma_i), Derman-Kamal discrete-hedging error, Leland cost; hedge-frequency sweep (risk vs cost). - POST /api/hedge/simulate with work caps; bootstrap reuses cached history. - Hedge Lab tab: scenario controls, 'use Vol Forecast' for realised vol, KPIs, P&L histogram, frequency sweep, attribution, sample paths. - 7 tests: GK parity, unbiased fair-vol hedge with Derman-Kamal sd, mean P&L vs theoretical edge, sqrt(N) sweep, band/Leland costs, gaps/jumps tails, endpoint. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FpiqUMAyo4RL5DdQHMWbwE
This commit is contained in:
@@ -40,6 +40,7 @@ NEXUS TERMINAL is a Bloomberg-style web application for professional forex optio
|
|||||||
| **Vol Smile (Vanna-Volga)** | Builds the FX smile from ATM / 25Δ risk reversal / 25Δ butterfly quotes (Castagna-Mercurio) and reprices each leg off the smile vs flat vol |
|
| **Vol Smile (Vanna-Volga)** | Builds the FX smile from ATM / 25Δ risk reversal / 25Δ butterfly quotes (Castagna-Mercurio) and reprices each leg off the smile vs flat vol |
|
||||||
| **Vol Lab** | Live Deribit BTC/ETH surface with a raw-SVI fit per expiry (Durrleman butterfly-arb check), ATM / 25Δ RR / 25Δ BF term structure, DVOL, vol risk premium, and a Burghardt-Lane realised-vol cone for every pair. Tests: `cd backend && python -m pytest tests` |
|
| **Vol Lab** | Live Deribit BTC/ETH surface with a raw-SVI fit per expiry (Durrleman butterfly-arb check), ATM / 25Δ RR / 25Δ BF term structure, DVOL, vol risk premium, and a Burghardt-Lane realised-vol cone for every pair. Tests: `cd backend && python -m pytest tests` |
|
||||||
| **Vol Forecast & Regime (AI)** | HAR-RV (weekend-aware for crypto), GJR-GARCH and gradient-boosting forecasts of 1D/1W/1M vol, rolling out-of-sample QLIKE leaderboard with an inverse-QLIKE ensemble, 2-state HMM regime detection, and a forecast-vs-Deribit-implied rich/cheap signal |
|
| **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 |
|
||||||
|
|
||||||
### Live Intelligence
|
### Live Intelligence
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,351 @@
|
|||||||
|
"""
|
||||||
|
hedge_sim.py — Monte Carlo delta-hedging / gamma-scalping P&L lab.
|
||||||
|
|
||||||
|
Simulates holding an option portfolio (any mix of calls/puts, each leg settling at
|
||||||
|
its own expiry) bought or sold at an implied vol σ_i and delta-hedged in the
|
||||||
|
underlying, and returns the P&L distribution with an attribution and analytic
|
||||||
|
benchmarks. Pricing and deltas use Garman-Kohlhagen (r_d domestic, r_f foreign /
|
||||||
|
lease rate / ≈0 for crypto).
|
||||||
|
|
||||||
|
Why a desk uses this: the delta-hedged P&L of a vanilla is, to first order,
|
||||||
|
dP&L ≈ ½ Γ S² (σ_realised² − σ_implied²) dt
|
||||||
|
so a long option is long realised and short implied variance. What actually lands
|
||||||
|
on the P&L also depends on discrete hedging, jumps and weekend gaps (which no hedge
|
||||||
|
can follow), and transaction costs — all simulated here.
|
||||||
|
|
||||||
|
Market scenarios
|
||||||
|
• "gbm": lognormal, constant realised vol σ_r.
|
||||||
|
• "jump": Merton (1976) jump-diffusion: compound-Poisson log-normal jumps
|
||||||
|
(intensity λ/yr, mean μ_J, sd σ_J), diffusion vol chosen so total
|
||||||
|
variance per year equals σ_r²; drift-compensated.
|
||||||
|
• "bootstrap": stationary block bootstrap (Politis & Romano 1994) of the asset's
|
||||||
|
own daily log returns — real fat tails, gaps and vol clustering —
|
||||||
|
optionally rescaled to σ_r. Intraday steps are a Brownian bridge
|
||||||
|
that sums exactly to each resampled day.
|
||||||
|
For 24/5 assets (FX, metals) every Monday's first step adds a weekend-gap shock
|
||||||
|
(sd = weekend_gap, fraction of spot) that occurs while the hedge cannot trade.
|
||||||
|
|
||||||
|
Hedging rules
|
||||||
|
• "time": rebalance to the target delta every step (steps_per_day per trading day).
|
||||||
|
• "band": rebalance only when the hedge is off target by more than band delta per
|
||||||
|
unit of option notional (band × Σ|qty|); a simple no-trade region in the
|
||||||
|
spirit of Whalley & Wilmott (1997).
|
||||||
|
|
||||||
|
Benchmarks
|
||||||
|
• Theoretical edge (hedging at realised vol, continuous): qty·(V(σ_r) − V(σ_i))·e^{r_d T}.
|
||||||
|
• Derman & Kamal (1999) discrete-hedging error: sd ≈ sqrt(π / 4N) · |vega| · σ.
|
||||||
|
• Leland (1985) cost: hedge-cost ≈ V(σ_L) − V(σ_r) with
|
||||||
|
σ_L² = σ_r² · (1 + √(2/π) · k / (σ_r √δt)), k = round-trip proportional cost.
|
||||||
|
|
||||||
|
References:
|
||||||
|
Derman, E. & Kamal, M. (1999). "When you cannot hedge continuously." Risk.
|
||||||
|
Leland, H. (1985). J. Finance, 40(5), 1283–1301.
|
||||||
|
Merton, R. (1976). J. Financial Economics, 3, 125–144.
|
||||||
|
Politis, D. & Romano, J. (1994). JASA, 89(428), 1303–1313.
|
||||||
|
Ahmad, R. & Wilmott, P. (2005). "Which free lunch would you like today, Sir?"
|
||||||
|
Wilmott Magazine.
|
||||||
|
Whalley, A. & Wilmott, P. (1997). Mathematical Finance, 7(3), 307–324.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
from scipy.stats import norm
|
||||||
|
|
||||||
|
_EPS_T = 1e-10
|
||||||
|
|
||||||
|
|
||||||
|
# ─── vectorised Garman-Kohlhagen ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
def gk_price_delta(S, K, tau, r_d, r_f, sigma, is_call):
|
||||||
|
"""Price and spot delta per unit for arrays of spots at time-to-expiry tau (≥0)."""
|
||||||
|
S = np.asarray(S, dtype=float)
|
||||||
|
if tau <= _EPS_T:
|
||||||
|
payoff = np.maximum(S - K, 0.0) if is_call else np.maximum(K - S, 0.0)
|
||||||
|
delta = (S > K).astype(float) if is_call else -(S < K).astype(float)
|
||||||
|
return payoff, delta
|
||||||
|
sq = sigma * np.sqrt(tau)
|
||||||
|
d1 = (np.log(S / K) + (r_d - r_f + 0.5 * sigma ** 2) * tau) / sq
|
||||||
|
d2 = d1 - sq
|
||||||
|
df_d, df_f = np.exp(-r_d * tau), np.exp(-r_f * tau)
|
||||||
|
if is_call:
|
||||||
|
return S * df_f * norm.cdf(d1) - K * df_d * norm.cdf(d2), df_f * norm.cdf(d1)
|
||||||
|
return K * df_d * norm.cdf(-d2) - S * df_f * norm.cdf(-d1), -df_f * norm.cdf(-d1)
|
||||||
|
|
||||||
|
|
||||||
|
def gk_gamma_vega(S, K, tau, r_d, r_f, sigma):
|
||||||
|
sq = sigma * np.sqrt(tau)
|
||||||
|
d1 = (np.log(S / K) + (r_d - r_f + 0.5 * sigma ** 2) * tau) / sq
|
||||||
|
df_f = np.exp(-r_f * tau)
|
||||||
|
return df_f * norm.pdf(d1) / (S * sq), S * df_f * norm.pdf(d1) * np.sqrt(tau)
|
||||||
|
|
||||||
|
|
||||||
|
def portfolio_value_delta(S, legs, t, r_d, r_f, sigma):
|
||||||
|
"""Value and delta of the still-live legs at time t (expired legs contribute 0)."""
|
||||||
|
S = np.asarray(S, dtype=float)
|
||||||
|
v, d = np.zeros_like(S), np.zeros_like(S)
|
||||||
|
for leg in legs:
|
||||||
|
tau = leg["T"] - t
|
||||||
|
if tau > _EPS_T:
|
||||||
|
p, dl = gk_price_delta(S, leg["K"], tau, r_d, r_f, sigma, leg["type"] == "call")
|
||||||
|
v += leg["qty"] * p
|
||||||
|
d += leg["qty"] * dl
|
||||||
|
return v, d
|
||||||
|
|
||||||
|
|
||||||
|
# ─── scenario generation ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _time_grid(T, ppy, steps_per_day, start_weekday):
|
||||||
|
"""Step lengths in years and the indices of steps that open after a weekend."""
|
||||||
|
n_days = max(1, int(round(T * ppy)))
|
||||||
|
n = n_days * steps_per_day
|
||||||
|
dt = np.full(n, T / n)
|
||||||
|
monday_steps = []
|
||||||
|
if ppy != 365: # 24/5 market: weekend gaps
|
||||||
|
wd = start_weekday
|
||||||
|
for day in range(n_days):
|
||||||
|
if day > 0 and wd == 0:
|
||||||
|
monday_steps.append(day * steps_per_day)
|
||||||
|
wd = (wd + 1) % 5 # trading-day clock: Mon..Fri
|
||||||
|
return dt, np.array(monday_steps, dtype=int), n_days
|
||||||
|
|
||||||
|
|
||||||
|
def simulate_log_increments(model, n_paths, dt, sigma_r, rng, mu=0.0, jump=None,
|
||||||
|
hist_returns=None, steps_per_day=1, rescale=True,
|
||||||
|
block=5):
|
||||||
|
"""Log-price increments, shape (n_paths, n_steps), with drift mu (risk-neutral by default)."""
|
||||||
|
n = len(dt)
|
||||||
|
if model == "bootstrap":
|
||||||
|
r = np.asarray(hist_returns, dtype=float)
|
||||||
|
r = r[np.isfinite(r)]
|
||||||
|
if len(r) < 60:
|
||||||
|
raise ValueError("not enough history to bootstrap")
|
||||||
|
r = r - r.mean()
|
||||||
|
day_len = dt[0] * steps_per_day
|
||||||
|
if rescale:
|
||||||
|
r = r * (sigma_r * np.sqrt(day_len)) / r.std()
|
||||||
|
# Risk-neutral drift with the bootstrap's own convexity term.
|
||||||
|
r = r + (mu - 0.5 * r.var() / day_len) * day_len
|
||||||
|
n_days = n // steps_per_day
|
||||||
|
# Stationary bootstrap: blocks with geometric lengths (mean `block`).
|
||||||
|
idx = np.empty((n_paths, n_days), dtype=int)
|
||||||
|
cur = rng.integers(0, len(r), n_paths)
|
||||||
|
for d in range(n_days):
|
||||||
|
restart = rng.random(n_paths) < 1.0 / block
|
||||||
|
cur = np.where(restart, rng.integers(0, len(r), n_paths), (cur + 1) % len(r))
|
||||||
|
idx[:, d] = cur
|
||||||
|
daily = r[idx]
|
||||||
|
if steps_per_day == 1:
|
||||||
|
x = daily
|
||||||
|
else:
|
||||||
|
# Brownian bridge inside each day: increments sum exactly to the daily return.
|
||||||
|
z = rng.standard_normal((n_paths, n_days, steps_per_day))
|
||||||
|
z -= z.mean(axis=2, keepdims=True)
|
||||||
|
scale = np.abs(daily)[..., None] / np.sqrt(steps_per_day)
|
||||||
|
x = (daily[..., None] / steps_per_day + scale * z).reshape(n_paths, n)
|
||||||
|
return x
|
||||||
|
sig_diff = sigma_r
|
||||||
|
jumps = 0.0
|
||||||
|
comp = 0.0
|
||||||
|
if model == "jump" and jump:
|
||||||
|
lam, mj, sj = jump["lambda"], jump["mean"], jump["sd"]
|
||||||
|
jump_var = lam * (mj ** 2 + sj ** 2)
|
||||||
|
sig_diff = np.sqrt(max(sigma_r ** 2 - jump_var, 0.05 * sigma_r ** 2))
|
||||||
|
k = np.exp(mj + 0.5 * sj ** 2) - 1
|
||||||
|
comp = lam * k # drift compensator
|
||||||
|
counts = rng.poisson(lam * dt, size=(n_paths, n))
|
||||||
|
jumps = counts * mj + np.sqrt(counts) * sj * rng.standard_normal((n_paths, n))
|
||||||
|
z = rng.standard_normal((n_paths, n))
|
||||||
|
return (mu - comp - 0.5 * sig_diff ** 2) * dt + sig_diff * np.sqrt(dt) * z + jumps
|
||||||
|
|
||||||
|
|
||||||
|
# ─── hedging engine ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def run_hedge(legs, S0, sigma_i, r_d, r_f, log_inc, dt, *, hedge_sigma=None, rule="time",
|
||||||
|
band=0.1, cost_bps=0.0, keep_paths=8):
|
||||||
|
"""
|
||||||
|
Self-financing delta hedge of `legs` (bought/sold at sigma_i at t=0).
|
||||||
|
Returns per-path P&L (domestic currency at T) and its attribution.
|
||||||
|
"""
|
||||||
|
hs = sigma_i if hedge_sigma is None else hedge_sigma
|
||||||
|
n_paths, n = log_inc.shape
|
||||||
|
T = float(np.sum(dt))
|
||||||
|
t_grid = np.concatenate([[0.0], np.cumsum(dt)])
|
||||||
|
cost = cost_bps / 1e4
|
||||||
|
|
||||||
|
S = np.full(n_paths, float(S0))
|
||||||
|
V0 = float(portfolio_value_delta(np.array([S0]), legs, 0.0, r_d, r_f, sigma_i)[0][0])
|
||||||
|
_, d0 = portfolio_value_delta(S, legs, 0.0, r_d, r_f, hs)
|
||||||
|
h = -d0 # units of underlying held
|
||||||
|
tc = cost * np.abs(h) * S
|
||||||
|
cash = -V0 - h * S - tc
|
||||||
|
total_cost = tc.copy()
|
||||||
|
carry = np.zeros(n_paths)
|
||||||
|
settled = np.zeros(n_paths)
|
||||||
|
done = [False] * len(legs)
|
||||||
|
band_units = band * sum(abs(l["qty"]) for l in legs)
|
||||||
|
n_trades = np.zeros(n_paths)
|
||||||
|
keep = min(keep_paths, n_paths)
|
||||||
|
path_S = [S[:keep].copy()]
|
||||||
|
|
||||||
|
for k in range(n):
|
||||||
|
g = dt[k]
|
||||||
|
cash *= np.exp(r_d * g)
|
||||||
|
carry_k = h * S * (np.exp(r_f * g) - 1.0) # foreign interest / lease on hedge
|
||||||
|
cash += carry_k
|
||||||
|
carry += carry_k
|
||||||
|
S = S * np.exp(log_inc[:, k])
|
||||||
|
t = t_grid[k + 1]
|
||||||
|
# Settle each leg once, at the first grid time on or after its expiry.
|
||||||
|
for j, leg in enumerate(legs):
|
||||||
|
if not done[j] and (leg["T"] <= t + 1e-12 or k == n - 1):
|
||||||
|
pay = np.maximum(S - leg["K"], 0) if leg["type"] == "call" else np.maximum(leg["K"] - S, 0)
|
||||||
|
settled += leg["qty"] * pay
|
||||||
|
cash += leg["qty"] * pay
|
||||||
|
done[j] = True
|
||||||
|
if k == n - 1:
|
||||||
|
break
|
||||||
|
live = [l for j, l in enumerate(legs) if not done[j]]
|
||||||
|
if not live:
|
||||||
|
target = np.zeros(n_paths)
|
||||||
|
else:
|
||||||
|
target = -portfolio_value_delta(S, live, t, r_d, r_f, hs)[1]
|
||||||
|
if rule == "band":
|
||||||
|
trade = np.where(np.abs(h - target) > band_units, target - h, 0.0)
|
||||||
|
else:
|
||||||
|
trade = target - h
|
||||||
|
tc = cost * np.abs(trade) * S
|
||||||
|
cash -= trade * S + tc
|
||||||
|
total_cost += tc
|
||||||
|
n_trades += trade != 0
|
||||||
|
h = h + trade
|
||||||
|
if len(path_S) < 400 or k % max(1, n // 400) == 0:
|
||||||
|
path_S.append(S[:keep].copy())
|
||||||
|
|
||||||
|
# Close the hedge at T.
|
||||||
|
tc = cost * np.abs(h) * S
|
||||||
|
cash += h * S - tc
|
||||||
|
total_cost += tc
|
||||||
|
pnl = cash
|
||||||
|
option_pnl = settled - V0 * np.exp(r_d * T)
|
||||||
|
hedge_pnl = pnl - option_pnl + total_cost
|
||||||
|
return {
|
||||||
|
"pnl": pnl, "option_pnl": option_pnl, "hedge_pnl": hedge_pnl, "costs": total_cost,
|
||||||
|
"carry": carry, "n_trades": n_trades, "premium": V0, "T": T,
|
||||||
|
"sample_spots": np.array(path_S).T if keep else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ─── analytics ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _stats(x):
|
||||||
|
x = np.asarray(x, dtype=float)
|
||||||
|
q05 = np.quantile(x, 0.05)
|
||||||
|
tail = x[x <= q05]
|
||||||
|
return {"mean": float(x.mean()), "std": float(x.std()), "p05": float(q05),
|
||||||
|
"p50": float(np.median(x)), "p95": float(np.quantile(x, 0.95)),
|
||||||
|
"es05": float(tail.mean()) if len(tail) else float(q05),
|
||||||
|
"prob_profit": float((x > 0).mean()), "min": float(x.min()), "max": float(x.max())}
|
||||||
|
|
||||||
|
|
||||||
|
def benchmarks(legs, S0, sigma_i, sigma_r, r_d, r_f, n_rebalances, cost_bps):
|
||||||
|
T = max(l["T"] for l in legs)
|
||||||
|
vi = portfolio_value_delta(np.array([S0]), legs, 0.0, r_d, r_f, sigma_i)[0][0]
|
||||||
|
vr = portfolio_value_delta(np.array([S0]), legs, 0.0, r_d, r_f, sigma_r)[0][0]
|
||||||
|
vega = sum(l["qty"] * gk_gamma_vega(S0, l["K"], l["T"], r_d, r_f, sigma_r)[1] for l in legs)
|
||||||
|
gamma = sum(l["qty"] * gk_gamma_vega(S0, l["K"], l["T"], r_d, r_f, sigma_r)[0] for l in legs)
|
||||||
|
dk = float(np.sqrt(np.pi / (4 * max(n_rebalances, 1))) * abs(vega) * sigma_r)
|
||||||
|
k = 2 * cost_bps / 1e4 # round-trip proportional cost
|
||||||
|
dt_h = T / max(n_rebalances, 1)
|
||||||
|
leland_num = np.sqrt(2 / np.pi) * k / (sigma_r * np.sqrt(dt_h)) if k > 0 else 0.0
|
||||||
|
sig_l = sigma_r * np.sqrt(1 + leland_num)
|
||||||
|
vl = portfolio_value_delta(np.array([S0]), legs, 0.0, r_d, r_f, sig_l)[0][0]
|
||||||
|
return {
|
||||||
|
"premium_at_implied": float(vi),
|
||||||
|
"value_at_realised": float(vr),
|
||||||
|
"theoretical_edge": float((vr - vi) * np.exp(r_d * T)),
|
||||||
|
"derman_kamal_sd": dk,
|
||||||
|
"leland_number": float(leland_num),
|
||||||
|
"leland_vol": float(sig_l),
|
||||||
|
"leland_cost": float(abs(vl - vr) * np.exp(r_d * T)),
|
||||||
|
"vega_1vol": float(vega / 100),
|
||||||
|
"dollar_gamma_1pct": float(0.5 * gamma * (0.01 * S0) ** 2),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def hedge_report(legs, S0, sigma_i, sigma_r, r_d, r_f, ppy, *, model="gbm", steps_per_day=1,
|
||||||
|
rule="time", band=0.1, cost_bps=0.0, n_paths=2000, jump=None,
|
||||||
|
weekend_gap=0.0, hist_returns=None, rescale=True, seed=7, start_weekday=0,
|
||||||
|
sweep=True):
|
||||||
|
"""Full P&L lab report."""
|
||||||
|
legs = [dict(l, T=float(l["T"]), K=float(l["K"]), qty=float(l["qty"])) for l in legs]
|
||||||
|
T = max(l["T"] for l in legs)
|
||||||
|
rng = np.random.default_rng(seed)
|
||||||
|
mu = r_d - r_f # risk-neutral drift (P&L vs implied)
|
||||||
|
|
||||||
|
def simulate(spd, paths, rng_):
|
||||||
|
dt, mondays, _ = _time_grid(T, ppy, spd, start_weekday)
|
||||||
|
inc = simulate_log_increments(model, paths, dt, sigma_r, rng_, mu=mu, jump=jump,
|
||||||
|
hist_returns=hist_returns, steps_per_day=spd,
|
||||||
|
rescale=rescale)
|
||||||
|
if weekend_gap > 0 and len(mondays):
|
||||||
|
inc[:, mondays] += weekend_gap * rng_.standard_normal((paths, len(mondays))) \
|
||||||
|
- 0.5 * weekend_gap ** 2
|
||||||
|
return inc, dt, mondays
|
||||||
|
|
||||||
|
inc, dt, mondays = simulate(steps_per_day, n_paths, rng)
|
||||||
|
res = run_hedge(legs, S0, sigma_i, r_d, r_f, inc, dt, rule=rule, band=band,
|
||||||
|
cost_bps=cost_bps)
|
||||||
|
n_reb = len(dt)
|
||||||
|
report = {
|
||||||
|
"inputs": {"model": model, "steps_per_day": steps_per_day, "rule": rule, "band": band,
|
||||||
|
"cost_bps": cost_bps, "n_paths": n_paths, "T": T, "n_steps": int(n_reb),
|
||||||
|
"periods_per_year": ppy, "weekend_gaps": int(len(mondays)),
|
||||||
|
"sigma_implied": sigma_i, "sigma_realised": sigma_r},
|
||||||
|
"pnl": _stats(res["pnl"]),
|
||||||
|
"attribution": {
|
||||||
|
"option": float(res["option_pnl"].mean()),
|
||||||
|
"hedge": float(res["hedge_pnl"].mean()),
|
||||||
|
"costs": float(-res["costs"].mean()),
|
||||||
|
"carry": float(res["carry"].mean()),
|
||||||
|
},
|
||||||
|
"trades_per_path": float(res["n_trades"].mean()),
|
||||||
|
"premium": res["premium"],
|
||||||
|
"benchmarks": benchmarks(legs, S0, sigma_i, sigma_r, r_d, r_f, n_reb, cost_bps),
|
||||||
|
"histogram": _histogram(res["pnl"]),
|
||||||
|
"sample_paths": res["sample_spots"][:, :: max(1, res["sample_spots"].shape[1] // 200)].tolist()
|
||||||
|
if res["sample_spots"] is not None else [],
|
||||||
|
}
|
||||||
|
if sweep:
|
||||||
|
rows = []
|
||||||
|
for spd in (1, 2, 4, 8, 24):
|
||||||
|
sub = np.random.default_rng(seed + spd)
|
||||||
|
i2, d2, m2 = simulate(spd, min(n_paths, 1000), sub)
|
||||||
|
r2 = run_hedge(legs, S0, sigma_i, r_d, r_f, i2, d2, rule="time",
|
||||||
|
cost_bps=cost_bps, keep_paths=0)
|
||||||
|
st = _stats(r2["pnl"])
|
||||||
|
rows.append({"steps_per_day": spd, "mean": st["mean"], "std": st["std"],
|
||||||
|
"es05": st["es05"], "cost": float(r2["costs"].mean()),
|
||||||
|
"derman_kamal_sd": benchmarks(legs, S0, sigma_i, sigma_r, r_d, r_f,
|
||||||
|
len(d2), cost_bps)["derman_kamal_sd"]})
|
||||||
|
report["frequency_sweep"] = rows
|
||||||
|
return _clean(report)
|
||||||
|
|
||||||
|
|
||||||
|
def _histogram(x, bins=40):
|
||||||
|
lo, hi = np.quantile(x, [0.005, 0.995])
|
||||||
|
if hi <= lo:
|
||||||
|
hi = lo + 1e-9
|
||||||
|
counts, edges = np.histogram(np.clip(x, lo, hi), bins=bins, range=(lo, hi))
|
||||||
|
return {"centers": (0.5 * (edges[1:] + edges[:-1])).tolist(), "counts": counts.tolist()}
|
||||||
|
|
||||||
|
|
||||||
|
def _clean(obj):
|
||||||
|
if isinstance(obj, (float, np.floating)):
|
||||||
|
x = float(obj)
|
||||||
|
return round(x, 8) if np.isfinite(x) else None
|
||||||
|
if isinstance(obj, (np.integer,)):
|
||||||
|
return int(obj)
|
||||||
|
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
|
||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from .routers import greeks, surface, market, montecarlo, scenarios, strategies, export, forex, institutional, news, livedata, smile, vol
|
from .routers import greeks, surface, market, montecarlo, scenarios, strategies, export, forex, institutional, news, livedata, smile, vol, hedge
|
||||||
|
|
||||||
app = FastAPI(title="QuantRisk FX Terminal API",
|
app = FastAPI(title="QuantRisk FX Terminal API",
|
||||||
description="Forex options risk analytics — Garman-Kohlhagen model.",
|
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=["*"],
|
app.add_middleware(CORSMiddleware, allow_origins=["*"],
|
||||||
allow_credentials=True, allow_methods=["*"], allow_headers=["*"])
|
allow_credentials=True, allow_methods=["*"], allow_headers=["*"])
|
||||||
|
|
||||||
for router in [greeks, surface, market, montecarlo, scenarios, strategies, export, forex, institutional, news, livedata, smile, vol]:
|
for router in [greeks, surface, market, montecarlo, scenarios, strategies, export, forex, institutional, news, livedata, smile, vol, hedge]:
|
||||||
app.include_router(router.router, prefix="/api")
|
app.include_router(router.router, prefix="/api")
|
||||||
|
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
from fastapi import APIRouter, HTTPException
|
||||||
|
|
||||||
|
from ..core.hedge_sim import hedge_report
|
||||||
|
from ..schemas import HedgeSimRequest
|
||||||
|
from .forex import PAIRS
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/hedge", tags=["hedge"])
|
||||||
|
|
||||||
|
MAX_WORK = 2.5e7 # paths × steps cap to keep a request within a few seconds
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/simulate")
|
||||||
|
def simulate_hedge(req: HedgeSimRequest):
|
||||||
|
"""Monte Carlo delta-hedging / gamma-scalping P&L lab for the current portfolio."""
|
||||||
|
pair = req.pair.upper()
|
||||||
|
meta = PAIRS.get(pair)
|
||||||
|
if meta is None:
|
||||||
|
raise HTTPException(404, f"Unknown pair '{pair}'.")
|
||||||
|
if not req.options:
|
||||||
|
raise HTTPException(422, "Add at least one option leg.")
|
||||||
|
ppy = meta["periods_per_year"]
|
||||||
|
T = max(o.T for o in req.options)
|
||||||
|
if T > 2:
|
||||||
|
raise HTTPException(422, "Longest leg must expire within 2 years.")
|
||||||
|
work = req.n_paths * max(1, round(T * ppy)) * req.steps_per_day
|
||||||
|
if work > MAX_WORK:
|
||||||
|
raise HTTPException(422, "Too many paths × steps — lower paths, hedge frequency or tenor.")
|
||||||
|
|
||||||
|
hist = None
|
||||||
|
if req.model == "bootstrap":
|
||||||
|
from . import vol # reuse the cached 2y daily closes
|
||||||
|
import numpy as np
|
||||||
|
try:
|
||||||
|
load = (lambda: vol._crypto_closes(pair[:3])) if meta["asset_class"] == "crypto" \
|
||||||
|
else (lambda: vol._yf_closes(meta["sym"]))
|
||||||
|
closes = vol._cached(f"closes:{pair}", 900, load)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(503, f"Price history unavailable for bootstrap: {e}")
|
||||||
|
if len(closes) < 120:
|
||||||
|
vol._cache.pop(f"closes:{pair}", None)
|
||||||
|
raise HTTPException(503, "Not enough price history to bootstrap.")
|
||||||
|
hist = np.diff(np.log(np.asarray(closes, dtype=float)))
|
||||||
|
|
||||||
|
jump = {"lambda": req.jump.intensity, "mean": req.jump.mean, "sd": req.jump.sd}
|
||||||
|
try:
|
||||||
|
report = hedge_report(
|
||||||
|
[o.model_dump() for o in req.options], req.S, req.sigma_implied, req.sigma_realised,
|
||||||
|
req.r_d, req.r_f, ppy, model=req.model, steps_per_day=req.steps_per_day,
|
||||||
|
rule=req.rule, band=req.band, cost_bps=req.cost_bps, n_paths=req.n_paths,
|
||||||
|
jump=jump, weekend_gap=req.weekend_gap if ppy != 365 else 0.0,
|
||||||
|
hist_returns=hist, rescale=req.rescale_bootstrap, sweep=req.sweep)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(422, str(e))
|
||||||
|
return {"pair": pair, "asset_class": meta["asset_class"], **report}
|
||||||
@@ -76,3 +76,29 @@ class SmileRequest(BaseModel):
|
|||||||
bf25: float = Field(default=0.0, gt=-1, lt=1, description="25Δ butterfly")
|
bf25: float = Field(default=0.0, gt=-1, lt=1, description="25Δ butterfly")
|
||||||
sigma: float | None = Field(default=None, gt=0, lt=5, description="Flat vol to compare against (defaults to ATM)")
|
sigma: float | None = Field(default=None, gt=0, lt=5, description="Flat vol to compare against (defaults to ATM)")
|
||||||
options: list[OptionLeg] = Field(default_factory=list)
|
options: list[OptionLeg] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class JumpParams(BaseModel):
|
||||||
|
intensity: float = Field(default=12.0, ge=0, le=365, description="Jumps per year")
|
||||||
|
mean: float = Field(default=-0.01, gt=-0.5, lt=0.5, description="Mean log jump size")
|
||||||
|
sd: float = Field(default=0.05, ge=0, lt=1, description="Log jump size st. dev.")
|
||||||
|
|
||||||
|
|
||||||
|
class HedgeSimRequest(BaseModel):
|
||||||
|
pair: str = Field(default="EURUSD", description="Selects the calendar (365 crypto / 252 otherwise) and bootstrap history")
|
||||||
|
options: list[OptionLeg]
|
||||||
|
S: float = Field(..., gt=0)
|
||||||
|
sigma_implied: float = Field(..., gt=0, lt=5, description="Vol the options are traded and hedged at")
|
||||||
|
sigma_realised: float = Field(..., gt=0, lt=5, description="Vol the market actually delivers")
|
||||||
|
r_d: float = Field(default=0.0525)
|
||||||
|
r_f: float = Field(default=0.0400)
|
||||||
|
model: Literal["gbm", "jump", "bootstrap"] = "gbm"
|
||||||
|
steps_per_day: Literal[1, 2, 4, 8, 24] = 1
|
||||||
|
rule: Literal["time", "band"] = "time"
|
||||||
|
band: float = Field(default=0.05, gt=0, le=1, description="No-trade band, delta per unit of option notional")
|
||||||
|
cost_bps: float = Field(default=0.0, ge=0, le=100, description="One-way cost, bps of traded notional")
|
||||||
|
n_paths: int = Field(default=2000, ge=200, le=5000)
|
||||||
|
jump: JumpParams = Field(default_factory=JumpParams)
|
||||||
|
weekend_gap: float = Field(default=0.0, ge=0, lt=0.5, description="Weekend-gap sd (fraction of spot), 24/5 assets")
|
||||||
|
rescale_bootstrap: bool = True
|
||||||
|
sweep: bool = True
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
"""Validation of the delta-hedging P&L lab against closed-form results.
|
||||||
|
|
||||||
|
Run from backend/: python -m pytest tests -q
|
||||||
|
"""
|
||||||
|
import numpy as np
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from app.core.garman_kohlhagen import gk_price
|
||||||
|
from app.core.hedge_sim import gk_price_delta, hedge_report
|
||||||
|
from app.main import app
|
||||||
|
from app.routers import vol as vol_router
|
||||||
|
|
||||||
|
S0, RD, RF = 83000.0, 0.039, 0.0
|
||||||
|
ATM_CALL = [{"type": "call", "K": 83000, "T": 30 / 365, "qty": 1}]
|
||||||
|
|
||||||
|
|
||||||
|
def _run(**kw):
|
||||||
|
args = dict(legs=ATM_CALL, S0=S0, sigma_i=0.40, sigma_r=0.40, r_d=RD, r_f=RF, ppy=365,
|
||||||
|
n_paths=4000, sweep=False)
|
||||||
|
args.update(kw)
|
||||||
|
legs, s, si, sr, rd, rf, ppy = (args.pop(k) for k in
|
||||||
|
("legs", "S0", "sigma_i", "sigma_r", "r_d", "r_f", "ppy"))
|
||||||
|
return hedge_report(legs, s, si, sr, rd, rf, ppy, **args)
|
||||||
|
|
||||||
|
|
||||||
|
def test_pricing_matches_gk_module_and_parity():
|
||||||
|
for K in (70000, 83000, 95000):
|
||||||
|
c, _ = gk_price_delta(np.array([S0]), K, 0.25, RD, 0.01, 0.4, True)
|
||||||
|
p, _ = gk_price_delta(np.array([S0]), K, 0.25, RD, 0.01, 0.4, False)
|
||||||
|
assert abs(c[0] - gk_price(S0, K, 0.25, RD, 0.01, 0.4, "call")) < 1e-8
|
||||||
|
assert abs(c[0] - p[0] - (S0 * np.exp(-0.01 * 0.25) - K * np.exp(-RD * 0.25))) < 1e-6
|
||||||
|
|
||||||
|
|
||||||
|
def test_fair_vol_hedge_is_unbiased_with_derman_kamal_error():
|
||||||
|
for spd in (1, 4):
|
||||||
|
r = _run(steps_per_day=spd)
|
||||||
|
se = r["pnl"]["std"] / np.sqrt(4000)
|
||||||
|
assert abs(r["pnl"]["mean"]) < 4 * se
|
||||||
|
assert abs(r["pnl"]["std"] / r["benchmarks"]["derman_kamal_sd"] - 1) < 0.1
|
||||||
|
|
||||||
|
|
||||||
|
def test_mean_pnl_tracks_theoretical_vol_edge():
|
||||||
|
for sr in (0.30, 0.50):
|
||||||
|
r = _run(sigma_r=sr, steps_per_day=4)
|
||||||
|
edge = r["benchmarks"]["theoretical_edge"]
|
||||||
|
assert abs(r["pnl"]["mean"] - edge) < 0.08 * abs(edge)
|
||||||
|
|
||||||
|
|
||||||
|
def test_frequency_sweep_trades_risk_for_costs():
|
||||||
|
r = hedge_report(ATM_CALL, S0, 0.4, 0.4, RD, RF, 365, n_paths=1500, cost_bps=3)
|
||||||
|
rows = r["frequency_sweep"]
|
||||||
|
stds = [x["std"] for x in rows]
|
||||||
|
costs = [x["cost"] for x in rows]
|
||||||
|
assert all(a > b for a, b in zip(stds, stds[1:])) # hedge more → less noise
|
||||||
|
assert all(a < b for a, b in zip(costs, costs[1:])) # …but more cost
|
||||||
|
assert stds[0] / stds[2] > 1.6 # ≈ √4 scaling
|
||||||
|
|
||||||
|
|
||||||
|
def test_band_rule_and_costs():
|
||||||
|
t = _run(steps_per_day=4, cost_bps=5)
|
||||||
|
b = _run(steps_per_day=4, cost_bps=5, rule="band", band=0.05)
|
||||||
|
assert b["trades_per_path"] < t["trades_per_path"]
|
||||||
|
assert -b["attribution"]["costs"] < -t["attribution"]["costs"]
|
||||||
|
lel = t["benchmarks"]["leland_cost"]
|
||||||
|
assert 0.5 * lel < -t["attribution"]["costs"] < 2 * lel
|
||||||
|
|
||||||
|
|
||||||
|
def test_gaps_and_jumps_fatten_the_tail():
|
||||||
|
xag = [{"type": "call", "K": 64.5, "T": 21 / 252, "qty": 100}]
|
||||||
|
base = _run(legs=xag, S0=64.5, r_d=0.039, r_f=0.02, ppy=252)
|
||||||
|
gap = _run(legs=xag, S0=64.5, r_d=0.039, r_f=0.02, ppy=252, weekend_gap=0.02)
|
||||||
|
assert gap["inputs"]["weekend_gaps"] >= 3 and gap["pnl"]["std"] > base["pnl"]["std"]
|
||||||
|
assert _run()["inputs"]["weekend_gaps"] == 0 # crypto trades 24/7
|
||||||
|
jump = _run(model="jump", jump={"lambda": 12, "mean": -0.01, "sd": 0.06})
|
||||||
|
assert jump["pnl"]["es05"] < _run()["pnl"]["es05"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_endpoint(monkeypatch):
|
||||||
|
rng = np.random.default_rng(1)
|
||||||
|
closes = list(83000 * np.exp(np.cumsum(rng.standard_t(3, 700) * 0.02)))
|
||||||
|
monkeypatch.setattr(vol_router, "_crypto_closes", lambda ccy: closes)
|
||||||
|
vol_router._cache.clear()
|
||||||
|
c = TestClient(app)
|
||||||
|
body = {"pair": "BTCUSD", "options": [{"type": "call", "K": 83000, "T": 0.0822, "qty": 1}],
|
||||||
|
"S": 83000, "sigma_implied": 0.4, "sigma_realised": 0.45, "r_d": 0.039, "r_f": 0,
|
||||||
|
"steps_per_day": 4, "cost_bps": 2, "n_paths": 1000}
|
||||||
|
r = c.post("/api/hedge/simulate", json=body)
|
||||||
|
assert r.status_code == 200
|
||||||
|
d = r.json()
|
||||||
|
assert d["inputs"]["periods_per_year"] == 365 and len(d["frequency_sweep"]) == 5
|
||||||
|
assert d["benchmarks"]["theoretical_edge"] > 0 and len(d["histogram"]["counts"]) == 40
|
||||||
|
assert c.post("/api/hedge/simulate", json={**body, "model": "bootstrap"}).status_code == 200
|
||||||
|
assert c.post("/api/hedge/simulate", json={**body, "pair": "NOPE"}).status_code == 404
|
||||||
|
assert c.post("/api/hedge/simulate", json={**body, "options": []}).status_code == 422
|
||||||
|
assert c.post("/api/hedge/simulate", json={**body, "steps_per_day": 3}).status_code == 422
|
||||||
@@ -26,6 +26,9 @@ export const computeSmile = (p) => api.post('/smile', p).then(r=>
|
|||||||
export const fetchVolCone = (pair) => api.get(`/vol/cone/${pair}`).then(r=>r.data)
|
export const fetchVolCone = (pair) => api.get(`/vol/cone/${pair}`).then(r=>r.data)
|
||||||
export const fetchCryptoVol = (currency) => api.get(`/vol/crypto/${currency}`).then(r=>r.data)
|
export const fetchCryptoVol = (currency) => api.get(`/vol/crypto/${currency}`).then(r=>r.data)
|
||||||
export const fetchVolForecast = (pair) => api.get(`/vol/forecast/${pair}`).then(r=>r.data)
|
export const fetchVolForecast = (pair) => api.get(`/vol/forecast/${pair}`).then(r=>r.data)
|
||||||
|
|
||||||
|
// Hedge Lab
|
||||||
|
export const simulateHedge = (p) => api.post('/hedge/simulate', p).then(r=>r.data)
|
||||||
export const exportCSV = (p) => api.post('/export/csv', p, {responseType:'blob'}).then(r=>r.data)
|
export const exportCSV = (p) => api.post('/export/csv', p, {responseType:'blob'}).then(r=>r.data)
|
||||||
|
|
||||||
// Institutional flow
|
// Institutional flow
|
||||||
|
|||||||
@@ -0,0 +1,305 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import _Plot from 'react-plotly.js'
|
||||||
|
const Plot = _Plot?.default ?? _Plot
|
||||||
|
import { usePortfolioStore, PAIR_META, isCrypto } from '../store/portfolio'
|
||||||
|
import { simulateHedge, fetchVolForecast } from '../api/client'
|
||||||
|
|
||||||
|
const LAYOUT = {
|
||||||
|
paper_bgcolor:'transparent', plot_bgcolor:'#080d18',
|
||||||
|
font:{ color:'#94a3b8', size:11 },
|
||||||
|
margin:{ l:56, r:16, t:12, b:44 },
|
||||||
|
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 axis = (title, extra = {}) => ({ title:{ text:title }, gridcolor:'#1e293b', color:'#475569', zeroline:false, ...extra })
|
||||||
|
const FREQS = [[1, 'Daily'], [2, '2× / day'], [4, '4× / day'], [8, '8× / day'], [24, 'Hourly']]
|
||||||
|
const MODELS = [['gbm', 'Lognormal (GBM)'], ['jump', 'Jump-diffusion (Merton)'], ['bootstrap', 'Historical bootstrap']]
|
||||||
|
|
||||||
|
const defaultsFor = (pair, sigma) => ({
|
||||||
|
sigma_realised: +(sigma * 100).toFixed(2),
|
||||||
|
model: isCrypto(pair) ? 'jump' : 'gbm',
|
||||||
|
steps_per_day: isCrypto(pair) ? 4 : 1,
|
||||||
|
rule: 'time', band: 0.05, cost_bps: isCrypto(pair) ? 3 : 1, n_paths: 2000,
|
||||||
|
jump: { intensity: 12, mean: -1, sd: 5 }, // % units in the UI
|
||||||
|
weekend_gap: PAIR_META[pair]?.assetClass === 'metal' ? 1.0 : 0.3,
|
||||||
|
rescale_bootstrap: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
export default function HedgeLab() {
|
||||||
|
const { legs, S, sigma, r_d, r_f, pair } = usePortfolioStore()
|
||||||
|
const [cfg, setCfg] = useState(() => ({ pair, ...defaultsFor(pair, sigma) }))
|
||||||
|
const [run, setRun] = useState(0)
|
||||||
|
const [response, setResponse] = useState({ key:null, data:null, error:null })
|
||||||
|
const [fcNote, setFcNote] = useState(null)
|
||||||
|
// Reset scenario defaults when the instrument changes (derived, no effect needed).
|
||||||
|
const params = cfg.pair === pair ? cfg : { pair, ...defaultsFor(pair, sigma) }
|
||||||
|
const set = (k, v) => setCfg({ ...params, [k]: v })
|
||||||
|
const crypto = isCrypto(pair)
|
||||||
|
const maxT = legs.length ? Math.max(...legs.map(l => +l.T)) : 0
|
||||||
|
|
||||||
|
const body = {
|
||||||
|
pair, S, r_d, r_f,
|
||||||
|
options: legs.map(({ type, K, T, qty }) => ({ type, K:+K, T:+T, qty:+qty })),
|
||||||
|
sigma_implied: sigma, sigma_realised: params.sigma_realised / 100,
|
||||||
|
model: params.model, steps_per_day: params.steps_per_day, rule: params.rule, band: params.band,
|
||||||
|
cost_bps: params.cost_bps, n_paths: params.n_paths,
|
||||||
|
jump: { intensity: params.jump.intensity, mean: params.jump.mean / 100, sd: params.jump.sd / 100 },
|
||||||
|
weekend_gap: crypto ? 0 : params.weekend_gap / 100, rescale_bootstrap: params.rescale_bootstrap,
|
||||||
|
}
|
||||||
|
const requestKey = `${run}:${JSON.stringify(body)}`
|
||||||
|
const [submitted, setSubmitted] = useState(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!submitted) return
|
||||||
|
let stale = false
|
||||||
|
simulateHedge(JSON.parse(submitted.slice(submitted.indexOf(':') + 1)))
|
||||||
|
.then(data => { if (!stale) setResponse({ key:submitted, data, error:null }) })
|
||||||
|
.catch(e => { if (!stale) setResponse({ key:submitted, data:null,
|
||||||
|
error: e?.response?.data?.detail?.toString() ?? e?.message ?? 'Simulation failed' }) })
|
||||||
|
return () => { stale = true }
|
||||||
|
}, [submitted])
|
||||||
|
|
||||||
|
const loading = submitted !== null && response.key !== submitted
|
||||||
|
const d = response.data?.pair === pair ? response.data : null
|
||||||
|
const stale = d && response.key !== requestKey
|
||||||
|
const go = () => { setRun(r => r + 1); setSubmitted(`${run + 1}:${JSON.stringify(body)}`) }
|
||||||
|
|
||||||
|
const loadForecast = async () => {
|
||||||
|
setFcNote('Loading Vol Forecast…')
|
||||||
|
try {
|
||||||
|
const f = await fetchVolForecast(pair)
|
||||||
|
const days = maxT * (crypto ? 365 : 252)
|
||||||
|
const h = days <= 3 ? '1D' : days <= 14 ? '1W' : '1M'
|
||||||
|
const v = f.current[h].Ensemble
|
||||||
|
set('sigma_realised', +(v * 100).toFixed(2))
|
||||||
|
setFcNote(`Realised vol set to the ${h} ensemble forecast (${(v * 100).toFixed(1)}%)`)
|
||||||
|
} catch (e) {
|
||||||
|
setFcNote(`Vol Forecast unavailable: ${e?.response?.data?.detail ?? e.message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding:16 }}>
|
||||||
|
<div style={{ marginBottom:10 }}>
|
||||||
|
<div className="section-title">Hedge Lab — delta-hedged P&L of your portfolio ({pair})</div>
|
||||||
|
<div style={{ fontSize:9, color:'var(--text-muted)', marginTop:2 }}>
|
||||||
|
Options traded & hedged at implied σ = {(sigma * 100).toFixed(2)}% · {legs.length} leg(s), longest {maxT}y ·
|
||||||
|
Monte Carlo with discrete hedging, {crypto ? 'jumps' : 'weekend gaps, jumps'}, transaction costs and carry
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Controls params={params} set={set} crypto={crypto} onRun={go} loading={loading}
|
||||||
|
onForecast={loadForecast} fcNote={fcNote} />
|
||||||
|
|
||||||
|
{response.error && response.key === submitted && (
|
||||||
|
<div style={{ padding:8, borderRadius:6, margin:'8px 0', fontSize:11,
|
||||||
|
background:'rgba(255,61,90,0.1)', border:'1px solid #ff3d5a', color:'#ff3d5a' }}>{response.error}</div>
|
||||||
|
)}
|
||||||
|
{!d && !loading && !response.error && (
|
||||||
|
<div style={{ padding:24, textAlign:'center', color:'var(--text-muted)', fontSize:12 }}>
|
||||||
|
Set the realised-vol scenario and hedging rule, then press <b style={{ color:'var(--cyan)' }}>Run simulation</b>.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{loading && !d && <div className="skeleton" style={{ height:340, borderRadius:8, marginTop:10 }}/>}
|
||||||
|
|
||||||
|
{d && (
|
||||||
|
<div style={{ opacity: loading ? 0.6 : 1, marginTop:10 }}>
|
||||||
|
{stale && <div style={{ fontSize:10, color:'var(--amber)', marginBottom:6 }}>Inputs changed — re-run to update.</div>}
|
||||||
|
<Kpis d={d} />
|
||||||
|
<div style={{ display:'grid', gridTemplateColumns:'repeat(auto-fit, minmax(380px, 1fr))', gap:12, marginBottom:12 }}>
|
||||||
|
<Histogram d={d} />
|
||||||
|
<Sweep d={d} />
|
||||||
|
</div>
|
||||||
|
<div style={{ display:'grid', gridTemplateColumns:'repeat(auto-fit, minmax(380px, 1fr))', gap:12 }}>
|
||||||
|
<Attribution d={d} />
|
||||||
|
<Paths d={d} />
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize:9, color:'var(--text-muted)', marginTop:10, lineHeight:1.5 }}>
|
||||||
|
Delta-hedged P&L ≈ ½ΓS²(σ²<sub>realised</sub> − σ²<sub>implied</sub>)dt, plus discrete-hedging noise
|
||||||
|
(Derman & Kamal 1999: sd ≈ √(π/4N)·vega·σ), jump/gap losses no hedge can follow, and costs
|
||||||
|
(Leland 1985). Jump-diffusion keeps total variance equal to the realised vol (Merton 1976); the bootstrap
|
||||||
|
resamples the asset's own daily returns in blocks (Politis & Romano 1994). Theoretical edge = value at
|
||||||
|
realised minus premium at implied (Ahmad & Wilmott 2005).
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Field({ label, children, hint }) {
|
||||||
|
return (
|
||||||
|
<label title={hint} style={{ display:'flex', flexDirection:'column', gap:3, fontSize:9, color:'var(--text-muted)' }}>
|
||||||
|
{label}{children}
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputStyle = { width:'100%', background:'var(--bg3)', border:'1px solid var(--border)', borderRadius:4,
|
||||||
|
color:'var(--cyan)', fontSize:11, padding:'4px 6px', fontFamily:'var(--font-mono)' }
|
||||||
|
|
||||||
|
function Num({ value, onChange, step = 0.1, min, max }) {
|
||||||
|
return <input type="number" style={inputStyle} value={value} step={step} min={min} max={max}
|
||||||
|
onChange={e => onChange(e.target.value === '' ? '' : +e.target.value)} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function Controls({ params, set, crypto, onRun, loading, onForecast, fcNote }) {
|
||||||
|
return (
|
||||||
|
<div className="glass" style={{ borderRadius:8, padding:10 }}>
|
||||||
|
<div style={{ display:'grid', gridTemplateColumns:'repeat(auto-fill, minmax(130px, 1fr))', gap:10, alignItems:'end' }}>
|
||||||
|
<Field label="Realised σ (%)" hint="Volatility the market actually delivers over the life of the trade">
|
||||||
|
<Num value={params.sigma_realised} onChange={v => set('sigma_realised', v)} step={0.5} min={1} />
|
||||||
|
</Field>
|
||||||
|
<Field label="Path model">
|
||||||
|
<select style={inputStyle} value={params.model} onChange={e => set('model', e.target.value)}>
|
||||||
|
{MODELS.map(([v, l]) => <option key={v} value={v}>{l}</option>)}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
<Field label="Hedge frequency">
|
||||||
|
<select style={inputStyle} value={params.steps_per_day} onChange={e => set('steps_per_day', +e.target.value)}>
|
||||||
|
{FREQS.map(([v, l]) => <option key={v} value={v}>{l}</option>)}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
<Field label="Hedge rule">
|
||||||
|
<select style={inputStyle} value={params.rule} onChange={e => set('rule', e.target.value)}>
|
||||||
|
<option value="time">Every step</option>
|
||||||
|
<option value="band">Delta band</option>
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
{params.rule === 'band' && (
|
||||||
|
<Field label="Band (Δ per unit)" hint="Rebalance only when off target by more than this delta per option unit">
|
||||||
|
<Num value={params.band} onChange={v => set('band', v)} step={0.01} min={0.01} max={1} />
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
<Field label="Cost (bps one-way)"><Num value={params.cost_bps} onChange={v => set('cost_bps', v)} step={0.5} min={0} /></Field>
|
||||||
|
<Field label="Paths"><Num value={params.n_paths} onChange={v => set('n_paths', v)} step={500} min={200} max={5000} /></Field>
|
||||||
|
{params.model === 'jump' && <>
|
||||||
|
<Field label="Jumps / year"><Num value={params.jump.intensity} onChange={v => set('jump', { ...params.jump, intensity:v })} step={1} min={0} /></Field>
|
||||||
|
<Field label="Mean jump (%)"><Num value={params.jump.mean} onChange={v => set('jump', { ...params.jump, mean:v })} step={0.5} /></Field>
|
||||||
|
<Field label="Jump sd (%)"><Num value={params.jump.sd} onChange={v => set('jump', { ...params.jump, sd:v })} step={0.5} min={0} /></Field>
|
||||||
|
</>}
|
||||||
|
{params.model === 'bootstrap' && (
|
||||||
|
<Field label="Scale to realised σ">
|
||||||
|
<select style={inputStyle} value={params.rescale_bootstrap ? 'y' : 'n'} onChange={e => set('rescale_bootstrap', e.target.value === 'y')}>
|
||||||
|
<option value="y">Yes (shape only)</option><option value="n">No (raw history)</option>
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
{!crypto && (
|
||||||
|
<Field label="Weekend gap sd (%)" hint="Monday-open gap the hedge cannot trade through (24/5 markets)">
|
||||||
|
<Num value={params.weekend_gap} onChange={v => set('weekend_gap', v)} step={0.1} min={0} />
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
<button className="btn-primary" onClick={onRun} disabled={loading} style={{ padding:'7px 10px', fontSize:11 }}>
|
||||||
|
{loading ? '⟳ Simulating…' : '▶ Run simulation'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div style={{ display:'flex', gap:8, alignItems:'center', marginTop:8 }}>
|
||||||
|
<button className="btn-ghost" onClick={onForecast} style={{ fontSize:10 }}>🤖 Use Vol Forecast for realised σ</button>
|
||||||
|
{fcNote && <span style={{ fontSize:10, color:'var(--text-muted)' }}>{fcNote}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const fmt = (v, dp = 2) => (v == null ? '—' : v.toLocaleString(undefined, { maximumFractionDigits: dp, minimumFractionDigits: dp }))
|
||||||
|
|
||||||
|
function Kpis({ d }) {
|
||||||
|
const p = d.pnl, b = d.benchmarks
|
||||||
|
const se = p.std / Math.sqrt(d.inputs.n_paths)
|
||||||
|
const items = [
|
||||||
|
{ label:'Premium (at implied)', value: fmt(d.premium) },
|
||||||
|
{ label:'Mean P&L', value: fmt(p.mean), sub:`± ${fmt(se)} (1 se)`, color: p.mean >= 0 ? 'var(--green)' : 'var(--red)' },
|
||||||
|
{ label:'Theoretical edge', value: fmt(b.theoretical_edge), sub:'continuous hedge at realised' },
|
||||||
|
{ label:'P&L st. dev.', value: fmt(p.std), sub:`Derman-Kamal (GBM) ${fmt(b.derman_kamal_sd)}` },
|
||||||
|
{ label:'5% ES', value: fmt(p.es05), color:'var(--red)', sub:`5% VaR ${fmt(p.p05)}` },
|
||||||
|
{ label:'P(profit)', value: `${(p.prob_profit * 100).toFixed(0)}%` },
|
||||||
|
{ label:'Hedge costs', value: fmt(-d.attribution.costs), sub:`Leland (GBM) ≈ ${fmt(b.leland_cost)}` },
|
||||||
|
{ label:'Trades / path', value: d.trades_per_path.toFixed(0), sub:`${d.inputs.n_steps} steps${d.inputs.weekend_gaps ? ` · ${d.inputs.weekend_gaps} weekend gaps` : ''}` },
|
||||||
|
]
|
||||||
|
return (
|
||||||
|
<div style={{ display:'flex', gap:8, marginBottom:12, flexWrap:'wrap' }}>
|
||||||
|
{items.map(k => (
|
||||||
|
<div key={k.label} className="glass" style={{ padding:'6px 12px', borderRadius:6, fontSize:11 }}>
|
||||||
|
<div style={{ color:'var(--text-muted)', fontSize:9, letterSpacing:'0.05em' }}>{k.label}</div>
|
||||||
|
<div className="font-mono" style={{ color: k.color ?? 'var(--cyan)', fontWeight:700, fontSize:14 }}>{k.value}</div>
|
||||||
|
{k.sub && <div className="font-mono" style={{ fontSize:9, color:'var(--text-muted)' }}>{k.sub}</div>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Card({ title, children }) {
|
||||||
|
return (
|
||||||
|
<div className="glass" style={{ borderRadius:8, padding:8 }}>
|
||||||
|
<div className="section-title" style={{ fontSize:10, margin:'2px 0 4px 6px' }}>{title}</div>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Histogram({ d }) {
|
||||||
|
const h = d.histogram
|
||||||
|
const vline = (x, color, dash) => ({ type:'line', x0:x, x1:x, yref:'paper', y0:0, y1:1, line:{ color, width:1.5, dash } })
|
||||||
|
return (
|
||||||
|
<Card title={`P&L distribution · ${d.inputs.n_paths} paths`}>
|
||||||
|
<Plot data={[{ x:h.centers, y:h.counts, type:'bar', name:'Paths',
|
||||||
|
marker:{ color:h.centers.map(c => c >= 0 ? 'rgba(0,255,136,0.55)' : 'rgba(255,61,90,0.55)') } }]}
|
||||||
|
layout={{ ...LAYOUT, showlegend:false, bargap:0.05, xaxis:axis('P&L at expiry'), yaxis:axis('Paths'),
|
||||||
|
shapes:[vline(0, 'rgba(255,255,255,0.35)', 'dot'), vline(d.pnl.mean, '#00d4ff', 'solid'),
|
||||||
|
vline(d.benchmarks.theoretical_edge, '#ffd700', 'dash')],
|
||||||
|
annotations:[{ x:d.pnl.mean, yref:'paper', y:1, text:'mean', showarrow:false, font:{ color:'#00d4ff', size:9 } },
|
||||||
|
{ x:d.benchmarks.theoretical_edge, yref:'paper', y:0.92, text:'edge', showarrow:false, font:{ color:'#ffd700', size:9 } }] }}
|
||||||
|
config={{ responsive:true, displayModeBar:false }} style={{ width:'100%', height:280 }} />
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Sweep({ d }) {
|
||||||
|
const s = d.frequency_sweep ?? []
|
||||||
|
if (!s.length) return null
|
||||||
|
const x = s.map(r => r.steps_per_day)
|
||||||
|
return (
|
||||||
|
<Card title="Hedge frequency: risk vs cost (time-based rule)">
|
||||||
|
<Plot data={[
|
||||||
|
{ x, y:s.map(r => r.std), type:'scatter', mode:'lines+markers', name:'P&L st. dev.', line:{ color:'#00d4ff', width:2 } },
|
||||||
|
{ x, y:s.map(r => r.derman_kamal_sd), type:'scatter', mode:'lines', name:'Derman-Kamal (GBM)', line:{ color:'#00d4ff', dash:'dot', width:1 } },
|
||||||
|
{ x, y:s.map(r => r.cost), type:'scatter', mode:'lines+markers', name:'Mean hedge cost', yaxis:'y2', line:{ color:'#ff3d5a', width:2 } },
|
||||||
|
{ x, y:s.map(r => -r.es05), type:'scatter', mode:'lines', name:'−5% ES', line:{ color:'#ffd700', width:1.2 } },
|
||||||
|
]}
|
||||||
|
layout={{ ...LAYOUT, xaxis:axis('Hedges per day', { type:'log', tickvals:x, ticktext:x.map(String) }),
|
||||||
|
yaxis:axis('Risk'), yaxis2:{ ...axis('Cost'), overlaying:'y', side:'right', showgrid:false },
|
||||||
|
margin:{ ...LAYOUT.margin, r:52 } }}
|
||||||
|
config={{ responsive:true, displayModeBar:false }} style={{ width:'100%', height:280 }} />
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Attribution({ d }) {
|
||||||
|
const a = d.attribution
|
||||||
|
const items = [['Options (payoff − premium)', a.option], ['Delta hedge (incl. financing)', a.hedge - a.carry],
|
||||||
|
['Carry on hedge (r_f / lease)', a.carry], ['Transaction costs', a.costs]]
|
||||||
|
return (
|
||||||
|
<Card title="Mean P&L attribution">
|
||||||
|
<Plot data={[{ type:'bar', orientation:'h', y:items.map(i => i[0]), x:items.map(i => i[1]),
|
||||||
|
marker:{ color:items.map(i => i[1] >= 0 ? 'rgba(0,255,136,0.6)' : 'rgba(255,61,90,0.6)') },
|
||||||
|
text:items.map(i => fmt(i[1])), textposition:'auto' }]}
|
||||||
|
layout={{ ...LAYOUT, showlegend:false, margin:{ ...LAYOUT.margin, l:190 }, xaxis:axis(''), yaxis:axis('', { automargin:true }) }}
|
||||||
|
config={{ responsive:true, displayModeBar:false }} style={{ width:'100%', height:240 }} />
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Paths({ d }) {
|
||||||
|
const paths = d.sample_paths ?? []
|
||||||
|
if (!paths.length) return null
|
||||||
|
return (
|
||||||
|
<Card title="Sample simulated spot paths">
|
||||||
|
<Plot data={paths.map((p, i) => ({ y:p, type:'scatter', mode:'lines', name:`path ${i + 1}`,
|
||||||
|
line:{ width:1, color:`hsla(${190 + i * 18}, 90%, 60%, 0.8)` } }))}
|
||||||
|
layout={{ ...LAYOUT, showlegend:false, xaxis:axis('Time →', { showticklabels:false }), yaxis:axis('Spot') }}
|
||||||
|
config={{ responsive:true, displayModeBar:false }} style={{ width:'100%', height:240 }} />
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import EconomicCalendar from '../components/EconomicCalendar'
|
|||||||
import VolSmile from '../components/VolSmile'
|
import VolSmile from '../components/VolSmile'
|
||||||
import VolLab from '../components/VolLab'
|
import VolLab from '../components/VolLab'
|
||||||
import VolForecast from '../components/VolForecast'
|
import VolForecast from '../components/VolForecast'
|
||||||
|
import HedgeLab from '../components/HedgeLab'
|
||||||
|
|
||||||
const IVSurface = lazy(() => import('../components/IVSurface'))
|
const IVSurface = lazy(() => import('../components/IVSurface'))
|
||||||
const WorldMap = lazy(() => import('../components/WorldMap'))
|
const WorldMap = lazy(() => import('../components/WorldMap'))
|
||||||
@@ -43,6 +44,7 @@ const TABS = [
|
|||||||
{ id:'signals', icon:'⚡', label:'AI Signals', component: QuantSignals },
|
{ id:'signals', icon:'⚡', label:'AI Signals', component: QuantSignals },
|
||||||
{ id:'vollab', icon:'🔬', label:'Vol Lab', component: VolLab },
|
{ id:'vollab', icon:'🔬', label:'Vol Lab', component: VolLab },
|
||||||
{ id:'volforecast', icon:'🤖', label:'Vol Forecast', component: VolForecast },
|
{ id:'volforecast', icon:'🤖', label:'Vol Forecast', component: VolForecast },
|
||||||
|
{ id:'hedgelab', icon:'⚖', label:'Hedge Lab', component: HedgeLab },
|
||||||
{ id:'smile', icon:'〰', label:'Vol Smile', component: VolSmile },
|
{ id:'smile', icon:'〰', label:'Vol Smile', component: VolSmile },
|
||||||
{ id:'surfaces', icon:'🗻', label:'3D Surfaces', component: SurfacePlot },
|
{ id:'surfaces', icon:'🗻', label:'3D Surfaces', component: SurfacePlot },
|
||||||
{ id:'breakeven', icon:'🎯', label:'Breakeven', component: BreakevenChart },
|
{ id:'breakeven', icon:'🎯', label:'Breakeven', component: BreakevenChart },
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 349 KiB |
Reference in New Issue
Block a user