LTC worked example

This academic long-term care model builds on Building multi-state models. It adds claim-duration effects, recovery and a second claim, an elimination period (EP), premium waiver, and a benefit counter retained across claims.

The rates are illustrative, not experience-study estimates. The model is an example in examples/ltc_model.jl, rather than an exported LTC product. Its conventions below are part of the example's specification.

Product specification

ItemConvention
Time stepOne month, represented as 1/12 year
PremiumAt the start of each month while healthy; waived on claim
CareOne monthly unit when qualified and benefits remain
Elimination periodThree completed claim months by default; once satisfied, qualified for life
Benefit maximum24 full continuing-claim payment months across all claims
Recovery or death during a payable monthHalf a monthly unit on the exit edge; that half-month does not consume the counter
Death benefitOn the death transition, at month end
TermNo premium, care or death payments beyond the policy term

The EP must be completed within one claim. An unqualified recovery resets claim duration; a qualified recovery retains qualification. The treatment of half-month exit payments is an approximation, so this counter is not an exact cap on total reimbursed care or money. The model has no inflation protection, care-setting differences or restoration of benefits.

State and transition assumptions

Each LTCState contains:

FieldMeaning
status:healthy, :claim, :dead or :lapsed
claim_monthsCompleted months in the current claim (capped; see below)
qualifiedWhether the lifetime EP has already been satisfied
benefit_monthsFull continuing-claim benefit months used across episodes

Dead and lapsed are absorbing and discard the remaining memory. Claim duration is counted up to max(6, elimination_months): the six-month rate band must not prevent a longer EP from qualifying. With EP zero, qualification starts on claim onset.

Default annual forces, rather than annual exit probabilities, are listed below. Here k is completed policy years at the start of the month: k=0 during the first policy year, k=1 during the second, and so on.

OriginDestinationForce
HealthyClaim0.02 at issue, multiplied by exp(0.10*k) after k completed years
HealthyDead0.03 at issue, multiplied by exp(0.09*k)
HealthyLapsed0.03
Claim, first six monthsHealthy / Dead0.60 / 0.25
Claim, later monthsHealthy / Dead0.20 / 0.15

For total exit force $\mu=\sum_j\mu_j$ over a month, staying has probability $e^{-\mu/12}$, and exit $j$ has probability $\frac{\mu_j}{\mu}(1-e^{-\mu/12})$. Healthy forces change at policy anniversaries. Only one exit is represented per step; recovery and re-entry take separate monthly steps. The engine itself does not infer a conversion from annual decrement rates.

Price a monthly premium

One valuation with a unit monthly premium gives the benefit and premium component values. Equivalence then sets the premium. This is a finite ten-year policy, so zero terminal value is justified by maturity.

using LifeContingencies
include(joinpath(pkgdir(LifeContingencies), "examples", "ltc_model.jl"))

model = LTCModel()
grid = months(10)
unit_contract = ltc_contract(model; term=10, premium=1.0)
unit_values = policy_values(unit_contract, model, 0.04; grid, terminal=0, roots=[HEALTHY])
v = unit_values.before[HEALTHY]
premium = (v.care + v.death) / -v.premium
premium
0.030097661908736834

That is a monthly premium per unit of monthly care benefit and unit death benefit. Scaling both benefits to 3,000 gives a premium of about 90 per month:

(monthly_care_benefit = 3_000, death_benefit = 3_000, monthly_premium = round(3_000 * premium; digits=2))
(monthly_care_benefit = 3000, death_benefit = 3000, monthly_premium = 90.29)

If the death benefit is a different amount, scale its component separately before setting the premium by equivalence.

Active-life and claim policy values

These four starts distinguish a new healthy policyholder, a new claim, an ongoing claim with benefits used, and a recovered life with retained qualification and benefit memory:

contract = ltc_contract(model; term=10, premium)
starts = [
    HEALTHY,
    LTCState(:claim, 0, false, 0),
    LTCState(:claim, 6, true, 10),
    LTCState(:healthy, 0, true, 10),
]
valued = policy_values(contract, model, 0.04; grid, terminal=0, roots=starts)
@assert isapprox(sum(valued.before[HEALTHY]), 0.0; atol=1e-10)
labels = ("Healthy at issue", "New claim", "Ongoing claim; 10 months used", "Recovered; qualified; 10 months used")
for (label, state) in zip(labels, starts)
    println(label, ": ", round(sum(valued.before[state]); digits=4))
