QuantumLearner commited on
Commit
e0419d5
·
verified ·
1 Parent(s): 23d5287

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +444 -345
app.py CHANGED
@@ -1,4 +1,9 @@
1
- # app.py — Volatility Mean-Reversion
 
 
 
 
 
2
  import io
3
  from datetime import datetime, timedelta
4
 
@@ -6,28 +11,26 @@ 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))
@@ -43,414 +46,510 @@ with st.sidebar:
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
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # app.py — Volatility Mean-Reversion (VIX vs Realized Vol)
2
+ # -----------------------------------------------------------------------------
3
+ # Requirements:
4
+ # pip install streamlit yfinance statsmodels plotly numpy pandas
5
+ # -----------------------------------------------------------------------------
6
+
7
  import io
8
  from datetime import datetime, timedelta
9
 
 
11
  import pandas as pd
12
  import streamlit as st
13
  import yfinance as yf
14
+ import plotly.graph_objects as go
15
+ from plotly.subplots import make_subplots
16
  import statsmodels.api as sm
17
  from statsmodels.tsa.stattools import adfuller
18
  from statsmodels.tsa.regime_switching.markov_regression import MarkovRegression
 
 
19
 
20
+ # ----------------------------- Page config & header -----------------------------
 
21
  st.set_page_config(page_title="Volatility Mean-Reversion", layout="wide")
22
  st.title("Volatility Mean-Reversion")
23
 
24
+ st.write(
25
+ "Compare implied volatility (VIX) with realized SPX volatility, test stationarity, "
26
+ "estimate mean-reversion speed and half-lives (AR(1) & OU), and detect high/low "
27
+ "volatility regimes via a two-state Markov model."
28
  )
29
 
30
+ # ----------------------------- Sidebar controls -----------------------------
31
  with st.sidebar:
32
  st.header("Controls")
33
 
 
34
  with st.expander("Data Window", expanded=False):
35
  default_start = datetime(2015, 1, 1).date()
36
  default_end = (datetime.today().date() + timedelta(days=1))
 
46
  value=default_end,
47
  min_value=default_start,
48
  max_value=default_end,
49
+ help="Set to today+1 (default) to include the latest close."
50
  )
 
 
 
51
  rv_window = st.number_input(
52
  "Realized-vol window (days)",
53
+ value=21, min_value=5, max_value=126, step=1,
54
+ help="Rolling window for realized volatility (log returns)."
55
  )
56
+
57
+ with st.expander("Scaling (VIX vs RV)", expanded=False):
58
  scale_mode = st.selectbox(
59
+ "Scaling method",
60
+ options=["Auto (match means)", "Manual"],
61
+ help="Auto scales realized vol to VIX by matching means; Manual uses your factor."
62
+ )
63
+ scale_factor = st.number_input(
64
+ "Manual scale factor",
65
+ value=1.0, step=0.1, format="%.3f",
66
+ help="Only used when 'Manual' is selected.",
67
+ disabled=(scale_mode != "Manual")
68
  )
 
 
 
 
 
 
 
69
 
70
+ with st.expander("Rolling & ADF", expanded=False):
71
+ roll_win = st.number_input(
72
+ "Rolling (days) for mean/std displays",
73
+ value=252, min_value=60, max_value=756, step=10,
74
+ help="Used to plot rolling mean and standard deviation of log series."
75
+ )
76
+ adf_alpha = st.selectbox(
77
+ "ADF significance level",
78
+ options=[0.10, 0.05, 0.01],
79
+ index=1,
80
+ help="p-value threshold for rejecting unit root (stationarity)."
81
+ )
82
+
83
+ with st.expander("OU & Half-Life", expanded=False):
84
  ou_roll_window = st.number_input(
85
  "OU rolling window (days)",
86
+ value=252, min_value=126, max_value=756, step=10,
87
+ help="Window for rolling OU half-life estimates."
88
  )
89
 
90
+ with st.expander("Markov Regime Model", expanded=False):
91
+ run_ms = st.checkbox(
92
+ "Run two-state Markov switching on log(Realized Vol)",
93
+ value=True,
94
+ help="Fits a 2-regime model with switching variance and shows shading."
 
95
  )
96
 
97
  run_btn = st.button("Run Analysis", type="primary")
98
 
