QuantumLearner commited on
Commit
b3ee258
·
verified ·
1 Parent(s): 6074c1a

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +382 -494
app.py CHANGED
@@ -35,23 +35,54 @@ from vix_utils import async_load_vix_term_structure
35
  from datetime import datetime, timedelta
36
  import traceback
37
 
38
- # Set wide layout
39
  st.set_page_config(layout="wide", page_title="VIX Regime Detection")
40
-
41
- # Apply nest_asyncio for async operations
42
  nest_asyncio.apply()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
43
 
44
- # Title and purpose
 
 
 
 
 
 
45
  st.title("VIX Regime Detection")
46
  st.write(
47
  "This tool tracks the VIX term structure and identifies regimes: contango, backwardation, or cautious. It reports carry sign, curve slope, and changes over time and shows regime persistence and transition probabilities."
48
  "For more details, see [this article](https://entreprenerdly.com/detecting-vix-term-structure-regimes/)."
49
  )
50
 
51
- # Sidebar for inputs
52
  with st.sidebar:
53
  st.title("Parameters")
54
 
 
 
 
 
 
 
 
55
  # Data Range expander
56
  with st.expander("Data Range", expanded=False):
57
  start_date = st.date_input(
@@ -178,42 +209,59 @@ with st.sidebar:
178
  # Run button
179
  run_analysis = st.button("Run Analysis")
180
 
181
- # Main logic
 
182
  if run_analysis:
183
  with st.spinner("Loading data..."):
184
  try:
185
- # Fixed end date
186
  end_date = datetime.today() + timedelta(days=1)
187
-
188
- # Async data load
189
- async def get_term_structure():
190
- df = await async_load_vix_term_structure()
191
- return df
192
-
193
- loop = asyncio.get_event_loop()
194
- df = loop.run_until_complete(get_term_structure())
195
-
 
 
 
 
 
 
 
 
 
 
 
 
196
  # Filter by start date
197
- df['Trade Date'] = pd.to_datetime(df['Trade Date'])
198
- df = df[df['Trade Date'] >= pd.to_datetime(start_date)]
199
-
200
  if df.empty:
201
  st.error("No data available for the selected date range.")
202
  st.stop()
203
-
204
- # Suppress warnings
205
- warnings.filterwarnings("ignore", category=FutureWarning, module="vix_utils")
206
-
207
- # Section 1: Raw Dataframe
208
  st.header("VIX Term Structure Dataset")
209
  st.write("The raw VIX term structure data loaded for analysis.")
210
  with st.expander("1. Raw Dataset", expanded=False):
211
- st.dataframe(df, use_container_width=True)
212
-
213
- # Section 2: Slope Time Series Analysis
 
 
 
 
 
 
 
 
214
  st.header("Slope Time Series Across Time")
215
  st.write("Visualizes the VIX term structure slopes over time with regime classifications.")
216
-
217
  with st.expander("Methodology", expanded=False):
218
  st.write("""
219
  This analysis filters the VIX futures data to include only dates from the specified start date onward. For each trade date, the data is grouped, and only groups with at least two tenors are considered. The term structure is sorted by monthly tenor.
@@ -237,99 +285,100 @@ if run_analysis:
237
 
238
  This visualization allows investors to observe how the term structure evolves over time and how regimes shift. This gives insights into market sentiment and potential volatility dynamics.
239
  """)
240
-
241
- # Code for slope time series
242
- df_sub = df.loc[df['Trade Date'] >= pd.to_datetime(start_date)].copy()
243
- df_sub.sort_values('Trade Date', inplace=True)
244
 
245
  groups = [
246
- (dt, grp.sort_values('Tenor_Monthly'))
247
- for dt, grp in df_sub.groupby('Trade Date')
248
  if len(grp) > 1
249
  ]
250
 
 
 
 
 
 
251
  regime_map = {}
252
  for dt, grp in groups:
253
- slope = grp['Settle'].iloc[-1] - grp['Settle'].iloc[0]
254
  if slope > slope_thr:
255
  regime = "CONTANGO"
256
  elif slope < -slope_thr:
257
  regime = "BACKWARDATION"
258
  else:
259
  regime = "CAUTIOUS"
260
- regime_map[str(dt.date())] = regime
261
-
262
- fig = go.Figure()
263
- dates = []
264
- for i, (dt, grp) in enumerate(groups):
265
- date_str = str(dt.date())
266
- dates.append(date_str)
267
- fig.add_trace(
268
- go.Scatter(
269
- x=grp['Expiry'],
270
- y=grp['Settle'],
271
- mode='lines+markers',
272
- name=date_str,
273
- visible=(i == len(groups) - 1),
274
- line=dict(width=2),
275
- marker=dict(size=6)
 
 
 
 
276
  )
277
- )
278
-
279
- steps = []
280
- for i, d in enumerate(dates):
281
- title = f"VIX Term Structure — {d} — {regime_map[d]}"
282
- steps.append({
283
- "method": "update",
284
- "args": [
285
- {"visible": [j == i for j in range(len(dates))]},
286
- {"title": title}
287
- ],
288
- "label": d
289
- })
290
-
291
- # Raise the slider a bit and give it space
292
- slider = {
293
- "active": len(dates) - 1,
294
- "currentvalue": {"prefix": "Trade Date: ", "font": {"size": 14}},
295
- "pad": {"t": 16, "b": 0}, # smaller top pad -> closer to plot
296
- "x": 0.0,
297
- "y": 0.0015, # lift slightly above the bottom
298
- "len": 1.0,
299
- "steps": steps
300
- }
301
 
302
- fig.update_layout(
303
- sliders=[slider],
304
- title=f"VIX Term Structure — {dates[-1]} — {regime_map[dates[-1]]}",
305
- xaxis_title="Futures Expiry",
306
- yaxis_title="VIX Futures Price",
307
- height=500,
308
- margin=dict(l=60, r=20, t=60, b=90), # extra bottom margin to avoid clipping
309
- template="plotly_dark",
310
- paper_bgcolor="rgba(0,0,0,1)",
311
- plot_bgcolor="rgba(0,0,0,1)",
312
- title_font_color="white",
313
- font=dict(color="white")
314
- )
315
 
316
- # Subtle gridlines for clarity (optional)
317
- fig.update_xaxes(gridcolor="rgba(255,255,255,0.08)")
318
- fig.update_yaxes(gridcolor="rgba(255,255,255,0.08)")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
319
 
320
- st.plotly_chart(fig, use_container_width=True)
321
-
322
  with st.expander("Dynamic Interpretation", expanded=False):
323
- # Build daily and interpretations
324
  daily_rows = []
325
  grp_map = {pd.to_datetime(dt): g.sort_values('Tenor_Monthly') for dt, g in groups}
326
-
327
  for dt, grp in grp_map.items():
328
  g = grp.dropna(subset=['Tenor_Monthly', 'Settle']).copy()
329
  settle_by_m = g.groupby('Tenor_Monthly', as_index=True)['Settle'].last()
330
  if settle_by_m.size < 2:
331
  continue
332
-
333
  first_tenor = settle_by_m.index.min()
334
  last_tenor = settle_by_m.index.max()
335
  front = float(settle_by_m.loc[first_tenor])
@@ -337,13 +386,11 @@ if run_analysis:
337
  slope = back - front
338
  curve_width = float(settle_by_m.max() - settle_by_m.min())
339
  n_tenors = int(settle_by_m.size)
340
-
341
  vx1 = float(settle_by_m.loc[1.0]) if 1.0 in settle_by_m.index else np.nan
342
  vx2 = float(settle_by_m.loc[2.0]) if 2.0 in settle_by_m.index else np.nan
343
  vx6 = float(settle_by_m.loc[6.0]) if 6.0 in settle_by_m.index else np.nan
344
  c12 = (vx2 - vx1) if np.isfinite(vx1) and np.isfinite(vx2) else np.nan
345
  c61 = (vx6 - vx1) if np.isfinite(vx1) and np.isfinite(vx6) else np.nan
346
-
347
  dstr = str(pd.to_datetime(dt).date())
348
  daily_rows.append({
349
  'Trade Date': pd.to_datetime(dt),
@@ -356,53 +403,42 @@ if run_analysis:
356
  'VX6_VX1': c61,
357
  'Regime': regime_map.get(dstr, 'UNKNOWN')
358
  })
359
-
360
  daily = pd.DataFrame(daily_rows).sort_values('Trade Date').reset_index(drop=True)
361
-
362
  if not daily.empty:
363
  for w in (5, 20, 60, 120):
364
  mp_mean = min(3, w)
365
  mp_std = min(10, w)
366
  daily[f'Slope_MA_{w}'] = daily['Slope'].rolling(window=w, min_periods=mp_mean).mean()
367
  daily[f'Slope_STD_{w}'] = daily['Slope'].rolling(window=w, min_periods=mp_std).std()
368
-
369
  daily['Slope_Z_120'] = np.where(
370
  daily['Slope_STD_120'].fillna(0) > 0,
371
  (daily['Slope'] - daily['Slope_MA_120']) / daily['Slope_STD_120'],
372
  np.nan
373
  )
374
-
375
  def _streak(vals):
376
  out = np.ones(len(vals), dtype=int)
377
  for i in range(1, len(vals)):
378
  out[i] = out[i-1] + 1 if vals[i] == vals[i-1] else 1
379
  return out
380
-
381
  daily['Regime_Streak'] = _streak(daily['Regime'].to_numpy())
382
-
383
  def _trend_tag(val, ref):
384
  if pd.isna(ref): return "n/a"
385
  return "above" if val > ref else ("below" if val < ref else "equal")
386
-
387
  def _trend_word(vs_ma):
388
  if vs_ma == "above": return "steeper"
389
  if vs_ma == "below": return "flatter"
390
  return "unchanged"
391
-
392
  def _carry_word(x):
393
  if pd.isna(x): return "n/a"
394
  return "positive" if x >= 0 else "negative"
395
-
396
  def _dominant_regime(comp):
397
  if not comp: return "n/a"
398
  k = max(comp, key=comp.get)
399
  return f"{k.lower()} ({comp[k]:.1f}%)"
400
-
401
  def _safe_pct_rank(series, value):
402
  s = pd.to_numeric(series, errors='coerce').dropna()
403
  if s.empty or not np.isfinite(value): return np.nan
404
  return float((s < value).mean() * 100.0)
405
-
406
  def _qbin(series, value, q=(0.1,0.25,0.5,0.75,0.9)):
407
  s = pd.to_numeric(series, errors='coerce').dropna()
408
  if s.empty or not np.isfinite(value): return "n/a"
@@ -413,12 +449,10 @@ if run_analysis:
413
  if value <= qs[q[3]]: return f"{int(q[2]*100)}–{int(q[3]*100)}th"
414
  if value <= qs[q[4]]: return f"{int(q[3]*100)}–{int(q[4]*100)}th"
415
  return f">{int(q[4]*100)}th"
416
-
417
  start, end = daily['Trade Date'].min().date(), daily['Trade Date'].max().date()
418
  days = len(daily)
419
  avg_tenors = daily['NumTenors'].mean()
420
  last = daily.iloc[-1]
421
-
422
  st.write("— Snapshot —")
423
  st.write(f"Sample {start} to {end} ({days} days).")
424
  st.write(f"Average tenors per day {avg_tenors:.1f}.")
@@ -426,32 +460,26 @@ if run_analysis:
426
  st.write(f"Curve width {last['CurveWidth']:.2f} pts across {last['NumTenors']} tenors.")
427
  if not pd.isna(last['VX2_VX1']):
428
  st.write(f"Front carry VX2−VX1 {last['VX2_VX1']:.2f} pts ({_carry_word(last['VX2_VX1'])}).")
429
- if not pd.isna(last['VX6_VX1']):
430
- st.write(f"Term carry VX6−VX1 {last['VX6_VX1']:.2f} pts ({_carry_word(last['VX6_VX1'])}).")
431
-
432
  tag5 = _trend_tag(last['Slope'], last.get('Slope_MA_5'))
433
  tag20 = _trend_tag(last['Slope'], last.get('Slope_MA_20'))
434
  if tag5 != "n/a": st.write(f"Slope is {_trend_word(tag5)} than 5-day average.")
435
  if tag20 != "n/a": st.write(f"Slope is {_trend_word(tag20)} than 20-day average.")
436
-
437
  z120 = last.get('Slope_Z_120', np.nan)
438
  if not pd.isna(z120):
439
  if z120 >= 2: st.write(f"Slope high vs 120-day history (z={z120:.2f}).")
440
  elif z120 <= -2: st.write(f"Slope low vs 120-day history (z={z120:.2f}).")
441
  else: st.write(f"Slope within 120-day normal (z={z120:.2f}).")
442
-
443
  arr = pd.to_numeric(daily['Slope'], errors='coerce').dropna().to_numpy()
444
  if arr.size and np.isfinite(last['Slope']):
445
  pct = float((arr < last['Slope']).mean() * 100.0)
446
  st.write(f"Slope at {pct:.1f} percentile of sample.")
447
-
448
  for window in (30, 90):
449
  sub = daily.tail(window)
450
  if sub.empty: continue
451
  comp = (sub['Regime'].value_counts(normalize=True) * 100).to_dict()
452
  dom = _dominant_regime(comp)
453
  st.write(f"Last {window} days dominant regime {dom}.")
454
-
455
  streak = int(last['Regime_Streak'])
456
  if len(daily) >= 2:
457
  changed = daily['Regime'].to_numpy() != daily['Regime'].shift(1).to_numpy()
@@ -461,26 +489,21 @@ if run_analysis:
461
  st.write(f"Current {last['Regime'].lower()} streak {streak} days since {last_change_day}.")
462
  else:
463
  st.write(f"Current {last['Regime'].lower()} streak {streak} days.")
464
-
465
  if len(daily) >= 3:
466
  hi = daily.nlargest(1, 'Slope').iloc[0]
467
  lo = daily.nsmallest(1, 'Slope').iloc[0]
468
  st.write(f"Max slope {hi['Slope']:.2f} on {hi['Trade Date'].date()} ({hi['Regime']}).")
469
  st.write(f"Min slope {lo['Slope']:.2f} on {lo['Trade Date'].date()} ({lo['Regime']}).")
470
-
471
  anoms = daily[daily['Slope_Z_120'].abs() >= 3]
472
  if len(anoms) > 0:
473
  last_a = anoms.iloc[-1]
474
  st.write(f"Recent anomaly {last_a['Trade Date'].date()} (|z120|={abs(last_a['Slope_Z_120']):.2f}).")
475
-
476
  sparse = daily[daily['NumTenors'] < 3]
477
  if len(sparse) > 0:
478
  st.write(f"{len(sparse)} sparse days (<3 tenors). Treat slopes carefully.")
479
-
480
  st.write("— History context —")
481
  today = last['Trade Date'].date()
482
  reg = last['Regime']
483
-
484
  slope_pct = _safe_pct_rank(daily['Slope'], last['Slope'])
485
  width_pct = _safe_pct_rank(daily['CurveWidth'], last['CurveWidth'])
486
  c12_pct = _safe_pct_rank(daily['VX2_VX1'], last['VX2_VX1']) if pd.notna(last['VX2_VX1']) else np.nan
@@ -489,7 +512,6 @@ if run_analysis:
489
  if pd.notna(width_pct): st.write(f"{today}: width percentile vs sample {width_pct:.1f}%.")
490
  if pd.notna(c12_pct): st.write(f"{today}: VX2−VX1 percentile vs sample {c12_pct:.1f}%.")
491
  if pd.notna(c61_pct): st.write(f"{today}: VX6−VX1 percentile vs sample {c61_pct:.1f}%.")
492
-
493
  sub_reg = daily[daily['Regime'] == reg]
494
  if not sub_reg.empty:
495
  slope_reg_pct = _safe_pct_rank(sub_reg['Slope'], last['Slope'])
@@ -501,7 +523,6 @@ if run_analysis:
501
  st.write(f"{today}: slope vs {reg.lower()} median {slope_diff:+.2f} pts (median {slope_med:.2f}).")
502
  else:
503
  st.write(f"{today}: no history for regime {reg}.")
504
-
505
  spells = []
506
  start_idx = 0
507
  vals = daily['Regime'].to_numpy()
@@ -512,7 +533,6 @@ if run_analysis:
512
  spells.append({'Regime': r, 'Length': length, 'EndIndex': i-1})
513
  start_idx = i
514
  spells = pd.DataFrame(spells)
515
-
516
  if not spells.empty:
517
  cur_len = int(spells.iloc[-1]['Length'])
518
  reg_spells = spells[spells['Regime'] == reg]['Length']
@@ -524,7 +544,6 @@ if run_analysis:
524
  st.write(f"{today}: spell is {tag} than mean ({mean_len:.1f} days).")
525
  if pd.notna(p75_len):
526
  st.write(f"{today}: spell {'≥' if cur_len >= p75_len else '<'} 75th percentile ({p75_len:.0f} days).")
527
-
528
  trans = (
529
  daily[['Regime']]
530
  .assign(Prev=lambda x: x['Regime'].shift(1))
@@ -540,7 +559,6 @@ if run_analysis:
540
  if not stay_row.empty:
541
  p_stay = float(stay_row['Prob'].iloc[0])
542
  st.write(f"{today}: one-day stay probability in {reg.lower()} {p_stay:.2f}.")
543
-
544
  daily['Month'] = daily['Trade Date'].dt.to_period('M')
545
  cur_month = daily['Month'].iloc[-1]
546
  mtd = daily[daily['Month'] == cur_month]
@@ -558,13 +576,11 @@ if run_analysis:
558
  if pd.notna(typical):
559
  comp = "above" if mtd_changes > typical else ("below" if mtd_changes < typical else "in line")
560
  st.write(f"{today}: MTD regime churn {comp} median month ({typical:.0f}).")
561
-
562
  moy = mtd['Trade Date'].dt.month.iloc[-1]
563
  same_moy = daily[daily['Trade Date'].dt.month == moy]['Slope']
564
  if not same_moy.dropna().empty:
565
  moy_pct = _safe_pct_rank(same_moy, last['Slope'])
566
  st.write(f"{today}: slope percentile vs historical {pd.Timestamp(today).strftime('%B')} {moy_pct:.1f}%.")
567
-
568
  slope_bin = _qbin(daily['Slope'], last['Slope'])
569
  width_bin = _qbin(daily['CurveWidth'], last['CurveWidth'])
570
  st.write(f"{today}: slope bin {slope_bin}.")
@@ -575,7 +591,6 @@ if run_analysis:
575
  tail_lo = (s_all <= last['Slope']).mean()*100.0
576
  tail = min(tail_hi, tail_lo)
577
  st.write(f"{today}: tail frequency at this slope level {tail:.1f}%.")
578
-
579
  band = max(0.25, s_all.std()*0.1) if not s_all.empty else 0.25
580
  recent_sim = daily[(daily['Regime'] == reg) &
581
  (daily['Slope'].between(last['Slope']-band, last['Slope']+band))]
@@ -583,7 +598,6 @@ if run_analysis:
583
  prev = recent_sim.iloc[-2]['Trade Date'].date()
584
  days_since = (pd.Timestamp(today) - pd.Timestamp(prev)).days
585
  st.write(f"{today}: last similar day was {prev} ({days_since} days ago).")
586
-
587
  def _stab(row):
588
  c1 = abs(row['Slope'] - row.get('Slope_MA_20', np.nan))
589
  c2 = abs(row.get('Slope_Z_120', np.nan))
@@ -591,16 +605,15 @@ if run_analysis:
591
  if np.isfinite(c1): parts.append(1.0 / (1.0 + c1))
592
  if np.isfinite(c2): parts.append(1.0 / (1.0 + c2))
593
  return np.mean(parts) if parts else np.nan
594
-
595
  daily['Stability'] = daily.apply(_stab, axis=1)
596
  stab_pct = _safe_pct_rank(daily['Stability'], daily['Stability'].iloc[-1])
597
  if pd.notna(stab_pct):
598
  st.write(f"{today}: stability percentile {stab_pct:.1f}% (higher means steadier slope).")
599
-
600
- # Section 3: 3D Term-Structure Visualization
601
  st.header("Term-Structure Surface")
602
  st.write("A 3D scatter plot showing the VIX term structure over time with trade date, days to expiration, and settle price.")
603
-
604
  with st.expander("Methodology", expanded=False):
605
  st.write("""
606
  This visualization filters the data to include only monthly (non-weekly) and non-expired VIX futures contracts. Dates with fewer than two tenors are excluded to ensure meaningful term structures.
@@ -640,202 +653,189 @@ if run_analysis:
640
 
641
  The Surface helps identify clusters, trends, and anomalies in the term structure surface.
642
  """)
643
-
644
- # Code for 3D
645
  monthly_df = df[(df["Weekly"] == False) & (df["Expired"] == False)].copy()
646
  valid_dates = monthly_df['Trade Date'].value_counts()
647
- valid_dates = valid_dates[valid_dates > 1].index.tolist()
648
  monthly_df_filtered = monthly_df[monthly_df['Trade Date'].isin(valid_dates)].copy()
649
 
650
- # lock the color scale explicitly
651
- cmin = float(monthly_df_filtered["Settle"].min())
652
- cmax = float(monthly_df_filtered["Settle"].max())
653
-
654
- fig = px.scatter_3d(
655
- monthly_df_filtered,
656
- x="Trade Date",
657
- y="Tenor_Days",
658
- z="Settle",
659
- color="Settle",
660
- color_continuous_scale="Viridis", # matches the raw notebook’s default
661
- range_color=(cmin, cmax),
662
- title="VIX Futures Term Structure over Time"
663
- )
664
-
665
- # marker styling to match the raw feel
666
- fig.update_traces(
667
- marker=dict(size=3, opacity=0.9,
668
- colorbar=dict(
669
- title="Settle",
670
- thickness=12,
671
- tickcolor="white",
672
- titlefont=dict(color="white"),
673
- tickfont=dict(color="white")
674
- ))
675
- )
676
-
677
- fig.update_layout(
678
- scene=dict(
679
- xaxis_title='Trade Date',
680
- yaxis_title='Days to Expiration',
681
- zaxis_title='Settle Price',
682
- xaxis=dict(gridcolor="rgba(255,255,255,0.08)", tickcolor="white"),
683
- yaxis=dict(gridcolor="rgba(255,255,255,0.08)", tickcolor="white"),
684
- zaxis=dict(gridcolor="rgba(255,255,255,0.08)", tickcolor="white"),
685
- bgcolor="rgba(0,0,0,1)"
686
- ),
687
- template="plotly_dark",
688
- paper_bgcolor="rgba(0,0,0,1)",
689
- plot_bgcolor="rgba(0,0,0,1)",
690
- title_font_color="white",
691
- font=dict(color="white"),
692
- margin=dict(l=0, r=0, t=60, b=0)
693
- )
694
-
695
- st.plotly_chart(fig, use_container_width=True)
696
-
697
- with st.expander("Dynamic Interpretation", expanded=False):
698
- # Dynamic interp for 3D
699
- grouped = monthly_df_filtered.groupby("Trade Date")
700
- rows = []
701
- for dt, g in grouped:
702
- g = g.dropna(subset=["Tenor_Days", "Settle"]).sort_values("Tenor_Days")
703
- if g["Tenor_Days"].nunique() < 2:
704
- continue
705
-
706
- level = float(g["Settle"].mean())
707
- width = float(g["Settle"].max() - g["Settle"].min())
708
- front = float(g.iloc[0]["Settle"])
709
- back = float(g.iloc[-1]["Settle"])
710
- nten = int(g["Tenor_Days"].nunique())
711
-
712
- x = g["Tenor_Days"].to_numpy(dtype=float)
713
- y = g["Settle"].to_numpy(dtype=float)
714
- lin = np.polyfit(x, y, 1)
715
- slope = float(lin[0])
716
- curv = np.nan
717
- if nten >= 3:
718
- quad = np.polyfit(x, y, 2)
719
- curv = float(quad[0])
720
-
721
- by_m = (g.dropna(subset=["Tenor_Monthly"])
722
- .drop_duplicates("Tenor_Monthly")
723
- .set_index("Tenor_Monthly")["Settle"])
724
- vx1 = float(by_m.loc[1.0]) if 1.0 in by_m.index else np.nan
725
- vx2 = float(by_m.loc[2.0]) if 2.0 in by_m.index else np.nan
726
- vx6 = float(by_m.loc[6.0]) if 6.0 in by_m.index else np.nan
727
- c12 = (vx2 - vx1) if np.isfinite(vx1) and np.isfinite(vx2) else np.nan
728
- c61 = (vx6 - vx1) if np.isfinite(vx1) and np.isfinite(vx6) else np.nan
729
-
730
- rows.append({
731
- "Trade Date": pd.to_datetime(dt),
732
- "Level": level,
733
- "Width": width,
734
- "Front": front,
735
- "Back": back,
736
- "Slope_pd": slope,
737
- "Curvature": curv,
738
- "NumTenors": nten,
739
- "VX2_VX1": c12,
740
- "VX6_VX1": c61
741
- })
742
-
743
- surf = pd.DataFrame(rows).sort_values("Trade Date").reset_index(drop=True)
744
-
745
- if not surf.empty:
746
- for w in (5, 20, 60, 120):
747
- mp_mean = min(3, w)
748
- mp_std = min(10, w)
749
- surf[f"SlopeMA_{w}"] = surf["Slope_pd"].rolling(w, min_periods=mp_mean).mean()
750
- surf[f"SlopeSTD_{w}"] = surf["Slope_pd"].rolling(w, min_periods=mp_std).std()
751
- surf[f"LevelMA_{w}"] = surf["Level"].rolling(w, min_periods=mp_mean).mean()
752
-
753
- surf["SlopeZ_120"] = np.where(
754
- surf["SlopeSTD_120"].fillna(0) > 0,
755
- (surf["Slope_pd"] - surf["SlopeMA_120"]) / surf["SlopeSTD_120"],
756
- np.nan
757
- )
758
-
759
- def pct_rank(series, value):
760
- s = pd.to_numeric(series, errors="coerce").dropna()
761
- if s.empty or not np.isfinite(value):
762
- return np.nan
763
- return float((s < value).mean() * 100.0)
764
-
765
- def explain_percentile(label, pct):
766
- if pd.isna(pct):
767
- st.write(f"{label}: n/a. Not enough history.")
768
- else:
769
- higher = 100.0 - pct
770
- st.write(f"{label}: {pct:.1f}% of days were lower. {higher:.1f}% were higher.")
771
-
772
- def trend_tag(val, ref):
773
- if pd.isna(ref): return "n/a"
774
- return "above" if val > ref else ("below" if val < ref else "equal")
775
-
776
- def carry_word(x):
777
- if pd.isna(x): return "n/a"
778
- return "positive" if x >= 0 else "negative"
779
-
780
- last = surf.iloc[-1]
781
- start, end = surf["Trade Date"].min().date(), surf["Trade Date"].max().date()
782
- st.write("— Term-structure surface snapshot —")
783
- st.write(f"Sample {start} to {end} ({len(surf)} days).")
784
- st.write(f"Level {last['Level']:.2f}. Width {last['Width']:.2f}.")
785
- st.write(f"Slope {last['Slope_pd']:.4f} pts/day. Curvature {last['Curvature']:.6f}.")
786
-
787
- if not pd.isna(last["VX2_VX1"]):
788
- st.write(f"Front carry VX2−VX1 {last['VX2_VX1']:.2f} ({carry_word(last['VX2_VX1'])}).")
789
- if not pd.isna(last["VX6_VX1"]):
790
- st.write(f"Term carry VX6−VX1 {last['VX6_VX1']:.2f} ({carry_word(last['VX6_VX1'])}).")
791
-
792
- t5 = trend_tag(last["Slope_pd"], last.get("SlopeMA_5"))
793
- t20 = trend_tag(last["Slope_pd"], last.get("SlopeMA_20"))
794
- if t5 != "n/a": st.write(f"Slope is {t5} the 5-day mean.")
795
- if t20 != "n/a": st.write(f"Slope is {t20} the 20-day mean.")
796
-
797
- z = last.get("SlopeZ_120", np.nan)
798
- if pd.notna(z):
799
- if z >= 2: st.write(f"Slope is high vs 120-day history (z={z:.2f}).")
800
- elif z <= -2: st.write(f"Slope is low vs 120-day history (z={z:.2f}).")
801
- else: st.write(f"Slope is within 120-day range (z={z:.2f}).")
802
-
803
- st.write(" How today compares to history —")
804
- slope_pct = pct_rank(surf["Slope_pd"], last["Slope_pd"])
805
- width_pct = pct_rank(surf["Width"], last["Width"])
806
- level_pct = pct_rank(surf["Level"], last["Level"])
807
- explain_percentile("Slope percentile", slope_pct)
808
- explain_percentile("Width percentile", width_pct)
809
- explain_percentile("Level percentile", level_pct)
810
-
811
- if pd.notna(last["VX2_VX1"]):
812
- c12_pct = pct_rank(surf["VX2_VX1"], last["VX2_VX1"])
813
- explain_percentile("VX2−VX1 percentile", c12_pct)
814
- if pd.notna(last["VX6_VX1"]):
815
- c61_pct = pct_rank(surf["VX6_VX1"], last["VX6_VX1"])
816
- explain_percentile("VX6−VX1 percentile", c61_pct)
817
-
818
- hi = surf.nlargest(1, "Slope_pd").iloc[0]
819
- lo = surf.nsmallest(1, "Slope_pd").iloc[0]
820
- st.write(f"Steepest day {hi['Trade Date'].date()} with {hi['Slope_pd']:.4f} pts/day.")
821
- st.write(f"Flattest day {lo['Trade Date'].date()} with {lo['Slope_pd']:.4f} pts/day.")
822
-
823
- surf["Month"] = surf["Trade Date"].dt.to_period("M")
824
- cur_m = surf["Month"].iloc[-1]
825
- mtd = surf[surf["Month"] == cur_m]
826
- if not mtd.empty and len(mtd) >= 5:
827
- mtd_slope_std = float(mtd["Slope_pd"].std())
828
- mtd_level_std = float(mtd["Level"].std())
829
- st.write(f"MTD slope std {mtd_slope_std:.4f} pts/day. MTD level std {mtd_level_std:.2f}.")
830
-
831
- sparse = surf[surf["NumTenors"] < 3]
832
- if len(sparse) > 0:
833
- st.write(f"{len(sparse)} days have <3 tenors. Interpret slope and curvature carefully.")
834
-
835
- # Section 4: HMM Regime Classification
836
  st.header("HMM Regime Classification")
837
  st.write("Classifies VIX regimes using Hidden Markov Model on slope time series.")
838
-
839
  with st.expander("Methodology", expanded=False):
840
  st.write("""
841
  This analysis focuses on monthly VIX futures contracts. For each trade date with at least two tenors, the daily slope is computed as the linear regression coefficient of settle prices against days to expiration:
@@ -870,8 +870,7 @@ if run_analysis:
870
  The plot shows slopes over time, colored by regime, with a black line connecting the slopes and a dashed horizontal at 0 for reference.
871
 
872
  """)
873
-
874
- # Code for HMM
875
  base = df[~df['Weekly']].copy()
876
  rows = []
877
  for d, g in base.groupby('Trade Date'):
@@ -880,31 +879,36 @@ if run_analysis:
880
  continue
881
  slope = np.polyfit(g['Tenor_Days'], g['Settle'], 1)[0]
882
  rows.append({'Trade Date': d, 'Slope': slope})
883
-
884
  slope_df = pd.DataFrame(rows).sort_values('Trade Date')
885
 
 
 
 
 
 
886
  X = StandardScaler().fit_transform(slope_df[['Slope']])
 
 
 
887
  hmm = GaussianHMM(
888
- n_components=hmm_n_components,
889
  covariance_type='full',
890
- n_iter=hmm_n_iter,
891
  random_state=1
892
  ).fit(X)
893
 
894
  hidden = hmm.predict(X)
895
-
896
- state_mean = pd.Series(hmm.means_.flatten(), index=range(hmm_n_components))
897
  order = state_mean.sort_values().index
898
- label_map = {order[i]: ['BACKWARDATION', 'CAUTIOUS', 'CONTANGO'][i] for i in range(min(3, hmm_n_components))}
899
  slope_df['Regime'] = [label_map.get(s, 'UNKNOWN') for s in hidden]
900
 
901
- # consistent ordering + color mapping (dark-friendly)
902
  cat_order = ['BACKWARDATION', 'CAUTIOUS', 'CONTANGO', 'UNKNOWN']
903
  color_map = {
904
- 'BACKWARDATION': '#d62728', # red
905
- 'CAUTIOUS': '#7f7f7f', # gray
906
- 'CONTANGO': '#2ca02c', # green
907
- 'UNKNOWN': '#1f77b4' # fallback
908
  }
909
 
910
  fig = px.scatter(
@@ -917,20 +921,16 @@ if run_analysis:
917
  opacity=0.6,
918
  title='Daily VIX Curve Slope with Regime States (HMM)'
919
  )
920
-
921
- # slope line (keep simple like the raw code)
922
  fig.add_trace(
923
  go.Scatter(
924
  x=slope_df['Trade Date'],
925
  y=slope_df['Slope'],
926
  mode='lines',
927
- line=dict(color='white', width=1), # white for visibility on dark bg
928
  name='Slope (line)'
929
  )
930
  )
931
-
932
  fig.add_hline(y=0, line_dash='dash', line_color='rgba(255,255,255,0.6)')
933
-
934
  fig.update_layout(
935
  xaxis_title='Trade Date',
936
  yaxis_title='Slope (pts / day)',
@@ -946,8 +946,6 @@ if run_analysis:
946
  ),
947
  margin=dict(l=60, r=20, t=60, b=40)
948
  )
949
-
950
- # axes text + ticks in white (and subtle grids)
951
  fig.update_xaxes(
952
  title_font=dict(color="white"),
953
  tickfont=dict(color="white"),
@@ -966,36 +964,31 @@ if run_analysis:
966
  linecolor="rgba(255,255,255,0.15)",
967
  ticks="outside"
968
  )
969
-
970
  st.plotly_chart(fig, use_container_width=True)
 
971
  with st.expander("Dynamic Interpretation", expanded=False):
972
- # Dynamic interp for HMM
973
  trans = pd.DataFrame(
974
  hmm.transmat_,
975
- index=[label_map[i] for i in range(hmm.n_components)],
976
- columns=[label_map[i] for i in range(hmm.n_components)]
977
  )
978
  st.write("\nTransition probabilities\n")
979
  st.dataframe(trans.round(3))
980
-
981
  def pct_rank(series, value):
982
  s = pd.to_numeric(series, errors="coerce").dropna()
983
  if s.empty or not np.isfinite(value):
984
  return np.nan
985
  return float((s < value).mean() * 100.0)
986
-
987
  def exp_duration(pii):
988
  if np.isclose(pii, 1.0):
989
  return np.inf
990
  return 1.0 / max(1e-12, (1.0 - pii))
991
-
992
  def note_regime(name):
993
  if name == "CONTANGO":
994
  return "term structure slopes up. carry tends to be positive."
995
  if name == "BACKWARDATION":
996
  return "term structure slopes down. stress is more likely."
997
  return "term structure is near flat. signals are mixed."
998
-
999
  def risk_bias_for_transition(src, dst):
1000
  if src == "CONTANGO" and dst == "CAUTIOUS":
1001
  return "carry tailwind may fade."
@@ -1010,40 +1003,33 @@ if run_analysis:
1010
  if src == "BACKWARDATION" and dst == "CONTANGO":
1011
  return "stress may unwind fast."
1012
  return "no clear tilt."
1013
-
1014
  def entropy_row(p):
1015
  p = np.asarray(p, float)
1016
  p = p[p > 0]
1017
  return -np.sum(p * np.log2(p)) if p.size else np.nan
1018
-
1019
  _, post = hmm.score_samples(X)
1020
  today = slope_df['Trade Date'].iloc[-1].date()
1021
  cur_state = hidden[-1]
1022
- cur_regime = label_map[cur_state]
1023
- cur_probs = {label_map[i]: float(post[-1, i]) for i in range(hmm.n_components)}
1024
  cur_prob = cur_probs[cur_regime]
1025
-
1026
  stay_prob = float(trans.loc[cur_regime, cur_regime])
1027
  edur = exp_duration(stay_prob)
1028
-
1029
  st.write("— Interpretation —")
1030
  st.write(f"Date {today}. Model labels today as {cur_regime} (prob {cur_prob:.2f}).")
1031
  st.write(f"This means {note_regime(cur_regime)}")
1032
-
1033
  if cur_prob >= 0.8:
1034
  st.write("Confidence is high. The label is stable.")
1035
  elif cur_prob >= 0.6:
1036
  st.write("Confidence is moderate. Treat it as useful, not certain.")
1037
  else:
1038
  st.write("Confidence is low. Be cautious using this label.")
1039
-
1040
  if stay_prob >= 0.85:
1041
  st.write("Day-to-day persistence is high. Expect the same regime tomorrow.")
1042
  elif stay_prob >= 0.65:
1043
  st.write("Day-to-day persistence is moderate. A hold is slightly more likely.")
1044
  else:
1045
  st.write("Day-to-day persistence is low. A switch is common.")
1046
-
1047
  if np.isinf(edur):
1048
  st.write("Spells in this regime can run very long in this model.")
1049
  elif edur >= 10:
@@ -1052,7 +1038,6 @@ if run_analysis:
1052
  st.write(f"Typical spell length is medium (~{edur:.0f} days).")
1053
  else:
1054
  st.write(f"Typical spell length is short (~{edur:.0f} days).")
1055
-
1056
  streak = 1
1057
  for i in range(len(hidden) - 2, -1, -1):
1058
  if hidden[i] == cur_state:
@@ -1068,13 +1053,11 @@ if run_analysis:
1068
  st.write("Streak is mid to late stage.")
1069
  else:
1070
  st.write("Streak is early stage.")
1071
-
1072
  row_sorted = trans.loc[cur_regime].sort_values(ascending=False)
1073
  exit_target = row_sorted.drop(index=cur_regime).idxmax()
1074
  exit_p = float(row_sorted.drop(index=cur_regime).max())
1075
  back_p = float(trans.loc[exit_target, cur_regime])
1076
  asym = exit_p - back_p
1077
-
1078
  st.write(f"Most likely exit is to {exit_target} at {exit_p:.2f}.")
1079
  st.write(f"If that happens: {risk_bias_for_transition(cur_regime, exit_target)}")
1080
  if abs(asym) >= 0.10:
@@ -1082,7 +1065,6 @@ if run_analysis:
1082
  st.write(f"Flow between {cur_regime} and {exit_target} is {tilt} ({asym:+.2f}).")
1083
  else:
1084
  st.write("Two-way flow between these regimes is roughly balanced.")
1085
-
1086
  h_bits = entropy_row(trans.loc[cur_regime].values)
1087
  if h_bits <= 0.6:
1088
  st.write("Next-state outcomes are concentrated. Path is predictable.")
@@ -1090,14 +1072,12 @@ if run_analysis:
1090
  st.write("Next-state outcomes cluster in a few paths.")
1091
  else:
1092
  st.write("Next-state outcomes are diffuse. Path is uncertain.")
1093
-
1094
  T = trans.values
1095
  name_to_idx = {n:i for i, n in enumerate(trans.index)}
1096
  i0 = name_to_idx[cur_regime]
1097
  def kstep(T, i, k):
1098
  Tk = np.linalg.matrix_power(T, k)
1099
  return pd.Series(Tk[i], index=trans.columns)
1100
-
1101
  d5 = kstep(T, i0, 5)
1102
  p5_stay = float(d5[cur_regime])
1103
  if p5_stay >= 0.60:
@@ -1106,7 +1086,6 @@ if run_analysis:
1106
  st.write("Five-day view: staying is plausible but not dominant.")
1107
  else:
1108
  st.write("Five-day view: a different regime is more likely.")
1109
-
1110
  eigvals, eigvecs = np.linalg.eig(T.T)
1111
  idx = np.argmin(np.abs(eigvals - 1))
1112
  pi = np.real(eigvecs[:, idx]); pi = pi / pi.sum()
@@ -1122,7 +1101,6 @@ if run_analysis:
1122
  st.write("Long-run: regimes are sticky.")
1123
  else:
1124
  st.write("Long-run: regimes churn at a moderate pace.")
1125
-
1126
  cur_slope = float(slope_df['Slope'].iloc[-1])
1127
  pct_full = pct_rank(slope_df['Slope'], cur_slope)
1128
  st.write(f"Current slope is {cur_slope:.4f} pts/day.")
@@ -1135,8 +1113,7 @@ if run_analysis:
1135
  else:
1136
  band = "upper" if pct_full >= 60 else ("lower" if pct_full <= 40 else "middle")
1137
  st.write(f"Slope sits in the {band} part of its range "
1138
- f"({pct_full:.1f}% of days were lower; {higher:.1f}% higher).")
1139
-
1140
  means = hmm.means_.ravel()
1141
  if hmm.covariance_type == "full":
1142
  stds = np.sqrt(np.array([c[0,0] for c in hmm.covars_]))
@@ -1154,17 +1131,16 @@ if run_analysis:
1154
  st.write("States have moderate overlap. Expect some flips.")
1155
  else:
1156
  st.write("States overlap a lot. Treat labels with care.")
1157
-
1158
  if hasattr(hmm, "monitor_"):
1159
  conv = hmm.monitor_.converged
1160
  n_iter = hmm.monitor_.iter
1161
  if not conv:
1162
  st.write(f"Training did not fully converge in {n_iter} iterations. Use caution.")
1163
-
1164
- # Section 5: Carry Spread Analysis
1165
  st.header("Carry Spread Analysis")
1166
  st.write("Analyzes carry spreads between short and long term VIX futures expectations.")
1167
-
1168
  with st.expander("Methodology", expanded=False):
1169
  st.write("""
1170
  This analysis uses monthly VIX futures data. The settle prices are pivoted into a wide format with rows as trade dates and columns as monthly tenors.
@@ -1179,30 +1155,24 @@ if run_analysis:
1179
 
1180
  Positive carry indicates potential roll-down benefits for long positions, while negative carry suggests cost for holding. This helps assess the economic incentive for carrying futures positions across maturities.
1181
  """)
1182
-
1183
- # Code for carry
1184
- # Carry Spreads — match dark style; force white legend/axes text & ticks
1185
  monthly_df_full = df[~df['Weekly']].copy()
1186
  monthly_df_full = monthly_df_full.sort_values('Trade Date')
1187
-
1188
  pivot = (
1189
  monthly_df_full
1190
  .pivot(index='Trade Date', columns='Tenor_Monthly', values='Settle')
1191
  .sort_index()
1192
  )
1193
-
1194
  spreads = pd.DataFrame(index=pivot.index)
1195
  long_legs = [float(l.strip()) for l in carry_long_legs.split(',') if l.strip()]
1196
  for long_leg in long_legs:
1197
  if {carry_short_leg, long_leg}.issubset(pivot.columns):
1198
- label = f'VX{int(long_leg) if long_leg.is_integer() else long_leg}-VX{int(carry_short_leg) if carry_short_leg.is_integer() else carry_short_leg}'
1199
  spreads[label] = pivot[long_leg] - pivot[carry_short_leg]
1200
-
1201
  spreads = spreads.dropna(how='all')
1202
  spreads_long = spreads.reset_index().melt(
1203
  id_vars='Trade Date', value_name='Spread', var_name='Leg'
1204
  )
1205
-
1206
  fig = px.line(
1207
  spreads_long,
1208
  x='Trade Date',
@@ -1210,13 +1180,10 @@ if run_analysis:
1210
  color='Leg',
1211
  title='VIX Carry Spreads (Front ↔ 2nd & 6th Month)',
1212
  markers=True,
1213
- # bright palette that reads well on dark bg
1214
  color_discrete_sequence=px.colors.qualitative.Plotly
1215
  )
1216
-
1217
  fig.update_traces(marker=dict(size=5), line=dict(width=2))
1218
  fig.add_hline(y=0, line_dash='dash', line_color='rgba(255,255,255,0.6)')
1219
-
1220
  fig.update_layout(
1221
  xaxis_title='Trade Date',
1222
  yaxis_title='Spread (points)',
@@ -1232,8 +1199,6 @@ if run_analysis:
1232
  ),
1233
  margin=dict(l=60, r=20, t=60, b=40)
1234
  )