end
Healthy at issue: 0.0
New claim: 12.0325
Ongoing claim; 10 months used: 11.057
Recovered; qualified; 10 months used: -0.3287

The healthy value is zero by construction. Other states change both claim eligibility and remaining benefits. These are policy values before current state payments, at the same policy date and with the same premium fixed at issue.

Project component cash flows

projection = project(contract, model; grid, initial=[HEALTHY => 1.0])
pv = present_values(contract, model, 0.04; grid, initial=[HEALTHY => 1.0])
@assert all(isapprox(a, b; atol=1e-10) for (a, b) in zip(pv, valued.before[HEALTHY]))
(expected_care_units = sum(cf.amount for cf in projection.cashflows.care),
 component_pvs = pv)
(expected_care_units = 2.23843612899034, component_pvs = (premium = -2.0925284729337905, care = 1.8025863741737058, death = 0.28994209876008403))

Care units include the half-unit exit payments. Occupancy alone would not capture their within-month accrual; it is represented by transition payments, including claim self-loops.

Follow a second claim

A qualified claimant with one full benefit month used recovers. The recovery month pays half a unit and does not increment the benefit counter. Healthy months keep qualification and usage. On the next claim the EP is already satisfied:

first_claim = LTCState(:claim, 4, true, 1)
recovered = LTCState(:healthy, 0, true, 1)
second_claim = LTCState(:claim, 0, true, 1)
next_month = LTCState(:claim, 1, true, 2)
row_has(from, to, interval) = any(first(edge) == to && last(edge) > 0
                                for edge in transitions(model, interval, from))
@assert row_has(first_claim, recovered, Interval(0.0, 1/12))
@assert row_has(recovered, second_claim, Interval(1/12, 2/12))
@assert row_has(second_claim, next_month, Interval(2/12, 3/12))
(recovery_month = contract.care.f(Interval(0.0, 1/12), first_claim, recovered),
 new_claim_month = contract.care.f(Interval(1/12, 2/12), recovered, second_claim),
 continuing_claim_month = contract.care.f(Interval(2/12, 3/12), second_claim, next_month))
(recovery_month = 0.5, new_claim_month = 0.0, continuing_claim_month = 1.0)

The payments are (0.5, 0.0, 1.0). Losing either qualified or benefit_months on recovery would change the second claim's payments.

Validation and full source

test/ltc.jl checks reduction to SingleLife when incidence and lapse are zero, exhaustive short-horizon path enumeration, a hand-checked second claim, and 20,000 Monte Carlo paths within four standard errors. It also checks EP zero and EP longer than the rate band, plus payments stopping at the term even when the projection grid runs further.

The example uses the same transition rows for all these checks. Its full source is included below so the rates, memory updates and payment conventions can be inspected together.

# An academic long-term care model for `LifeContingencies.Experimental`.
#
# Monthly steps. A policyholder is healthy, on claim, dead or lapsed, and each state also
# carries the contract's memory: how long the current claim has lasted, whether the
# elimination period has been satisfied, and how many benefit months have been paid.
# The rates are illustrative, not from any experience study. docs/src/ltc.md gives the
# specification and its simplifications.

using LifeContingencies.Experimental

struct LTCState
    status::Symbol         # :healthy, :claim, :dead or :lapsed
    claim_months::Int      # whole months in the current claim, counted up to `claim_months_counted`
    qualified::Bool        # whether the elimination period has been satisfied (once per lifetime)
    benefit_months::Int    # benefit months paid so far, across all claims
end

const HEALTHY = LTCState(:healthy, 0, false, 0)
const DEAD = LTCState(:dead, 0, false, 0)
const LAPSED = LTCState(:lapsed, 0, false, 0)

# claim termination rates change after this many months on claim
const RATE_BANDS = 6

