diff --git a/README.md b/README.md index 6b02d8a..5aba5a8 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,8 @@ NEXUS TERMINAL is a Bloomberg-style web application for professional forex optio | **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 | | **Cross-Asset Desk** | Gold · silver · BTC · ETH · EURUSD · DXY · US10Y · WTI correlation matrices (30D/90D/1Y/EWMA) with a correlation-break monitor; gold/silver ratio z-scores, OU half-life, Engle-Granger cointegration and Kalman hedge ratio; Margrabe outperformance-option pricer with correlation sensitivity | | **Jump Lab** | Merton and Bates (Heston + jumps, COS pricing) calibrated to every live Deribit BTC/ETH expiry: model-vs-market smiles, fit RMSE, jump intensity/size, jump share of variance, crash-jump odds, 30-day risk-neutral density vs lognormal; manual Merton for metals/FX event and gap risk; one-click hand-off of jump parameters to Hedge Lab | +| **Event Vol** | Event & weekend "vol time" term structure: strips FOMC / CPI / NFP variance lumps and the weekend weight out of the ATM curve (live Deribit fit for BTC/ETH; assumptions or pasted broker quotes for XAU/XAG/EUR/any pair), event-implied moves and straddle breakevens, forward vols and calendar-arbitrage flags, fair vol for any expiry incl. 0DTE/1-day, built-in 2026 FOMC/CPI/NFP calendar merged with live Forex Factory events, realised day-of-week / weekend-weight / FOMC-lump study | +| **Exotics Desk** | Structured-products pricer: all eight single barriers (Reiner-Rubinstein/Haug, rebates) with Broadie-Glasserman-Kou discrete-monitoring correction, barrier price/delta profile through the barrier, touch ladder of hit probabilities, smile-consistent digitals (−∂C/∂K on the vanna-volga smile); Dual Currency Investment / crypto "Dual Investment" fair-APR ladder with an offer audit (platform margin, implied vol you are paid); KO accumulator Monte Carlo with zero-cost strike, KO odds and geared-loss tail, optional Merton jumps | ### Live Intelligence diff --git a/backend/app/core/event_vol.py b/backend/app/core/event_vol.py new file mode 100644 index 0000000..3db0621 --- /dev/null +++ b/backend/app/core/event_vol.py @@ -0,0 +1,290 @@ +""" +event_vol.py — Event & weekend volatility: "vol time" term-structure model. + +Desks do not price short-dated options on calendar time. Each hour to expiry carries +a variance weight: 1 for a normal weekday hour, w_we for a weekend hour (≈0.1–0.3 for +FX/metals whose spot is shut, ≈0.5–0.9 for 24/7 crypto), and each scheduled event +(FOMC, CPI, NFP, ECB, BoJ…) adds a lump of variance v_e on its timestamp: + + σ_ATM(T)² · T = σ_b² · τ(T) + Σ_{events e before T} v_e, + τ(T) = (1/8760) · Σ_{hours h in (now, T]} weight(h) (ACT/365 hours) + +σ_b is the weekday ("base", ex-event) vol in ACT/365 units, and √v_e is the event's +own 1-sd move. Given listed expiries the model is linear in (σ_b², v_type) for a fixed +weekend weight, so it is fitted by non-negative least squares on total variance with +a grid search over w_we. Forward vols between expiries follow from additivity of +variance; a negative forward variance is a calendar arbitrage. + +References: + Castagna, A. (2010). FX Options and Smile Risk, ch. 6 (event and weekend weighting). + Clark, I. (2011). Foreign Exchange Option Pricing, §3.6 "Volatility time". + Wystup, U. (2017). FX Options and Structured Products, §1.5 (overnight vols). + Dubinsky, Johannes, Kaeck & Seeger (2019). "Option pricing of earnings announcement + risks", Review of Financial Studies 32(2) — event-variance stripping from the term + structure. + Gatheral, J. (2006). The Volatility Surface, ch. 1 (calendar arbitrage: total implied + variance must be non-decreasing in T). +""" + +from datetime import datetime, timedelta, timezone + +import numpy as np +from scipy.optimize import nnls + +HOURS_PER_YEAR = 365 * 24 + +# Scheduled release times (UTC). US events move with US daylight saving. +# FOMC 2026 (decision day, 14:00 ET) — federalreserve.gov calendar. +FOMC_2026 = ["2026-01-28", "2026-03-18", "2026-04-29", "2026-06-17", "2026-07-29", + "2026-09-16", "2026-10-28", "2026-12-09"] +FOMC_PAST = ["2024-01-31", "2024-03-20", "2024-05-01", "2024-06-12", "2024-07-31", "2024-09-18", + "2024-11-07", "2024-12-18", "2025-01-29", "2025-03-19", "2025-05-07", "2025-06-18", + "2025-07-30", "2025-09-17", "2025-10-29", "2025-12-10"] +# Remaining 2026 US data releases known at build time (BLS schedule, 08:30 ET). +CPI_2026 = ["2026-10-14", "2026-11-12", "2026-12-10"] +NFP_2026 = ["2026-11-06", "2026-12-04"] + +EVENT_TYPES = ("FOMC", "CPI", "NFP", "OTHER") + + +def _us_dst(d: datetime) -> bool: + """US daylight saving: second Sunday of March → first Sunday of November.""" + y = d.year + mar = datetime(y, 3, 8, tzinfo=timezone.utc) + start = mar + timedelta(days=(6 - mar.weekday()) % 7) + nov = datetime(y, 11, 1, tzinfo=timezone.utc) + end = nov + timedelta(days=(6 - nov.weekday()) % 7) + return start <= d < end + + +def et_to_utc(date_str: str, hour_et: float) -> datetime: + d = datetime.fromisoformat(date_str).replace(tzinfo=timezone.utc) + offset = 4 if _us_dst(d) else 5 + return d + timedelta(hours=hour_et + offset) + + +def default_events(now: datetime, horizon_days: float = 120) -> list: + """Built-in US calendar (FOMC 14:00 ET, CPI/NFP 08:30 ET) within the horizon.""" + out = [] + for t, dates, hr in (("FOMC", FOMC_2026, 14.0), ("CPI", CPI_2026, 8.5), ("NFP", NFP_2026, 8.5)): + for d in dates: + ts = et_to_utc(d, hr) + if now < ts <= now + timedelta(days=horizon_days): + out.append({"type": t, "name": f"US {t}", "time": ts}) + return sorted(out, key=lambda e: e["time"]) + + +def classify_ff_event(title: str, country: str): + """Map a Forex Factory high-impact title to an event type (None = ignore).""" + t = title.lower() + if country == "USD": + if "federal funds rate" in t or "fomc statement" in t: + return "FOMC" + if t.startswith("cpi") or "core cpi" in t: + return "CPI" + if "non-farm" in t: + return "NFP" + if country in ("EUR", "JPY", "GBP") and ("rate" in t and ("main refinancing" in t or "policy rate" in t + or "official bank rate" in t)): + return "OTHER" + return None + + +def is_weekend(ts: datetime, market: str = "fx") -> bool: + """ + Weekend hours. FX/metals: Fri 21:00 UTC → Sun 21:00 UTC (spot closed). + Crypto: Saturday and Sunday UTC (market open but thinner). + """ + wd, h = ts.weekday(), ts.hour + if market == "crypto": + return wd >= 5 + return (wd == 4 and h >= 21) or wd == 5 or (wd == 6 and h < 21) + + +def hour_grid(now: datetime, end: datetime, market: str): + """Hourly grid (now, end]: weekend flags and the fraction of each hour used.""" + n = max(0, int(np.ceil((end - now).total_seconds() / 3600))) + starts = [now + timedelta(hours=i) for i in range(n)] + frac = np.ones(n) + if n: + frac[-1] = ((end - now).total_seconds() / 3600) - (n - 1) + we = np.array([is_weekend(s, market) for s in starts], dtype=bool) + return we, frac + + +def vol_time(now: datetime, end: datetime, w_we: float, market: str) -> tuple: + """(τ in weighted years, weekday-hour years, weekend-hour years).""" + we, frac = hour_grid(now, end, market) + wk = float(frac[~we].sum()) / HOURS_PER_YEAR + wkend = float(frac[we].sum()) / HOURS_PER_YEAR + return wk + w_we * wkend, wk, wkend + + +def _design(now, expiries, events, w_we, market, types): + rows = [] + for e in expiries: + tau, _, _ = vol_time(now, e["time"], w_we, market) + counts = [sum(1 for ev in events if ev["type"] == t and now < ev["time"] <= e["time"]) for t in types] + rows.append([tau] + counts) + return np.array(rows, float) + + +def fit_term_structure(now, expiries, events, market="crypto", w_grid=None, fix_w=None, + event_floor=None, fix_events=None): + """ + Fit σ_b, w_we and per-type event variances to listed ATM vols. + expiries: [{"time": datetime, "atm_vol": σ, "label": str}], events: [{"type","time",...}]. + event_floor: optional {type: min variance} prior (e.g. from realised history) — types + whose window holds no expiry pair are otherwise unidentified and left at the floor. + fix_events: {type: variance} held fixed (only σ_b, and w_we unless fixed, are fitted) — + used when there are too few quotes to identify event lumps. + """ + exps = sorted([e for e in expiries if e["time"] > now], key=lambda e: e["time"]) + if len(exps) < 3: + raise ValueError("need at least 3 future expiries") + T = np.array([(e["time"] - now).total_seconds() / 3600 / HOURS_PER_YEAR for e in exps]) + target = np.array([e["atm_vol"] ** 2 for e in exps]) * T + types = [t for t in EVENT_TYPES if any(ev["type"] == t and now < ev["time"] <= exps[-1]["time"] for ev in events)] + wts = 1.0 / np.maximum(target, 1e-8) # relative error in total variance ≈ 2× vol error + floor = np.array([(event_floor or {}).get(t, 0.0) for t in types]) + if fix_events is not None: + floor = np.array([fix_events.get(t, 0.0) for t in types]) + grid = [fix_w] if fix_w is not None else (w_grid if w_grid is not None else np.round(np.arange(0.0, 1.21, 0.05), 2)) + best = None + for w in grid: + A = _design(now, exps, events, w, market, types) + # Event variance above the floor is the free (non-negative) unknown. + b = target - (A[:, 1:] @ floor if len(types) else 0.0) + sw = np.sqrt(wts) + if fix_events is not None: + x0, _ = nnls(A[:, :1] * sw[:, None], b * sw) + x = np.concatenate([x0, np.zeros(len(types))]) + else: + x, _ = nnls(A * sw[:, None], b * sw) + x[1:] += floor + fit = A @ x + sse = float(np.sum(wts * (fit - target) ** 2)) + if best is None or sse < best[0]: + best = (sse, w, x, A, fit) + sse, w, x, A, fit = best + base_var = x[0] + ev_var = dict(zip(types, x[1:].tolist())) + # Identification: an event type is pinned only if some consecutive expiry pair brackets it. + ident = {} + for t in ([] if fix_events is not None else types): + ident[t] = any(any(ev["type"] == t and exps[i]["time"] < ev["time"] <= exps[i + 1]["time"] for ev in events) + for i in range(len(exps) - 1)) or any(ev["type"] == t and now < ev["time"] <= exps[0]["time"] for ev in events) + model_vol = np.sqrt(fit / T) + rows = [] + for i, e in enumerate(exps): + rows.append({"label": e.get("label"), "time": e["time"].isoformat(), "days": float(T[i] * 365), + "market_vol": float(e["atm_vol"]), "model_vol": float(model_vol[i]), + "error_vol": float(model_vol[i] - e["atm_vol"]), + "event_share": float((A[i, 1:] @ x[1:]) / fit[i]) if fit[i] > 0 and len(types) else 0.0, + "events_before": int(A[i, 1:].sum()) if len(types) else 0}) + return {"base_vol": float(np.sqrt(base_var)), "weekend_weight": float(w), + "event_variance": ev_var, "identified": ident, + "rmse_vol": float(np.sqrt(np.mean((model_vol - np.array([e["atm_vol"] for e in exps])) ** 2))), + "expiries": rows, "market": market} + + +def model_vol(now, end, base_vol, w_we, events, ev_var, market): + """Model ATM vol to an arbitrary expiry and its variance decomposition.""" + T = (end - now).total_seconds() / 3600 / HOURS_PER_YEAR + if T <= 0: + raise ValueError("expiry must be in the future") + _, wk, wkend = vol_time(now, end, w_we, market) + inside = [ev for ev in events if now < ev["time"] <= end] + v_wk, v_we = base_vol ** 2 * wk, base_vol ** 2 * w_we * wkend + v_ev = sum(ev_var.get(ev["type"], 0.0) for ev in inside) + tot = v_wk + v_we + v_ev + return {"T": T, "days": T * 365, "vol": float(np.sqrt(tot / T)), + "flat_calendar_vol": float(base_vol * np.sqrt((5 + 2 * w_we) / 7)), + "share": {"weekday": v_wk / tot, "weekend": v_we / tot, "events": v_ev / tot}, + "events": [{"type": ev["type"], "name": ev.get("name", ev["type"]), "time": ev["time"].isoformat()} for ev in inside]} + + +def forward_vols(points): + """Forward vols between consecutive (T, vol) points; flags negative forward variance.""" + out = [] + pts = sorted(points) + for (t0, v0), (t1, v1) in zip(pts[:-1], pts[1:]): + fv = v1 ** 2 * t1 - v0 ** 2 * t0 + out.append({"T0": t0, "T1": t1, "fwd_var": fv, + "fwd_vol": float(np.sqrt(fv / (t1 - t0))) if fv > 0 else None, "arbitrage": bool(fv < 0)}) + return out + + +def event_moves(ev_var, base_vol, spot, w_we=1.0): + """Event-day 1-sd move (base day + event lump), expected |move| and straddle breakeven.""" + day = base_vol ** 2 / 365 + out = {} + for t, v in ev_var.items(): + sd = np.sqrt(day + v) + out[t] = {"event_sd_pct": float(np.sqrt(v) * 100), "day_sd_pct": float(sd * 100), + "normal_day_sd_pct": float(np.sqrt(day) * 100), "multiple_of_normal_day": float(sd / np.sqrt(day)) if day > 0 else None, + "expected_abs_move_pct": float(np.sqrt(2 / np.pi) * sd * 100), + "expected_abs_move": float(np.sqrt(2 / np.pi) * sd * spot) if spot else None} + return out + + +# ─── Realised study ─────────────────────────────────────────────────────────── + +def realised_study(dates, closes, market="fx", fomc_dates=FOMC_PAST + FOMC_2026): + """ + Day-of-week realised variance profile, implied weekend weight and the FOMC lump. + + FX/metals daily closes: Monday's return spans the weekend, so + w_we ≈ (var_Mon − var_weekday) / (2 · var_weekday) (2 weekend days). + Crypto: Saturday/Sunday returns are observed directly, w_we = var_weekend / var_weekday. + FOMC (14:00 ET) lands after many daily closes (CME gold settles 13:30 ET), so the event + window is the 2-day return over the decision day and the next day: + v_FOMC ≈ E[r²_2d] − 2 · var_weekday. + """ + d = [datetime.fromisoformat(str(x)[:10]) for x in dates] + c = np.asarray(closes, float) + r = np.diff(np.log(c)) + d1 = d[1:] + wd = np.array([x.weekday() for x in d1]) + # Robust to outliers: winsorise at 6 sd before the variance profile. + s = np.std(r) + rw = np.clip(r, -6 * s, 6 * s) + fomc = {datetime.fromisoformat(x).date() for x in fomc_dates} + idx_by_date = {x.date(): i for i, x in enumerate(d1)} + ev_idx = set() + windows = [] + for f in sorted(fomc): + i = idx_by_date.get(f) + if i is None or i + 1 >= len(r): + continue + ev_idx.update({i, i + 1}) + windows.append({"date": f.isoformat(), "move_2d_pct": float((r[i] + r[i + 1]) * 100)}) + mask_clean = np.array([i not in ev_idx for i in range(len(r))]) + prof = [] + names = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"] + for k in range(7): + m = (wd == k) & mask_clean + if m.sum() >= 10: + prof.append({"day": names[k], "n": int(m.sum()), "vol_ann": float(np.sqrt(np.mean(rw[m] ** 2) * 365) )}) + if market == "crypto": + wk = (wd < 5) & mask_clean + we = (wd >= 5) & mask_clean + v_wk = float(np.mean(rw[wk] ** 2)) + w_we = float(np.mean(rw[we] ** 2) / v_wk) if we.sum() > 10 else None + else: + mid = np.isin(wd, [1, 2, 3, 4]) & mask_clean + mon = (wd == 0) & mask_clean + v_wk = float(np.mean(rw[mid] ** 2)) + w_we = float(max(0.0, (np.mean(rw[mon] ** 2) - v_wk) / (2 * v_wk))) if mon.sum() > 10 else None + if windows: + m2 = np.array([w["move_2d_pct"] / 100 for w in windows]) + v_f = float(max(0.0, np.mean(m2 ** 2) - 2 * v_wk)) + fomc_stats = {"n": len(windows), "event_sd_pct": float(np.sqrt(v_f) * 100), + "avg_abs_2d_move_pct": float(np.mean(np.abs(m2)) * 100), + "normal_2d_sd_pct": float(np.sqrt(2 * v_wk) * 100), "event_variance": v_f, + "recent": windows[-8:]} + else: + fomc_stats = None + return {"weekday_vol_ann": float(np.sqrt(v_wk * 365)), "weekend_weight": w_we, + "day_of_week": prof, "fomc": fomc_stats, "n_returns": int(len(r)), + "start": d[0].date().isoformat(), "end": d[-1].date().isoformat()} diff --git a/backend/app/core/exotics.py b/backend/app/core/exotics.py new file mode 100644 index 0000000..ba73f43 --- /dev/null +++ b/backend/app/core/exotics.py @@ -0,0 +1,267 @@ +""" +exotics.py — Exotics & structured products for FX, metals and crypto: +single barriers, touches, smile-consistent digitals, Dual Currency Investments +(crypto "Dual Investment") and accumulators. + +Conventions: Garman-Kohlhagen with r = r_d (domestic / quote currency) and +cost of carry b = r_d − r_f (r_f = foreign rate, metal lease rate, ≈0 for crypto). +Prices are in quote currency per unit of base. + +1. Single barriers — Reiner & Rubinstein (1991) closed forms in Haug's A–F notation + (all eight in/out × up/down × call/put, with rebates: knock-ins pay the rebate at + expiry if never knocked in, knock-outs pay it at the hit). Discrete monitoring + via the Broadie-Glasserman-Kou (1997) shift H → H·e^{±0.5826·σ√Δt} + (away from spot). +2. Touches: one-touch paying at hit (Haug's F with unit rebate), one-touch paying at + expiry and no-touch (from the knock-in rebate term E). +3. Digitals: cash-or-nothing e^{−rT}N(±d₂), smile-consistent version + −∂C/∂K = BS digital ∓ vega·∂σ/∂K (Breeden-Litzenberger), with σ(K) from a + vanna-volga smile built on ATM / 25Δ RR / 25Δ BF quotes. +4. Dual Currency Investment: deposit + short option. "Sell high" = deposit base, + short call struck K (converts to quote if S_T ≥ K); "buy low" = deposit quote, + short put (converts to base if S_T ≤ K). Fair APR = deposit rate + premium/T. +5. Accumulator (KO forward accumulation): buy q units at strike K on every fixing, + 2q (gearing) when the fixing is below K, the contract knocks out when a fixing + is ≥ KO. Monte Carlo under GBM or Merton jumps; zero-cost strike by root search. + +References: + Reiner, E. & Rubinstein, M. (1991). "Breaking down the barriers." Risk 4(8). + Haug, E. (2007). The Complete Guide to Option Pricing Formulas, 2nd ed., §4.17. + Broadie, M., Glasserman, P. & Kou, S. (1997). Mathematical Finance, 7(4), 325–349. + Breeden, D. & Litzenberger, R. (1978). Journal of Business, 51(4), 621–651. + Wystup, U. (2017). FX Options and Structured Products, 2nd ed. (DCDs, accumulators). +""" + +import numpy as np +from scipy.optimize import brentq +from scipy.stats import norm + +BGK_BETA = 0.5826 # −ζ(1/2)/√(2π) + + +# ─── vanilla & building blocks ──────────────────────────────────────────────── + +def gk_vanilla(S, K, T, r_d, r_f, sigma, is_call=True): + sq = sigma * np.sqrt(T) + d1 = (np.log(S / K) + (r_d - r_f + 0.5 * sigma ** 2) * T) / sq + d2 = d1 - sq + if is_call: + return S * np.exp(-r_f * T) * norm.cdf(d1) - K * np.exp(-r_d * T) * norm.cdf(d2) + return K * np.exp(-r_d * T) * norm.cdf(-d2) - S * np.exp(-r_f * T) * norm.cdf(-d1) + + +def bgk_barrier(H, S, sigma, n_obs_per_year): + """Continuous-equivalent barrier for discrete monitoring (shifted away from spot).""" + if not n_obs_per_year: + return H + shift = np.exp(BGK_BETA * sigma * np.sqrt(1.0 / n_obs_per_year)) + return H * shift if H > S else H / shift + + +def _terms(S, X, H, T, r, b, sigma, phi, eta, rebate): + sq = sigma * np.sqrt(T) + mu = (b - 0.5 * sigma ** 2) / sigma ** 2 + lam = np.sqrt(mu ** 2 + 2 * r / sigma ** 2) + x1 = np.log(S / X) / sq + (1 + mu) * sq + x2 = np.log(S / H) / sq + (1 + mu) * sq + y1 = np.log(H * H / (S * X)) / sq + (1 + mu) * sq + y2 = np.log(H / S) / sq + (1 + mu) * sq + z = np.log(H / S) / sq + lam * sq + fwd = S * np.exp((b - r) * T) + dk = X * np.exp(-r * T) + hs = H / S + N = norm.cdf + A = phi * fwd * N(phi * x1) - phi * dk * N(phi * x1 - phi * sq) + B = phi * fwd * N(phi * x2) - phi * dk * N(phi * x2 - phi * sq) + C = phi * fwd * hs ** (2 * (mu + 1)) * N(eta * y1) - phi * dk * hs ** (2 * mu) * N(eta * y1 - eta * sq) + D = phi * fwd * hs ** (2 * (mu + 1)) * N(eta * y2) - phi * dk * hs ** (2 * mu) * N(eta * y2 - eta * sq) + E = rebate * np.exp(-r * T) * (N(eta * x2 - eta * sq) - hs ** (2 * mu) * N(eta * y2 - eta * sq)) + F = rebate * (hs ** (mu + lam) * N(eta * z) + hs ** (mu - lam) * N(eta * z - 2 * eta * lam * sq)) + return A, B, C, D, E, F + + +def barrier_price(S, K, H, T, r_d, r_f, sigma, is_call=True, direction="down", knock="out", + rebate=0.0, monitoring_per_year=None): + """ + Reiner-Rubinstein single barrier (Haug A–F). direction: "down"|"up", knock: "in"|"out". + Already-breached barriers return the vanilla (knock-in) or the rebate (knock-out). + monitoring_per_year: e.g. 252 for daily fixings (BGK correction); None = continuous. + """ + up = direction == "up" + if (up and S >= H) or (not up and S <= H): + if knock == "in": + return float(gk_vanilla(S, K, T, r_d, r_f, sigma, is_call)) + return float(rebate) + Hc = bgk_barrier(H, S, sigma, monitoring_per_year) + r, b = r_d, r_d - r_f + phi = 1 if is_call else -1 + eta = -1 if up else 1 + A, B, C, D, E, F = _terms(S, K, Hc, T, r, b, sigma, phi, eta, rebate) + hi = K > Hc + table = { + ("call", "down", "in"): C + E if hi else A - B + D + E, + ("call", "up", "in"): A + E if hi else B - C + D + E, + ("put", "down", "in"): B - C + D + E if hi else A + E, + ("put", "up", "in"): A - B + D + E if hi else C + E, + ("call", "down", "out"): A - C + F if hi else B - D + F, + ("call", "up", "out"): F if hi else A - B + C - D + F, + ("put", "down", "out"): A - B + C - D + F if hi else F, + ("put", "up", "out"): B - D + F if hi else A - C + F, + } + return float(max(table[("call" if is_call else "put", direction, knock)], 0.0)) + + +def touch_prices(S, H, T, r_d, r_f, sigma, payout=1.0, monitoring_per_year=None): + """One-touch (paid at hit / at expiry) and no-touch on barrier H, plus hit probability.""" + up = H > S + Hc = bgk_barrier(H, S, sigma, monitoring_per_year) + eta = -1 if up else 1 + *_, E, F = _terms(S, S, Hc, T, r_d, r_d - r_f, sigma, 1, eta, payout) + df = np.exp(-r_d * T) + no_touch = float(max(E, 0.0)) + return {"one_touch_at_hit": float(F), "one_touch_at_expiry": float(payout * df - no_touch), + "no_touch": no_touch, "prob_touch": float(1 - no_touch / (payout * df)), + "barrier_used": float(Hc)} + + +def digital_prices(S, K, T, r_d, r_f, sigma, smile_vol=None, smile_slope=0.0, payout=1.0): + """Cash-or-nothing call/put: flat-vol and smile-consistent (−∂C/∂K) values.""" + vol = smile_vol if smile_vol else sigma + sq = vol * np.sqrt(T) + d1 = (np.log(S / K) + (r_d - r_f + 0.5 * vol ** 2) * T) / sq + d2 = d1 - sq + df = np.exp(-r_d * T) + bs_call = df * norm.cdf(d2) + vega = S * np.exp(-r_f * T) * norm.pdf(d1) * np.sqrt(T) + call_smile = bs_call - vega * smile_slope # dC/dK = −digital + vega·σ'(K) + sq0 = sigma * np.sqrt(T) + flat = df * norm.cdf((np.log(S / K) + (r_d - r_f - 0.5 * sigma ** 2) * T) / sq0) + return {"call_flat": float(payout * flat), "put_flat": float(payout * (df - flat)), + "call_smile": float(payout * call_smile), "put_smile": float(payout * (df - call_smile)), + "smile_adjustment": float(payout * (call_smile - flat))} + + +# ─── Dual Currency Investment ───────────────────────────────────────────────── + +def dci_quote(S, K, T, r_d, r_f, sigma, side="sell_high", offered_apr=None): + """ + Fair APR of a DCI and the conversion odds. + sell_high: deposit 1 unit of base (e.g. 1 BTC), short a call at K (paid in base terms). + buy_low: deposit K units of quote (e.g. USD), short a put at K, paid in quote terms. + """ + if side == "sell_high": + prem = gk_vanilla(S, K, T, r_d, r_f, sigma, True) / S # base-currency premium per 1 base + base_rate = r_f + conv = gk_prob(S, K, T, r_d, r_f, sigma, above=True) + else: + prem = gk_vanilla(S, K, T, r_d, r_f, sigma, False) / K # quote premium per 1 quote deposited + base_rate = r_d + conv = gk_prob(S, K, T, r_d, r_f, sigma, above=False) + fair_apr = base_rate + prem / T + out = {"premium_pct": float(prem * 100), "deposit_rate": float(base_rate), + "fair_apr": float(fair_apr), "prob_conversion": float(conv), + "breakeven": float(K * (1 + prem) if side == "sell_high" else K * (1 - prem))} + if offered_apr is not None: + out["offered_apr"] = offered_apr + out["margin_apr"] = float(fair_apr - offered_apr) + # Vol at which the offered APR would be fair (the vol the platform is paying you). + def gap(v): + return dci_quote(S, K, T, r_d, r_f, v, side)["fair_apr"] - offered_apr + try: + out["implied_vol_paid"] = float(brentq(gap, 1e-3, 5.0)) + except ValueError: + out["implied_vol_paid"] = None + return out + + +def gk_prob(S, K, T, r_d, r_f, sigma, above=True): + d2 = (np.log(S / K) + (r_d - r_f - 0.5 * sigma ** 2) * T) / (sigma * np.sqrt(T)) + return float(norm.cdf(d2) if above else norm.cdf(-d2)) + + +# ─── Accumulator ────────────────────────────────────────────────────────────── + +def simulate_paths(S, T, r_d, r_f, sigma, n_steps, n_paths, rng, jump=None): + dt = T / n_steps + mu = r_d - r_f + comp, jumps, sd = 0.0, 0.0, sigma + if jump and jump.get("lambda", 0) > 0: + lam, mj, sj = jump["lambda"], jump["mean"], jump["sd"] + comp = lam * (np.exp(mj + 0.5 * sj ** 2) - 1) + counts = rng.poisson(lam * dt, (n_paths, n_steps)) + jumps = counts * mj + np.sqrt(counts) * sj * rng.standard_normal((n_paths, n_steps)) + z = rng.standard_normal((n_paths, n_steps)) + x = (mu - comp - 0.5 * sd ** 2) * dt + sd * np.sqrt(dt) * z + jumps + return S * np.exp(np.cumsum(x, axis=1)) + + +def accumulator_value(paths, K, KO, T, r_d, qty=1.0, gearing=2.0): + """PV to the client (long) per path, accumulated quantity, KO flags/times.""" + n_paths, n = paths.shape + t = np.arange(1, n + 1) * T / n + df = np.exp(-r_d * t) + ko_hit = paths >= KO + first = np.where(ko_hit.any(axis=1), ko_hit.argmax(axis=1), n) # index of KO fixing + alive = np.arange(n)[None, :] < first[:, None] # fixings strictly before KO + q = np.where(paths < K, gearing * qty, qty) * alive + pv = (q * (paths - K) * df[None, :]).sum(axis=1) + return pv, q.sum(axis=1), first < n, np.where(first < n, (first + 1) * T / n, np.nan) + + +def accumulator_report(S, T, r_d, r_f, sigma, ko_pct=105.0, strike_pct=None, qty=1.0, + gearing=2.0, fixings_per_year=252, n_paths=20000, seed=11, jump=None): + n = max(1, int(round(T * fixings_per_year))) + rng = np.random.default_rng(seed) + paths = simulate_paths(S, T, r_d, r_f, sigma, n, n_paths, rng, jump) + KO = S * ko_pct / 100 + + def pv_mean(K): + return accumulator_value(paths, K, KO, T, r_d, qty, gearing)[0].mean() + + zero_cost_K = brentq(pv_mean, S * 0.3, KO * 0.999) + K = S * strike_pct / 100 if strike_pct else zero_cost_K + pv, qsum, ko, t_ko = accumulator_value(paths, K, KO, T, r_d, qty, gearing) + # Client P&L at maturity if the accumulated units are marked at the final fixing. + final_mtm = np.zeros(n_paths) + t = np.arange(1, n + 1) * T / n + first = np.where(paths >= KO, 1, 0).argmax(axis=1) + first = np.where((paths >= KO).any(axis=1), first, n) + alive = np.arange(n)[None, :] < first[:, None] + q = np.where(paths < K, gearing * qty, qty) * alive + end_px = np.where(first < n, paths[np.arange(n_paths), np.minimum(first, n - 1)], paths[:, -1]) + final_mtm = (q * (end_px[:, None] - K)).sum(axis=1) + hist_c, hist_e = np.histogram(final_mtm, bins=40) + sample = paths[:6, :: max(1, n // 120)] + return { + "n_fixings": n, "ko_level": float(KO), "zero_cost_strike": float(zero_cost_K), + "zero_cost_strike_pct": float(zero_cost_K / S * 100), "strike": float(K), + "pv_to_client": float(pv.mean()), "pv_se": float(pv.std() / np.sqrt(n_paths)), + "expected_units": float(qsum.mean()), "max_units": float(gearing * qty * n), + "prob_knock_out": float(ko.mean()), + "expected_ko_days": float(np.nanmean(t_ko) * 365) if ko.any() else None, + "pnl": {"mean": float(final_mtm.mean()), "p05": float(np.quantile(final_mtm, 0.05)), + "p01": float(np.quantile(final_mtm, 0.01)), + "es05": float(final_mtm[final_mtm <= np.quantile(final_mtm, 0.05)].mean()), + "prob_loss": float((final_mtm < 0).mean())}, + "histogram": {"centers": (0.5 * (hist_e[1:] + hist_e[:-1])).tolist(), "counts": hist_c.tolist()}, + "sample_paths": sample.tolist(), + } + + +def barrier_mc(S, K, H, T, r_d, r_f, sigma, is_call, direction, knock, rebate=0.0, + n_steps=252, n_paths=200_000, seed=3): + """Monte Carlo check with discrete monitoring at n_steps fixings (knock-out rebate at hit).""" + rng = np.random.default_rng(seed) + paths = simulate_paths(S, T, r_d, r_f, sigma, n_steps, n_paths, rng) + hit = (paths >= H) if direction == "up" else (paths <= H) + touched = hit.any(axis=1) + ST = paths[:, -1] + pay = np.maximum(ST - K, 0) if is_call else np.maximum(K - ST, 0) + df = np.exp(-r_d * T) + if knock == "out": + idx = hit.argmax(axis=1) + t_hit = (idx + 1) * T / n_steps + v = np.where(touched, rebate * np.exp(-r_d * t_hit), pay * df) + else: + v = np.where(touched, pay * df, rebate * df) + return float(v.mean()), float(v.std() / np.sqrt(n_paths)) diff --git a/backend/app/main.py b/backend/app/main.py index 67c49a3..8c08095 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,6 +1,6 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware -from .routers import greeks, surface, market, montecarlo, scenarios, strategies, export, forex, institutional, news, livedata, smile, vol, hedge, xasset, jumps +from .routers import greeks, surface, market, montecarlo, scenarios, strategies, export, forex, institutional, news, livedata, smile, vol, hedge, xasset, jumps, exotics, eventvol app = FastAPI(title="QuantRisk FX Terminal API", description="Forex options risk analytics — Garman-Kohlhagen model.", @@ -9,7 +9,7 @@ app = FastAPI(title="QuantRisk FX Terminal API", app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"]) -for router in [greeks, surface, market, montecarlo, scenarios, strategies, export, forex, institutional, news, livedata, smile, vol, hedge, xasset, jumps]: +for router in [greeks, surface, market, montecarlo, scenarios, strategies, export, forex, institutional, news, livedata, smile, vol, hedge, xasset, jumps, exotics, eventvol]: app.include_router(router.router, prefix="/api") @app.get("/") diff --git a/backend/app/routers/eventvol.py b/backend/app/routers/eventvol.py new file mode 100644 index 0000000..92fecc7 --- /dev/null +++ b/backend/app/routers/eventvol.py @@ -0,0 +1,246 @@ +""" +eventvol.py — Event & Weekend Vol desk: strips FOMC/CPI/NFP event variance and the +weekend weight out of the ATM term structure (live Deribit for BTC/ETH, quotes or +assumptions for metals/FX), prices any expiry on vol time, and backs the assumptions +with a realised day-of-week / FOMC study. Free sources only; every live input degrades +gracefully. +""" + +from datetime import datetime, timedelta, timezone + +import numpy as np +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel, Field + +from ..core.event_vol import (EVENT_TYPES, classify_ff_event, default_events, event_moves, + fit_term_structure, forward_vols, model_vol, realised_study) +from .forex import PAIRS + +router = APIRouter(prefix="/eventvol", tags=["eventvol"]) + +TENORS = [("O/N", 1), ("1W", 7), ("2W", 14), ("1M", 30), ("2M", 61), ("3M", 91)] + + +def _now(): + return datetime.now(timezone.utc).replace(minute=0, second=0, microsecond=0) + + +def _market(pair): + return "crypto" if PAIRS[pair]["asset_class"] == "crypto" else "fx" + + +def _cut(day: datetime, market: str): + """Expiry cut: Deribit 08:00 UTC for crypto, NY 10:00 ET (≈14:00/15:00 UTC) otherwise.""" + from ..core.event_vol import et_to_utc + if market == "crypto": + return day.replace(hour=8, minute=0, second=0, microsecond=0) + return et_to_utc(day.date().isoformat(), 10.0) + + +def calendar_events(now, horizon=120, live=True): + """Built-in US calendar merged with live Forex Factory high-impact events (if reachable).""" + events = default_events(now, horizon) + source = ["built-in 2026 FOMC/CPI/NFP schedule"] + if live: + try: + from .news import _load_calendar + cal = _load_calendar() + ff = cal.get("events", []) if isinstance(cal, dict) else [] + added = 0 + for ev in ff: + if (ev.get("impact") or "").lower() != "high" or not ev.get("datetime_utc"): + continue + t = classify_ff_event(ev.get("title", ""), ev.get("country", "")) + if not t: + continue + ts = datetime.strptime(ev["datetime_utc"], "%Y-%m-%dT%H:%MZ").replace(tzinfo=timezone.utc) + if not (now < ts <= now + timedelta(days=horizon)): + continue + if any(e["type"] == t and abs((e["time"] - ts).total_seconds()) < 36 * 3600 for e in events): + continue + events.append({"type": t, "name": f'{ev.get("country")} {ev.get("title")}', "time": ts}) + added += 1 + if added: + source.append(f"Forex Factory (+{added})") + except Exception: + pass + return sorted(events, key=lambda e: e["time"]), source + + +def _ev_json(events): + return [{"type": e["type"], "name": e.get("name", e["type"]), "time": e["time"].isoformat()} for e in events] + + +def _curve(now, base, w, events, ev_var, market, days=90): + """Model ATM vol for daily expiries 1..days (at the market's cut), plus the flat-calendar vol.""" + out = [] + for d in range(1, days + 1): + end = _cut(now + timedelta(days=d), market) + if end <= now: + continue + m = model_vol(now, end, base, w, events, ev_var, market) + out.append({"date": end.date().isoformat(), "days": m["days"], "vol": m["vol"], + "event_share": m["share"]["events"], "weekend": end.weekday() >= 5}) + return out + + +def _tenors(now, base, w, events, ev_var, market): + rows = [] + for name, d in TENORS: + end = _cut(now + timedelta(days=d), market) + m = model_vol(now, end, base, w, events, ev_var, market) + rows.append({"tenor": name, "expiry": end.isoformat(), "days": m["days"], "vol": m["vol"], + "share": m["share"], "events": [e["type"] for e in m["events"]]}) + pts = [(r["days"] / 365, r["vol"]) for r in rows] + for r, f in zip(rows[1:], forward_vols(pts)): + r["fwd_vol"] = f["fwd_vol"] + return rows + + +def _load_history(pair): + from .vol import _cached, _crypto_ohlc, _yf_ohlc + meta = PAIRS[pair] + return _cached(f"ohlc:{pair}", 1800, + lambda: _crypto_ohlc(pair[:3]) if meta["asset_class"] == "crypto" else _yf_ohlc(meta["sym"])) + + +@router.get("/realised/{pair}") +def realised(pair: str): + """Day-of-week realised vol, implied weekend weight and the realised FOMC lump.""" + pair = pair.upper() + if pair not in PAIRS: + raise HTTPException(404, f"Unknown pair '{pair}'.") + try: + data = _load_history(pair) + if not data or len(data["c"]) < 250: + raise ValueError("not enough daily history") + rep = realised_study(data["dates"], data["c"], _market(pair)) + except Exception as e: + raise HTTPException(503, f"Price history unavailable: {e}") + return _clean({"pair": pair, "market": _market(pair), **rep}) + + +@router.get("/crypto/{currency}") +def crypto_fit(currency: str): + """Fit base vol, weekend weight and event variances to the live Deribit ATM term structure.""" + currency = currency.upper() + if currency not in ("BTC", "ETH"): + raise HTTPException(404, "Live event-vol fit is available for BTC and ETH (Deribit).") + from .vol import get_crypto_vol + try: + surf = get_crypto_vol(currency) + except HTTPException as e: + raise HTTPException(503, f"Deribit surface unavailable: {e.detail}") + now = _now() + exps = [{"time": datetime.fromisoformat(e["expiry"]).replace(hour=8, tzinfo=timezone.utc), + "atm_vol": e["atm_vol"], "label": e["label"]} + for e in surf["expiries"] if e.get("atm_vol") and e["days"] <= 200] + exps = [e for e in exps if e["time"] > now + timedelta(hours=2)] + events, source = calendar_events(now, 200) + try: + fit = fit_term_structure(now, exps, events, "crypto") + except ValueError as e: + raise HTTPException(503, f"Fit failed: {e}") + spot = surf.get("index_price") + mkt_pts = [(r["days"] / 365, r["market_vol"]) for r in fit["expiries"]] + fwd = forward_vols(mkt_pts) + for r, f in zip(fit["expiries"][1:], fwd): + r["fwd_vol_market"], r["calendar_arb"] = f["fwd_vol"], f["arbitrage"] + ev_var = fit["event_variance"] + return _clean({ + "pair": f"{currency}USD", "spot": spot, "source": "Deribit ATM (SVI) + " + ", ".join(source), + "fit": fit, "moves": event_moves(ev_var, fit["base_vol"], spot), + "events": _ev_json([e for e in events if e["time"] <= exps[-1]["time"]]), + "curve": _curve(now, fit["base_vol"], fit["weekend_weight"], events, ev_var, "crypto"), + "tenors": _tenors(now, fit["base_vol"], fit["weekend_weight"], events, ev_var, "crypto"), + "now": now.isoformat()}) + + +class Quote(BaseModel): + days: float = Field(..., gt=0.04, le=400) + vol: float = Field(..., gt=0.005, lt=5) + + +class CustomEvent(BaseModel): + type: str = Field(default="OTHER") + name: str = Field(default="Custom event", max_length=60) + time: datetime + + +class ModelRequest(BaseModel): + pair: str = "XAUUSD" + S: float | None = Field(default=None, gt=0) + base_vol: float = Field(default=0.16, gt=0.005, lt=5, description="Weekday (ex-event) vol, ACT/365") + weekend_weight: float = Field(default=0.2, ge=0, le=1.5) + event_moves_pct: dict[str, float] = Field(default={"FOMC": 1.2, "CPI": 1.0, "NFP": 0.9, "OTHER": 0.8}, + description="Event 1-sd move in % per type") + quotes: list[Quote] = Field(default=[], max_length=20, description="Optional ATM quotes to fit") + custom_events: list[CustomEvent] = Field(default=[], max_length=20) + include_calendar: bool = True + fit_events: bool = Field(default=False, description="Free event lumps in the quote fit (needs dense quotes)") + expiry_days: float | None = Field(default=None, gt=0.01, le=400, description="Calculator expiry") + + +@router.post("/model") +def model(req: ModelRequest): + """Vol-time term structure for any pair: build from assumptions or fit to quoted ATM vols.""" + pair = req.pair.upper() + if pair not in PAIRS: + raise HTTPException(404, f"Unknown pair '{pair}'.") + market = _market(pair) + now = _now() + events, source = calendar_events(now, 400) if req.include_calendar else ([], []) + for ce in req.custom_events: + t = ce.time if ce.time.tzinfo else ce.time.replace(tzinfo=timezone.utc) + if t > now: + events.append({"type": ce.type.upper() if ce.type.upper() in EVENT_TYPES else "OTHER", + "name": ce.name, "time": t}) + events.sort(key=lambda e: e["time"]) + ev_var = {k.upper(): (v / 100) ** 2 for k, v in req.event_moves_pct.items() if k.upper() in EVENT_TYPES and v >= 0} + base, w, fit = req.base_vol, req.weekend_weight, None + if len(req.quotes) >= 3: + exps = [{"time": _cut(now + timedelta(days=q.days), market) if q.days >= 1 else now + timedelta(days=q.days), + "atm_vol": q.vol, "label": f"{q.days:g}d"} for q in req.quotes] + last = max(e["time"] for e in exps) + n_types = len({e["type"] for e in events if now < e["time"] <= last}) + # Sparse broker tenors cannot pin event lumps: hold them at the stated moves unless + # the trader asks for a free fit and there are enough quotes to identify it. + fix = None if (req.fit_events and len(exps) >= n_types + 2) else ev_var + try: + fit = fit_term_structure(now, exps, events, market, fix_w=req.weekend_weight, fix_events=fix) + fit["events_fixed"] = fix is not None + except ValueError as e: + raise HTTPException(422, str(e)) + base = fit["base_vol"] + # Fitted event types override assumptions only where the quotes identify them. + ev_var = {**ev_var, **{t: v for t, v in fit["event_variance"].items() if fit["identified"].get(t)}} + fwd = forward_vols([(r["days"] / 365, r["market_vol"]) for r in fit["expiries"]]) + for r, f in zip(fit["expiries"][1:], fwd): + r["fwd_vol_market"], r["calendar_arb"] = f["fwd_vol"], f["arbitrage"] + calc = None + if req.expiry_days: + end = now + timedelta(days=req.expiry_days) + if req.expiry_days >= 1: + end = _cut(end, market) + calc = model_vol(now, end, base, w, events, ev_var, market) + calc["expiry"] = end.isoformat() + return _clean({ + "pair": pair, "market": market, "spot": req.S, "base_vol": base, "weekend_weight": w, + "flat_calendar_vol": base * np.sqrt((5 + 2 * w) / 7), "event_variance": ev_var, + "moves": event_moves(ev_var, base, req.S), "fit": fit, "calculator": calc, + "events": _ev_json([e for e in events if e["time"] <= now + timedelta(days=95)]), + "source": source, "curve": _curve(now, base, w, events, ev_var, market), + "tenors": _tenors(now, base, w, events, ev_var, market), "now": now.isoformat()}) + + +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, (bool, np.bool_)): + return bool(obj) + if isinstance(obj, dict): + return {k: _clean(v) for k, v in obj.items()} + if isinstance(obj, (list, tuple, np.ndarray)): + return [_clean(v) for v in obj] + return obj diff --git a/backend/app/routers/exotics.py b/backend/app/routers/exotics.py new file mode 100644 index 0000000..6baca7d --- /dev/null +++ b/backend/app/routers/exotics.py @@ -0,0 +1,182 @@ +""" +exotics.py — Exotics & Structured Products desk: single barriers (continuous and +BGK discrete), touch ladders, smile-consistent digitals, Dual Currency Investment +(crypto "Dual Investment") fair-APR ladders and accumulators. +""" + +import numpy as np +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel, Field + +from ..core.exotics import (accumulator_report, barrier_price, dci_quote, digital_prices, + gk_prob, gk_vanilla, touch_prices) +from ..core.vanna_volga import pillars, vv_vol +from .forex import PAIRS + +router = APIRouter(prefix="/exotics", tags=["exotics"]) + +KINDS = [(cp, d, k) for cp in ("call", "put") for d in ("down", "up") for k in ("out", "in")] + + +class Market(BaseModel): + pair: str = "XAUUSD" + S: float = Field(..., gt=0) + T: float = Field(..., gt=0, le=3, description="Years") + r_d: float = Field(default=0.04, ge=-0.05, le=0.5) + r_f: float = Field(default=0.0, ge=-0.05, le=0.5) + sigma: float = Field(..., gt=0.005, lt=3) + + +class BarrierRequest(Market): + K: float = Field(..., gt=0) + H_down: float = Field(..., gt=0) + H_up: float = Field(..., gt=0) + rebate: float = Field(default=0.0, ge=0) + monitoring_per_year: int | None = Field(default=252, ge=1, le=100000) + is_call: bool = True + direction: str = Field(default="up", pattern="^(up|down)$") + knock: str = Field(default="out", pattern="^(in|out)$") + rr25: float | None = Field(default=None, gt=-1, lt=1) + bf25: float | None = Field(default=None, gt=-0.5, lt=1) + + +class DciRequest(Market): + side: str = Field(default="sell_high", pattern="^(sell_high|buy_low)$") + strikes_pct: list[float] = Field(default=[90, 94, 97, 103, 106, 110], min_length=1, max_length=30) + offered_apr: float | None = Field(default=None, ge=0, le=20, description="Platform APR (decimal) at strike_pct_offer") + strike_pct_offer: float | None = Field(default=None, gt=10, lt=500) + + +class AccumulatorRequest(Market): + ko_pct: float = Field(default=105.0, gt=100, lt=200) + strike_pct: float | None = Field(default=None, gt=10, lt=200) + qty: float = Field(default=1.0, gt=0) + gearing: float = Field(default=2.0, ge=1, le=4) + fixings_per_year: int = Field(default=252, ge=12, le=365) + n_paths: int = Field(default=20000, ge=1000, le=60000) + jump_intensity: float = Field(default=0.0, ge=0, le=100) + jump_mean: float = Field(default=0.0, gt=-0.8, lt=0.5) + jump_sd: float = Field(default=0.0, ge=0, lt=1) + + +def _check(pair): + if pair.upper() not in PAIRS: + raise HTTPException(404, f"Unknown pair '{pair}'.") + + +def _smile_vol(req, K): + """σ(K) and ∂σ/∂K from a vanna-volga smile when RR/BF are supplied, else flat.""" + if req.rr25 is None or req.bf25 is None: + return req.sigma, 0.0 + pl = pillars(req.S, req.T, req.r_d, req.r_f, req.sigma, req.rr25, req.bf25) + h = K * 1e-3 + v = float(vv_vol(K, req.S, req.T, req.r_d, req.r_f, pl)) + vu = float(vv_vol(K + h, req.S, req.T, req.r_d, req.r_f, pl)) + vd = float(vv_vol(K - h, req.S, req.T, req.r_d, req.r_f, pl)) + return v, (vu - vd) / (2 * h) + + +@router.post("/barrier") +def barrier_desk(req: BarrierRequest): + """All eight single barriers, touches, digitals and the selected barrier's spot profile.""" + _check(req.pair) + if not req.H_down < req.S < req.H_up: + raise HTTPException(422, "Need H_down < spot < H_up.") + a = (req.S, req.K) + m = (req.T, req.r_d, req.r_f, req.sigma) + rows = [] + for cp, d, k in KINDS: + H = req.H_up if d == "up" else req.H_down + cont = barrier_price(*a, H, *m, cp == "call", d, k, req.rebate) + disc = barrier_price(*a, H, *m, cp == "call", d, k, req.rebate, req.monitoring_per_year) + van = gk_vanilla(*a, *m, cp == "call") + rows.append({"type": cp, "direction": d, "knock": k, "barrier": H, "continuous": cont, + "discrete": disc, "vanilla": float(van), + "pct_of_vanilla": float(disc / van * 100) if van > 1e-12 else None}) + + # Spot profile of the selected barrier: price and delta through the barrier. + H = req.H_up if req.direction == "up" else req.H_down + lo, hi = min(req.H_down, req.K, req.S) * 0.9, max(req.H_up, req.K, req.S) * 1.1 + spots = np.linspace(lo, hi, 121) + prof = [barrier_price(s, req.K, H, *m, req.is_call, req.direction, req.knock, req.rebate, + req.monitoring_per_year) for s in spots] + van = [float(gk_vanilla(s, req.K, *m, req.is_call)) for s in spots] + delta = np.gradient(prof, spots) + + touches = [] + for Hb in (req.H_down, req.H_up): + t = touch_prices(req.S, Hb, req.T, req.r_d, req.r_f, req.sigma, 1.0, req.monitoring_per_year) + t["barrier"] = Hb + t["prob_finish_beyond"] = gk_prob(req.S, Hb, req.T, req.r_d, req.r_f, req.sigma, above=Hb > req.S) + touches.append(t) + + # Touch ladder: hit probability vs barrier distance (in ATM-sd units). + sd = req.sigma * np.sqrt(req.T) + ladder = [] + for z in np.linspace(0.25, 3.0, 12): + for sgn in (-1, 1): + Hb = req.S * np.exp(sgn * z * sd) + t = touch_prices(req.S, Hb, req.T, req.r_d, req.r_f, req.sigma, 1.0, req.monitoring_per_year) + ladder.append({"barrier": float(Hb), "sd": float(sgn * z), "prob_touch": t["prob_touch"], + "one_touch": t["one_touch_at_expiry"]}) + ladder.sort(key=lambda r: r["barrier"]) + + vol_k, slope = _smile_vol(req, req.K) + dig = digital_prices(req.S, req.K, req.T, req.r_d, req.r_f, req.sigma, vol_k, slope) + dig.update({"strike": req.K, "smile_vol": vol_k, "smile_slope_per_1pct": slope * req.K * 0.01}) + + return _clean({"pair": req.pair.upper(), "rows": rows, "selected": { + "type": "call" if req.is_call else "put", "direction": req.direction, "knock": req.knock, + "barrier": H, "spots": spots.tolist(), "price": prof, "vanilla": van, "delta": delta.tolist()}, + "touches": touches, "touch_ladder": ladder, "digital": dig, + "bgk_shift_pct": float((np.exp(0.5826 * req.sigma * np.sqrt(1 / req.monitoring_per_year)) - 1) * 100) + if req.monitoring_per_year else 0.0}) + + +@router.post("/dci") +def dci_ladder(req: DciRequest): + """Fair APR / conversion-odds ladder for a Dual Currency Investment, plus offer audit.""" + _check(req.pair) + out = [] + for p in sorted(req.strikes_pct): + if (req.side == "sell_high") != (p > 100): + continue # DCIs are struck out-of-the-money + q = dci_quote(req.S, req.S * p / 100, req.T, req.r_d, req.r_f, req.sigma, req.side) + out.append({"strike_pct": p, "strike": req.S * p / 100, **q}) + if not out: + raise HTTPException(422, "No out-of-the-money strikes for this side " + "(sell_high needs strikes above 100%, buy_low below).") + audit = None + if req.offered_apr is not None and req.strike_pct_offer: + audit = dci_quote(req.S, req.S * req.strike_pct_offer / 100, req.T, req.r_d, req.r_f, + req.sigma, req.side, req.offered_apr) + audit["strike_pct"] = req.strike_pct_offer + audit["vol_haircut"] = (req.sigma - audit["implied_vol_paid"]) if audit.get("implied_vol_paid") else None + return _clean({"pair": req.pair.upper(), "side": req.side, "tenor_days": req.T * 365, + "ladder": out, "audit": audit}) + + +@router.post("/accumulator") +def accumulator(req: AccumulatorRequest): + """Zero-cost strike, KO odds and the client P&L distribution of a KO accumulator.""" + _check(req.pair) + jump = None + if req.jump_intensity > 0 and req.jump_sd > 0: + jump = {"lambda": req.jump_intensity, "mean": req.jump_mean, "sd": req.jump_sd} + try: + rep = accumulator_report(req.S, req.T, req.r_d, req.r_f, req.sigma, req.ko_pct, req.strike_pct, + req.qty, req.gearing, req.fixings_per_year, req.n_paths, jump=jump) + except ValueError as e: + raise HTTPException(422, f"No zero-cost strike: {e}") + return _clean({"pair": req.pair.upper(), "spot": req.S, **rep}) + + +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, dict): + return {k: _clean(v) for k, v in obj.items()} + if isinstance(obj, (list, tuple, np.ndarray)): + return [_clean(v) for v in obj] + return obj diff --git a/backend/tests/test_event_vol.py b/backend/tests/test_event_vol.py new file mode 100644 index 0000000..20eb0bd --- /dev/null +++ b/backend/tests/test_event_vol.py @@ -0,0 +1,140 @@ +"""Event & weekend vol: vol time, exact recovery of planted parameters, realised study, endpoints.""" + +from datetime import datetime, timedelta, timezone + +import numpy as np +import pytest +from fastapi.testclient import TestClient + +from app.core.event_vol import (FOMC_PAST, default_events, et_to_utc, fit_term_structure, + forward_vols, is_weekend, model_vol, realised_study, vol_time) +from app.main import app +from app.routers import eventvol as ev_router +from app.routers import news as news_router +from app.routers import vol as vol_router + +NOW = datetime(2026, 10, 11, 5, 0, tzinfo=timezone.utc) # a Sunday +client = TestClient(app) + + +def test_calendar_and_dst(): + assert et_to_utc("2026-10-28", 14).hour == 18 # EDT + assert et_to_utc("2026-12-09", 14).hour == 19 # EST + evs = default_events(NOW, 60) + assert [e["type"] for e in evs][:3] == ["CPI", "FOMC", "NFP"] + assert is_weekend(datetime(2026, 10, 9, 22, tzinfo=timezone.utc), "fx") # Fri 22:00 FX closed + assert not is_weekend(datetime(2026, 10, 9, 22, tzinfo=timezone.utc), "crypto") + + +def test_vol_time_limits(): + end = NOW + timedelta(days=28) # 4 whole weeks + tau1, wk, we = vol_time(NOW, end, 1.0, "crypto") + assert tau1 == pytest.approx(28 / 365) and we == pytest.approx(8 / 365) + tau0, _, _ = vol_time(NOW, end, 0.0, "fx") + assert tau0 == pytest.approx(20 / 365, abs=1e-9) # 20 weekdays of FX trading + + +@pytest.mark.parametrize("market,base,w,moves", [ + ("crypto", 0.42, 0.65, {"FOMC": 0.035, "CPI": 0.025, "NFP": 0.02}), + ("fx", 0.16, 0.15, {"FOMC": 0.012, "CPI": 0.010, "NFP": 0.008}), +]) +def test_fit_recovers_planted_parameters(market, base, w, moves): + evs = default_events(NOW, 120) + ev_var = {k: v ** 2 for k, v in moves.items()} + exps = [] + for d in [1, 2, 3, 4, 5, 6, 7, 12, 19, 26, 33, 47, 54, 82]: + t = (NOW + timedelta(days=d)).replace(hour=8) + exps.append({"time": t, "atm_vol": model_vol(NOW, t, base, w, evs, ev_var, market)["vol"]}) + f = fit_term_structure(NOW, exps, evs, market) + assert f["base_vol"] == pytest.approx(base, rel=1e-6) + assert f["weekend_weight"] == pytest.approx(w) + for k, v in moves.items(): + assert np.sqrt(f["event_variance"][k]) == pytest.approx(v, rel=1e-5) + assert f["identified"][k] + assert f["rmse_vol"] < 1e-8 + + +def test_event_variance_is_additive_and_forward_arbitrage(): + evs = default_events(NOW, 30) + end = et_to_utc("2026-10-15", 10) # just after CPI + a = model_vol(NOW, end, 0.2, 0.2, evs, {}, "fx") + b = model_vol(NOW, end, 0.2, 0.2, evs, {"CPI": 0.01 ** 2}, "fx") + assert b["vol"] ** 2 * b["T"] - a["vol"] ** 2 * a["T"] == pytest.approx(1e-4) + assert forward_vols([(0.1, 0.30), (0.2, 0.20)])[0]["arbitrage"] # total variance falls + assert not forward_vols([(0.1, 0.20), (0.2, 0.20)])[0]["arbitrage"] + + +def test_realised_study_recovers_weekend_weight(): + rng = np.random.default_rng(7) + d, dates, c = datetime(2018, 1, 1), [], [100.0] + dates.append(d) + while len(dates) < 2200: + d += timedelta(days=1) + if d.weekday() >= 5: + continue + var = 0.01 ** 2 * (1 + (2 * 0.3 if d.weekday() == 0 else 0)) + c.append(c[-1] * np.exp(rng.normal(0, np.sqrt(var)))) + dates.append(d) + rs = realised_study([x.isoformat() for x in dates], c, "fx", fomc_dates=[]) + assert rs["weekend_weight"] == pytest.approx(0.3, abs=0.07) + assert rs["weekday_vol_ann"] == pytest.approx(0.01 * np.sqrt(365), rel=0.05) + + +def test_realised_fomc_lump(): + rng = np.random.default_rng(3) + d, dates, c = datetime(2024, 1, 1), [datetime(2024, 1, 1)], [100.0] + fomc = {datetime.fromisoformat(x).date() for x in FOMC_PAST} + while d < datetime(2025, 12, 31): + d += timedelta(days=1) + lump = 0.03 ** 2 if d.date() in fomc else 0.0 + c.append(c[-1] * np.exp(rng.normal(0, np.sqrt(0.005 ** 2 + lump)))) + dates.append(d) + rs = realised_study([x.isoformat() for x in dates], c, "crypto", fomc_dates=FOMC_PAST) + assert rs["fomc"]["n"] == 16 + assert rs["fomc"]["event_sd_pct"] == pytest.approx(3.0, abs=0.9) + assert rs["weekend_weight"] == pytest.approx(1.0, abs=0.25) + + +@pytest.fixture +def offline(monkeypatch): + monkeypatch.setattr(ev_router, "_now", lambda: NOW) + monkeypatch.setattr(news_router, "_load_calendar", lambda: (_ for _ in ()).throw(RuntimeError("offline"))) + + +def test_crypto_endpoint(monkeypatch, offline): + evs = default_events(NOW, 200) + ev_var = {"FOMC": 0.03 ** 2, "CPI": 0.02 ** 2, "NFP": 0.015 ** 2} + rows = [] + for d in [1, 2, 3, 5, 12, 19, 26, 54, 82, 173]: + t = (NOW + timedelta(days=d)).replace(hour=8) + rows.append({"expiry": t.strftime("%Y-%m-%d"), "label": t.strftime("%d%b%y").upper(), "days": d, + "atm_vol": model_vol(NOW, t, 0.45, 0.7, evs, ev_var, "crypto")["vol"]}) + monkeypatch.setattr(vol_router, "get_crypto_vol", lambda ccy: {"expiries": rows, "index_price": 62000.0}) + r = client.get("/api/eventvol/crypto/BTC") + assert r.status_code == 200, r.text + js = r.json() + assert js["fit"]["base_vol"] == pytest.approx(0.45, rel=1e-4) + assert js["moves"]["FOMC"]["event_sd_pct"] == pytest.approx(3.0, rel=1e-3) + assert len(js["curve"]) >= 85 and len(js["tenors"]) == 6 + assert client.get("/api/eventvol/crypto/XRP").status_code == 404 + + +def test_model_endpoint(offline): + base = {"pair": "XAUUSD", "S": 4400, "base_vol": 0.18, "weekend_weight": 0.15, + "event_moves_pct": {"FOMC": 1.4, "CPI": 1.1, "NFP": 1.0}, "expiry_days": 4} + r = client.post("/api/eventvol/model", json=base) + assert r.status_code == 200, r.text + js = r.json() + assert js["calculator"]["share"]["events"] > 0.15 # 4-day option spans CPI + on = js["tenors"][0] + assert on["tenor"] == "O/N" and on["vol"] > 0 + # Fit mode: three quotes → event lumps held at the stated moves, base vol solved. + q = client.post("/api/eventvol/model", json={**base, "quotes": [{"days": 7, "vol": 0.21}, + {"days": 30, "vol": 0.20}, {"days": 91, "vol": 0.19}]}) + assert q.status_code == 200 and q.json()["fit"]["events_fixed"] is True + assert q.json()["moves"]["FOMC"]["event_sd_pct"] == pytest.approx(1.4) + # Dense quotes + fit_events frees the lumps. + dense = [{"days": d, "vol": 0.2} for d in (1, 2, 3, 4, 5, 7, 14, 21, 30, 61, 91)] + q2 = client.post("/api/eventvol/model", json={**base, "quotes": dense, "fit_events": True}) + assert q2.status_code == 200 and q2.json()["fit"]["events_fixed"] is False + assert client.post("/api/eventvol/model", json={**base, "pair": "NOPE"}).status_code == 404 diff --git a/backend/tests/test_exotics.py b/backend/tests/test_exotics.py new file mode 100644 index 0000000..9e9f634 --- /dev/null +++ b/backend/tests/test_exotics.py @@ -0,0 +1,96 @@ +"""Exotics desk: Haug reference values, parity, BGK vs Monte Carlo, touches, DCI, accumulator.""" + +import numpy as np +import pytest +from fastapi.testclient import TestClient + +from app.core.exotics import (accumulator_report, barrier_mc, barrier_price, dci_quote, + digital_prices, gk_vanilla, simulate_paths, touch_prices) +from app.main import app + +S, T, R, Q, V = 100.0, 0.5, 0.08, 0.04, 0.25 +client = TestClient(app) + + +@pytest.mark.parametrize("cp,d,k,K,H,ref", [ + # Haug (2007) Table 4-13, rebate 3. + ("call", "down", "out", 90, 95, 9.0246), ("call", "down", "in", 90, 95, 7.7627), + ("call", "up", "out", 90, 105, 2.6789), ("call", "up", "in", 90, 105, 14.1112), + ("put", "down", "out", 90, 95, 2.2798), ("put", "down", "in", 90, 95, 2.9586), + ("put", "up", "out", 90, 105, 3.7760), ("put", "up", "in", 90, 105, 1.4653), +]) +def test_haug_table(cp, d, k, K, H, ref): + assert barrier_price(S, K, H, T, R, Q, V, cp == "call", d, k, 3.0) == pytest.approx(ref, abs=1e-4) + + +def test_in_out_parity_and_breached(): + for cp in (True, False): + for d, H in (("down", 95), ("up", 105)): + for K in (90, 100, 110): + tot = barrier_price(S, K, H, T, R, Q, V, cp, d, "in") + barrier_price(S, K, H, T, R, Q, V, cp, d, "out") + assert tot == pytest.approx(gk_vanilla(S, K, T, R, Q, V, cp), abs=1e-9) + assert barrier_price(S, 100, 101, T, R, Q, V, True, "down", "out", 2.0) == 2.0 + assert barrier_price(S, 100, 99, T, R, Q, V, True, "up", "in") == pytest.approx(gk_vanilla(S, 100, T, R, Q, V)) + + +@pytest.mark.parametrize("K,H,c,d,k", [(100, 95, True, "down", "out"), (100, 105, False, "up", "in")]) +def test_bgk_matches_discrete_mc(K, H, c, d, k): + mc, se = barrier_mc(S, K, H, T, R, Q, V, c, d, k, n_steps=126, n_paths=100_000) + bgk = barrier_price(S, K, H, T, R, Q, V, c, d, k, monitoring_per_year=252) + cont = barrier_price(S, K, H, T, R, Q, V, c, d, k) + assert abs(bgk - mc) < 4 * se + 0.01 + assert abs(cont - mc) > abs(bgk - mc) + + +def test_touches_vs_mc(): + t = touch_prices(S, 110, T, R, Q, V, monitoring_per_year=252) + p = simulate_paths(S, T, R, Q, V, 126, 100_000, np.random.default_rng(1)) + nt = np.exp(-R * T) * (~(p >= 110).any(axis=1)).mean() + assert t["no_touch"] == pytest.approx(nt, abs=0.006) + assert t["no_touch"] + t["one_touch_at_expiry"] == pytest.approx(np.exp(-R * T)) + assert t["one_touch_at_hit"] > t["one_touch_at_expiry"] # paid earlier when r > 0 + + +def test_smile_digital_is_minus_dC_dK(): + """With a skewed σ(K) the digital equals the finite-difference −∂C/∂K.""" + sig = lambda K: 0.25 - 0.002 * (K - 100) + K, h = 102.0, 1e-3 + fd = -(gk_vanilla(S, K + h, T, R, Q, sig(K + h)) - gk_vanilla(S, K - h, T, R, Q, sig(K - h))) / (2 * h) + d = digital_prices(S, K, T, R, Q, 0.25, smile_vol=sig(K), smile_slope=-0.002) + assert d["call_smile"] == pytest.approx(fd, abs=1e-6) + assert d["call_smile"] > d["call_flat"] # negative skew richens call digitals + + +def test_dci_fair_apr_and_implied_vol(): + q = dci_quote(60000, 65000, 7 / 365, 0.04, 0.0, 0.5, "sell_high") + assert 0 < q["prob_conversion"] < 0.5 and q["fair_apr"] > 0 + audit = dci_quote(60000, 65000, 7 / 365, 0.04, 0.0, 0.5, "sell_high", offered_apr=q["fair_apr"] * 0.8) + assert audit["margin_apr"] > 0 and audit["implied_vol_paid"] < 0.5 + hi = dci_quote(60000, 65000, 7 / 365, 0.04, 0.0, 0.8, "sell_high") + assert hi["fair_apr"] > q["fair_apr"] + + +def test_accumulator_zero_cost(): + rep = accumulator_report(S, 0.5, 0.04, 0.0, 0.3, ko_pct=105, n_paths=5000) + assert abs(rep["pv_to_client"]) < 1e-6 + assert rep["zero_cost_strike"] < S and 0 < rep["prob_knock_out"] < 1 + j = accumulator_report(S, 0.5, 0.04, 0.0, 0.3, ko_pct=105, n_paths=5000, + jump={"lambda": 4, "mean": -0.05, "sd": 0.05}) + assert j["pnl"]["p01"] < rep["pnl"]["p01"] # crash jumps fatten the loss tail + + +def test_endpoints(): + base = {"pair": "XAUUSD", "S": 2650, "T": 0.25, "r_d": 0.0525, "r_f": 0.0, "sigma": 0.18} + r = client.post("/api/exotics/barrier", json={**base, "K": 2650, "H_down": 2450, "H_up": 2850, + "rr25": 0.02, "bf25": 0.005}) + assert r.status_code == 200, r.text + js = r.json() + assert len(js["rows"]) == 8 and len(js["touch_ladder"]) == 24 + assert js["digital"]["smile_adjustment"] != 0 + assert client.post("/api/exotics/barrier", json={**base, "K": 2650, "H_down": 2700, "H_up": 2850}).status_code == 422 + r = client.post("/api/exotics/dci", json={**base, "pair": "BTCUSD", "S": 60000, "T": 7 / 365, "sigma": 0.5, + "offered_apr": 0.3, "strike_pct_offer": 105}) + assert r.status_code == 200 and all(x["strike_pct"] > 100 for x in r.json()["ladder"]) + r = client.post("/api/exotics/accumulator", json={**base, "n_paths": 2000}) + assert r.status_code == 200 and r.json()["zero_cost_strike"] < 2650 + assert client.post("/api/exotics/dci", json={**base, "pair": "NOPE"}).status_code == 404 diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js index 0a43048..fe3c131 100644 --- a/frontend/src/api/client.js +++ b/frontend/src/api/client.js @@ -46,3 +46,13 @@ export const fetchInstitutional = (pair, weeks) => // Economic calendar (Forex Factory) export const fetchCalendar = () => api.get('/news/calendar').then(r => r.data) + +// Exotics Desk +export const priceBarriers = (p) => api.post('/exotics/barrier', p).then(r=>r.data) +export const priceDci = (p) => api.post('/exotics/dci', p).then(r=>r.data) +export const priceAccumulator = (p) => api.post('/exotics/accumulator', p).then(r=>r.data) + +// Event & Weekend Vol +export const fetchEventVolCrypto = (ccy) => api.get(`/eventvol/crypto/${ccy}`).then(r=>r.data) +export const fetchEventVolRealised = (pair) => api.get(`/eventvol/realised/${pair}`).then(r=>r.data) +export const priceEventVol = (p) => api.post('/eventvol/model', p).then(r=>r.data) diff --git a/frontend/src/components/EventVol.jsx b/frontend/src/components/EventVol.jsx new file mode 100644 index 0000000..9c411f5 --- /dev/null +++ b/frontend/src/components/EventVol.jsx @@ -0,0 +1,377 @@ +import { useEffect, useState } from 'react' +import _Plot from 'react-plotly.js' +const Plot = _Plot?.default ?? _Plot +import { usePortfolioStore, isCrypto, PAIR_META } from '../store/portfolio' +import { fetchEventVolCrypto, fetchEventVolRealised, priceEventVol } from '../api/client' + +const LAYOUT = { + paper_bgcolor:'transparent', plot_bgcolor:'#080d18', + font:{ color:'#94a3b8', size:11 }, + margin:{ l:52, r:16, t:12, b:42 }, + legend:{ bgcolor:'transparent', font:{ size:9 }, orientation:'h', x:0, y:-0.22 }, + hoverlabel:{ bgcolor:'#0f172a', font:{ color:'#e2e8f0' }, bordercolor:'rgba(0,212,255,0.15)' }, +} +const CFG = { responsive:true, displayModeBar:false } +const axis = (title, extra = {}) => ({ title:{ text:title }, gridcolor:'#1e293b', color:'#475569', zeroline:false, ...extra }) +const pct = (v, d = 1) => (v == null ? '—' : (v * 100).toFixed(d) + '%') +const num = (v, d = 2) => (v == null ? '—' : Number(v).toLocaleString(undefined, { minimumFractionDigits:d, maximumFractionDigits:d })) +const EV_COLOR = { FOMC:'#ff3d5a', CPI:'#ffd700', NFP:'#c084fc', OTHER:'#00ff88' } +const fmtTime = (iso) => new Date(iso).toUTCString().slice(5, 22) + ' UTC' + +// Starting assumptions per asset class: weekend weight and event 1-sd moves (%). +const DEFAULTS = { + metal: { weekend_weight:0.15, moves:{ FOMC:1.3, CPI:1.1, NFP:1.0, OTHER:0.8 } }, + fx: { weekend_weight:0.15, moves:{ FOMC:0.6, CPI:0.5, NFP:0.5, OTHER:0.5 } }, + crypto: { weekend_weight:0.70, moves:{ FOMC:2.5, CPI:2.0, NFP:1.5, OTHER:1.5 } }, +} +const defaultsFor = (pair, sigma) => { + const cls = PAIR_META[pair]?.assetClass ?? 'fx' + const d = DEFAULTS[cls] ?? DEFAULTS.fx + const silver = pair === 'XAGUSD' ? 1.6 : 1 + return { base_vol:+(sigma * 100).toFixed(1), weekend_weight:d.weekend_weight, + moves:Object.fromEntries(Object.entries(d.moves).map(([k, v]) => [k, +(v * silver).toFixed(2)])), + quotes:'', fitEvents:false, expiry_days:3, custom:{ name:'', time:'', type:'OTHER' } } +} + +function useLoad(fn, key, enabled = true) { + const [res, setRes] = useState({ key:null, data:null, error:null }) + useEffect(() => { + if (!enabled) return + let stale = false + const t = setTimeout(() => { + fn(JSON.parse(key)) + .then(data => { if (!stale) setRes({ key, data, error:null }) }) + .catch(e => { if (!stale) setRes({ key, data:null, error: e?.response?.data?.detail?.toString() ?? 'Request failed — is the backend up?' }) }) + }, 250) + return () => { stale = true; clearTimeout(t) } + }, [fn, key, enabled]) + return { ...res, loading: res.key !== key } +} + +export default function EventVol() { + const { pair } = usePortfolioStore() + const [mode, setMode] = useState(null) + const live = mode ?? (isCrypto(pair) ? 'live' : 'model') + return ( +
+
+
+
Event & Weekend Vol — vol time, event moves, forward vols ({pair})
+
+ Strip FOMC / CPI / NFP lumps and the weekend weight out of the ATM curve · price short-dated and 0DTE vols on vol time +
+
+
+ {isCrypto(pair) && setMode('live')}>Fit live Deribit curve} + setMode('model')}>Model / fit quotes +
+
+ {live === 'live' && isCrypto(pair) ? : } + +
+ σ²(T)·T = σb²·τ(T) + Σ ve, with τ = hours to expiry weighted 1 (weekday) or w (weekend), ACT/365 (Clark 2011 "volatility + time"; Castagna 2010). Fit: non-negative least squares on total variance with a grid over w. FX/metal weekend = Fri 21:00 → Sun 21:00 UTC; + crypto = Sat/Sun UTC. Built-in 2026 FOMC (14:00 ET), CPI/NFP (08:30 ET) dates merged with live Forex Factory high-impact events. +
+
+ ) +} + +const Chip = ({ active, onClick, children }) => ( + +) + +function Card({ title, right, children }) { + return ( +
+
+ {title}{right} +
+ {children} +
+ ) +} + +function Kpis({ items }) { + return ( +
+ {items.map(k => ( +
+
{k.label}
+
{k.value}
+ {k.sub &&
{k.sub}
} +
+ ))} +
+ ) +} + +function ErrorBox({ msg }) { + return
{msg}
+} + +const inputStyle = { background:'var(--bg3)', border:'1px solid var(--border)', borderRadius:4, color:'var(--cyan)', fontSize:11, + padding:'4px 6px', fontFamily:'var(--font-mono)', width:'100%' } + +function Field({ label, value, onChange, step, hint, type = 'number', placeholder }) { + return ( + + ) +} + +function moveKpis(moves, spot, dp = 2) { + return Object.entries(moves ?? {}).filter(([, m]) => m.event_sd_pct > 0).map(([t, m]) => ({ + label:`${t} implied move`, value:`±${m.event_sd_pct.toFixed(2)}%`, color:EV_COLOR[t], + sub:`day ${m.multiple_of_normal_day?.toFixed(1)}× normal · E|Δ| ${spot ? num(m.expected_abs_move, dp) : m.expected_abs_move_pct.toFixed(2) + '%'}`, + hint:'1-sd event lump √v; day = base day + lump; E|Δ| = √(2/π)·day sd (straddle breakeven)' })) +} + +function eventShapes(events) { + return (events ?? []).map(e => ({ type:'line', x0:e.time, x1:e.time, yref:'paper', y0:0, y1:1, + line:{ color:EV_COLOR[e.type] ?? '#94a3b8', dash:'dot', width:1.2 } })) +} + +function CurveChart({ d, marketPoints }) { + const evs = d.events ?? [] + return ( + + c.date), y:d.curve.map(c => c.vol * 100), type:'scatter', mode:'lines+markers', name:'Model ATM vol', + line:{ color:'#00d4ff', width:2, shape:'hv' }, marker:{ size:4, color:d.curve.map(c => c.weekend ? '#475569' : '#00d4ff') } }, + { x:d.curve.map(c => c.date), y:d.curve.map(() => d.flat * 100), type:'scatter', mode:'lines', name:'Flat calendar vol (no events)', + line:{ color:'rgba(255,255,255,0.3)', dash:'dash', width:1 } }, + ...(marketPoints ? [{ x:marketPoints.map(p => p.time), y:marketPoints.map(p => p.vol * 100), type:'scatter', mode:'markers', + name:'Market (Deribit / quotes)', marker:{ color:'#ffd700', size:8, symbol:'diamond' } }] : []), + ...Object.keys(EV_COLOR).filter(t => evs.some(e => e.type === t)).map(t => ({ x:[null], y:[null], type:'scatter', mode:'lines', + name:t, line:{ color:EV_COLOR[t], dash:'dot' } })), + ]} + layout={{ ...LAYOUT, hovermode:'x unified', xaxis:axis('Expiry', { type:'date' }), yaxis:axis('ATM vol (%)'), shapes:eventShapes(evs) }} + config={CFG} style={{ width:'100%', height:300 }} /> +
+ Steps up at each event (dotted) and flattens over weekends: a 1-day option spanning FOMC can carry 2–3× the vol of one that doesn't. +
+
+ ) +} + +function TenorTable({ rows }) { + return ( + +
+ + + + + + {rows.map(r => ( + + + + + + + + + + ))} +
TenorExpiryATM volFwd volWeekdayWeekendEventsSpans
{r.tenor}{r.expiry.slice(0, 10)}{pct(r.vol, 2)}{pct(r.fwd_vol, 2)}{pct(r.share.weekday, 0)}{pct(r.share.weekend, 0)} 0.2 ? 'var(--red)' : undefined }}>{pct(r.share.events, 0)} + {r.events.map((t, i) => {t})} +
+
+
+ ) +} + +function FitTable({ fit }) { + return ( + +
+ + + + + + {fit.expiries.map(r => ( + + + + + + + + + ))} +
ExpiryDaysMarketModelErrorMkt fwd volEvent share
{r.label ?? r.time.slice(0, 10)}{r.days.toFixed(1)}{pct(r.market_vol, 2)}{pct(r.model_vol, 2)} 0.01 ? 'var(--red)' : undefined }}>{(r.error_vol * 100).toFixed(2)}{r.calendar_arb ? ARB : pct(r.fwd_vol_market, 1)}{pct(r.event_share, 0)}
+
+
+ Red row = negative forward variance between consecutive expiries (calendar arbitrage). Errors > 1 vol flag a mispriced expiry or an unmodelled event. +
+
+ ) +} + +function EventList({ events }) { + if (!events?.length) return null + return ( +
+ Scheduled events: + {events.slice(0, 10).map((e, i) => ( + {e.name} · {fmtTime(e.time)}))} +
+ ) +} + +// ─── Live Deribit fit ──────────────────────────────────────────────────────── + +function Live({ pair }) { + const ccy = pair.slice(0, 3) + const { data:d, error, loading } = useLoad(fetchEventVolCrypto, JSON.stringify(ccy)) + if (error) return + if (!d) return
Fitting the live Deribit ATM curve…
+ const f = d.fit + return ( +
+ + +
+ ({ time:e.time, vol:e.market_vol }))} /> + +
+ +
+ {d.source}. Event types whose dates fall between two listed expiries are identified exactly; with daily Deribit expiries that is usually all of them. +
+
+ ) +} + +// ─── Model / quote fit (any pair) ───────────────────────────────────────────── + +function parseQuotes(s) { + return s.split(/[,;\n]+/).map(x => x.trim()).filter(Boolean).map(x => { + const [days, vol] = x.split(/[:=\s]+/).map(Number) + return Number.isFinite(days) && Number.isFinite(vol) && days > 0 && vol > 0 ? { days, vol: vol / 100 } : null + }).filter(Boolean) +} + +function Model({ pair }) { + const { S, sigma } = usePortfolioStore() + const dp = PAIR_META[pair]?.dp ?? 4 + const [p, setP] = useState(() => defaultsFor(pair, sigma)) + const set = (k) => (v) => setP(q => ({ ...q, [k]: v })) + const setMove = (k) => (v) => setP(q => ({ ...q, moves:{ ...q.moves, [k]: v } })) + const setCustom = (k) => (v) => setP(q => ({ ...q, custom:{ ...q.custom, [k]: v } })) + const quotes = parseQuotes(p.quotes) + const custom = p.custom.time ? [{ name:p.custom.name || 'Custom event', type:p.custom.type, time:new Date(p.custom.time + 'Z').toISOString() }] : [] + const body = { pair, S, base_vol:p.base_vol / 100, weekend_weight:p.weekend_weight, event_moves_pct:p.moves, + quotes, fit_events:p.fitEvents, custom_events:custom, expiry_days:p.expiry_days > 0 ? p.expiry_days : null } + const { data:d, error, loading } = useLoad(priceEventVol, JSON.stringify(body), p.base_vol > 0) + const c = d?.calculator + + return ( +
+
+
+ + + {Object.keys(p.moves).map(k => )} + +
+
+ + + + + +
+
+ No free listed-option feed exists for {pair}: price from your own assumptions, or paste broker / OVML ATM quotes and the base vol + {quotes.length >= 3 ? ' is fitted' : ' will be fitted (≥ 3 quotes)'}. + Use the realised study below to anchor the weekend weight and FOMC move. +
+
+ {error && } + {d && ( +
+ 0.25 ? 'var(--red)' : 'var(--cyan)', + sub:`${pct(c.share.weekday, 0)} weekday · ${pct(c.share.weekend, 0)} weekend` }] : []), + { label: d.fit ? 'Fitted base vol' : 'Base vol', value:pct(d.base_vol), sub:`flat-calendar ${pct(d.flat_calendar_vol)}` }, + ...(d.fit ? [{ label:'Quote fit RMSE', value:`${(d.fit.rmse_vol * 100).toFixed(2)} vol`, sub: d.fit.events_fixed ? 'events held at inputs' : 'events fitted' }] : []), + ...moveKpis(d.moves, d.spot, dp), + ]} /> + +
+ ({ time:e.time, vol:e.market_vol })) : null} /> + +
+ {d.fit && } +
+ )} +
+ ) +} + +// ─── Realised study ─────────────────────────────────────────────────────────── + +function Realised({ pair }) { + const { data:d, error } = useLoad(fetchEventVolRealised, JSON.stringify(pair)) + if (error) return
Realised study unavailable ({error}).
+ if (!d) return null + return ( +
+ + r.day), y:d.day_of_week.map(r => r.vol_ann * 100), type:'bar', + marker:{ color:d.day_of_week.map(r => ['Sat', 'Sun', 'Mon'].includes(r.day) ? 'rgba(192,132,252,0.6)' : 'rgba(0,212,255,0.5)') }, + text:d.day_of_week.map(r => `n=${r.n}`), textposition:'outside', hovertemplate:'%{x}: %{y:.1f}%' }]} + layout={{ ...LAYOUT, showlegend:false, xaxis:axis(''), yaxis:axis('Close-to-close vol, ann. (%)') }} + config={CFG} style={{ width:'100%', height:240 }} /> +
+ {d.market === 'crypto' ? 'Weekend weight = Sat/Sun variance ÷ weekday variance.' : 'Monday spans the weekend: weight = (var Mon − var Tue–Fri) ÷ (2 · var Tue–Fri).'} +
+
+ + + {d.fomc && ( +
+ {d.fomc.recent.map(w => ( + = 0 ? 'var(--green)' : 'var(--red)' }}>{w.date.slice(2)} {w.move_2d_pct >= 0 ? '+' : ''}{w.move_2d_pct.toFixed(2)}%))} +
+ )} +
+ Compare the implied lumps above with the realised one: implied > realised is event vol risk premium you can sell (e.g. buy the + weekly calendar, sell the event day); implied < realised says the market is under-pricing the meeting. +
+
+
+ ) +} diff --git a/frontend/src/components/ExoticsDesk.jsx b/frontend/src/components/ExoticsDesk.jsx new file mode 100644 index 0000000..df8db14 --- /dev/null +++ b/frontend/src/components/ExoticsDesk.jsx @@ -0,0 +1,402 @@ +import { useEffect, useState } from 'react' +import _Plot from 'react-plotly.js' +const Plot = _Plot?.default ?? _Plot +import { usePortfolioStore, isCrypto, PAIR_META } from '../store/portfolio' +import { priceBarriers, priceDci, priceAccumulator } from '../api/client' + +const LAYOUT = { + paper_bgcolor:'transparent', plot_bgcolor:'#080d18', + font:{ color:'#94a3b8', size:11 }, + margin:{ l:56, r:16, t:12, b:42 }, + legend:{ bgcolor:'transparent', font:{ size:9 }, orientation:'h', x:0, y:-0.22 }, + hoverlabel:{ bgcolor:'#0f172a', font:{ color:'#e2e8f0' }, bordercolor:'rgba(0,212,255,0.15)' }, +} +const CFG = { responsive:true, displayModeBar:false } +const axis = (title, extra = {}) => ({ title:{ text:title }, gridcolor:'#1e293b', color:'#475569', zeroline:false, ...extra }) +const pct = (v, d = 1) => (v == null ? '—' : (v * 100).toFixed(d) + '%') +const num = (v, d = 2) => (v == null ? '—' : Number(v).toLocaleString(undefined, { minimumFractionDigits:d, maximumFractionDigits:d })) +const dpOf = (pair) => PAIR_META[pair]?.dp ?? (PAIR_META[pair]?.pip === 0.01 ? 3 : 5) +const roundTo = (x, dp) => +Number(x).toFixed(dp) + +// Debounced POST keyed by the request body; stale responses are dropped. +function useQuote(fn, body, enabled = true) { + const key = JSON.stringify(body) + const [res, setRes] = useState({ key:null, data:null, error:null }) + useEffect(() => { + if (!enabled) return + let stale = false + const t = setTimeout(() => { + fn(JSON.parse(key)) + .then(data => { if (!stale) setRes({ key, data, error:null }) }) + .catch(e => { if (!stale) setRes({ key, data:null, error: e?.response?.data?.detail?.toString() ?? 'Pricing failed — is the backend up?' }) }) + }, 250) + return () => { stale = true; clearTimeout(t) } + }, [fn, key, enabled]) + return { ...res, loading: res.key !== key } +} + +export default function ExoticsDesk() { + const { pair } = usePortfolioStore() + const [tab, setTab] = useState('barrier') + return ( +
+
+
+
Exotics Desk — barriers, touches, Dual Investment & accumulators ({pair})
+
+ Price what clients are actually sold: KO/KI and touch structures, crypto/gold DCI yields, KO accumulators +
+
+
+ setTab('barrier')}>Barriers & touches + setTab('dci')}>Dual Investment (DCI) + setTab('acc')}>Accumulator +
+
+ {tab === 'barrier' && } + {tab === 'dci' && } + {tab === 'acc' && } +
+ ) +} + +const Chip = ({ active, onClick, children }) => ( + +) + +function Card({ title, right, children }) { + return ( +
+
+ {title}{right} +
+ {children} +
+ ) +} + +function Kpis({ items }) { + return ( +
+ {items.map(k => ( +
+
{k.label}
+
{k.value}
+ {k.sub &&
{k.sub}
} +
+ ))} +
+ ) +} + +function ErrorBox({ msg }) { + return
{msg}
+} + +const inputStyle = { background:'var(--bg3)', border:'1px solid var(--border)', borderRadius:4, color:'var(--cyan)', fontSize:11, + padding:'4px 6px', fontFamily:'var(--font-mono)', width:'100%' } + +function Field({ label, value, onChange, step, hint }) { + return ( + + ) +} + +function Select({ label, value, onChange, options }) { + return ( + + ) +} + +const Panel = ({ children, note }) => ( +
+
{children}
+ {note &&
{note}
} +
+) + +const MONITORING = [['0', 'Continuous'], ['252', 'Daily close (252/y)'], ['365', 'Daily, 24/7 (365/y)'], ['8760', 'Hourly (8760/y)']] + +// ─── Barriers, touches & digitals ───────────────────────────────────────────── + +function Barriers({ pair }) { + const { S, sigma, T, r_d, r_f, smileQuotes } = usePortfolioStore() + const dp = dpOf(pair) + const [p, setP] = useState(() => { + const sd = sigma * Math.sqrt(T) + return { K: roundTo(S, dp), H_down: roundTo(S * Math.exp(-sd), dp), H_up: roundTo(S * Math.exp(sd), dp), + T: +T, sigma: +(sigma * 100).toFixed(2), rebate: 0, monitoring: isCrypto(pair) ? '365' : '252', + product: 'call|up|out', useSmile: !!smileQuotes } + }) + const set = (k) => (v) => setP(q => ({ ...q, [k]: v })) + const [cp, direction, knock] = p.product.split('|') + const smile = p.useSmile && smileQuotes + const body = { pair, S, T: p.T, r_d, r_f, sigma: (smile ? smileQuotes.atm : p.sigma) / 100, K: p.K, H_down: p.H_down, H_up: p.H_up, + rebate: p.rebate, monitoring_per_year: +p.monitoring || null, is_call: cp === 'call', direction, knock, + rr25: smile ? smileQuotes.rr25 / 100 : null, bf25: smile ? smileQuotes.bf25 / 100 : null } + const { data: d, error, loading } = useQuote(priceBarriers, body) + const sel = d?.rows.find(r => r.type === cp && r.direction === direction && r.knock === knock) + const [tDown, tUp] = d?.touches ?? [] + + return ( +
+ + Reiner–Rubinstein closed forms (Haug A–F) with rebates; discrete fixings via the Broadie–Glasserman–Kou shift + H·e±0.5826σ√Δt{d ? ` (${d.bgk_shift_pct.toFixed(2)}% here)` : ''}. Crypto barriers monitor 24/7 — use 365/y. + {smileQuotes ? ' Digitals use the vanna-volga smile from Vol Smile / Vol Lab when "smile" is on.' : ' Load RR/BF quotes in Vol Smile or Vol Lab to get smile-consistent digitals.'} + }> + + {smileQuotes && + + + set('offered')(Number.isFinite(v) ? v : '')} step={1} /> + set('strikeOffer')(Number.isFinite(v) ? v : '')} step={1} /> + + {error && } + {d && ( +
+ {a && 0 ? 'var(--red)' : 'var(--green)', + hint:'Fair − offered: the yield the platform keeps' }, + { label:'Vol you are paid', value:pct(a.implied_vol_paid, 1), sub: a.vol_haircut != null ? `${(a.vol_haircut * 100).toFixed(1)} vol below σ` : null, + color:'var(--gold, #ffd700)', hint:'Implied vol at which the offered APR is fair' }, + { label:'P(conversion)', value:pct(a.prob_conversion, 1), color:'var(--red)' }, + { label:'Breakeven', value:num(a.breakeven, dp) }, + ]} />} +
+ + r.strike), y:d.ladder.map(r => r.fair_apr * 100), type:'bar', name:'Fair APR %', marker:{ color:'rgba(0,212,255,0.55)' } }, + { x:d.ladder.map(r => r.strike), y:d.ladder.map(r => r.prob_conversion * 100), type:'scatter', mode:'lines+markers', name:'P(conversion) %', + yaxis:'y2', line:{ color:'#ff3d5a', width:2 } }, + ...(a ? [{ x:[S * a.strike_pct / 100], y:[a.offered_apr * 100], type:'scatter', mode:'markers', name:'Offered', + marker:{ color:'#ffd700', size:12, symbol:'diamond' } }] : []), + ]} + layout={{ ...LAYOUT, hovermode:'x unified', xaxis:axis('Target price (strike)'), yaxis:axis('APR %'), + yaxis2:{ overlaying:'y', side:'right', color:'#ff3d5a', showgrid:false, zeroline:false, title:{ text:'P(conv) %' } } }} + config={CFG} style={{ width:'100%', height:300 }} /> + + +
+ + + + + {d.ladder.map(r => ( + + + + + + + ))} +
Strike% spotPremiumFair APRP(conv)Breakeven
{num(r.strike, dp)}{r.strike_pct}%{r.premium_pct.toFixed(3)}%{pct(r.fair_apr, 1)}{pct(r.prob_conversion, 1)}{num(r.breakeven, dp)}
+
+
+ Breakeven: the spot at which converting at K leaves you level with simply holding, after the premium. +
+
+
+
+ )} +
+ ) +} + +// ─── Accumulator ────────────────────────────────────────────────────────────── + +function Accumulator({ pair }) { + const { S, sigma, r_d, r_f, hedgeJump } = usePortfolioStore() + const dp = dpOf(pair) + const [p, setP] = useState(() => ({ months:6, sigma:+(sigma * 100).toFixed(2), ko:105, strike:'', gearing:2, qty:1, + fixings: isCrypto(pair) ? 365 : 252, jumps: !!hedgeJump || isCrypto(pair), + jump: hedgeJump ?? (isCrypto(pair) ? { intensity:12, mean:-2, sd:6 } : { intensity:2, mean:-5, sd:6 }) })) + const set = (k) => (v) => setP(q => ({ ...q, [k]: v })) + const setJ = (k) => (v) => setP(q => ({ ...q, jump:{ ...q.jump, [k]: v } })) + const body = { pair, S, T: p.months / 12, r_d, r_f, sigma: p.sigma / 100, ko_pct: p.ko, strike_pct: p.strike === '' ? null : +p.strike, + qty: p.qty, gearing: p.gearing, fixings_per_year: p.fixings, n_paths: 20000, + jump_intensity: p.jumps ? p.jump.intensity : 0, jump_mean: p.jumps ? p.jump.mean / 100 : 0, jump_sd: p.jumps ? p.jump.sd / 100 : 0 } + const { data: d, error, loading } = useQuote(priceAccumulator, body, p.months > 0) + + return ( +
+ + "I kill you later": the client buys {p.qty} unit(s) at K every fixing, {p.gearing}× when the fixing is below K, until a fixing + trades ≥ KO. Leaving the strike blank solves the zero-cost strike by Monte Carlo (20k paths, common random numbers). + Jumps come from Jump Lab when sent there. P&L marks accumulated units at the KO / final fixing. + }> + + + + set('strike')(Number.isFinite(v) && v > 0 ? v : '')} step={0.5} /> + + + set('jumps')(v === 'jump')} options={[['gbm', 'GBM'], ['jump', 'Merton jumps']]} /> + {p.jumps && <> + + + + } + + {error && } + {d && ( +
+ = 0 ? 'var(--green)' : 'var(--red)' }, + { label:'P(knock-out)', value:pct(d.prob_knock_out), sub: d.expected_ko_days ? `avg after ${d.expected_ko_days.toFixed(0)} days` : null }, + { label:'Expected units', value:num(d.expected_units, 1), sub:`max ${num(d.max_units, 0)}` }, + { label:'P(loss)', value:pct(d.pnl.prob_loss), color:'var(--red)' }, + { label:'1% worst P&L', value:num(d.pnl.p01, dp), sub:`ES5% ${num(d.pnl.es05, dp)}`, color:'var(--red)' }, + ]} /> +
+ + c < 0 ? 'rgba(255,61,90,0.6)' : 'rgba(0,255,136,0.55)') } }]} + layout={{ ...LAYOUT, showlegend:false, bargap:0.05, xaxis:axis(`P&L (${pair.slice(3)})`), yaxis:axis('Paths'), + shapes:[{ type:'line', x0:d.pnl.p01, x1:d.pnl.p01, yref:'paper', y0:0, y1:1, line:{ color:'#ff3d5a', dash:'dot', width:1.5 } }] }} + config={CFG} style={{ width:'100%', height:300 }} /> +
+ Capped upside (knocked out early), uncapped geared downside — the asymmetry behind the 2008 and 2022 accumulator blow-ups. +
+
+ + ({ y, type:'scatter', mode:'lines', name:`path ${i + 1}`, line:{ width:1.2 } }))} + layout={{ ...LAYOUT, showlegend:false, xaxis:axis('Fixing (sampled)'), yaxis:axis('Spot'), + shapes:[ + { type:'line', xref:'paper', x0:0, x1:1, y0:d.ko_level, y1:d.ko_level, line:{ color:'#00ff88', dash:'dash', width:1.5 } }, + { type:'line', xref:'paper', x0:0, x1:1, y0:d.strike, y1:d.strike, line:{ color:'#ff3d5a', dash:'dash', width:1.5 } }] }} + config={CFG} style={{ width:'100%', height:300 }} /> +
Green = KO level, red = strike (below it the client buys {p.gearing}×).
+
+
+
+ )} +
+ ) +} diff --git a/frontend/src/pages/Dashboard.jsx b/frontend/src/pages/Dashboard.jsx index e33ed64..7cf0309 100644 --- a/frontend/src/pages/Dashboard.jsx +++ b/frontend/src/pages/Dashboard.jsx @@ -14,6 +14,8 @@ import VolForecast from '../components/VolForecast' import HedgeLab from '../components/HedgeLab' import CrossAsset from '../components/CrossAsset' import JumpLab from '../components/JumpLab' +import ExoticsDesk from '../components/ExoticsDesk' +import EventVol from '../components/EventVol' const IVSurface = lazy(() => import('../components/IVSurface')) const WorldMap = lazy(() => import('../components/WorldMap')) @@ -46,8 +48,10 @@ const TABS = [ { id:'signals', icon:'⚡', label:'AI Signals', component: QuantSignals }, { id:'vollab', icon:'🔬', label:'Vol Lab', component: VolLab }, { id:'volforecast', icon:'🤖', label:'Vol Forecast', component: VolForecast }, + { id:'eventvol', icon:'📆', label:'Event Vol', component: EventVol }, { id:'hedgelab', icon:'⚖', label:'Hedge Lab', component: HedgeLab }, { id:'jumps', icon:'⚡', label:'Jump Lab', component: JumpLab }, + { id:'exotics', icon:'🧩', label:'Exotics Desk', component: ExoticsDesk }, { id:'xasset', icon:'🔗', label:'Cross-Asset', component: CrossAsset }, { id:'smile', icon:'〰', label:'Vol Smile', component: VolSmile }, { id:'surfaces', icon:'🗻', label:'3D Surfaces', component: SurfacePlot }, diff --git a/screenshots/event-vol-btc.png b/screenshots/event-vol-btc.png new file mode 100644 index 0000000..dd8b5d8 Binary files /dev/null and b/screenshots/event-vol-btc.png differ diff --git a/screenshots/event-vol-xau.png b/screenshots/event-vol-xau.png new file mode 100644 index 0000000..d3e956a Binary files /dev/null and b/screenshots/event-vol-xau.png differ diff --git a/screenshots/exotics-accumulator-xag.png b/screenshots/exotics-accumulator-xag.png new file mode 100644 index 0000000..ec697da Binary files /dev/null and b/screenshots/exotics-accumulator-xag.png differ diff --git a/screenshots/exotics-barriers-xau.png b/screenshots/exotics-barriers-xau.png new file mode 100644 index 0000000..6439a12 Binary files /dev/null and b/screenshots/exotics-barriers-xau.png differ diff --git a/screenshots/exotics-dci-btc.png b/screenshots/exotics-dci-btc.png new file mode 100644 index 0000000..52c0b36 Binary files /dev/null and b/screenshots/exotics-dci-btc.png differ