1235
-
1236
- # axes text + ticks in white (and subtle grids)
1237
  fig.update_xaxes(
1238
  title_font=dict(color="white"),
1239
  tickfont=dict(color="white"),
@@ -1252,10 +1217,8 @@ if run_analysis:
1252
  linecolor="rgba(255,255,255,0.15)",
1253
  ticks="outside"
1254
  )
1255
-
1256
  st.plotly_chart(fig, use_container_width=True)
1257
 
1258
-
1259
  with st.expander("Dynamic Interpretation", expanded=False):
1260
  if spreads.empty:
1261
  st.write("No spreads could be computed because required tenors are missing in the dataset.")
@@ -1263,47 +1226,41 @@ if run_analysis:
1263
  latest = spreads.iloc[-1]
1264
  date = spreads.index[-1].date()
1265
  st.write(f"Latest trade date in sample: {date}")
1266
-
1267
  for col in spreads.columns:
1268
  series = spreads[col].dropna()
1269
  if series.empty:
1270
  continue
1271
-
1272
  val = latest[col]
1273
  mean = series.mean()
1274
  pct = (series.rank(pct=True).iloc[-1] * 100).round(1)
1275
-
1276
  st.write(f"\nSpread: {col}")
1277
  st.write(f" Current value: {val:.2f} points")
