Actuarial Loss Development Triangle — Makie.jl

A triangular matrix visualization showing cumulative insurance claim payments developing over time. Rows represent accident/origin years, columns represent development periods (e.g., 1-10 years), with the upper-left triangle displaying actual observed cumulative claims and the lower-right triangle showing projected/estimated values (IBNR). This plot is essential for actuarial reserving, enabling analysts to visualize the chain-ladder method and identify development patterns in loss data.

Actuarial Loss Development Triangle rendered with Makie.jl

Renders

Julia source (Makie.jl)

# anyplot.ai
# heatmap-loss-triangle: Actuarial Loss Development Triangle
# Library: makie 0.22.10 | Julia 1.11.9
# Quality: 90/100 | Created: 2026-06-03

using CairoMakie
using Colors
using Random

Random.seed!(42)

# Theme tokens
const THEME       = get(ENV, "ANYPLOT_THEME", "light")
const PAGE_BG     = THEME == "light" ? colorant"#FAF8F1" : colorant"#1A1A17"
const ELEVATED_BG = THEME == "light" ? colorant"#FFFDF6" : colorant"#242420"
const INK         = THEME == "light" ? colorant"#1A1A17" : colorant"#F0EFE8"
const INK_SOFT    = THEME == "light" ? colorant"#4A4A44" : colorant"#B8B7B0"

# Approved Imprint colormaps
const ANYPLOT_SEQ = cgrad([colorant"#009E73", colorant"#4467A3"])
const ANYPLOT_DIV = cgrad([colorant"#AE3030", PAGE_BG, colorant"#4467A3"])

# Data: Actuarial loss development triangle
# 10 accident years (2015-2024) x 10 development periods
const N_YEARS    = 10
const N_PERIODS  = 10
const START_YEAR = 2015

# Ultimate losses per accident year (in $M)
const ULTIMATE = [42.5, 48.2, 51.8, 55.3, 60.1, 63.7, 58.9, 67.4, 72.1, 78.5]

# Age-to-age link ratios (period 1→2, 2→3, ..., 9→10)
const DEV_FACTORS = [3.421, 1.845, 1.412, 1.198, 1.087, 1.042, 1.018, 1.008, 1.003]

# Precompute percent-paid at each development period
pct_at = fill(1.0, N_PERIODS)
for k in (N_PERIODS - 1):-1:1
    pct_at[k] = pct_at[k + 1] / DEV_FACTORS[k]
end

# Build the triangle: cumulative amounts at each (year, period) cell
# Year-specific noise only: link ratios then exactly equal DEV_FACTORS (all > 1)
cum_amounts  = fill(NaN, N_YEARS, N_PERIODS)
is_projected = fill(false, N_YEARS, N_PERIODS)
yr_noise     = [1.0 + randn() * 0.06 for _ in 1:N_YEARS]

for yr in 1:N_YEARS
    max_dev = N_YEARS - yr + 1
    for dev in 1:N_PERIODS
        cum_amounts[yr, dev]  = ULTIMATE[yr] * pct_at[dev] * yr_noise[yr]
        is_projected[yr, dev] = dev > max_dev
    end
end

# Age-to-age factors as weighted average link ratios (sum col k+1 / sum col k)
dev_factor_labels = String[]
for col in 1:(N_PERIODS - 1)
    num = 0.0
    den = 0.0
    for yr in 1:N_YEARS
        if !is_projected[yr, col] && !is_projected[yr, col + 1]
            num += cum_amounts[yr, col + 1]
            den += cum_amounts[yr, col]
        end
    end
    f = den > 0 ? num / den : NaN
    push!(dev_factor_labels, isnan(f) ? "—" : string(round(f; digits=3)))
end

# Normalize for colormapping
actual_vals = [cum_amounts[yr, dev]
               for yr in 1:N_YEARS for dev in 1:N_PERIODS
               if !is_projected[yr, dev] && !isnan(cum_amounts[yr, dev])]
actual_max = maximum(actual_vals)
actual_min = minimum(actual_vals)

proj_vals = [cum_amounts[yr, dev]
             for yr in 1:N_YEARS for dev in 1:N_PERIODS
             if is_projected[yr, dev] && !isnan(cum_amounts[yr, dev])]
proj_max = isempty(proj_vals) ? 1.0 : maximum(proj_vals)
proj_min = isempty(proj_vals) ? 0.0 : minimum(proj_vals)

# Plot
fig = Figure(
    size            = (1200, 1200),
    fontsize        = 13,
    backgroundcolor = PAGE_BG,
)

# Title
Label(fig[0, 1],
    "Loss Development Triangle · heatmap-loss-triangle · julia · makie · anyplot.ai";
    fontsize  = 14,
    color     = INK,
    font      = :bold,
    padding   = (0, 0, 12, 0),
    tellwidth = false,
)

ax = Axis(
    fig[1, 1];
    backgroundcolor   = PAGE_BG,
    titlecolor        = INK,
    xlabelcolor       = INK,
    ylabelcolor       = INK,
    xticklabelcolor   = INK_SOFT,
    yticklabelcolor   = INK_SOFT,
    xtickcolor        = INK_SOFT,
    ytickcolor        = INK_SOFT,
    leftspinecolor    = INK_SOFT,
    bottomspinecolor  = INK_SOFT,
    topspinevisible   = false,
    rightspinevisible = false,
    xgridvisible      = false,
    ygridvisible      = false,
    xlabel            = "Development Period (Years)",
    ylabel            = "Accident Year",
    xlabelsize        = 14,
    ylabelsize        = 14,
    xticklabelsize    = 12,
    yticklabelsize    = 12,
    xticks            = (1:N_PERIODS, string.(1:N_PERIODS)),
    yticks            = (1:N_YEARS, string.(START_YEAR:(START_YEAR + N_YEARS - 1))),
    yreversed         = true,
)

