The IFRS 17 formulas
This page explains how the IFRS 17 engine turns a nominal cash-flow projection into a balance, and then explains how that balance moved. Every formula is quoted as the code writes it. The source path is named beside each formula, so you can check it.
We start with one worked example and follow it step by step. The general rules, the data model and a reference table come after.
For the methodology behind these formulas — ported from the documentation of the open-source IFRS 17 Calculation Engine, with what this implementation does and does not do yet — see The IFRS 17 methodology.
Where the inputs come from. The engine does not read the Reinsurance cash-flow cube. It reads its own input type, RawVariable: one row per group of contracts, AoC step, novelty, estimate type and amount type, holding a monthly array of nominal amounts. Figures it has already computed are stored as IfrsVariable. The queries are listed in
Ifrs17/Engine/Source/Ifrs17DataSource.cs. They read the engine hub's own partition, thenIfrs17andReinsuranceDemo. An installed copy atalice/Ifrs17/Enginereadsalice,Ifrs17andReinsuranceDemo(EngineTests.Queries_AreAnchoredAndNameTheirPartitions), and never any other partition. The demo's MTPL incurred-claims book (ReinsuranceDemo/Ifrs17/...) is built from the demo's claims register, but that happens when the data files are generated (ReinsuranceDemo/SampleData/Source/Ifrs17Spine.cs,OutstandingOnOurBook()), not at run time. For the cash-flow cube the other modules share, see How the cash-flow model works. For how the modules fit together, see the suite overview.
The worked example: a Swiss endowment, one year
The movement report uses one fixture book, and the end-to-end tests check
that same book (Ifrs17/Report/Test/EndToEndFixture.cs). Its Swiss group,
Demo/Groups/EndowmentCH, is set up as follows:
| Input | Value |
|---|---|
Claims (NIC, best estimate BE) |
−120 a year for ten years, on the monthly grid: 120 entries of −10 |
| Steps supplied | BOP/I (opening, in force) and CL/C (combined control step), both carrying the same array |
| Valuation approach | BBA, liability for remaining coverage (LRC) |
| Locked-in curve (2025-12) | 2.0 % flat |
| Closing curve (2026-12) | 1.0 % flat |
| Reporting period | 2026-12, one projection period: Shift = 0, TimeStep = 12 |
| Currency | CHF throughout, so nothing is translated |
Here is the live report computed from that fixture. The Swiss table is the one we rebuild by hand below.
Subscribers see this result live; below is the same worked example as a static table.
Swiss group EndowmentCH, best-estimate claims, CHF, reporting period 2026-12. The live report also
shows the fixture's other groups; only the Swiss one is reproduced here. Steps 3–5 below derive each
value by hand.
| AoC step | Value |
|---|---|
| Opening balance (BOP / I) | 1,087.76 |
| Cash flow (CF / I) | −120.00 |
| Interest accretion (IA / I) | +20.66 |
| Yield-curve update (YCU / I) | 0.00 |
| Combined control (CL / C) | 0.00 |
| Closing balance (EOP / C) | 988.41 |
The movement report runs on the test fixture, not on the installed book. Its node says so: "It is not yet wired to the installed book" (
Ifrs17/MovementReport.json).
Step 1 — the measurement basis
Ifrs17Reporting.Measure takes the economic basis from the group's valuation approach
(Ifrs17/Engine/Source/Identities.cs):
(ValuationApproaches.BBA, _) => EconomicBases.L,
(ValuationApproaches.VFA, _) => EconomicBases.C,
(ValuationApproaches.PAA, LiabilityTypes.LIC) => EconomicBases.C,
_ => EconomicBases.N,
The group is BBA, so every step is valued on the locked-in basis L. On that basis the curve
is the one observed when the group was recognised (data.Year, data.Month = 2025-12), so it is
2.0 % (ImportStorage.GetYearlyYieldCurve).
Step 2 — annual rate to monthly factors
MonthlyRate (Ifrs17/Engine/Source/PresentValue.cs):
double[] Interest => YearlyYieldCurve.Select(rate => Math.Pow(1d + rate, 1d / Ifrs17Constants.MonthInAYear)).ToArray();
double[] Discount => Interest.Select(x => Math.Pow(x, -1)).ToArray();
With rate 0.02:
- monthly interest factor = 1.02^(1/12) = 1.00165158
- monthly discount factor = 1 / 1.00165158 = 0.99835114
The code takes the twelfth root of the annual rate. It does not divide the rate by twelve, so compounding is preserved.
Step 3 — discount and cumulate into a reserve
DiscountedCashflow.Values (PresentValue.cs) calls ComputeDiscountAndCumulate
(Ifrs17/Source/Arithmetics.cs) and flips the sign:
double[] Values => Arithmetics.Multiply(-1d, NominalValues.ComputeDiscountAndCumulate(MonthlyDiscounting, PeriodType));
// PeriodType.EndOfPeriod (claims, here):
for (var i = nominalValues.Length - 1; i >= 0; i--)
result[i] = (nominalValues[i] + result.GetValidElement(i + 1)) * monthlyDiscounting.GetValidElement(i / 12);
The recursion runs backwards from the last month. Each entry result[i] is the value at the
start of month i of every payment from month i onwards. The sign flip turns a cash outflow (−)
into a liability (+). The resulting reserve at each month:
Month i |
Reserve R[i] |
|---|---|
| 0 | 1,087.76 |
| 1 | 1,079.55 |
| 11 | 996.77 |
| 12 | 988.41 |
Step 4 — read each step at the point its configuration names
PresentValue.GetValueFromValues (PresentValue.cs) reads the value array at the point the step's
configured ValuationPeriod names:
ValuationPeriod.BeginningOfPeriod => values.ElementAtOrDefault(shift),
ValuationPeriod.MidOfPeriod => values.ElementAtOrDefault(shift + Convert.ToInt32(Math.Round(timeStep / 2d, MidpointRounding.AwayFromZero)) - 1),
ValuationPeriod.Delta => values.Skip(shift).Take(timeStep).Sum(),
ValuationPeriod.EndOfPeriod => values.ElementAtOrDefault(shift + timeStep),
Each step then works out as follows:
| Step | Rule that applies | Formula | Value |
|---|---|---|---|
| BOP / I opening | PresentValueFromDiscountedCashflow |
R[shift] = R[0] |
1,087.76 |
| CF / I cash flow | CashflowAocStep |
nominal values, read as Delta: sum of months 0–11 = 12 × −10 |
−120.00 |
| IA / I interest accretion | PresentValueWithInterestAccretion |
Σ over months 0–11 of R[i] × (1.00165158 − 1) |
+20.66 |
| YCU / I yield-curve update | TelescopicDifference |
R_YCU[12] − R_BOP[12]; both on the locked-in 2 % curve |
0.00 |
| CL / C combined control | TelescopicDifference |
R_CL[12] − R_YCU[12]; same claims, same curve |
0.00 |
| EOP / C closing | PresentValueFromDiscountedCashflow |
R[shift + timeStep] = R[12], read from CL/C |
988.41 |
The interest-accretion line is InterestAccretion.Values for an end-of-period amount type:
result[i] = -1d * discounted[i] * (interestFactors.GetValidElement(i / Ifrs17Constants.MonthInAYear) - 1d);
Here discounted is the reserve with its sign flipped back. The result is the interest earned on
the balance in each month.
Step 5 — the reconciliation
opening 1,087.76
cash flow −120.00
interest +20.66
YCU, CL 0.00
---------
explained 988.41 = valued closing balance 988.41
The two sides agree exactly; the gap is 3 × 10⁻¹³. They agree because of how the reserve recursion
is built, not because anything was adjusted. Each month the reserve moves by
R[i+1] = R[i] × f − c, where f is the monthly interest factor and c is that month's payment.
Summed over the twelve months:
Σ R[i] × (f − 1) = R[12] − R[0] + 12 × 10
So the interest-accretion line is exactly the closing balance, less the opening balance, plus the cash that was paid.
The live report shows 1,087.8 − 120.0 + 20.7 = 988.5, then a Rounding −0.1 line, then a closing balance of 988.4. That rounding line comes from summing figures already rounded to one decimal. The unrounded figures above agree exactly.
What if the curve were current, not locked in? On the current basis C, the yield-curve-update
step reads the closing 1.0 % curve, while its parent BOP/I reads the opening 2.0 % curve
(YcPeriod EndOfPeriod versus BeginningOfPeriod). The step would then be
R_1%[12] − R_2%[12] = 1,032.63 − 988.41 = +44.21: rates fell, so the liability rose. The
fixture is BBA and therefore locked-in, which is why the report shows 0.0 on that line. The
+44.21 is computed with the same formulas but is not a figure the module displays.
EngineTests.YieldCurveUpdate_MeasuresTheCurveMove tests the same mechanism in the other direction:
rates rise from 2 % to 4 %, and the step must come out negative.
Step 6 — the German book: translation and the FX step
The German group in the same fixture pays −120 a year for ten years in USD. Its entity measures
in EUR and the group reports in CHF. The USD rate moves from 0.90 (spot, 2025-12) to 1.00
(spot, 2026-12), with an average of 0.95. The EUR rate is flat at 1.00. Fx.Rate
(Ifrs17/Report/Source/Fx.cs):
double Rate => FunctionalRate * GroupRate;
// FunctionalRate: BeginningOfPeriod → previous-period Spot, Average → Average, otherwise → current Spot
// GroupRate: functional → group at the Average rate (only when CurrencyType.Group)
Each step is translated at its own rate: the opening balance at 0.90, cash flow and interest
accretion at 0.95, the closing balance at 1.00. Translated that way, the steps no longer add up to
the closing balance. FxData.FxMovement names the gap instead of hiding it:
closing.Minus(movements).AggregateOver(Novelty, VariableType) → VariableType = FX, Novelty = C
The live report gives: opening 912.5, CF −114.0, IA +31.9, FX +97.1, closing
927.5. EndToEndTests.GermanBook_ReconcilesOnlyBecauseOfTheFxStep asserts that the FX step is
non-zero and that the report reconciles. The consolidated group sums each entity after
translation (ReportingNodeRollup). The group total is 2,000.3 → 1,915.9.
The CSM and the loss component — worked on the pinned numbers
The movement report above shows best-estimate present values only. Ifrs17Reporting.Measure
evaluates PresentValue and nothing else. The margin scopes are tested on their own, in
Ifrs17/Engine/Test/TechnicalMarginTests.cs, with these pinned numbers.
Sign convention. The technical margin is positive when the group is onerous:
string Classify(double aggregatedTechnicalMargin) =>
aggregatedTechnicalMargin > Ifrs17Constants.Precision ? EstimateTypes.L : EstimateTypes.C;
Here Precision = 1E-5. A negative margin means profit not yet earned, which is the CSM.
The case: a group has accumulated a margin of −30, so it carries 30 of unearned profit. A
step then adds +50: the cash flows deteriorate by 50. AllocateTechnicalMargin.Value
(Ifrs17/Engine/Source/TechnicalMargin.cs):
double Value => (HasSwitch, EstimateType == ComputedEstimateType) switch
{
(true, true) => StepMargin + AggregatedMargin,
(true, false) => -1d * AggregatedMargin,
(false, true) => StepMargin,
_ => 0d,
};
- After the step the margin is −30 + 50 = +20 > 1E-5. The group is now classified
L, while before the step it wasC, soHasSwitchis true. - Asked for
L(the loss component):(true, true)gives 50 + (−30) = +20. - Asked for
C:(true, false)gives −1 × (−30) = +30. ContractualServiceMargin.Value = -1d * GetScope<AllocateTechnicalMargin>(Identity, EstimateTypes.C).Value= −30. The step releases the whole CSM of 30.LossComponent.Value = GetScope<AllocateTechnicalMargin>(Identity, EstimateTypes.L).Value= +20. A loss of 20 is recognised.
The two allocations add up to exactly the step: 20 + 30 = 50. Switch_ConservesTheStep checks this
for four (accumulated margin, step) pairs. The group never ends up holding 30 of profit and 20
of loss at the same time.
What the margin is measured from. TechnicalMargin.Value:
double Value =>
GetScope<TechnicalMarginAmountType>((Identity, EstimateType)).Values
.Sum(amountType => GetScope<PresentValue>(new ValuationIdentity(Identity, amountType, EstimateTypes.BE, null), EconomicBasis).Value)
+ GetScope<PresentValue>(new ValuationIdentity(Identity, string.Empty, EstimateTypes.RA, null), EconomicBasis).Value;
So the margin is the best estimate summed over the service amount types, plus the risk adjustment.
TechnicalMarginAmountType leaves out coverage units, non-attributable expenses and CDR. For loss
recovery (LR) it also leaves out attributable expenses. Under PAA it also leaves out premiums
and deferrable expenses. The basis is L (locked-in), or C under VFA
(TechnicalMarginForCurrentBasis).
The risk adjustment is an input, not a model. The engine values the
RAestimate type through the samePresentValuechain as any other cash flow. No scope here computes a risk adjustment (no cost of capital, no confidence level). It has to arrive asRawVariablerows withEstimateType = RA.
The general rule
A step's value is its own valuation minus its parent's. That one line,
TelescopicDifference.Values = CurrentValues − PreviousValues, is the analysis of change.
ParentAocStep chains each step to the one before it, in the order the configuration gives. Adding
up the whole chain therefore collapses to the last valuation minus the first, so the report
reconciles by construction. Steps are data: each AocConfiguration
row gives a step's order, data type, yield-curve period, FX period and valuation period. To add a
step, you add a row.
Four kinds of step are not themselves telescopic differences:
- opening and closing balances, read straight off the discounted cash flow
(
PresentValueFromDiscountedCashflow); - cash flow, which is the nominal amounts that moved in the period;
- interest accretion, the unwinding of the discount.
Only the last three are Calculated in the configuration (EOP/C, CF/I, IA/I in
EndToEndFixture.Configuration); the opening balance BOP/I is Optional. ParentAocStep
drops Calculated steps from the parent candidates, but keeps the opening balance. So the
yield-curve update telescopes against the opening balance, not against interest accretion
(EngineTests.StepGraph_TelescopesAgainstTheLastValuation).
The data model
Prerequisite: the class diagrams on this page are embedded from the Data Modelling plugin's explorer (/DataModelling/Explorer), which Ifrs17 does not require. On an installation without it they render nothing; each type's own page under /Ifrs17 still lists its properties.
A group of contracts, the types it references, and the inputs that point at it:
Check yourself
Q1. In the Swiss example, the closing curve is 1.0 % and the opening curve is 2.0 %. Why does the yield-curve-update line show 0.0 rather than about +44?
A. The group is
BBA, soGetDefaultEconomicBasisDrivermeasures it on the locked-in basisL.GetYearlyYieldCurvethen returns the curve from the recognition date (2.0 %) for every step, including the yield-curve update. A step and its parent valued on the same curve differ by nothing. The +44.21 shows up only on the current basisC, which aVFAgroup or aPAAincurred-claims group would use.
Q2. A profitable group carries an accumulated technical margin of −100, and a step adds −20.
What do ContractualServiceMargin and LossComponent report for that step?
A. −100 − 20 = −120, still classified
C, so there is no switch.Allocate(C)=StepMargin= −20, so the CSM is −1 × −20 = +20: the CSM grows by 20.Allocate(L)= 0 (TechnicalMarginTests.Profitable_CarriesMarginAndNoLoss).
Q3. Why does the cash-flow step use Delta, while the opening balance reads a single month?
A. A cash flow is a flow: money that moved over the period. It is the sum of the months the period spans. A balance is a stock: the reserve at one point in time. The two steps share the reading helper (
GetValueFromValues), not the array it reads:CashflowAocStepsums the nominal array (NominalCashflow.Values, 12 × −10 = −120), whilePresentValueFromDiscountedCashflowreads one month of the discounted reserve (DiscountedCashflow.Values,R[0]= 1,087.76).
What this module does not (yet) do
These are stated plainly so nobody has to rediscover them:
- No CSM release over coverage units. The amortisation step
AM/Cis inComputationHelper.AocStepWithNoPv. No scope reads the coverage-unit amount type orDataNodeParameter.ReleasePatternto release the margin. The original open-source engine does this; the port has not brought it across yet. - The movement report shows only best-estimate present values. CSM, loss component and risk
adjustment rows are not part of
Ifrs17Reporting.Measure. - The report is fixture-backed. The installed demo book (
ReinsuranceDemo/Ifrs17/Groups/*, including the premium-bearing Endowment CH 2025) is not yet what the movement report renders. - Not established here: when several
WithApplicabilityrules match one identity, which one wins. In the examples on this page, each step matches exactly one rule. The precedence lives in the platform's business-rules registry, not in this module.
Reference — the scopes
Measurement (Ifrs17/Engine/Source/PresentValue.cs)
| Scope | Identity | Formula (as written) |
|---|---|---|
MonthlyRate |
ImportIdentity |
Interest = (1 + rate)^(1/12) per curve year; Discount = Interest^-1; basis N ⇒ curve {0} |
NominalCashflow |
ValuationIdentity |
Σ over ReferenceAocStep of GetValues(step, amountType, estimateType, accidentYear) |
AllClaimsCashflow |
″ | the same, summed over every claims amount type (applies to credit-risk amount types) |
CreditDefaultRiskNominalCashflow |
″ | PvCreditDefaultDecumulated − NominalClaimsCashflow; backward result[i] = e^(−rate)·result[i+1] + claims[i] − claims[i+1] |
DiscountedCashflow |
″ | −1 × ComputeDiscountAndCumulate(nominal, discount, periodType) |
DiscountedCreditRiskCashflow |
″ | −1 × ComputeDiscountAndCumulateWithCreditDefaultRisk(nominal, discount, nonPerformanceRiskRate), for reinsurance held only |
TelescopicDifference |
″ | DiscountedCashflow(this) − Σ DiscountedCashflow(parent steps) |
InterestAccretion |
″ | end of period: −discounted[i] × (f − 1); beginning of period: −(discounted[i] − nominal[i]) × (f − 1) |
PresentValue |
″ | Value = GetValueFromValues(Values); Values = TelescopicDifference unless a rule below applies |
PresentValueFromDiscountedCashflow |
″ | BOP (novelty ≠ C) and EOP: DiscountedCashflow.Values |
CashflowAocStep |
″ | CF: NominalCashflow.Values, read as Delta |
PresentValueWithInterestAccretion |
″ | IA: InterestAccretion.Values |
EmptyValuesAocStep |
″ | EA/C, AM/C, RCU/I, and CRU on a non-CDR amount type: 0 |
ComputePresentValueWithIfrsVariable |
″ | not a cash-flow import: read back the stored IfrsVariable |
Step structure (Ifrs17/Engine/Source/AocStructure.cs)
| Scope | What it returns |
|---|---|
PreviousAocSteps |
the steps with a lower Order, of the same novelty (all novelties for C) |
ParentAocStep |
the last non-Calculated previous step, excluding CRU unless the amount type is CDR (for a CDR valuation the credit-risk update stays an eligible parent); none for BOP or calculated steps; the last step of each novelty for the first combined step |
ReferenceAocStep / …ForProjections |
which step's data a calculated step reads: EA ← CF/C, AM ← CL/C, EOP ← CL/C, otherwise the last supplied step; in projection years, in-force steps read CL/C |
GetIdentities / GetCashflowIdentities |
the steps to compute: every configured step, or (for cash-flow imports) the supplied steps plus the derived ones for novelties that have data |
Technical margin (Ifrs17/Engine/Source/TechnicalMargin.cs)
| Scope | Formula (as written) |
|---|---|
TechnicalMarginAmountType |
the group's amount types, minus CU, non-attributable expenses and CDR (minus attributable expenses for LR; minus premiums and deferrables under PAA) |
TechnicalMargin |
Σ PV(BE, amountType) + PV(RA) on basis L; AggregatedValue = Σ TechnicalMargin(previous steps) |
TechnicalMarginForCurrentBasis |
VFA: basis C |
TechnicalMarginForOpeningBalance |
BOP/I: −stored(C) + stored(L) |
TechnicalMarginIsZero |
CF: 0 |
TechnicalMarginForInterestAccretion |
IA: AggregatedValue × (Π monthly factors over the period − 1) |
TechnicalMarginForClosingBalance |
EOP: AggregatedValue |
AllocateTechnicalMargin |
the four-case switch above; Classify(m) = m > 1E-5 ? L : C; opening balances never switch; closing balance = Σ allocations of the previous steps |
ContractualServiceMargin |
−TechnicalMargin(C) for reinsurance held, else −AllocateTechnicalMargin(C) |
LossComponent |
AllocateTechnicalMargin(L) |
Reporting (Ifrs17/Report/Source/)
| Scope | Formula (as written) |
|---|---|
Fx |
Rate = FunctionalRate × GroupRate |
FxData |
Translated + FxMovement; FxMovement = closing − (all other steps), aggregated over novelty and step, kept only when its absolute value is at least Precision |
FxDataWithoutAoc |
estimate types with no AoC chain (A, F, BEPA, APA, PL): Translated only |
ReportingNodeRollup |
FxData.Values aggregated over reporting node, group and portfolio |
AocReport |
Unexplained = Closing − Opening − Movements; Reconciles = abs(Unexplained) < Precision |