"""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()