mirror of
https://github.com/Mihirkansara/nexus-quant-terminal.git
synced 2026-11-02 09:36:11 +00:00
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:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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()}
|
||||||
@@ -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
@@ -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("/")
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 & 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 "volatility
|
||||||
|
time"; Castagna 2010). Fit: non-negative least squares on total variance with a grid over w. FX/metal weekend = Fri 21:00 → Sun 21:00 UTC;
|
||||||
|
crypto = Sat/Sun UTC. Built-in 2026 FOMC (14:00 ET), CPI/NFP (08:30 ET) dates merged with live Forex Factory high-impact events.
|
||||||
|
</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'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 > 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 > realised is event vol risk premium you can sell (e.g. buy the
|
||||||
|
weekly calendar, sell the event day); implied < realised says the market is under-pricing the meeting.
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 & 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 & 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 "will gold hit X" 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 ("Dual Investment" on Binance/OKX/Bybit/KuCoin, "Dual Currency Deposit" at banks) is a deposit plus a
|
||||||
|
short option. Sell-high: deposit {base}, short a {base} call — converts to {quote} at K if spot finishes above. Buy-low: deposit {quote},
|
||||||
|
short a put — you buy {base} at K. Fair APR = deposit rate + option premium ÷ tenor. Enter a platform's APR to see the vol it pays you.
|
||||||
|
</>}>
|
||||||
|
<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={<>
|
||||||
|
"I kill you later": the client buys {p.qty} unit(s) at K every fixing, {p.gearing}× when the fixing is below K, until a fixing
|
||||||
|
trades ≥ KO. Leaving the strike blank solves the zero-cost strike by Monte Carlo (20k paths, common random numbers).
|
||||||
|
Jumps come from Jump Lab when sent there. P&L marks accumulated units at the KO / final fixing.
|
||||||
|
</>}>
|
||||||
|
<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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 |
Reference in New Issue
Block a user