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:
Claude
2026-10-03 05:25:36 +00:00
parent 608b507504
commit 1eecc06bc2
10 changed files with 839 additions and 2 deletions
+1
View File
@@ -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
+351
View File
@@ -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
View File
@@ -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("/")
+54
View File
@@ -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}
+26
View File
@@ -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
+95
View File
@@ -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
+3
View File
@@ -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
+305
View File
@@ -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&amp;L of your portfolio ({pair})</div>
<div style={{ fontSize:9, color:'var(--text-muted)', marginTop:2 }}>
Options traded &amp; 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&amp;L ≈ ½ΓS²(σ²<sub>realised</sub> − σ²<sub>implied</sub>)dt, plus discrete-hedging noise
(Derman &amp; 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&apos;s own daily returns in blocks (Politis &amp; Romano 1994). Theoretical edge = value at
realised minus premium at implied (Ahmad &amp; 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>
)
}
+2
View File
@@ -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