# Draw colored rectangles for each cell
cell_width  = 0.92
cell_height = 0.92

for yr in 1:N_YEARS
    for dev in 1:N_PERIODS
        v = cum_amounts[yr, dev]
        projected = is_projected[yr, dev]

        isnan(v) && continue

        # Determine fill color using approved Imprint colormaps
        if projected
            t = proj_max > proj_min ? (v - proj_min) / (proj_max - proj_min) : 0.5
            fill_color = ANYPLOT_DIV[t * 0.45]  # red half only — avoids near-BG midpoint
        else
            t = actual_max > actual_min ? (v - actual_min) / (actual_max - actual_min) : 0.5
            fill_color = ANYPLOT_SEQ[t]
        end

        poly!(ax,
            Point2f[(dev - cell_width / 2, yr - cell_height / 2),
                    (dev + cell_width / 2, yr - cell_height / 2),
                    (dev + cell_width / 2, yr + cell_height / 2),
                    (dev - cell_width / 2, yr + cell_height / 2)];
            color       = fill_color,
            strokecolor = PAGE_BG,
            strokewidth = 1.5,
        )

        # Cell annotation: value in $K with luminance-adaptive text color
        m = round(Int, v * 1000)
        label_str = m >= 1000 ? string(div(m, 1000), ",", lpad(string(mod(m, 1000)), 3, "0")) : string(m)
        lum = 0.299 * red(fill_color) + 0.587 * green(fill_color) + 0.114 * blue(fill_color)
        txt_color = lum > 0.45 ? colorant"#1A1A17" : colorant"#F0EFE8"

        text!(ax, dev, yr;
            text     = label_str,
            fontsize = 9,
            color    = txt_color,
            align    = (:center, :center),
        )
    end
end

# Diagonal separator line (observed vs projected boundary)
diag_x = Float64[]
diag_y = Float64[]
for yr in 1:(N_YEARS + 1)
    max_dev_for_yr = N_YEARS - yr + 2
    push!(diag_x, max_dev_for_yr + 0.5)
    push!(diag_y, yr - 0.5)
    if yr <= N_YEARS
        push!(diag_x, max_dev_for_yr + 0.5)
        push!(diag_y, yr + 0.5)
    end
end
lines!(ax, diag_x, diag_y; color = INK, linewidth = 2.5)

# Development factors row below the main grid
Label(fig[2, 1],
    "Age-to-Age Factors: " * join(dev_factor_labels, "  →  ");
    fontsize  = 11,
    color     = INK_SOFT,
    tellwidth = false,
    padding   = (0, 0, 4, 8),
)

# Legend: actual vs projected swatches
Legend(fig[3, 1],
    [PolyElement(color = ANYPLOT_SEQ[0.6], strokecolor = :transparent),
     PolyElement(color = ANYPLOT_DIV[0.2], strokecolor = :transparent)],
    ["Actual (observed)", "Projected (IBNR estimate)"];
    orientation     = :horizontal,
    framecolor      = ELEVATED_BG,
    framevisible    = true,
    backgroundcolor = ELEVATED_BG,
    labelcolor      = INK_SOFT,
    labelsize       = 12,
    patchsize       = (18, 14),
    tellwidth       = false,
    padding         = (8, 8, 6, 6),
)

# Set axis limits
xlims!(ax, 0.5, N_PERIODS + 0.5)
ylims!(ax, 0.5, N_YEARS + 0.5)

# Layout
rowsize!(fig.layout, 0, Auto(0.08))
rowsize!(fig.layout, 1, Auto(1.0))
rowsize!(fig.layout, 2, Auto(0.07))
rowsize!(fig.layout, 3, Auto(0.07))

save("plot-$(THEME).png", fig; px_per_unit = 2)

Retrieve this implementation

Runnable source as JSON, for any HTTP client: https://api.anyplot.ai/specs/heatmap-loss-triangle/makie/code. Any spec id and library id listed in llms-full.txt fit the same URL shape; every URL below is complete and callable.

{
  "spec_id": "heatmap-loss-triangle",
  "language": "julia",
  "library": "makie",
  "page": "https://anyplot.ai/heatmap-loss-triangle/julia/makie",
  "hub": "https://anyplot.ai/heatmap-loss-triangle",
  "code_json": "https://api.anyplot.ai/specs/heatmap-loss-triangle/makie/code",
  "spec_json": "https://api.anyplot.ai/specs/heatmap-loss-triangle",
  "render_light_png": "https://storage.googleapis.com/anyplot-images/plots/heatmap-loss-triangle/julia/makie/plot-light.png",
  "render_dark_png": "https://storage.googleapis.com/anyplot-images/plots/heatmap-loss-triangle/julia/makie/plot-dark.png",
  "quality_score": 90.0,
  "license": "MIT",
  "guide": "https://anyplot.ai/llms.txt"
}

Part of Actuarial Loss Development Triangle on anyplot.ai.

Other implementations