{"spec_id":"heatmap-loss-triangle","library":"altair","language":"python","code":"\"\"\" anyplot.ai\nheatmap-loss-triangle: Actuarial Loss Development Triangle\nLibrary: altair 6.1.0 | Python 3.13.13\nQuality: 89/100 | Updated: 2026-06-03\n\"\"\"\n\nimport os\nimport sys as _sys\n\n\n# Prevent this file from shadowing the installed altair package.\n# Python inserts the script's directory as sys.path[0] when running `python altair.py`.\n_self_dir = os.path.dirname(os.path.abspath(__file__))\nif _self_dir in _sys.path:\n    _sys.path.remove(_self_dir)\ndel _sys, _self_dir\n\nimport altair as alt\nimport numpy as np\nimport pandas as pd\nfrom PIL import Image\n\n\n# Theme-adaptive chrome (Imprint palette)\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# Canvas — square 2400×2400 for symmetric heatmap grid\nTW, TH = 2400, 2400\n\n# Imprint sequential colormap: brand green → blue (single-polarity, claim magnitude)\nIMPRINT_SEQ_START = \"#009E73\"  # Imprint palette position 1\nIMPRINT_SEQ_END = \"#4467A3\"  # Imprint palette position 3\nAMBER = \"#DDCC77\"  # Imprint amber — IBNR boundary marker\n\n# Data — cumulative paid claims triangle (10 accident years × 10 development periods)\nnp.random.seed(42)\naccident_years = list(range(2015, 2025))\ndev_periods = list(range(1, 11))\nn_years = len(accident_years)\n\nbase_claims = np.array([3200, 3500, 3800, 4100, 3900, 4300, 4600, 4200, 4800, 5100]) * 1000\ndev_factors = [2.50, 1.45, 1.22, 1.12, 1.07, 1.04, 1.025, 1.015, 1.008]\n\ncumulative = np.zeros((n_years, len(dev_periods)))\nfor i in range(n_years):\n    cumulative[i, 0] = base_claims[i] + np.random.normal(0, base_claims[i] * 0.05)\n    for j in range(1, len(dev_periods)):\n        noise = 1 + np.random.normal(0, 0.01)\n        cumulative[i, j] = cumulative[i, j - 1] * dev_factors[j - 1] * noise\n\nrows = []\nfor i, year in enumerate(accident_years):\n    for j, period in enumerate(dev_periods):\n        is_projected = (i + j) >= n_years\n        amount = round(cumulative[i, j])\n        label = f\"{amount / 1e6:.1f}M\" if amount >= 1e6 else f\"{amount / 1e3:.0f}K\"\n        rows.append(\n            {\n                \"Accident Year\": str(year),\n                \"Development Period\": period,\n                \"Cumulative Amount\": amount,\n                \"Status\": \"Projected (IBNR)\" if is_projected else \"Actual\",\n                \"Label\": label,\n            }\n        )\n\ndf = pd.DataFrame(rows)\n\nlegend_data = pd.DataFrame(\n    [{\"legend_label\": \"Actual (Observed)\", \"legend_order\": 0}, {\"legend_label\": \"Projected (IBNR)\", \"legend_order\": 1}]\n)\n\nfactor_rows = []\nfor j, factor in enumerate(dev_factors):\n    factor_rows.append({\"Accident Year\": \"Dev Factor\", \"Development Period\": j + 1, \"Factor\": f\"{factor:.3f}\"})\ndf_factors = pd.DataFrame(factor_rows)\n\nyear_order = [str(y) for y in accident_years] + [\"Dev Factor\"]\nmin_val = df[\"Cumulative Amount\"].min()\nmax_val = df[\"Cumulative Amount\"].max()\n\nx_enc = alt.X(\n    \"Development Period:O\",\n    axis=alt.Axis(labelFontSize=10, titleFontSize=12, labelAngle=0, orient=\"top\", titlePadding=10),\n)\ny_enc = alt.Y(\n    \"Accident Year:N\",\n    sort=[str(y) for y in accident_years],\n    axis=alt.Axis(labelFontSize=10, titleFontSize=12, titlePadding=10),\n)\n\n# Heatmap cells with Imprint sequential colormap encoding cumulative claim magnitude\nheatmap = (\n    alt.Chart(df)\n    .mark_rect(stroke=PAGE_BG, strokeWidth=1.5, cornerRadius=1)\n    .encode(\n        x=x_enc,\n        y=y_enc,\n        color=alt.Color(\n            \"Cumulative Amount:Q\",\n            scale=alt.Scale(range=[IMPRINT_SEQ_START, IMPRINT_SEQ_END], domain=[min_val, max_val]),\n            legend=alt.Legend(\n                title=\"Cumulative Claims\",\n                titleFontSize=10,\n                labelFontSize=10,\n                gradientLength=200,\n                gradientThickness=12,\n                orient=\"right\",\n                offset=12,\n            ),\n        ),\n        opacity=alt.when(alt.datum.Status == \"Actual\").then(alt.value(1.0)).otherwise(alt.value(0.6)),\n        tooltip=[\n            alt.Tooltip(\"Accident Year:N\"),\n            alt.Tooltip(\"Development Period:O\"),\n            alt.Tooltip(\"Cumulative Amount:Q\", format=\",.0f\", title=\"Cumulative ($)\"),\n            alt.Tooltip(\"Status:N\"),\n        ],\n    )\n)\n\n# Dashed amber border on projected cells — marks the IBNR evaluation boundary\nprojected_df = df[df[\"Status\"] == \"Projected (IBNR)\"].copy()\nprojected_border = (\n    alt.Chart(projected_df)\n    .mark_rect(stroke=AMBER, strokeWidth=2.5, strokeDash=[6, 3], filled=False, cornerRadius=1)\n    .encode(x=x_enc, y=y_enc)\n)\n\n# Cell text annotations — ink color adapts for contrast over light/dark cells\ntext = (\n    alt.Chart(df)\n    .mark_text(fontSize=12, fontWeight=\"bold\")\n    .encode(\n        x=x_enc,\n        y=y_enc,\n        text=\"Label:N\",\n        color=alt.when(alt.datum[\"Cumulative Amount\"] > (max_val * 0.55))\n        .then(alt.value(PAGE_BG))\n        .otherwise(alt.value(INK)),\n    )\n)\n\n# Dev factors background row (age-to-age chain-ladder factors)\nfactor_bg = (\n    alt.Chart(df_factors)\n    .mark_rect(fill=ELEVATED_BG, stroke=INK_SOFT, strokeWidth=1, cornerRadius=1)\n    .encode(x=alt.X(\"Development Period:O\"), y=alt.Y(\"Accident Year:N\", sort=year_order))\n)\nfactor_text = (\n    alt.Chart(df_factors)\n    .mark_text(fontSize=11, fontWeight=\"bold\", color=INK_SOFT)\n    .encode(x=alt.X(\"Development Period:O\"), y=alt.Y(\"Accident Year:N\", sort=year_order), text=\"Factor:N\")\n)\n\n# Status legend placed at bottom to avoid crowding the right-side color legend\nstatus_legend = (\n    alt.Chart(legend_data)\n    .mark_square(size=150, stroke=INK_SOFT, strokeWidth=1)\n    .encode(\n        opacity=alt.Opacity(\n            \"legend_label:N\",\n            scale=alt.Scale(domain=[\"Actual (Observed)\", \"Projected (IBNR)\"], range=[1.0, 0.6]),\n            legend=alt.Legend(\n                title=\"Status\",\n                titleFontSize=10,\n                labelFontSize=10,\n                orient=\"bottom\",\n                offset=12,\n                symbolType=\"square\",\n                symbolSize=150,\n                symbolStrokeWidth=1.5,\n                symbolFillColor=IMPRINT_SEQ_START,\n            ),\n        )\n    )\n)\n\nchart = (\n    (heatmap + projected_border + text + factor_bg + factor_text + status_legend)\n    .properties(\n        width=360,\n        height=380,\n        background=PAGE_BG,\n        title=alt.Title(\n            \"heatmap-loss-triangle · python · altair · anyplot.ai\",\n            subtitle=[\n                \"Cumulative paid claims development triangle with chain-ladder projections.\",\n                \"Full opacity = actual observed  |  Faded + dashed border = projected (IBNR)  |  Bottom row = age-to-age factors.\",\n            ],\n            fontSize=16,\n            subtitleFontSize=11,\n            subtitleColor=INK_MUTED,\n            color=INK,\n            anchor=\"start\",\n            offset=12,\n        ),\n        padding={\"left\": 20, \"right\": 20, \"top\": 20, \"bottom\": 20},\n    )\n    .configure_axis(grid=False, domainWidth=0, labelColor=INK_SOFT, titleColor=INK, tickColor=INK_SOFT)\n    .configure_view(strokeWidth=0, fill=PAGE_BG)\n    .configure_legend(fillColor=ELEVATED_BG, strokeColor=INK_SOFT, labelColor=INK_SOFT, titleColor=INK)\n    .configure_title(color=INK)\n)\n\n# Save PNG then pad to exact 2400×2400 target (square format for symmetric heatmap)\nchart.save(f\"plot-{THEME}.png\", scale_factor=4.0)\nchart.save(f\"plot-{THEME}.html\")\n\n_img = Image.open(f\"plot-{THEME}.png\").convert(\"RGB\")\n_w, _h = _img.size\nif _w > TW or _h > TH:\n    raise SystemExit(\n        f\"altair vl-convert produced {_w}×{_h}, exceeds target {TW}×{TH}. \"\n        f\"Shrink chart .properties(width=, height=) values and re-render.\"\n    )\nif _w < TW or _h < TH:\n    _canvas = Image.new(\"RGB\", (TW, TH), PAGE_BG)\n    _canvas.paste(_img, ((TW - _w) // 2, (TH - _h) // 2))\n    _canvas.save(f\"plot-{THEME}.png\")\n"}