Loss Modelling — the formulas

This page works through one deal by hand: the demo stop-loss model. It is an employer group of 1,000 members, a specific deductible of 150,000, a max reimbursement of 2,000,000, a 12/15 contract basis, a 90 % share and 3 policy years. We take each formula in the order the engine runs it. Then we do the same for its twin on the claim-duration engine, DurationDemo.

Every formula below is quoted from source. File paths are given relative to LossModelling/. Where a figure comes from a run rather than from a formula, the page says so and names where it was read.

Live views of the demo model:

Subscribers see this result live; below is the same worked example as a static table.

Demo model, 3 years, our 90 % share Amount
Our premium 2,290,160
Recoveries −1,464,538
Costs −526,737
Result 298,885

These are the Result area's figures for 400 simulations at seed 2026, read on 2026-11-03. The full table in the high-cost engine section further down adds the margin, the year-1 loss ratio and its spread.

Step 0 — which engine, and is there anything to compute?

StopLossEngines.Evaluate (StopLossModel/Source/StopLossEngines.cs) is the one evaluation. The views draw it, and a stored price must come from it. It refuses before it computes:

Check Formula in source Demo
a census m.Census.Count == 0 \|\| Census.Lives(m.Census) == 0 refuses 1,000 lives, so it passes
complete terms t.Deductible <= 0 \|\| t.MaxReimbursement <= 0 \|\| t.AnnualPremium <= 0 refuses 150,000 / 2,000,000 / 768,768, so it passes
a known engine IsDuration(m) (engine = ConditionDuration) or IsHighCost(m) (empty or HighCost) empty, so HighCost
HighCost only: the deductible clears the trended threshold floor = a.HighCostThreshold * Math.Pow(1 + a.MedicalTrend, years - 1); refuse if t.Deductible < floor 100,000 × 1.085² = 117,722.50 < 150,000, so it passes

The floor check exists because the engine draws claims only above the threshold. A deductible below it would miss the recoveries that fall between the deductible and the threshold.

The high-cost engine (StopLossSimulation.Run)

Source: StopLossModel/Source/StopLossSimulation.cs. The basis is the US2026Demo health assumptions:

Demo only. The set's own basis says: "Demo character only — not a pricing basis." Some of its values are sourced and some are estimates; the set's basis field names which. The same holds for the condition set. Every figure on this page illustrates the mechanics; none is a price.

Every run is seeded (new Random(seed), seed 2026) and clamped:

1. Premium and costs, on our share

yearPremium = terms.AnnualPremium * Math.Pow(1 + renewalIncrease, y) * terms.Share
monthly premium = yearPremium / 12
monthly costs   = yearPremium / 12 * (terms.ExternalCostRatio + terms.InternalCostRatio)

On the demo:

Policy year yearPremium costs (18 % + 5 % = 23 %)
1 768,768 × 1.0 × 0.9 = 691,891.20 159,134.98
2 768,768 × 1.1 × 0.9 = 761,080.32 175,048.47
3 768,768 × 1.21 × 0.9 = 837,188.35 192,553.32
Total 2,290,159.87 526,736.77

The monthly premium in year 1 is 57,657.60. Net of costs it is 57,657.60 × 0.77 = 44,396.35.

2. Who is high-cost: the stationary share, persistence and incidence

The census is spread over single ages by Expand: each band's members are spread evenly over its ages, and the remainder goes to the youngest ages. In year 0 each age and sex cell draws its high-cost members as Binomial(normal, Share(a, age, sex)), where Share is the band's stationary high-cost share.

Worked: the expected number of high-cost members at the start is Σ (members × share) over the census. That is 24 × 1.6 % + 24 × 1.4 % + … + 18 × 2.25 % = 8.98, about 0.9 % of 1,000. The test ThePopulation_HoldsItsHeadcount pins "between 3 and 25 a year".

At each anniversary a high-cost member stays high-cost with probability ρ. A normal member falls ill with the incidence that keeps the share stationary:

incidence = share >= 1 ? 1 : share * (1 - a.Persistence) / (1 - share)      // i = s(1−ρ)/(1−s)

Worked, males 40–49 (s = 1.15 %): i = 0.0115 × 0.79 / 0.9885 = 0.919 % a year.

Check the stationarity: next year's share is s·ρ + (1−s)·i = 0.0115 × 0.21 + 0.9885 × 0.00919 = 0.0115.

3. Deaths, leavers and replacements

At year end, for each age and sex cell:

q  = band.MaleMortality | band.FemaleMortality
dn = Binomial(normal, q);    dh = Binomial(high, Math.Min(1, q * a.HighCostMortalityMultiplier))
ln = Binomial(normal - dn, a.Turnover);   lh = Binomial(high - dh, a.Turnover)

