feat(chart): Buffett Indicator with danger threshold
This commit is contained in:
229
src/charts/buffett_indicator.py
Normal file
229
src/charts/buffett_indicator.py
Normal 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()
|
||||
Reference in New Issue
Block a user