Merge pull request #8 from Mihirkansara/claude/daily-job-scheduling-pr27a6

Daily: Event & Weekend Vol desk + Exotics Desk (barriers/BGK, touches, Dual Investment audit, accumulators)
This commit is contained in:
github-actions[bot]
2026-10-11 11:26:44 +00:00
committed by GitHub
17 changed files with 2018 additions and 2 deletions
+2
View File
@@ -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 | | **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 | | **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 | | **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 ### Live Intelligence
+290
View File
@@ -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()}
+267
View File
@@ -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))
+2 -2
View File
@@ -1,6 +1,6 @@
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from .routers import greeks, surface, market, montecarlo, scenarios, strategies, export, forex, institutional, news, livedata, smile, vol, 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", app = FastAPI(title="QuantRisk FX Terminal API",
description="Forex options risk analytics — Garman-Kohlhagen model.", description="Forex options risk analytics — Garman-Kohlhagen model.",
@@ -9,7 +9,7 @@ app = FastAPI(title="QuantRisk FX Terminal API",
app.add_middleware(CORSMiddleware, allow_origins=["*"], app.add_middleware(CORSMiddleware, allow_origins=["*"],
allow_credentials=True, allow_methods=["*"], allow_headers=["*"]) allow_credentials=True, allow_methods=["*"], allow_headers=["*"])
for router in [greeks, surface, market, montecarlo, scenarios, strategies, export, forex, institutional, news, livedata, smile, vol, 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.include_router(router.router, prefix="/api")
@app.get("/") @app.get("/")
+246
View File
@@ -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
+182
View File
@@ -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
+140
View File
@@ -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
+96
View File
@@ -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
+10
View File
@@ -46,3 +46,13 @@ export const fetchInstitutional = (pair, weeks) =>
// Economic calendar (Forex Factory) // Economic calendar (Forex Factory)
export const fetchCalendar = () => export const fetchCalendar = () =>
api.get('/news/calendar').then(r => r.data) 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)
+377
View File
@@ -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 (
<div style={{ padding:16 }}>
<div style={{ display:'flex', justifyContent:'space-between', alignItems:'flex-start', gap:12, marginBottom:12, flexWrap:'wrap' }}>
<div>
<div className="section-title">Event &amp; Weekend Vol — vol time, event moves, forward vols ({pair})</div>
<div style={{ fontSize:9, color:'var(--text-muted)', marginTop:2 }}>
Strip FOMC / CPI / NFP lumps and the weekend weight out of the ATM curve · price short-dated and 0DTE vols on vol time
</div>
</div>
<div style={{ display:'flex', gap:4 }}>
{isCrypto(pair) && <Chip active={live === 'live'} onClick={() => setMode('live')}>Fit live Deribit curve</Chip>}
<Chip active={live === 'model'} onClick={() => setMode('model')}>Model / fit quotes</Chip>
</div>
</div>
{live === 'live' && isCrypto(pair) ? <Live pair={pair} /> : <Model key={pair} pair={pair} />}
<Realised pair={pair} />
<div style={{ fontSize:9, color:'var(--text-muted)', marginTop:10, lineHeight:1.5 }}>
σ²(T)·T = σ<sub>b</sub>²·τ(T) + Σ v<sub>e</sub>, with τ = hours to expiry weighted 1 (weekday) or w (weekend), ACT/365 (Clark 2011 &quot;volatility
time&quot;; 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.
</div>
</div>
)
}
const Chip = ({ active, onClick, children }) => (
<button onClick={onClick} className="font-mono" style={{ padding:'3px 9px', borderRadius:4, fontSize:9, cursor:'pointer',
border:'1px solid ' + (active ? 'var(--cyan)' : 'var(--border)'), background: active ? 'rgba(0,212,255,0.1)' : 'transparent',
color: active ? 'var(--cyan)' : 'var(--text-muted)' }}>{children}</button>
)
function Card({ title, right, children }) {
return (
<div className="glass" style={{ borderRadius:8, padding:8 }}>
<div style={{ display:'flex', justifyContent:'space-between', alignItems:'center', margin:'2px 6px 4px', gap:8, flexWrap:'wrap' }}>
<span className="section-title" style={{ fontSize:10 }}>{title}</span>{right}
</div>
{children}
</div>
)
}
function Kpis({ items }) {
return (
<div style={{ display:'flex', gap:8, marginBottom:12, flexWrap:'wrap' }}>
{items.map(k => (
<div key={k.label} className="glass" title={k.hint} style={{ padding:'6px 12px', borderRadius:6, fontSize:11 }}>
<div style={{ color:'var(--text-muted)', fontSize:9, letterSpacing:'0.05em' }}>{k.label}</div>
<div className="font-mono" style={{ color: k.color ?? 'var(--cyan)', fontWeight:700, fontSize:14 }}>{k.value}</div>
{k.sub && <div className="font-mono" style={{ fontSize:9, color:'var(--text-muted)' }}>{k.sub}</div>}
</div>
))}
</div>
)
}
function ErrorBox({ msg }) {
return <div style={{ padding:8, borderRadius:6, fontSize:11, marginBottom:10, background:'rgba(255,61,90,0.1)', border:'1px solid #ff3d5a', color:'#ff3d5a' }}>{msg}</div>
}
const inputStyle = { background:'var(--bg3)', border:'1px solid var(--border)', borderRadius:4, color:'var(--cyan)', fontSize:11,
padding:'4px 6px', fontFamily:'var(--font-mono)', width:'100%' }
function Field({ label, value, onChange, step, hint, type = 'number', placeholder }) {
return (
<label title={hint} style={{ display:'flex', flexDirection:'column', gap:3, fontSize:9, color:'var(--text-muted)' }}>{label}
<input type={type} value={value} step={step} placeholder={placeholder}
onChange={e => onChange(type === 'number' ? +e.target.value : e.target.value)} style={inputStyle} />
</label>
)
}
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 (
<Card title="ATM term structure on vol time — daily expiries">
<Plot data={[
{ x:d.curve.map(c => 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 }} />
<div style={{ fontSize:9, color:'var(--text-muted)', padding:'0 8px 4px' }}>
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&apos;t.
</div>
</Card>
)
}
function TenorTable({ rows }) {
return (
<Card title="Standard tenors — vol, forward vol and what drives the variance">
<div style={{ overflowX:'auto' }}>
<table style={{ width:'100%', borderCollapse:'collapse', fontSize:11 }}>
<thead><tr style={{ color:'var(--text-muted)', fontSize:9, textAlign:'right' }}>
<th style={{ textAlign:'left', padding:'4px 8px' }}>Tenor</th><th style={{ padding:'4px 8px' }}>Expiry</th><th style={{ padding:'4px 8px' }}>ATM vol</th>
<th style={{ padding:'4px 8px' }}>Fwd vol</th><th style={{ padding:'4px 8px' }}>Weekday</th><th style={{ padding:'4px 8px' }}>Weekend</th>
<th style={{ padding:'4px 8px' }}>Events</th><th style={{ textAlign:'left', padding:'4px 8px' }}>Spans</th></tr></thead>
<tbody>{rows.map(r => (
<tr key={r.tenor} className="font-mono" style={{ borderTop:'1px solid var(--border)', textAlign:'right' }}>
<td style={{ textAlign:'left', padding:'5px 8px' }}>{r.tenor}</td>
<td style={{ padding:'5px 8px', color:'var(--text-muted)' }}>{r.expiry.slice(0, 10)}</td>
<td style={{ padding:'5px 8px', color:'var(--cyan)' }}>{pct(r.vol, 2)}</td>
<td style={{ padding:'5px 8px' }}>{pct(r.fwd_vol, 2)}</td>
<td style={{ padding:'5px 8px' }}>{pct(r.share.weekday, 0)}</td>
<td style={{ padding:'5px 8px' }}>{pct(r.share.weekend, 0)}</td>
<td style={{ padding:'5px 8px', color: r.share.events > 0.2 ? 'var(--red)' : undefined }}>{pct(r.share.events, 0)}</td>
<td style={{ textAlign:'left', padding:'5px 8px', fontSize:9 }}>
{r.events.map((t, i) => <span key={i} style={{ color:EV_COLOR[t], marginRight:4 }}>{t}</span>)}
</td>
</tr>))}</tbody>
</table>
</div>
</Card>
)
}
function FitTable({ fit }) {
return (
<Card title={`Fit by expiry · RMSE ${(fit.rmse_vol * 100).toFixed(2)} vol`}>
<div style={{ overflowX:'auto', maxHeight:300 }}>
<table style={{ width:'100%', borderCollapse:'collapse', fontSize:11 }}>
<thead><tr style={{ color:'var(--text-muted)', fontSize:9, textAlign:'right' }}>
<th style={{ textAlign:'left', padding:'4px 8px' }}>Expiry</th><th style={{ padding:'4px 8px' }}>Days</th><th style={{ padding:'4px 8px' }}>Market</th>
<th style={{ padding:'4px 8px' }}>Model</th><th style={{ padding:'4px 8px' }}>Error</th><th style={{ padding:'4px 8px' }}>Mkt fwd vol</th>
<th style={{ padding:'4px 8px' }}>Event share</th></tr></thead>
<tbody>{fit.expiries.map(r => (
<tr key={r.time} className="font-mono" style={{ borderTop:'1px solid var(--border)', textAlign:'right',
background: r.calendar_arb ? 'rgba(255,61,90,0.12)' : 'transparent' }}>
<td style={{ textAlign:'left', padding:'5px 8px' }}>{r.label ?? r.time.slice(0, 10)}</td>
<td style={{ padding:'5px 8px' }}>{r.days.toFixed(1)}</td>
<td style={{ padding:'5px 8px', color:'#ffd700' }}>{pct(r.market_vol, 2)}</td>
<td style={{ padding:'5px 8px', color:'var(--cyan)' }}>{pct(r.model_vol, 2)}</td>
<td style={{ padding:'5px 8px', color: Math.abs(r.error_vol) > 0.01 ? 'var(--red)' : undefined }}>{(r.error_vol * 100).toFixed(2)}</td>
<td style={{ padding:'5px 8px' }}>{r.calendar_arb ? <span style={{ color:'var(--red)' }}>ARB</span> : pct(r.fwd_vol_market, 1)}</td>
<td style={{ padding:'5px 8px' }}>{pct(r.event_share, 0)}</td>
</tr>))}</tbody>
</table>
</div>
<div style={{ fontSize:9, color:'var(--text-muted)', padding:'6px 8px' }}>
Red row = negative forward variance between consecutive expiries (calendar arbitrage). Errors &gt; 1 vol flag a mispriced expiry or an unmodelled event.
</div>
</Card>
)
}
function EventList({ events }) {
if (!events?.length) return null
return (
<div className="glass" style={{ borderRadius:8, padding:'6px 10px', marginBottom:12, fontSize:10, display:'flex', gap:12, flexWrap:'wrap' }}>
<span style={{ color:'var(--text-muted)' }}>Scheduled events:</span>
{events.slice(0, 10).map((e, i) => (
<span key={i} className="font-mono" style={{ color:EV_COLOR[e.type] }}>{e.name} · {fmtTime(e.time)}</span>))}
</div>
)
}
// ─── 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 <ErrorBox msg={`${error} — switch to "Model / fit quotes" to work offline.`} />
if (!d) return <div style={{ fontSize:11, color:'var(--text-muted)', padding:12 }}>Fitting the live Deribit ATM curve…</div>
const f = d.fit
return (
<div style={{ opacity: loading ? 0.6 : 1 }}>
<Kpis items={[
{ label:'Base (ex-event) vol', value:pct(f.base_vol), sub:`flat-calendar ${pct(f.base_vol * Math.sqrt((5 + 2 * f.weekend_weight) / 7))}`,
hint:'Weekday diffusive vol after removing event lumps' },
{ label:'Weekend weight', value:f.weekend_weight.toFixed(2), sub:'weekend hour ÷ weekday hour', color:'#c084fc' },
...moveKpis(d.moves, d.spot, 0),
]} />
<EventList events={d.events} />
<div style={{ display:'grid', gridTemplateColumns:'repeat(auto-fit, minmax(420px, 1fr))', gap:12, marginBottom:12 }}>
<CurveChart d={{ ...d, flat: f.base_vol * Math.sqrt((5 + 2 * f.weekend_weight) / 7) }}
marketPoints={f.expiries.map(e => ({ time:e.time, vol:e.market_vol }))} />
<FitTable fit={f} />
</div>
<TenorTable rows={d.tenors} />
<div style={{ fontSize:9, color:'var(--text-muted)', marginTop:6 }}>
{d.source}. Event types whose dates fall between two listed expiries are identified exactly; with daily Deribit expiries that is usually all of them.
</div>
</div>
)
}
// ─── 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 (
<div>
<div className="glass" style={{ borderRadius:8, padding:10, marginBottom:12 }}>
<div style={{ display:'grid', gridTemplateColumns:'repeat(auto-fill, minmax(110px, 1fr))', gap:10, alignItems:'end' }}>
<Field label="Base vol σ_b (%)" value={p.base_vol} onChange={set('base_vol')} step={0.5} hint="Weekday, ex-event vol (ACT/365)" />
<Field label="Weekend weight" value={p.weekend_weight} onChange={set('weekend_weight')} step={0.05} />
{Object.keys(p.moves).map(k => <Field key={k} label={`${k} move (±%)`} value={p.moves[k]} onChange={setMove(k)} step={0.1} />)}
<Field label="Calculator expiry (days)" value={p.expiry_days} onChange={set('expiry_days')} step={0.5} />
</div>
<div style={{ display:'grid', gridTemplateColumns:'2fr 0.9fr 1fr 1.1fr 0.8fr', gap:10, alignItems:'end', marginTop:10 }}>
<Field type="text" label="Market ATM quotes to fit — days:vol%, e.g. 7:21, 30:19.5, 91:18.8" value={p.quotes}
onChange={set('quotes')} placeholder="leave blank to price from the assumptions" />
<label title="Sparse tenors cannot pin event lumps; free them only with dense quotes around each event"
style={{ display:'flex', flexDirection:'column', gap:3, fontSize:9, color:'var(--text-muted)' }}>Event lumps in fit
<select value={p.fitEvents ? 'fit' : 'hold'} onChange={e => set('fitEvents')(e.target.value === 'fit')} style={inputStyle}>
<option value="hold">Hold at my moves</option><option value="fit">Fit from quotes</option>
</select>
</label>
<Field type="text" label="Custom event name" value={p.custom.name} onChange={setCustom('name')} placeholder="ECB / BoJ / election" />
<Field type="datetime-local" label="Event time (UTC)" value={p.custom.time} onChange={setCustom('time')} />
<label style={{ display:'flex', flexDirection:'column', gap:3, fontSize:9, color:'var(--text-muted)' }}>Event type
<select value={p.custom.type} onChange={e => setCustom('type')(e.target.value)} style={inputStyle}>
{['OTHER', 'FOMC', 'CPI', 'NFP'].map(t => <option key={t}>{t}</option>)}
</select>
</label>
</div>
<div style={{ fontSize:9, color:'var(--text-muted)', marginTop:6 }}>
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.
</div>
</div>
{error && <ErrorBox msg={error} />}
{d && (
<div style={{ opacity: loading ? 0.6 : 1, transition:'opacity .2s' }}>
<Kpis items={[
...(c ? [{ label:`Fair ATM vol · ${p.expiry_days}d`, value:pct(c.vol, 2), sub:`expiry ${c.expiry.slice(0, 16).replace('T', ' ')} UTC`,
hint:'Model vol to the calculator expiry' },
{ label:'Variance mix', value:`${pct(c.share.events, 0)} events`, color: c.share.events > 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),
]} />
<EventList events={d.events} />
<div style={{ display:'grid', gridTemplateColumns:'repeat(auto-fit, minmax(420px, 1fr))', gap:12, marginBottom:12 }}>
<CurveChart d={{ ...d, flat:d.flat_calendar_vol }} marketPoints={d.fit ? d.fit.expiries.map(e => ({ time:e.time, vol:e.market_vol })) : null} />
<TenorTable rows={d.tenors} />
</div>
{d.fit && <FitTable fit={d.fit} />}
</div>
)}
</div>
)
}
// ─── Realised study ───────────────────────────────────────────────────────────
function Realised({ pair }) {
const { data:d, error } = useLoad(fetchEventVolRealised, JSON.stringify(pair))
if (error) return <div style={{ fontSize:9, color:'var(--text-muted)', marginTop:12 }}>Realised study unavailable ({error}).</div>
if (!d) return null
return (
<div style={{ marginTop:12, display:'grid', gridTemplateColumns:'repeat(auto-fit, minmax(420px, 1fr))', gap:12 }}>
<Card title={`Realised vol by weekday · ${d.start} → ${d.end} (event days excluded)`}>
<Plot data={[{ x:d.day_of_week.map(r => 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}%<extra></extra>' }]}
layout={{ ...LAYOUT, showlegend:false, xaxis:axis(''), yaxis:axis('Close-to-close vol, ann. (%)') }}
config={CFG} style={{ width:'100%', height:240 }} />
<div style={{ fontSize:9, color:'var(--text-muted)', padding:'0 8px 4px' }}>
{d.market === 'crypto' ? 'Weekend weight = Sat/Sun variance ÷ weekday variance.' : 'Monday spans the weekend: weight = (var Mon − var Tue–Fri) ÷ (2 · var Tue–Fri).'}
</div>
</Card>
<Card title="What history says">
<Kpis items={[
{ label:'Realised weekend weight', value: d.weekend_weight == null ? '—' : d.weekend_weight.toFixed(2), color:'#c084fc' },
{ label:'Weekday vol (ex-FOMC)', value:pct(d.weekday_vol_ann) },
...(d.fomc ? [{ label:`FOMC lump (${d.fomc.n} meetings)`, value:`±${d.fomc.event_sd_pct.toFixed(2)}%`, color:EV_COLOR.FOMC,
sub:`avg |2-day move| ${d.fomc.avg_abs_2d_move_pct.toFixed(2)}% vs ${d.fomc.normal_2d_sd_pct.toFixed(2)}% normal sd` }] : []),
]} />
{d.fomc && (
<div className="font-mono" style={{ fontSize:10, padding:'0 8px', display:'flex', gap:10, flexWrap:'wrap' }}>
{d.fomc.recent.map(w => (
<span key={w.date} style={{ color: w.move_2d_pct >= 0 ? 'var(--green)' : 'var(--red)' }}>{w.date.slice(2)} {w.move_2d_pct >= 0 ? '+' : ''}{w.move_2d_pct.toFixed(2)}%</span>))}
</div>
)}
<div style={{ fontSize:9, color:'var(--text-muted)', padding:'8px' }}>
Compare the implied lumps above with the realised one: implied &gt; realised is event vol risk premium you can sell (e.g. buy the
weekly calendar, sell the event day); implied &lt; realised says the market is under-pricing the meeting.
</div>
</Card>
</div>
)
}
+402
View File
@@ -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 (
<div style={{ padding:16 }}>
<div style={{ display:'flex', justifyContent:'space-between', alignItems:'flex-start', gap:12, marginBottom:12, flexWrap:'wrap' }}>
<div>
<div className="section-title">Exotics Desk — barriers, touches, Dual Investment &amp; accumulators ({pair})</div>
<div style={{ fontSize:9, color:'var(--text-muted)', marginTop:2 }}>
Price what clients are actually sold: KO/KI and touch structures, crypto/gold DCI yields, KO accumulators
</div>
</div>
<div style={{ display:'flex', gap:4, flexWrap:'wrap' }}>
<Chip active={tab === 'barrier'} onClick={() => setTab('barrier')}>Barriers &amp; touches</Chip>
<Chip active={tab === 'dci'} onClick={() => setTab('dci')}>Dual Investment (DCI)</Chip>
<Chip active={tab === 'acc'} onClick={() => setTab('acc')}>Accumulator</Chip>
</div>
</div>
{tab === 'barrier' && <Barriers key={pair} pair={pair} />}
{tab === 'dci' && <Dci key={pair} pair={pair} />}
{tab === 'acc' && <Accumulator key={pair} pair={pair} />}
</div>
)
}
const Chip = ({ active, onClick, children }) => (
<button onClick={onClick} className="font-mono" style={{ padding:'3px 9px', borderRadius:4, fontSize:9, cursor:'pointer',
border:'1px solid ' + (active ? 'var(--cyan)' : 'var(--border)'), background: active ? 'rgba(0,212,255,0.1)' : 'transparent',
color: active ? 'var(--cyan)' : 'var(--text-muted)' }}>{children}</button>
)
function Card({ title, right, children }) {
return (
<div className="glass" style={{ borderRadius:8, padding:8 }}>
<div style={{ display:'flex', justifyContent:'space-between', alignItems:'center', margin:'2px 6px 4px', gap:8, flexWrap:'wrap' }}>
<span className="section-title" style={{ fontSize:10 }}>{title}</span>{right}
</div>
{children}
</div>
)
}
function Kpis({ items }) {
return (
<div style={{ display:'flex', gap:8, marginBottom:12, flexWrap:'wrap' }}>
{items.map(k => (
<div key={k.label} className="glass" title={k.hint} style={{ padding:'6px 12px', borderRadius:6, fontSize:11 }}>
<div style={{ color:'var(--text-muted)', fontSize:9, letterSpacing:'0.05em' }}>{k.label}</div>
<div className="font-mono" style={{ color: k.color ?? 'var(--cyan)', fontWeight:700, fontSize:14 }}>{k.value}</div>
{k.sub && <div className="font-mono" style={{ fontSize:9, color:'var(--text-muted)' }}>{k.sub}</div>}
</div>
))}
</div>
)
}
function ErrorBox({ msg }) {
return <div style={{ padding:8, borderRadius:6, fontSize:11, marginBottom:10, background:'rgba(255,61,90,0.1)', border:'1px solid #ff3d5a', color:'#ff3d5a' }}>{msg}</div>
}
const inputStyle = { background:'var(--bg3)', border:'1px solid var(--border)', borderRadius:4, color:'var(--cyan)', fontSize:11,
padding:'4px 6px', fontFamily:'var(--font-mono)', width:'100%' }
function Field({ label, value, onChange, step, hint }) {
return (
<label title={hint} style={{ display:'flex', flexDirection:'column', gap:3, fontSize:9, color:'var(--text-muted)' }}>{label}
<input type="number" value={value} step={step} onChange={e => onChange(+e.target.value)} style={inputStyle} />
</label>
)
}
function Select({ label, value, onChange, options }) {
return (
<label style={{ display:'flex', flexDirection:'column', gap:3, fontSize:9, color:'var(--text-muted)' }}>{label}
<select value={value} onChange={e => onChange(e.target.value)} style={inputStyle}>
{options.map(([v, l]) => <option key={v} value={v}>{l}</option>)}
</select>
</label>
)
}
const Panel = ({ children, note }) => (
<div className="glass" style={{ borderRadius:8, padding:10, marginBottom:12 }}>
<div style={{ display:'grid', gridTemplateColumns:'repeat(auto-fill, minmax(120px, 1fr))', gap:10, alignItems:'end' }}>{children}</div>
{note && <div style={{ fontSize:9, color:'var(--text-muted)', marginTop:6, lineHeight:1.5 }}>{note}</div>}
</div>
)
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 (
<div>
<Panel note={<>
Reiner–Rubinstein closed forms (Haug A–F) with rebates; discrete fixings via the Broadie–Glasserman–Kou shift
H·e<sup>±0.5826σ√Δt</sup>{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.'}
</>}>
<Select label="Structure" value={p.product} onChange={set('product')} options={[
['call|up|out', 'Up-and-out call'], ['call|up|in', 'Up-and-in call'], ['call|down|out', 'Down-and-out call'], ['call|down|in', 'Down-and-in call'],
['put|down|out', 'Down-and-out put'], ['put|down|in', 'Down-and-in put'], ['put|up|out', 'Up-and-out put'], ['put|up|in', 'Up-and-in put']]} />
<Field label="Strike K" value={p.K} onChange={set('K')} step={PAIR_META[pair]?.strikeStep ?? 0.001} />
<Field label="Lower barrier" value={p.H_down} onChange={set('H_down')} step={PAIR_META[pair]?.strikeStep ?? 0.001} />
<Field label="Upper barrier" value={p.H_up} onChange={set('H_up')} step={PAIR_META[pair]?.strikeStep ?? 0.001} />
<Field label="Tenor (years)" value={p.T} onChange={set('T')} step={0.05} />
<Field label={smile ? 'σ (ATM from smile, %)' : 'σ (%)'} value={smile ? smileQuotes.atm : p.sigma} onChange={set('sigma')} step={0.5} />
<Field label="Rebate" value={p.rebate} onChange={set('rebate')} step={0.1} hint="Knock-out: paid at hit · knock-in: paid at expiry if never triggered" />
<Select label="Monitoring" value={p.monitoring} onChange={set('monitoring')} options={MONITORING} />
{smileQuotes && <Select label="Digital vol" value={p.useSmile ? 'smile' : 'flat'} onChange={v => set('useSmile')(v === 'smile')}
options={[['smile', `Smile (RR ${smileQuotes.rr25}, BF ${smileQuotes.bf25})`], ['flat', 'Flat σ']]} />}
</Panel>
{error && <ErrorBox msg={error} />}
{d && (
<div style={{ opacity: loading ? 0.6 : 1, transition:'opacity .2s' }}>
<Kpis items={[
{ label: sel ? `${knock === 'out' ? 'KO' : 'KI'} price (discrete)` : 'Price', value: sel ? num(sel.discrete, dp) : '—',
sub: sel ? `continuous ${num(sel.continuous, dp)}` : null },
{ label:'% of vanilla', value: sel?.pct_of_vanilla == null ? '—' : sel.pct_of_vanilla.toFixed(1) + '%', sub: sel ? `vanilla ${num(sel.vanilla, dp)}` : null,
hint:'Cost saving of the barrier vs the plain option' },
{ label:`P(touch ${num(tUp.barrier, dp)})`, value:pct(tUp.prob_touch), color:'var(--green)',
sub:`finish above: ${pct(tUp.prob_finish_beyond)}`, hint:'Reflection rule of thumb: touch ≈ 2 × finish-beyond' },
{ label:`P(touch ${num(tDown.barrier, dp)})`, value:pct(tDown.prob_touch), color:'var(--red)', sub:`finish below: ${pct(tDown.prob_finish_beyond)}` },
{ label:'Double no-touch proxy', value:pct(Math.max(0, 1 - tUp.prob_touch - tDown.prob_touch)),
hint:'Lower bound 1 − P(up) − P(down); a true DNT is slightly richer' },
{ label:`Digital call @ ${num(d.digital.strike, dp)}`, value:pct(d.digital.call_smile),
sub:`flat ${pct(d.digital.call_flat)} · skew ${(d.digital.smile_adjustment * 100).toFixed(2)}pp`,
color: d.digital.smile_adjustment >= 0 ? 'var(--green)' : 'var(--red)', hint:'Price per 1 unit paid if S_T > K (−∂C/∂K under the smile)' },
]} />
<div style={{ display:'grid', gridTemplateColumns:'repeat(auto-fit, minmax(420px, 1fr))', gap:12 }}>
<Card title={`${knock === 'out' ? 'Knock-out' : 'Knock-in'} ${cp} vs vanilla across spot`}>
<Plot data={[
{ x:d.selected.spots, y:d.selected.vanilla, type:'scatter', mode:'lines', name:'Vanilla', line:{ color:'rgba(255,255,255,0.35)', dash:'dash', width:1.2 } },
{ x:d.selected.spots, y:d.selected.price, type:'scatter', mode:'lines', name:'Barrier', line:{ color:'#00d4ff', width:2.5 } },
{ x:d.selected.spots, y:d.selected.delta, type:'scatter', mode:'lines', name:'Barrier Δ (rhs)', yaxis:'y2', line:{ color:'#c084fc', width:1.3 } },
]}
layout={{ ...LAYOUT, hovermode:'x unified', xaxis:axis('Spot'), yaxis:axis('Price'),
yaxis2:{ overlaying:'y', side:'right', color:'#c084fc', showgrid:false, zeroline:false, title:{ text:'Δ' } },
shapes:[
{ type:'line', x0:d.selected.barrier, x1:d.selected.barrier, yref:'paper', y0:0, y1:1, line:{ color:'#ff3d5a', dash:'dot', width:1.5 } },
{ 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 }} />
<div style={{ fontSize:9, color:'var(--text-muted)', padding:'0 8px 4px' }}>
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.
</div>
</Card>
<Card title="Touch ladder — hit probability by barrier (daily fixings)">
<Plot data={[
{ x:d.touch_ladder.map(r => 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}<br>%{customdata} sd<br>P(touch) %{y:.1f}%<extra></extra>' },
]}
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 }} />
<div style={{ fontSize:9, color:'var(--text-muted)', padding:'0 8px 4px' }}>
Risk-neutral odds of a level trading before expiry — compare with prediction-market &quot;will gold hit X&quot; contracts.
</div>
</Card>
</div>
<BarrierTable rows={d.rows} dp={dp} sel={sel} />
</div>
)}
</div>
)
}
function BarrierTable({ rows, dp, sel }) {
const name = r => `${r.direction === 'up' ? 'Up' : 'Down'}-and-${r.knock} ${r.type}`
return (
<Card title="All eight single barriers (same K, lower/upper barrier)">
<div style={{ overflowX:'auto' }}>
<table style={{ width:'100%', borderCollapse:'collapse', fontSize:11 }}>
<thead><tr style={{ color:'var(--text-muted)', fontSize:9, textAlign:'right' }}>
<th style={{ textAlign:'left', padding:'4px 8px' }}>Structure</th><th style={{ padding:'4px 8px' }}>Barrier</th>
<th style={{ padding:'4px 8px' }}>Continuous</th><th style={{ padding:'4px 8px' }}>Discrete (BGK)</th>
<th style={{ padding:'4px 8px' }}>Vanilla</th><th style={{ padding:'4px 8px' }}>% vanilla</th></tr></thead>
<tbody>{rows.map(r => (
<tr key={name(r)} style={{ borderTop:'1px solid var(--border)', background: r === sel ? 'rgba(0,212,255,0.06)' : 'transparent' }}>
<td style={{ padding:'5px 8px' }}>{name(r)}</td>
{[r.barrier, r.continuous, r.discrete, r.vanilla].map((v, i) => (
<td key={i} className="font-mono" style={{ padding:'5px 8px', textAlign:'right', color: i === 2 ? 'var(--cyan)' : undefined }}>{num(v, dp)}</td>))}
<td className="font-mono" style={{ padding:'5px 8px', textAlign:'right' }}>{r.pct_of_vanilla == null ? '—' : r.pct_of_vanilla.toFixed(1) + '%'}</td>
</tr>))}</tbody>
</table>
</div>
</Card>
)
}
// ─── 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 (
<div>
<Panel note={<>
A DCI (&quot;Dual Investment&quot; on Binance/OKX/Bybit/KuCoin, &quot;Dual Currency Deposit&quot; 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&apos;s APR to see the vol it pays you.
</>}>
<Select label="Product" value={p.side} onChange={set('side')} options={[['sell_high', `Sell high (deposit ${base})`], ['buy_low', `Buy low (deposit ${quote})`]]} />
<Field label="Tenor (days)" value={p.days} onChange={set('days')} step={1} />
<Field label="σ (%)" value={p.sigma} onChange={set('sigma')} step={1} hint="Use the Deribit ATM vol from Vol Lab for BTC/ETH" />
<Field label="Offered APR (%)" value={p.offered} onChange={v => set('offered')(Number.isFinite(v) ? v : '')} step={1} />
<Field label="…at strike (% spot)" value={p.strikeOffer} onChange={v => set('strikeOffer')(Number.isFinite(v) ? v : '')} step={1} />
</Panel>
{error && <ErrorBox msg={error} />}
{d && (
<div style={{ opacity: loading ? 0.6 : 1, transition:'opacity .2s' }}>
{a && <Kpis items={[
{ label:`Fair APR @ ${a.strike_pct}%`, value:pct(a.fair_apr, 1) },
{ label:'Offered APR', value:pct(a.offered_apr, 1) },
{ label:'Platform margin', value:`${(a.margin_apr * 100).toFixed(1)}% APR`, color: a.margin_apr > 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) },
]} />}
<div style={{ display:'grid', gridTemplateColumns:'repeat(auto-fit, minmax(420px, 1fr))', gap:12 }}>
<Card title={`Fair APR vs conversion odds · ${p.days}d ${p.side === 'sell_high' ? 'sell-high' : 'buy-low'}`}>
<Plot data={[
{ x:d.ladder.map(r => 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 }} />
</Card>
<Card title="Strike ladder">
<div style={{ overflowX:'auto' }}>
<table style={{ width:'100%', borderCollapse:'collapse', fontSize:11 }}>
<thead><tr style={{ color:'var(--text-muted)', fontSize:9, textAlign:'right' }}>
<th style={{ padding:'4px 8px' }}>Strike</th><th style={{ padding:'4px 8px' }}>% spot</th><th style={{ padding:'4px 8px' }}>Premium</th>
<th style={{ padding:'4px 8px' }}>Fair APR</th><th style={{ padding:'4px 8px' }}>P(conv)</th><th style={{ padding:'4px 8px' }}>Breakeven</th></tr></thead>
<tbody>{d.ladder.map(r => (
<tr key={r.strike_pct} style={{ borderTop:'1px solid var(--border)', textAlign:'right' }} className="font-mono">
<td style={{ padding:'5px 8px' }}>{num(r.strike, dp)}</td><td style={{ padding:'5px 8px' }}>{r.strike_pct}%</td>
<td style={{ padding:'5px 8px' }}>{r.premium_pct.toFixed(3)}%</td>
<td style={{ padding:'5px 8px', color:'var(--cyan)' }}>{pct(r.fair_apr, 1)}</td>
<td style={{ padding:'5px 8px', color:'var(--red)' }}>{pct(r.prob_conversion, 1)}</td>
<td style={{ padding:'5px 8px' }}>{num(r.breakeven, dp)}</td>
</tr>))}</tbody>
</table>
</div>
<div style={{ fontSize:9, color:'var(--text-muted)', padding:'6px 8px' }}>
Breakeven: the spot at which converting at K leaves you level with simply holding, after the premium.
</div>
</Card>
</div>
</div>
)}
</div>
)
}
// ─── 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 (
<div>
<Panel note={<>
&quot;I kill you later&quot;: 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&amp;L marks accumulated units at the KO / final fixing.
</>}>
<Field label="Tenor (months)" value={p.months} onChange={set('months')} step={1} />
<Field label="σ (%)" value={p.sigma} onChange={set('sigma')} step={1} />
<Field label="Knock-out (% spot)" value={p.ko} onChange={set('ko')} step={0.5} />
<Field label="Strike (% spot, blank = zero-cost)" value={p.strike} onChange={v => set('strike')(Number.isFinite(v) && v > 0 ? v : '')} step={0.5} />
<Field label="Gearing" value={p.gearing} onChange={set('gearing')} step={0.5} />
<Field label="Units / fixing" value={p.qty} onChange={set('qty')} step={1} />
<Select label="Fixings / year" value={String(p.fixings)} onChange={v => set('fixings')(+v)} options={[['252', '252 (trading days)'], ['365', '365 (24/7 crypto)'], ['52', '52 (weekly)']]} />
<Select label="Dynamics" value={p.jumps ? 'jump' : 'gbm'} onChange={v => set('jumps')(v === 'jump')} options={[['gbm', 'GBM'], ['jump', 'Merton jumps']]} />
{p.jumps && <>
<Field label="Jumps / year" value={p.jump.intensity} onChange={setJ('intensity')} step={1} />
<Field label="Mean jump (%)" value={p.jump.mean} onChange={setJ('mean')} step={0.5} />
<Field label="Jump sd (%)" value={p.jump.sd} onChange={setJ('sd')} step={0.5} />
</>}
</Panel>
{error && <ErrorBox msg={error} />}
{d && (
<div style={{ opacity: loading ? 0.6 : 1, transition:'opacity .2s' }}>
<Kpis items={[
{ label:'Zero-cost strike', value:num(d.zero_cost_strike, dp), sub:`${d.zero_cost_strike_pct.toFixed(2)}% of spot` },
{ label:'Strike used', value:num(d.strike, dp), sub:`KO ${num(d.ko_level, dp)}` },
{ label:'PV to client', value:num(d.pv_to_client, dp), sub:`± ${num(d.pv_se, dp)} (MC s.e.)`,
color: d.pv_to_client >= 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)' },
]} />
<div style={{ display:'grid', gridTemplateColumns:'repeat(auto-fit, minmax(420px, 1fr))', gap:12 }}>
<Card title="Client P&L distribution at the end of the contract">
<Plot data={[{ x:d.histogram.centers, y:d.histogram.counts, type:'bar', name:'Paths',
marker:{ color:d.histogram.centers.map(c => 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 }} />
<div style={{ fontSize:9, color:'var(--text-muted)', padding:'0 8px 4px' }}>
Capped upside (knocked out early), uncapped geared downside — the asymmetry behind the 2008 and 2022 accumulator blow-ups.
</div>
</Card>
<Card title="Sample paths vs strike and knock-out">
<Plot data={d.sample_paths.map((y, i) => ({ 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 }} />
<div style={{ fontSize:9, color:'var(--text-muted)', padding:'0 8px 4px' }}>Green = KO level, red = strike (below it the client buys {p.gearing}×).</div>
</Card>
</div>
</div>
)}
</div>
)
}
+4
View File
@@ -14,6 +14,8 @@ import VolForecast from '../components/VolForecast'
import HedgeLab from '../components/HedgeLab' import HedgeLab from '../components/HedgeLab'
import CrossAsset from '../components/CrossAsset' import CrossAsset from '../components/CrossAsset'
import JumpLab from '../components/JumpLab' import JumpLab from '../components/JumpLab'
import ExoticsDesk from '../components/ExoticsDesk'
import EventVol from '../components/EventVol'
const IVSurface = lazy(() => import('../components/IVSurface')) const IVSurface = lazy(() => import('../components/IVSurface'))
const WorldMap = lazy(() => import('../components/WorldMap')) const WorldMap = lazy(() => import('../components/WorldMap'))
@@ -46,8 +48,10 @@ const TABS = [
{ id:'signals', icon:'⚡', label:'AI Signals', component: QuantSignals }, { id:'signals', icon:'⚡', label:'AI Signals', component: QuantSignals },
{ id:'vollab', icon:'🔬', label:'Vol Lab', component: VolLab }, { id:'vollab', icon:'🔬', label:'Vol Lab', component: VolLab },
{ id:'volforecast', icon:'🤖', label:'Vol Forecast', component: VolForecast }, { id:'volforecast', icon:'🤖', label:'Vol Forecast', component: VolForecast },
{ id:'eventvol', icon:'📆', label:'Event Vol', component: EventVol },
{ id:'hedgelab', icon:'⚖', label:'Hedge Lab', component: HedgeLab }, { id:'hedgelab', icon:'⚖', label:'Hedge Lab', component: HedgeLab },
{ id:'jumps', icon:'⚡', label:'Jump Lab', component: JumpLab }, { 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:'xasset', icon:'🔗', label:'Cross-Asset', component: CrossAsset },
{ id:'smile', icon:'〰', label:'Vol Smile', component: VolSmile }, { id:'smile', icon:'〰', label:'Vol Smile', component: VolSmile },
{ id:'surfaces', icon:'🗻', label:'3D Surfaces', component: SurfacePlot }, { id:'surfaces', icon:'🗻', label:'3D Surfaces', component: SurfacePlot },
Binary file not shown.

After

Width:  |  Height:  |  Size: 491 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 460 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 433 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 366 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 417 KiB