Then Replace runs: everyone ages one year, and the gone = dn + dh + ln + lh replacements are hired at the starting age mix. Each cohort is high-cost at its age's stationary share. That keeps both the headcount and the high-cost share stable. The test TheHighCostShare_StaysStationary_UnderTurnover pins this.

Worked, males 40–49: q = 0.32 %, and 1.28 % for a high-cost member (× 4). About 17 % of the survivors leave each year.

4. One high-cost member's claims (Recover)

The annual claim is drawn from a Pareto above the threshold and trended:

claims = severity.Sample(rng) * Math.Pow(1 + a.MedicalTrend, y)      // severity = Pareto { X0 = threshold, Alpha = α }
Pareto.Sample: X0 / Math.Pow(1.0 - U, 1.0 / Alpha)

A share of the claim, OnsetShare, comes as a lump in the onset month. The rest accrues evenly to month 11. A member who persists from last year starts at month 0. A new one starts at a uniform month, rng.Next(0, 12).

accrued[onset] = claims * lump;  accrued[k] += claims * (1 - lump) / (12 - onset)   for k = onset..11
cap    = Math.Min(claims - deductible, maxReimbursement)
excess_k = Math.Min(Math.Max(running - deductible, 0) - Math.Max(before - deductible, 0), cap - excessSoFar)
paid at month k + j + lag = excess_k * (Completion[j] - Completion[j-1])   — counted only if k + j + lag < paidMonths

Worked by hand: take one member who falls ill in month 3 with a claim of 400,000, in year 0.

Month k accrued excess over 150k incurred reimbursed by month 14 (× 0.9 share)
3 280,000 + 13,333.33 = 293,333.33 143,333.33 143,333.33 × Completion[11] = 0.996, so 128,484.00
4 13,333.33 13,333.33 × 0.993, so 11,916.00
5 … 11 13,333.33 each 13,333.33 each × 0.99, 0.985, 0.98, 0.97, 0.96, 0.94, 0.90
Total 400,000 250,000 = 400,000 − 150,000 (the 2m cap does not bind) 221,100.00

On a 12/12 basis (paidMonths = 12) the same claim reimburses only 202,065.00. The ratio is 0.91: the run-out is what the longer window buys. The test ThePaidWindow_AndTheDeductible_Bind pins the ordering 12/12 < 12/15 ≤ 12/24. It also pins that 12/12 recovers 0.70–0.92 of 12/15 over a whole run; the filed contract-basis factors imply about 0.84.

The laser is the known claimant. In year 0 it is drawn from the same Pareto, starts at month 0, and uses its own deductible, Math.Max(laser.Deductible, terms.Deductible) = 400,000.

Worked: a laser claim of 600,000 reimburses 176,139.00 on our share by month 14.

5. The Pareto analytics behind those draws

From Source/Pareto.cs:

GetMean     = Alpha * X0 / (Alpha - 1)                                   // α > 1
GetVariance = X0² · α / ((α − 1)² · (α − 2))                              // α > 2
S(x)        = (X0 / x)^α

With X₀ = 100,000 and α = 2.2:

6. The result, the spread and the stored price

LossRatio (per year)   = Recoveries / Premium
ratios                 = first-year recoveries of each simulation / first-year premium, sorted
LossRatioP10/P50/P90   = Percentile(ratios, q) = sorted[Math.Clamp((int)Math.Ceiling(q * n) - 1, 0, n - 1)]   // nearest rank
ProbabilityOfRecovery  = count(first-year recoveries > 0) / simulations
TotalResult            = TotalPremium - TotalCosts - TotalRecoveries
Margin (stored)        = TotalPremium > 0 ? TotalResult / TotalPremium : 0                        // StopLossPricing.Summarize
CumulativeNet[t]       = Σ_{τ≤t} (premium·(1 − cost ratios) − recoveries), averaged; P10/P90 per month

The Demo model's actual figures were read from the live Result area on the public instance on 2026-11-03: 400 simulations, seed 2026.

Figure Value How it closes
Our premium, 3 years 2,290,160 = the hand total above
Recoveries −1,464,538 simulated
Costs −526,737 = 23 % of premium, the hand total above
Result 298,885 2,290,160 − 1,464,538 − 526,737
Margin 13.1 % 298,885 / 2,290,160
Expected loss ratio, year 1 60.3 % simulated year-1 recoveries / 691,891
Loss ratio P10 – P90 6.5 % – 140.4 % nearest-rank percentiles over 400 runs
Chance of a recovery 96.8 % share of runs where year 1 recovers anything

