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

Daily: Vol Forecast & Regime engine (HAR-RV, GJR-GARCH, ML gradient boosting, HMM)
This commit is contained in:
github-actions[bot]
2026-10-03 04:48:28 +00:00
committed by GitHub
11 changed files with 934 additions and 0 deletions
+1
View File
@@ -39,6 +39,7 @@ NEXUS TERMINAL is a Bloomberg-style web application for professional forex optio
| **Strategy Library** | Pre-built multi-leg strategies: straddle, strangle, bull/bear spreads, iron condor, butterfly | | **Strategy Library** | Pre-built multi-leg strategies: straddle, strangle, bull/bear spreads, iron condor, butterfly |
| **Vol Smile (Vanna-Volga)** | Builds the FX smile from ATM / 25Δ risk reversal / 25Δ butterfly quotes (Castagna-Mercurio) and reprices each leg off the smile vs flat vol | | **Vol Smile (Vanna-Volga)** | Builds the FX smile from ATM / 25Δ risk reversal / 25Δ butterfly quotes (Castagna-Mercurio) and reprices each leg off the smile vs flat vol |
| **Vol Lab** | Live Deribit BTC/ETH surface with a raw-SVI fit per expiry (Durrleman butterfly-arb check), ATM / 25Δ RR / 25Δ BF term structure, DVOL, vol risk premium, and a Burghardt-Lane realised-vol cone for every pair. Tests: `cd backend && python -m pytest tests` | | **Vol Lab** | Live Deribit BTC/ETH surface with a raw-SVI fit per expiry (Durrleman butterfly-arb check), ATM / 25Δ RR / 25Δ BF term structure, DVOL, vol risk premium, and a Burghardt-Lane realised-vol cone for every pair. Tests: `cd backend && python -m pytest tests` |
| **Vol Forecast & Regime (AI)** | HAR-RV (weekend-aware for crypto), GJR-GARCH and gradient-boosting forecasts of 1D/1W/1M vol, rolling out-of-sample QLIKE leaderboard with an inverse-QLIKE ensemble, 2-state HMM regime detection, and a forecast-vs-Deribit-implied rich/cheap signal |
### Live Intelligence ### Live Intelligence
+437
View File
@@ -0,0 +1,437 @@
"""
vol_forecast.py — Volatility forecasting & regime engine for volatile assets.
Forecasts the average variance over the next h days (1D, 1W, 1M) with four
models, scores them on a rolling out-of-sample backtest and blends them:
1. HAR-RV (Corsi 2009): heterogeneous autoregression of log realised variance
log RV(t+1..t+h) = b0 + b_d·log RV_d + b_w·log RV_w + b_m·log RV_m [+ b_we·weekend]
with a weekend dummy for 24/7 crypto (intraweek seasonality, cf. Brauneis et al.).
2. GJR-GARCH(1,1) (Glosten, Jagannathan & Runkle 1993), Gaussian QMLE:
σ²_t = ω + (α + γ·1[r_{t−1}<0])·r²_{t−1} + β·σ²_{t−1}
with the closed-form multi-step variance term structure.
3. Gradient boosting (scikit-learn HistGradientBoostingRegressor) on HAR features
plus returns, ranges and calendar features — the ML benchmark of the
recent realised-vol literature.
4. EWMA RiskMetrics (λ = 0.94) — naive benchmark.
Daily variance proxy: Garman-Klass (1980) range estimator plus the squared
overnight gap, falling back to squared close-to-close returns without OHLC.
Discretely sampled highs/lows bias range estimators down, so the proxy is
rescaled to the mean squared close-to-close return (Hansen & Lunde 2005 style)
— forecasts are then on the same scale as close-to-close and implied vol.
Evaluation: QLIKE and MSE are the loss functions whose rankings are robust to
noise in the variance proxy (Patton 2011):
QLIKE(y, f) = y/f − ln(y/f) − 1
The ensemble weights each model by inverse out-of-sample QLIKE.
Regimes: 2-state Gaussian hidden Markov model on daily returns (Hamilton 1989),
fitted by Baum-Welch EM with scaled forward-backward recursions.
References:
Corsi, F. (2009). J. Financial Econometrics, 7(2), 174–196.
Glosten, L., Jagannathan, R. & Runkle, D. (1993). J. Finance, 48(5), 1779–1801.
Garman, M. & Klass, M. (1980). J. Business, 53(1), 67–78.
Patton, A. (2011). J. Econometrics, 160(1), 246–256.
Hamilton, J. (1989). Econometrica, 57(2), 357–384.
Brauneis, A., Mestel, R. et al. (2024). Crypto volatility forecasting.
"""
import warnings
import numpy as np
from scipy.optimize import minimize
from scipy.signal import lfilter
try:
from sklearn.ensemble import HistGradientBoostingRegressor
SKLEARN_AVAILABLE = True
except ImportError: # the engine still runs with the econometric models
SKLEARN_AVAILABLE = False
MODELS = ["HAR-RV", "GJR-GARCH", "Gradient Boosting", "EWMA"]
# ─── variance proxy & features ────────────────────────────────────────────────
def daily_variance(o, h, l, c, calib_end=None):
"""Garman-Klass daily variance + squared overnight gap (fallback: r²), scaled so
its mean matches the mean squared close-to-close return over [0, calib_end)."""
c = np.asarray(c, dtype=float)
r = np.diff(np.log(c))
if o is None or h is None or l is None:
return np.maximum(r ** 2, 1e-12), r
o, h, l = (np.asarray(x, dtype=float)[1:] for x in (o, h, l))
cc = c[1:]
gk = 0.5 * np.log(h / l) ** 2 - (2 * np.log(2) - 1) * np.log(cc / o) ** 2
gap = np.log(o / c[:-1]) ** 2
v = np.maximum(gk, 0) + gap
bad = ~np.isfinite(v) | (v <= 0)
v[bad] = r[bad] ** 2
k = slice(0, calib_end) # calibrate on in-sample data only
v *= np.mean(r[k] ** 2) / np.mean(v[k]) # scale to close-to-close variance
return np.maximum(v, 1e-12), r
def _rolling_mean(x, w):
cs = np.concatenate([[0.0], np.cumsum(x)])
out = np.full(len(x), np.nan)
out[w - 1:] = (cs[w:] - cs[:-w]) / w
return out
def _forward_mean(x, h):
"""Mean of x[t+1 .. t+h] aligned at t (NaN where unavailable)."""
cs = np.concatenate([[0.0], np.cumsum(x)])
out = np.full(len(x), np.nan)
n = len(x)
idx = np.arange(n - h)
out[idx] = (cs[idx + 1 + h] - cs[idx + 1]) / h
return out
def build_features(v, r, ppy, weekday=None):
"""HAR + ML feature matrix aligned at each day t (information up to t)."""
w_w = max(2, round(7 * ppy / 365))
w_m = max(5, round(30 * ppy / 365))
lv = np.log(v)
rv_w, rv_m = _rolling_mean(v, w_w), _rolling_mean(v, w_m)
har = np.column_stack([lv, np.log(rv_w), np.log(rv_m)])
extra = np.column_stack([
r, np.abs(r), np.minimum(r, 0) ** 2 / v.mean(),
np.log(rv_w) - np.log(rv_m),
_rolling_mean(r, w_m) * np.sqrt(w_m) / np.sqrt(np.maximum(rv_m, 1e-12)),
])
cal = None
if weekday is not None:
nxt = (np.asarray(weekday) + 1) % 7 # weekday of t+1
cal = (nxt >= 5).astype(float)[:, None] # next day is Sat/Sun
return har, extra, cal, w_w, w_m
# ─── models ───────────────────────────────────────────────────────────────────
def _ols(X, y):
X1 = np.column_stack([np.ones(len(X)), X])
beta, *_ = np.linalg.lstsq(X1, y, rcond=None)
resid = y - X1 @ beta
return beta, float(np.var(resid))
class HAR:
def __init__(self, use_weekend):
self.use_weekend = use_weekend
def _X(self, har, cal, h):
if self.use_weekend and cal is not None and h == 1:
return np.column_stack([har, cal])
return har
def fit(self, har, cal, y, h):
self.h = h
self.beta, self.s2 = _ols(self._X(har, cal, h), np.log(y))
return self
def predict(self, har, cal):
X = self._X(har, cal, self.h)
# log-normal bias correction: E[RV] = exp(μ + s²/2)
return np.exp(self.beta[0] + X @ self.beta[1:] + 0.5 * self.s2)
class GBM:
def fit(self, X, y):
self.m = HistGradientBoostingRegressor(max_iter=150, learning_rate=0.05, max_depth=3,
min_samples_leaf=25, l2_regularization=1.0,
random_state=0)
ly = np.log(y)
self.m.fit(X, ly)
self.s2 = float(np.var(ly - self.m.predict(X)))
return self
def predict(self, X):
return np.exp(self.m.predict(X) + 0.5 * self.s2)
def _gjr_filter(params, r, var0):
"""σ²[0] = var0, σ²[t+1] = ω + (α + γ·1[r_t<0])·r_t² + β·σ²[t] (vectorised AR(1) filter)."""
omega, alpha, gamma, beta = params
r = np.asarray(r, dtype=float)
x = omega + (alpha + gamma * (r < 0)) * r ** 2
tail = lfilter([1.0], [1.0, -beta], x, zi=[beta * var0])[0] if len(r) else np.array([])
return np.concatenate([[var0], tail])
def fit_gjr_garch(r):
"""Gaussian QMLE of GJR-GARCH(1,1) on demeaned returns. Returns params + filtered σ²."""
r = np.asarray(r, dtype=float) - np.mean(r)
var0 = float(np.var(r))
def unpack(x):
# positivity + stationarity via smooth transforms
a, g, b = np.exp(x[1:4])
tot = a + 0.5 * g + b
scale = 0.999 / tot if tot >= 0.999 else 1.0
a, g, b = a * scale, g * scale, b * scale
omega = var0 * (1 - a - 0.5 * g - b) * np.exp(x[0])
return omega, a, g, b
def nll(x):
p = unpack(x)
s2 = _gjr_filter(p, r, var0)[:-1]
s2 = np.maximum(s2, 1e-14)
return 0.5 * np.sum(np.log(s2) + r ** 2 / s2)
best = None
for x0 in ([0.0, np.log(0.05), np.log(0.05), np.log(0.85)],
[0.0, np.log(0.10), np.log(0.01), np.log(0.80)]):
res = minimize(nll, x0, method="L-BFGS-B")
if best is None or res.fun < best.fun:
best = res
params = unpack(best.x)
return params, _gjr_filter(params, r, var0), var0
def gjr_avg_variance(params, s2_next, h):
"""Average conditional variance over the next h days (closed form)."""
omega, a, g, b = params
persist = a + 0.5 * g + b
vl = omega / max(1 - persist, 1e-9)
k = np.arange(h)
return float(np.mean(vl + persist ** k * (s2_next - vl)))
# ─── 2-state Gaussian HMM ─────────────────────────────────────────────────────
def fit_hmm(r, n_iter=200, tol=1e-7):
"""Baum-Welch EM for a 2-state Gaussian HMM. State 1 = high-vol regime."""
x = np.asarray(r, dtype=float)
n = len(x)
q = np.quantile(np.abs(x), 0.75)
calm = np.abs(x) <= q
mu = np.array([x[calm].mean(), x[~calm].mean()])
sd = np.array([x[calm].std() + 1e-9, x[~calm].std() + 1e-9])
A = np.array([[0.97, 0.03], [0.06, 0.94]])
pi = np.array([0.7, 0.3])
ll_old = -np.inf
for _ in range(n_iter):
B = np.exp(-0.5 * ((x[:, None] - mu) / sd) ** 2) / (sd * np.sqrt(2 * np.pi)) + 1e-300
alpha = np.empty((n, 2)); c = np.empty(n)
alpha[0] = pi * B[0]; c[0] = alpha[0].sum(); alpha[0] /= c[0]
for t in range(1, n):
alpha[t] = (alpha[t - 1] @ A) * B[t]
c[t] = alpha[t].sum(); alpha[t] /= c[t]
beta = np.ones((n, 2))
for t in range(n - 2, -1, -1):
beta[t] = (A @ (B[t + 1] * beta[t + 1])) / c[t + 1]
gamma = alpha * beta
gamma /= gamma.sum(axis=1, keepdims=True)
xi = (alpha[:-1, :, None] * A[None] * (B[1:] * beta[1:])[:, None, :]) / c[1:, None, None]
pi = gamma[0]
A = xi.sum(0) / gamma[:-1].sum(0)[:, None]
A /= A.sum(1, keepdims=True)
w = gamma.sum(0)
mu = (gamma * x[:, None]).sum(0) / w
sd = np.sqrt((gamma * (x[:, None] - mu) ** 2).sum(0) / w) + 1e-9
ll = float(np.log(c).sum())
if abs(ll - ll_old) < tol * abs(ll):
break
ll_old = ll
if sd[0] > sd[1]: # order states: 0 = calm, 1 = turbulent
order = [1, 0]
mu, sd, A = mu[order], sd[order], A[np.ix_(order, order)]
alpha, gamma = alpha[:, order], gamma[:, order]
return {"mu": mu, "sd": sd, "A": A, "filtered": alpha, "smoothed": gamma, "loglik": ll}
# ─── backtest & report ────────────────────────────────────────────────────────
def qlike(y, f):
ratio = np.asarray(y) / np.asarray(f)
return float(np.mean(ratio - np.log(ratio) - 1))
def ewma_series(r, lam=0.94):
"""EWMA variance after observing r[0..t], for every t (RiskMetrics)."""
r = np.asarray(r, dtype=float)
s0 = float(np.var(r[:30])) if len(r) >= 30 else float(np.var(r))
return lfilter([1 - lam], [1.0, -lam], r ** 2, zi=[lam * s0])[0]
def _garch_state(r, last, cache):
"""GJR-GARCH fitted on r[..last], filter rolled forward without re-estimation, so
s2_full[t+1] uses returns up to t only. Cached: one fit serves every horizon."""
if last not in cache:
params, s2, _ = fit_gjr_garch(r[: last + 1])
rr = r - np.mean(r[: last + 1])
s2_full = np.empty(len(r) + 1)
s2_full[: last + 2] = s2
s2_full[last + 1:] = _gjr_filter(params, rr[last + 1:], s2[-1])
cache[last] = (params, s2_full)
return cache[last]
def _model_forecasts(fit_idx, pred_idx, h, v, r, har, X_ml, cal, use_weekend,
ewma, garch_cache, garch_last):
"""Fit every model on targets known at fit_idx; forecast at pred_idx."""
y = _forward_mean(v, h)
train = fit_idx[np.isfinite(y[fit_idx]) & np.all(np.isfinite(X_ml[fit_idx]), axis=1)]
out = {}
har_m = HAR(use_weekend).fit(har[train], None if cal is None else cal[train], y[train], h)
out["HAR-RV"] = har_m.predict(har[pred_idx], None if cal is None else cal[pred_idx])
if SKLEARN_AVAILABLE:
with warnings.catch_warnings():
warnings.simplefilter("ignore")
out["Gradient Boosting"] = GBM().fit(X_ml[train], y[train]).predict(X_ml[pred_idx])
params, s2_full = _garch_state(r, garch_last, garch_cache)
out["GJR-GARCH"] = np.array([gjr_avg_variance(params, s2_full[t + 1], h) for t in pred_idx])
out["EWMA"] = ewma[pred_idx]
return out, params
def forecast_report(o, h, l, c, dates=None, ppy=252, n_test=250, refit_every=21,
implied=None):
"""
Full report: current forecasts per model and horizon, out-of-sample scores,
ensemble, backtest series, HMM regime and (optionally) comparison to implied vols.
implied: {"1W": σ, "1M": σ} annualised implied vols (e.g. Deribit ATM).
"""
n_ret = len(c) - 1
w_m_est = max(5, round(30 * ppy / 365))
n_test_est = int(min(n_test, max(60, n_ret - (w_m_est + 5) - 250)))
# Calibrate the proxy scale on data before the first backtest origin (no look-ahead).
v, r = daily_variance(o, h, l, c, calib_end=n_ret - w_m_est - n_test_est)
weekday = None
if dates is not None and ppy == 365:
weekday = np.array([d.weekday() for d in dates[1:]])
har, extra, cal, w_w, w_m = build_features(v, r, ppy, weekday)
X_ml = np.column_stack([har, extra] + ([cal] if cal is not None else []))
n = len(v)
first_valid = w_m + 5
horizons = [("1D", 1), ("1W", w_w), ("1M", w_m)]
n_test = int(min(n_test, max(60, n - first_valid - 250)))
if n - first_valid < 300:
raise ValueError("need at least ~300 days of history for a fair backtest")
use_weekend = cal is not None
ewma = ewma_series(r)
garch_cache = {}
max_h = w_m
start = n - max_h - n_test # common origins for every horizon
scores, backtest, current = {}, {}, {}
for label, hz in horizons:
y = _forward_mean(v, hz)
preds = {m: [] for m in MODELS if m != "Gradient Boosting" or SKLEARN_AVAILABLE}
origins = []
for blk in range(start, n - max_h, refit_every):
pred_idx = np.arange(blk, min(blk + refit_every, n - max_h))
# Only targets fully realised by the block start may be used for fitting;
# GARCH (no target) is estimated on returns up to the block start.
fit_idx = np.arange(first_valid, blk - hz + 1)
f, _ = _model_forecasts(fit_idx, pred_idx, hz, v, r, har, X_ml, cal, use_weekend,
ewma, garch_cache, blk)
for m in preds:
preds[m].extend(f[m])
origins.extend(pred_idx)
origins = np.array(origins)
realised = y[origins]
model_scores = {m: {"qlike": qlike(realised, np.array(p)),
"rmse_vol": float(np.sqrt(np.mean((np.sqrt(np.array(p) * ppy)
- np.sqrt(realised * ppy)) ** 2))),
"bias_vol": float(np.mean(np.sqrt(np.array(p) * ppy)
- np.sqrt(realised * ppy)))}
for m, p in preds.items()}
inv = {m: 1 / s["qlike"] for m, s in model_scores.items() if m != "EWMA"}
tot = sum(inv.values())
weights = {m: w / tot for m, w in inv.items()}
ens = sum(weights[m] * np.array(preds[m]) for m in weights)
model_scores["Ensemble"] = {
"qlike": qlike(realised, ens),
"rmse_vol": float(np.sqrt(np.mean((np.sqrt(ens * ppy) - np.sqrt(realised * ppy)) ** 2))),
"bias_vol": float(np.mean(np.sqrt(ens * ppy) - np.sqrt(realised * ppy))),
}
ewma_q = model_scores["EWMA"]["qlike"]
for s in model_scores.values():
s["qlike_vs_ewma_pct"] = (1 - s["qlike"] / ewma_q) * 100
scores[label] = {"days": hz, "weights": weights, "models": model_scores,
"n_forecasts": int(len(origins))}
# Current forecast: fit on everything realised so far, predict from today.
fit_idx = np.arange(first_valid, n - hz)
f, gparams = _model_forecasts(fit_idx, np.array([n - 1]), hz, v, r, har, X_ml, cal,
use_weekend, ewma, garch_cache, n - 1)
cur = {m: float(np.sqrt(f[m][0] * ppy)) for m in f}
cur["Ensemble"] = float(np.sqrt(sum(weights[m] * f[m][0] for m in weights) * ppy))
current[label] = cur
if label == "1M":
backtest = {
"origin_index": origins.tolist(),
"dates": [str(dates[1:][i])[:10] for i in origins] if dates is not None else None,
"realised": np.sqrt(realised * ppy).tolist(),
"forecasts": {m: np.sqrt(np.array(p) * ppy).tolist() for m, p in preds.items()},
"ensemble": np.sqrt(ens * ppy).tolist(),
}
garch = dict(zip(["omega", "alpha", "gamma", "beta"], map(float, gparams)))
garch["persistence"] = garch["alpha"] + 0.5 * garch["gamma"] + garch["beta"]
garch["long_run_vol"] = float(np.sqrt(garch["omega"] / max(1 - garch["persistence"], 1e-9) * ppy))
garch["half_life_days"] = float(np.log(0.5) / np.log(garch["persistence"])) \
if 0 < garch["persistence"] < 1 else None
hmm = fit_hmm(r[-min(len(r), 1000):])
A = hmm["A"]
p_now = hmm["filtered"][-1]
p_next = p_now @ A
regime = {
"state_vol": (hmm["sd"] * np.sqrt(ppy)).tolist(),
"state_mean_ann": (hmm["mu"] * ppy).tolist(),
"transition": A.tolist(),
"expected_duration_days": (1 / np.maximum(1 - np.diag(A), 1e-9)).tolist(),
"p_turbulent_now": float(p_now[1]),
"p_turbulent_next": float(p_next[1]),
"current": "TURBULENT" if p_now[1] > 0.5 else "CALM",
"history_p_turbulent": hmm["smoothed"][-min(365, len(r)):, 1].tolist(),
"history_dates": [str(x)[:10] for x in dates[1:][-min(365, len(r)):]] if dates is not None else None,
}
signals = {}
if implied:
for label in ("1W", "1M"):
iv = implied.get(label)
if iv:
fc = current[label]["Ensemble"]
spread = iv - fc
signals[label] = {
"implied": iv, "forecast": fc, "spread": spread,
"ratio": iv / fc,
"view": "IMPLIED RICH" if spread > 0.1 * fc else
"IMPLIED CHEAP" if spread < -0.1 * fc else "FAIR",
}
rv_now = {lbl: float(np.sqrt(np.nanmean(v[-hz:]) * ppy)) for lbl, hz in horizons}
return _clean({
"periods_per_year": ppy,
"n_obs": int(n),
"horizons": {lbl: hz for lbl, hz in horizons},
"ml_available": SKLEARN_AVAILABLE,
"realised_now": rv_now,
"current": current,
"scores": scores,
"backtest": backtest,
"garch": garch,
"regime": regime,
"implied_signals": signals,
})
def _clean(obj, nd=6):
if isinstance(obj, (float, np.floating)):
x = float(obj)
return round(x, nd) if np.isfinite(x) else None
if isinstance(obj, (np.integer,)):
return int(obj)
if isinstance(obj, dict):
return {k: _clean(v, nd) for k, v in obj.items()}
if isinstance(obj, (list, tuple)):
return [_clean(v, nd) for v in obj]
return obj
+78
View File
@@ -123,3 +123,81 @@ def get_crypto_vol(currency: str):
_cache.pop(f"crypto:{currency}", None) _cache.pop(f"crypto:{currency}", None)
raise HTTPException(503, f"Deribit data unavailable: {e}") raise HTTPException(503, f"Deribit data unavailable: {e}")
return {"currency": currency, "source": "Deribit public API", **report} return {"currency": currency, "source": "Deribit public API", **report}
# ─── Vol forecast & regime engine ─────────────────────────────────────────────
def _yf_ohlc(sym: str, period: str = "3y"):
hist = yf.download(sym, period=period, interval="1d", progress=False, auto_adjust=True)
if hist.empty:
return None
if hasattr(hist.columns, "droplevel"):
try:
hist.columns = hist.columns.droplevel(1)
except Exception:
pass
hist = hist[["Open", "High", "Low", "Close"]].dropna()
hist = hist[(hist > 0).all(axis=1)]
return {"dates": [d.date() for d in hist.index],
**{k: hist[col].to_numpy(dtype=float) for k, col in
(("o", "Open"), ("h", "High"), ("l", "Low"), ("c", "Close"))}}
def _crypto_ohlc(currency: str):
"""Daily OHLC of the Deribit perpetual (3y); falls back to yfinance spot."""
now = int(time.time() * 1000)
try:
res = _deribit("get_tradingview_chart_data", instrument_name=f"{currency}-PERPETUAL",
start_timestamp=now - 1100 * _DAY_MS, end_timestamp=now, resolution="1D")
if res.get("status", "ok") == "ok" and len(res.get("close", [])) > 400:
from datetime import datetime, timezone
arr = {k: np.asarray(res[src], dtype=float) for k, src in
(("o", "open"), ("h", "high"), ("l", "low"), ("c", "close"))}
ok = np.all([arr[k] > 0 for k in arr], axis=0)
return {"dates": [datetime.fromtimestamp(t / 1000, tz=timezone.utc).date()
for t, keep in zip(res["ticks"], ok) if keep],
**{k: a[ok] for k, a in arr.items()}}
except Exception:
pass
return _yf_ohlc(f"{currency}-USD")
@router.get("/forecast/{pair}")
def get_vol_forecast(pair: str):
"""
HAR-RV / GJR-GARCH / gradient-boosting vol forecasts (1D, 1W, 1M) with a rolling
out-of-sample QLIKE backtest, inverse-QLIKE ensemble, 2-state HMM regime and,
for BTC/ETH, a comparison against Deribit implied vols.
"""
from ..core.vol_forecast import forecast_report
pair = pair.upper()
if pair not in PAIRS:
raise HTTPException(404, f"Unknown pair '{pair}'.")
meta = PAIRS[pair]
crypto = meta["asset_class"] == "crypto"
def load():
data = _crypto_ohlc(pair[:3]) if crypto else _yf_ohlc(meta["sym"])
if not data or len(data["c"]) < 400:
raise ValueError("not enough daily OHLC history (need ~400 days)")
implied, dvol = None, None
if crypto:
try:
surf = get_crypto_vol(pair[:3])
ts = {r["tenor"]: r["atm_vol"] for r in surf.get("atm_term_structure", [])}
implied = {"1W": ts.get("1W"), "1M": surf.get("iv_30d")}
dvol = surf.get("dvol")
except HTTPException:
pass
rep = forecast_report(data["o"], data["h"], data["l"], data["c"], dates=data["dates"],
ppy=meta["periods_per_year"], implied=implied)
return {**rep, "dvol": dvol, "last_price": float(data["c"][-1]),
"last_date": str(data["dates"][-1])}
try:
report = _cached(f"forecast:{pair}", 1800, load)
except Exception as e:
_cache.pop(f"forecast:{pair}", None)
raise HTTPException(503, f"Vol forecast unavailable: {e}")
return {"pair": pair, "asset_class": meta["asset_class"], **report}
+1
View File
@@ -7,3 +7,4 @@ yfinance>=0.2.38
pydantic>=2.0,<3.0 pydantic>=2.0,<3.0
reportlab>=4.0,<5.0 reportlab>=4.0,<5.0
httpx>=0.27,<1.0 httpx>=0.27,<1.0
scikit-learn>=1.3,<2.0
+123
View File
@@ -0,0 +1,123 @@
"""Validation of the vol forecast & regime engine on simulations with known answers.
Run from backend/: python -m pytest tests -q
"""
import numpy as np
from fastapi.testclient import TestClient
import app.core.vol_forecast as vf
from app.main import app
from app.routers import vol as vol_router
from tests.vol_sim import dates, gjr_garch, ohlc_from_daily_vols, regime_vols
def _sv_path(n, base, phi, eta, rng):
"""AR(1) log-vol stochastic-volatility path (daily sd)."""
lv = np.empty(n)
lv[0] = np.log(base)
for t in range(1, n):
lv[t] = np.log(base) + phi * (lv[t - 1] - np.log(base)) + eta * rng.standard_normal()
return np.exp(lv)
def _crypto_sample(seed=9, n=1300, weekend=0.5):
rng = np.random.default_rng(seed)
d = dates(n)
wk = np.array([weekend if x.weekday() >= 5 else 1.0 for x in d[1:]])
o, h, l, c = ohlc_from_daily_vols(_sv_path(n, 0.03, 0.97, 0.12, rng) * wk, rng, s0=83000)
return o, h, l, c, d
def test_scaled_range_proxy_is_unbiased():
rng = np.random.default_rng(0)
o, h, l, c = ohlc_from_daily_vols(np.full(3000, 0.02), rng)
v, r = vf.daily_variance(o, h, l, c)
assert abs(v.mean() / 0.02 ** 2 - 1) < 0.03
assert np.var(r ** 2) / np.var(v) > 3 # range proxy is far less noisy
def test_vectorised_gjr_filter_matches_recursion():
p = (2e-5, 0.05, 0.08, 0.88)
r = np.random.default_rng(1).standard_normal(400) * 0.02
s = np.empty(401); s[0] = 4e-4
for t in range(400):
s[t + 1] = p[0] + (p[1] + p[2] * (r[t] < 0)) * r[t] ** 2 + p[3] * s[t]
assert np.allclose(vf._gjr_filter(p, r, 4e-4), s, rtol=1e-12)
def test_gjr_garch_recovers_parameters():
_, r = gjr_garch(4000, 2e-5, 0.05, 0.08, 0.88, np.random.default_rng(1))
omega, a, g, b = vf.fit_gjr_garch(r)[0]
assert abs(a - 0.05) < 0.03 and abs(g - 0.08) < 0.05 and abs(b - 0.88) < 0.04
assert abs((a + g / 2 + b) - 0.97) < 0.015
def test_hmm_recovers_regimes():
rng = np.random.default_rng(0)
vols, states = regime_vols(2000, [0.01, 0.035], [0.98, 0.95], rng)
m = vf.fit_hmm(vols * rng.standard_normal(2000))
assert np.allclose(m["sd"], [0.01, 0.035], rtol=0.1)
assert ((m["smoothed"][:, 1] > 0.5) == states).mean() > 0.93
def test_models_beat_ewma_and_weekend_feature_helps():
o, h, l, c, d = _crypto_sample()
rep = vf.forecast_report(o, h, l, c, dates=d, ppy=365)
for hz in ("1D", "1W", "1M"):
m = rep["scores"][hz]["models"]
assert m["HAR-RV"]["qlike"] < m["EWMA"]["qlike"]
assert m["Ensemble"]["qlike"] < m["EWMA"]["qlike"]
assert abs(sum(rep["scores"][hz]["weights"].values()) - 1) < 1e-9
assert rep["horizons"] == {"1D": 1, "1W": 7, "1M": 30}
assert 0 <= rep["regime"]["p_turbulent_now"] <= 1
with_dummy = rep["scores"]["1D"]["models"]["HAR-RV"]["qlike"]
no_dates = vf.forecast_report(o, h, l, c, dates=None, ppy=365)
assert with_dummy < 0.8 * no_dates["scores"]["1D"]["models"]["HAR-RV"]["qlike"]
def test_backtest_has_no_look_ahead():
o, h, l, c, d = _crypto_sample(n=1200)
v, r = vf.daily_variance(o, h, l, c, calib_end=900)
wd = np.array([x.weekday() for x in d[1:]])
har, extra, cal, _, w_m = vf.build_features(v, r, 365, wd)
X = np.column_stack([har, extra, cal])
blk, hz = 1000, 30
fit, pred = np.arange(w_m + 5, blk - hz + 1), np.arange(blk, blk + 21)
before, _ = vf._model_forecasts(fit, pred, hz, v, r, har, X, cal, True, vf.ewma_series(r), {}, blk)
# Scramble everything after the last prediction origin: forecasts must not move.
cut = pred[-1] + 1
v2, r2 = v.copy(), r.copy()
v2[cut:] *= 7.0
r2[cut:] *= -3.0
har2, extra2, cal2, _, _ = vf.build_features(v2, r2, 365, wd)
X2 = np.column_stack([har2, extra2, cal2])
after, _ = vf._model_forecasts(fit, pred, hz, v2, r2, har2, X2, cal2, True, vf.ewma_series(r2), {}, blk)
for m in before:
assert np.allclose(before[m], after[m]), m
def test_implied_signal_and_endpoint(monkeypatch):
o, h, l, c, d = _crypto_sample(n=1100)
data = {"dates": d, "o": o, "h": h, "l": l, "c": c}
monkeypatch.setattr(vol_router, "_crypto_ohlc", lambda ccy: data)
monkeypatch.setattr(vol_router, "_yf_ohlc", lambda sym, period="3y": data)
monkeypatch.setattr(vol_router, "get_crypto_vol", lambda ccy: {
"atm_term_structure": [{"tenor": "1W", "atm_vol": 2.0}], "iv_30d": 0.05,
"dvol": {"last": 0.4, "series": []}})
vol_router._cache.clear()
client = TestClient(app)
res = client.get("/api/vol/forecast/btcusd")
assert res.status_code == 200
j = res.json()
assert j["implied_signals"]["1W"]["view"] == "IMPLIED RICH" # 200% implied
assert j["implied_signals"]["1M"]["view"] == "IMPLIED CHEAP" # 5% implied
assert len(j["backtest"]["dates"]) == len(j["backtest"]["realised"]) > 100
xag = client.get("/api/vol/forecast/XAGUSD").json()
assert xag["periods_per_year"] == 252 and xag["horizons"]["1M"] == 21
assert xag["implied_signals"] == {}
assert client.get("/api/vol/forecast/NOPE").status_code == 404
short = {k: (v[:200] if k != "dates" else v[:200]) for k, v in data.items()}
monkeypatch.setattr(vol_router, "_yf_ohlc", lambda sym, period="3y": short)
vol_router._cache.clear()
assert client.get("/api/vol/forecast/EURUSD").status_code == 503
+41
View File
@@ -0,0 +1,41 @@
"""Simulators with known volatility dynamics for validating vol_forecast."""
from datetime import date, timedelta
import numpy as np
def ohlc_from_daily_vols(daily_sd, rng, s0=100.0, steps=48):
"""Intraday Brownian paths -> daily O/H/L/C with exact daily variance daily_sd²."""
n = len(daily_sd)
o, h, l, c = (np.empty(n + 1) for _ in range(4))
o[0] = h[0] = l[0] = c[0] = s0
px = s0
for t in range(n):
inc = rng.standard_normal(steps) * daily_sd[t] / np.sqrt(steps)
path = px * np.exp(np.concatenate([[0.0], np.cumsum(inc)]))
o[t + 1], h[t + 1], l[t + 1], c[t + 1] = path[0], path.max(), path.min(), path[-1]
px = path[-1]
return o, h, l, c
def gjr_garch(n, omega, alpha, gamma, beta, rng):
"""Returns (conditional sd path, returns) of a GJR-GARCH(1,1) with N(0,1) shocks."""
s2 = omega / (1 - alpha - 0.5 * gamma - beta)
sds, rets = np.empty(n), np.empty(n)
for t in range(n):
sds[t] = np.sqrt(s2)
rets[t] = sds[t] * rng.standard_normal()
s2 = omega + (alpha + gamma * (rets[t] < 0)) * rets[t] ** 2 + beta * s2
return sds, rets
def regime_vols(n, vols, p_stay, rng):
state, out, states = 0, [], []
for _ in range(n):
out.append(vols[state]); states.append(state)
if rng.random() > p_stay[state]:
state = 1 - state
return np.array(out), np.array(states)
def dates(n, start=date(2023, 1, 2)):
return [start + timedelta(days=i) for i in range(n + 1)]
+1
View File
@@ -25,6 +25,7 @@ export const computeSmile = (p) => api.post('/smile', p).then(r=>
// Vol Lab // Vol Lab
export const fetchVolCone = (pair) => api.get(`/vol/cone/${pair}`).then(r=>r.data) export const fetchVolCone = (pair) => api.get(`/vol/cone/${pair}`).then(r=>r.data)
export const fetchCryptoVol = (currency) => api.get(`/vol/crypto/${currency}`).then(r=>r.data) export const fetchCryptoVol = (currency) => api.get(`/vol/crypto/${currency}`).then(r=>r.data)
export const fetchVolForecast = (pair) => api.get(`/vol/forecast/${pair}`).then(r=>r.data)
export const exportCSV = (p) => api.post('/export/csv', p, {responseType:'blob'}).then(r=>r.data) export const exportCSV = (p) => api.post('/export/csv', p, {responseType:'blob'}).then(r=>r.data)
// Institutional flow // Institutional flow
+250
View File
@@ -0,0 +1,250 @@
import { useEffect, useState } from 'react'
import _Plot from 'react-plotly.js'
const Plot = _Plot?.default ?? _Plot
import { usePortfolioStore, isCrypto } from '../store/portfolio'
import { fetchVolForecast } from '../api/client'
const pct = (v, d = 1) => (v == null ? '—' : (v * 100).toFixed(d) + '%')
const MODEL_COLORS = {
'Ensemble': '#00ff88', 'HAR-RV': '#00d4ff', 'GJR-GARCH': '#ffd700',
'Gradient Boosting': '#c084fc', 'EWMA': '#94a3b8',
}
const HORIZONS = ['1D', '1W', '1M']
const VIEW_COLOR = { 'IMPLIED RICH': 'var(--amber)', 'IMPLIED CHEAP': 'var(--green)', 'FAIR': 'var(--cyan)' }
const LAYOUT = {
paper_bgcolor:'transparent', plot_bgcolor:'#080d18',
font:{ color:'#94a3b8', size:11 },
margin:{ l:52, r:16, t:12, b:44 },
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 axis = (title, extra = {}) => ({ title:{ text:title }, gridcolor:'#1e293b', color:'#475569', zeroline:false, ...extra })
export default function VolForecast() {
const { pair } = usePortfolioStore()
const [nonce, setNonce] = useState(0)
const [response, setResponse] = useState({ key:null, data:null, error:null })
const [horizon, setHorizon] = useState('1M')
const requestKey = `${pair}:${nonce}`
useEffect(() => {
let stale = false
fetchVolForecast(pair)
.then(data => { if (!stale) setResponse({ key:requestKey, data, error:null }) })
.catch(e => { if (!stale) setResponse({ key:requestKey, data:null,
error: e?.response?.data?.detail ?? e?.message ?? 'Forecast unavailable' }) })
return () => { stale = true }
}, [pair, requestKey])
const loading = response.key !== requestKey
const d = response.key?.startsWith(`${pair}:`) ? response.data : null
const error = response.key === requestKey ? response.error : null
return (
<div style={{ padding:16 }}>
<div style={{ display:'flex', alignItems:'flex-start', justifyContent:'space-between', gap:12, marginBottom:12, flexWrap:'wrap' }}>
<div>
<div className="section-title">Vol Forecast &amp; Regime — {pair}</div>
<div style={{ fontSize:9, color:'var(--text-muted)', marginTop:2 }}>
HAR-RV · GJR-GARCH · Gradient Boosting (ML) · HMM regimes · out-of-sample QLIKE backtest
{d && <> · {d.periods_per_year}-day vol · data to {d.last_date}</>}
</div>
</div>
<button className="btn-ghost" onClick={() => setNonce(n => n + 1)} disabled={loading} style={{ fontSize:10 }}>
{loading ? '⟳ Fitting models' : '↻ Refresh'}
</button>
</div>
{error && (
<div style={{ padding:8, borderRadius:6, marginBottom:8, fontSize:11,
background:'rgba(255,61,90,0.1)', border:'1px solid #ff3d5a', color:'#ff3d5a' }}>{error}</div>
)}
{!d && loading && (
<div>
<div style={{ fontSize:11, color:'var(--text-muted)', marginBottom:8 }}>
Fitting 4 models across 3 horizons and running a rolling out-of-sample backtest (first load can take ~10–20s)…
</div>
<div className="skeleton" style={{ height:360, borderRadius:8 }}/>
</div>
)}
{d && (
<div style={{ opacity: loading ? 0.6 : 1 }}>
<Kpis d={d} pair={pair} />
<div style={{ display:'grid', gridTemplateColumns:'repeat(auto-fit, minmax(380px, 1fr))', gap:12, marginBottom:12 }}>
<TermChart d={d} />
<RegimeChart d={d} />
</div>
<BacktestChart d={d} />
<Leaderboard d={d} horizon={horizon} setHorizon={setHorizon} />
<div style={{ fontSize:9, color:'var(--text-muted)', marginTop:10, lineHeight:1.5 }}>
Forecast target: average variance over the next h days, proxy = Garman-Klass range + overnight gap scaled to
close-to-close (calibrated before the backtest window). HAR-RV (Corsi 2009){isCrypto(pair) ? ' with a weekend dummy for 24/7 crypto' : ''};
GJR-GARCH(1,1) Gaussian QMLE (Glosten-Jagannathan-Runkle 1993); gradient boosting on HAR, return and calendar features.
Models refit every 21 days on data available at each origin; scored with QLIKE, robust to proxy noise (Patton 2011);
ensemble weights ∝ 1/QLIKE. Regimes: 2-state Gaussian HMM (Hamilton 1989).
</div>
</div>
)}
</div>
)
}
function Kpis({ d, pair }) {
const sig = d.implied_signals?.['1M']
const reg = d.regime
const items = [
{ label:'Forecast 1M (ensemble)', value: pct(d.current['1M'].Ensemble) },
{ label:'Realised 1M', value: pct(d.realised_now['1M']) },
...(isCrypto(pair) ? [
{ label:'Implied 1M (Deribit)', value: pct(sig?.implied) },
{ label:'Implied − forecast', value: sig ? `${sig.spread >= 0 ? '+' : ''}${(sig.spread * 100).toFixed(1)} vol` : '—',
sub: sig?.view, color: sig ? VIEW_COLOR[sig.view] : undefined },
] : []),
{ label:'Regime (HMM)', value: reg.current, sub:`P(turbulent) ${pct(reg.p_turbulent_now, 0)}`,
color: reg.current === 'TURBULENT' ? 'var(--red)' : 'var(--green)' },
// Persistence ≳ 0.995 is effectively IGARCH: shocks barely decay and the "long-run vol" is not meaningful.
d.garch?.persistence >= 0.995
? { label:'GARCH persistence', value: d.garch.persistence.toFixed(3), sub:'near-integrated (IGARCH-like)', color:'var(--amber)' }
: { label:'GARCH half-life', value: d.garch?.half_life_days ? `${d.garch.half_life_days.toFixed(0)}d` : '—',
sub:`long-run σ ${pct(d.garch?.long_run_vol)}` },
]
return (
<div style={{ display:'flex', gap:8, marginBottom:12, flexWrap:'wrap' }}>
{items.map(k => (
<div key={k.label} className="glass" 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: k.color ?? 'var(--text-muted)' }}>{k.sub}</div>}
</div>
))}
</div>
)
}
function Card({ title, children }) {
return (
<div className="glass" style={{ borderRadius:8, padding:8 }}>
<div className="section-title" style={{ fontSize:10, margin:'2px 0 4px 6px' }}>{title}</div>
{children}
</div>
)
}
function TermChart({ d }) {
const models = Object.keys(d.current['1M'])
const x = HORIZONS.map(h => d.horizons[h])
const traces = models.map(m => ({
x, y:HORIZONS.map(h => d.current[h][m] * 100), type:'scatter', mode:'lines+markers', name:m,
line:{ color:MODEL_COLORS[m], width: m === 'Ensemble' ? 3 : 1.5, dash: m === 'EWMA' ? 'dot' : 'solid' },
marker:{ size: m === 'Ensemble' ? 8 : 5 },
}))
traces.push({ x, y:HORIZONS.map(h => d.realised_now[h] * 100), type:'scatter', mode:'markers',
name:'Trailing realised', marker:{ color:'#ffffff', size:7, symbol:'x' } })
const iv = HORIZONS.filter(h => d.implied_signals?.[h])
if (iv.length) traces.push({ x:iv.map(h => d.horizons[h]), y:iv.map(h => d.implied_signals[h].implied * 100),
type:'scatter', mode:'markers', name:'Deribit implied ATM', marker:{ color:'#ff3d5a', size:11, symbol:'diamond' } })
return (
<Card title="Forecast term structure (annualised)">
<Plot data={traces}
layout={{ ...LAYOUT, xaxis:axis('', { tickvals:x, ticktext:HORIZONS }), yaxis:axis('Vol (%)') }}
config={{ responsive:true, displayModeBar:false }} style={{ width:'100%', height:280 }} />
</Card>
)
}
function RegimeChart({ d }) {
const r = d.regime
const x = r.history_dates ?? r.history_p_turbulent.map((_, i) => i)
return (
<Card title={`HMM regime · calm σ ${pct(r.state_vol[0], 0)} (~${r.expected_duration_days[0].toFixed(0)}d) · turbulent σ ${pct(r.state_vol[1], 0)} (~${r.expected_duration_days[1].toFixed(0)}d)`}>
<Plot data={[{ x, y:r.history_p_turbulent.map(p => p * 100), type:'scatter', mode:'lines', fill:'tozeroy',
name:'P(turbulent)', line:{ color:'#ff3d5a', width:1.5 }, fillcolor:'rgba(255,61,90,0.15)' }]}
layout={{ ...LAYOUT, showlegend:false, xaxis:axis(''), yaxis:axis('P(turbulent) %', { range:[0, 100] }),
shapes:[{ type:'line', xref:'paper', x0:0, x1:1, y0:50, y1:50, line:{ color:'rgba(255,255,255,0.2)', dash:'dot', width:1 } }] }}
config={{ responsive:true, displayModeBar:false }} style={{ width:'100%', height:280 }} />
</Card>
)
}
function BacktestChart({ d }) {
const b = d.backtest
if (!b?.realised?.length) return null
const x = b.dates ?? b.origin_index
const traces = [
{ x, y:b.realised.map(v => v * 100), type:'scatter', mode:'lines', name:'Realised next 1M',
line:{ color:'#ffffff', width:2 } },
{ x, y:b.ensemble.map(v => v * 100), type:'scatter', mode:'lines', name:'Ensemble forecast',
line:{ color:MODEL_COLORS.Ensemble, width:2 } },
...Object.entries(b.forecasts).map(([m, ys]) => ({
x, y:ys.map(v => v * 100), type:'scatter', mode:'lines', name:m, visible:'legendonly',
line:{ color:MODEL_COLORS[m], width:1.2 } })),
]
if (d.dvol?.series?.length) traces.push({
x:d.dvol.series.map(p => new Date(p[0]).toISOString().slice(0, 10)), y:d.dvol.series.map(p => p[1] * 100),
type:'scatter', mode:'lines', name:'DVOL (implied 30d)', line:{ color:'#ff3d5a', width:1.5, dash:'dot' } })
return (
<div style={{ marginBottom:12 }}>
<Card title="Out-of-sample backtest — 1M-ahead forecast at each date vs the vol realised over the following month">
<Plot data={traces}
layout={{ ...LAYOUT, hovermode:'x unified', xaxis:axis(''), yaxis:axis('Vol (%)') }}
config={{ responsive:true, displayModeBar:false }} style={{ width:'100%', height:300 }} />
</Card>
</div>
)
}
function Leaderboard({ d, horizon, setHorizon }) {
const s = d.scores[horizon]
const rows = Object.entries(s.models).sort((a, b) => a[1].qlike - b[1].qlike)
const best = rows[0][0]
return (
<div>
<div style={{ display:'flex', alignItems:'center', gap:8, marginBottom:6 }}>
<span className="section-title" style={{ fontSize:10 }}>Model leaderboard · {s.n_forecasts} out-of-sample forecasts</span>
{HORIZONS.map(h => (
<button key={h} onClick={() => setHorizon(h)} className="font-mono"
style={{ padding:'2px 8px', borderRadius:4, fontSize:9, cursor:'pointer',
border:'1px solid ' + (h === horizon ? 'var(--cyan)' : 'var(--border)'),
background: h === horizon ? 'rgba(0,212,255,0.1)' : 'transparent',
color: h === horizon ? 'var(--cyan)' : 'var(--text-muted)' }}>{h}</button>
))}
</div>
<div style={{ overflowX:'auto', borderRadius:8, border:'1px solid var(--border)' }}>
<table style={{ width:'100%', borderCollapse:'collapse', fontSize:10 }}>
<thead>
<tr style={{ background:'var(--bg3)' }}>
{['Model', 'QLIKE', 'vs EWMA', 'RMSE (vol pts)', 'Bias (vol pts)', 'Ensemble weight', `Forecast ${horizon}`].map((h, i) => (
<th key={h} style={{ padding:'7px 10px', fontWeight:600, fontSize:9, color:'var(--text-muted)',
textAlign: i === 0 ? 'left' : 'right', whiteSpace:'nowrap' }}>{h}</th>
))}
</tr>
</thead>
<tbody>
{rows.map(([m, v]) => (
<tr key={m} style={{ borderTop:'1px solid var(--border)', background: m === best ? 'rgba(0,255,136,0.05)' : undefined }}>
<td style={{ padding:'6px 10px', color:MODEL_COLORS[m], fontWeight:600 }}>{m}{m === best ? ' ★' : ''}</td>
<Num v={v.qlike.toFixed(4)} />
<Num v={m === 'EWMA' ? '—' : `${v.qlike_vs_ewma_pct >= 0 ? '+' : ''}${v.qlike_vs_ewma_pct.toFixed(1)}%`}
color={m === 'EWMA' ? undefined : v.qlike_vs_ewma_pct >= 0 ? 'var(--green)' : 'var(--red)'} />
<Num v={(v.rmse_vol * 100).toFixed(2)} />
<Num v={`${v.bias_vol >= 0 ? '+' : ''}${(v.bias_vol * 100).toFixed(2)}`} />
<Num v={s.weights[m] != null ? pct(s.weights[m], 0) : '—'} />
<Num v={pct(d.current[horizon][m])} color="var(--cyan)" />
</tr>
))}
</tbody>
</table>
</div>
<div style={{ fontSize:9, color:'var(--text-muted)', marginTop:6 }}>
Lower QLIKE is better. &quot;vs EWMA&quot; = QLIKE reduction against the RiskMetrics benchmark. Positive bias = over-forecasting.
{!d.ml_available && ' Gradient boosting unavailable (scikit-learn not installed on the server).'}
</div>
</div>
)
}
const Num = ({ v, color }) => (
<td className="font-mono" style={{ padding:'6px 10px', textAlign:'right', whiteSpace:'nowrap', color }}>{v}</td>
)
+2
View File
@@ -10,6 +10,7 @@ import InstitutionalFlow from '../components/InstitutionalFlow'
import EconomicCalendar from '../components/EconomicCalendar' import EconomicCalendar from '../components/EconomicCalendar'
import VolSmile from '../components/VolSmile' import VolSmile from '../components/VolSmile'
import VolLab from '../components/VolLab' import VolLab from '../components/VolLab'
import VolForecast from '../components/VolForecast'
const IVSurface = lazy(() => import('../components/IVSurface')) const IVSurface = lazy(() => import('../components/IVSurface'))
const WorldMap = lazy(() => import('../components/WorldMap')) const WorldMap = lazy(() => import('../components/WorldMap'))
@@ -41,6 +42,7 @@ const TABS = [
{ id:'chart', icon:'📈', label:'Chart', component: CandlestickChart }, { id:'chart', icon:'📈', label:'Chart', component: CandlestickChart },
{ 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:'smile', icon:'〰', label:'Vol Smile', component: VolSmile }, { id:'smile', icon:'〰', label:'Vol Smile', component: VolSmile },
{ id:'surfaces', icon:'🗻', label:'3D Surfaces', component: SurfacePlot }, { id:'surfaces', icon:'🗻', label:'3D Surfaces', component: SurfacePlot },
{ id:'breakeven', icon:'🎯', label:'Breakeven', component: BreakevenChart }, { id:'breakeven', icon:'🎯', label:'Breakeven', component: BreakevenChart },
Binary file not shown.

After

Width:  |  Height:  |  Size: 504 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 195 KiB