mirror of
https://github.com/Mihirkansara/nexus-quant-terminal.git
synced 2026-11-02 09:36:11 +00:00
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:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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)]
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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 & 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. "vs EWMA" = 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>
|
||||||
|
)
|
||||||
@@ -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 |
Reference in New Issue
Block a user