The stored price (StopLossPricingResult, StopLossModel/Source/StopLossPricing.cs) holds exactly these figures. ExpectedLossRatio = r.ByYear[0].LossRatio; the other fields are copied from the run, plus a Fingerprint. The fingerprint is the first 12 bytes, in hex, of the SHA-256 of [EngineVersion, model-without-pricing-fields-and-name, terms, assumptions, (conditions, table on the duration engine)]. StopLossPricing.Decide maps the stored state to a step:

Stored state Request Step
result current (IsCurrent) none None
result current Price ClearRequest
anything not current Price Price, even right after a failed attempt on the same inputs
not current; LastAttemptFingerprint ?? Result?.Fingerprint differs from the current fingerprint (a stale result, or inputs repaired after a failure) none Price
not current; the last attempt was on these same inputs (for example a failed attempt that wrote a PricingNote) none None: it waits for a request
never priced or attempted (both fingerprints null) none None

The shipped Demo node carries no stored result: its Price area reads Not priced yet. The test TheStoredResult_EqualsTheViewsNumbers pins that a stored price equals the view's figures.

The claim-duration engine (ClaimDurationSimulation.Run)

Source: StopLossModel/Source/ClaimDurationSimulation.cs, with Source/ClaimDurationCurves.cs, Source/ConditionAssumptions.cs and Source/MortalityTable.cs. DurationDemo runs the same census and terms over 1 year, on:

Before it runs, Evaluate also refuses four inputs:

and it refuses a mortality table that lacks a sex or has a q outside 0–1 (Mortality.Problem).

1. Onset: one draw over the groups

A member is eligible in a policy year only if they are alive and not on claim at the year's start. An eligible member starts at most one claim a year. Within one age × sex cell, the groups are drawn in order:

p = Math.Clamp(groups[g].IncidenceAt(attained, sex), 0, 1)
k = Binomial(remaining, Math.Clamp(p / left, 0, 1));   left -= p;   remaining -= k
onset month = yearStart + rng.Next(0, 12)

IncidenceAt reads the band the age falls in. The oldest band holds past its end. An age in a gap between bands gets 0.

Worked: Cardiovascular for a male aged 45 has an incidence of 0.25875 % a year. Transplant for a male aged 50 has 0.0975 %.

2. On claim, month by month: cost, death, recovery

incurred[t] = ((t == Start && !Prevalent ? g.OnsetCost : 0) + g.MonthlyCost) * Math.Pow(1 + g.CostTrend, t / 12) * level
level       = CostLevel(cv) = Math.Exp(Math.Sqrt(s2) * z - s2 / 2),  s2 = Math.Log(1 + cv * cv)   // lognormal, mean 1; 1 when cv = 0
die     if U < MonthlyDeath = Mortality.MonthlyQ(Mortality.Scaled(Mortality.Q(table, age, sex), g.ExcessMortality))
recover if U < RecoveryHazard(d) = 1 - S(d+1) / S(d)        // the last step's hazard holds past the curve's end
else stay on claim; the claim stops at the cover's end (CutOff)

t / 12 is integer division, so the trend steps once per policy year.

The mortality arithmetic (Source/MortalityTable.cs):

MonthlyQ(q)       = 1 - Math.Pow(1 - q, 1.0 / 12)          // constant force; twelve months compound back to q
Scaled(q, m)      = Math.Clamp(q * m, 0, 1)
Survival(x, n)    = Π_{k<n} (1 - Q(x + k))                  // ₙpₓ
CurtateExpectation = Σ_{k≥1} ₖpₓ ;   CompleteExpectation = curtate + 0.5
Q(x, years)       = q(x) · (1 − i(x))^years   when the table has an improvement scale

Worked, the laser. The condition text is "NSTEMI myocardial infarction". Classify finds no exact group id. It then takes the first group, in order, with a keyword in the text: Cancer has none, and Cardiovascular matches "myocardial". The laser is male, aged 58.

A laser is prevalent (Prevalent: true). It pays no onset cost, only 6,000 a month × its cost level, against a 400,000 deductible. At level 1 that is about 2.8 months × 6,000 ≈ 17,000, far below the deductible. Only a very high lognormal level (cv = 1) reaches the layer.

3. The analytic duration curve

ClaimDurationCurves.Curve runs the same death and recovery recursion without randomness and with no cut-off, for 120 months:

on[d] += alive;  dies = alive * MonthlyDeath(age + d/12);  recovers = (alive - dies) * RecoveryHazard(d)
expected months on claim = Σ OnClaim;   P(dies on claim) = Died[^1]