1278
  st.write(f" Historical mean: {mean:.2f} points")
1279
  st.write(f" Current percentile vs history: {pct}%")
1280
-
1281
  if val > 0:
1282
  st.write(" Interpretation: Futures curve is in CONTANGO for this leg "
1283
- f"(longer maturity higher than front).")
1284
  elif val < 0:
1285
  st.write(" Interpretation: Futures curve is in BACKWARDATION for this leg "
1286
- f"(front contract richer than longer maturity).")
1287
  else:
1288
  st.write(" Interpretation: Spread is flat, indicating balance between front and further contracts.")
1289
-
1290
  if val > mean:
1291
  st.write(" Compared to history: Current spread is ABOVE average, "
1292
- "suggesting stronger than typical contango/backwardation.")
1293
  elif val < mean:
1294
  st.write(" Compared to history: Current spread is BELOW average, "
1295
- "suggesting weaker structure than typical.")
1296
  else:
1297
  st.write(" Compared to history: Current spread is close to historical mean.")
1298
-
1299
  st.write("\nNote: Percentiles show how extreme today’s spread is compared to the full sample. "
1300
- "For example, a 90% percentile means the spread is higher than 90% of past values, "
1301
- "indicating an unusually strong curve slope.")
1302
-
1303
- # Section 6: PCA Decomposition of the Curve
1304
  st.header("PCA Decomposition of the Curve")
1305
  st.write("Decomposes the VIX curve into principal components like level, slope, and curvature.")
1306
-
1307
  with st.expander("Methodology", expanded=False):
1308
  st.write("""
1309
  This analysis uses monthly VIX futures, pivoting settle prices by trade date and user-specified tenors (default first 6 months). Rows with missing values are dropped.
@@ -1336,32 +1293,25 @@ if run_analysis:
1336
  PCA reduces dimensionality, capturing main modes of variation in the term structure: level (overall volatility), slope (carry/roll), curvature (mid-term premiums).
1337
  """)
1338
 
1339
-
1340
- # Code for PCA
1341
- # PCA — match dark style; force white legend/axes text & ticks
1342
  pca_df = df[~df['Weekly']].copy()
1343
-
1344
  pivot = (
1345
  pca_df
1346
  .pivot(index='Trade Date', columns='Tenor_Monthly', values='Settle')
1347
  .sort_index()
1348
  )
1349
-
1350
  tenors_list = [float(t.strip()) for t in pca_tenors.split(',') if t.strip()]
1351
  wide = pivot[tenors_list].dropna()
1352
 
1353
- X = StandardScaler().fit_transform(wide.values)
1354
- pca = PCA(n_components=pca_n_components).fit(X)
1355
-
1356
- labels = ['Level (PC1)', 'Slope (PC2)', 'Curvature (PC3)', 'PC4', 'PC5'][:pca_n_components]
1357
 
1358
- pc_scores = pd.DataFrame(
1359
- pca.transform(X),
1360
- index=wide.index,
1361
- columns=labels
1362
- )
1363
 
1364
- # Scores over time
1365
  fig_scores = px.line(
1366
  pc_scores,
1367
  x=pc_scores.index,
@@ -1403,8 +1353,6 @@ if run_analysis:
1403
  linecolor="rgba(255,255,255,0.15)",
1404
  ticks="outside"
1405
  )
