diff --git a/src/charts/bubble_indicators.py b/src/charts/bubble_indicators.py new file mode 100644 index 0000000..8d8a07e --- /dev/null +++ b/src/charts/bubble_indicators.py @@ -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()