Both are finite-horizon quantities over the 120 months d = 0 … 119. Σ OnClaim adds up the months spent on claim within that window. Died[^1] is the probability of having died by the start of month 119.

With no onset mix the curve takes a claimant aged 50, male. The figures below are computed by hand with that recursion on the shipped US2026Demo set and the US2024 table:

Group Recovery hazard, month 0 Monthly death at 50 M Months on claim, accumulated over months 0–119 P(died by the start of month 119)
Cancer 0.10 0.010816 9.09 10.1 %
Cardiovascular 0.35 0.002062 2.85 0.6 %
Transplant 0.20 0.004173 4.92 2.1 %
Renal 0.02 0.006333 35.40 26.8 %
Neonatal 0.50 0.002062 2.00 0.4 %
Neuro 0.15 0.001646 6.60 1.1 %
Specialty 0.01 0.000614 67.64 5.5 %
Other 0.25 0.001232 3.99 0.5 %

At the horizon (month 119) a Specialty claimant still has about 27 % probability of being on claim, and a Renal claimant about 3 %. For those two groups the columns are truncated, not complete. Specialty and Renal are the long-running groups. They do recover, at a monthly hazard of only about 1 % and 2 % (their continuation curves fall by 1 % and 2 % a month), so most of their claims run until death or the cut-off. A truly chronic group would need a flat curve, with a hazard of 0. No shipped group has one.

4. Reimbursement and the run-out (Reimburse)

The policy year's 12 incurred months accumulate against the deductible. In year 0 that is the episode's own deductible (the laser's, or the terms'). In later years it is the terms' deductible, reset each year. Each month's excess is paid along the completion pattern:

excess_k = Math.Min(Math.Max(running - deductible, 0) - Math.Max(before - deductible, 0), maxReimbursement - excessSoFar)
paid     = excess_k * (completion[j] - completion[j-1]) * share,   at month k + j + lag
counted  if at < paidMonths; otherwise LOST to the run-out (ClaimDurationResult.LostToRunOut)

Worked: one Transplant claim at cost level 1. It starts in month 2 and recovers after 5 months on claim.

Month incurred excess over 150k reimbursed by month 14 (× 0.9)
2 250,000 + 12,000 = 262,000 112,000 × Completion[12] = 1.0, so 100,800.00
3 12,000 12,000 × 0.996, so 10,756.80
4 12,000 12,000 × 0.993, so 10,724.40
5 12,000 12,000 × 0.990, so 10,692.00
6 12,000 12,000 × 0.985, so 10,638.00
Total 310,000 160,000 143,611.20 reimbursed + 388.80 lost to the run-out = 160,000 × 0.9

5. The duration result

The result has the same shape as the high-cost engine's: premium, recoveries, costs, the cumulative net with its P10–P90 band, and the loss-ratio spread. On top of that, ClaimDurationResult adds, per group:

It also adds a row per laser and LostToRunOut. Nobody is replaced on this engine: Members = lives + modelled lasers − dead before the year, and Leavers = 0.

DurationDemo's actual figures were read from the live Summary area on the public instance on 2026-11-03:

The frequency-severity model

Sources: FrequencySeverityLossModel/Source/FrequencySeveritySampler.cs, DiscreteDistributionEstimator.cs and LinearTimeModelSampler.cs. One simulated period draws N = Frequency.Sample(random), then N severities, and sums them:

AggregateAnnualLoss = Σ_{i=1..N} Severity.Sample(random)
E[S]   = E[N] · E[X]
Var[S] = E[N] · Var[X] + Var[N] · E[X]²

Worked: this is the fixture of the test CompoundMean / CompoundVariance (FrequencySeverityLossModel/Test/FrequencySeveritySamplerTests.cs): Poisson λ = 3, Pareto x₀ = 1, α = 5.

300,000 seeded runs recover the mean within 3 % and the variance within 10 %; those are the test's tolerances.

The estimator. It keeps Σx and Σx². Its mean is Σx / n and its population variance is Math.Max(0, Σx²/n − mean²). The test EstimatorMomentsExact pins {1, 2, 3, 4}: mean 2.5, variance 1.25.

The compressed distribution sorts the samples into nPoints equal-count bins. Each point is a bin's mean value at probability (i + ½)/nPoints. Quantile(p) interpolates linearly between bin centres and clamps to the end bins.

The linear time model. One claim of severity X is spread as follows:

Worked (test LinearMapShape): with N_inc = 3, N_paid = 5 and X = 100, incurred[1] = 33.33 and paid[2] = 20.

Panjer: one knob for the frequency

Source/Panjer.cs is parameterised by its Mean and Q = Var / Mean, and dispatches on Q:

