mirror of
https://github.com/Mihirkansara/nexus-quant-terminal.git
synced 2026-11-02 09:36:11 +00:00
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:
@@ -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 |
|
||||
| **Cross-Asset Desk** | Gold · silver · BTC · ETH · EURUSD · DXY · US10Y · WTI correlation matrices (30D/90D/1Y/EWMA) with a correlation-break monitor; gold/silver ratio z-scores, OU half-life, Engle-Granger cointegration and Kalman hedge ratio; Margrabe outperformance-option pricer with correlation sensitivity |
|
||||
| **Jump Lab** | Merton and Bates (Heston + jumps, COS pricing) calibrated to every live Deribit BTC/ETH expiry: model-vs-market smiles, fit RMSE, jump intensity/size, jump share of variance, crash-jump odds, 30-day risk-neutral density vs lognormal; manual Merton for metals/FX event and gap risk; one-click hand-off of jump parameters to Hedge Lab |
|
||||
| **Event Vol** | Event & weekend "vol time" term structure: strips FOMC / CPI / NFP variance lumps and the weekend weight out of the ATM curve (live Deribit fit for BTC/ETH; assumptions or pasted broker quotes for XAU/XAG/EUR/any pair), event-implied moves and straddle breakevens, forward vols and calendar-arbitrage flags, fair vol for any expiry incl. 0DTE/1-day, built-in 2026 FOMC/CPI/NFP calendar merged with live Forex Factory events, realised day-of-week / weekend-weight / FOMC-lump study |
|
||||
| **Exotics Desk** | Structured-products pricer: all eight single barriers (Reiner-Rubinstein/Haug, rebates) with Broadie-Glasserman-Kou discrete-monitoring correction, barrier price/delta profile through the barrier, touch ladder of hit probabilities, smile-consistent digitals (−∂C/∂K on the vanna-volga smile); Dual Currency Investment / crypto "Dual Investment" fair-APR ladder with an offer audit (platform margin, implied vol you are paid); KO accumulator Monte Carlo with zero-cost strike, KO odds and geared-loss tail, optional Merton jumps |
|
||||
|
||||
### Live Intelligence
|
||||
|
||||
@@ -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
@@ -1,6 +1,6 @@
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from .routers import greeks, surface, market, montecarlo, scenarios, strategies, export, forex, institutional, news, livedata, smile, vol, hedge, xasset, jumps, 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",
|
||||
description="Forex options risk analytics — Garman-Kohlhagen model.",
|
||||
@@ -9,7 +9,7 @@ app = FastAPI(title="QuantRisk FX Terminal API",
|
||||
app.add_middleware(CORSMiddleware, allow_origins=["*"],
|
||||
allow_credentials=True, allow_methods=["*"], allow_headers=["*"])
|
||||
|
||||
for router in [greeks, surface, market, montecarlo, scenarios, strategies, export, forex, institutional, news, livedata, smile, vol, hedge, xasset, jumps, 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.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,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
|
||||
@@ -51,3 +51,8 @@ export const fetchCalendar = () =>
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import HedgeLab from '../components/HedgeLab'
|
||||
import CrossAsset from '../components/CrossAsset'
|
||||
import JumpLab from '../components/JumpLab'
|
||||
import ExoticsDesk from '../components/ExoticsDesk'
|
||||
import EventVol from '../components/EventVol'
|
||||
|
||||
const IVSurface = lazy(() => import('../components/IVSurface'))
|
||||
const WorldMap = lazy(() => import('../components/WorldMap'))
|
||||
@@ -47,6 +48,7 @@ const TABS = [
|
||||
{ id:'signals', icon:'⚡', label:'AI Signals', component: QuantSignals },
|
||||
{ id:'vollab', icon:'🔬', label:'Vol Lab', component: VolLab },
|
||||
{ id:'volforecast', icon:'🤖', label:'Vol Forecast', component: VolForecast },
|
||||
{ id:'eventvol', icon:'📆', label:'Event Vol', component: EventVol },
|
||||
{ id:'hedgelab', icon:'⚖', label:'Hedge Lab', component: HedgeLab },
|
||||
{ id:'jumps', icon:'⚡', label:'Jump Lab', component: JumpLab },
|
||||
{ id:'exotics', icon:'🧩', label:'Exotics Desk', component: ExoticsDesk },
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 491 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 460 KiB |
Reference in New Issue
Block a user