feat(chart): flagship 3x3 narrative dashboard
This commit is contained in:
293
src/charts/narrative_dashboard.py
Normal file
293
src/charts/narrative_dashboard.py
Normal file
@@ -0,0 +1,293 @@
|
|||||||
|
"""Narrative Dashboard — 3×3 grid telling the AI bubble story
|
||||||
|
|
||||||
|
FLAGSHIP chart: single figure combining all evidence streams
|
||||||
|
into a cohesive visual narrative.
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
# Ensure the project root is on sys.path so `src.*` imports work
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
|
||||||
|
|
||||||
|
import matplotlib
|
||||||
|
matplotlib.use("Agg")
|
||||||
|
|
||||||
|
# Patch matplotlib Path.__deepcopy__ to break Python 3.14 recursion loop
|
||||||
|
# Known bug: https://github.com/matplotlib/matplotlib/issues/29280
|
||||||
|
try:
|
||||||
|
from matplotlib.path import Path
|
||||||
|
_original_path_deepcopy = Path.__deepcopy__
|
||||||
|
|
||||||
|
def _safe_path_deepcopy(self, memo):
|
||||||
|
if id(self) in memo:
|
||||||
|
return memo[id(self)]
|
||||||
|
memo[id(self)] = self
|
||||||
|
return self
|
||||||
|
|
||||||
|
Path.__deepcopy__ = _safe_path_deepcopy
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
import matplotlib.pyplot as plt
|
||||||
|
import numpy as np
|
||||||
|
from src.data.market_bubbles import shiller_cape, buffett_indicator, sp500_pe
|
||||||
|
from src.data.ai_infrastructure import hyperscaler_capex_annual, nvidia_revenue
|
||||||
|
from src.data.agent_adoption import agent_survey_data, developer_ai_adoption
|
||||||
|
from src.utils.styling import (
|
||||||
|
get_theme, EXPORT_DPI, BUBBLE_ZONE, WARNING_ZONE, NORMAL_ZONE,
|
||||||
|
GRAY_DARK, GRAY_MEDIUM, BLACK, WHITE,
|
||||||
|
AGENT_GROWTH, REVENUE, DEBT, AI_SPEND, PRODUCTIVITY,
|
||||||
|
get_company_colors,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def plot_narrative_dashboard() -> str:
|
||||||
|
"""Generate the flagship 3×3 narrative dashboard.
|
||||||
|
|
||||||
|
Returns the output file path.
|
||||||
|
"""
|
||||||
|
plt.rcParams.update(get_theme())
|
||||||
|
|
||||||
|
fig, axes = plt.subplots(3, 3, figsize=(20, 16))
|
||||||
|
fig.set_facecolor(WHITE)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# ROW 1: Market Bubble Evidence
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Panel (0,0): Shiller CAPE
|
||||||
|
ax = axes[0, 0]
|
||||||
|
years = [d["year"] for d in shiller_cape]
|
||||||
|
values = [d["value"] for d in shiller_cape]
|
||||||
|
ax.axhspan(0, 20, alpha=0.15, color=NORMAL_ZONE)
|
||||||
|
ax.axhspan(20, 30, alpha=0.15, color=WARNING_ZONE)
|
||||||
|
ax.axhspan(30, 50, alpha=0.15, color=BUBBLE_ZONE)
|
||||||
|
ax.plot(years, values, color=GRAY_DARK, linewidth=0.8)
|
||||||
|
ax.axhline(y=17.39, color="#333", linestyle="--", linewidth=0.6)
|
||||||
|
ax.text(2024, 18.5, "mean: 17.4", fontsize=7, color=GRAY_MEDIUM)
|
||||||
|
ax.annotate(
|
||||||
|
f"{values[-1]:.1f}",
|
||||||
|
xy=(2026, values[-1]), fontsize=7, fontweight="bold",
|
||||||
|
color=BUBBLE_ZONE, xytext=(2023, values[-1] - 5),
|
||||||
|
arrowprops=dict(arrowstyle="->", color=BUBBLE_ZONE, lw=0.6),
|
||||||
|
)
|
||||||
|
ax.set_title("Shiller CAPE (1880–2026)", fontsize=11, fontweight="bold")
|
||||||
|
ax.set_ylabel("CAPE")
|
||||||
|
ax.set_ylim(0, 50)
|
||||||
|
ax.tick_params(labelsize=7)
|
||||||
|
ax.grid(True, alpha=0.2)
|
||||||
|
|
||||||
|
# Panel (0,1): Buffett Indicator
|
||||||
|
ax = axes[0, 1]
|
||||||
|
b_years = [d["year"] for d in buffett_indicator]
|
||||||
|
b_vals = [d["value"] for d in buffett_indicator]
|
||||||
|
ax.axhspan(0, 100, alpha=0.15, color=NORMAL_ZONE)
|
||||||
|
ax.axhspan(100, 200, alpha=0.15, color=WARNING_ZONE)
|
||||||
|
ax.axhspan(200, 300, alpha=0.15, color=BUBBLE_ZONE)
|
||||||
|
ax.plot(b_years, b_vals, color=GRAY_DARK, linewidth=0.8)
|
||||||
|
ax.axhline(y=200, color=BUBBLE_ZONE, linestyle="--", linewidth=1)
|
||||||
|
ax.text(2000, 205, "Danger: 200%", fontsize=7, color=BUBBLE_ZONE)
|
||||||
|
ax.annotate(
|
||||||
|
f"{b_vals[-1]:.0f}%",
|
||||||
|
xy=(2026, b_vals[-1]), fontsize=7, fontweight="bold",
|
||||||
|
color=BUBBLE_ZONE, xytext=(2020, b_vals[-1] + 10),
|
||||||
|
arrowprops=dict(arrowstyle="->", color=BUBBLE_ZONE, lw=0.6),
|
||||||
|
)
|
||||||
|
ax.set_title("Buffett Indicator (1975–2026)", fontsize=11, fontweight="bold")
|
||||||
|
ax.set_ylabel("Mkt Cap / GDP %")
|
||||||
|
ax.tick_params(labelsize=7)
|
||||||
|
ax.grid(True, alpha=0.2)
|
||||||
|
|
||||||
|
# Panel (0,2): S&P 500 P/E
|
||||||
|
ax = axes[0, 2]
|
||||||
|
pe_years = [d["year"] for d in sp500_pe]
|
||||||
|
pe_vals = [d["value"] for d in sp500_pe]
|
||||||
|
ax.plot(pe_years, pe_vals, color=GRAY_DARK, linewidth=0.8)
|
||||||
|
ax.axhline(y=17.9, color="#333", linestyle="--", linewidth=0.6)
|
||||||
|
ax.text(2020, 19, "mean: 17.9", fontsize=7, color=GRAY_MEDIUM)
|
||||||
|
ax.annotate(
|
||||||
|
f"{pe_vals[-1]:.1f}",
|
||||||
|
xy=(2026, pe_vals[-1]), fontsize=7, fontweight="bold",
|
||||||
|
color=WARNING_ZONE, xytext=(2023, pe_vals[-1] - 3),
|
||||||
|
arrowprops=dict(arrowstyle="->", color=WARNING_ZONE, lw=0.6),
|
||||||
|
)
|
||||||
|
ax.set_title("S&P 500 P/E (1950–2026)", fontsize=11, fontweight="bold")
|
||||||
|
ax.set_ylabel("P/E")
|
||||||
|
ax.set_ylim(0, 75)
|
||||||
|
ax.tick_params(labelsize=7)
|
||||||
|
ax.grid(True, alpha=0.2)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# ROW 2: AI Infrastructure Buildout
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Panel (1,0): Hyperscaler Capex (stacked area, 2020–2026)
|
||||||
|
ax = axes[1, 0]
|
||||||
|
company_colors = get_company_colors()
|
||||||
|
companies = ["Microsoft", "Alphabet", "Meta", "Amazon"]
|
||||||
|
years_annual = list(range(2020, 2027))
|
||||||
|
|
||||||
|
data = {c: [0.0] * 7 for c in companies}
|
||||||
|
for entry in hyperscaler_capex_annual:
|
||||||
|
idx = entry["year"] - 2020
|
||||||
|
if 0 <= idx < 7:
|
||||||
|
data[entry["company"]][idx] = entry["capex_billions"]
|
||||||
|
|
||||||
|
y_off = np.zeros(7)
|
||||||
|
for c in companies:
|
||||||
|
vals = np.array(data[c], dtype=float)
|
||||||
|
ax.fill_between(
|
||||||
|
years_annual, y_off, y_off + vals,
|
||||||
|
alpha=0.7, color=company_colors[c], label=c,
|
||||||
|
)
|
||||||
|
y_off += vals
|
||||||
|
|
||||||
|
ax.set_title("Hyperscaler Capex (2020–2026)", fontsize=11, fontweight="bold")
|
||||||
|
ax.set_ylabel("Capex $B")
|
||||||
|
ax.tick_params(labelsize=7)
|
||||||
|
ax.legend(loc="upper left", fontsize=6, framealpha=0.8)
|
||||||
|
ax.grid(True, alpha=0.2, axis="y")
|
||||||
|
|
||||||
|
# Panel (1,1): Tech Debt Spike
|
||||||
|
ax = axes[1, 1]
|
||||||
|
debt_years = [2020, 2021, 2022, 2023, 2024, 2025, 2026]
|
||||||
|
debt_vals = [25, 30, 28, 25, 30, 121, 125]
|
||||||
|
colors_debt = [GRAY_DARK] * 5 + [BUBBLE_ZONE, WARNING_ZONE]
|
||||||
|
bars = ax.bar(debt_years, debt_vals, color=colors_debt, width=0.5)
|
||||||
|
avg5 = np.mean(debt_vals[:5])
|
||||||
|
ax.axhline(y=avg5, color="#333", linestyle="--", linewidth=1)
|
||||||
|
ax.text(2022, avg5 + 3, f"pre-2025 avg: ${avg5:.0f}B",
|
||||||
|
fontsize=7, color=GRAY_MEDIUM)
|
||||||
|
ax.text(2025.5, 125 + 5, "4× spike!", fontsize=8,
|
||||||
|
fontweight="bold", color=BUBBLE_ZONE, ha="right")
|
||||||
|
ax.set_title("Tech Debt: 2025 4× Spike", fontsize=11, fontweight="bold")
|
||||||
|
ax.set_ylabel("Debt $B")
|
||||||
|
ax.set_ylim(0, 150)
|
||||||
|
ax.tick_params(labelsize=7)
|
||||||
|
|
||||||
|
# Panel (1,2): NVIDIA Data Center Revenue
|
||||||
|
ax = axes[1, 2]
|
||||||
|
dc_rev = [d.get("data_center_billions",
|
||||||
|
d.get("compute_billions", 0) + d.get("networking_billions", 0))
|
||||||
|
for d in nvidia_revenue]
|
||||||
|
quarters = list(range(len(dc_rev)))
|
||||||
|
ax.fill_between(quarters, dc_rev, alpha=0.25, color=REVENUE)
|
||||||
|
ax.plot(quarters, dc_rev, color=REVENUE, linewidth=1)
|
||||||
|
|
||||||
|
# Mark the inflection and latest
|
||||||
|
nvidia_quarters_labels = [d["fiscal_quarter"] for d in nvidia_revenue]
|
||||||
|
# Highlight 2026-Q4 (index 27)
|
||||||
|
latest_idx = len(dc_rev) - 2 # before FY2027-Q1
|
||||||
|
ax.plot(latest_idx, dc_rev[latest_idx], "o", color=REVENUE,
|
||||||
|
markersize=5)
|
||||||
|
ax.annotate(
|
||||||
|
f"${dc_rev[latest_idx]:.1f}B",
|
||||||
|
xy=(latest_idx, dc_rev[latest_idx]),
|
||||||
|
xytext=(latest_idx - 3, dc_rev[latest_idx] - 8),
|
||||||
|
fontsize=7, fontweight="bold", color=REVENUE,
|
||||||
|
arrowprops=dict(arrowstyle="->", color=REVENUE, lw=0.5),
|
||||||
|
)
|
||||||
|
ax.set_title("NVIDIA DC Revenue (Quarterly)", fontsize=11, fontweight="bold")
|
||||||
|
ax.set_ylabel("Revenue $B")
|
||||||
|
ax.tick_params(labelsize=7)
|
||||||
|
ax.set_xticks(range(0, len(quarters), 4))
|
||||||
|
ax.set_xticklabels([nvidia_quarters_labels[i].replace("FY", "")
|
||||||
|
for i in range(0, len(quarters), 4)],
|
||||||
|
rotation=45, ha="right")
|
||||||
|
ax.grid(True, alpha=0.2)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# ROW 3: Agent Revolution and Reality
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Panel (2,0): GPU Utilization Paradox
|
||||||
|
ax = axes[2, 0]
|
||||||
|
cats = ["AI Spend", "GPU Util.", "Target", "Human"]
|
||||||
|
vals = [100, 5, 65, 85]
|
||||||
|
colors_util = [AI_SPEND, BUBBLE_ZONE, NORMAL_ZONE, WARNING_ZONE]
|
||||||
|
bars = ax.barh(cats, vals, color=colors_util, height=0.5)
|
||||||
|
for bar, v in zip(bars, vals):
|
||||||
|
ax.text(v + 2, bar.get_y() + bar.get_height() / 2,
|
||||||
|
f"{v}%", va="center", fontsize=8, fontweight="bold",
|
||||||
|
color=BLACK)
|
||||||
|
ax.set_title("GPU Utilization Paradox", fontsize=11, fontweight="bold")
|
||||||
|
ax.set_xlim(0, 115)
|
||||||
|
ax.tick_params(labelsize=8)
|
||||||
|
ax.grid(True, alpha=0.2, axis="x")
|
||||||
|
|
||||||
|
# Panel (2,1): Developer AI Reality
|
||||||
|
ax = axes[2, 1]
|
||||||
|
dev_cats = ["Use AI tools", "Daily AI use", "AI code merged", "AI PR issues"]
|
||||||
|
dev_vals = [84, 51, 22, 70] # 70 ≈ 1.7× more issues (scaled to %)
|
||||||
|
dev_colors = [AGENT_GROWTH, AGENT_GROWTH, NORMAL_ZONE, BUBBLE_ZONE]
|
||||||
|
bars = ax.barh(dev_cats, dev_vals, color=dev_colors, height=0.5)
|
||||||
|
for bar, v in zip(bars, dev_vals):
|
||||||
|
ax.text(v + 2, bar.get_y() + bar.get_height() / 2,
|
||||||
|
f"{v}%", va="center", fontsize=8, fontweight="bold",
|
||||||
|
color=BLACK)
|
||||||
|
ax.set_title("Developer AI Reality", fontsize=11, fontweight="bold")
|
||||||
|
ax.set_xlim(0, 100)
|
||||||
|
ax.tick_params(labelsize=8)
|
||||||
|
ax.grid(True, alpha=0.2, axis="x")
|
||||||
|
|
||||||
|
# Panel (2,2): Enterprise Agent Adoption
|
||||||
|
ax = axes[2, 2]
|
||||||
|
surveys = ["LangChain", "McKinsey", "PwC"]
|
||||||
|
labels = ["In production", "Scaling agents", "Measurable value"]
|
||||||
|
adoption = [
|
||||||
|
agent_survey_data["langchain_2025"]["production"],
|
||||||
|
agent_survey_data["mckinsey_2025"]["agentic_ai_scaling"],
|
||||||
|
agent_survey_data["pwc_2025"]["measurable_productivity_value"],
|
||||||
|
]
|
||||||
|
bars = ax.barh(surveys, adoption, color=AGENT_GROWTH, height=0.5)
|
||||||
|
for bar, v, label in zip(bars, adoption, labels):
|
||||||
|
ax.text(v + 1, bar.get_y() + bar.get_height() / 2,
|
||||||
|
f"{v:.0f}% ({label})", va="center", fontsize=7,
|
||||||
|
fontweight="bold", color=BLACK)
|
||||||
|
ax.set_title("Enterprise Agent Adoption", fontsize=11, fontweight="bold")
|
||||||
|
ax.set_xlim(0, 100)
|
||||||
|
ax.tick_params(labelsize=8)
|
||||||
|
ax.grid(True, alpha=0.2, axis="x")
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Row labels (vertical text on the left)
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
fig.text(0.02, 0.78, "MARKET BUBBLE EVIDENCE", fontsize=10,
|
||||||
|
fontweight="bold", color=GRAY_MEDIUM, rotation=90,
|
||||||
|
va="center")
|
||||||
|
fig.text(0.02, 0.50, "AI INFRASTRUCTURE BUILDOUT", fontsize=10,
|
||||||
|
fontweight="bold", color=GRAY_MEDIUM, rotation=90,
|
||||||
|
va="center")
|
||||||
|
fig.text(0.02, 0.22, "AGENT REVOLUTION & REALITY", fontsize=10,
|
||||||
|
fontweight="bold", color=GRAY_MEDIUM, rotation=90,
|
||||||
|
va="center")
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Overall title
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
fig.suptitle(
|
||||||
|
"The AI Bubble and the Fundamental Value of LLMs — June 2026",
|
||||||
|
fontsize=20, fontweight="bold", y=0.98,
|
||||||
|
)
|
||||||
|
|
||||||
|
fig.subplots_adjust(
|
||||||
|
hspace=0.35, wspace=0.25,
|
||||||
|
left=0.06, right=0.98,
|
||||||
|
top=0.95, bottom=0.04,
|
||||||
|
)
|
||||||
|
|
||||||
|
out_path = "output/combined/narrative_dashboard.png"
|
||||||
|
fig.savefig(
|
||||||
|
out_path, dpi=EXPORT_DPI,
|
||||||
|
facecolor=fig.get_facecolor(), edgecolor="none",
|
||||||
|
)
|
||||||
|
plt.close(fig)
|
||||||
|
return out_path
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
path = plot_narrative_dashboard()
|
||||||
|
print(f"Dashboard saved: {path}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Reference in New Issue
Block a user