feat: Event & Weekend Vol — vol-time term structure, event moves, forward vols

Strips FOMC/CPI/NFP variance lumps and the weekend weight out of the ATM
term structure (NNLS on total variance with a weekend-weight grid): live
Deribit fit for BTC/ETH, assumptions or broker quotes for metals/FX.
Event-implied moves, forward vols with calendar-arbitrage flags, fair vol
for any expiry, built-in 2026 FOMC/CPI/NFP calendar merged with live Forex
Factory events, and a realised day-of-week / FOMC-lump study.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FpiqUMAyo4RL5DdQHMWbwE
This commit is contained in:
Claude
2026-10-11 05:26:49 +00:00
parent 78cd5c2ef7
commit 459cea18f0
10 changed files with 1063 additions and 2 deletions
+1
View File
@@ -43,6 +43,7 @@ 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 | | **Exotics Desk** | Structured-products pricer: all eight single barriers (Reiner-Rubinstein/Haug, rebates) with Broadie-Glasserman-Kou discrete-monitoring correction, barrier price/delta profile through the barrier, touch ladder of hit probabilities, smile-consistent digitals (−∂C/∂K on the vanna-volga smile); Dual Currency Investment / crypto "Dual Investment" fair-APR ladder with an offer audit (platform margin, implied vol you are paid); KO accumulator Monte Carlo with zero-cost strike, KO odds and geared-loss tail, optional Merton jumps |
### Live Intelligence ### Live Intelligence
+290
View File
@@ -0,0 +1,290 @@
"""
event_vol.py — Event & weekend volatility: "vol time" term-structure model.
Desks do not price short-dated options on calendar time. Each hour to expiry carries
a variance weight: 1 for a normal weekday hour, w_we for a weekend hour (≈0.1–0.3 for
FX/metals whose spot is shut, ≈0.5–0.9 for 24/7 crypto), and each scheduled event
(FOMC, CPI, NFP, ECB, BoJ…) adds a lump of variance v_e on its timestamp:
σ_ATM(T)² · T = σ_b² · τ(T) + Σ_{events e before T} v_e,
τ(T) = (1/8760) · Σ_{hours h in (now, T]} weight(h) (ACT/365 hours)
σ_b is the weekday ("base", ex-event) vol in ACT/365 units, and √v_e is the event's
own 1-sd move. Given listed expiries the model is linear in (σ_b², v_type) for a fixed
weekend weight, so it is fitted by non-negative least squares on total variance with
a grid search over w_we. Forward vols between expiries follow from additivity of
variance; a negative forward variance is a calendar arbitrage.
References:
Castagna, A. (2010). FX Options and Smile Risk, ch. 6 (event and weekend weighting).
Clark, I. (2011). Foreign Exchange Option Pricing, §3.6 "Volatility time".
Wystup, U. (2017). FX Options and Structured Products, §1.5 (overnight vols).
Dubinsky, Johannes, Kaeck & Seeger (2019). "Option pricing of earnings announcement
risks", Review of Financial Studies 32(2) — event-variance stripping from the term
structure.
Gatheral, J. (2006). The Volatility Surface, ch. 1 (calendar arbitrage: total implied
variance must be non-decreasing in T).
"""
from datetime import datetime, timedelta, timezone
import numpy as np
from scipy.optimize import nnls
HOURS_PER_YEAR = 365 * 24
# Scheduled release times (UTC). US events move with US daylight saving.
# FOMC 2026 (decision day, 14:00 ET) — federalreserve.gov calendar.
FOMC_2026 = ["2026-01-28", "2026-03-18", "2026-04-29", "2026-06-17", "2026-07-29",
"2026-09-16", "2026-10-28", "2026-12-09"]
FOMC_PAST = ["2024-01-31", "2024-03-20", "2024-05-01", "2024-06-12", "2024-07-31", "2024-09-18",
"2024-11-07", "2024-12-18", "2025-01-29", "2025-03-19", "2025-05-07", "2025-06-18",
"2025-07-30", "2025-09-17", "2025-10-29", "2025-12-10"]
# Remaining 2026 US data releases known at build time (BLS schedule, 08:30 ET).
CPI_2026 = ["2026-10-14", "2026-11-12", "2026-12-10"]
NFP_2026 = ["2026-11-06", "2026-12-04"]
EVENT_TYPES = ("FOMC", "CPI", "NFP", "OTHER")
def _us_dst(d: datetime) -> bool:
"""US daylight saving: second Sunday of March → first Sunday of November."""
y = d.year
mar = datetime(y, 3, 8, tzinfo=timezone.utc)
start = mar + timedelta(days=(6 - mar.weekday()) % 7)
nov = datetime(y, 11, 1, tzinfo=timezone.utc)
end = nov + timedelta(days=(6 - nov.weekday()) % 7)
return start <= d < end
def et_to_utc(date_str: str, hour_et: float) -> datetime:
d = datetime.fromisoformat(date_str).replace(tzinfo=timezone.utc)
offset = 4 if _us_dst(d) else 5
return d + timedelta(hours=hour_et + offset)
def default_events(now: datetime, horizon_days: float = 120) -> list:
"""Built-in US calendar (FOMC 14:00 ET, CPI/NFP 08:30 ET) within the horizon."""
out = []
for t, dates, hr in (("FOMC", FOMC_2026, 14.0), ("CPI", CPI_2026, 8.5), ("NFP", NFP_2026, 8.5)):
for d in dates:
ts = et_to_utc(d, hr)
if now < ts <= now + timedelta(days=horizon_days):
out.append({"type": t, "name": f"US {t}", "time": ts})
return sorted(out, key=lambda e: e["time"])
def classify_ff_event(title: str, country: str):
"""Map a Forex Factory high-impact title to an event type (None = ignore)."""
t = title.lower()
if country == "USD":
if "federal funds rate" in t or "fomc statement" in t:
return "FOMC"
if t.startswith("cpi") or "core cpi" in t:
return "CPI"
if "non-farm" in t:
return "NFP"
if country in ("EUR", "JPY", "GBP") and ("rate" in t and ("main refinancing" in t or "policy rate" in t
or "official bank rate" in t)):
return "OTHER"
return None
def is_weekend(ts: datetime, market: str = "fx") -> bool:
"""
Weekend hours. FX/metals: Fri 21:00 UTC → Sun 21:00 UTC (spot closed).
Crypto: Saturday and Sunday UTC (market open but thinner).
"""
wd, h = ts.weekday(), ts.hour
if market == "crypto":
return wd >= 5
return (wd == 4 and h >= 21) or wd == 5 or (wd == 6 and h < 21)
def hour_grid(now: datetime, end: datetime, market: str):
"""Hourly grid (now, end]: weekend flags and the fraction of each hour used."""
n = max(0, int(np.ceil((end - now).total_seconds() / 3600)))
starts = [now + timedelta(hours=i) for i in range(n)]
frac = np.ones(n)
if n:
frac[-1] = ((end - now).total_seconds() / 3600) - (n - 1)
we = np.array([is_weekend(s, market) for s in starts], dtype=bool)
return we, frac
def vol_time(now: datetime, end: datetime, w_we: float, market: str) -> tuple:
"""(τ in weighted years, weekday-hour years, weekend-hour years)."""
we, frac = hour_grid(now, end, market)
wk = float(frac[~we].sum()) / HOURS_PER_YEAR
wkend = float(frac[we].sum()) / HOURS_PER_YEAR
return wk + w_we * wkend, wk, wkend
def _design(now, expiries, events, w_we, market, types):
rows = []
for e in expiries:
tau, _, _ = vol_time(now, e["time"], w_we, market)
counts = [sum(1 for ev in events if ev["type"] == t and now < ev["time"] <= e["time"]) for t in types]
rows.append([tau] + counts)
return np.array(rows, float)
def fit_term_structure(now, expiries, events, market="crypto", w_grid=None, fix_w=None,
event_floor=None, fix_events=None):
"""
Fit σ_b, w_we and per-type event variances to listed ATM vols.
expiries: [{"time": datetime, "atm_vol": σ, "label": str}], events: [{"type","time",...}].
event_floor: optional {type: min variance} prior (e.g. from realised history) — types
whose window holds no expiry pair are otherwise unidentified and left at the floor.
fix_events: {type: variance} held fixed (only σ_b, and w_we unless fixed, are fitted) —
used when there are too few quotes to identify event lumps.
"""
exps = sorted([e for e in expiries if e["time"] > now], key=lambda e: e["time"])
if len(exps) < 3:
raise ValueError("need at least 3 future expiries")
T = np.array([(e["time"] - now).total_seconds() / 3600 / HOURS_PER_YEAR for e in exps])
target = np.array([e["atm_vol"] ** 2 for e in exps]) * T
types = [t for t in EVENT_TYPES if any(ev["type"] == t and now < ev["time"] <= exps[-1]["time"] for ev in events)]
wts = 1.0 / np.maximum(target, 1e-8) # relative error in total variance ≈ 2× vol error
floor = np.array([(event_floor or {}).get(t, 0.0) for t in types])
if fix_events is not None:
floor = np.array([fix_events.get(t, 0.0) for t in types])
grid = [fix_w] if fix_w is not None else (w_grid if w_grid is not None else np.round(np.arange(0.0, 1.21, 0.05), 2))
best = None
for w in grid:
A = _design(now, exps, events, w, market, types)
# Event variance above the floor is the free (non-negative) unknown.
b = target - (A[:, 1:] @ floor if len(types) else 0.0)
sw = np.sqrt(wts)
if fix_events is not None:
x0, _ = nnls(A[:, :1] * sw[:, None], b * sw)
x = np.concatenate([x0, np.zeros(len(types))])
else:
x, _ = nnls(A * sw[:, None], b * sw)
x[1:] += floor
fit = A @ x
sse = float(np.sum(wts * (fit - target) ** 2))
if best is None or sse < best[0]:
best = (sse, w, x, A, fit)
sse, w, x, A, fit = best
base_var = x[0]
ev_var = dict(zip(types, x[1:].tolist()))
# Identification: an event type is pinned only if some consecutive expiry pair brackets it.
ident = {}
for t in ([] if fix_events is not None else types):
ident[t] = any(any(ev["type"] == t and exps[i]["time"] < ev["time"] <= exps[i + 1]["time"] for ev in events)
for i in range(len(exps) - 1)) or any(ev["type"] == t and now < ev["time"] <= exps[0]["time"] for ev in events)
model_vol = np.sqrt(fit / T)
rows = []
for i, e in enumerate(exps):
rows.append({"label": e.get("label"), "time": e["time"].isoformat(), "days": float(T[i] * 365),
"market_vol": float(e["atm_vol"]), "model_vol": float(model_vol[i]),
"error_vol": float(model_vol[i] - e["atm_vol"]),
"event_share": float((A[i, 1:] @ x[1:]) / fit[i]) if fit[i] > 0 and len(types) else 0.0,
"events_before": int(A[i, 1:].sum()) if len(types) else 0})
return {"base_vol": float(np.sqrt(base_var)), "weekend_weight": float(w),
"event_variance": ev_var, "identified": ident,
"rmse_vol": float(np.sqrt(np.mean((model_vol - np.array([e["atm_vol"] for e in exps])) ** 2))),
"expiries": rows, "market": market}
def model_vol(now, end, base_vol, w_we, events, ev_var, market):
"""Model ATM vol to an arbitrary expiry and its variance decomposition."""
T = (end - now).total_seconds() / 3600 / HOURS_PER_YEAR
if T <= 0:
raise ValueError("expiry must be in the future")
_, wk, wkend = vol_time(now, end, w_we, market)
inside = [ev for ev in events if now < ev["time"] <= end]
v_wk, v_we = base_vol ** 2 * wk, base_vol ** 2 * w_we * wkend
v_ev = sum(ev_var.get(ev["type"], 0.0) for ev in inside)
tot = v_wk + v_we + v_ev
return {"T": T, "days": T * 365, "vol": float(np.sqrt(tot / T)),
"flat_calendar_vol": float(base_vol * np.sqrt((5 + 2 * w_we) / 7)),
"share": {"weekday": v_wk / tot, "weekend": v_we / tot, "events": v_ev / tot},
"events": [{"type": ev["type"], "name": ev.get("name", ev["type"]), "time": ev["time"].isoformat()} for ev in inside]}
def forward_vols(points):
"""Forward vols between consecutive (T, vol) points; flags negative forward variance."""
out = []
pts = sorted(points)
for (t0, v0), (t1, v1) in zip(pts[:-1], pts[1:]):
fv = v1 ** 2 * t1 - v0 ** 2 * t0
out.append({"T0": t0, "T1": t1, "fwd_var": fv,
"fwd_vol": float(np.sqrt(fv / (t1 - t0))) if fv > 0 else None, "arbitrage": bool(fv < 0)})
return out
def event_moves(ev_var, base_vol, spot, w_we=1.0):
"""Event-day 1-sd move (base day + event lump), expected |move| and straddle breakeven."""
day = base_vol ** 2 / 365
out = {}
for t, v in ev_var.items():
sd = np.sqrt(day + v)
out[t] = {"event_sd_pct": float(np.sqrt(v) * 100), "day_sd_pct": float(sd * 100),
"normal_day_sd_pct": float(np.sqrt(day) * 100), "multiple_of_normal_day": float(sd / np.sqrt(day)) if day > 0 else None,
"expected_abs_move_pct": float(np.sqrt(2 / np.pi) * sd * 100),
"expected_abs_move": float(np.sqrt(2 / np.pi) * sd * spot) if spot else None}
return out
# ─── Realised study ───────────────────────────────────────────────────────────
def realised_study(dates, closes, market="fx", fomc_dates=FOMC_PAST + FOMC_2026):
"""
Day-of-week realised variance profile, implied weekend weight and the FOMC lump.
FX/metals daily closes: Monday's return spans the weekend, so
w_we ≈ (var_Mon − var_weekday) / (2 · var_weekday) (2 weekend days).
Crypto: Saturday/Sunday returns are observed directly, w_we = var_weekend / var_weekday.
FOMC (14:00 ET) lands after many daily closes (CME gold settles 13:30 ET), so the event
window is the 2-day return over the decision day and the next day:
v_FOMC ≈ E[r²_2d] − 2 · var_weekday.
"""
d = [datetime.fromisoformat(str(x)[:10]) for x in dates]
c = np.asarray(closes, float)
r = np.diff(np.log(c))
d1 = d[1:]
wd = np.array([x.weekday() for x in d1])
# Robust to outliers: winsorise at 6 sd before the variance profile.
s = np.std(r)
rw = np.clip(r, -6 * s, 6 * s)
fomc = {datetime.fromisoformat(x).date() for x in fomc_dates}
idx_by_date = {x.date(): i for i, x in enumerate(d1)}
ev_idx = set()
windows = []
for f in sorted(fomc):
i = idx_by_date.get(f)
if i is None or i + 1 >= len(r):
continue
ev_idx.update({i, i + 1})
windows.append({"date": f.isoformat(), "move_2d_pct": float((r[i] + r[i + 1]) * 100)})
mask_clean = np.array([i not in ev_idx for i in range(len(r))])
prof = []
names = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
for k in range(7):
m = (wd == k) & mask_clean
if m.sum() >= 10:
prof.append({"day": names[k], "n": int(m.sum()), "vol_ann": float(np.sqrt(np.mean(rw[m] ** 2) * 365) )})
if market == "crypto":
wk = (wd < 5) & mask_clean
we = (wd >= 5) & mask_clean
v_wk = float(np.mean(rw[wk] ** 2))
w_we = float(np.mean(rw[we] ** 2) / v_wk) if we.sum() > 10 else None
else:
mid = np.isin(wd, [1, 2, 3, 4]) & mask_clean
mon = (wd == 0) & mask_clean
v_wk = float(np.mean(rw[mid] ** 2))
w_we = float(max(0.0, (np.mean(rw[mon] ** 2) - v_wk) / (2 * v_wk))) if mon.sum() > 10 else None
if windows:
m2 = np.array([w["move_2d_pct"] / 100 for w in windows])
v_f = float(max(0.0, np.mean(m2 ** 2) - 2 * v_wk))
fomc_stats = {"n": len(windows), "event_sd_pct": float(np.sqrt(v_f) * 100),
"avg_abs_2d_move_pct": float(np.mean(np.abs(m2)) * 100),
"normal_2d_sd_pct": float(np.sqrt(2 * v_wk) * 100), "event_variance": v_f,
"recent": windows[-8:]}
else:
fomc_stats = None
return {"weekday_vol_ann": float(np.sqrt(v_wk * 365)), "weekend_weight": w_we,
"day_of_week": prof, "fomc": fomc_stats, "n_returns": int(len(r)),
"start": d[0].date().isoformat(), "end": d[-1].date().isoformat()}
+2 -2
View File
@@ -1,6 +1,6 @@
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from .routers import greeks, surface, market, montecarlo, scenarios, strategies, export, forex, institutional, news, livedata, smile, vol, hedge, xasset, jumps, exotics 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, exotics]: for router in [greeks, surface, market, montecarlo, scenarios, strategies, export, forex, institutional, news, livedata, smile, vol, hedge, xasset, jumps, exotics, eventvol]:
app.include_router(router.router, prefix="/api") app.include_router(router.router, prefix="/api")
@app.get("/") @app.get("/")
+246
View File
@@ -0,0 +1,246 @@
"""
eventvol.py — Event & Weekend Vol desk: strips FOMC/CPI/NFP event variance and the
weekend weight out of the ATM term structure (live Deribit for BTC/ETH, quotes or
assumptions for metals/FX), prices any expiry on vol time, and backs the assumptions
with a realised day-of-week / FOMC study. Free sources only; every live input degrades
gracefully.
"""
from datetime import datetime, timedelta, timezone
import numpy as np
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, Field
from ..core.event_vol import (EVENT_TYPES, classify_ff_event, default_events, event_moves,
fit_term_structure, forward_vols, model_vol, realised_study)
from .forex import PAIRS
router = APIRouter(prefix="/eventvol", tags=["eventvol"])
TENORS = [("O/N", 1), ("1W", 7), ("2W", 14), ("1M", 30), ("2M", 61), ("3M", 91)]
def _now():
return datetime.now(timezone.utc).replace(minute=0, second=0, microsecond=0)
def _market(pair):
return "crypto" if PAIRS[pair]["asset_class"] == "crypto" else "fx"
def _cut(day: datetime, market: str):
"""Expiry cut: Deribit 08:00 UTC for crypto, NY 10:00 ET (≈14:00/15:00 UTC) otherwise."""
from ..core.event_vol import et_to_utc
if market == "crypto":
return day.replace(hour=8, minute=0, second=0, microsecond=0)
return et_to_utc(day.date().isoformat(), 10.0)
def calendar_events(now, horizon=120, live=True):
"""Built-in US calendar merged with live Forex Factory high-impact events (if reachable)."""
events = default_events(now, horizon)
source = ["built-in 2026 FOMC/CPI/NFP schedule"]
if live:
try:
from .news import _load_calendar
cal = _load_calendar()
ff = cal.get("events", []) if isinstance(cal, dict) else []
added = 0
for ev in ff:
if (ev.get("impact") or "").lower() != "high" or not ev.get("datetime_utc"):
continue
t = classify_ff_event(ev.get("title", ""), ev.get("country", ""))
if not t:
continue
ts = datetime.strptime(ev["datetime_utc"], "%Y-%m-%dT%H:%MZ").replace(tzinfo=timezone.utc)
if not (now < ts <= now + timedelta(days=horizon)):
continue
if any(e["type"] == t and abs((e["time"] - ts).total_seconds()) < 36 * 3600 for e in events):
continue
events.append({"type": t, "name": f'{ev.get("country")} {ev.get("title")}', "time": ts})
added += 1
if added:
source.append(f"Forex Factory (+{added})")
except Exception:
pass
return sorted(events, key=lambda e: e["time"]), source
def _ev_json(events):
return [{"type": e["type"], "name": e.get("name", e["type"]), "time": e["time"].isoformat()} for e in events]
def _curve(now, base, w, events, ev_var, market, days=90):
"""Model ATM vol for daily expiries 1..days (at the market's cut), plus the flat-calendar vol."""
out = []
for d in range(1, days + 1):
end = _cut(now + timedelta(days=d), market)
if end <= now:
continue
m = model_vol(now, end, base, w, events, ev_var, market)
out.append({"date": end.date().isoformat(), "days": m["days"], "vol": m["vol"],
"event_share": m["share"]["events"], "weekend": end.weekday() >= 5})
return out
def _tenors(now, base, w, events, ev_var, market):
rows = []
for name, d in TENORS:
end = _cut(now + timedelta(days=d), market)
m = model_vol(now, end, base, w, events, ev_var, market)
rows.append({"tenor": name, "expiry": end.isoformat(), "days": m["days"], "vol": m["vol"],
"share": m["share"], "events": [e["type"] for e in m["events"]]})
pts = [(r["days"] / 365, r["vol"]) for r in rows]
for r, f in zip(rows[1:], forward_vols(pts)):
r["fwd_vol"] = f["fwd_vol"]
return rows
def _load_history(pair):
from .vol import _cached, _crypto_ohlc, _yf_ohlc
meta = PAIRS[pair]
return _cached(f"ohlc:{pair}", 1800,
lambda: _crypto_ohlc(pair[:3]) if meta["asset_class"] == "crypto" else _yf_ohlc(meta["sym"]))
@router.get("/realised/{pair}")
def realised(pair: str):
"""Day-of-week realised vol, implied weekend weight and the realised FOMC lump."""
pair = pair.upper()
if pair not in PAIRS:
raise HTTPException(404, f"Unknown pair '{pair}'.")
try:
data = _load_history(pair)
if not data or len(data["c"]) < 250:
raise ValueError("not enough daily history")
rep = realised_study(data["dates"], data["c"], _market(pair))
except Exception as e:
raise HTTPException(503, f"Price history unavailable: {e}")
return _clean({"pair": pair, "market": _market(pair), **rep})
@router.get("/crypto/{currency}")
def crypto_fit(currency: str):
"""Fit base vol, weekend weight and event variances to the live Deribit ATM term structure."""
currency = currency.upper()
if currency not in ("BTC", "ETH"):
raise HTTPException(404, "Live event-vol fit is available for BTC and ETH (Deribit).")
from .vol import get_crypto_vol
try:
surf = get_crypto_vol(currency)
except HTTPException as e:
raise HTTPException(503, f"Deribit surface unavailable: {e.detail}")
now = _now()
exps = [{"time": datetime.fromisoformat(e["expiry"]).replace(hour=8, tzinfo=timezone.utc),
"atm_vol": e["atm_vol"], "label": e["label"]}
for e in surf["expiries"] if e.get("atm_vol") and e["days"] <= 200]
exps = [e for e in exps if e["time"] > now + timedelta(hours=2)]
events, source = calendar_events(now, 200)
try:
fit = fit_term_structure(now, exps, events, "crypto")
except ValueError as e:
raise HTTPException(503, f"Fit failed: {e}")
spot = surf.get("index_price")
mkt_pts = [(r["days"] / 365, r["market_vol"]) for r in fit["expiries"]]
fwd = forward_vols(mkt_pts)
for r, f in zip(fit["expiries"][1:], fwd):
r["fwd_vol_market"], r["calendar_arb"] = f["fwd_vol"], f["arbitrage"]
ev_var = fit["event_variance"]
return _clean({
"pair": f"{currency}USD", "spot": spot, "source": "Deribit ATM (SVI) + " + ", ".join(source),
"fit": fit, "moves": event_moves(ev_var, fit["base_vol"], spot),
"events": _ev_json([e for e in events if e["time"] <= exps[-1]["time"]]),
"curve": _curve(now, fit["base_vol"], fit["weekend_weight"], events, ev_var, "crypto"),
"tenors": _tenors(now, fit["base_vol"], fit["weekend_weight"], events, ev_var, "crypto"),
"now": now.isoformat()})
class Quote(BaseModel):
days: float = Field(..., gt=0.04, le=400)
vol: float = Field(..., gt=0.005, lt=5)
class CustomEvent(BaseModel):
type: str = Field(default="OTHER")
name: str = Field(default="Custom event", max_length=60)
time: datetime
class ModelRequest(BaseModel):
pair: str = "XAUUSD"
S: float | None = Field(default=None, gt=0)
base_vol: float = Field(default=0.16, gt=0.005, lt=5, description="Weekday (ex-event) vol, ACT/365")
weekend_weight: float = Field(default=0.2, ge=0, le=1.5)
event_moves_pct: dict[str, float] = Field(default={"FOMC": 1.2, "CPI": 1.0, "NFP": 0.9, "OTHER": 0.8},
description="Event 1-sd move in % per type")
quotes: list[Quote] = Field(default=[], max_length=20, description="Optional ATM quotes to fit")
custom_events: list[CustomEvent] = Field(default=[], max_length=20)
include_calendar: bool = True
fit_events: bool = Field(default=False, description="Free event lumps in the quote fit (needs dense quotes)")
expiry_days: float | None = Field(default=None, gt=0.01, le=400, description="Calculator expiry")
@router.post("/model")
def model(req: ModelRequest):
"""Vol-time term structure for any pair: build from assumptions or fit to quoted ATM vols."""
pair = req.pair.upper()
if pair not in PAIRS:
raise HTTPException(404, f"Unknown pair '{pair}'.")
market = _market(pair)
now = _now()
events, source = calendar_events(now, 400) if req.include_calendar else ([], [])
for ce in req.custom_events:
t = ce.time if ce.time.tzinfo else ce.time.replace(tzinfo=timezone.utc)
if t > now:
events.append({"type": ce.type.upper() if ce.type.upper() in EVENT_TYPES else "OTHER",
"name": ce.name, "time": t})
events.sort(key=lambda e: e["time"])
ev_var = {k.upper(): (v / 100) ** 2 for k, v in req.event_moves_pct.items() if k.upper() in EVENT_TYPES and v >= 0}
base, w, fit = req.base_vol, req.weekend_weight, None
if len(req.quotes) >= 3:
exps = [{"time": _cut(now + timedelta(days=q.days), market) if q.days >= 1 else now + timedelta(days=q.days),
"atm_vol": q.vol, "label": f"{q.days:g}d"} for q in req.quotes]
last = max(e["time"] for e in exps)
n_types = len({e["type"] for e in events if now < e["time"] <= last})
# Sparse broker tenors cannot pin event lumps: hold them at the stated moves unless
# the trader asks for a free fit and there are enough quotes to identify it.
fix = None if (req.fit_events and len(exps) >= n_types + 2) else ev_var
try:
fit = fit_term_structure(now, exps, events, market, fix_w=req.weekend_weight, fix_events=fix)
fit["events_fixed"] = fix is not None
except ValueError as e:
raise HTTPException(422, str(e))
base = fit["base_vol"]
# Fitted event types override assumptions only where the quotes identify them.
ev_var = {**ev_var, **{t: v for t, v in fit["event_variance"].items() if fit["identified"].get(t)}}
fwd = forward_vols([(r["days"] / 365, r["market_vol"]) for r in fit["expiries"]])
for r, f in zip(fit["expiries"][1:], fwd):
r["fwd_vol_market"], r["calendar_arb"] = f["fwd_vol"], f["arbitrage"]
calc = None
if req.expiry_days:
end = now + timedelta(days=req.expiry_days)
if req.expiry_days >= 1:
end = _cut(end, market)
calc = model_vol(now, end, base, w, events, ev_var, market)
calc["expiry"] = end.isoformat()
return _clean({
"pair": pair, "market": market, "spot": req.S, "base_vol": base, "weekend_weight": w,
"flat_calendar_vol": base * np.sqrt((5 + 2 * w) / 7), "event_variance": ev_var,
"moves": event_moves(ev_var, base, req.S), "fit": fit, "calculator": calc,
"events": _ev_json([e for e in events if e["time"] <= now + timedelta(days=95)]),
"source": source, "curve": _curve(now, base, w, events, ev_var, market),
"tenors": _tenors(now, base, w, events, ev_var, market), "now": now.isoformat()})
def _clean(obj):
if isinstance(obj, (float, np.floating)):
x = float(obj)
return round(x, 8) if np.isfinite(x) else None
if isinstance(obj, (bool, np.bool_)):
return bool(obj)
if isinstance(obj, dict):
return {k: _clean(v) for k, v in obj.items()}
if isinstance(obj, (list, tuple, np.ndarray)):
return [_clean(v) for v in obj]
return obj
+140
View File
@@ -0,0 +1,140 @@
"""Event & weekend vol: vol time, exact recovery of planted parameters, realised study, endpoints."""
from datetime import datetime, timedelta, timezone
import numpy as np
import pytest
from fastapi.testclient import TestClient
from app.core.event_vol import (FOMC_PAST, default_events, et_to_utc, fit_term_structure,
forward_vols, is_weekend, model_vol, realised_study, vol_time)
from app.main import app
from app.routers import eventvol as ev_router
from app.routers import news as news_router
from app.routers import vol as vol_router
NOW = datetime(2026, 10, 11, 5, 0, tzinfo=timezone.utc) # a Sunday
client = TestClient(app)
def test_calendar_and_dst():
assert et_to_utc("2026-10-28", 14).hour == 18 # EDT
assert et_to_utc("2026-12-09", 14).hour == 19 # EST
evs = default_events(NOW, 60)
assert [e["type"] for e in evs][:3] == ["CPI", "FOMC", "NFP"]
assert is_weekend(datetime(2026, 10, 9, 22, tzinfo=timezone.utc), "fx") # Fri 22:00 FX closed
assert not is_weekend(datetime(2026, 10, 9, 22, tzinfo=timezone.utc), "crypto")
def test_vol_time_limits():
end = NOW + timedelta(days=28) # 4 whole weeks
tau1, wk, we = vol_time(NOW, end, 1.0, "crypto")
assert tau1 == pytest.approx(28 / 365) and we == pytest.approx(8 / 365)
tau0, _, _ = vol_time(NOW, end, 0.0, "fx")
assert tau0 == pytest.approx(20 / 365, abs=1e-9) # 20 weekdays of FX trading
@pytest.mark.parametrize("market,base,w,moves", [
("crypto", 0.42, 0.65, {"FOMC": 0.035, "CPI": 0.025, "NFP": 0.02}),
("fx", 0.16, 0.15, {"FOMC": 0.012, "CPI": 0.010, "NFP": 0.008}),
])
def test_fit_recovers_planted_parameters(market, base, w, moves):
evs = default_events(NOW, 120)
ev_var = {k: v ** 2 for k, v in moves.items()}
exps = []
for d in [1, 2, 3, 4, 5, 6, 7, 12, 19, 26, 33, 47, 54, 82]:
t = (NOW + timedelta(days=d)).replace(hour=8)
exps.append({"time": t, "atm_vol": model_vol(NOW, t, base, w, evs, ev_var, market)["vol"]})
f = fit_term_structure(NOW, exps, evs, market)
assert f["base_vol"] == pytest.approx(base, rel=1e-6)
assert f["weekend_weight"] == pytest.approx(w)
for k, v in moves.items():
assert np.sqrt(f["event_variance"][k]) == pytest.approx(v, rel=1e-5)
assert f["identified"][k]
assert f["rmse_vol"] < 1e-8
def test_event_variance_is_additive_and_forward_arbitrage():
evs = default_events(NOW, 30)
end = et_to_utc("2026-10-15", 10) # just after CPI
a = model_vol(NOW, end, 0.2, 0.2, evs, {}, "fx")
b = model_vol(NOW, end, 0.2, 0.2, evs, {"CPI": 0.01 ** 2}, "fx")
assert b["vol"] ** 2 * b["T"] - a["vol"] ** 2 * a["T"] == pytest.approx(1e-4)
assert forward_vols([(0.1, 0.30), (0.2, 0.20)])[0]["arbitrage"] # total variance falls
assert not forward_vols([(0.1, 0.20), (0.2, 0.20)])[0]["arbitrage"]
def test_realised_study_recovers_weekend_weight():
rng = np.random.default_rng(7)
d, dates, c = datetime(2018, 1, 1), [], [100.0]
dates.append(d)
while len(dates) < 2200:
d += timedelta(days=1)
if d.weekday() >= 5:
continue
var = 0.01 ** 2 * (1 + (2 * 0.3 if d.weekday() == 0 else 0))
c.append(c[-1] * np.exp(rng.normal(0, np.sqrt(var))))
dates.append(d)
rs = realised_study([x.isoformat() for x in dates], c, "fx", fomc_dates=[])
assert rs["weekend_weight"] == pytest.approx(0.3, abs=0.07)
assert rs["weekday_vol_ann"] == pytest.approx(0.01 * np.sqrt(365), rel=0.05)
def test_realised_fomc_lump():
rng = np.random.default_rng(3)
d, dates, c = datetime(2024, 1, 1), [datetime(2024, 1, 1)], [100.0]
fomc = {datetime.fromisoformat(x).date() for x in FOMC_PAST}
while d < datetime(2025, 12, 31):
d += timedelta(days=1)
lump = 0.03 ** 2 if d.date() in fomc else 0.0
c.append(c[-1] * np.exp(rng.normal(0, np.sqrt(0.005 ** 2 + lump))))
dates.append(d)
rs = realised_study([x.isoformat() for x in dates], c, "crypto", fomc_dates=FOMC_PAST)
assert rs["fomc"]["n"] == 16
assert rs["fomc"]["event_sd_pct"] == pytest.approx(3.0, abs=0.9)
assert rs["weekend_weight"] == pytest.approx(1.0, abs=0.25)
@pytest.fixture
def offline(monkeypatch):
monkeypatch.setattr(ev_router, "_now", lambda: NOW)
monkeypatch.setattr(news_router, "_load_calendar", lambda: (_ for _ in ()).throw(RuntimeError("offline")))
def test_crypto_endpoint(monkeypatch, offline):
evs = default_events(NOW, 200)
ev_var = {"FOMC": 0.03 ** 2, "CPI": 0.02 ** 2, "NFP": 0.015 ** 2}
rows = []
for d in [1, 2, 3, 5, 12, 19, 26, 54, 82, 173]:
t = (NOW + timedelta(days=d)).replace(hour=8)
rows.append({"expiry": t.strftime("%Y-%m-%d"), "label": t.strftime("%d%b%y").upper(), "days": d,
"atm_vol": model_vol(NOW, t, 0.45, 0.7, evs, ev_var, "crypto")["vol"]})
monkeypatch.setattr(vol_router, "get_crypto_vol", lambda ccy: {"expiries": rows, "index_price": 62000.0})
r = client.get("/api/eventvol/crypto/BTC")
assert r.status_code == 200, r.text
js = r.json()
assert js["fit"]["base_vol"] == pytest.approx(0.45, rel=1e-4)
assert js["moves"]["FOMC"]["event_sd_pct"] == pytest.approx(3.0, rel=1e-3)
assert len(js["curve"]) >= 85 and len(js["tenors"]) == 6
assert client.get("/api/eventvol/crypto/XRP").status_code == 404
def test_model_endpoint(offline):
base = {"pair": "XAUUSD", "S": 4400, "base_vol": 0.18, "weekend_weight": 0.15,
"event_moves_pct": {"FOMC": 1.4, "CPI": 1.1, "NFP": 1.0}, "expiry_days": 4}
r = client.post("/api/eventvol/model", json=base)
assert r.status_code == 200, r.text
js = r.json()
assert js["calculator"]["share"]["events"] > 0.15 # 4-day option spans CPI
on = js["tenors"][0]
assert on["tenor"] == "O/N" and on["vol"] > 0
# Fit mode: three quotes → event lumps held at the stated moves, base vol solved.
q = client.post("/api/eventvol/model", json={**base, "quotes": [{"days": 7, "vol": 0.21},
{"days": 30, "vol": 0.20}, {"days": 91, "vol": 0.19}]})
assert q.status_code == 200 and q.json()["fit"]["events_fixed"] is True
assert q.json()["moves"]["FOMC"]["event_sd_pct"] == pytest.approx(1.4)
# Dense quotes + fit_events frees the lumps.
dense = [{"days": d, "vol": 0.2} for d in (1, 2, 3, 4, 5, 7, 14, 21, 30, 61, 91)]
q2 = client.post("/api/eventvol/model", json={**base, "quotes": dense, "fit_events": True})
assert q2.status_code == 200 and q2.json()["fit"]["events_fixed"] is False
assert client.post("/api/eventvol/model", json={**base, "pair": "NOPE"}).status_code == 404
+5
View File
@@ -51,3 +51,8 @@ export const fetchCalendar = () =>
export const priceBarriers = (p) => api.post('/exotics/barrier', p).then(r=>r.data) 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 priceDci = (p) => api.post('/exotics/dci', p).then(r=>r.data)
export const priceAccumulator = (p) => api.post('/exotics/accumulator', p).then(r=>r.data) export const priceAccumulator = (p) => api.post('/exotics/accumulator', p).then(r=>r.data)
// Event & Weekend Vol
export const fetchEventVolCrypto = (ccy) => api.get(`/eventvol/crypto/${ccy}`).then(r=>r.data)
export const fetchEventVolRealised = (pair) => api.get(`/eventvol/realised/${pair}`).then(r=>r.data)
export const priceEventVol = (p) => api.post('/eventvol/model', p).then(r=>r.data)
+377
View File
@@ -0,0 +1,377 @@
import { useEffect, useState } from 'react'
import _Plot from 'react-plotly.js'
const Plot = _Plot?.default ?? _Plot
import { usePortfolioStore, isCrypto, PAIR_META } from '../store/portfolio'
import { fetchEventVolCrypto, fetchEventVolRealised, priceEventVol } from '../api/client'
const LAYOUT = {
paper_bgcolor:'transparent', plot_bgcolor:'#080d18',
font:{ color:'#94a3b8', size:11 },
margin:{ l:52, r:16, t:12, b:42 },
legend:{ bgcolor:'transparent', font:{ size:9 }, orientation:'h', x:0, y:-0.22 },
hoverlabel:{ bgcolor:'#0f172a', font:{ color:'#e2e8f0' }, bordercolor:'rgba(0,212,255,0.15)' },
}
const CFG = { responsive:true, displayModeBar:false }
const axis = (title, extra = {}) => ({ title:{ text:title }, gridcolor:'#1e293b', color:'#475569', zeroline:false, ...extra })
const pct = (v, d = 1) => (v == null ? '—' : (v * 100).toFixed(d) + '%')
const num = (v, d = 2) => (v == null ? '—' : Number(v).toLocaleString(undefined, { minimumFractionDigits:d, maximumFractionDigits:d }))
const EV_COLOR = { FOMC:'#ff3d5a', CPI:'#ffd700', NFP:'#c084fc', OTHER:'#00ff88' }
const fmtTime = (iso) => new Date(iso).toUTCString().slice(5, 22) + ' UTC'
// Starting assumptions per asset class: weekend weight and event 1-sd moves (%).
const DEFAULTS = {
metal: { weekend_weight:0.15, moves:{ FOMC:1.3, CPI:1.1, NFP:1.0, OTHER:0.8 } },
fx: { weekend_weight:0.15, moves:{ FOMC:0.6, CPI:0.5, NFP:0.5, OTHER:0.5 } },
crypto: { weekend_weight:0.70, moves:{ FOMC:2.5, CPI:2.0, NFP:1.5, OTHER:1.5 } },
}
const defaultsFor = (pair, sigma) => {
const cls = PAIR_META[pair]?.assetClass ?? 'fx'
const d = DEFAULTS[cls] ?? DEFAULTS.fx
const silver = pair === 'XAGUSD' ? 1.6 : 1
return { base_vol:+(sigma * 100).toFixed(1), weekend_weight:d.weekend_weight,
moves:Object.fromEntries(Object.entries(d.moves).map(([k, v]) => [k, +(v * silver).toFixed(2)])),
quotes:'', fitEvents:false, expiry_days:3, custom:{ name:'', time:'', type:'OTHER' } }
}
function useLoad(fn, key, enabled = true) {
const [res, setRes] = useState({ key:null, data:null, error:null })
useEffect(() => {
if (!enabled) return
let stale = false
const t = setTimeout(() => {
fn(JSON.parse(key))
.then(data => { if (!stale) setRes({ key, data, error:null }) })
.catch(e => { if (!stale) setRes({ key, data:null, error: e?.response?.data?.detail?.toString() ?? 'Request failed — is the backend up?' }) })
}, 250)
return () => { stale = true; clearTimeout(t) }
}, [fn, key, enabled])
return { ...res, loading: res.key !== key }
}
export default function EventVol() {
const { pair } = usePortfolioStore()
const [mode, setMode] = useState(null)
const live = mode ?? (isCrypto(pair) ? 'live' : 'model')
return (
<div style={{ padding:16 }}>
<div style={{ display:'flex', justifyContent:'space-between', alignItems:'flex-start', gap:12, marginBottom:12, flexWrap:'wrap' }}>
<div>
<div className="section-title">Event &amp; Weekend Vol — vol time, event moves, forward vols ({pair})</div>
<div style={{ fontSize:9, color:'var(--text-muted)', marginTop:2 }}>
Strip FOMC / CPI / NFP lumps and the weekend weight out of the ATM curve · price short-dated and 0DTE vols on vol time
</div>
</div>
<div style={{ display:'flex', gap:4 }}>
{isCrypto(pair) && <Chip active={live === 'live'} onClick={() => setMode('live')}>Fit live Deribit curve</Chip>}
<Chip active={live === 'model'} onClick={() => setMode('model')}>Model / fit quotes</Chip>
</div>
</div>
{live === 'live' && isCrypto(pair) ? <Live pair={pair} /> : <Model key={pair} pair={pair} />}
<Realised pair={pair} />
<div style={{ fontSize:9, color:'var(--text-muted)', marginTop:10, lineHeight:1.5 }}>
σ²(T)·T = σ<sub>b</sub>²·τ(T) + Σ v<sub>e</sub>, with τ = hours to expiry weighted 1 (weekday) or w (weekend), ACT/365 (Clark 2011 &quot;volatility
time&quot;; Castagna 2010). Fit: non-negative least squares on total variance with a grid over w. FX/metal weekend = Fri 21:00 → Sun 21:00 UTC;
crypto = Sat/Sun UTC. Built-in 2026 FOMC (14:00 ET), CPI/NFP (08:30 ET) dates merged with live Forex Factory high-impact events.
</div>
</div>
)
}
const Chip = ({ active, onClick, children }) => (
<button onClick={onClick} className="font-mono" style={{ padding:'3px 9px', borderRadius:4, fontSize:9, cursor:'pointer',
border:'1px solid ' + (active ? 'var(--cyan)' : 'var(--border)'), background: active ? 'rgba(0,212,255,0.1)' : 'transparent',
color: active ? 'var(--cyan)' : 'var(--text-muted)' }}>{children}</button>
)
function Card({ title, right, children }) {
return (
<div className="glass" style={{ borderRadius:8, padding:8 }}>
<div style={{ display:'flex', justifyContent:'space-between', alignItems:'center', margin:'2px 6px 4px', gap:8, flexWrap:'wrap' }}>
<span className="section-title" style={{ fontSize:10 }}>{title}</span>{right}
</div>
{children}
</div>
)
}
function Kpis({ items }) {
return (
<div style={{ display:'flex', gap:8, marginBottom:12, flexWrap:'wrap' }}>
{items.map(k => (
<div key={k.label} className="glass" title={k.hint} style={{ padding:'6px 12px', borderRadius:6, fontSize:11 }}>
<div style={{ color:'var(--text-muted)', fontSize:9, letterSpacing:'0.05em' }}>{k.label}</div>
<div className="font-mono" style={{ color: k.color ?? 'var(--cyan)', fontWeight:700, fontSize:14 }}>{k.value}</div>
{k.sub && <div className="font-mono" style={{ fontSize:9, color:'var(--text-muted)' }}>{k.sub}</div>}
</div>
))}
</div>
)
}
function ErrorBox({ msg }) {
return <div style={{ padding:8, borderRadius:6, fontSize:11, marginBottom:10, background:'rgba(255,61,90,0.1)', border:'1px solid #ff3d5a', color:'#ff3d5a' }}>{msg}</div>
}
const inputStyle = { background:'var(--bg3)', border:'1px solid var(--border)', borderRadius:4, color:'var(--cyan)', fontSize:11,
padding:'4px 6px', fontFamily:'var(--font-mono)', width:'100%' }
function Field({ label, value, onChange, step, hint, type = 'number', placeholder }) {
return (
<label title={hint} style={{ display:'flex', flexDirection:'column', gap:3, fontSize:9, color:'var(--text-muted)' }}>{label}
<input type={type} value={value} step={step} placeholder={placeholder}
onChange={e => onChange(type === 'number' ? +e.target.value : e.target.value)} style={inputStyle} />
</label>
)
}
function moveKpis(moves, spot, dp = 2) {
return Object.entries(moves ?? {}).filter(([, m]) => m.event_sd_pct > 0).map(([t, m]) => ({
label:`${t} implied move`, value:`±${m.event_sd_pct.toFixed(2)}%`, color:EV_COLOR[t],
sub:`day ${m.multiple_of_normal_day?.toFixed(1)}× normal · E|Δ| ${spot ? num(m.expected_abs_move, dp) : m.expected_abs_move_pct.toFixed(2) + '%'}`,
hint:'1-sd event lump √v; day = base day + lump; E|Δ| = √(2/π)·day sd (straddle breakeven)' }))
}
function eventShapes(events) {
return (events ?? []).map(e => ({ type:'line', x0:e.time, x1:e.time, yref:'paper', y0:0, y1:1,
line:{ color:EV_COLOR[e.type] ?? '#94a3b8', dash:'dot', width:1.2 } }))
}
function CurveChart({ d, marketPoints }) {
const evs = d.events ?? []
return (
<Card title="ATM term structure on vol time — daily expiries">
<Plot data={[
{ x:d.curve.map(c => c.date), y:d.curve.map(c => c.vol * 100), type:'scatter', mode:'lines+markers', name:'Model ATM vol',
line:{ color:'#00d4ff', width:2, shape:'hv' }, marker:{ size:4, color:d.curve.map(c => c.weekend ? '#475569' : '#00d4ff') } },
{ x:d.curve.map(c => c.date), y:d.curve.map(() => d.flat * 100), type:'scatter', mode:'lines', name:'Flat calendar vol (no events)',
line:{ color:'rgba(255,255,255,0.3)', dash:'dash', width:1 } },
...(marketPoints ? [{ x:marketPoints.map(p => p.time), y:marketPoints.map(p => p.vol * 100), type:'scatter', mode:'markers',
name:'Market (Deribit / quotes)', marker:{ color:'#ffd700', size:8, symbol:'diamond' } }] : []),
...Object.keys(EV_COLOR).filter(t => evs.some(e => e.type === t)).map(t => ({ x:[null], y:[null], type:'scatter', mode:'lines',
name:t, line:{ color:EV_COLOR[t], dash:'dot' } })),
]}
layout={{ ...LAYOUT, hovermode:'x unified', xaxis:axis('Expiry', { type:'date' }), yaxis:axis('ATM vol (%)'), shapes:eventShapes(evs) }}
config={CFG} style={{ width:'100%', height:300 }} />
<div style={{ fontSize:9, color:'var(--text-muted)', padding:'0 8px 4px' }}>
Steps up at each event (dotted) and flattens over weekends: a 1-day option spanning FOMC can carry 2–3× the vol of one that doesn&apos;t.
</div>
</Card>
)
}
function TenorTable({ rows }) {
return (
<Card title="Standard tenors — vol, forward vol and what drives the variance">
<div style={{ overflowX:'auto' }}>
<table style={{ width:'100%', borderCollapse:'collapse', fontSize:11 }}>
<thead><tr style={{ color:'var(--text-muted)', fontSize:9, textAlign:'right' }}>
<th style={{ textAlign:'left', padding:'4px 8px' }}>Tenor</th><th style={{ padding:'4px 8px' }}>Expiry</th><th style={{ padding:'4px 8px' }}>ATM vol</th>
<th style={{ padding:'4px 8px' }}>Fwd vol</th><th style={{ padding:'4px 8px' }}>Weekday</th><th style={{ padding:'4px 8px' }}>Weekend</th>
<th style={{ padding:'4px 8px' }}>Events</th><th style={{ textAlign:'left', padding:'4px 8px' }}>Spans</th></tr></thead>
<tbody>{rows.map(r => (
<tr key={r.tenor} className="font-mono" style={{ borderTop:'1px solid var(--border)', textAlign:'right' }}>
<td style={{ textAlign:'left', padding:'5px 8px' }}>{r.tenor}</td>
<td style={{ padding:'5px 8px', color:'var(--text-muted)' }}>{r.expiry.slice(0, 10)}</td>
<td style={{ padding:'5px 8px', color:'var(--cyan)' }}>{pct(r.vol, 2)}</td>
<td style={{ padding:'5px 8px' }}>{pct(r.fwd_vol, 2)}</td>
<td style={{ padding:'5px 8px' }}>{pct(r.share.weekday, 0)}</td>
<td style={{ padding:'5px 8px' }}>{pct(r.share.weekend, 0)}</td>
<td style={{ padding:'5px 8px', color: r.share.events > 0.2 ? 'var(--red)' : undefined }}>{pct(r.share.events, 0)}</td>
<td style={{ textAlign:'left', padding:'5px 8px', fontSize:9 }}>
{r.events.map((t, i) => <span key={i} style={{ color:EV_COLOR[t], marginRight:4 }}>{t}</span>)}
</td>
</tr>))}</tbody>
</table>
</div>
</Card>
)
}
function FitTable({ fit }) {
return (
<Card title={`Fit by expiry · RMSE ${(fit.rmse_vol * 100).toFixed(2)} vol`}>
<div style={{ overflowX:'auto', maxHeight:300 }}>
<table style={{ width:'100%', borderCollapse:'collapse', fontSize:11 }}>
<thead><tr style={{ color:'var(--text-muted)', fontSize:9, textAlign:'right' }}>
<th style={{ textAlign:'left', padding:'4px 8px' }}>Expiry</th><th style={{ padding:'4px 8px' }}>Days</th><th style={{ padding:'4px 8px' }}>Market</th>
<th style={{ padding:'4px 8px' }}>Model</th><th style={{ padding:'4px 8px' }}>Error</th><th style={{ padding:'4px 8px' }}>Mkt fwd vol</th>
<th style={{ padding:'4px 8px' }}>Event share</th></tr></thead>
<tbody>{fit.expiries.map(r => (
<tr key={r.time} className="font-mono" style={{ borderTop:'1px solid var(--border)', textAlign:'right',
background: r.calendar_arb ? 'rgba(255,61,90,0.12)' : 'transparent' }}>
<td style={{ textAlign:'left', padding:'5px 8px' }}>{r.label ?? r.time.slice(0, 10)}</td>
<td style={{ padding:'5px 8px' }}>{r.days.toFixed(1)}</td>
<td style={{ padding:'5px 8px', color:'#ffd700' }}>{pct(r.market_vol, 2)}</td>
<td style={{ padding:'5px 8px', color:'var(--cyan)' }}>{pct(r.model_vol, 2)}</td>
<td style={{ padding:'5px 8px', color: Math.abs(r.error_vol) > 0.01 ? 'var(--red)' : undefined }}>{(r.error_vol * 100).toFixed(2)}</td>
<td style={{ padding:'5px 8px' }}>{r.calendar_arb ? <span style={{ color:'var(--red)' }}>ARB</span> : pct(r.fwd_vol_market, 1)}</td>
<td style={{ padding:'5px 8px' }}>{pct(r.event_share, 0)}</td>
</tr>))}</tbody>
</table>
</div>
<div style={{ fontSize:9, color:'var(--text-muted)', padding:'6px 8px' }}>
Red row = negative forward variance between consecutive expiries (calendar arbitrage). Errors &gt; 1 vol flag a mispriced expiry or an unmodelled event.
</div>
</Card>
)
}
function EventList({ events }) {
if (!events?.length) return null
return (
<div className="glass" style={{ borderRadius:8, padding:'6px 10px', marginBottom:12, fontSize:10, display:'flex', gap:12, flexWrap:'wrap' }}>
<span style={{ color:'var(--text-muted)' }}>Scheduled events:</span>
{events.slice(0, 10).map((e, i) => (
<span key={i} className="font-mono" style={{ color:EV_COLOR[e.type] }}>{e.name} · {fmtTime(e.time)}</span>))}
</div>
)
}
// ─── Live Deribit fit ────────────────────────────────────────────────────────
function Live({ pair }) {
const ccy = pair.slice(0, 3)
const { data:d, error, loading } = useLoad(fetchEventVolCrypto, JSON.stringify(ccy))
if (error) return <ErrorBox msg={`${error} — switch to "Model / fit quotes" to work offline.`} />
if (!d) return <div style={{ fontSize:11, color:'var(--text-muted)', padding:12 }}>Fitting the live Deribit ATM curve…</div>
const f = d.fit
return (
<div style={{ opacity: loading ? 0.6 : 1 }}>
<Kpis items={[
{ label:'Base (ex-event) vol', value:pct(f.base_vol), sub:`flat-calendar ${pct(f.base_vol * Math.sqrt((5 + 2 * f.weekend_weight) / 7))}`,
hint:'Weekday diffusive vol after removing event lumps' },
{ label:'Weekend weight', value:f.weekend_weight.toFixed(2), sub:'weekend hour ÷ weekday hour', color:'#c084fc' },
...moveKpis(d.moves, d.spot, 0),
]} />
<EventList events={d.events} />
<div style={{ display:'grid', gridTemplateColumns:'repeat(auto-fit, minmax(420px, 1fr))', gap:12, marginBottom:12 }}>
<CurveChart d={{ ...d, flat: f.base_vol * Math.sqrt((5 + 2 * f.weekend_weight) / 7) }}
marketPoints={f.expiries.map(e => ({ time:e.time, vol:e.market_vol }))} />
<FitTable fit={f} />
</div>
<TenorTable rows={d.tenors} />
<div style={{ fontSize:9, color:'var(--text-muted)', marginTop:6 }}>
{d.source}. Event types whose dates fall between two listed expiries are identified exactly; with daily Deribit expiries that is usually all of them.
</div>
</div>
)
}
// ─── Model / quote fit (any pair) ─────────────────────────────────────────────
function parseQuotes(s) {
return s.split(/[,;\n]+/).map(x => x.trim()).filter(Boolean).map(x => {
const [days, vol] = x.split(/[:=\s]+/).map(Number)
return Number.isFinite(days) && Number.isFinite(vol) && days > 0 && vol > 0 ? { days, vol: vol / 100 } : null
}).filter(Boolean)
}
function Model({ pair }) {
const { S, sigma } = usePortfolioStore()
const dp = PAIR_META[pair]?.dp ?? 4
const [p, setP] = useState(() => defaultsFor(pair, sigma))
const set = (k) => (v) => setP(q => ({ ...q, [k]: v }))
const setMove = (k) => (v) => setP(q => ({ ...q, moves:{ ...q.moves, [k]: v } }))
const setCustom = (k) => (v) => setP(q => ({ ...q, custom:{ ...q.custom, [k]: v } }))
const quotes = parseQuotes(p.quotes)
const custom = p.custom.time ? [{ name:p.custom.name || 'Custom event', type:p.custom.type, time:new Date(p.custom.time + 'Z').toISOString() }] : []
const body = { pair, S, base_vol:p.base_vol / 100, weekend_weight:p.weekend_weight, event_moves_pct:p.moves,
quotes, fit_events:p.fitEvents, custom_events:custom, expiry_days:p.expiry_days > 0 ? p.expiry_days : null }
const { data:d, error, loading } = useLoad(priceEventVol, JSON.stringify(body), p.base_vol > 0)
const c = d?.calculator
return (
<div>
<div className="glass" style={{ borderRadius:8, padding:10, marginBottom:12 }}>
<div style={{ display:'grid', gridTemplateColumns:'repeat(auto-fill, minmax(110px, 1fr))', gap:10, alignItems:'end' }}>
<Field label="Base vol σ_b (%)" value={p.base_vol} onChange={set('base_vol')} step={0.5} hint="Weekday, ex-event vol (ACT/365)" />
<Field label="Weekend weight" value={p.weekend_weight} onChange={set('weekend_weight')} step={0.05} />
{Object.keys(p.moves).map(k => <Field key={k} label={`${k} move (±%)`} value={p.moves[k]} onChange={setMove(k)} step={0.1} />)}
<Field label="Calculator expiry (days)" value={p.expiry_days} onChange={set('expiry_days')} step={0.5} />
</div>
<div style={{ display:'grid', gridTemplateColumns:'2fr 0.9fr 1fr 1.1fr 0.8fr', gap:10, alignItems:'end', marginTop:10 }}>
<Field type="text" label="Market ATM quotes to fit — days:vol%, e.g. 7:21, 30:19.5, 91:18.8" value={p.quotes}
onChange={set('quotes')} placeholder="leave blank to price from the assumptions" />
<label title="Sparse tenors cannot pin event lumps; free them only with dense quotes around each event"
style={{ display:'flex', flexDirection:'column', gap:3, fontSize:9, color:'var(--text-muted)' }}>Event lumps in fit
<select value={p.fitEvents ? 'fit' : 'hold'} onChange={e => set('fitEvents')(e.target.value === 'fit')} style={inputStyle}>
<option value="hold">Hold at my moves</option><option value="fit">Fit from quotes</option>
</select>
</label>
<Field type="text" label="Custom event name" value={p.custom.name} onChange={setCustom('name')} placeholder="ECB / BoJ / election" />
<Field type="datetime-local" label="Event time (UTC)" value={p.custom.time} onChange={setCustom('time')} />
<label style={{ display:'flex', flexDirection:'column', gap:3, fontSize:9, color:'var(--text-muted)' }}>Event type
<select value={p.custom.type} onChange={e => setCustom('type')(e.target.value)} style={inputStyle}>
{['OTHER', 'FOMC', 'CPI', 'NFP'].map(t => <option key={t}>{t}</option>)}
</select>
</label>
</div>
<div style={{ fontSize:9, color:'var(--text-muted)', marginTop:6 }}>
No free listed-option feed exists for {pair}: price from your own assumptions, or paste broker / OVML ATM quotes and the base vol
{quotes.length >= 3 ? ' is fitted' : ' will be fitted (≥ 3 quotes)'}.
Use the realised study below to anchor the weekend weight and FOMC move.
</div>
</div>
{error && <ErrorBox msg={error} />}
{d && (
<div style={{ opacity: loading ? 0.6 : 1, transition:'opacity .2s' }}>
<Kpis items={[
...(c ? [{ label:`Fair ATM vol · ${p.expiry_days}d`, value:pct(c.vol, 2), sub:`expiry ${c.expiry.slice(0, 16).replace('T', ' ')} UTC`,
hint:'Model vol to the calculator expiry' },
{ label:'Variance mix', value:`${pct(c.share.events, 0)} events`, color: c.share.events > 0.25 ? 'var(--red)' : 'var(--cyan)',
sub:`${pct(c.share.weekday, 0)} weekday · ${pct(c.share.weekend, 0)} weekend` }] : []),
{ label: d.fit ? 'Fitted base vol' : 'Base vol', value:pct(d.base_vol), sub:`flat-calendar ${pct(d.flat_calendar_vol)}` },
...(d.fit ? [{ label:'Quote fit RMSE', value:`${(d.fit.rmse_vol * 100).toFixed(2)} vol`, sub: d.fit.events_fixed ? 'events held at inputs' : 'events fitted' }] : []),
...moveKpis(d.moves, d.spot, dp),
]} />
<EventList events={d.events} />
<div style={{ display:'grid', gridTemplateColumns:'repeat(auto-fit, minmax(420px, 1fr))', gap:12, marginBottom:12 }}>
<CurveChart d={{ ...d, flat:d.flat_calendar_vol }} marketPoints={d.fit ? d.fit.expiries.map(e => ({ time:e.time, vol:e.market_vol })) : null} />
<TenorTable rows={d.tenors} />
</div>
{d.fit && <FitTable fit={d.fit} />}
</div>
)}
</div>
)
}
// ─── Realised study ───────────────────────────────────────────────────────────
function Realised({ pair }) {
const { data:d, error } = useLoad(fetchEventVolRealised, JSON.stringify(pair))
if (error) return <div style={{ fontSize:9, color:'var(--text-muted)', marginTop:12 }}>Realised study unavailable ({error}).</div>
if (!d) return null
return (
<div style={{ marginTop:12, display:'grid', gridTemplateColumns:'repeat(auto-fit, minmax(420px, 1fr))', gap:12 }}>
<Card title={`Realised vol by weekday · ${d.start} → ${d.end} (event days excluded)`}>
<Plot data={[{ x:d.day_of_week.map(r => r.day), y:d.day_of_week.map(r => r.vol_ann * 100), type:'bar',
marker:{ color:d.day_of_week.map(r => ['Sat', 'Sun', 'Mon'].includes(r.day) ? 'rgba(192,132,252,0.6)' : 'rgba(0,212,255,0.5)') },
text:d.day_of_week.map(r => `n=${r.n}`), textposition:'outside', hovertemplate:'%{x}: %{y:.1f}%<extra></extra>' }]}
layout={{ ...LAYOUT, showlegend:false, xaxis:axis(''), yaxis:axis('Close-to-close vol, ann. (%)') }}
config={CFG} style={{ width:'100%', height:240 }} />
<div style={{ fontSize:9, color:'var(--text-muted)', padding:'0 8px 4px' }}>
{d.market === 'crypto' ? 'Weekend weight = Sat/Sun variance ÷ weekday variance.' : 'Monday spans the weekend: weight = (var Mon − var Tue–Fri) ÷ (2 · var Tue–Fri).'}
</div>
</Card>
<Card title="What history says">
<Kpis items={[
{ label:'Realised weekend weight', value: d.weekend_weight == null ? '—' : d.weekend_weight.toFixed(2), color:'#c084fc' },
{ label:'Weekday vol (ex-FOMC)', value:pct(d.weekday_vol_ann) },
...(d.fomc ? [{ label:`FOMC lump (${d.fomc.n} meetings)`, value:`±${d.fomc.event_sd_pct.toFixed(2)}%`, color:EV_COLOR.FOMC,
sub:`avg |2-day move| ${d.fomc.avg_abs_2d_move_pct.toFixed(2)}% vs ${d.fomc.normal_2d_sd_pct.toFixed(2)}% normal sd` }] : []),
]} />
{d.fomc && (
<div className="font-mono" style={{ fontSize:10, padding:'0 8px', display:'flex', gap:10, flexWrap:'wrap' }}>
{d.fomc.recent.map(w => (
<span key={w.date} style={{ color: w.move_2d_pct >= 0 ? 'var(--green)' : 'var(--red)' }}>{w.date.slice(2)} {w.move_2d_pct >= 0 ? '+' : ''}{w.move_2d_pct.toFixed(2)}%</span>))}
</div>
)}
<div style={{ fontSize:9, color:'var(--text-muted)', padding:'8px' }}>
Compare the implied lumps above with the realised one: implied &gt; realised is event vol risk premium you can sell (e.g. buy the
weekly calendar, sell the event day); implied &lt; realised says the market is under-pricing the meeting.
</div>
</Card>
</div>
)
}
+2
View File
@@ -15,6 +15,7 @@ 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 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'))
@@ -47,6 +48,7 @@ const TABS = [
{ id:'signals', icon:'⚡', label:'AI Signals', component: QuantSignals }, { id:'signals', icon:'⚡', label:'AI Signals', component: QuantSignals },
{ id:'vollab', icon:'🔬', label:'Vol Lab', component: VolLab }, { id:'vollab', icon:'🔬', label:'Vol Lab', component: VolLab },
{ id:'volforecast', icon:'🤖', label:'Vol Forecast', component: VolForecast }, { id:'volforecast', icon:'🤖', label:'Vol Forecast', component: VolForecast },
{ id:'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:'exotics', icon:'🧩', label:'Exotics Desk', component: ExoticsDesk },
Binary file not shown.

After

Width:  |  Height:  |  Size: 491 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 460 KiB