feat(chart): Buffett Indicator with danger threshold

This commit is contained in:
Orchestrator
2026-06-04 17:23:34 -05:00
committed by marsultor
parent 75a6d3b95e
commit a3da271366

View File

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