From 75a6d3b95e86de4d03bb02a493d820f25b5780dd Mon Sep 17 00:00:00 2001 From: Orchestrator Date: Thu, 4 Jun 2026 17:22:16 -0500 Subject: [PATCH] feat(chart): S&P 500 P/E and dividend yield historical --- src/charts/pe_dividend.py | 84 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 src/charts/pe_dividend.py diff --git a/src/charts/pe_dividend.py b/src/charts/pe_dividend.py new file mode 100644 index 0000000..ebc33dd --- /dev/null +++ b/src/charts/pe_dividend.py @@ -0,0 +1,84 @@ +"""S&P 500 P/E and Dividend Yield Charts""" +import matplotlib +matplotlib.use("Agg") +import matplotlib.pyplot as plt +from src.data.market_bubbles import sp500_pe, sp500_dividend_yield +from src.utils.styling import get_theme, EXPORT_DPI, BUBBLE_ZONE, WARNING_ZONE, NORMAL_ZONE, GRAY_DARK +from src.utils.export import save_chart_tight + + +def plot_pe_dividend() -> str: + """Generate 2-panel S&P 500 P/E Ratio and Dividend Yield chart.""" + plt.rcParams.update(get_theme()) + fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(14, 10), sharex=True) + + # ── Panel 1: P/E ─────────────────────────────────────────────────────── + years_pe = [d["year"] for d in sp500_pe] + values_pe = [d["value"] for d in sp500_pe] + ax1.plot(years_pe, values_pe, color=GRAY_DARK, linewidth=1.5) + ax1.axhspan(0, 15, alpha=0.15, color=NORMAL_ZONE) + ax1.axhspan(15, 25, alpha=0.15, color=WARNING_ZONE) + ax1.axhspan(25, 80, alpha=0.15, color=BUBBLE_ZONE) + ax1.axhline(y=18.2, color="#333", linestyle="--", linewidth=1, alpha=0.7) + ax1.text(1960, 18.7, "Mean: 18.2", fontsize=10) + ax1.set_ylabel("P/E Ratio", fontsize=12) + ax1.set_ylim(0, 80) + + pe_events = [ + (1999, 32.92, "1999 Peak"), + (2009, 70.91, "2009 Anomaly"), + (2021, 35.96, "2021"), + (2026, 29.60, "2026"), + ] + for year, val, label in pe_events: + ax1.annotate(label, xy=(year, val), xytext=(year + 3, val + 3), + arrowprops=dict(arrowstyle="->", color="gray", lw=0.8), + fontsize=8) + + # ── Panel 2: Dividend Yield ──────────────────────────────────────────── + years_dy = [d["year"] for d in sp500_dividend_yield] + values_dy = [d["value"] for d in sp500_dividend_yield] + ax2.plot(years_dy, values_dy, color="#e74c3c", linewidth=1.5) + ax2.axhline(y=3.2, color="#333", linestyle="--", linewidth=1, alpha=0.7) + ax2.text(1960, 3.5, "Mean: 3.2%", fontsize=10) + ax2.set_ylabel("Dividend Yield (%)", fontsize=12) + ax2.set_xlabel("Year", fontsize=12) + ax2.set_ylim(0, 8) + + dy_events = [ + (1950, 7.44, "1950: 7.44%"), + (2000, 1.22, "2000: 1.22%"), + (2026, 1.04, "2026: 1.04%"), + ] + for year, val, label in dy_events: + ax2.annotate(label, xy=(year, val), xytext=(year + 3, val + 0.5), + arrowprops=dict(arrowstyle="->", color="gray", lw=0.8), + fontsize=8) + + fig.suptitle( + "S&P 500 Valuation Metrics — P/E Ratio and Dividend Yield", + fontsize=16, fontweight="bold", + ) + + for ax in (ax1, ax2): + ax.grid(True, alpha=0.3) + + # Save — avoid tight_layout() and bbox_inches="tight" to bypass + # Python 3.14 + matplotlib deepcopy RecursionError + import os + output_dir = "output/charts" + os.makedirs(output_dir, exist_ok=True) + path = os.path.join(output_dir, "03_pe_dividend.png") + fig.subplots_adjust(top=0.90, bottom=0.06, left=0.08, right=0.95, hspace=0.28) + plt.savefig(path, dpi=EXPORT_DPI, facecolor=fig.get_facecolor(), edgecolor="none") + plt.close(fig) + return path + + +def main(): + path = plot_pe_dividend() + print(f"Chart saved: {path}") + + +if __name__ == "__main__": + main()