99
+ # ----------------------------- Data fetch (cached) -----------------------------
 
100
  @st.cache_data(show_spinner=False)
101
  def fetch_yf_close(tickers: list[str], start: str, end: str) -> pd.DataFrame:
102
  """
103
+ Yahoo Finance Close prices ONLY (avoid 'Adj Close' confusion).
104
+ Returns a DF with columns ['VIX','SPX'] for ['^VIX','^GSPC'] where possible.
105
  """
106
+ data = yf.download(tickers, start=start, end=end, progress=False, auto_adjust=False)
 
 
 
107
  if isinstance(data.columns, pd.MultiIndex):
108
+ out = data['Close'].copy() # keep only Close
109
+ else:
110
+ out = data[['Close']].copy()
111
+ col_name = tickers[0] if tickers else 'Close'
112
+ out = out.rename(columns={'Close': col_name})
113
+
114
+ out = out.rename(columns={'^VIX': 'VIX', '^GSPC': 'SPX'})
115
+ keep = []
116
+ if '^VIX' in tickers or 'VIX' in out.columns: keep.append('VIX')
117
+ if '^GSPC' in tickers or 'SPX' in out.columns: keep.append('SPX')
118
+ if keep:
119
+ out = out[[c for c in keep if c in out.columns]]
120
  return out.sort_index().ffill()
121
 
122
+ def _tickformatstops_monthy():
123
+ # Month-aware tick formats that refine as you zoom
 
 
 
 
 
 
124
  return [
125
+ dict(dtickrange=[None, "M1"], value="%b %Y"), # < 1M step
126
+ dict(dtickrange=["M1", "M12"], value="%b %Y"), # 1M..12M
127
+ dict(dtickrange=["M12", None], value="%Y") # >= yearly
 
 
128
  ]
129
 
130
+ # ----------------------------- Run pipeline -----------------------------
131
+ if run_btn:
132
+ start_str = pd.to_datetime(start_date).strftime("%Y-%m-%d")
133
+ end_str = pd.to_datetime(end_date).strftime("%Y-%m-%d")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
134
 
135
+ with st.spinner("Downloading VIX & SPX…"):
136
+ px = fetch_yf_close(['^VIX', '^GSPC'], start_str, end_str)
137
 
138
+ if px.empty or not set(['VIX', 'SPX']).issubset(px.columns):
139
+ st.error("Could not fetch both VIX and SPX 'Close' series. Try a different date range.")
 
 
 
 
 
 
140
  st.stop()
141
 
142
+ vix = px['VIX'].copy()
143
+ spx = px['SPX'].copy()
144
+
145
+ # ---------- Section 1: Implied vs Realized Volatility ----------
146
+ st.header("Implied vs Realized Volatility")
147
+ with st.expander("Methodology", expanded=False):
148
+ st.write("We compare **implied volatility (VIX)** to **realized SPX volatility** over a rolling window.")
149
+ st.write("Log returns and realized volatility:")
150
+ st.latex(r"r_t = \ln P_t - \ln P_{t-1}, \qquad \mathrm{RV}_{n}(t) = \sqrt{252}\ \mathrm{stdev}\big(r_{t-n+1},\ldots,r_t\big)")
151
+ st.write("Scaling (to compare levels):")
152
+ st.latex(r"s = \frac{\overline{\mathrm{VIX}}}{\overline{\mathrm{RV}_n}} \quad \Rightarrow \quad \mathrm{RV}^{\mathrm{scaled}}_n = s\cdot \mathrm{RV}_n")
153
+ st.write("Gap:")
154
+ st.latex(r"\Delta_t = \mathrm{VIX}_t - \mathrm{RV}^{\mathrm{scaled}}_{n}(t)")
155
+ st.write(
156
+ "Interpretation: VIX > scaled RV suggests an implied risk premium; VIX < scaled RV suggests realized "
157
+ "volatility is running ‘hot’ relative to implied."
158
+ )
159
 
160
+ # Realized volatility
161
  log_ret = np.log(spx).diff()
162
  rv = log_ret.rolling(int(rv_window)).std() * np.sqrt(252)
163
  rv = rv.dropna()
164
 
165
+ # Align VIX and compute scaling
166
  vix = vix.reindex(rv.index).ffill()
