feat(chart): Shiller CAPE historical with bubble zone shading
This commit is contained in:
101
src/charts/bubble_indicators.py
Normal file
101
src/charts/bubble_indicators.py
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
"""Bubble Evidence Charts — Shiller CAPE, Buffett Indicator, P/E + Dividend"""
|
||||||
|
import copy
|
||||||
|
import matplotlib
|
||||||
|
matplotlib.use("Agg")
|
||||||
|
|
||||||
|
# Patch matplotlib Path.__deepcopy__ to break Python 3.14 recursion loop
|
||||||
|
# This is a known bug: https://github.com/matplotlib/matplotlib/issues/29280
|
||||||
|
_original_path_deepcopy = None
|
||||||
|
try:
|
||||||
|
from matplotlib.path import Path
|
||||||
|
_original_path_deepcopy = Path.__deepcopy__
|
||||||
|
def _safe_path_deepcopy(self, memo):
|
||||||
|
if id(self) in memo:
|
||||||
|
return memo[id(self)]
|
||||||
|
memo[id(self)] = self
|
||||||
|
return self
|
||||||
|
Path.__deepcopy__ = _safe_path_deepcopy
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
import matplotlib.pyplot as plt
|
||||||
|
import matplotlib.ticker as mticker
|
||||||
|
import os
|
||||||
|
|
||||||
|
from src.data.market_bubbles import shiller_cape, shiller_cape_meta
|
||||||
|
from src.utils.styling import get_theme, EXPORT_DPI, BUBBLE_ZONE, WARNING_ZONE, NORMAL_ZONE, GRAY_DARK
|
||||||
|
|
||||||
|
|
||||||
|
def plot_shiller_cape() -> str:
|
||||||
|
"""Generate Shiller CAPE historical chart with bubble zone shading."""
|
||||||
|
theme = get_theme()
|
||||||
|
theme["savefig.bbox"] = None
|
||||||
|
plt.rcParams.update(theme)
|
||||||
|
|
||||||
|
fig, ax = plt.subplots(figsize=(14, 8))
|
||||||
|
|
||||||
|
# Extract data
|
||||||
|
years = [d["year"] for d in shiller_cape]
|
||||||
|
values = [d["value"] for d in shiller_cape]
|
||||||
|
|
||||||
|
# Plot main line
|
||||||
|
ax.plot(years, values, color=GRAY_DARK, linewidth=1.5, zorder=5)
|
||||||
|
|
||||||
|
# Shaded zones
|
||||||
|
ax.axhspan(0, 20, alpha=0.15, color=NORMAL_ZONE, label="Normal (≤20)")
|
||||||
|
ax.axhspan(20, 30, alpha=0.15, color=WARNING_ZONE, label="Warning (20-30)")
|
||||||
|
ax.axhspan(30, 60, alpha=0.15, color=BUBBLE_ZONE, label="Bubble (>30)")
|
||||||
|
|
||||||
|
# Historical mean line
|
||||||
|
ax.axhline(y=17.39, color="#333333", linestyle="--", linewidth=1, alpha=0.7)
|
||||||
|
ax.text(1890, 17.8, "Mean: 17.39", fontsize=10, color="#333333")
|
||||||
|
|
||||||
|
# Annotations
|
||||||
|
events = [
|
||||||
|
(1929, 27.08, "1929 Crash", -3),
|
||||||
|
(2000, 43.77, "Dot-com Peak", 2),
|
||||||
|
(2007, 27.21, "2007 Crisis", -2),
|
||||||
|
(2020, 30.99, "Pandemic", 2),
|
||||||
|
(2026, 40.03, "2026 (Current)", 2),
|
||||||
|
]
|
||||||
|
for year, val, label, y_offset in events:
|
||||||
|
ax.annotate(label, xy=(year, val), xytext=(year, val + y_offset),
|
||||||
|
arrowprops=dict(arrowstyle="->", color="gray", lw=0.8),
|
||||||
|
fontsize=9, ha="center", fontweight="bold")
|
||||||
|
|
||||||
|
ax.set_title("Shiller CAPE (Cyclically Adjusted P/E) — 1880 to 2026",
|
||||||
|
fontsize=16, fontweight="bold")
|
||||||
|
ax.set_xlabel("Year", fontsize=12)
|
||||||
|
ax.set_ylabel("CAPE Ratio", fontsize=12)
|
||||||
|
ax.legend(loc="upper left", fontsize=9)
|
||||||
|
ax.grid(True, alpha=0.3)
|
||||||
|
ax.set_ylim(0, 50)
|
||||||
|
|
||||||
|
# X-axis: integer years, use MultipleLocator for clean tick marks
|
||||||
|
ax.xaxis.set_major_locator(mticker.MultipleLocator(20))
|
||||||
|
ax.xaxis.set_major_formatter(mticker.StrMethodFormatter("{x:.0f}"))
|
||||||
|
|
||||||
|
# Subtitle via a second text element
|
||||||
|
ax.text(0.5, -0.18,
|
||||||
|
"Historical mean: 17.39 | Dot-com peak: 43.77 (2000) | Current: 40.03",
|
||||||
|
transform=ax.transAxes, fontsize=10, ha="center", color="#666666")
|
||||||
|
|
||||||
|
# Adjust subplot to leave room for subtitle
|
||||||
|
fig.subplots_adjust(bottom=0.18)
|
||||||
|
|
||||||
|
# Save chart
|
||||||
|
path = os.path.join("output/charts", "01_shiller_cape.png")
|
||||||
|
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||||
|
fig.savefig(path, dpi=EXPORT_DPI,
|
||||||
|
facecolor=fig.get_facecolor(), edgecolor="none")
|
||||||
|
plt.close(fig)
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
path = plot_shiller_cape()
|
||||||
|
print(f"Chart saved: {path}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Reference in New Issue
Block a user