1406
-
1407
- # Explained variance
1408
  fig_var = px.bar(
1409
  x=labels,
1410
  y=pca.explained_variance_ratio_,
@@ -1443,14 +1391,12 @@ if run_analysis:
1443
  st.plotly_chart(fig_scores, use_container_width=True)
1444
  st.plotly_chart(fig_var, use_container_width=True)
1445
 
1446
-
1447
  with st.expander("Dynamic Interpretation", expanded=False):
1448
  def pct_rank(series, value):
1449
  s = pd.to_numeric(series, errors="coerce").dropna()
1450
  if s.empty or not np.isfinite(value):
1451
  return np.nan
1452
  return float((s < value).mean() * 100.0)
1453
-
1454
  def band_from_pct(p):
1455
  if pd.isna(p): return "n/a"
1456
  if p >= 90: return "extreme high (top 10%)"
@@ -1458,48 +1404,39 @@ if run_analysis:
1458
  if p <= 10: return "extreme low (bottom 10%)"
1459
  if p <= 25: return "low (bottom quartile)"
1460
  return "middle range"
1461
-
1462
  def delta_tag(x, pos, neg, neutral="unchanged"):
1463
  if pd.isna(x): return neutral
1464
  if x > 0: return pos
1465
  if x < 0: return neg
1466
  return neutral
1467
-
1468
  st.write("\n— PCA components and what they mean —")
1469
  st.write("PC1: Level. Parallel moves of the whole curve. High means futures are broadly high. Low means broadly low.")
1470
  st.write("PC2: Slope. Steepness front to back. Positive means contango (back > front). Negative means backwardation (front > back).")
1471
  st.write("PC3: Curvature. Shape in the middle. Positive means a hump in mid tenors. Negative means a dip in mid tenors.")
1472
-
1473
  var_share = pca.explained_variance_ratio_
1474
  total_var = var_share.sum()
1475
  st.write("\n— Variance explained —")
1476
  for i, v in enumerate(var_share):
1477
  st.write(f"PC{i+1} accounts for {v*100:.1f}% of curve changes.")
1478
  st.write(f"Together they cover {total_var*100:.1f}% of the variation. The rest is noise or higher order shape.")
1479
-
1480
  latest_date = pc_scores.index[-1].date()
1481
  row = pc_scores.iloc[-1]
1482
  lvl = float(row[labels[0]]) if len(labels) > 0 else np.nan
1483
  slp = float(row[labels[1]]) if len(labels) > 1 else np.nan
1484
  cur = float(row[labels[2]]) if len(labels) > 2 else np.nan
1485
-
1486
  lvl_pct = pct_rank(pc_scores[labels[0]], lvl) if len(labels) > 0 else np.nan
1487
  slp_pct = pct_rank(pc_scores[labels[1]], slp) if len(labels) > 1 else np.nan
1488
  cur_pct = pct_rank(pc_scores[labels[2]], cur) if len(labels) > 2 else np.nan
1489
-
1490
  lvl_band = band_from_pct(lvl_pct)
1491
  slp_band = band_from_pct(slp_pct)
1492
  cur_band = band_from_pct(cur_pct)
1493
-
1494
  lvl_d5 = pc_scores[labels[0]].diff(5).iloc[-1] if len(labels) > 0 else np.nan
1495
  slp_d5 = pc_scores[labels[1]].diff(5).iloc[-1] if len(labels) > 1 else np.nan
1496
  cur_d5 = pc_scores[labels[2]].diff(5).iloc[-1] if len(labels) > 2 else np.nan
1497
  lvl_d20 = pc_scores[labels[0]].diff(20).iloc[-1] if len(labels) > 0 else np.nan
1498
  slp_d20 = pc_scores[labels[1]].diff(20).iloc[-1] if len(labels) > 1 else np.nan
1499
  cur_d20 = pc_scores[labels[2]].diff(20).iloc[-1] if len(labels) > 2 else np.nan
1500
-
1501
  st.write(f"\n— Latest observation: {latest_date} —")
1502
-
1503
  if len(labels) > 0:
1504
  st.write("\nLevel (PC1):")
1505
  st.write(f"Position vs history: {lvl_band}. This gauges the overall price of variance along the strip.")
@@ -1509,12 +1446,10 @@ if run_analysis:
1509
  st.write("Implication: options and variance products tend to be cheap across expiries.")
1510
  else:
1511
  st.write("Implication: overall level is near its long-run zone.")
1512
-
1513
  st.write(f"Recent move: {delta_tag(lvl_d5,'up over 1 week','down over 1 week')}; "
1514
- f"{delta_tag(lvl_d20,'up over 1 month','down over 1 month')}.")
1515
  st.write("Use case: compare with slope. High level with negative slope often marks stress. "
1516
- "High level with positive slope often marks calm but pricey carry.")
1517
-
1518
  if len(labels) > 1:
1519
  st.write("\nSlope (PC2):")
1520
  st.write(f"Position vs history: {slp_band}. This is the carry signal.")
@@ -1524,11 +1459,9 @@ if run_analysis:
1524
  st.write("Implication: backwardation or near inversion. Hedging demand is high. Carry is hostile for short front exposure.")
1525
  else:
1526
  st.write("Implication: slope is near normal. Carry is modest.")
1527
-
1528
  st.write(f"Recent move: {delta_tag(slp_d5,'steepening over 1 week','flattening over 1 week')}; "
1529
- f"{delta_tag(slp_d20,'steepening over 1 month','flattening over 1 month')}.")
1530
  st.write("Risk note: fast drops in slope from a high zone often precede drawdowns in carry trades.")
1531
-
1532
  if len(labels) > 2:
1533
  st.write("\nCurvature (PC3):")
1534
  st.write(f"Position vs history: {cur_band}. This shows where risk concentrates on the term structure.")
@@ -1538,18 +1471,15 @@ if run_analysis:
1538
  st.write("Implication: mid tenors are discounted vs the ends. Risk focus sits in very short or long expiries.")
1539
  else:
1540
  st.write("Implication: shape is ordinary. No special mid-curve premium or discount.")
1541
-
1542
  st.write(f"Recent move: {delta_tag(cur_d5,'higher over 1 week','lower over 1 week')}; "
1543
- f"{delta_tag(cur_d20,'higher over 1 month','lower over 1 month')}.")
1544
  st.write("Use case: aligns hedges to the horizon that the market prices most.")
1545
-
1546
  st.write("\n— Joint reading and practical takeaways —")
1547
  if len(labels) > 1:
1548
  calm_contango = (("high" in slp_band or "extreme high" in slp_band) and "middle" in lvl_band)
1549
  expensive_calm = (("high" in slp_band or "extreme high" in slp_band) and ("high" in lvl_band or "extreme high" in lvl_band))
1550
  stress_state = (("low" in slp_band or "extreme low" in slp_band) and ("high" in lvl_band or "extreme high" in lvl_band))
1551
  flat_transition = ("middle" in slp_band and "middle" in lvl_band)
1552
-
1553
  if stress_state:
1554
  st.write("Stress signal: high level with backwardation. Hedging flows dominate. Carry is negative at the front.")
1555
  elif expensive_calm:
@@ -1560,13 +1490,11 @@ if run_analysis:
1560
  st.write("Transition zone: level and slope near normal. Wait for a break in slope momentum.")
1561
  else:
1562
  st.write("Mixed signals: cross-currents across level and slope. Reduce leverage and watch slope momentum.")
1563
-
1564
  if len(labels) > 2:
1565
  if "high" in cur_band or "extreme high" in cur_band:
1566
  st.write("Horizon bias: risk priced in mid tenors. Size hedges in the 2–4 month area.")
1567
  elif "low" in cur_band or "extreme low" in cur_band:
1568
  st.write("Horizon bias: risk priced at the tails. Favor very short or long expiries for hedges.")
1569
-
1570
  warn = []
1571
  if ("high" in slp_band or "extreme high" in slp_band) and slp_d5 < 0:
1572
  warn.append("slope is rolling over from a high zone")
@@ -1576,7 +1504,6 @@ if run_analysis:
1576
  warn.append("level keeps rising; shock risk remains")
1577
  if warn:
1578
  st.write("Watchlist: " + "; ".join(warn) + ".")
1579
-
1580
  st.write("\n— Recap —")
1581
  if len(labels) > 0:
1582
  if "high" in lvl_band or "extreme high" in lvl_band:
@@ -1585,7 +1512,6 @@ if run_analysis:
1585
  st.write("The whole curve is cheap. Protection costs less than usual.")
1586
  else:
1587
  st.write("The whole curve is fairly priced vs its own history.")
1588
-
1589
  if len(labels) > 1:
1590
  if "high" in slp_band or "extreme high" in slp_band:
1591
  st.write("Carry is supportive right now. It helps short front exposure, unless a shock hits.")
@@ -1593,7 +1519,6 @@ if run_analysis:
1593
  st.write("Carry is hostile right now. It punishes short front exposure.")
1594
  else:
1595
  st.write("Carry is modest. No strong tilt from slope.")
1596
-
1597
  if len(labels) > 2:
1598
  if "high" in cur_band or "extreme high" in cur_band:
1599
  st.write("Risk is concentrated in the middle of the term structure.")
@@ -1601,13 +1526,12 @@ if run_analysis:
1601
  st.write("Risk is concentrated at the very short or very long end.")
1602
  else:
1603
  st.write("Risk is spread evenly across the curve.")
1604
-
1605
  st.write("These readings are in-sample. Use them as context, not a forecast.")
1606
-
1607
- # Section 7: Constant-Maturity 30-Day Futures Index
1608
  st.header("Constant-Maturity 30-Day Futures Index")
1609
  st.write("Constructs an unlevered index simulating constant 30-day maturity VIX futures exposure.")
1610
-
1611
  with st.expander("Methodology", expanded=False):
1612
  st.write("""
1613
  This constructs a synthetic constant-maturity VIX futures price by interpolating between the nearest contracts bracketing the target maturity (default 30 days).
@@ -1634,9 +1558,7 @@ if run_analysis:
1634
 
1635
  This index proxies the performance of continuously rolling to maintain constant exposure to 30-day volatility, capturing roll yield and spot moves without leverage.
1636
  """)
1637
-
1638
- # Code for CM
1639
- # Constant-Maturity 30-Day Index — match dark style; force white axes/ticks
1640
  roll_df = df.copy()
1641
  roll_df = roll_df[roll_df['Settle'] > 0]
1642
  roll_df = roll_df.sort_values(['Trade Date', 'Tenor_Days'])
@@ -1645,10 +1567,8 @@ if run_analysis:
1645
  for trade_date, g in roll_df.groupby('Trade Date'):
1646
  lo = g[g['Tenor_Days'] <= cm_target].tail(1)
1647
  hi = g[g['Tenor_Days'] >= cm_target].head(1)
1648
-
1649
  if lo.empty and hi.empty:
1650
  continue
1651
-
1652
  if hi.empty or lo.empty:
1653
  blend = (hi if not hi.empty else lo)['Settle'].iloc[0]
1654
  else:
@@ -1659,7 +1579,6 @@ if run_analysis:
1659
  else:
1660
  w2 = (cm_target - d1) / (d2 - d1)
1661
  blend = p1 + w2 * (p2 - p1)
1662
-
1663
  if blend > 0:
1664
  records.append({'Trade Date': trade_date, 'Blend': blend})
1665
 
@@ -1679,7 +1598,6 @@ if run_analysis:
1679
  )
1680
  fig.update_traces(line=dict(width=2))
1681
  fig.add_hline(y=cm_start, line_dash='dash', line_color='rgba(255,255,255,0.6)')
1682
-
1683
  fig.update_layout(
1684
  xaxis_title='Trade Date',
1685
  yaxis_title='Index level',
@@ -1690,8 +1608,6 @@ if run_analysis:
1690
  showlegend=False,
1691
  margin=dict(l=60, r=20, t=60, b=40)
1692
  )
1693
-
1694
- # axes text + ticks in white (and subtle grids)
1695
  fig.update_xaxes(
1696
  title_font=dict(color="white"),
1697
  tickfont=dict(color="white"),
@@ -1710,10 +1626,8 @@ if run_analysis:
1710
  linecolor="rgba(255,255,255,0.15)",
1711
  ticks="outside"
1712
  )
1713
-
1714
  st.plotly_chart(fig, use_container_width=True)
1715
 
1716
-
1717
  with st.expander("Dynamic Interpretation", expanded=False):
1718
  if idx.empty:
1719
  st.write("No observations available for interpretation.")
@@ -1721,13 +1635,11 @@ if run_analysis:
1721
  ts = idx.copy().reset_index(drop=True)
1722
  ts['Trade Date'] = pd.to_datetime(ts['Trade Date'])
1723
  ts = ts.sort_values('Trade Date')
1724
-
1725
  def pct_rank(series, value):
1726
  s = pd.to_numeric(series, errors="coerce").dropna()
1727
  if s.empty or not np.isfinite(value):
1728
  return np.nan
1729
  return float((s < value).mean() * 100.0)
1730
-
1731
  def streak_updown(x):
1732
  s = np.sign(x.fillna(0).to_numpy())
1733
  streak = 0
@@ -1736,31 +1648,25 @@ if run_analysis:
1736
  elif v < 0: streak = streak - 1 if streak <= 0 else -1
1737
  else: break
1738
  return streak
1739
-
1740
  for w in (5, 20, 60, 120):
1741
  mp = min(3, w)
1742
  ts[f'Ret_MA_{w}'] = ts['Return'].rolling(w, min_periods=mp).mean()
1743
  ts[f'Ret_STD_{w}'] = ts['Return'].rolling(w, min_periods=mp).std(ddof=0)
1744
-
1745
  ts['Vol20'] = ts['Ret_STD_20'] * np.sqrt(252)
1746
  ts['Vol60'] = ts['Ret_STD_60'] * np.sqrt(252)
1747
  ts['Vol120'] = ts['Ret_STD_120'] * np.sqrt(252)
1748
-
1749
  for w in (20, 60, 120, 252):
1750
  mp = min(5, w)
1751
  ts[f'Idx_MA_{w}'] = ts['Index'].rolling(w, min_periods=mp).mean()
1752
-
1753
  for w in (60, 120, 252):
1754
  mu = ts['Blend'].rolling(w, min_periods=min(20, w)).mean()
1755
  sd = ts['Blend'].rolling(w, min_periods=min(20, w)).std(ddof=0)
1756
  ts[f'Blend_Z_{w}'] = np.where(sd > 0, (ts['Blend'] - mu) / sd, np.nan)
1757
-
1758
  cummax = ts['Index'].cummax()
1759
  ts['Drawdown'] = ts['Index'] / cummax - 1.0
1760
  max_dd = float(ts['Drawdown'].min()) if len(ts) else np.nan
1761
  dd_now = float(ts['Drawdown'].iloc[-1])
1762
  peak_date = ts.loc[ts['Index'].idxmax(), 'Trade Date'].date()
1763
-
1764
  r = ts['Return'].dropna()
1765
  r_mu = r.mean()
1766
  r_sd = r.std(ddof=0)
@@ -1769,7 +1675,6 @@ if run_analysis:
1769
  last_tail = not r.empty and (abs(r.iloc[-1] - r_mu) >= 2*r_sd)
1770
  else:
1771
  tail_2s, last_tail = np.nan, False
1772
-
1773
  last = ts.iloc[-1]
1774
  end_date = last['Trade Date'].date()
1775
  def window_ret(days):
@@ -1779,19 +1684,15 @@ if run_analysis:
1779
  ret_5d = window_ret(5)
1780
  ret_20d = window_ret(20)
1781
  ret_60d = window_ret(60)
1782
-
1783
  updown_streak = streak_updown(ts['Return'])
1784
-
1785
  idx_pct = pct_rank(ts['Index'], last['Index'])
1786
  blend_pct = pct_rank(ts['Blend'], last['Blend'])
1787
-
1788
  def pos(val, ref):
1789
  if pd.isna(ref): return "n/a"
1790
  return "above" if val > ref else ("below" if val < ref else "at")
1791
  st20 = pos(last['Index'], last.get('Idx_MA_20'))
1792
  st60 = pos(last['Index'], last.get('Idx_MA_60'))
1793
  st120 = pos(last['Index'], last.get('Idx_MA_120'))
1794
-
1795
  ma20 = ts['Idx_MA_20']
1796
  ma20_slope = np.nan
1797
  if ma20.notna().sum() >= 5:
@@ -1800,7 +1701,6 @@ if run_analysis:
1800
  if len(y) >= 5:
1801
  b1 = np.polyfit(x, y, 1)[0]
1802
  ma20_slope = float(b1)
1803
-
1804
  ts['Month'] = ts['Trade Date'].dt.to_period('M')
1805
  cur_month = ts['Month'].iloc[-1]
1806
  mtd = ts[ts['Month'] == cur_month]
@@ -1814,10 +1714,8 @@ if run_analysis:
1814
  .dropna()
1815
  )
1816
  med_m = float(by_month['mret'].median()) if not by_month.empty else np.nan
1817
-
1818
  st.write("\n— 30d Constant-Maturity VIX Futures Index: interpretation —")
1819
  st.write(f"Date: {end_date}")
1820
-
1821
  if pd.notna(idx_pct):
1822
  if idx_pct >= 90:
1823
  st.write("The index level sits in the top decile of its history. Vol risk is priced high.")
@@ -1826,7 +1724,6 @@ if run_analysis:
1826
  else:
1827
  zone = "upper" if idx_pct >= 60 else ("lower" if idx_pct <= 40 else "middle")
1828
  st.write(f"The index level is in the {zone} part of its historical range.")
1829
-
1830
  st.write(f"Trend check: index is {st20} the 20d average, {st60} the 60d, {st120} the 120d.")
1831
  if np.isfinite(ma20_slope):
1832
  if ma20_slope > 0:
@@ -1835,11 +1732,9 @@ if run_analysis:
1835
  st.write("Short-term trend is falling. The 20d average is pointing down.")
1836
  else:
1837
  st.write("Short-term trend is flat.")
1838
-
1839
  def fmt_pct(x):
1840
  return "n/a" if pd.isna(x) else f"{x*100:.1f}%"
1841
  st.write(f"Recent performance: 1w {fmt_pct(ret_5d)}, 1m {fmt_pct(ret_20d)}, 3m {fmt_pct(ret_60d)}.")
1842
-
1843
  if pd.notna(max_dd):
1844
  if dd_now < -0.05:
1845
  st.write(f"Current drawdown: {dd_now*100:.1f}%. The index is below its peak from {peak_date}.")
@@ -1848,16 +1743,13 @@ if run_analysis:
1848
  else:
1849
  st.write(f"Modest drawdown: {dd_now*100:.1f}% vs peak on {peak_date}.")
1850
  st.write(f"Worst drawdown in sample: {max_dd*100:.1f}%.")
1851
-
1852
  v20, v60, v120 = last.get('Vol20'), last.get('Vol60'), last.get('Vol120')
1853
  if pd.notna(v20):
1854
  st.write(f"Annualized return volatility: 20d {v20*100:.1f}%, 60d {v60*100:.1f}%, 120d {v120*100:.1f}%.")
1855
-
1856
  if pd.notna(tail_2s):
1857
  st.write(f"Tail frequency: {tail_2s:.1f}% of days move more than 2σ from the mean.")
1858
  if last_tail:
1859
  st.write("Today’s move was a tail event relative to recent history.")
1860
-
1861
  b_pct = blend_pct
1862
  b_z120 = last.get('Blend_Z_120')
1863
  b_z252 = last.get('Blend_Z_252') if 'Blend_Z_252' in ts.columns else np.nan
@@ -1875,14 +1767,12 @@ if run_analysis:
1875
  st.write(f"Relative to the last ~6 months, the 30d blend price is unusually low (z={b_z120:.2f}).")
1876
  else:
1877
  st.write(f"Relative to the last ~6 months, the 30d blend price is normal (z={b_z120:.2f}).")
1878
-
1879
  if updown_streak > 0:
1880
  st.write(f"Up streak: {updown_streak} days of gains.")
1881
  elif updown_streak < 0:
1882
  st.write(f"Down streak: {abs(updown_streak)} days of losses.")
1883
  else:
1884
  st.write("No up/down streak today.")
1885
-
1886
  if pd.notna(mtd_ret):
1887
  st.write(f"Month-to-date return: {mtd_ret*100:.1f}%.")
1888
  if pd.notna(med_m):
@@ -1892,7 +1782,6 @@ if run_analysis:
1892
  st.write("This is below the median month in the sample.")
1893
  else:
1894
  st.write("This is in line with a typical month.")
1895
-
1896
  notes = []
1897
  if pd.notna(v20) and v20 > v60:
1898
  notes.append("short-term volatility is elevated vs medium term")
@@ -1904,7 +1793,6 @@ if run_analysis:
1904
  notes.append("30d blend price is an outlier vs 6m history")
1905
  if notes:
1906
  st.write("Risk notes: " + "; ".join(notes) + ".")
1907
-
1908
  st.write("\n— Recap —")
1909
  if pd.notna(idx_pct):
