Spaces:
Sleeping
Sleeping
Create app.py
Browse files
app.py
ADDED
|
@@ -0,0 +1,456 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# app.py — Volatility Mean-Reversion
|
| 2 |
+
import io
|
| 3 |
+
from datetime import datetime, timedelta
|
| 4 |
+
|
| 5 |
+
import numpy as np
|
| 6 |
+
import pandas as pd
|
| 7 |
+
import streamlit as st
|
| 8 |
+
import yfinance as yf
|
| 9 |
+
import statsmodels.api as sm
|
| 10 |
+
from statsmodels.tsa.stattools import adfuller
|
| 11 |
+
from statsmodels.tsa.regime_switching.markov_regression import MarkovRegression
|
| 12 |
+
from plotly.subplots import make_subplots
|
| 13 |
+
import plotly.graph_objects as go
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
# ============================== Page config ===============================
|
| 17 |
+
st.set_page_config(page_title="Volatility Mean-Reversion", layout="wide")
|
| 18 |
+
st.title("Volatility Mean-Reversion")
|
| 19 |
+
|
| 20 |
+
st.markdown(
|
| 21 |
+
"Compare **implied** volatility (VIX) with **realized** volatility of the S&P 500, "
|
| 22 |
+
"test for stationarity, estimate mean-reversion speeds (AR(1) and OU), and detect "
|
| 23 |
+
"high/low-volatility regimes with a 2-state Markov model."
|
| 24 |
+
)
|
| 25 |
+
|
| 26 |
+
# ================================ Sidebar ================================
|
| 27 |
+
with st.sidebar:
|
| 28 |
+
st.header("Controls")
|
| 29 |
+
|
| 30 |
+
# Data window
|
| 31 |
+
with st.expander("Data Window", expanded=False):
|
| 32 |
+
default_start = datetime(2015, 1, 1).date()
|
| 33 |
+
default_end = (datetime.today().date() + timedelta(days=1))
|
| 34 |
+
start_date = st.date_input(
|
| 35 |
+
"Start date",
|
| 36 |
+
value=default_start,
|
| 37 |
+
min_value=datetime(2000, 1, 1).date(),
|
| 38 |
+
max_value=default_end,
|
| 39 |
+
help="Earlier start = more history. Later start = faster."
|
| 40 |
+
)
|
| 41 |
+
end_date = st.date_input(
|
| 42 |
+
"End date",
|
| 43 |
+
value=default_end,
|
| 44 |
+
min_value=default_start,
|
| 45 |
+
max_value=default_end,
|
| 46 |
+
help="Default is today + 1 to include latest close."
|
| 47 |
+
)
|
| 48 |
+
|
| 49 |
+
# Realized vol settings
|
| 50 |
+
with st.expander("Realized Volatility", expanded=False):
|
| 51 |
+
rv_window = st.number_input(
|
| 52 |
+
"Realized-vol window (days)",
|
| 53 |
+
value=21, min_value=5, max_value=63, step=1,
|
| 54 |
+
help="Rolling window for realized volatility (annualized)."
|
| 55 |
+
)
|
| 56 |
+
scale_mode = st.selectbox(
|
| 57 |
+
"Scaling of realized vol vs VIX",
|
| 58 |
+
options=["Auto (match means)", "Manual factor"],
|
| 59 |
+
index=0,
|
| 60 |
+
help="Aligns realized vol to VIX scale for the gap chart."
|
| 61 |
+
)
|
| 62 |
+
scale_factor = 1.0
|
| 63 |
+
if scale_mode == "Manual factor":
|
| 64 |
+
scale_factor = st.number_input(
|
| 65 |
+
"Manual scaling factor",
|
| 66 |
+
value=1.0, min_value=0.1, max_value=10.0, step=0.1,
|
| 67 |
+
help="Multiply realized vol by this factor before comparing to VIX."
|
| 68 |
+
)
|
| 69 |
+
|
| 70 |
+
# AR(1) & OU
|
| 71 |
+
with st.expander("Mean-Reversion", expanded=False):
|
| 72 |
+
ou_roll_window = st.number_input(
|
| 73 |
+
"OU rolling window (days)",
|
| 74 |
+
value=252, min_value=63, max_value=756, step=21,
|
| 75 |
+
help="Lookback for rolling OU half-life estimates."
|
| 76 |
+
)
|
| 77 |
+
|
| 78 |
+
# Markov switching
|
| 79 |
+
with st.expander("Regime Model", expanded=False):
|
| 80 |
+
ms_states = st.number_input(
|
| 81 |
+
"Regimes (fixed at 2)",
|
| 82 |
+
value=2, min_value=2, max_value=2, step=0,
|
| 83 |
+
help="Two regimes: low volatility and high volatility."
|
| 84 |
+
)
|
| 85 |
+
|
| 86 |
+
run_btn = st.button("Run Analysis", type="primary")
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
# ================================ Caching ================================
|
| 90 |
+
@st.cache_data(show_spinner=False)
|
| 91 |
+
def fetch_yf_close(tickers: list[str], start: str, end: str) -> pd.DataFrame:
|
| 92 |
+
"""
|
| 93 |
+
Cached Yahoo Finance close prices for the requested tickers/date range.
|
| 94 |
+
"""
|
| 95 |
+
data = yf.download(
|
| 96 |
+
tickers, start=start, end=end, progress=False, auto_adjust=False
|
| 97 |
+
)
|
| 98 |
+
# Flatten potential MultiIndex columns (['Close']['^VIX'])
|
| 99 |
+
if isinstance(data.columns, pd.MultiIndex):
|
| 100 |
+
data.columns = [" ".join([str(c) for c in tup]).strip() for tup in data.columns]
|
| 101 |
+
# Keep closes only
|
| 102 |
+
cols = [c for c in data.columns if "Close" in c]
|
| 103 |
+
out = data[cols].copy()
|
| 104 |
+
# rename standard tickers
|
| 105 |
+
rename = {}
|
| 106 |
+
for c in out.columns:
|
| 107 |
+
if "^VIX" in c: rename[c] = "VIX"
|
| 108 |
+
if "^GSPC" in c: rename[c] = "SPX"
|
| 109 |
+
out = out.rename(columns=rename)
|
| 110 |
+
return out.sort_index().ffill()
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
# =============== Small helpers ===============
|
| 114 |
+
def _date_str(d): return pd.to_datetime(d).strftime("%Y-%m-%d")
|
| 115 |
+
|
| 116 |
+
def _tickformatstops():
|
| 117 |
+
# dynamic, granular date ticks on zoom
|
| 118 |
+
day = 24*3600*1000
|
| 119 |
+
week = 7*day
|
| 120 |
+
return [
|
| 121 |
+
dict(dtickrange=[None, day], value="%b %d\n%Y"),
|
| 122 |
+
dict(dtickrange=[day, week], value="%b %d"),
|
| 123 |
+
dict(dtickrange=[week, "M1"], value="%b %d\n%Y"),
|
| 124 |
+
dict(dtickrange=["M1", "M6"], value="%b %Y"),
|
| 125 |
+
dict(dtickrange=["M6", None], value="%Y"),
|
| 126 |
+
]
|
| 127 |
+
|
| 128 |
+
def _white_axes(fig):
|
| 129 |
+
fig.update_layout(template="plotly_dark", font=dict(color="white"))
|
| 130 |
+
if hasattr(fig.layout, "annotations"):
|
| 131 |
+
for a in fig.layout.annotations:
|
| 132 |
+
a.font = dict(color="white", size=12)
|
| 133 |
+
fig.update_xaxes(
|
| 134 |
+
ticklabelmode="period",
|
| 135 |
+
tickformatstops=_tickformatstops(),
|
| 136 |
+
tickangle=0,
|
| 137 |
+
tickfont=dict(color="white"),
|
| 138 |
+
title_font=dict(color="white"),
|
| 139 |
+
showgrid=True, gridcolor="rgba(160,160,160,0.2)",
|
| 140 |
+
showline=True, linecolor="rgba(255,255,255,0.4)"
|
| 141 |
+
)
|
| 142 |
+
fig.update_yaxes(
|
| 143 |
+
tickfont=dict(color="white"),
|
| 144 |
+
title_font=dict(color="white"),
|
| 145 |
+
showgrid=True, gridcolor="rgba(160,160,160,0.2)",
|
| 146 |
+
showline=True, linecolor="rgba(255,255,255,0.4)"
|
| 147 |
+
)
|
| 148 |
+
return fig
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
# =============================== Run =====================================
|
| 152 |
+
if run_btn:
|
| 153 |
+
# ---------- Data ----------
|
| 154 |
+
start_str, end_str = _date_str(start_date), _date_str(end_date)
|
| 155 |
+
with st.spinner("Fetching VIX & SPX…"):
|
| 156 |
+
px = fetch_yf_close(["^VIX", "^GSPC"], start_str, end_str)
|
| 157 |
+
if px.empty or "VIX" not in px or "SPX" not in px:
|
| 158 |
+
st.error("Couldn’t fetch VIX/SPX. Adjust dates and try again.")
|
| 159 |
+
st.stop()
|
| 160 |
+
|
| 161 |
+
vix = px["VIX"].copy()
|
| 162 |
+
spx = px["SPX"].copy()
|
| 163 |
+
|
| 164 |
+
# Realized vol from SPX log returns
|
| 165 |
+
log_ret = np.log(spx).diff()
|
| 166 |
+
rv = log_ret.rolling(int(rv_window)).std() * np.sqrt(252)
|
| 167 |
+
rv = rv.dropna()
|
| 168 |
+
|
| 169 |
+
# Align VIX to realized-vol index
|
| 170 |
+
vix = vix.reindex(rv.index).ffill()
|
| 171 |
+
|
| 172 |
+
# Scaling
|
| 173 |
+
if scale_mode.startswith("Auto"):
|
| 174 |
+
sf = float(vix.mean() / rv.mean()) if rv.mean() != 0 else 1.0
|
| 175 |
+
else:
|
| 176 |
+
sf = float(scale_factor)
|
| 177 |
+
rv_scaled = rv * sf
|
| 178 |
+
diff = vix - rv_scaled
|
| 179 |
+
|
| 180 |
+
# ===================== SECTION 1 — Gap: VIX vs Realized Vol =====================
|
| 181 |
+
st.header("1) Implied vs Realized Volatility")
|
| 182 |
+
with st.expander("Methodology", expanded=False):
|
| 183 |
+
st.write("**Goal:** Compare implied volatility (VIX) to realized volatility of the S&P 500 and track their gap.")
|
| 184 |
+
st.write("**Realized volatility** (annualized) from log returns over window \(w\):")
|
| 185 |
+
st.latex(r"r_t=\ln\frac{P_t}{P_{t-1}},\qquad \sigma^{(w)}_t=\sqrt{252}\cdot \operatorname{stdev}\!\big(r_{t-w+1},\dots,r_t\big)")
|
| 186 |
+
st.write("**Scaling** aligns magnitudes for comparison:")
|
| 187 |
+
st.latex(r"\tilde{\sigma}_t = s \cdot \sigma^{(w)}_t,\quad s=\frac{\overline{\text{VIX}}}{\overline{\sigma^{(w)}}}\ \ \text{(auto, or manual factor)}")
|
| 188 |
+
st.write("**Gap (VIX – scaled realized)**:")
|
| 189 |
+
st.latex(r"\Delta_t=\text{VIX}_t-\tilde{\sigma}_t")
|
| 190 |
+
st.write(
|
| 191 |
+
"- **Δ > 0**: implied > realized → options rich / risk premium elevated.\n"
|
| 192 |
+
"- **Δ < 0**: realized ≥ implied → options cheap vs recent movement."
|
| 193 |
+
)
|
| 194 |
+
|
| 195 |
+
fig1 = make_subplots(rows=2, cols=1, shared_xaxes=True,
|
| 196 |
+
subplot_titles=("VIX vs Scaled Realized Vol", "Gap: VIX – Scaled Realized Vol"))
|
| 197 |
+
fig1.add_trace(go.Scatter(x=vix.index, y=vix, name="VIX"), row=1, col=1)
|
| 198 |
+
fig1.add_trace(go.Scatter(x=rv_scaled.index, y=rv_scaled, name=f"Realized Vol × {sf:.2f}"), row=1, col=1)
|
| 199 |
+
fig1.add_trace(go.Scatter(x=diff.index, y=diff, name="Gap Δ"), row=2, col=1)
|
| 200 |
+
fig1.add_hline(y=0, line_dash="dash", line_color="rgba(180,180,180,0.8)", row=2, col=1)
|
| 201 |
+
fig1.update_yaxes(title_text="Vol level", row=1, col=1)
|
| 202 |
+
fig1.update_yaxes(title_text="Δ (points)", row=2, col=1)
|
| 203 |
+
fig1.update_xaxes(title_text="Date", row=2, col=1)
|
| 204 |
+
fig1.update_layout(height=600, legend=dict(orientation="h", y=1.05, x=0))
|
| 205 |
+
_white_axes(fig1)
|
| 206 |
+
st.plotly_chart(fig1, use_container_width=True)
|
| 207 |
+
|
| 208 |
+
# Short read
|
| 209 |
+
with st.expander("Quick Read (current)", expanded=False):
|
| 210 |
+
last_dt = rv.index[-1].date()
|
| 211 |
+
st.write(
|
| 212 |
+
f"As of **{last_dt}**: VIX = **{float(vix.iloc[-1]):.2f}**, "
|
| 213 |
+
f"scaled realized = **{float(rv_scaled.iloc[-1]):.2f}**, gap Δ = **{float(diff.iloc[-1]):+.2f}**."
|
| 214 |
+
)
|
| 215 |
+
|
| 216 |
+
# ===================== SECTION 2 — Stationarity (ADF) & Rolling Diagnostics =====================
|
| 217 |
+
st.header("2) Stationarity & Rolling Diagnostics (log-vol)")
|
| 218 |
+
with st.expander("Methodology", expanded=False):
|
| 219 |
+
st.write("Test whether log-vol processes are stationary (mean-reverting) and show rolling mean/std.")
|
| 220 |
+
st.write("**Log transform:**")
|
| 221 |
+
st.latex(r"x_t=\ln(\text{VIX}_t),\qquad y_t=\ln(\sigma^{(w)}_t)")
|
| 222 |
+
st.write("**ADF test** (null: unit root / non-stationary):")
|
| 223 |
+
st.latex(r"\Delta z_t=\alpha+\beta t+\gamma z_{t-1}+\sum_{i=1}^{p}\phi_i \Delta z_{t-i}+\varepsilon_t\quad\text{(test } \gamma=0\text{)}")
|
| 224 |
+
st.write("Rejecting the null suggests stationarity (mean-reversion).")
|
| 225 |
+
st.write("We also show **252-day rolling mean** and **std** of log-vol.")
|
| 226 |
+
|
| 227 |
+
log_vix = np.log(vix).dropna()
|
| 228 |
+
log_rv = np.log(rv).dropna()
|
| 229 |
+
# Align for plotting windows
|
| 230 |
+
w_roll = 252
|
| 231 |
+
# ADF
|
| 232 |
+
adf_v = adfuller(log_vix, autolag="AIC")
|
| 233 |
+
adf_r = adfuller(log_rv, autolag="AIC")
|
| 234 |
+
|
| 235 |
+
# Rolling diag figure
|
| 236 |
+
fig2 = make_subplots(rows=2, cols=1, shared_xaxes=True,
|
| 237 |
+
subplot_titles=("log(VIX) with 252-day Rolling Mean/Std",
|
| 238 |
+
"log(Realized Vol) with 252-day Rolling Mean/Std"))
|
| 239 |
+
fig2.add_trace(go.Scatter(x=log_vix.index, y=log_vix, name="log(VIX)"), row=1, col=1)
|
| 240 |
+
fig2.add_trace(go.Scatter(x=log_vix.index, y=log_vix.rolling(w_roll).mean(), name="Rolling Mean", line=dict(dash="dash")), row=1, col=1)
|
| 241 |
+
fig2.add_trace(go.Scatter(x=log_vix.index, y=log_vix.rolling(w_roll).std(), name="Rolling Std", line=dict(dash="dot")), row=1, col=1)
|
| 242 |
+
|
| 243 |
+
fig2.add_trace(go.Scatter(x=log_rv.index, y=log_rv, name="log(Realized Vol)"), row=2, col=1)
|
| 244 |
+
fig2.add_trace(go.Scatter(x=log_rv.index, y=log_rv.rolling(w_roll).mean(), name="Rolling Mean", line=dict(dash="dash")), row=2, col=1)
|
| 245 |
+
fig2.add_trace(go.Scatter(x=log_rv.index, y=log_rv.rolling(w_roll).std(), name="Rolling Std", line=dict(dash="dot")), row=2, col=1)
|
| 246 |
+
|
| 247 |
+
fig2.update_yaxes(title_text="log scale", row=1, col=1)
|
| 248 |
+
fig2.update_yaxes(title_text="log scale", row=2, col=1)
|
| 249 |
+
fig2.update_xaxes(title_text="Date", row=2, col=1)
|
| 250 |
+
fig2.update_layout(height=600, legend=dict(orientation="h", y=1.05, x=0))
|
| 251 |
+
_white_axes(fig2)
|
| 252 |
+
st.plotly_chart(fig2, use_container_width=True)
|
| 253 |
+
|
| 254 |
+
# ADF results
|
| 255 |
+
def _fmt_adf(label, res):
|
| 256 |
+
stat, pval, usedlag, nobs, crit, icbest = res
|
| 257 |
+
interp = "Reject H₀ → stationary" if (stat < crit["5%"] and pval < 0.05) else "Fail to reject H₀"
|
| 258 |
+
return {
|
| 259 |
+
"Series": label,
|
| 260 |
+
"ADF stat": f"{stat:.3f}",
|
| 261 |
+
"p-value": f"{pval:.4f}",
|
| 262 |
+
"5% crit": f"{crit['5%']:.3f}",
|
| 263 |
+
"Interp": interp
|
| 264 |
+
}
|
| 265 |
+
st.table(pd.DataFrame([_fmt_adf("log(VIX)", adf_v), _fmt_adf("log(Realized Vol)", adf_r)]))
|
| 266 |
+
|
| 267 |
+
# ===================== SECTION 3 — AR(1) & Half-Life =====================
|
| 268 |
+
st.header("3) AR(1) Mean-Reversion & Shock Half-Life")
|
| 269 |
+
with st.expander("Methodology", expanded=False):
|
| 270 |
+
st.write("Fit AR(1) to log-vol and compute the shock half-life.")
|
| 271 |
+
st.latex(r"z_t = c + \phi z_{t-1} + \varepsilon_t")
|
| 272 |
+
st.write("If \(0<\phi<1\), shocks decay geometrically. **Half-life**:")
|
| 273 |
+
st.latex(r"\text{HL} = -\frac{\ln 2}{\ln \phi}")
|
| 274 |
+
st.write("Shorter HL ⇒ faster mean-reversion.")
|
| 275 |
+
|
| 276 |
+
def estimate_ar1(series: pd.Series):
|
| 277 |
+
y = series.dropna()
|
| 278 |
+
y_lag = y.shift(1).dropna()
|
| 279 |
+
y = y.loc[y_lag.index]
|
| 280 |
+
X = sm.add_constant(y_lag)
|
| 281 |
+
res = sm.OLS(y, X).fit()
|
| 282 |
+
c = float(res.params["const"])
|
| 283 |
+
phi = float(res.params[y_lag.name])
|
| 284 |
+
return c, phi, res
|
| 285 |
+
|
| 286 |
+
c_v, phi_v, res_v = estimate_ar1(log_vix)
|
| 287 |
+
c_r, phi_r, res_r = estimate_ar1(log_rv)
|
| 288 |
+
|
| 289 |
+
def half_life_from_phi(phi: float):
|
| 290 |
+
if phi <= 0 or phi >= 1:
|
| 291 |
+
return np.nan
|
| 292 |
+
return -np.log(2) / np.log(phi)
|
| 293 |
+
|
| 294 |
+
hl_v = half_life_from_phi(phi_v)
|
| 295 |
+
hl_r = half_life_from_phi(phi_r)
|
| 296 |
+
|
| 297 |
+
# Scatter + regression lines (side-by-side)
|
| 298 |
+
def _scatter_fit(series, c, phi, name, color):
|
| 299 |
+
y = series.dropna()
|
| 300 |
+
xlag = y.shift(1).dropna()
|
| 301 |
+
y = y.loc[xlag.index]
|
| 302 |
+
x_line = np.linspace(float(xlag.min()), float(xlag.max()), 100)
|
| 303 |
+
return xlag, y, x_line, c + phi * x_line, name, color
|
| 304 |
+
|
| 305 |
+
x1, y1, xl1, yl1, n1, col1 = _scatter_fit(log_vix, c_v, phi_v, "log(VIX)", "#00d2ff")
|
| 306 |
+
x2, y2, xl2, yl2, n2, col2 = _scatter_fit(log_rv, c_r, phi_r, "log(Realized Vol)", "#ff8ef8")
|
| 307 |
+
|
| 308 |
+
fig3 = make_subplots(rows=1, cols=2, subplot_titles=(
|
| 309 |
+
f"AR(1) on log(VIX): φ={phi_v:.3f}, HL={hl_v:.1f}d" if np.isfinite(hl_v) else f"AR(1) on log(VIX): φ={phi_v:.3f}",
|
| 310 |
+
f"AR(1) on log(Realized Vol): φ={phi_r:.3f}, HL={hl_r:.1f}d" if np.isfinite(hl_r) else f"AR(1) on log(Realized Vol): φ={phi_r:.3f}"
|
| 311 |
+
))
|
| 312 |
+
fig3.add_trace(go.Scatter(x=x1, y=y1, mode="markers", marker=dict(size=3, opacity=0.5, color=col1), name=n1), row=1, col=1)
|
| 313 |
+
fig3.add_trace(go.Scatter(x=xl1, y=yl1, mode="lines", line=dict(width=2, color=col1), name="fit"), row=1, col=1)
|
| 314 |
+
fig3.add_trace(go.Scatter(x=x2, y=y2, mode="markers", marker=dict(size=3, opacity=0.5, color=col2), name=n2), row=1, col=2)
|
| 315 |
+
fig3.add_trace(go.Scatter(x=xl2, y=yl2, mode="lines", line=dict(width=2, color=col2), name="fit"), row=1, col=2)
|
| 316 |
+
fig3.update_xaxes(title_text="lagged log-vol", row=1, col=1)
|
| 317 |
+
fig3.update_yaxes(title_text="log-vol", row=1, col=1)
|
| 318 |
+
fig3.update_xaxes(title_text="lagged log-vol", row=1, col=2)
|
| 319 |
+
fig3.update_yaxes(title_text="log-vol", row=1, col=2)
|
| 320 |
+
fig3.update_layout(height=450, legend=dict(orientation="h", y=1.05, x=0))
|
| 321 |
+
_white_axes(fig3)
|
| 322 |
+
st.plotly_chart(fig3, use_container_width=True)
|
| 323 |
+
|
| 324 |
+
st.caption(
|
| 325 |
+
f"AR(1) φ: VIX = **{phi_v:.3f}**, Realized = **{phi_r:.3f}**. "
|
| 326 |
+
f"Half-life (days): VIX = **{hl_v:.1f}**, Realized = **{hl_r:.1f}** "
|
| 327 |
+
"(if φ∈(0,1))."
|
| 328 |
+
)
|
| 329 |
+
|
| 330 |
+
# ===================== SECTION 4 — OU Parameters & Rolling Half-Life =====================
|
| 331 |
+
st.header("4) Ornstein–Uhlenbeck (OU) & Rolling Half-Life")
|
| 332 |
+
with st.expander("Methodology", expanded=False):
|
| 333 |
+
st.write("Estimate discrete-time OU parameters via Δx regression and track rolling half-life.")
|
| 334 |
+
st.latex(r"\Delta x_t = a + b\,x_{t-1} + \varepsilon_t \quad\Rightarrow\quad \kappa=-b,\ \ \mu=\frac{a}{\kappa},\ \ \text{HL}=\frac{\ln 2}{\kappa}")
|
| 335 |
+
st.write("Interpretation: **κ>0** → mean-reverting toward **μ**. Larger κ → faster reversion (shorter HL).")
|
| 336 |
+
|
| 337 |
+
def estimate_ou_params(x: pd.Series):
|
| 338 |
+
x = x.dropna()
|
| 339 |
+
dx = x.diff().dropna()
|
| 340 |
+
x_lag = x.shift(1).loc[dx.index]
|
| 341 |
+
X = sm.add_constant(x_lag)
|
| 342 |
+
res = sm.OLS(dx, X).fit()
|
| 343 |
+
a = float(res.params["const"])
|
| 344 |
+
b = float(res.params[x_lag.name])
|
| 345 |
+
kappa = -b
|
| 346 |
+
mu = a / kappa if kappa != 0 else np.nan
|
| 347 |
+
hl = np.log(2) / kappa if kappa > 0 else np.nan
|
| 348 |
+
sigma = float(res.resid.std())
|
| 349 |
+
return kappa, mu, sigma, hl
|
| 350 |
+
|
| 351 |
+
k_v, mu_v, sig_v, hl_v_ou = estimate_ou_params(log_vix)
|
| 352 |
+
k_r, mu_r, sig_r, hl_r_ou = estimate_ou_params(log_rv)
|
| 353 |
+
|
| 354 |
+
st.caption(
|
| 355 |
+
f"OU κ (speed): VIX = **{k_v:.4f}**, Realized = **{k_r:.4f}** | "
|
| 356 |
+
f"HL (days): VIX = **{hl_v_ou:.1f}**, Realized = **{hl_r_ou:.1f}**"
|
| 357 |
+
)
|
| 358 |
+
|
| 359 |
+
# Rolling half-life series
|
| 360 |
+
roll = int(ou_roll_window)
|
| 361 |
+
def rolling_ou_hl(x: pd.Series, w: int):
|
| 362 |
+
out_idx, out_vals = [], []
|
| 363 |
+
for i in range(w, len(x)):
|
| 364 |
+
seg = x.iloc[i-w:i]
|
| 365 |
+
k, _, _, hl = estimate_ou_params(seg)
|
| 366 |
+
out_idx.append(x.index[i])
|
| 367 |
+
out_vals.append(hl)
|
| 368 |
+
return pd.Series(out_vals, index=pd.Index(out_idx, name="Date"))
|
| 369 |
+
|
| 370 |
+
hl_v_ts = rolling_ou_hl(log_vix, roll)
|
| 371 |
+
hl_r_ts = rolling_ou_hl(log_rv, roll)
|
| 372 |
+
med_v = float(hl_v_ts.median(skipna=True)) if len(hl_v_ts) else np.nan
|
| 373 |
+
med_r = float(hl_r_ts.median(skipna=True)) if len(hl_r_ts) else np.nan
|
| 374 |
+
|
| 375 |
+
fig4 = make_subplots(rows=1, cols=1, subplot_titles=(f"Rolling OU Half-Life (window={roll}d)",))
|
| 376 |
+
fig4.add_trace(go.Scatter(x=hl_v_ts.index, y=hl_v_ts, name="HL log(VIX)", line=dict(width=1)))
|
| 377 |
+
fig4.add_trace(go.Scatter(x=hl_r_ts.index, y=hl_r_ts, name="HL log(Realized Vol)", line=dict(width=1)))
|
| 378 |
+
if np.isfinite(med_v): fig4.add_hline(y=med_v, line_dash="dash", line_color="#00d2ff", annotation_text=f"Median VIX HL {med_v:.1f}d")
|
| 379 |
+
if np.isfinite(med_r): fig4.add_hline(y=med_r, line_dash="dash", line_color="#ff8ef8", annotation_text=f"Median RV HL {med_r:.1f}d")
|
| 380 |
+
fig4.update_yaxes(title_text="Half-life (days)")
|
| 381 |
+
fig4.update_xaxes(title_text="Date")
|
| 382 |
+
fig4.update_layout(height=450, legend=dict(orientation="h", y=1.05, x=0))
|
| 383 |
+
_white_axes(fig4)
|
| 384 |
+
st.plotly_chart(fig4, use_container_width=True)
|
| 385 |
+
|
| 386 |
+
# ===================== SECTION 5 — Markov Regimes on log(Realized Vol) =====================
|
| 387 |
+
st.header("5) High/Low-Volatility Regimes (Markov Switching)")
|
| 388 |
+
with st.expander("Methodology", expanded=False):
|
| 389 |
+
st.write("Two-state Markov model on log realized volatility with switching variance.")
|
| 390 |
+
st.latex(r"y_t \sim \mathcal{N}(\mu_{s_t},\,\sigma^2_{s_t}),\quad s_t\in\{0,1\}")
|
| 391 |
+
st.latex(r"P=\begin{bmatrix}p_{00} & p_{01}\\ p_{10} & p_{11}\end{bmatrix},\quad \text{Exp spell length of state }j=\frac{1}{1-p_{jj}}")
|
| 392 |
+
st.write(
|
| 393 |
+
"The high-vol regime is identified as the state with the higher mean \( \mu \). "
|
| 394 |
+
"We plot smoothed probabilities and shade periods with \( P(\text{high-vol})>0.5 \)."
|
| 395 |
+
)
|
| 396 |
+
|
| 397 |
+
y_ms = np.log(rv).dropna()
|
| 398 |
+
try:
|
| 399 |
+
ms = MarkovRegression(y_ms, k_regimes=2, trend="c", switching_variance=True)
|
| 400 |
+
res = ms.fit(disp=False)
|
| 401 |
+
p = res.smoothed_marginal_probabilities # columns [0,1]
|
| 402 |
+
# Transition matrix
|
| 403 |
+
P = res.model.regime_transition_matrix(res.params).squeeze()
|
| 404 |
+
# Identify high-vol
|
| 405 |
+
mean0 = (y_ms * p[0]).sum() / p[0].sum()
|
| 406 |
+
mean1 = (y_ms * p[1]).sum() / p[1].sum()
|
| 407 |
+
high = 1 if mean1 > mean0 else 0
|
| 408 |
+
p_high = p[high]
|
| 409 |
+
|
| 410 |
+
# Expected spell lengths
|
| 411 |
+
p00, p11 = float(P[0, 0]), float(P[1, 1])
|
| 412 |
+
exp_len_0 = 1.0 / (1.0 - p00) if p00 < 1 else np.inf
|
| 413 |
+
exp_len_1 = 1.0 / (1.0 - p11) if p11 < 1 else np.inf
|
| 414 |
+
|
| 415 |
+
# Plot series + shading and probability
|
| 416 |
+
fig5 = make_subplots(rows=2, cols=1, shared_xaxes=True,
|
| 417 |
+
subplot_titles=("log(Realized Vol) with High-Vol Shading",
|
| 418 |
+
f"Smoothed Probability of High-Vol Regime (State {high})"))
|
| 419 |
+
fig5.add_trace(go.Scatter(x=y_ms.index, y=y_ms, name="log(Realized Vol)"), row=1, col=1)
|
| 420 |
+
# Shading where p_high > 0.5
|
| 421 |
+
mask = p_high > 0.5
|
| 422 |
+
# Highlight spans by drawing rectangles across contiguous True segments
|
| 423 |
+
grp = (mask != mask.shift()).cumsum()
|
| 424 |
+
for _, span in mask[mask].groupby(grp):
|
| 425 |
+
x0 = span.index[0]; x1 = span.index[-1]
|
| 426 |
+
fig5.add_vrect(x0=x0, x1=x1, fillcolor="red", opacity=0.2, line_width=0, row=1, col=1)
|
| 427 |
+
fig5.add_trace(go.Scatter(x=p_high.index, y=p_high, name="P(High-Vol)"), row=2, col=1)
|
| 428 |
+
fig5.add_hline(y=0.5, line_dash="dash", line_color="rgba(180,180,180,0.8)", row=2, col=1)
|
| 429 |
+
fig5.update_yaxes(title_text="log-vol", row=1, col=1)
|
| 430 |
+
fig5.update_yaxes(title_text="Probability", row=2, col=1, range=[0, 1])
|
| 431 |
+
fig5.update_xaxes(title_text="Date", row=2, col=1)
|
| 432 |
+
fig5.update_layout(height=600, legend=dict(orientation="h", y=1.05, x=0))
|
| 433 |
+
_white_axes(fig5)
|
| 434 |
+
st.plotly_chart(fig5, use_container_width=True)
|
| 435 |
+
|
| 436 |
+
# Transition matrix & durations
|
| 437 |
+
st.subheader("Regime Persistence")
|
| 438 |
+
tbl = pd.DataFrame(
|
| 439 |
+
[[P[0,0], P[0,1]], [P[1,0], P[1,1]]],
|
| 440 |
+
index=["to Reg-0", "to Reg-1"], columns=["from Reg-0", "from Reg-1"]
|
| 441 |
+
).round(4)
|
| 442 |
+
st.table(tbl)
|
| 443 |
+
st.caption(
|
| 444 |
+
f"Expected spell lengths — Reg-0: **{exp_len_0:.1f}** days, Reg-1: **{exp_len_1:.1f}** days. "
|
| 445 |
+
f"High-vol identified as **Reg-{high}**."
|
| 446 |
+
)
|
| 447 |
+
except Exception as e:
|
| 448 |
+
st.warning(f"Markov model failed to converge: {e}")
|
| 449 |
+
|
| 450 |
+
# ============================ Footer note =============================
|
| 451 |
+
st.markdown(
|
| 452 |
+
"<sub>Note: analytical settings (windows, scaling, etc.) recompute from the "
|
| 453 |
+
"cached price data. Changing dates triggers a new data fetch; changing parameters "
|
| 454 |
+
"does not.</sub>",
|
| 455 |
+
unsafe_allow_html=True
|
| 456 |
+
)
|