"""vix-closes-above-30-by-year.py — count VIX closes above 30 (and 20, 40) per calendar year since 1990. Input : datasets/vix-closes-above-30-by-year.csv (FRED VIXCLS daily close), retrieved 2026-09-05 via the public fredgraph CSV. Re-download with --download. Method: drop missing days (holidays), group by year, count closes strictly above each threshold, and report the yearly maximum with its date. 2026 is a partial year (through the last observation). Run : python code/vix-closes-above-30-by-year.py [--download] Needs : Python 3.13, pandas 3.0.2. """ import io import sys import urllib.request import pandas as pd CSV = "datasets/vix-closes-above-30-by-year.csv" def download(): req = urllib.request.Request("https://fred.stlouisfed.org/graph/fredgraph.csv?id=VIXCLS", headers={"User-Agent": "prism-data-lab/1.0"}) txt = urllib.request.urlopen(req, timeout=60).read().decode() s = pd.read_csv(io.StringIO(txt), na_values=".", index_col=0).iloc[:, 0].rename("VIXCLS") s.index.name = "date" s[s.index >= "1990-01-01"].to_csv(CSV) def main(): if "--download" in sys.argv: download() v = pd.read_csv(CSV, parse_dates=["date"], index_col="date")["VIXCLS"].dropna() print("| year | closes | above 20 | above 30 | above 40 | share above 30 | max close (date) | mean close |") print("|---|---|---|---|---|---|---|---|") for y, s in v.groupby(v.index.year): print(f"| {y} | {len(s)} | {int((s > 20).sum())} | {int((s > 30).sum())} | {int((s > 40).sum())} | " f"{100 * (s > 30).mean():.1f}% | {s.max():.2f} ({s.idxmax():%Y-%m-%d}) | {s.mean():.1f} |") print(f"\nwhole sample {v.index[0]:%Y-%m-%d} to {v.index[-1]:%Y-%m-%d}: {len(v)} closes; above 30 on " f"{int((v > 30).sum())} ({100 * (v > 30).mean():.1f}%); above 40 on {int((v > 40).sum())}; " f"median close {v.median():.2f}; all-time max {v.max():.2f} on {v.idxmax():%Y-%m-%d}") yrs = v.groupby(v.index.year).apply(lambda s: int((s > 30).sum())) print(f"years with zero closes above 30: {list(yrs.index[yrs == 0])}") print(f"latest close {v.index[-1]:%Y-%m-%d}: {v.iloc[-1]:.2f}") above = v > 30 grp = (above != above.shift()).cumsum() runs = [] for _, s in v[above].groupby(grp[above]): runs.append((s.index[0], s.index[-1], len(s))) print("longest consecutive stretches of closes above 30:") for a, b, n in sorted(runs, key=lambda r: -r[2])[:5]: print(f" {a:%Y-%m-%d} to {b:%Y-%m-%d}: {n} closes") if __name__ == "__main__": main()