This notebook contains a simple chain-ladder calculator written in Julia. The chain-ladder method is used by insurers to forecast the amount of reserves required to cover future (incurred by not reported) claims.

Install/import packages¶

In [25]:
using Pkg
Pkg.add(["DataFrames", "Statistics", "XLSX"]);
using DataFrames, Statistics, XLSX
   Resolving package versions...
     Project No packages added to or removed from `C:\Users\lblevins\.julia\environments\v1.12\Project.toml`
    Manifest No packages added to or removed from `C:\Users\lblevins\.julia\environments\v1.12\Manifest.toml`
In [26]:
reported_claims = DataFrame(XLSX.readtable("Reported Claims.xlsx", "Data"))
5×5 DataFrame
RowYear 0Year 1Year 2Year 3Year 4
AnyAnyAnyAnyAny
18050.111341.67268.3357.0911.65
25224.46725.55172.7544.29missing
37942.761118.59219.33missingmissing
48022.771215.45missingmissingmissing
57762.01missingmissingmissingmissing

Calculate cumulative claims¶

In [27]:
cumulative_claims  = DataFrame(cumsum(Matrix(reported_claims), dims=2), names(reported_claims))
5×5 DataFrame
RowYear 0Year 1Year 2Year 3Year 4
AnyAnyAnyAnyAny
18050.119391.789660.119717.29728.85
25224.465950.016122.766167.05missing
37942.769061.359280.68missingmissing
48022.779238.22missingmissingmissing
57762.01missingmissingmissingmissing

Calculate age-to-age factors¶

Create empty dataframe to hold age-to-age factors, using headings from above dataframe¶

In [28]:
column_titles_list = names(cumulative_claims)

new_columns = [
    Symbol("$(a) to $(b)")
    for (a, b) in zip(column_titles_list[1:end-1], column_titles_list[2:end])
]

age_to_age = DataFrame([col => Float64[] for col in new_columns]...)
0×4 DataFrame
RowYear 0 to Year 1Year 1 to Year 2Year 2 to Year 3Year 3 to Year 4
Float64Float64Float64Float64

Add data into age-to-age dataframe¶

In [29]:
ratio_list = Float64[]

df_width = size(cumulative_claims, 2) - 1 

for i in 1:df_width
    col1 = cumulative_claims[!, i]
    col2 = cumulative_claims[!, i+1]

    mask = .!(ismissing.(col1) .| ismissing.(col2))
    col1_clean = col1[mask]
    col2_clean = col2[mask]

    col1_total = sum(col1_clean)
    col2_total = sum(col2_clean)

    push!(ratio_list, col2_total / col1_total)
end

push!(age_to_age, ratio_list)
1×4 DataFrame
RowYear 0 to Year 1Year 1 to Year 2Year 2 to Year 3Year 3 to Year 4
Float64Float64Float64Float64
11.150521.027061.006421.0012

Calculate run-off triangle¶

In [30]:
run_off = deepcopy(cumulative_claims)   

for i in 1:(length(column_titles_list) - 1)
    current_column = column_titles_list[i]
    next_column    = column_titles_list[i + 1]

    factor = age_to_age[1, i]   

    run_off[!, next_column] = coalesce.(run_off[!, next_column],
                                        run_off[!, current_column] .* factor)
end

run_off = round.(run_off, digits=2)
5×5 DataFrame
RowYear 0Year 1Year 2Year 3Year 4
Float64Float64Float64Float64Float64
18050.119391.789660.119717.29728.85
25224.465950.016122.766167.056174.44
37942.769061.359280.689340.299351.49
48022.779238.229488.239549.189560.62
57762.018930.369172.049230.959242.02

Calculate incurred but not reported claims¶

In [31]:
# reverse to make indexing slightly simplier
run_off_reverse = run_off[:, reverse(1:end)]

num_rows = nrow(run_off_reverse)

ibnr   = Float64[]
period = copy(run_off_reverse[!, 1])

for i in 1:num_rows
    period[i] = run_off_reverse[i, 1]                     
    push!(ibnr, run_off_reverse[i, 1] - run_off_reverse[i, i])
end

ibnr_df = DataFrame(Period = period, IBNR = ibnr)
5×2 DataFrame
RowPeriodIBNR
Float64Float64
19728.850.0
26174.447.39
39351.4970.81
49560.62322.4
59242.021480.01
In [32]:
println("Total IBNR is: ", round(sum(ibnr_df.IBNR), digits=2))
Total IBNR is: 1880.61