1910
  loc = "high" if idx_pct >= 60 else ("low" if idx_pct <= 40 else "mid")
@@ -1915,12 +1803,12 @@ if run_analysis:
1915
  if pd.notna(v20):
1916
  st.write(f"Return vol (20d): {v20*100:.1f}%.")
1917
  st.write("Use this as context, not a forecast.")
1918
-
1919
  except Exception as e:
1920
  st.error("An error occurred during analysis. Please check your inputs and try again.")
1921
  st.write(traceback.format_exc())
1922
 
1923
- # Hide default Streamlit style
1924
  st.markdown(
1925
  """
1926
  <style>
 
35
  from datetime import datetime, timedelta
36
  import traceback
37
 
38
+ # ---------- App config ----------
39
  st.set_page_config(layout="wide", page_title="VIX Regime Detection")
 
 
40
  nest_asyncio.apply()
41
+ warnings.filterwarnings("ignore", category=FutureWarning, module="vix_utils")
42
+
43
+ # ---------- Helpers ----------
44
+ @st.cache_data(show_spinner=False)
45
+ def fetch_term_structure_cached():
46
+ """
47
+ Fresh event loop for hosted envs; closed after use to avoid leaks.
48
+ Cached to keep memory + network stable across reruns.
49
+ """
50
+ loop = asyncio.new_event_loop()
51
+ try:
52
+ asyncio.set_event_loop(loop)
53
+ return loop.run_until_complete(async_load_vix_term_structure())
54
+ finally:
55
+ try:
56
+ loop.run_until_complete(loop.shutdown_asyncgens())
57
+ except Exception:
58
+ pass
59
+ loop.close()
60
+
61
 
62
+ def limit_df_rows_for_display(df: pd.DataFrame, n: int) -> pd.DataFrame:
63
+ if len(df) <= n:
64
+ return df
65
+ return df.tail(n)
66
+
67
+
68
+ # ---------- Title ----------
69
  st.title("VIX Regime Detection")
70
  st.write(
71
  "This tool tracks the VIX term structure and identifies regimes: contango, backwardation, or cautious. It reports carry sign, curve slope, and changes over time and shows regime persistence and transition probabilities."
72
  "For more details, see [this article](https://entreprenerdly.com/detecting-vix-term-structure-regimes/)."
73
  )
74
 
75
+ # ---------- Sidebar ----------
76
  with st.sidebar:
77
  st.title("Parameters")
78
 
79
+ # Stability toggle for Spaces (caps heavy charts & samples)
80
+ SAFE_MODE = st.checkbox(
81
+ "Safe mode (limit heavy charts)",
82
+ value=True,
83
+ help="Caps figure sizes & sampling to avoid out-of-memory on hosted environments.",
84
+ )
85
+
86
  # Data Range expander
87
  with st.expander("Data Range", expanded=False):
88
  start_date = st.date_input(
 
209
  # Run button
210
  run_analysis = st.button("Run Analysis")
211
 
212
+
213
+ # ---------- Main ----------
214
  if run_analysis:
215
  with st.spinner("Loading data..."):
216
  try:
217
+ # Fixed end date (kept, though not used further)
218
  end_date = datetime.today() + timedelta(days=1)
219
+
220
+ # Async data load (cached, fresh loop)
221
+ df = fetch_term_structure_cached()
222
+
223
+ # Keep only columns we actually use to cut memory
224
+ keep_cols = [
225
+ "Trade Date", "Expiry", "Tenor_Days", "Tenor_Monthly",
226
+ "Weekly", "Expired", "Settle"
227
+ ]
228
+ df = df[keep_cols].copy()
229
+
230
+ # Parse Trade Date, sort
231
+ df["Trade Date"] = pd.to_datetime(df["Trade Date"])
232
+ df.sort_values(["Trade Date", "Tenor_Days"], inplace=True)
233
+
234
+ # Drop exact duplicates early to avoid bloat
235
+ dup_count = int(df.duplicated(subset=keep_cols).sum())
236
+ if dup_count:
237
+ df = df.drop_duplicates(subset=keep_cols)
238
+ st.warning(f"Removed {dup_count:,} duplicate rows detected in the source feed.")
239
+
240
  # Filter by start date
241
+ df = df[df["Trade Date"] >= pd.to_datetime(start_date)]
242
+
 
243
  if df.empty:
244
  st.error("No data available for the selected date range.")
245
  st.stop()
246
+
247
+ # ---------- Section 1: Raw Dataframe ----------
 
 
 
248
  st.header("VIX Term Structure Dataset")
249
  st.write("The raw VIX term structure data loaded for analysis.")
250
  with st.expander("1. Raw Dataset", expanded=False):
251
+ max_show = 2_000 if SAFE_MODE else 10_000
252
+ st.caption(f"Showing last {min(max_show, len(df)):,} rows (total {len(df):,}). Use download to get all.")
253
+ st.dataframe(limit_df_rows_for_display(df, max_show), use_container_width=True, height=420)
254
+ st.download_button(
255
+ "Download full CSV",
256
+ df.to_csv(index=False).encode(),
257
+ file_name="vix_term_structure.csv",
258
+ mime="text/csv",
259
+ )
260
+
261
+ # ---------- Section 2: Slope Time Series Analysis ----------
262
  st.header("Slope Time Series Across Time")
263
  st.write("Visualizes the VIX term structure slopes over time with regime classifications.")
264
+
265
  with st.expander("Methodology", expanded=False):
266
  st.write("""
267
  This analysis filters the VIX futures data to include only dates from the specified start date onward. For each trade date, the data is grouped, and only groups with at least two tenors are considered. The term structure is sorted by monthly tenor.
 
285
 
286
  This visualization allows investors to observe how the term structure evolves over time and how regimes shift. This gives insights into market sentiment and potential volatility dynamics.
287
  """)
288
+
289
+ df_sub = df.copy()
290
+ df_sub.sort_values("Trade Date", inplace=True)
 
291
 
292
  groups = [
293
+ (dt, grp.sort_values("Tenor_Monthly"))
294
+ for dt, grp in df_sub.groupby("Trade Date")
295
  if len(grp) > 1
296
  ]
297
 
298
+ # Cap the number of slider dates to avoid thousands of traces
299
+ MAX_SLIDER_DATES = 240 if SAFE_MODE else 800
300
+ if len(groups) > MAX_SLIDER_DATES:
301
+ groups = groups[-MAX_SLIDER_DATES:]
302
+
303
  regime_map = {}
304
  for dt, grp in groups:
305
+ slope = grp["Settle"].iloc[-1] - grp["Settle"].iloc[0]
306
  if slope > slope_thr:
307
  regime = "CONTANGO"
308
  elif slope < -slope_thr:
309
  regime = "BACKWARDATION"
310
  else:
311
  regime = "CAUTIOUS"
312
+ regime_map[str(pd.to_datetime(dt).date())] = regime
313
+
314
+ if len(groups) == 0:
315
+ st.info("Not enough data to render the slider plot.")
316
+ else:
317
+ fig = go.Figure()
318
+ dates = []
319
+ for i, (dt, grp) in enumerate(groups):
320
+ date_str = str(pd.to_datetime(dt).date())
321
+ dates.append(date_str)
322
+ fig.add_trace(
323
+ go.Scatter(
324
+ x=grp["Expiry"],
325
+ y=grp["Settle"],
326
+ mode="lines+markers",
327
+ name=date_str,
328
+ visible=(i == len(groups) - 1),
329
+ line=dict(width=2),
330
+ marker=dict(size=6),
331
+ )
332
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
333
 
334
+ steps = []
335
+ for i, d in enumerate(dates):
336
+ title = f"VIX Term Structure — {d} — {regime_map[d]}"
337
+ steps.append({
338
+ "method": "update",
339
+ "args": [
340
+ {"visible": [j == i for j in range(len(dates))]},
341
+ {"title": title},
342
+ ],
343
+ "label": d,
344
+ })
 
 
345
 
346
+ slider = {
347
+ "active": len(dates) - 1,
348
+ "currentvalue": {"prefix": "Trade Date: ", "font": {"size": 14}},
349
+ "pad": {"t": 16, "b": 0},
350
+ "x": 0.0,
351
+ "y": 0.0015,
352
+ "len": 1.0,
353
+ "steps": steps,
354
+ }
355
+
356
+ fig.update_layout(
357
+ sliders=[slider],
358
+ title=f"VIX Term Structure — {dates[-1]} — {regime_map[dates[-1]]}",
359
+ xaxis_title="Futures Expiry",
360
+ yaxis_title="VIX Futures Price",
361
+ height=500,
362
+ margin=dict(l=60, r=20, t=60, b=90),
363
+ template="plotly_dark",
364
+ paper_bgcolor="rgba(0,0,0,1)",
365
+ plot_bgcolor="rgba(0,0,0,1)",
366
+ title_font_color="white",
367
+ font=dict(color="white"),
368
+ )
369
+ fig.update_xaxes(gridcolor="rgba(255,255,255,0.08)")
370
+ fig.update_yaxes(gridcolor="rgba(255,255,255,0.08)")
371
+ st.plotly_chart(fig, use_container_width=True)
372
 
 
 
373
  with st.expander("Dynamic Interpretation", expanded=False):
374
+ # Build daily and interpretations (unchanged logic)
375
  daily_rows = []
376
  grp_map = {pd.to_datetime(dt): g.sort_values('Tenor_Monthly') for dt, g in groups}
 
377
  for dt, grp in grp_map.items():
378
  g = grp.dropna(subset=['Tenor_Monthly', 'Settle']).copy()
379
  settle_by_m = g.groupby('Tenor_Monthly', as_index=True)['Settle'].last()
380
  if settle_by_m.size < 2:
381
  continue
 
382
  first_tenor = settle_by_m.index.min()
383
  last_tenor = settle_by_m.index.max()
384
  front = float(settle_by_m.loc[first_tenor])
 
386
  slope = back - front
387
  curve_width = float(settle_by_m.max() - settle_by_m.min())
388
  n_tenors = int(settle_by_m.size)
 
389
  vx1 = float(settle_by_m.loc[1.0]) if 1.0 in settle_by_m.index else np.nan
390
  vx2 = float(settle_by_m.loc[2.0]) if 2.0 in settle_by_m.index else np.nan
391
  vx6 = float(settle_by_m.loc[6.0]) if 6.0 in settle_by_m.index else np.nan
392
  c12 = (vx2 - vx1) if np.isfinite(vx1) and np.isfinite(vx2) else np.nan
393
  c61 = (vx6 - vx1) if np.isfinite(vx1) and np.isfinite(vx6) else np.nan
 
394
  dstr = str(pd.to_datetime(dt).date())
395
  daily_rows.append({
396
  'Trade Date': pd.to_datetime(dt),
 
403
  'VX6_VX1': c61,
404
  'Regime': regime_map.get(dstr, 'UNKNOWN')
405
  })
 
406
  daily = pd.DataFrame(daily_rows).sort_values('Trade Date').reset_index(drop=True)
 
407
  if not daily.empty:
408
  for w in (5, 20, 60, 120):
409
  mp_mean = min(3, w)
410
  mp_std = min(10, w)
411
  daily[f'Slope_MA_{w}'] = daily['Slope'].rolling(window=w, min_periods=mp_mean).mean()
412
  daily[f'Slope_STD_{w}'] = daily['Slope'].rolling(window=w, min_periods=mp_std).std()
 
413
  daily['Slope_Z_120'] = np.where(
414
  daily['Slope_STD_120'].fillna(0) > 0,
415
  (daily['Slope'] - daily['Slope_MA_120']) / daily['Slope_STD_120'],
416
  np.nan
417
  )
 
418
  def _streak(vals):
419
  out = np.ones(len(vals), dtype=int)
420
  for i in range(1, len(vals)):
421
  out[i] = out[i-1] + 1 if vals[i] == vals[i-1] else 1
422
  return out
 
423
  daily['Regime_Streak'] = _streak(daily['Regime'].to_numpy())
 
424
  def _trend_tag(val, ref):
425
  if pd.isna(ref): return "n/a"
426
  return "above" if val > ref else ("below" if val < ref else "equal")
 
427
  def _trend_word(vs_ma):
428
  if vs_ma == "above": return "steeper"
429
  if vs_ma == "below": return "flatter"
430
  return "unchanged"
 
431
  def _carry_word(x):
432
  if pd.isna(x): return "n/a"
433
  return "positive" if x >= 0 else "negative"
 
434
  def _dominant_regime(comp):
435
  if not comp: return "n/a"
436
  k = max(comp, key=comp.get)
437
  return f"{k.lower()} ({comp[k]:.1f}%)"
 
438
  def _safe_pct_rank(series, value):
439
  s = pd.to_numeric(series, errors='coerce').dropna()
440
  if s.empty or not np.isfinite(value): return np.nan
441
  return float((s < value).mean() * 100.0)
 
442
  def _qbin(series, value, q=(0.1,0.25,0.5,0.75,0.9)):
443
  s = pd.to_numeric(series, errors='coerce').dropna()
444
  if s.empty or not np.isfinite(value): return "n/a"
 
449
  if value <= qs[q[3]]: return f"{int(q[2]*100)}–{int(q[3]*100)}th"
450
  if value <= qs[q[4]]: return f"{int(q[3]*100)}–{int(q[4]*100)}th"
451
  return f">{int(q[4]*100)}th"
 
452
  start, end = daily['Trade Date'].min().date(), daily['Trade Date'].max().date()
453
  days = len(daily)
454
  avg_tenors = daily['NumTenors'].mean()
455
  last = daily.iloc[-1]
 
456
  st.write("— Snapshot —")
457
  st.write(f"Sample {start} to {end} ({days} days).")
458
  st.write(f"Average tenors per day {avg_tenors:.1f}.")
 
460
  st.write(f"Curve width {last['CurveWidth']:.2f} pts across {last['NumTenors']} tenors.")
461
  if not pd.isna(last['VX2_VX1']):
462
  st.write(f"Front carry VX2−VX1 {last['VX2_VX1']:.2f} pts ({_carry_word(last['VX2_VX1'])}).")
463
+ if not pd.isna(last['VX6_VX1']): st.write(f"Term carry VX6−VX1 {last['VX6_VX1']:.2f} pts ({_carry_word(last['VX6_VX1'])}).")
 
 
464
  tag5 = _trend_tag(last['Slope'], last.get('Slope_MA_5'))
465
  tag20 = _trend_tag(last['Slope'], last.get('Slope_MA_20'))
466
  if tag5 != "n/a": st.write(f"Slope is {_trend_word(tag5)} than 5-day average.")
467
  if tag20 != "n/a": st.write(f"Slope is {_trend_word(tag20)} than 20-day average.")
 
468
  z120 = last.get('Slope_Z_120', np.nan)
469
  if not pd.isna(z120):
470
  if z120 >= 2: st.write(f"Slope high vs 120-day history (z={z120:.2f}).")
471
  elif z120 <= -2: st.write(f"Slope low vs 120-day history (z={z120:.2f}).")
472
  else: st.write(f"Slope within 120-day normal (z={z120:.2f}).")
 
473
  arr = pd.to_numeric(daily['Slope'], errors='coerce').dropna().to_numpy()
474
  if arr.size and np.isfinite(last['Slope']):
475
  pct = float((arr < last['Slope']).mean() * 100.0)
476
  st.write(f"Slope at {pct:.1f} percentile of sample.")
 
477
  for window in (30, 90):
478
  sub = daily.tail(window)
479
  if sub.empty: continue
480
  comp = (sub['Regime'].value_counts(normalize=True) * 100).to_dict()
481
  dom = _dominant_regime(comp)
482
  st.write(f"Last {window} days dominant regime {dom}.")
 
483
  streak = int(last['Regime_Streak'])
484
  if len(daily) >= 2:
485
  changed = daily['Regime'].to_numpy() != daily['Regime'].shift(1).to_numpy()
 
489
  st.write(f"Current {last['Regime'].lower()} streak {streak} days since {last_change_day}.")
490
  else:
491
  st.write(f"Current {last['Regime'].lower()} streak {streak} days.")
 
492
  if len(daily) >= 3:
493
  hi = daily.nlargest(1, 'Slope').iloc[0]
494
  lo = daily.nsmallest(1, 'Slope').iloc[0]
495
  st.write(f"Max slope {hi['Slope']:.2f} on {hi['Trade Date'].date()} ({hi['Regime']}).")
496
  st.write(f"Min slope {lo['Slope']:.2f} on {lo['Trade Date'].date()} ({lo['Regime']}).")
 
497
  anoms = daily[daily['Slope_Z_120'].abs() >= 3]
498
  if len(anoms) > 0:
499
  last_a = anoms.iloc[-1]
500
  st.write(f"Recent anomaly {last_a['Trade Date'].date()} (|z120|={abs(last_a['Slope_Z_120']):.2f}).")
 
501
  sparse = daily[daily['NumTenors'] < 3]
502
  if len(sparse) > 0:
503
  st.write(f"{len(sparse)} sparse days (<3 tenors). Treat slopes carefully.")
 
504
  st.write("— History context —")
505
  today = last['Trade Date'].date()
506
  reg = last['Regime']
 
507
  slope_pct = _safe_pct_rank(daily['Slope'], last['Slope'])
508
  width_pct = _safe_pct_rank(daily['CurveWidth'], last['CurveWidth'])
509
  c12_pct = _safe_pct_rank(daily['VX2_VX1'], last['VX2_VX1']) if pd.notna(last['VX2_VX1']) else np.nan
 
512
  if pd.notna(width_pct): st.write(f"{today}: width percentile vs sample {width_pct:.1f}%.")
513
  if pd.notna(c12_pct): st.write(f"{today}: VX2−VX1 percentile vs sample {c12_pct:.1f}%.")
514
  if pd.notna(c61_pct): st.write(f"{today}: VX6−VX1 percentile vs sample {c61_pct:.1f}%.")
 
515
  sub_reg = daily[daily['Regime'] == reg]
516
  if not sub_reg.empty:
517
  slope_reg_pct = _safe_pct_rank(sub_reg['Slope'], last['Slope'])
 
523
  st.write(f"{today}: slope vs {reg.lower()} median {slope_diff:+.2f} pts (median {slope_med:.2f}).")
524
  else:
525
  st.write(f"{today}: no history for regime {reg}.")
 
526
  spells = []
527
  start_idx = 0
528
  vals = daily['Regime'].to_numpy()
 
533
  spells.append({'Regime': r, 'Length': length, 'EndIndex': i-1})
534
  start_idx = i
535
  spells = pd.DataFrame(spells)
 
536
  if not spells.empty:
537
  cur_len = int(spells.iloc[-1]['Length'])
538
  reg_spells = spells[spells['Regime'] == reg]['Length']
 
544
  st.write(f"{today}: spell is {tag} than mean ({mean_len:.1f} days).")
545
  if pd.notna(p75_len):
546
  st.write(f"{today}: spell {'≥' if cur_len >= p75_len else '<'} 75th percentile ({p75_len:.0f} days).")
 
547
  trans = (
548
  daily[['Regime']]
549
  .assign(Prev=lambda x: x['Regime'].shift(1))
 
559
  if not stay_row.empty:
560
  p_stay = float(stay_row['Prob'].iloc[0])
561
  st.write(f"{today}: one-day stay probability in {reg.lower()} {p_stay:.2f}.")
 
562
  daily['Month'] = daily['Trade Date'].dt.to_period('M')
563
  cur_month = daily['Month'].iloc[-1]
564
  mtd = daily[daily['Month'] == cur_month]
 
576
  if pd.notna(typical):
577
  comp = "above" if mtd_changes > typical else ("below" if mtd_changes < typical else "in line")
578
  st.write(f"{today}: MTD regime churn {comp} median month ({typical:.0f}).")
 
579
  moy = mtd['Trade Date'].dt.month.iloc[-1]
580
  same_moy = daily[daily['Trade Date'].dt.month == moy]['Slope']
581
  if not same_moy.dropna().empty:
582
  moy_pct = _safe_pct_rank(same_moy, last['Slope'])
583
  st.write(f"{today}: slope percentile vs historical {pd.Timestamp(today).strftime('%B')} {moy_pct:.1f}%.")
 
584
  slope_bin = _qbin(daily['Slope'], last['Slope'])
585
  width_bin = _qbin(daily['CurveWidth'], last['CurveWidth'])
586
  st.write(f"{today}: slope bin {slope_bin}.")
 
591
  tail_lo = (s_all <= last['Slope']).mean()*100.0
592
  tail = min(tail_hi, tail_lo)
593
  st.write(f"{today}: tail frequency at this slope level {tail:.1f}%.")
 
594
  band = max(0.25, s_all.std()*0.1) if not s_all.empty else 0.25
595
  recent_sim = daily[(daily['Regime'] == reg) &
596
  (daily['Slope'].between(last['Slope']-band, last['Slope']+band))]
 
598
  prev = recent_sim.iloc[-2]['Trade Date'].date()
599
  days_since = (pd.Timestamp(today) - pd.Timestamp(prev)).days
600
  st.write(f"{today}: last similar day was {prev} ({days_since} days ago).")
 
601
  def _stab(row):
602
  c1 = abs(row['Slope'] - row.get('Slope_MA_20', np.nan))
603
  c2 = abs(row.get('Slope_Z_120', np.nan))
 
605
  if np.isfinite(c1): parts.append(1.0 / (1.0 + c1))
606
  if np.isfinite(c2): parts.append(1.0 / (1.0 + c2))
607
  return np.mean(parts) if parts else np.nan
 
608
  daily['Stability'] = daily.apply(_stab, axis=1)
609
  stab_pct = _safe_pct_rank(daily['Stability'], daily['Stability'].iloc[-1])
610
  if pd.notna(stab_pct):
611
  st.write(f"{today}: stability percentile {stab_pct:.1f}% (higher means steadier slope).")
612
+
613
+ # ---------- Section 3: 3D Term-Structure Visualization ----------
614
  st.header("Term-Structure Surface")
615
  st.write("A 3D scatter plot showing the VIX term structure over time with trade date, days to expiration, and settle price.")
616
+
617
  with st.expander("Methodology", expanded=False):
618
  st.write("""
619
  This visualization filters the data to include only monthly (non-weekly) and non-expired VIX futures contracts. Dates with fewer than two tenors are excluded to ensure meaningful term structures.
 
653
 
654
  The Surface helps identify clusters, trends, and anomalies in the term structure surface.
655
  """)
656
+
 
657
  monthly_df = df[(df["Weekly"] == False) & (df["Expired"] == False)].copy()
658
  valid_dates = monthly_df['Trade Date'].value_counts()
659
+ valid_dates = valid_dates[valid_dates > 1].index
660
  monthly_df_filtered = monthly_df[monthly_df['Trade Date'].isin(valid_dates)].copy()
661
 
662
+ # Downsample for hosted stability
663
+ if SAFE_MODE:
664
+ cutoff = pd.Timestamp.today().normalize() - pd.DateOffset(months=24)
665
+ monthly_df_filtered = monthly_df_filtered[monthly_df_filtered["Trade Date"] >= cutoff]
666
+ unique_dates = monthly_df_filtered["Trade Date"].drop_duplicates().sort_values()
667
+ step = max(1, len(unique_dates) // 150) if len(unique_dates) else 1
668
+ keep_dates = set(unique_dates.iloc[::step])
669
+ monthly_df_filtered = monthly_df_filtered[monthly_df_filtered["Trade Date"].isin(keep_dates)]
670
+
671
+ MAX_ROWS_3D = 15_000 if SAFE_MODE else 50_000
672
+ if len(monthly_df_filtered) == 0:
673
+ st.info("Not enough data for the 3D surface after filtering.")
674
+ elif len(monthly_df_filtered) > MAX_ROWS_3D:
675
+ st.warning("3D surface skipped to protect memory (too many points). Disable Safe mode or narrow the date range.")
676
+ else:
677
+ cmin = float(monthly_df_filtered["Settle"].min())
678
+ cmax = float(monthly_df_filtered["Settle"].max())
679
+ fig = px.scatter_3d(
680
+ monthly_df_filtered,
681
+ x="Trade Date",
682
+ y="Tenor_Days",
683
+ z="Settle",
684
+ color="Settle",
685
+ color_continuous_scale="Viridis",
686
+ range_color=(cmin, cmax),
687
+ title="VIX Futures Term Structure over Time"
688
+ )
689
+ fig.update_traces(
690
+ marker=dict(size=2 if SAFE_MODE else 3, opacity=0.9,
691
+ colorbar=dict(
692
+ title="Settle",
693
+ thickness=12,
694
+ tickcolor="white",
695
+ titlefont=dict(color="white"),
696
+ tickfont=dict(color="white")
697
+ ))
698
+ )
699
+ fig.update_layout(
700
+ scene=dict(
701
+ xaxis_title='Trade Date',
702
+ yaxis_title='Days to Expiration',
703
+ zaxis_title='Settle Price',
704
+ xaxis=dict(gridcolor="rgba(255,255,255,0.08)", tickcolor="white"),
705
+ yaxis=dict(gridcolor="rgba(255,255,255,0.08)", tickcolor="white"),
706
+ zaxis=dict(gridcolor="rgba(255,255,255,0.08)", tickcolor="white"),
707
+ bgcolor="rgba(0,0,0,1)"
708
+ ),
709
+ template="plotly_dark",
710
+ paper_bgcolor="rgba(0,0,0,1)",
711
+ plot_bgcolor="rgba(0,0,0,1)",
712
+ title_font_color="white",
713
+ font=dict(color="white"),
714
+ margin=dict(l=0, r=0, t=60, b=0)
715
+ )
716
+ st.plotly_chart(fig, use_container_width=True)
717
+
718
+ with st.expander("Dynamic Interpretation", expanded=False):
719
+ grouped = monthly_df_filtered.groupby("Trade Date")
720
+ rows = []
721
+ for dt, g in grouped:
722
+ g = g.dropna(subset=["Tenor_Days", "Settle"]).sort_values("Tenor_Days")
723
+ if g["Tenor_Days"].nunique() < 2:
724
+ continue
725
+ level = float(g["Settle"].mean())
726
+ width = float(g["Settle"].max() - g["Settle"].min())
727
+ front = float(g.iloc[0]["Settle"])
728
+ back = float(g.iloc[-1]["Settle"])
729
+ nten = int(g["Tenor_Days"].nunique())
730
+ x = g["Tenor_Days"].to_numpy(dtype=float)
731
+ y = g["Settle"].to_numpy(dtype=float)
732
+ lin = np.polyfit(x, y, 1)
733
+ slope = float(lin[0])
734
+ curv = np.nan
735
+ if nten >= 3:
736
+ quad = np.polyfit(x, y, 2)
737
+ curv = float(quad[0])
738
+ by_m = (g.dropna(subset=["Tenor_Monthly"])
739
+ .drop_duplicates("Tenor_Monthly")
740
+ .set_index("Tenor_Monthly")["Settle"])
741
+ vx1 = float(by_m.loc[1.0]) if 1.0 in by_m.index else np.nan
742
+ vx2 = float(by_m.loc[2.0]) if 2.0 in by_m.index else np.nan
743
+ vx6 = float(by_m.loc[6.0]) if 6.0 in by_m.index else np.nan
744
+ c12 = (vx2 - vx1) if np.isfinite(vx1) and np.isfinite(vx2) else np.nan
745
+ c61 = (vx6 - vx1) if np.isfinite(vx1) and np.isfinite(vx6) else np.nan
746
+ rows.append({
747
+ "Trade Date": pd.to_datetime(dt),
748
+ "Level": level,
749
+ "Width": width,
750
+ "Front": front,
751
+ "Back": back,
752
+ "Slope_pd": slope,
753
+ "Curvature": curv,
754
+ "NumTenors": nten,
755
+ "VX2_VX1": c12,
756
+ "VX6_VX1": c61
757
+ })
758
+ surf = pd.DataFrame(rows).sort_values("Trade Date").reset_index(drop=True)
759
+ if not surf.empty:
760
+ for w in (5, 20, 60, 120):
761
+ mp_mean = min(3, w)
762
+ mp_std = min(10, w)
763
+ surf[f"SlopeMA_{w}"] = surf["Slope_pd"].rolling(w, min_periods=mp_mean).mean()
764
+ surf[f"SlopeSTD_{w}"] = surf["Slope_pd"].rolling(w, min_periods=mp_std).std()
765
+ surf[f"LevelMA_{w}"] = surf["Level"].rolling(w, min_periods=mp_mean).mean()
766
+ surf["SlopeZ_120"] = np.where(
767
+ surf["SlopeSTD_120"].fillna(0) > 0,
768
+ (surf["Slope_pd"] - surf["SlopeMA_120"]) / surf["SlopeSTD_120"],
769
+ np.nan
770
+ )
771
+ def pct_rank(series, value):
772
+ s = pd.to_numeric(series, errors="coerce").dropna()
773
+ if s.empty or not np.isfinite(value):
774
+ return np.nan
775
+ return float((s < value).mean() * 100.0)
776
+ def explain_percentile(label, pct):
777
+ if pd.isna(pct):
778
+ st.write(f"{label}: n/a. Not enough history.")
779
+ else:
780
+ higher = 100.0 - pct
781
+ st.write(f"{label}: {pct:.1f}% of days were lower. {higher:.1f}% were higher.")
782
+ def trend_tag(val, ref):
783
+ if pd.isna(ref): return "n/a"
784
+ return "above" if val > ref else ("below" if val < ref else "equal")
785
+ def carry_word(x):
786
+ if pd.isna(x): return "n/a"
787
+ return "positive" if x >= 0 else "negative"
788
+ last = surf.iloc[-1]
789
+ start, end = surf["Trade Date"].min().date(), surf["Trade Date"].max().date()
790
+ st.write(" Term-structure surface snapshot ")
791
+ st.write(f"Sample {start} to {end} ({len(surf)} days).")
792
+ st.write(f"Level {last['Level']:.2f}. Width {last['Width']:.2f}.")
793
+ st.write(f"Slope {last['Slope_pd']:.4f} pts/day. Curvature {last['Curvature']:.6f}.")
794
+ if not pd.isna(last["VX2_VX1"]):
795
+ st.write(f"Front carry VX2−VX1 {last['VX2_VX1']:.2f} ({carry_word(last['VX2_VX1'])}).")
796
+ if not pd.isna(last["VX6_VX1"]):
797
+ st.write(f"Term carry VX6−VX1 {last['VX6_VX1']:.2f} ({carry_word(last['VX6_VX1'])}).")
798
+ t5 = trend_tag(last["Slope_pd"], last.get("SlopeMA_5"))
799
+ t20 = trend_tag(last["Slope_pd"], last.get("SlopeMA_20"))
800
+ if t5 != "n/a": st.write(f"Slope is {t5} the 5-day mean.")
801
+ if t20 != "n/a": st.write(f"Slope is {t20} the 20-day mean.")
802
+ z = last.get("SlopeZ_120", np.nan)
803
+ if pd.notna(z):
804
+ if z >= 2: st.write(f"Slope is high vs 120-day history (z={z:.2f}).")
805
+ elif z <= -2: st.write(f"Slope is low vs 120-day history (z={z:.2f}).")
806
+ else: st.write(f"Slope is within 120-day range (z={z:.2f}).")
807
+ st.write(" How today compares to history —")
808
+ slope_pct = pct_rank(surf["Slope_pd"], last["Slope_pd"])
809
+ width_pct = pct_rank(surf["Width"], last["Width"])
810
+ level_pct = pct_rank(surf["Level"], last["Level"])
811
+ explain_percentile("Slope percentile", slope_pct)
812
+ explain_percentile("Width percentile", width_pct)
813
+ explain_percentile("Level percentile", level_pct)
814
+ if pd.notna(last["VX2_VX1"]):
815
+ c12_pct = pct_rank(surf["VX2_VX1"], last["VX2_VX1"])
816
+ explain_percentile("VX2−VX1 percentile", c12_pct)
817
+ if pd.notna(last["VX6_VX1"]):
818
+ c61_pct = pct_rank(surf["VX6_VX1"], last["VX6_VX1"])
819
+ explain_percentile("VX6−VX1 percentile", c61_pct)
820
+ hi = surf.nlargest(1, "Slope_pd").iloc[0]
821
+ lo = surf.nsmallest(1, "Slope_pd").iloc[0]
822
+ st.write(f"Steepest day {hi['Trade Date'].date()} with {hi['Slope_pd']:.4f} pts/day.")
823
+ st.write(f"Flattest day {lo['Trade Date'].date()} with {lo['Slope_pd']:.4f} pts/day.")
824
+ surf["Month"] = surf["Trade Date"].dt.to_period("M")
825
+ cur_m = surf["Month"].iloc[-1]
826
+ mtd = surf[surf["Month"] == cur_m]
827
+ if not mtd.empty and len(mtd) >= 5:
828
+ mtd_slope_std = float(mtd["Slope_pd"].std())
829
+ mtd_level_std = float(mtd["Level"].std())
830
+ st.write(f"MTD slope std {mtd_slope_std:.4f} pts/day. MTD level std {mtd_level_std:.2f}.")
831
+ sparse = surf[surf["NumTenors"] < 3]
832
+ if len(sparse) > 0:
833
+ st.write(f"{len(sparse)} days have <3 tenors. Interpret slope and curvature carefully.")
834
+
835
+ # ---------- Section 4: HMM Regime Classification ----------
 
 
 
 
 
 
 
 
 
 
 
 
836
  st.header("HMM Regime Classification")
837
  st.write("Classifies VIX regimes using Hidden Markov Model on slope time series.")
838
+
839
  with st.expander("Methodology", expanded=False):
840
  st.write("""
841
  This analysis focuses on monthly VIX futures contracts. For each trade date with at least two tenors, the daily slope is computed as the linear regression coefficient of settle prices against days to expiration:
 
870
  The plot shows slopes over time, colored by regime, with a black line connecting the slopes and a dashed horizontal at 0 for reference.
871
 
872
  """)
873
+
 
874
  base = df[~df['Weekly']].copy()
875
  rows = []
876
  for d, g in base.groupby('Trade Date'):
 
879
  continue
880
  slope = np.polyfit(g['Tenor_Days'], g['Settle'], 1)[0]
881
  rows.append({'Trade Date': d, 'Slope': slope})
 
882
  slope_df = pd.DataFrame(rows).sort_values('Trade Date')
883
 
884
+ # Cap HMM sample size in SAFE_MODE
885
+ MAX_HMM_OBS = 800 if SAFE_MODE else 3000
886
+ if len(slope_df) > MAX_HMM_OBS:
887
+ slope_df = slope_df.tail(MAX_HMM_OBS)
888
+
889
  X = StandardScaler().fit_transform(slope_df[['Slope']])
890
+
891
+ # Trim iterations in safe mode (respect user's input otherwise)
892
+ hmm_iter = min(int(hmm_n_iter), 300) if SAFE_MODE else int(hmm_n_iter)
893
  hmm = GaussianHMM(
894
+ n_components=int(hmm_n_components),
895
  covariance_type='full',
896
+ n_iter=hmm_iter,
897
  random_state=1
898
  ).fit(X)
899
 
900
  hidden = hmm.predict(X)
901
+ state_mean = pd.Series(hmm.means_.flatten(), index=range(hmm.n_components))
 
902
  order = state_mean.sort_values().index
903
+ label_map = {order[i]: ['BACKWARDATION', 'CAUTIOUS', 'CONTANGO'][i] for i in range(min(3, hmm.n_components))}
904
  slope_df['Regime'] = [label_map.get(s, 'UNKNOWN') for s in hidden]
905
 
 
906
  cat_order = ['BACKWARDATION', 'CAUTIOUS', 'CONTANGO', 'UNKNOWN']
907
  color_map = {
908
+ 'BACKWARDATION': '#d62728',
909
+ 'CAUTIOUS': '#7f7f7f',
910
+ 'CONTANGO': '#2ca02c',
911
+ 'UNKNOWN': '#1f77b4'
912
  }
913
 
914
  fig = px.scatter(
 
921
  opacity=0.6,
922
  title='Daily VIX Curve Slope with Regime States (HMM)'
923
  )
 
 
924
  fig.add_trace(
925
  go.Scatter(
926
  x=slope_df['Trade Date'],
927
  y=slope_df['Slope'],
928
  mode='lines',
929
+ line=dict(color='white', width=1),
930
  name='Slope (line)'
931
  )
932
  )
 
933
  fig.add_hline(y=0, line_dash='dash', line_color='rgba(255,255,255,0.6)')
 
934
  fig.update_layout(
935
  xaxis_title='Trade Date',
936
  yaxis_title='Slope (pts / day)',
 
946
  ),
947
  margin=dict(l=60, r=20, t=60, b=40)
948
  )
 
 
949
  fig.update_xaxes(
950
  title_font=dict(color="white"),
951
  tickfont=dict(color="white"),
 
964
  linecolor="rgba(255,255,255,0.15)",
965
  ticks="outside"
966
  )
 
967
  st.plotly_chart(fig, use_container_width=True)
968
+
969
  with st.expander("Dynamic Interpretation", expanded=False):
 
970
  trans = pd.DataFrame(
971
  hmm.transmat_,
972
+ index=[label_map.get(i, f"S{i}") for i in range(hmm.n_components)],
973
+ columns=[label_map.get(i, f"S{i}") for i in range(hmm.n_components)]
974
  )
975
  st.write("\nTransition probabilities\n")
976
  st.dataframe(trans.round(3))
 
977
  def pct_rank(series, value):
978
  s = pd.to_numeric(series, errors="coerce").dropna()
979
  if s.empty or not np.isfinite(value):
980
  return np.nan
981
  return float((s < value).mean() * 100.0)
 
982
  def exp_duration(pii):
983
  if np.isclose(pii, 1.0):
984
  return np.inf
985
  return 1.0 / max(1e-12, (1.0 - pii))
 
986
  def note_regime(name):
987
  if name == "CONTANGO":
988
  return "term structure slopes up. carry tends to be positive."
989
  if name == "BACKWARDATION":
990
  return "term structure slopes down. stress is more likely."
991
  return "term structure is near flat. signals are mixed."
 
992
  def risk_bias_for_transition(src, dst):
993
  if src == "CONTANGO" and dst == "CAUTIOUS":
994
  return "carry tailwind may fade."
 
1003
  if src == "BACKWARDATION" and dst == "CONTANGO":
1004
  return "stress may unwind fast."
1005
  return "no clear tilt."
 
1006
  def entropy_row(p):
1007
  p = np.asarray(p, float)
1008
  p = p[p > 0]
1009
  return -np.sum(p * np.log2(p)) if p.size else np.nan
 
1010
  _, post = hmm.score_samples(X)
1011
  today = slope_df['Trade Date'].iloc[-1].date()
1012
  cur_state = hidden[-1]
1013
+ cur_regime = label_map.get(cur_state, f"S{cur_state}")
1014
+ cur_probs = {label_map.get(i, f"S{i}"): float(post[-1, i]) for i in range(hmm.n_components)}
1015
  cur_prob = cur_probs[cur_regime]
 
1016
  stay_prob = float(trans.loc[cur_regime, cur_regime])
1017
  edur = exp_duration(stay_prob)
 
1018
  st.write("— Interpretation —")
1019
  st.write(f"Date {today}. Model labels today as {cur_regime} (prob {cur_prob:.2f}).")
1020
  st.write(f"This means {note_regime(cur_regime)}")
 
1021
  if cur_prob >= 0.8:
1022
  st.write("Confidence is high. The label is stable.")
1023
  elif cur_prob >= 0.6:
1024
  st.write("Confidence is moderate. Treat it as useful, not certain.")
1025
  else:
1026
  st.write("Confidence is low. Be cautious using this label.")
 
1027
  if stay_prob >= 0.85:
1028
  st.write("Day-to-day persistence is high. Expect the same regime tomorrow.")
1029
  elif stay_prob >= 0.65:
1030
  st.write("Day-to-day persistence is moderate. A hold is slightly more likely.")
1031
  else:
1032
  st.write("Day-to-day persistence is low. A switch is common.")
 
1033
  if np.isinf(edur):
1034
  st.write("Spells in this regime can run very long in this model.")
1035
  elif edur >= 10:
 
1038
  st.write(f"Typical spell length is medium (~{edur:.0f} days).")
1039
  else:
1040
  st.write(f"Typical spell length is short (~{edur:.0f} days).")
 
1041
  streak = 1
1042
  for i in range(len(hidden) - 2, -1, -1):
1043
  if hidden[i] == cur_state:
 
1053
  st.write("Streak is mid to late stage.")
1054
  else:
1055
  st.write("Streak is early stage.")
 
1056
  row_sorted = trans.loc[cur_regime].sort_values(ascending=False)
1057
  exit_target = row_sorted.drop(index=cur_regime).idxmax()
1058
  exit_p = float(row_sorted.drop(index=cur_regime).max())
1059
  back_p = float(trans.loc[exit_target, cur_regime])
1060
  asym = exit_p - back_p
 
1061
  st.write(f"Most likely exit is to {exit_target} at {exit_p:.2f}.")
1062
  st.write(f"If that happens: {risk_bias_for_transition(cur_regime, exit_target)}")
1063
  if abs(asym) >= 0.10:
 
1065
  st.write(f"Flow between {cur_regime} and {exit_target} is {tilt} ({asym:+.2f}).")
1066
  else:
1067
  st.write("Two-way flow between these regimes is roughly balanced.")
 
1068
  h_bits = entropy_row(trans.loc[cur_regime].values)
1069
  if h_bits <= 0.6:
1070
  st.write("Next-state outcomes are concentrated. Path is predictable.")
 
1072
  st.write("Next-state outcomes cluster in a few paths.")
1073
  else:
1074
  st.write("Next-state outcomes are diffuse. Path is uncertain.")
 
1075
  T = trans.values
1076
  name_to_idx = {n:i for i, n in enumerate(trans.index)}
1077
  i0 = name_to_idx[cur_regime]
1078
  def kstep(T, i, k):
1079
  Tk = np.linalg.matrix_power(T, k)
1080
  return pd.Series(Tk[i], index=trans.columns)
 
1081
  d5 = kstep(T, i0, 5)
1082
  p5_stay = float(d5[cur_regime])
1083
  if p5_stay >= 0.60:
 
1086
  st.write("Five-day view: staying is plausible but not dominant.")
1087
  else:
1088
  st.write("Five-day view: a different regime is more likely.")
 
1089
  eigvals, eigvecs = np.linalg.eig(T.T)
1090
  idx = np.argmin(np.abs(eigvals - 1))
1091
  pi = np.real(eigvecs[:, idx]); pi = pi / pi.sum()
 
1101
  st.write("Long-run: regimes are sticky.")
1102
  else:
1103
  st.write("Long-run: regimes churn at a moderate pace.")
 
1104
  cur_slope = float(slope_df['Slope'].iloc[-1])
1105
  pct_full = pct_rank(slope_df['Slope'], cur_slope)
1106
  st.write(f"Current slope is {cur_slope:.4f} pts/day.")
 
1113
  else:
1114
  band = "upper" if pct_full >= 60 else ("lower" if pct_full <= 40 else "middle")
1115
  st.write(f"Slope sits in the {band} part of its range "
1116
+ f"({pct_full:.1f}% of days were lower; {higher:.1f}% higher).")
 
1117
  means = hmm.means_.ravel()
1118
  if hmm.covariance_type == "full":
1119
  stds = np.sqrt(np.array([c[0,0] for c in hmm.covars_]))
 
1131
  st.write("States have moderate overlap. Expect some flips.")
1132
  else:
1133
  st.write("States overlap a lot. Treat labels with care.")
 
1134
  if hasattr(hmm, "monitor_"):
1135
  conv = hmm.monitor_.converged
1136
  n_iter = hmm.monitor_.iter
1137
  if not conv:
1138
  st.write(f"Training did not fully converge in {n_iter} iterations. Use caution.")
1139
+
1140
+ # ---------- Section 5: Carry Spread Analysis ----------
1141
  st.header("Carry Spread Analysis")
1142
  st.write("Analyzes carry spreads between short and long term VIX futures expectations.")
1143
+
1144
  with st.expander("Methodology", expanded=False):
1145
  st.write("""
1146
  This analysis uses monthly VIX futures data. The settle prices are pivoted into a wide format with rows as trade dates and columns as monthly tenors.
 
1155
 
1156
  Positive carry indicates potential roll-down benefits for long positions, while negative carry suggests cost for holding. This helps assess the economic incentive for carrying futures positions across maturities.
1157
  """)
1158
+
 
 
1159
  monthly_df_full = df[~df['Weekly']].copy()
1160
  monthly_df_full = monthly_df_full.sort_values('Trade Date')
 
1161
  pivot = (
1162
  monthly_df_full
1163
  .pivot(index='Trade Date', columns='Tenor_Monthly', values='Settle')
1164
  .sort_index()
1165
  )
 
1166
  spreads = pd.DataFrame(index=pivot.index)
1167
  long_legs = [float(l.strip()) for l in carry_long_legs.split(',') if l.strip()]
1168
  for long_leg in long_legs:
1169
  if {carry_short_leg, long_leg}.issubset(pivot.columns):
1170
+ label = f'VX{int(long_leg) if float(long_leg).is_integer() else long_leg}-VX{int(carry_short_leg) if float(carry_short_leg).is_integer() else carry_short_leg}'
1171
  spreads[label] = pivot[long_leg] - pivot[carry_short_leg]
 
1172
  spreads = spreads.dropna(how='all')
1173
  spreads_long = spreads.reset_index().melt(
1174
  id_vars='Trade Date', value_name='Spread', var_name='Leg'
1175
  )
 
1176
  fig = px.line(
1177
  spreads_long,
1178
  x='Trade Date',
 
1180
  color='Leg',
1181
  title='VIX Carry Spreads (Front ↔ 2nd & 6th Month)',
1182
  markers=True,
 
1183
  color_discrete_sequence=px.colors.qualitative.Plotly
1184
  )
 
1185
  fig.update_traces(marker=dict(size=5), line=dict(width=2))
1186
  fig.add_hline(y=0, line_dash='dash', line_color='rgba(255,255,255,0.6)')
 
1187
  fig.update_layout(
1188
  xaxis_title='Trade Date',
1189
  yaxis_title='Spread (points)',
 
1199
  ),
1200
  margin=dict(l=60, r=20, t=60, b=40)
1201
  )
 
 
1202
  fig.update_xaxes(
1203
  title_font=dict(color="white"),
1204
  tickfont=dict(color="white"),
 
1217
  linecolor="rgba(255,255,255,0.15)",
1218
  ticks="outside"
1219
  )
 
1220
  st.plotly_chart(fig, use_container_width=True)
1221
 
 
1222
  with st.expander("Dynamic Interpretation", expanded=False):
1223
  if spreads.empty:
1224
  st.write("No spreads could be computed because required tenors are missing in the dataset.")
 
1226
  latest = spreads.iloc[-1]
1227
  date = spreads.index[-1].date()
1228
  st.write(f"Latest trade date in sample: {date}")
 
1229
  for col in spreads.columns:
1230
  series = spreads[col].dropna()
1231
  if series.empty:
1232
  continue
 
1233
  val = latest[col]
1234
  mean = series.mean()
1235
  pct = (series.rank(pct=True).iloc[-1] * 100).round(1)
 
1236
  st.write(f"\nSpread: {col}")
1237
  st.write(f" Current value: {val:.2f} points")
1238
  st.write(f" Historical mean: {mean:.2f} points")
1239
  st.write(f" Current percentile vs history: {pct}%")
 
1240
  if val > 0:
1241
  st.write(" Interpretation: Futures curve is in CONTANGO for this leg "
1242
+ f"(longer maturity higher than front).")
1243
  elif val < 0:
1244
  st.write(" Interpretation: Futures curve is in BACKWARDATION for this leg "
1245
+ f"(front contract richer than longer maturity).")
1246
  else:
1247
  st.write(" Interpretation: Spread is flat, indicating balance between front and further contracts.")
 
1248
  if val > mean:
1249
  st.write(" Compared to history: Current spread is ABOVE average, "
1250
+ "suggesting stronger than typical contango/backwardation.")
1251
  elif val < mean:
1252
  st.write(" Compared to history: Current spread is BELOW average, "
1253
+ "suggesting weaker structure than typical.")
1254
  else:
1255
  st.write(" Compared to history: Current spread is close to historical mean.")
 
1256
  st.write("\nNote: Percentiles show how extreme today’s spread is compared to the full sample. "
1257
+ "For example, a 90% percentile means the spread is higher than 90% of past values, "
1258
+ "indicating an unusually strong curve slope.")
1259
+
1260
+ # ---------- Section 6: PCA Decomposition of the Curve ----------
1261
  st.header("PCA Decomposition of the Curve")
1262
  st.write("Decomposes the VIX curve into principal components like level, slope, and curvature.")
1263
+
1264
  with st.expander("Methodology", expanded=False):
1265
  st.write("""
1266
  This analysis uses monthly VIX futures, pivoting settle prices by trade date and user-specified tenors (default first 6 months). Rows with missing values are dropped.
 
1293
  PCA reduces dimensionality, capturing main modes of variation in the term structure: level (overall volatility), slope (carry/roll), curvature (mid-term premiums).
1294
  """)
1295
 
 
 
 
1296
  pca_df = df[~df['Weekly']].copy()
 
1297
  pivot = (
1298
  pca_df
1299
  .pivot(index='Trade Date', columns='Tenor_Monthly', values='Settle')
1300
  .sort_index()
1301
  )
 
1302
  tenors_list = [float(t.strip()) for t in pca_tenors.split(',') if t.strip()]
1303
  wide = pivot[tenors_list].dropna()
1304
 
1305
+ # Cap PCA rows for hosted stability
1306
+ MAX_PCA_ROWS = 1200 if SAFE_MODE else 4000
1307
+ if len(wide) > MAX_PCA_ROWS:
1308
+ wide = wide.tail(MAX_PCA_ROWS)
1309
 
1310
+ X = StandardScaler().fit_transform(wide.values)
1311
+ pca = PCA(n_components=int(pca_n_components)).fit(X)
1312
+ labels = ['Level (PC1)', 'Slope (PC2)', 'Curvature (PC3)', 'PC4', 'PC5'][:int(pca_n_components)]
1313
+ pc_scores = pd.DataFrame(pca.transform(X), index=wide.index, columns=labels)
 
1314
 
 
1315
  fig_scores = px.line(
1316
  pc_scores,
1317
  x=pc_scores.index,
 
1353
  linecolor="rgba(255,255,255,0.15)",
1354
  ticks="outside"
1355
  )
 
 
1356
  fig_var = px.bar(
1357
  x=labels,
1358
  y=pca.explained_variance_ratio_,
 
1391
  st.plotly_chart(fig_scores, use_container_width=True)
1392
  st.plotly_chart(fig_var, use_container_width=True)
1393
 
 
1394
  with st.expander("Dynamic Interpretation", expanded=False):
1395
  def pct_rank(series, value):
1396
  s = pd.to_numeric(series, errors="coerce").dropna()
1397
  if s.empty or not np.isfinite(value):
1398
  return np.nan
1399
  return float((s < value).mean() * 100.0)
 
1400
  def band_from_pct(p):
1401
  if pd.isna(p): return "n/a"
1402
  if p >= 90: return "extreme high (top 10%)"
 
1404
  if p <= 10: return "extreme low (bottom 10%)"
1405
  if p <= 25: return "low (bottom quartile)"
1406
  return "middle range"
 
1407
  def delta_tag(x, pos, neg, neutral="unchanged"):
1408
  if pd.isna(x): return neutral
1409
  if x > 0: return pos
1410
  if x < 0: return neg
1411
  return neutral
 
1412
  st.write("\n— PCA components and what they mean —")
1413
  st.write("PC1: Level. Parallel moves of the whole curve. High means futures are broadly high. Low means broadly low.")
1414
  st.write("PC2: Slope. Steepness front to back. Positive means contango (back > front). Negative means backwardation (front > back).")
1415
  st.write("PC3: Curvature. Shape in the middle. Positive means a hump in mid tenors. Negative means a dip in mid tenors.")
 
1416
  var_share = pca.explained_variance_ratio_
1417
  total_var = var_share.sum()
1418
  st.write("\n— Variance explained —")
1419
  for i, v in enumerate(var_share):
1420
  st.write(f"PC{i+1} accounts for {v*100:.1f}% of curve changes.")
1421
  st.write(f"Together they cover {total_var*100:.1f}% of the variation. The rest is noise or higher order shape.")
 
1422
  latest_date = pc_scores.index[-1].date()
1423
  row = pc_scores.iloc[-1]
1424
  lvl = float(row[labels[0]]) if len(labels) > 0 else np.nan
1425
  slp = float(row[labels[1]]) if len(labels) > 1 else np.nan
1426
  cur = float(row[labels[2]]) if len(labels) > 2 else np.nan
 
1427
  lvl_pct = pct_rank(pc_scores[labels[0]], lvl) if len(labels) > 0 else np.nan
1428
  slp_pct = pct_rank(pc_scores[labels[1]], slp) if len(labels) > 1 else np.nan
1429
  cur_pct = pct_rank(pc_scores[labels[2]], cur) if len(labels) > 2 else np.nan
 
1430
  lvl_band = band_from_pct(lvl_pct)
1431
  slp_band = band_from_pct(slp_pct)
1432
  cur_band = band_from_pct(cur_pct)
 
1433
  lvl_d5 = pc_scores[labels[0]].diff(5).iloc[-1] if len(labels) > 0 else np.nan
1434
  slp_d5 = pc_scores[labels[1]].diff(5).iloc[-1] if len(labels) > 1 else np.nan
1435
  cur_d5 = pc_scores[labels[2]].diff(5).iloc[-1] if len(labels) > 2 else np.nan
1436
  lvl_d20 = pc_scores[labels[0]].diff(20).iloc[-1] if len(labels) > 0 else np.nan
1437
  slp_d20 = pc_scores[labels[1]].diff(20).iloc[-1] if len(labels) > 1 else np.nan
1438
  cur_d20 = pc_scores[labels[2]].diff(20).iloc[-1] if len(labels) > 2 else np.nan
 
1439
  st.write(f"\n— Latest observation: {latest_date} —")
 
1440
  if len(labels) > 0:
1441
  st.write("\nLevel (PC1):")
1442
  st.write(f"Position vs history: {lvl_band}. This gauges the overall price of variance along the strip.")
 
1446
  st.write("Implication: options and variance products tend to be cheap across expiries.")
1447
  else:
1448
  st.write("Implication: overall level is near its long-run zone.")
 
1449
  st.write(f"Recent move: {delta_tag(lvl_d5,'up over 1 week','down over 1 week')}; "
1450
+ f"{delta_tag(lvl_d20,'up over 1 month','down over 1 month')}.")
1451
  st.write("Use case: compare with slope. High level with negative slope often marks stress. "
1452
+ "High level with positive slope often marks calm but pricey carry.")
 
1453
  if len(labels) > 1:
1454
  st.write("\nSlope (PC2):")
1455
  st.write(f"Position vs history: {slp_band}. This is the carry signal.")
 
1459
  st.write("Implication: backwardation or near inversion. Hedging demand is high. Carry is hostile for short front exposure.")
1460
  else:
1461
  st.write("Implication: slope is near normal. Carry is modest.")
 
1462
  st.write(f"Recent move: {delta_tag(slp_d5,'steepening over 1 week','flattening over 1 week')}; "
1463
+ f"{delta_tag(slp_d20,'steepening over 1 month','flattening over 1 month')}.")
1464
  st.write("Risk note: fast drops in slope from a high zone often precede drawdowns in carry trades.")
 
1465
  if len(labels) > 2:
1466
  st.write("\nCurvature (PC3):")
1467
  st.write(f"Position vs history: {cur_band}. This shows where risk concentrates on the term structure.")
 
1471
  st.write("Implication: mid tenors are discounted vs the ends. Risk focus sits in very short or long expiries.")
1472
  else:
1473
  st.write("Implication: shape is ordinary. No special mid-curve premium or discount.")
 
1474
  st.write(f"Recent move: {delta_tag(cur_d5,'higher over 1 week','lower over 1 week')}; "
1475
+ f"{delta_tag(cur_d20,'higher over 1 month','lower over 1 month')}.")
1476
  st.write("Use case: aligns hedges to the horizon that the market prices most.")
 
1477
  st.write("\n— Joint reading and practical takeaways —")
1478
  if len(labels) > 1:
1479
  calm_contango = (("high" in slp_band or "extreme high" in slp_band) and "middle" in lvl_band)
1480
  expensive_calm = (("high" in slp_band or "extreme high" in slp_band) and ("high" in lvl_band or "extreme high" in lvl_band))
1481
  stress_state = (("low" in slp_band or "extreme low" in slp_band) and ("high" in lvl_band or "extreme high" in lvl_band))
1482
  flat_transition = ("middle" in slp_band and "middle" in lvl_band)
 
1483
  if stress_state:
1484
  st.write("Stress signal: high level with backwardation. Hedging flows dominate. Carry is negative at the front.")
1485
  elif expensive_calm:
 
1490
  st.write("Transition zone: level and slope near normal. Wait for a break in slope momentum.")
1491
  else:
1492
  st.write("Mixed signals: cross-currents across level and slope. Reduce leverage and watch slope momentum.")
 
1493
  if len(labels) > 2:
1494
  if "high" in cur_band or "extreme high" in cur_band:
1495
  st.write("Horizon bias: risk priced in mid tenors. Size hedges in the 2–4 month area.")
1496
  elif "low" in cur_band or "extreme low" in cur_band:
1497
  st.write("Horizon bias: risk priced at the tails. Favor very short or long expiries for hedges.")
 
1498
  warn = []
1499
  if ("high" in slp_band or "extreme high" in slp_band) and slp_d5 < 0:
1500
  warn.append("slope is rolling over from a high zone")
 
1504
  warn.append("level keeps rising; shock risk remains")
1505
  if warn:
1506
  st.write("Watchlist: " + "; ".join(warn) + ".")
 
1507
  st.write("\n— Recap —")
1508
  if len(labels) > 0:
1509
  if "high" in lvl_band or "extreme high" in lvl_band:
 
1512
  st.write("The whole curve is cheap. Protection costs less than usual.")
1513
  else:
1514
  st.write("The whole curve is fairly priced vs its own history.")
 
1515
  if len(labels) > 1:
1516
  if "high" in slp_band or "extreme high" in slp_band:
1517
  st.write("Carry is supportive right now. It helps short front exposure, unless a shock hits.")
 
1519
  st.write("Carry is hostile right now. It punishes short front exposure.")
1520
  else:
1521
  st.write("Carry is modest. No strong tilt from slope.")
 
1522
  if len(labels) > 2:
1523
  if "high" in cur_band or "extreme high" in cur_band:
1524
  st.write("Risk is concentrated in the middle of the term structure.")
 
1526
  st.write("Risk is concentrated at the very short or very long end.")
1527
  else:
1528
  st.write("Risk is spread evenly across the curve.")
 
1529
  st.write("These readings are in-sample. Use them as context, not a forecast.")
1530
+
1531
+ # ---------- Section 7: Constant-Maturity 30-Day Futures Index ----------
1532
  st.header("Constant-Maturity 30-Day Futures Index")
1533
  st.write("Constructs an unlevered index simulating constant 30-day maturity VIX futures exposure.")
1534
+
1535
  with st.expander("Methodology", expanded=False):
1536
  st.write("""
1537
  This constructs a synthetic constant-maturity VIX futures price by interpolating between the nearest contracts bracketing the target maturity (default 30 days).
 
1558
 
1559
  This index proxies the performance of continuously rolling to maintain constant exposure to 30-day volatility, capturing roll yield and spot moves without leverage.
1560
  """)
1561
+
 
 
1562
  roll_df = df.copy()
1563
  roll_df = roll_df[roll_df['Settle'] > 0]
1564
  roll_df = roll_df.sort_values(['Trade Date', 'Tenor_Days'])
 
1567
  for trade_date, g in roll_df.groupby('Trade Date'):
1568
  lo = g[g['Tenor_Days'] <= cm_target].tail(1)
1569
  hi = g[g['Tenor_Days'] >= cm_target].head(1)
 
1570
  if lo.empty and hi.empty:
1571
  continue
 
1572
  if hi.empty or lo.empty:
1573
  blend = (hi if not hi.empty else lo)['Settle'].iloc[0]
1574
  else:
 
1579
  else:
1580
  w2 = (cm_target - d1) / (d2 - d1)
1581
  blend = p1 + w2 * (p2 - p1)
 
1582
  if blend > 0:
1583
  records.append({'Trade Date': trade_date, 'Blend': blend})
1584
 
 
1598
  )
1599
  fig.update_traces(line=dict(width=2))
1600
  fig.add_hline(y=cm_start, line_dash='dash', line_color='rgba(255,255,255,0.6)')
 
1601
  fig.update_layout(
1602
  xaxis_title='Trade Date',
1603
  yaxis_title='Index level',
 
1608
  showlegend=False,
1609
  margin=dict(l=60, r=20, t=60, b=40)
1610
  )
 
 
1611
  fig.update_xaxes(
1612
  title_font=dict(color="white"),
1613
  tickfont=dict(color="white"),
 
1626
  linecolor="rgba(255,255,255,0.15)",
1627
  ticks="outside"
1628
  )
 
1629
  st.plotly_chart(fig, use_container_width=True)
1630
 
 
1631
  with st.expander("Dynamic Interpretation", expanded=False):
1632
  if idx.empty:
1633
  st.write("No observations available for interpretation.")
 
1635
  ts = idx.copy().reset_index(drop=True)
1636
  ts['Trade Date'] = pd.to_datetime(ts['Trade Date'])
1637
  ts = ts.sort_values('Trade Date')
 
1638
  def pct_rank(series, value):
1639
  s = pd.to_numeric(series, errors="coerce").dropna()
1640
  if s.empty or not np.isfinite(value):
1641
  return np.nan
1642
  return float((s < value).mean() * 100.0)
 
1643
  def streak_updown(x):
1644
  s = np.sign(x.fillna(0).to_numpy())
1645
  streak = 0
 
1648
  elif v < 0: streak = streak - 1 if streak <= 0 else -1
1649
  else: break
1650
  return streak
 
1651
  for w in (5, 20, 60, 120):
1652
  mp = min(3, w)
1653
  ts[f'Ret_MA_{w}'] = ts['Return'].rolling(w, min_periods=mp).mean()
1654
  ts[f'Ret_STD_{w}'] = ts['Return'].rolling(w, min_periods=mp).std(ddof=0)
 
1655
  ts['Vol20'] = ts['Ret_STD_20'] * np.sqrt(252)
1656
  ts['Vol60'] = ts['Ret_STD_60'] * np.sqrt(252)
1657
  ts['Vol120'] = ts['Ret_STD_120'] * np.sqrt(252)
 
1658
  for w in (20, 60, 120, 252):
1659
  mp = min(5, w)
1660
  ts[f'Idx_MA_{w}'] = ts['Index'].rolling(w, min_periods=mp).mean()
 
1661
  for w in (60, 120, 252):
1662
  mu = ts['Blend'].rolling(w, min_periods=min(20, w)).mean()
1663
  sd = ts['Blend'].rolling(w, min_periods=min(20, w)).std(ddof=0)
1664
  ts[f'Blend_Z_{w}'] = np.where(sd > 0, (ts['Blend'] - mu) / sd, np.nan)
 
1665
  cummax = ts['Index'].cummax()
1666
  ts['Drawdown'] = ts['Index'] / cummax - 1.0
1667
  max_dd = float(ts['Drawdown'].min()) if len(ts) else np.nan
1668
  dd_now = float(ts['Drawdown'].iloc[-1])
1669
  peak_date = ts.loc[ts['Index'].idxmax(), 'Trade Date'].date()
 
1670
  r = ts['Return'].dropna()
1671
  r_mu = r.mean()
1672
  r_sd = r.std(ddof=0)
 
1675
  last_tail = not r.empty and (abs(r.iloc[-1] - r_mu) >= 2*r_sd)
1676
  else:
1677
  tail_2s, last_tail = np.nan, False
 
1678
  last = ts.iloc[-1]
1679
  end_date = last['Trade Date'].date()
1680
  def window_ret(days):
 
1684
  ret_5d = window_ret(5)
1685
  ret_20d = window_ret(20)
1686
  ret_60d = window_ret(60)
 
1687
  updown_streak = streak_updown(ts['Return'])
 
1688
  idx_pct = pct_rank(ts['Index'], last['Index'])
1689
  blend_pct = pct_rank(ts['Blend'], last['Blend'])
 
1690
  def pos(val, ref):
1691
  if pd.isna(ref): return "n/a"
1692
  return "above" if val > ref else ("below" if val < ref else "at")
1693
  st20 = pos(last['Index'], last.get('Idx_MA_20'))
1694
  st60 = pos(last['Index'], last.get('Idx_MA_60'))
1695
  st120 = pos(last['Index'], last.get('Idx_MA_120'))
 
1696
  ma20 = ts['Idx_MA_20']
1697
  ma20_slope = np.nan
1698
  if ma20.notna().sum() >= 5:
 
1701
  if len(y) >= 5:
1702
  b1 = np.polyfit(x, y, 1)[0]
1703
  ma20_slope = float(b1)
 
1704
  ts['Month'] = ts['Trade Date'].dt.to_period('M')
1705
  cur_month = ts['Month'].iloc[-1]
1706
  mtd = ts[ts['Month'] == cur_month]
 
1714
  .dropna()
1715
  )
1716
  med_m = float(by_month['mret'].median()) if not by_month.empty else np.nan
 
1717
  st.write("\n— 30d Constant-Maturity VIX Futures Index: interpretation —")
1718
  st.write(f"Date: {end_date}")
 
1719
  if pd.notna(idx_pct):
1720
  if idx_pct >= 90:
1721
  st.write("The index level sits in the top decile of its history. Vol risk is priced high.")
 
1724
  else:
1725
  zone = "upper" if idx_pct >= 60 else ("lower" if idx_pct <= 40 else "middle")
1726
  st.write(f"The index level is in the {zone} part of its historical range.")
 
1727
  st.write(f"Trend check: index is {st20} the 20d average, {st60} the 60d, {st120} the 120d.")
1728
  if np.isfinite(ma20_slope):
1729
  if ma20_slope > 0:
 
1732
  st.write("Short-term trend is falling. The 20d average is pointing down.")
1733
  else:
1734
  st.write("Short-term trend is flat.")
 
1735
  def fmt_pct(x):
1736
  return "n/a" if pd.isna(x) else f"{x*100:.1f}%"
1737
  st.write(f"Recent performance: 1w {fmt_pct(ret_5d)}, 1m {fmt_pct(ret_20d)}, 3m {fmt_pct(ret_60d)}.")
 
1738
  if pd.notna(max_dd):
1739
  if dd_now < -0.05:
1740
  st.write(f"Current drawdown: {dd_now*100:.1f}%. The index is below its peak from {peak_date}.")
 
1743
  else:
1744
  st.write(f"Modest drawdown: {dd_now*100:.1f}% vs peak on {peak_date}.")
1745
  st.write(f"Worst drawdown in sample: {max_dd*100:.1f}%.")
 
1746
  v20, v60, v120 = last.get('Vol20'), last.get('Vol60'), last.get('Vol120')
1747
  if pd.notna(v20):
1748
  st.write(f"Annualized return volatility: 20d {v20*100:.1f}%, 60d {v60*100:.1f}%, 120d {v120*100:.1f}%.")
 
1749
  if pd.notna(tail_2s):
1750
  st.write(f"Tail frequency: {tail_2s:.1f}% of days move more than 2σ from the mean.")
1751
  if last_tail:
1752
  st.write("Today’s move was a tail event relative to recent history.")
 
1753
  b_pct = blend_pct
1754
  b_z120 = last.get('Blend_Z_120')
1755
  b_z252 = last.get('Blend_Z_252') if 'Blend_Z_252' in ts.columns else np.nan
 
1767
  st.write(f"Relative to the last ~6 months, the 30d blend price is unusually low (z={b_z120:.2f}).")
1768
  else:
1769
  st.write(f"Relative to the last ~6 months, the 30d blend price is normal (z={b_z120:.2f}).")
 
1770
  if updown_streak > 0:
1771
  st.write(f"Up streak: {updown_streak} days of gains.")
1772
  elif updown_streak < 0:
1773
  st.write(f"Down streak: {abs(updown_streak)} days of losses.")
1774
  else:
1775
  st.write("No up/down streak today.")
 
1776
  if pd.notna(mtd_ret):
1777
  st.write(f"Month-to-date return: {mtd_ret*100:.1f}%.")
1778
  if pd.notna(med_m):
 
1782
  st.write("This is below the median month in the sample.")
1783
  else:
1784
  st.write("This is in line with a typical month.")
 
1785
  notes = []
1786
  if pd.notna(v20) and v20 > v60:
1787
  notes.append("short-term volatility is elevated vs medium term")
 
1793
  notes.append("30d blend price is an outlier vs 6m history")
1794
  if notes:
1795
  st.write("Risk notes: " + "; ".join(notes) + ".")
 
1796
  st.write("\n— Recap —")
1797
  if pd.notna(idx_pct):
1798
  loc = "high" if idx_pct >= 60 else ("low" if idx_pct <= 40 else "mid")
 
1803
  if pd.notna(v20):
1804
  st.write(f"Return vol (20d): {v20*100:.1f}%.")
1805
  st.write("Use this as context, not a forecast.")
1806
+
1807
  except Exception as e:
1808
  st.error("An error occurred during analysis. Please check your inputs and try again.")
1809
  st.write(traceback.format_exc())
1810
 
1811
+ # ---------- Hide default Streamlit style ----------
1812
  st.markdown(
1813
  """
1814
  <style>