QuantumLearner commited on
Commit
de01d22
·
verified ·
1 Parent(s): 507966a

Create app.py

Browse files
Files changed (1) hide show
  1. app.py +456 -0
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
+ )