167
+ vix_mean = float(vix.mean()) if len(vix) else np.nan
168
+ rv_mean = float(rv.mean()) if len(rv) else np.nan
169
  if scale_mode.startswith("Auto"):
170
+ sf = (vix_mean / rv_mean) if (np.isfinite(vix_mean) and np.isfinite(rv_mean) and rv_mean != 0) else 1.0
171
  else:
172
  sf = float(scale_factor)
173
+
174
  rv_scaled = rv * sf
175
  diff = vix - rv_scaled
176
 
177
+ # Plot: VIX vs RV (row 1), Gap (row 2)
178
+ fig1 = make_subplots(
179
+ rows=2, cols=1, shared_xaxes=True, vertical_spacing=0.05,
180
+ specs=[[{"secondary_y": True}], [{}]],
181
+ subplot_titles=("VIX vs Realized Volatility", "VIX − Scaled Realized Volatility")
182
+ )
183
+ # Row 1
184
+ fig1.add_trace(go.Scatter(x=vix.index, y=vix, name="VIX", line=dict(width=1, color="cyan")), row=1, col=1, secondary_y=False)
185
+ fig1.add_trace(go.Scatter(x=rv.index, y=rv, name=f"Realized Vol ({int(rv_window)}d)", line=dict(width=1, color="magenta")), row=1, col=1, secondary_y=True)
186
+ fig1.update_yaxes(title_text="VIX", row=1, col=1, secondary_y=False)
187
+ fig1.update_yaxes(title_text="Realized Vol", row=1, col=1, secondary_y=True)
188
+
189
+ # Row 2
190
+ fig1.add_trace(go.Scatter(x=diff.index, y=diff, name="VIX − Scaled RV", line=dict(width=1, color="white")), row=2, col=1)
191
+ fig1.add_hline(y=0, line_dash="dash", line_color="gray", row=2, col=1)
192
+ fig1.update_yaxes(title_text="Difference", row=2, col=1)
193
+
194
+ # Style
195
+ fig1.update_xaxes(
196
+ tickformatstops=_tickformatstops_monthy(),
197
+ showgrid=True, gridcolor="rgba(160,160,160,0.2)",
198
+ showline=True, linecolor="rgba(255,255,255,0.4)"
199
+ )
200
+ fig1.update_yaxes(
201
+ showgrid=True, gridcolor="rgba(160,160,160,0.2)",
202
+ showline=True, linecolor="rgba(255,255,255,0.4)"
203
+ )
204
+ fig1.update_layout(
205
+ template="plotly_dark",
206
+ height=650,
207
+ margin=dict(l=60, r=20, t=60, b=40),
208
+ legend=dict(orientation="h", yanchor="bottom", y=1.02, xanchor="left", x=0),
209
+ font=dict(color="white"),
210
+ hovermode="x unified"
211
+ )
212
+ # Ensure white subplot titles
213
+ if hasattr(fig1.layout, "annotations"):
214
+ for a in fig1.layout.annotations:
215
+ a.font = dict(color="white", size=12)
216
+ st.plotly_chart(fig1, use_container_width=True)
217
+
218
+ # ---------- Section 2: Stationarity (ADF) & Rolling Diagnostics ----------
219
+ st.header("Stationarity & Rolling Diagnostics")
220
  with st.expander("Methodology", expanded=False):
221
+ st.write("Test whether log-volatility is stationary (mean-reverting) using the ADF test.")
222
+ st.latex(r"\text{ADF null: unit root (non-stationary)}\quad\text{vs}\quad \text{stationary (mean-reverting)}")
223
+ st.write("Rolling mean and std provide a visual check of stability over time.")
224
+
225
+ # log series
226
+ log_vix = np.log(vix)
227
+ log_real_vol = np.log(rv)
228
+
229
+ # ADF tests
230
+ adf_vix = adfuller(log_vix.dropna(), autolag='AIC')
231
+ adf_rv = adfuller(log_real_vol.dropna(), autolag='AIC')
232
+
233
+ # Rolling plots (two rows)
234
+ fig2 = make_subplots(
235
+ rows=2, cols=1, shared_xaxes=True, vertical_spacing=0.06,
236
+ subplot_titles=(f"log(VIX) with {int(roll_win)}d Rolling Mean & Std",
237
+ f"log(Realized Vol) with {int(roll_win)}d Rolling Mean & Std")
238
+ )
239
+ # log(VIX)
240
+ fig2.add_trace(go.Scatter(x=log_vix.index, y=log_vix, name="log(VIX)", line=dict(width=1, color="#00d2ff")), row=1, col=1)
241
+ fig2.add_trace(go.Scatter(x=log_vix.index, y=log_vix.rolling(int(roll_win)).mean(), name="Rolling Mean", line=dict(width=1, dash="dash", color="#aaaaaa")), row=1, col=1)
242
+ fig2.add_trace(go.Scatter(x=log_vix.index, y=log_vix.rolling(int(roll_win)).std(), name="Rolling Std", line=dict(width=1, dash="dot", color="#888888")), row=1, col=1)
243
 