Base.@kwdef struct LTCModel
    issue_age::Int = 75
    elimination_months::Int = 3
    maximum_benefit_months::Int = 24
    # annual forces, by completed years since issue
    healthy_mortality::Float64 = 0.03     # at issue, growing by `mortality_growth` a year
    mortality_growth::Float64 = 0.09
    incidence::Float64 = 0.02             # at issue, growing by `incidence_growth` a year
    incidence_growth::Float64 = 0.10
    lapse::Float64 = 0.03
    # annual forces on claim, for the first `RATE_BANDS` months and after
    recovery::NTuple{2,Float64} = (0.6, 0.2)
    claim_mortality::NTuple{2,Float64} = (0.25, 0.15)
end

const MONTH = 1 / 12
months(years) = range(0, years, length=12years + 1)

# Probabilities of staying and of each exit over a step of length h, under constant forces.
function exits(forces, h)
    total = sum(forces)
    stay = exp(-total * h)
    return stay, map(μ -> iszero(total) ? zero(μ) : μ / total * (1 - stay), forces)
end

care_payable(m::LTCModel, s::LTCState) =
    s.status === :claim && s.qualified && s.benefit_months < m.maximum_benefit_months

# Claim duration matters until both the rate band and the elimination period are settled,
# so it is counted that far and no further, which keeps the states few.
claim_months_counted(m::LTCModel) = max(RATE_BANDS, m.elimination_months)

# A claim that has lasted `months` whole months. It qualifies once it has lasted the
# elimination period, so with no elimination period it qualifies as it starts.
on_claim(m::LTCModel, months, qualified, benefit_months) =
    LTCState(:claim, min(months, claim_months_counted(m)), qualified || months >= m.elimination_months, benefit_months)

# Every row has four entries so that all rows have one type.
function LifeContingencies.Experimental.transitions(m::LTCModel, interval::Interval, s::LTCState)
    isapprox(interval.stop - interval.start, MONTH) ||
        throw(ArgumentError("the LTC model moves in steps of one month; got $interval"))
    years = round(Int, 12 * interval.start) ÷ 12
    if s.status === :healthy
        forces = (m.incidence * exp(m.incidence_growth * years), m.healthy_mortality * exp(m.mortality_growth * years), m.lapse)
        stay, (incidence, death, lapse) = exits(forces, MONTH)
        claim = on_claim(m, 0, s.qualified, s.benefit_months)
        return (s => stay, claim => incidence, DEAD => death, LAPSED => lapse)
    elseif s.status === :claim
        band = s.claim_months < RATE_BANDS ? 1 : 2
        stay, (recovery, death) = exits((m.recovery[band], m.claim_mortality[band]), MONTH)
        continuing = on_claim(m, s.claim_months + 1, s.qualified, s.benefit_months + care_payable(m, s))
        recovered = LTCState(:healthy, 0, s.qualified, s.benefit_months)
        return (continuing => stay, recovered => recovery, DEAD => death, DEAD => 0.0)
    else
        return (s => 1.0, s => 0.0, s => 0.0, s => 0.0)
    end
end

# A month on claim is paid in full. A month that ends in recovery or death is paid for half,
# and doesn't count toward the maximum.
function care_payment(m::LTCModel, from::LTCState, to::LTCState)
    care_payable(m, from) || return 0.0
    to.status === :claim && return 1.0
    to.status in (:healthy, :dead) && return 0.5
    return 0.0
end

# A `term`-year policy: a monthly premium at the start of each month while healthy (waived on
# claim), a monthly care benefit, and a benefit on death. Nothing is paid for a month that
# ends after the term, however far the projection grid runs.
function ltc_contract(m::LTCModel; term, premium, care=1.0, death=1.0)
    return (
        premium=StatePayment((t, s) -> s.status === :healthy && t < term ? -premium : 0.0),
        care=TransitionPayment((interval, from, to) -> interval.stop <= term ? care * care_payment(m, from, to) : 0.0),
        death=TransitionPayment((interval, from, to) ->
            interval.stop <= term && from.status in (:healthy, :claim) && to.status === :dead ? death : 0.0),
    )
end