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