244
+ # log(RV)
245
+ fig2.add_trace(go.Scatter(x=log_real_vol.index, y=log_real_vol, name="log(Realized Vol)", line=dict(width=1, color="#ff6ad5")), row=2, col=1)
246
+ fig2.add_trace(go.Scatter(x=log_real_vol.index, y=log_real_vol.rolling(int(roll_win)).mean(), name="Rolling Mean", line=dict(width=1, dash="dash", color="#aaaaaa")), row=2, col=1)
247
+ fig2.add_trace(go.Scatter(x=log_real_vol.index, y=log_real_vol.rolling(int(roll_win)).std(), name="Rolling Std", line=dict(width=1, dash="dot", color="#888888")), row=2, col=1)
 
 
 
 
 
 
 
 
248
 
249
+ fig2.update_yaxes(title_text="Level", row=1, col=1)
250
+ fig2.update_yaxes(title_text="Level", row=2, col=1)
 
 
 
 
 
251
 
252
+ fig2.update_xaxes(
253
+ tickformatstops=_tickformatstops_monthy(),
254
+ showgrid=True, gridcolor="rgba(160,160,160,0.2)",
255
+ showline=True, linecolor="rgba(255,255,255,0.4)"
256
+ )
257
+ fig2.update_yaxes(
258
+ showgrid=True, gridcolor="rgba(160,160,160,0.2)",
259
+ showline=True, linecolor="rgba(255,255,255,0.4)"
260
+ )
261
+ fig2.update_layout(
262
+ template="plotly_dark",
263
+ height=650,
264
+ margin=dict(l=60, r=20, t=60, b=40),
265
+ font=dict(color="white"),
266
+ hovermode="x unified"
267
+ )
268
+ if hasattr(fig2.layout, "annotations"):
269
+ for a in fig2.layout.annotations:
270
+ a.font = dict(color="white", size=12)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
271
  st.plotly_chart(fig2, use_container_width=True)
272
 
273
+ # ADF interpretation (match raw narrative style)
274
+ def _print_adf(name, adf_res, alpha):
275
+ buf = io.StringIO()
276
+ stat, pvalue, usedlag, nobs, crit_vals, icbest = adf_res
277
+ print(f"ADF Test on {name}:", file=buf)
278
+ print(f" Statistic : {stat:.4f}", file=buf)
279
+ print(f" p-value : {pvalue:.4f}", file=buf)
280
+ print(" Critical Values:", file=buf)
281
+ for lvl, val in crit_vals.items():
282
+ print(f" {lvl}: {val:.4f}", file=buf)
283
+ if (stat < crit_vals['5%']) and (pvalue < alpha):
284
+ print(" Reject H₀: series is stationary (mean-reverting)\n", file=buf)
285
+ else:
286
+ print(" → Fail to reject H₀: series likely has a unit root (no clear mean-reversion)\n", file=buf)
287
+ return buf.getvalue()
288
+
289
+ with st.expander("ADF Results & Interpretation", expanded=False):
290
+ st.text(_print_adf("log(VIX)", adf_vix, adf_alpha))
291
+ st.text(_print_adf("log(Realized Vol)", adf_rv, adf_alpha))
292
+
293
+ # ---------- Section 3: AR(1) & Half-Lives ----------
294
+ st.header("AR(1) Mean-Reversion & Shock Half-Lives")
295
  with st.expander("Methodology", expanded=False):
