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 }) => (
+ {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 (
+ {label}
+ onChange(type === 'number' ? +e.target.value : e.target.value)} style={inputStyle} />
+
+ )
+}
+
+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 (
+
+
+
+
+ Tenor Expiry ATM vol
+ Fwd vol Weekday Weekend
+ Events Spans
+ {rows.map(r => (
+
+ {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 (
+
+
+
+
+ Expiry Days Market
+ Model Error Mkt fwd vol
+ Event share
+ {fit.expiries.map(r => (
+
+ {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 => )}
+
+
+
+
+ Event lumps in fit
+ set('fitEvents')(e.target.value === 'fit')} style={inputStyle}>
+ Hold at my moves Fit from quotes
+
+
+
+
+ Event type
+ setCustom('type')(e.target.value)} style={inputStyle}>
+ {['OTHER', 'FOMC', 'CPI', 'NFP'].map(t => {t} )}
+
+
+
+
+ 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 }) => (
+ {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 (
+ {label}
+ onChange(+e.target.value)} style={inputStyle} />
+
+ )
+}
+
+function Select({ label, value, onChange, options }) {
+ return (
+ {label}
+ onChange(e.target.value)} style={inputStyle}>
+ {options.map(([v, l]) => {l} )}
+
+
+ )
+}
+
+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('useSmile')(v === 'smile')}
+ options={[['smile', `Smile (RR ${smileQuotes.rr25}, BF ${smileQuotes.bf25})`], ['flat', 'Flat σ']]} />}
+
+ {error &&
}
+ {d && (
+
+
= 0 ? 'var(--green)' : 'var(--red)', hint:'Price per 1 unit paid if S_T > K (−∂C/∂K under the smile)' },
+ ]} />
+
+
+
+
+ Red = barrier, gold = spot. Watch Δ near the barrier: a reverse KO (barrier in the money) has a delta gap — the pin risk dealers hedge with barrier shifts.
+
+
+
+ r.barrier), y:d.touch_ladder.map(r => r.prob_touch * 100), type:'bar', name:'P(touch)',
+ marker:{ color:d.touch_ladder.map(r => r.barrier > S ? 'rgba(0,255,136,0.55)' : 'rgba(255,61,90,0.55)') },
+ customdata:d.touch_ladder.map(r => r.sd.toFixed(2)), hovertemplate:'H %{x} %{customdata} sd P(touch) %{y:.1f}% ' },
+ ]}
+ layout={{ ...LAYOUT, showlegend:false, xaxis:axis('Barrier'), yaxis:axis('P(touch before expiry) %', { range:[0, 100] }),
+ shapes:[{ type:'line', x0:S, x1:S, yref:'paper', y0:0, y1:1, line:{ color:'rgba(255,215,0,0.5)', dash:'dot', width:1 } }] }}
+ config={CFG} style={{ width:'100%', height:300 }} />
+
+ Risk-neutral odds of a level trading before expiry — compare with prediction-market "will gold hit X" contracts.
+
+
+
+
+
+ )}
+
+ )
+}
+
+function BarrierTable({ rows, dp, sel }) {
+ const name = r => `${r.direction === 'up' ? 'Up' : 'Down'}-and-${r.knock} ${r.type}`
+ return (
+
+
+
+
+ Structure Barrier
+ Continuous Discrete (BGK)
+ Vanilla % vanilla
+ {rows.map(r => (
+
+ {name(r)}
+ {[r.barrier, r.continuous, r.discrete, r.vanilla].map((v, i) => (
+ {num(v, dp)} ))}
+ {r.pct_of_vanilla == null ? '—' : r.pct_of_vanilla.toFixed(1) + '%'}
+ ))}
+
+
+
+ )
+}
+
+// ─── Dual Currency Investment ────────────────────────────────────────────────
+
+function Dci({ pair }) {
+ const { S, sigma, r_d, r_f } = usePortfolioStore()
+ const dp = dpOf(pair)
+ const base = pair.slice(0, 3), quote = pair.slice(3)
+ const [p, setP] = useState({ side:'sell_high', days:7, sigma:+(sigma * 100).toFixed(2), offered:'', strikeOffer:'' })
+ const set = (k) => (v) => setP(q => ({ ...q, [k]: v }))
+ const strikes = p.side === 'sell_high' ? [101, 102, 103, 104, 105, 106, 108, 110, 115] : [85, 90, 92, 94, 95, 96, 97, 98, 99]
+ const audit = p.offered !== '' && p.strikeOffer !== ''
+ const body = { pair, S, T: p.days / 365, r_d, r_f, sigma: p.sigma / 100, side: p.side, strikes_pct: strikes,
+ offered_apr: audit ? +p.offered / 100 : null, strike_pct_offer: audit ? +p.strikeOffer : null }
+ const { data: d, error, loading } = useQuote(priceDci, body, p.days > 0)
+ const a = d?.audit
+
+ return (
+
+
+ A DCI ("Dual Investment" on Binance/OKX/Bybit/KuCoin, "Dual Currency Deposit" at banks) is a deposit plus a
+ short option. Sell-high: deposit {base}, short a {base} call — converts to {quote} at K if spot finishes above. Buy-low: deposit {quote},
+ short a put — you buy {base} at K. Fair APR = deposit rate + option premium ÷ tenor. Enter a platform's APR to see the vol it pays you.
+ >}>
+
+
+
+ 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 }} />
+
+
+
+
+
+ Strike % spot Premium
+ Fair APR P(conv) Breakeven
+ {d.ladder.map(r => (
+
+ {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('fixings')(+v)} options={[['252', '252 (trading days)'], ['365', '365 (24/7 crypto)'], ['52', '52 (weekly)']]} />
+ 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