{"spec_id":"heatmap-loss-triangle","library":"makie","language":"julia","code":"# anyplot.ai\n# heatmap-loss-triangle: Actuarial Loss Development Triangle\n# Library: makie 0.22.10 | Julia 1.11.9\n# Quality: 90/100 | Created: 2026-06-03\n\nusing CairoMakie\nusing Colors\nusing Random\n\nRandom.seed!(42)\n\n# Theme tokens\nconst THEME       = get(ENV, \"ANYPLOT_THEME\", \"light\")\nconst PAGE_BG     = THEME == \"light\" ? colorant\"#FAF8F1\" : colorant\"#1A1A17\"\nconst ELEVATED_BG = THEME == \"light\" ? colorant\"#FFFDF6\" : colorant\"#242420\"\nconst INK         = THEME == \"light\" ? colorant\"#1A1A17\" : colorant\"#F0EFE8\"\nconst INK_SOFT    = THEME == \"light\" ? colorant\"#4A4A44\" : colorant\"#B8B7B0\"\n\n# Approved Imprint colormaps\nconst ANYPLOT_SEQ = cgrad([colorant\"#009E73\", colorant\"#4467A3\"])\nconst ANYPLOT_DIV = cgrad([colorant\"#AE3030\", PAGE_BG, colorant\"#4467A3\"])\n\n# Data: Actuarial loss development triangle\n# 10 accident years (2015-2024) x 10 development periods\nconst N_YEARS    = 10\nconst N_PERIODS  = 10\nconst START_YEAR = 2015\n\n# Ultimate losses per accident year (in $M)\nconst ULTIMATE = [42.5, 48.2, 51.8, 55.3, 60.1, 63.7, 58.9, 67.4, 72.1, 78.5]\n\n# Age-to-age link ratios (period 1→2, 2→3, ..., 9→10)\nconst DEV_FACTORS = [3.421, 1.845, 1.412, 1.198, 1.087, 1.042, 1.018, 1.008, 1.003]\n\n# Precompute percent-paid at each development period\npct_at = fill(1.0, N_PERIODS)\nfor k in (N_PERIODS - 1):-1:1\n    pct_at[k] = pct_at[k + 1] / DEV_FACTORS[k]\nend\n\n# Build the triangle: cumulative amounts at each (year, period) cell\n# Year-specific noise only: link ratios then exactly equal DEV_FACTORS (all > 1)\ncum_amounts  = fill(NaN, N_YEARS, N_PERIODS)\nis_projected = fill(false, N_YEARS, N_PERIODS)\nyr_noise     = [1.0 + randn() * 0.06 for _ in 1:N_YEARS]\n\nfor yr in 1:N_YEARS\n    max_dev = N_YEARS - yr + 1\n    for dev in 1:N_PERIODS\n        cum_amounts[yr, dev]  = ULTIMATE[yr] * pct_at[dev] * yr_noise[yr]\n        is_projected[yr, dev] = dev > max_dev\n    end\nend\n\n# Age-to-age factors as weighted average link ratios (sum col k+1 / sum col k)\ndev_factor_labels = String[]\nfor col in 1:(N_PERIODS - 1)\n    num = 0.0\n    den = 0.0\n    for yr in 1:N_YEARS\n        if !is_projected[yr, col] && !is_projected[yr, col + 1]\n            num += cum_amounts[yr, col + 1]\n            den += cum_amounts[yr, col]\n        end\n    end\n    f = den > 0 ? num / den : NaN\n    push!(dev_factor_labels, isnan(f) ? \"—\" : string(round(f; digits=3)))\nend\n\n# Normalize for colormapping\nactual_vals = [cum_amounts[yr, dev]\n               for yr in 1:N_YEARS for dev in 1:N_PERIODS\n               if !is_projected[yr, dev] && !isnan(cum_amounts[yr, dev])]\nactual_max = maximum(actual_vals)\nactual_min = minimum(actual_vals)\n\nproj_vals = [cum_amounts[yr, dev]\n             for yr in 1:N_YEARS for dev in 1:N_PERIODS\n             if is_projected[yr, dev] && !isnan(cum_amounts[yr, dev])]\nproj_max = isempty(proj_vals) ? 1.0 : maximum(proj_vals)\nproj_min = isempty(proj_vals) ? 0.0 : minimum(proj_vals)\n\n# Plot\nfig = Figure(\n    size            = (1200, 1200),\n    fontsize        = 13,\n    backgroundcolor = PAGE_BG,\n)\n\n# Title\nLabel(fig[0, 1],\n    \"Loss Development Triangle · heatmap-loss-triangle · julia · makie · anyplot.ai\";\n    fontsize  = 14,\n    color     = INK,\n    font      = :bold,\n    padding   = (0, 0, 12, 0),\n    tellwidth = false,\n)\n\nax = Axis(\n    fig[1, 1];\n    backgroundcolor   = PAGE_BG,\n    titlecolor        = INK,\n    xlabelcolor       = INK,\n    ylabelcolor       = INK,\n    xticklabelcolor   = INK_SOFT,\n    yticklabelcolor   = INK_SOFT,\n    xtickcolor        = INK_SOFT,\n    ytickcolor        = INK_SOFT,\n    leftspinecolor    = INK_SOFT,\n    bottomspinecolor  = INK_SOFT,\n    topspinevisible   = false,\n    rightspinevisible = false,\n    xgridvisible      = false,\n    ygridvisible      = false,\n    xlabel            = \"Development Period (Years)\",\n    ylabel            = \"Accident Year\",\n    xlabelsize        = 14,\n    ylabelsize        = 14,\n    xticklabelsize    = 12,\n    yticklabelsize    = 12,\n    xticks            = (1:N_PERIODS, string.(1:N_PERIODS)),\n    yticks            = (1:N_YEARS, string.(START_YEAR:(START_YEAR + N_YEARS - 1))),\n    yreversed         = true,\n)\n\n# Draw colored rectangles for each cell\ncell_width  = 0.92\ncell_height = 0.92\n\nfor yr in 1:N_YEARS\n    for dev in 1:N_PERIODS\n        v = cum_amounts[yr, dev]\n        projected = is_projected[yr, dev]\n\n        isnan(v) && continue\n\n        # Determine fill color using approved Imprint colormaps\n        if projected\n            t = proj_max > proj_min ? (v - proj_min) / (proj_max - proj_min) : 0.5\n            fill_color = ANYPLOT_DIV[t * 0.45]  # red half only — avoids near-BG midpoint\n        else\n            t = actual_max > actual_min ? (v - actual_min) / (actual_max - actual_min) : 0.5\n            fill_color = ANYPLOT_SEQ[t]\n        end\n\n        poly!(ax,\n            Point2f[(dev - cell_width / 2, yr - cell_height / 2),\n                    (dev + cell_width / 2, yr - cell_height / 2),\n                    (dev + cell_width / 2, yr + cell_height / 2),\n                    (dev - cell_width / 2, yr + cell_height / 2)];\n            color       = fill_color,\n            strokecolor = PAGE_BG,\n            strokewidth = 1.5,\n        )\n\n        # Cell annotation: value in $K with luminance-adaptive text color\n        m = round(Int, v * 1000)\n        label_str = m >= 1000 ? string(div(m, 1000), \",\", lpad(string(mod(m, 1000)), 3, \"0\")) : string(m)\n        lum = 0.299 * red(fill_color) + 0.587 * green(fill_color) + 0.114 * blue(fill_color)\n        txt_color = lum > 0.45 ? colorant\"#1A1A17\" : colorant\"#F0EFE8\"\n\n        text!(ax, dev, yr;\n            text     = label_str,\n            fontsize = 9,\n            color    = txt_color,\n            align    = (:center, :center),\n        )\n    end\nend\n\n# Diagonal separator line (observed vs projected boundary)\ndiag_x = Float64[]\ndiag_y = Float64[]\nfor yr in 1:(N_YEARS + 1)\n    max_dev_for_yr = N_YEARS - yr + 2\n    push!(diag_x, max_dev_for_yr + 0.5)\n    push!(diag_y, yr - 0.5)\n    if yr <= N_YEARS\n        push!(diag_x, max_dev_for_yr + 0.5)\n        push!(diag_y, yr + 0.5)\n    end\nend\nlines!(ax, diag_x, diag_y; color = INK, linewidth = 2.5)\n\n# Development factors row below the main grid\nLabel(fig[2, 1],\n    \"Age-to-Age Factors: \" * join(dev_factor_labels, \"  →  \");\n    fontsize  = 11,\n    color     = INK_SOFT,\n    tellwidth = false,\n    padding   = (0, 0, 4, 8),\n)\n\n# Legend: actual vs projected swatches\nLegend(fig[3, 1],\n    [PolyElement(color = ANYPLOT_SEQ[0.6], strokecolor = :transparent),\n     PolyElement(color = ANYPLOT_DIV[0.2], strokecolor = :transparent)],\n    [\"Actual (observed)\", \"Projected (IBNR estimate)\"];\n    orientation     = :horizontal,\n    framecolor      = ELEVATED_BG,\n    framevisible    = true,\n    backgroundcolor = ELEVATED_BG,\n    labelcolor      = INK_SOFT,\n    labelsize       = 12,\n    patchsize       = (18, 14),\n    tellwidth       = false,\n    padding         = (8, 8, 6, 6),\n)\n\n# Set axis limits\nxlims!(ax, 0.5, N_PERIODS + 0.5)\nylims!(ax, 0.5, N_YEARS + 0.5)\n\n# Layout\nrowsize!(fig.layout, 0, Auto(0.08))\nrowsize!(fig.layout, 1, Auto(1.0))\nrowsize!(fig.layout, 2, Auto(0.07))\nrowsize!(fig.layout, 3, Auto(0.07))\n\nsave(\"plot-$(THEME).png\", fig; px_per_unit = 2)\n"}