296
+ st.write("Fit AR(1):")
297
+ st.latex(r"y_t = c + \phi y_{t-1} + \varepsilon_t")
298
+ st.write("Half-life (days) of a one-off shock:")
299
+ st.latex(r"\mathrm{HL} = -\frac{\ln 2}{\ln \phi} \quad \text{(valid if } 0<\phi<1\text{)}")
300
+ st.write("Interpretation: smaller HL ⇒ faster mean-reversion.")
301
 
302
+ def estimate_ar1(series):
303
  y = series.dropna()
304
  y_lag = y.shift(1).dropna()
305
  y = y.loc[y_lag.index]
306
  X = sm.add_constant(y_lag)
307
  res = sm.OLS(y, X).fit()
308
+ return float(res.params['const']), float(res.params[1])
 
 
 
 
 
309
 
310
+ c_vix, phi_vix = estimate_ar1(np.log(vix))
311
+ c_rv, phi_rv = estimate_ar1(np.log(rv))
 
 
312
 
313
+ # Half-lives (guard domain)
314
+ hl_vix = (-np.log(2) / np.log(phi_vix)) if (phi_vix > 0 and phi_vix != 1) else np.nan
315
+ hl_rv = (-np.log(2) / np.log(phi_rv)) if (phi_rv > 0 and phi_rv != 1) else np.nan
316
 
317
+ # Scatter & regression lines
318
+ fig3 = make_subplots(
319
+ rows=1, cols=2, subplot_titles=(f"AR(1) on log(VIX)\nφ={phi_vix:.3f}, HL={hl_vix:.1f}d",
320
+ f"AR(1) on log(Realized Vol)\nφ={phi_rv:.3f}, HL={hl_rv:.1f}d")
321
+ )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
322
 
323
+ # VIX panel
324
+ y = np.log(vix).dropna()
325
+ yl = y.shift(1).dropna()
326
+ y = y.loc[yl.index]
327
+ x_line = np.linspace(float(yl.min()), float(yl.max()), 100)
328
+ fig3.add_trace(go.Scatter(x=yl, y=y, mode="markers", marker=dict(size=4, color="white"), name="Data"), row=1, col=1)
329
+ fig3.add_trace(go.Scatter(x=x_line, y=c_vix + phi_vix * x_line, name=f"Fit: y={phi_vix:.2f}·x+{c_vix:.2f}", line=dict(color="cyan")), row=1, col=1)
330
+ fig3.update_xaxes(title_text="log(VIX) lagged", row=1, col=1)
331
+ fig3.update_yaxes(title_text="log(VIX)", row=1, col=1)
332
+
333
+ # RV panel
334
+ y = np.log(rv).dropna()
335
+ yl = y.shift(1).dropna()
336
+ y = y.loc[yl.index]
337
+ x_line = np.linspace(float(yl.min()), float(yl.max()), 100)
338
+ fig3.add_trace(go.Scatter(x=yl, y=y, mode="markers", marker=dict(size=4, color="white"), name="Data"), row=1, col=2)
339
+ fig3.add_trace(go.Scatter(x=x_line, y=c_rv + phi_rv * x_line, name=f"Fit: y={phi_rv:.2f}·x+{c_rv:.2f}", line=dict(color="magenta")), row=1, col=2)
340
+ fig3.update_xaxes(title_text="log(RV) lagged", row=1, col=2)
341
+ fig3.update_yaxes(title_text="log(RV)", row=1, col=2)
342
+
343
+ fig3.update_layout(
344
+ template="plotly_dark",
345
+ height=450,
346
+ margin=dict(l=50, r=20, t=80, b=40),
347
+ font=dict(color="white")
348
  )
349
+ if hasattr(fig3.layout, "annotations"):
350
+ for a in fig3.layout.annotations:
351
+ a.font = dict(color="white", size=12)
352
+ st.plotly_chart(fig3, use_container_width=True)
353
 