Q < 1 − 1e-9  →  Binomial.WithMeanVariance(Mean, Mean * Q)          // under-dispersed
Q > 1 + 1e-9  →  NegativeBinomial.WithMeanVariance(Mean, Mean * Q)  // over-dispersed
otherwise     →  Poisson { Lambda = Mean }
GetMean = Mean;   GetVariance = Mean * Q

For Q < 1 these are target moments. Binomial.WithMeanVariance sets P = 1 − Q and N = Mean / P, which is usually fractional. Binomial.Sample then draws (int)Math.Round(N) trials (Source/Binomial.cs).

Worked: Panjer(10, 0.7) gives P = 0.3 and N = 33.33. The sampler draws 33 trials, so the sampled mean is 33 × 0.3 = 9.9 and the sampled variance 33 × 0.3 × 0.7 = 6.93, against the targets 10 and 7. GetMean / GetVariance keep reporting 10 and 7.

Worked (test ClaimCountRecovery): Panjer.WithMeanQ(10, 2.0) resolves to a NegativeBinomial with E[N] = 10 and Var[N] = 20. 200,000 seeded draws average within 2 % of 10.

Poisson itself is sampled by LossMath.NextPoisson: Knuth's product for λ < 30, and Hörmann's transformed rejection (PTRS) above.

Check yourself

Q1. On the Demo model you lower the paid window from 12/15 to 12/12. By how much does the single 400,000 claim with onset month 3 lose, and why?

A1. It falls from 221,100.00 to 202,065.00, a drop of 19,035 or 8.6 %. With paidMonths = 12, a payment at month k + j counts only if k + j < 12. The tail of the completion pattern for the late months (from month 3 on) falls outside the window.

Q2. On DurationDemo, why does the lasered NSTEMI claimant add almost nothing to the expected recoveries, although the laser exists to flag a high-cost claimant?

A2. Three reasons, all in the source:

The episode costs about 17,000 at cost level 1, so only a very high lognormal level pierces it.

Reference

Member Formula as in source File
Evaluate high-cost floor a.HighCostThreshold * Math.Pow(1 + a.MedicalTrend, years - 1) StopLossModel/Source/StopLossEngines.cs
MortalityPath model's → condition set's → LossModelling/MortalityTable/US2024 StopLossModel/Source/StopLossEngines.cs
year premium AnnualPremium * (1 + renewal)^y * Share StopLossModel/Source/StopLossSimulation.cs
incidence s * (1 - ρ) / (1 - s) StopLossModel/Source/StopLossSimulation.cs
Recover lump + even accrual, capped excess, completion, paid window StopLossModel/Source/StopLossSimulation.cs
Percentile nearest rank ceil(q·n) − 1 StopLossModel/Source/StopLossSimulation.cs
Binomial inversion for n·p < 30, normal approximation above StopLossModel/Source/StopLossSimulation.cs
Census.FromSummary normal spread (sd 11) over the working ages, largest remainder StopLossModel/Source/StopLossModel.cs
Summarize / Margin TotalResult / TotalPremium StopLossModel/Source/StopLossPricing.cs
Fingerprint SHA-256 → 12 bytes hex over the inputs StopLossModel/Source/StopLossPricing.cs
CostLevel lognormal, mean 1, CV cv StopLossModel/Source/ClaimDurationSimulation.cs
Reimburse capped excess, completion, lost to run-out StopLossModel/Source/ClaimDurationSimulation.cs
RecoveryHazard 1 − S(d+1)/S(d) Source/ConditionAssumptions.cs
Classify exact id → first keyword hit → DefaultGroup → last group Source/ConditionAssumptions.cs
MonthlyDeath MonthlyQ(Scaled(Q(x), excess)) Source/ClaimDurationCurves.cs
Curve deterministic on-claim / died / recovered recursion, 120 months Source/ClaimDurationCurves.cs
MonthlyQ, Survival, CurtateExpectation see above Source/MortalityTable.cs
Pareto Sample / GetMean / GetVariance X0·(1−U)^(−1/α), αX0/(α−1), X0²α/((α−1)²(α−2)) Source/Pareto.cs
compound moments E[N]E[X], E[N]Var[X] + Var[N]E[X]² FrequencySeverityLossModel/Source/FrequencySeveritySampler.cs
Panjer.Resolve Q < 1 Binomial · Q = 1 Poisson · Q > 1 NegativeBinomial Source/Panjer.cs

Related:

Reconnecting…
The connection to the server was interrupted. Trying to restore it…
Trying again…
The connection could not be restored. Reloading the page…
The server was updated. Reloading the page to pick up the latest version.