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
| Item | Convention |
|---|---|
| Time step | One month, represented as 1/12 year |
| Premium | At the start of each month while healthy; waived on claim |
| Care | One monthly unit when qualified and benefits remain |
| Elimination period | Three completed claim months by default; once satisfied, qualified for life |
| Benefit maximum | 24 full continuing-claim payment months across all claims |
| Recovery or death during a payable month | Half a monthly unit on the exit edge; that half-month does not consume the counter |
| Death benefit | On the death transition, at month end |
| Term | No 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:
| Field | Meaning |
|---|---|
status | :healthy, :claim, :dead or :lapsed |
claim_months | Completed months in the current claim (capped; see below) |
qualified | Whether the lifetime EP has already been satisfied |
benefit_months | Full 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.
| Origin | Destination | Force |
|---|---|---|
| Healthy | Claim | 0.02 at issue, multiplied by exp(0.10*k) after k completed years |
| Healthy | Dead | 0.03 at issue, multiplied by exp(0.09*k) |
| Healthy | Lapsed | 0.03 |
| Claim, first six months | Healthy / Dead | 0.60 / 0.25 |
| Claim, later months | Healthy / Dead | 0.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
premium0.030097661908736834That 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))
endHealthy at issue: 0.0
New claim: 12.0325
Ongoing claim; 10 months used: 11.057
Recovered; qualified; 10 months used: -0.3287The 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