354
+ with st.expander("AR(1) Results (raw-style text)", expanded=False):
355
+ buf = io.StringIO()
356
+ print("AR(1) on log(VIX):", file=buf)
357
+ print(f" φ = {phi_vix:.4f}", file=buf)
358
+ print(f" Half-life = {hl_vix:.1f} days", file=buf)
359
+ print(f" → A one-time shock to log(VIX) decays by half after about {hl_vix:.1f} trading days.", file=buf)
360
+ print(" → |φ| < 1: log(VIX) is stationary (mean-reverting)\n" if abs(phi_vix) < 1 else
361
+ " → |φ| ≥ 1: log(VIX) is non-stationary (no mean-reversion)\n", file=buf)
362
+ print("AR(1) on log(Realized Vol):", file=buf)
363
+ print(f" φ = {phi_rv:.4f}", file=buf)
364
+ print(f" Half-life = {hl_rv:.1f} days", file=buf)
365
+ print(f" → A one-time shock to log(Realized Vol) decays by half after about {hl_rv:.1f} trading days.", file=buf)
366
+ print(" → |φ| < 1: log(Realized Vol) is stationary (mean-reverting)\n" if abs(phi_rv) < 1 else
367
+ " → |φ| ≥ 1: log(Realized Vol) is non-stationary (no mean-reversion)\n", file=buf)
368
+ st.text(buf.getvalue())
369
+
370
+ # ---------- Section 4: OU Parameters & Rolling Half-Lives ----------
371
+ st.header("Ornstein–Uhlenbeck (OU) & Rolling Half-Life")
372
  with st.expander("Methodology", expanded=False):
373
+ st.write("Discrete OU approximation on log-volatility:")
374
+ st.latex(r"x_t - x_{t-1} = a + b\,x_{t-1} + \varepsilon_t \quad \Rightarrow \quad \kappa = -b,\ \ \mu = \frac{a}{\kappa}")
375
+ st.write("Half-life (days):")
376
+ st.latex(r"\mathrm{HL} = \frac{\ln 2}{\kappa} \quad (\kappa>0)")
377
+ st.write("We estimate OU on rolling windows to see how mean-reversion speed changes over time.")
378
 
379
+ def _ou_params(x: pd.Series):
380
  x = x.dropna()
381
  dx = x.diff().dropna()
382
  x_lag = x.shift(1).loc[dx.index]
383
  X = sm.add_constant(x_lag)
384
  res = sm.OLS(dx, X).fit()
385
+ a = float(res.params['const'])
386
  b = float(res.params[x_lag.name])
387
  kappa = -b
388
+ mu = (a / kappa) if kappa != 0 else np.nan
 
389
  sigma = float(res.resid.std())
390
+ hl = (np.log(2) / kappa) if kappa > 0 else np.nan
391
  return kappa, mu, sigma, hl
392
 
393
+ κ_vix, μ_vix, σ_vix, hl_vix_ou = _ou_params(np.log(vix))
394
+ κ_rv, μ_rv, σ_rv, hl_rv_ou = _ou_params(np.log(rv))
 
 
 
 
 
395
 
396
  # Rolling half-life series
397
+ def _rolling_hl(x: pd.Series, window: int):
398
+ xs = x.dropna()
399
+ hl = []
400
+ idx = []
401
+ for i in range(window, len(xs)):
402
+ seg = xs.iloc[i-window:i]
403
+ k, _, _, hl_i = _ou_params(seg)
404
+ hl.append(hl_i)
405
+ idx.append(seg.index[-1])
406
+ return pd.Series(hl, index=pd.Index(idx, name="Date"))
407
+
408
+ hl_vix_ts = _rolling_hl(np.log(vix), int(ou_roll_window))
409
+ hl_rv_ts = _rolling_hl(np.log(rv), int(ou_roll_window))
410
+
411
+ med_vix = float(hl_vix_ts.median()) if hl_vix_ts.notna().any() else np.nan
412
+ med_rv = float(hl_rv_ts.median()) if hl_rv_ts.notna().any() else np.nan
413
+
414
+ fig4 = go.Figure()
415
+ fig4.add_trace(go.Scatter(x=hl_vix_ts.index, y=hl_vix_ts, name="HL log(VIX)", line=dict(color="cyan", width=1)))
416
+ fig4.add_trace(go.Scatter(x=hl_rv_ts.index, y=hl_rv_ts, name="HL log(RV)", line=dict(color="magenta", width=1)))
417
+ if np.isfinite(med_vix):
418
+ fig4.add_hline(y=med_vix, line_dash="dash", line_color="cyan", opacity=0.6)
419
+ if np.isfinite(med_rv):
420
+ fig4.add_hline(y=med_rv, line_dash="dash", line_color="magenta", opacity=0.6)
421
  fig4.update_yaxes(title_text="Half-life (days)")
