From ebb0c930d7abb4fb98b3eea8ef5fde0b969b7058 Mon Sep 17 00:00:00 2001 From: Orchestrator Date: Thu, 4 Jun 2026 17:23:34 -0500 Subject: [PATCH] feat(chart): Buffett Indicator with danger threshold --- src/charts/buffett_indicator.py | 229 ++++++++++++++++++++++++++++++++ 1 file changed, 229 insertions(+) create mode 100644 src/charts/buffett_indicator.py diff --git a/src/charts/buffett_indicator.py b/src/charts/buffett_indicator.py new file mode 100644 index 0000000..44ce1fa --- /dev/null +++ b/src/charts/buffett_indicator.py @@ -0,0 +1,229 @@ +"""Buffett Indicator Chart — US Market Cap / GDP""" + +import matplotlib + +matplotlib.use("Agg") +import matplotlib.pyplot as plt # noqa: E402 +import matplotlib.ticker as mticker # noqa: E402 +from pathlib import Path # noqa: E402 + +from src.data.market_bubbles import buffett_indicator # noqa: E402 +from src.utils.styling import ( # noqa: E402 + BUBBLE_ZONE, + EXPORT_DPI, + FIGURE_SIZE_DEFAULT, + NORMAL_ZONE, + WARNING_ZONE, + WHITE, +) + + +def _ensure_dir(path: str) -> Path: + """Ensure output directory exists and return Path.""" + p = Path(path) + p.mkdir(parents=True, exist_ok=True) + return p + + +def _set_rc_safe() -> None: + """Set rcParams without savefig.bbox=tight (avoids Python 3.14 RecursionError).""" + plt.rcParams.update({ + "font.family": "DejaVu Sans", + "font.size": 12, + "figure.facecolor": WHITE, + "figure.dpi": EXPORT_DPI, + "axes.facecolor": "#fafafa", + "axes.edgecolor": "#dddddd", + "axes.grid": True, + "axes.axisbelow": True, + "grid.color": "#e0e0e0", + "grid.linestyle": "-", + "grid.linewidth": 0.5, + "grid.alpha": 0.7, + "xtick.labelsize": 10, + "ytick.labelsize": 10, + "axes.titlesize": 16, + "axes.titleweight": "bold", + "axes.labelsize": 12, + "legend.fontsize": 9, + "figure.titlesize": 16, + "figure.titleweight": "bold", + "savefig.dpi": EXPORT_DPI, + "savefig.facecolor": WHITE, + # NOTE: deliberately omitting savefig.bbox to avoid Python 3.14 + + # matplotlib 3.9.2 deepcopy RecursionError with bbox_inches="tight" + }) + + +def plot_buffett_indicator() -> str: + """Generate Buffett Indicator chart with danger threshold zones. + + Returns + ------- + str + Absolute path to the saved PNG file. + """ + # ------------------------------------------------------------------ + # Data extraction + # ------------------------------------------------------------------ + data = sorted(buffett_indicator, key=lambda d: d["year"]) + years = [d["year"] for d in data] + values = [d["value"] for d in data] + + hist_years = [d["year"] for d in data if d["year"] < 2021] + hist_vals = [d["value"] for d in data if d["year"] < 2021] + comp_years = [d["year"] for d in data if d["year"] >= 2021] + comp_vals = [d["value"] for d in data if d["year"] >= 2021] + + # ------------------------------------------------------------------ + # Figure setup + # ------------------------------------------------------------------ + _set_rc_safe() + fig, ax = plt.subplots(figsize=FIGURE_SIZE_DEFAULT, dpi=EXPORT_DPI) + fig.set_facecolor(WHITE) + ax.set_facecolor("#fafafa") + + # Spine cleanup + ax.spines["top"].set_visible(False) + ax.spines["right"].set_visible(False) + ax.spines["left"].set_color("#cccccc") + ax.spines["bottom"].set_color("#cccccc") + + # ------------------------------------------------------------------ + # Shaded zones + # ------------------------------------------------------------------ + ax.axhspan(0, 100, facecolor=NORMAL_ZONE, alpha=0.15, label="Normal (\u2264100%)") + ax.axhspan(100, 200, facecolor=WARNING_ZONE, alpha=0.15, label="Warning (100\u2013200%)") + ax.axhspan(200, 350, facecolor=BUBBLE_ZONE, alpha=0.15, label="Bubble (>200%)") + + # Composite data band + ax.axvspan(2020.5, 2026.5, facecolor="gray", alpha=0.06, + label="Estimated / composite data") + + # ------------------------------------------------------------------ + # Data lines + # ------------------------------------------------------------------ + ax.plot( + hist_years, hist_vals, + color="#2c3e50", + linewidth=2, + marker="o", + markersize=4, + label="Historical (FRED / World Bank)", + zorder=3, + ) + ax.plot( + comp_years, comp_vals, + color="#e67e22", + linewidth=2, + linestyle="--", + marker="s", + markersize=5, + label="Estimated / composite (2021\u20132026)", + zorder=4, + ) + + # ------------------------------------------------------------------ + # Danger threshold line + # ------------------------------------------------------------------ + ax.axhline( + y=200, + color=BUBBLE_ZONE, + linewidth=2, + linestyle="--", + alpha=0.8, + zorder=5, + ) + ax.text( + 2011, 228, + "Buffett danger threshold (200%)", + fontsize=10, + fontweight="bold", + color=BUBBLE_ZONE, + zorder=6, + ) + + # ------------------------------------------------------------------ + # Key peak labels (text-only) + # ------------------------------------------------------------------ + peaks = [ + (1999, 153.43, "dot-com buildup"), + (2000, 147.38, "peak"), + (2020, 194.89, "pandemic bubble"), + (2024, 216.3, "all-time high"), + (2026, 219.0, "current"), + ] + + for year, val, label in peaks: + color = "#2c3e50" if year < 2021 else "#e67e22" + ax.text( + year, + val + 14, + f"{year}: {val:.1f}%\n({label})", + fontsize=8.5, + fontweight="bold", + ha="center", + color=color, + zorder=7, + ) + + # ------------------------------------------------------------------ + # Title, subtitle, labels, legend + # ------------------------------------------------------------------ + ax.set_title( + "Buffett Indicator \u2014 US Market Cap / GDP \u2014 1975 to 2026", + fontsize=16, + fontweight="bold", + pad=14, + ) + ax.set_xlabel("Year", fontsize=12) + ax.set_ylabel("Market Cap / GDP (%)", fontsize=12) + + ax.text( + 0.5, -0.18, + "Warren Buffett's danger threshold: 200% | Current: 219%", + transform=ax.transAxes, + fontsize=11, + ha="center", + style="italic", + color="#7f8c8d", + ) + + ax.set_xlim(1974, 2027) + ax.set_yticks(range(0, 301, 50)) + ax.set_ylim(0, 300) + ax.xaxis.set_major_locator(mticker.MultipleLocator(5)) + + ax.legend( + loc="upper left", + fontsize=8, + framealpha=0.9, + edgecolor="#cccccc", + ) + + # ------------------------------------------------------------------ + # Save — direct savefig (no bbox_inches to avoid RecursionError) + # ------------------------------------------------------------------ + output_dir = _ensure_dir("output/charts") + output_path = output_dir / "02_buffett_indicator.png" + + fig.savefig( + str(output_path), + format="png", + dpi=EXPORT_DPI, + facecolor=fig.get_facecolor(), + edgecolor="none", + ) + + plt.close(fig) + return str(output_path) + + +def main() -> None: + """Entry point for standalone execution.""" + path = plot_buffett_indicator() + print(f"Chart saved: {path}") + + +if __name__ == "__main__": + main()