{"spec_id":"heatmap-loss-triangle","library":"plotly","language":"python","code":"\"\"\" anyplot.ai\nheatmap-loss-triangle: Actuarial Loss Development Triangle\nLibrary: plotly 6.7.0 | Python 3.13.13\nQuality: 87/100 | Updated: 2026-06-03\n\"\"\"\n\nimport os\nimport sys\n\n\n# Prevent this file (plotly.py) from shadowing the installed plotly package\n_here = os.path.dirname(os.path.abspath(__file__))\nsys.path = [p for p in sys.path if p and os.path.abspath(p) != _here]\n\nimport numpy as np\nimport plotly.graph_objects as go\n\n\n# Theme\nTHEME = os.getenv(\"ANYPLOT_THEME\", \"light\")\n\n# Imprint palette — theme-adaptive chrome tokens\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\"\nGRID = \"rgba(26,26,23,0.15)\" if THEME == \"light\" else \"rgba(240,239,232,0.15)\"\n\n# Imprint sequential colormap for continuous loss data (green → blue)\nimprint_seq = [[0.0, \"#009E73\"], [1.0, \"#4467A3\"]]\n# Amber anchor for projected / IBNR region\nANYPLOT_AMBER = \"#DDCC77\"\n\n# Data generation\nnp.random.seed(42)\naccident_years = list(range(2015, 2025))\ndevelopment_periods = list(range(1, 11))\nn_years = len(accident_years)\nn_periods = len(development_periods)\n\ndev_factors = [2.50, 1.60, 1.30, 1.15, 1.08, 1.05, 1.03, 1.02, 1.01]\n\nbase_claims = np.array([4200, 4500, 4800, 5100, 5500, 5800, 6200, 6500, 6900, 7300], dtype=float)\nbase_claims += np.random.normal(0, 200, n_years)\nbase_claims = np.round(base_claims / 100) * 100\n\ncumulative = np.full((n_years, n_periods), np.nan)\ncumulative[:, 0] = base_claims\nfor col in range(1, n_periods):\n    factor = dev_factors[col - 1] + np.random.normal(0, 0.02, n_years)\n    cumulative[:, col] = cumulative[:, col - 1] * factor\ncumulative = np.round(cumulative, 0)\n\nis_actual = np.full((n_years, n_periods), False)\nfor i in range(n_years):\n    is_actual[i, : n_years - i] = True\n\nz_min = np.nanmin(cumulative)\nz_max = np.nanmax(cumulative)\n\n# Figure\nfig = go.Figure()\n\nfig.add_trace(\n    go.Heatmap(\n        z=cumulative,\n        x=list(range(n_periods)),\n        y=list(range(n_years)),\n        colorscale=imprint_seq,\n        zmin=z_min,\n        zmax=z_max,\n        colorbar={\n            \"title\": {\"text\": \"Cumulative Claims ($)\", \"font\": {\"size\": 10, \"color\": INK}},\n            \"tickfont\": {\"size\": 9, \"color\": INK_SOFT},\n            \"thickness\": 14,\n            \"len\": 0.75,\n            \"tickformat\": \",.0f\",\n            \"bgcolor\": ELEVATED_BG,\n            \"bordercolor\": INK_SOFT,\n            \"borderwidth\": 1,\n        },\n        hovertemplate=(\n            \"Accident Year: %{customdata[0]}<br>Dev Period: %{customdata[1]}<br>Claims: $%{z:,.0f}<extra></extra>\"\n        ),\n        customdata=[[(accident_years[i], development_periods[j]) for j in range(n_periods)] for i in range(n_years)],\n        showscale=True,\n    )\n)\n\n# Cell value annotations — bolder font for actual cells, lighter for projected\nannotations = []\nfor i in range(n_years):\n    for j in range(n_periods):\n        val = cumulative[i, j]\n        relative = (val - z_min) / (z_max - z_min)\n        font_color = \"#F0EFE8\" if relative > 0.50 else INK\n        boundary = j == n_years - 1 - i\n        projected = not is_actual[i, j]\n        # Typographic distinction: actual cells use Arial Black, projected use regular Arial\n        font_family = \"Arial, sans-serif\" if projected else \"Arial Black, Arial, sans-serif\"\n        annotations.append(\n            {\n                \"x\": j,\n                \"y\": i,\n                \"text\": f\"{val:,.0f}\",\n                \"showarrow\": False,\n                \"font\": {\"size\": 9, \"color\": font_color, \"family\": font_family},\n                \"bgcolor\": ELEVATED_BG if boundary else None,\n                \"borderpad\": 2 if boundary else 0,\n            }\n        )\n\n# Projected cell overlays (Imprint amber tint to distinguish from actual)\nshapes = []\nfor i in range(n_years):\n    for j in range(n_periods):\n        if not is_actual[i, j]:\n            shapes.append(\n                {\n                    \"type\": \"rect\",\n                    \"x0\": j - 0.5,\n                    \"x1\": j + 0.5,\n                    \"y0\": i - 0.5,\n                    \"y1\": i + 0.5,\n                    \"line\": {\"color\": \"rgba(0,0,0,0)\"},\n                    \"fillcolor\": \"rgba(221,204,119,0.22)\",\n                    \"layer\": \"above\",\n                }\n            )\n\n# Diagonal separator (actual vs projected boundary)\nshapes.append(\n    {\n        \"type\": \"line\",\n        \"x0\": n_periods - 1 + 0.5,\n        \"y0\": 0 - 0.5,\n        \"x1\": 0 - 0.5,\n        \"y1\": n_years - 1 + 0.5,\n        \"line\": {\"color\": INK_SOFT, \"width\": 2, \"dash\": \"dash\"},\n        \"layer\": \"above\",\n    }\n)\n\n# Development factors row below the x-axis\nfor k, factor in enumerate(dev_factors):\n    annotations.append(\n        {\n            \"x\": (k + 0.5) / n_periods,\n            \"y\": -0.20,\n            \"xref\": \"paper\",\n            \"yref\": \"paper\",\n            \"text\": f\"{factor:.3f}\",\n            \"showarrow\": False,\n            \"font\": {\"size\": 8, \"color\": INK_MUTED, \"family\": \"Arial, sans-serif\"},\n            \"xanchor\": \"center\",\n        }\n    )\nannotations.append(\n    {\n        \"x\": -0.01,\n        \"y\": -0.20,\n        \"xref\": \"paper\",\n        \"yref\": \"paper\",\n        \"text\": \"<b>Dev Factors</b>\",\n        \"showarrow\": False,\n        \"font\": {\"size\": 8, \"color\": INK_MUTED, \"family\": \"Arial, sans-serif\"},\n        \"xanchor\": \"right\",\n    }\n)\n\n# Legend: actual vs projected\nannotations.append(\n    {\n        \"x\": 0.01,\n        \"y\": 1.10,\n        \"xref\": \"paper\",\n        \"yref\": \"paper\",\n        \"text\": \"■ <b>Actual</b> (observed)\",\n        \"showarrow\": False,\n        \"font\": {\"size\": 10, \"color\": \"#4467A3\", \"family\": \"Arial, sans-serif\"},\n        \"xanchor\": \"left\",\n    }\n)\nannotations.append(\n    {\n        \"x\": 0.36,\n        \"y\": 1.10,\n        \"xref\": \"paper\",\n        \"yref\": \"paper\",\n        \"text\": \"■ <b>Projected</b> (est. IBNR)\",\n        \"showarrow\": False,\n        \"font\": {\"size\": 10, \"color\": ANYPLOT_AMBER, \"family\": \"Arial, sans-serif\"},\n        \"xanchor\": \"left\",\n    }\n)\n\nfig.update_layout(\n    autosize=False,\n    title={\n        \"text\": \"heatmap-loss-triangle · python · plotly · anyplot.ai\",\n        \"font\": {\"size\": 16, \"family\": \"Arial, sans-serif\", \"color\": INK},\n        \"x\": 0.5,\n        \"xanchor\": \"center\",\n    },\n    xaxis={\n        \"title\": {\"text\": \"Development Period (Years)\", \"font\": {\"size\": 12, \"color\": INK}},\n        \"tickfont\": {\"size\": 10, \"color\": INK_SOFT},\n        \"tickvals\": list(range(n_periods)),\n        \"ticktext\": [str(p) for p in development_periods],\n        \"side\": \"bottom\",\n        \"gridcolor\": GRID,\n        \"linecolor\": INK_SOFT,\n        \"zerolinecolor\": INK_SOFT,\n    },\n    yaxis={\n        \"title\": {\"text\": \"Accident Year\", \"font\": {\"size\": 12, \"color\": INK}},\n        \"tickfont\": {\"size\": 10, \"color\": INK_SOFT},\n        \"tickvals\": list(range(n_years)),\n        \"ticktext\": [str(y) for y in accident_years],\n        \"autorange\": \"reversed\",\n        \"gridcolor\": GRID,\n        \"linecolor\": INK_SOFT,\n        \"zerolinecolor\": INK_SOFT,\n    },\n    paper_bgcolor=PAGE_BG,\n    plot_bgcolor=PAGE_BG,\n    font={\"color\": INK},\n    shapes=shapes,\n    annotations=annotations,\n    margin={\"l\": 70, \"r\": 95, \"t\": 100, \"b\": 100},\n)\n\nfig.write_image(f\"plot-{THEME}.png\", width=600, height=600, scale=4)\nfig.write_html(f\"plot-{THEME}.html\", include_plotlyjs=\"cdn\")\n"}