422
+ fig4.update_xaxes(
423
+ tickformatstops=_tickformatstops_monthy(),
424
+ showgrid=True, gridcolor="rgba(160,160,160,0.2)",
425
+ showline=True, linecolor="rgba(255,255,255,0.4)"
426
+ )
427
+ fig4.update_layout(
428
+ template="plotly_dark",
429
+ height=450,
430
+ margin=dict(l=60, r=20, t=60, b=40),
431
+ font=dict(color="white")
432
+ )
433
  st.plotly_chart(fig4, use_container_width=True)
434
 
435
+ with st.expander("OU Results (raw-style text)", expanded=False):
436
+ buf = io.StringIO()
437
+ print("OU fit on log(VIX):", file=buf)
438
+ print(f" κ = {κ_vix:.4f}", file=buf)
439
+ print(f" μ = {μ_vix:.4f}", file=buf)
440
+ print(f" σ = {σ_vix:.4f}", file=buf)
441
+ print(f" Half-life = {hl_vix_ou:.1f} days", file=buf)
442
+ if κ_vix > 0:
443
+ print(" κ > 0: process is mean-reverting toward μ.", file=buf)
444
+ print(f" → A shock decays by half in {hl_vix_ou:.1f} trading days.\n", file=buf)
445
+ else:
446
+ print(" → κ ≤ 0: no mean-reversion detected.\n", file=buf)
447
+
448
+ print("OU fit on log(Realized Vol):", file=buf)
449
+ print(f" κ = {κ_rv:.4f}", file=buf)
450
+ print(f" μ = {μ_rv:.4f}", file=buf)
451
+ print(f" σ = {σ_rv:.4f}", file=buf)
452
+ print(f" Half-life = {hl_rv_ou:.1f} days", file=buf)
453
+ if κ_rv > 0:
454
+ print(" → κ > 0: process is mean-reverting toward μ.", file=buf)
455
+ print(f" → A shock decays by half in {hl_rv_ou:.1f} trading days.\n", file=buf)
456
+ else:
457
+ print(" → κ ≤ 0: no mean-reversion detected.\n", file=buf)
458
+
459
+ # Simple interpretation of rolling HLs
460
+ print("Median OU half-life over history:", file=buf)
461
+ print(f" log(VIX) = {med_vix:.1f} days", file=buf)
462
+ print(f" log(Realized Vol) = {med_rv:.1f} days", file=buf)
463
+ if np.isfinite(med_vix) and np.isfinite(med_rv):
464
+ if med_vix < med_rv:
465
+ print(" → On average, log(VIX) mean-reverts faster than log(Realized Vol).\n", file=buf)
466
+ else:
467
+ print(" On average, log(Realized Vol) mean-reverts faster than log(VIX).\n", file=buf)
468
+ st.text(buf.getvalue())
469
+
470
+ # ---------- Section 5: Two-State Markov Regimes ----------
471
+ if run_ms:
472
+ st.header("Two-State Markov Regime Model (log Realized Vol)")
473
+ with st.expander("Methodology", expanded=False):
474
+ st.write("We fit a **two-regime Markov switching** model on log(Realized Vol):")
475
+ st.latex(r"y_t = c_{s_t} + \varepsilon_{t}, \quad \varepsilon_t \sim \mathcal{N}(0,\sigma^2_{s_t}), \quad s_t \in \{0,1\}")
476
+ st.write("The model estimates transition probabilities between regimes and smoothed probabilities over time.")
477
+ st.latex(r"P = \begin{pmatrix}p_{00} & p_{01}\\ p_{10} & p_{11}\end{pmatrix}, \quad \mathbb{E}[\text{spell length in } j] = \frac{1}{1-p_{jj}}")
478
+ st.write("Interpretation: high-vol regime persistence ⇒ longer stressful periods; a rising probability can warn of transitions.")
479
+
480
+ series = np.log(rv).dropna()
481
+ if len(series) < 300:
482
+ st.warning("Not enough history to fit a stable Markov model. Increase the date range.")
483
+ else:
484
+ ms = MarkovRegression(series, k_regimes=2, trend='c', switching_variance=True)
485
+ res = ms.fit(disp=False)
486
+ p = res.smoothed_marginal_probabilities # DataFrame with cols [0,1]
487
+
488
+ # Transition matrix
489
+ T = res.model.regime_transition_matrix(res.params).squeeze()
490
+ p00, p01 = float(T[0,0]), float(T[0,1])
491
+ p10, p11 = float(T[1,0]), float(T[1,1])
492
+ exp_len_0 = 1.0 / (1.0 - p00) if p00 < 1 else np.inf
493
+ exp_len_1 = 1.0 / (1.0 - p11) if p11 < 1 else np.inf
494
+
495
+ # Which regime is "high vol"?
496
+ mean0 = float((series * p[0]).sum() / p[0].sum())
497
+ mean1 = float((series * p[1]).sum() / p[1].sum())
498
+ high = 1 if mean1 > mean0 else 0
499
+ p_high = p[high]
500
+
501
+ # Plot: top series with shading; bottom probability
502
+ fig5 = make_subplots(rows=2, cols=1, shared_xaxes=True, vertical_spacing=0.06,
503
+ subplot_titles=("log(Realized Vol) with High-Vol Regime Shading",
504
+ f"Smoothed Probability of High-Vol Regime (Regime {high})"))
505
+
506
+ # Top line
507
+ fig5.add_trace(go.Scatter(x=series.index, y=series, name="log(RV)", line=dict(color="white", width=1)), row=1, col=1)
508
+
509
+ # Shading spans where p_high>0.5
510
+ mask = (p_high > 0.5)
511
+ grp = (mask != mask.shift()).cumsum()
512
+ for _, span in mask[mask].groupby(grp):
513
+ x0 = span.index[0]; x1 = span.index[-1]
514
+ fig5.add_vrect(x0=x0, x1=x1, line_width=0, fillcolor="red", opacity=0.2, row=1, col=1)
515
+
516
+ # Bottom probability
517
+ fig5.add_trace(go.Scatter(x=p_high.index, y=p_high, name=f"P(Regime {high})", line=dict(color="magenta", width=1)), row=2, col=1)
518
+ fig5.add_hline(y=0.5, line_dash="dash", line_color="gray", row=2, col=1)
519
+ fig5.update_yaxes(title_text="log(RV)", row=1, col=1)
520
+ fig5.update_yaxes(title_text="Probability", row=2, col=1)
521
+
522
+ fig5.update_xaxes(
523
+ tickformatstops=_tickformatstops_monthy(),
524
+ showgrid=True, gridcolor="rgba(160,160,160,0.2)",
525
+ showline=True, linecolor="rgba(255,255,255,0.4)"
526
+ )
527
+ fig5.update_yaxes(
528
+ showgrid=True, gridcolor="rgba(160,160,160,0.2)",
529
+ showline=True, linecolor="rgba(255,255,255,0.4)"
530
+ )
531
+ fig5.update_layout(
532
+ template="plotly_dark",
533
+ height=600,
534
+ margin=dict(l=60, r=20, t=60, b=40),
535
+ font=dict(color="white")
536
+ )
537
+ if hasattr(fig5.layout, "annotations"):
538
+ for a in fig5.layout.annotations:
539
+ a.font = dict(color="white", size=12)
540
+ st.plotly_chart(fig5, use_container_width=True)
541
+
542
+ with st.expander("Markov Model Results (raw-style text)", expanded=False):
543
+ buf = io.StringIO()
544
+ print("\nEstimated transition probabilities (rows = to, cols = from)", file=buf)
545
+ print(" from Reg-0 from Reg-1", file=buf)
546
+ print(f"to Reg-0 {p00:.4f} {p10:.4f}", file=buf)
547
+ print(f"to Reg-1 {p01:.4f} {p11:.4f}", file=buf)
548
+ print("\nInterpretation:", file=buf)
549
+ print(f"• Low-vol regime (Reg-0) persistence = {p00:.2%}. Avg spell ≈ {exp_len_0:.1f} trading days.", file=buf)
550
+ print(f"• High-vol regime (Reg-1) persistence = {p11:.2%}. Avg spell ≈ {exp_len_1:.1f} trading days.", file=buf)
551
+ print(f"• Chance of jumping LOW → HIGH next day = {p01:.2%}.", file=buf)
552
+ print(f"• Chance of jumping HIGH → LOW next day = {p10:.2%}.\n", file=buf)
553
+ st.text(buf.getvalue())
554
+
555
+ st.success("Analysis complete.")