{"spec_id":"timeseries-forecast-uncertainty","library":"altair","language":"python","code":"\"\"\" anyplot.ai\ntimeseries-forecast-uncertainty: Time Series Forecast with Uncertainty Band\nLibrary: altair 6.1.0 | Python 3.13.13\nQuality: 91/100 | Updated: 2026-05-19\n\"\"\"\n\nimport os\n\nimport altair as alt\nimport numpy as np\nimport pandas as pd\n\n\n# Theme tokens\nTHEME = os.getenv(\"ANYPLOT_THEME\", \"light\")\nPAGE_BG = \"#FAF8F1\" if THEME == \"light\" else \"#1A1A17\"\nELEVATED_BG = \"#FFFDF6\" if THEME == \"light\" else \"#242420\"\nINK = \"#1A1A17\" if THEME == \"light\" else \"#F0EFE8\"\nINK_SOFT = \"#4A4A44\" if THEME == \"light\" else \"#B8B7B0\"\nINK_MUTED = \"#6B6A63\" if THEME == \"light\" else \"#A8A79F\"\n\n# Okabe-Ito palette\nBRAND = \"#009E73\"  # First series - historical data\nFORECAST_COLOR = \"#C475FD\"  # Second series - forecast\n\n# Theme-adjusted CI band opacity — orange appears more saturated on dark backgrounds\n# so we reduce opacity in dark mode to keep visual weight consistent across themes\nBAND_80_OPACITY = 0.30 if THEME == \"light\" else 0.20\nBAND_95_OPACITY = 0.15 if THEME == \"light\" else 0.10\n\n# Data — Monthly sales with 36 months history + 12 months forecast\nnp.random.seed(42)\n\nhistorical_dates = pd.date_range(\"2021-01-01\", periods=36, freq=\"MS\")\ntrend = np.linspace(100, 180, 36)\nseasonal = 15 * np.sin(np.linspace(0, 6 * np.pi, 36))\nnoise = np.random.normal(0, 8, 36)\nhistorical_values = trend + seasonal + noise\n\nforecast_dates = pd.date_range(\"2024-01-01\", periods=12, freq=\"MS\")\nforecast_trend = np.linspace(180, 210, 12)\nforecast_seasonal = 15 * np.sin(np.linspace(6 * np.pi, 8 * np.pi, 12))\nforecast_values = forecast_trend + forecast_seasonal\n\nforecast_std = np.linspace(5, 20, 12)\nlower_80 = forecast_values - 1.28 * forecast_std\nupper_80 = forecast_values + 1.28 * forecast_std\nlower_95 = forecast_values - 1.96 * forecast_std\nupper_95 = forecast_values + 1.96 * forecast_std\n\nhistorical_df = pd.DataFrame({\"date\": historical_dates, \"actual\": historical_values})\nforecast_df = pd.DataFrame(\n    {\n        \"date\": forecast_dates,\n        \"forecast\": forecast_values,\n        \"lower_80\": lower_80,\n        \"upper_80\": upper_80,\n        \"lower_95\": lower_95,\n        \"upper_95\": upper_95,\n    }\n)\n\ny_scale = alt.Scale(domain=[50, 270])\n\n# 95% CI band (lighter, drawn first so 80% renders on top)\nband_95 = (\n    alt.Chart(forecast_df)\n    .mark_area(opacity=BAND_95_OPACITY)\n    .encode(\n        x=alt.X(\"date:T\"),\n        y=alt.Y(\"lower_95:Q\", scale=y_scale),\n        y2=alt.Y2(\"upper_95:Q\"),\n        color=alt.value(FORECAST_COLOR),\n    )\n)\n\n# 80% CI band (darker, rendered on top of 95%)\nband_80 = (\n    alt.Chart(forecast_df)\n    .mark_area(opacity=BAND_80_OPACITY)\n    .encode(\n        x=alt.X(\"date:T\"),\n        y=alt.Y(\"lower_80:Q\", scale=y_scale),\n        y2=alt.Y2(\"upper_80:Q\"),\n        color=alt.value(FORECAST_COLOR),\n    )\n)\n\n# Historical line (solid) — invisible overlay points enable HTML tooltips\nhistorical_line = (\n    alt.Chart(historical_df)\n    .mark_line(strokeWidth=3, point=alt.OverlayMarkDef(size=50, opacity=0))\n    .encode(\n        x=alt.X(\"date:T\", title=\"Date\"),\n        y=alt.Y(\"actual:Q\", title=\"Sales (thousands USD)\", scale=y_scale),\n        color=alt.value(BRAND),\n        tooltip=[\n            alt.Tooltip(\"date:T\", title=\"Date\", format=\"%b %Y\"),\n            alt.Tooltip(\"actual:Q\", title=\"Sales (K USD)\", format=\".1f\"),\n        ],\n    )\n)\n\n# Forecast line (dashed) — tooltip surfaces CI bounds for context\nforecast_line = (\n    alt.Chart(forecast_df)\n    .mark_line(strokeWidth=3, strokeDash=[8, 4], point=alt.OverlayMarkDef(size=50, opacity=0))\n    .encode(\n        x=alt.X(\"date:T\"),\n        y=alt.Y(\"forecast:Q\", scale=y_scale),\n        color=alt.value(FORECAST_COLOR),\n        tooltip=[\n            alt.Tooltip(\"date:T\", title=\"Date\", format=\"%b %Y\"),\n            alt.Tooltip(\"forecast:Q\", title=\"Forecast (K USD)\", format=\".1f\"),\n            alt.Tooltip(\"lower_80:Q\", title=\"80% CI low\", format=\".1f\"),\n            alt.Tooltip(\"upper_80:Q\", title=\"80% CI high\", format=\".1f\"),\n        ],\n    )\n)\n\n# Vertical rule marking forecast start\nforecast_start_df = pd.DataFrame({\"date\": [pd.Timestamp(\"2024-01-01\")]})\nvertical_rule = (\n    alt.Chart(forecast_start_df).mark_rule(strokeWidth=2, strokeDash=[6, 3], color=INK_SOFT).encode(x=\"date:T\")\n)\n\n# Annotation labelling the forecast region — placed above the CI bands\nannotation_df = pd.DataFrame({\"date\": [pd.Timestamp(\"2024-02-01\")], \"y\": [253], \"label\": [\"Forecast period →\"]})\nforecast_annotation = (\n    alt.Chart(annotation_df)\n    .mark_text(align=\"left\", fontSize=12, fontStyle=\"italic\")\n    .encode(x=alt.X(\"date:T\"), y=alt.Y(\"y:Q\", scale=y_scale), text=\"label:N\", color=alt.value(INK_MUTED))\n)\n\n# Legend via invisible size-0 points — Altair-idiomatic approach for fixed-color layers\nlegend_df = pd.DataFrame(\n    {\n        \"date\": [historical_dates[0], forecast_dates[0], forecast_dates[0], forecast_dates[0]],\n        \"value\": [0, 0, 0, 0],\n        \"type\": [\"Historical Data\", \"Forecast\", \"80% CI\", \"95% CI\"],\n    }\n)\nlegend_chart = (\n    alt.Chart(legend_df)\n    .mark_point(size=0)\n    .encode(\n        color=alt.Color(\n            \"type:N\",\n            scale=alt.Scale(\n                domain=[\"Historical Data\", \"Forecast\", \"80% CI\", \"95% CI\"],\n                range=[BRAND, FORECAST_COLOR, \"rgba(196, 117, 253, 0.60)\", \"rgba(196, 117, 253, 0.25)\"],\n            ),\n            legend=alt.Legend(title=\"Series\", orient=\"right\", titleFontSize=14, labelFontSize=12),\n        )\n    )\n)\n\n# Combine all layers\nchart = (\n    alt.layer(band_95, band_80, historical_line, forecast_line, vertical_rule, forecast_annotation, legend_chart)\n    .properties(\n        width=800,\n        height=450,\n        background=PAGE_BG,\n        title=alt.Title(\n            \"timeseries-forecast-uncertainty · python · altair · anyplot.ai\",\n            fontSize=18,\n            anchor=\"middle\",\n            color=INK,\n            subtitle=\"Monthly Sales with 80% and 95% Confidence Intervals\",\n            subtitleFontSize=14,\n            subtitleColor=INK_SOFT,\n        ),\n    )\n    .configure_axis(\n        labelFontSize=12,\n        titleFontSize=14,\n        labelColor=INK_SOFT,\n        titleColor=INK,\n        domain=False,\n        tickSize=0,\n        gridColor=INK,\n        gridOpacity=0.10,\n    )\n    .configure_axisX(grid=False)\n    .configure_view(fill=PAGE_BG, strokeWidth=0)\n    .configure_legend(\n        titleFontSize=14,\n        labelFontSize=12,\n        fillColor=ELEVATED_BG,\n        strokeColor=INK_SOFT,\n        labelColor=INK_SOFT,\n        titleColor=INK,\n    )\n)\n\n# Save outputs\nchart.save(f\"plot-{THEME}.png\", scale_factor=4.0)\nchart.save(f\"plot-{THEME}.html\")\n"}