Actuarial-Interest Calculations
Definition
Actuarial interest is a method of calculating interest where the interest is accrued during the schedule. The initial interest balance is zero, and each time a scheduled payment is due or an actual payment is made, the interest is calculated based on the outstanding principal balance and the number of days it has been outstanding. Payments are applied in the order charges -> interest -> fees -> principal, but as interest is not front loaded (as in the add-on interest method), this means each payment pays off the interest in full and then the principal balance is reduced. This means that the principal balance is lower than under the add-on interest method, and therefore the interest accrued is lower. Calculating the interest this way is the simplest method of calculating interest.
Relevant Code
The Scheduling module contains the functions that create the basic schedule. The basic schedule is a basic schedule that allows us to calculate the interest accrued over the schedule as well as the level and final payments.
Let's start by defining the parameters. Let's define a loan of £1000 advanced on 22 April 2025, paid back over 4 months starting one month after the advance date. The loan has a daily interest rate of 0.798% and a cap of 0.8% per day as well as a cap of 100% of the principal amount. Interest is calculated using the actuarial method.
let bp: BasicParameters = {
EvaluationDate = Date(2025, 4, 22)
StartDate = Date(2025, 4, 22)
Principal = 1000_00L<Cent>
ScheduleConfig =
AutoGenerateSchedule {
UnitPeriodConfig = Monthly(1, 2025, 5, 22)
ScheduleLength = PaymentCount 4
}
PaymentConfig = {
LevelPaymentOption = LowerFinalPayment
Rounding = RoundUp
}
FeeConfig = ValueNone
InterestConfig = {
Method = Interest.Method.Actuarial
StandardRate = Interest.Rate.Daily(Percent 0.798m)
Cap = {
TotalAmount = Amount.Percentage(Percent 100m, Restriction.NoLimit)
DailyAmount = Amount.Percentage(Percent 0.8m, Restriction.NoLimit)
}
Rounding = RoundDown
AprMethod = Apr.CalculationMethod.UnitedKingdom 3
}
}
Then we call the Scheduling.calculateBasicSchedule function to generate the schedule:
let actuarialInterestSchedule = calculateBasicSchedule bp
Day | Scheduled payment | Actuarial interest | Interest portion | Principal portion | Interest balance | Principal balance | Total actuarial interest | Total interest | Total principal |
---|---|---|---|---|---|---|---|---|---|
0 | 0.00 | 0.0000 | 0.00 | 0.00 | 0.00 | 1,000.00 | 0.0000 | 0.00 | 0.00 |
30 | 417.72 | 239.4000 | 239.40 | 178.32 | 0.00 | 821.68 | 239.4000 | 239.40 | 178.32 |
61 | 417.72 | 203.2672 | 203.26 | 214.46 | 0.00 | 607.22 | 442.6672 | 442.66 | 392.78 |
91 | 417.72 | 145.3685 | 145.36 | 272.36 | 0.00 | 334.86 | 588.0357 | 588.02 | 665.14 |
122 | 417.69 | 82.8377 | 82.83 | 334.86 | 0.00 | 0.00 | 670.8733 | 670.85 | 1,000.00 |
As there is no initial interest balance, the principal starts to be paid off immediately, and the total interest accrued is therefore lower.
Add-on-interest comparison (click to expand)
To illustrate this, we can compare the actuarial-interest schedule with an add-on-interest schedule:let addOnInterestSchedule =
calculateBasicSchedule {
bp with
InterestConfig.Method = Interest.Method.AddOn
}
Day | Scheduled payment | Actuarial interest | Interest portion | Principal portion | Interest balance | Principal balance | Total actuarial interest | Total interest | Total principal |
---|---|---|---|---|---|---|---|---|---|
0 | 0.00 | 0.0000 | 0.00 | 0.00 | 816.56 | 1,000.00 | 0.0000 | 0.00 | 0.00 |
30 | 454.15 | 239.4000 | 454.15 | 0.00 | 362.41 | 1,000.00 | 239.4000 | 454.15 | 0.00 |
61 | 454.15 | 247.3800 | 362.41 | 91.74 | 0.00 | 908.26 | 486.7800 | 816.56 | 91.74 |
91 | 454.15 | 217.4374 | 0.00 | 454.15 | 0.00 | 454.11 | 704.2174 | 816.56 | 545.89 |
122 | 454.11 | 112.3377 | 0.00 | 454.11 | 0.00 | 0.00 | 816.5552 | 816.56 | 1,000.00 |
Here, the schedule has calculated an initial interest balance of £816.56. We can see that the interest balance is paid off before the principal, meaning that the full principal remains outstanding for two months. Given that interest is accrued on the principal balance only (no interest on interest), maintaining a higher principal balance for longer means that the interest accrued is higher than it would be if the principal was paid off first.
Calculation Details
Scheduling.calculateBasicSchedule is the function that generates the schedule. Here is a summary of the calculation steps:
- Generate payment days: generate the payment days based on the unit-period (e.g. monthly) and the first payment date
- Estimate total interest: estimate the total interest and level payment to improve the solver performance
- Solve for payment values: use the bisection method to determine the level payments required
- Tweak final payment: ensure the final payment fully amortises the schedule
Let's look at each of these items in more detail.
Step 1: Generate payment days
Here we take the schedule config from the parameters and generate the payment days. In this example, the schedule is auto-generated, so the Scheduling.generatePaymentMap function takes the start date of the schedule, the unit period and the first payment date, and generates the payment days. It is also possible to specify the payment days manually or specify multiple ranges of dates at different intervals. However, the auto-generated schedule is the most common use case, and ensures respect for varying month lengths and month-end tracking dates. We'll also get extract a couple of values for later use.
let paymentMap = generatePaymentMap bp.StartDate bp.ScheduleConfig
let paymentDays = paymentMap |> Map.keys |> Seq.toArray
let finalScheduledPaymentDay = Array.last paymentDays
let paymentCount = Array.length paymentDays
Payment days = 30, 61, 91, 122
Step 2: Estimate the total interest
To make the iteration more efficient, we use an initial guess for the payment value, which is calculated based on the estimated total interest and the number of payments.
let roughInterest =
let dailyInterestRate =
bp.InterestConfig.StandardRate |> Interest.Rate.daily |> Percent.toDecimal
Cent.toDecimalCent bp.Principal
* dailyInterestRate
* decimal finalScheduledPaymentDay
* Fraction.toDecimal (Fraction.Simple(2, 3))
let roughPayment =
calculateLevelPayment bp.Principal 0L<Cent> roughInterest paymentCount bp.PaymentConfig.Rounding
|> Cent.toDecimalCent
|> decimal
Estimated interest total = 649.04
Initial payment guess = 412.26
Step 3: Solve for payment values
Determining the payment values requires the use of a solver, because payment values determine how much principal is paid off each unit-period, and therefore how much interest is accrued, which in turn affects the payment values. We use the bisection method (Array.solveBisection) for this. This method runs a generator function (Scheduling.generatePaymentValue) on the schedule, which calculates the final principal balance for a given payment value. The bisection method then iteratively narrows down the level payment value until the final principal balance is close to zero (usually just below zero, so the final payment can be slightly smaller).
let generatePaymentValue (bp: BasicParameters) paymentDays firstItem roughPayment =
let scheduledPayment =
roughPayment
|> Cent.round bp.PaymentConfig.Rounding
|> fun rp -> ScheduledPayment.quick (ValueSome rp) ValueNone
let schedule =
paymentDays
|> Array.fold
(fun basicItem pd -> generateItem bp bp.InterestConfig.Method scheduledPayment basicItem pd)
firstItem
let principalBalance = decimal schedule.PrincipalBalance
principalBalance, ScheduledPayment.total schedule.ScheduledPayment |> Cent.toDecimal
let initialBasicItem = {
BasicItem.zero with
PrincipalBalance = bp.Principal
}
let basicItems =
let solution =
Array.solveBisection
(generatePaymentValue bp paymentDays initialBasicItem)
100u
roughPayment
(LevelPaymentOption.toTargetTolerance bp.PaymentConfig.LevelPaymentOption)
(ToleranceSteps.forPaymentValue paymentCount)
match solution with
| Solution.Found(paymentValue, _, _) ->
let paymentMap' =
paymentMap
|> Map.map (fun _ sp -> {
sp with
Original = sp.Original |> ValueOption.map (fun _ -> paymentValue |> Cent.fromDecimal)
})
paymentDays
|> Array.scan
(fun basicItem pd -> generateItem bp bp.InterestConfig.Method paymentMap'[pd] basicItem pd)
initialBasicItem
| _ -> [||]
Day | Scheduled payment | Actuarial interest | Interest portion | Principal portion | Interest balance | Principal balance | Total actuarial interest | Total interest | Total principal |
---|---|---|---|---|---|---|---|---|---|
0 | 0.00 | 0.0000 | 0.00 | 0.00 | 0.00 | 1,000.00 | 0.0000 | 0.00 | 0.00 |
30 | 417.72 | 239.4000 | 239.40 | 178.32 | 0.00 | 821.68 | 239.4000 | 239.40 | 178.32 |
61 | 417.72 | 203.2672 | 203.26 | 214.46 | 0.00 | 607.22 | 442.6672 | 442.66 | 392.78 |
91 | 417.72 | 145.3685 | 145.36 | 272.36 | 0.00 | 334.86 | 588.0357 | 588.02 | 665.14 |
122 | 417.72 | 82.8377 | 82.83 | 334.89 | 0.00 | -0.03 | 670.8733 | 670.85 | 1,000.03 |
Step 4: Tweak final payment
The final payment is adjusted (Scheduling.adjustFinalPayment) to ensure that the final principal balance is zero.
let items =
basicItems
|> adjustFinalPayment finalScheduledPaymentDay bp.ScheduleConfig.IsAutoGenerateSchedule
Day | Scheduled payment | Actuarial interest | Interest portion | Principal portion | Interest balance | Principal balance | Total actuarial interest | Total interest | Total principal |
---|---|---|---|---|---|---|---|---|---|
0 | 0.00 | 0.0000 | 0.00 | 0.00 | 0.00 | 1,000.00 | 0.0000 | 0.00 | 0.00 |
30 | 417.72 | 239.4000 | 239.40 | 178.32 | 0.00 | 821.68 | 239.4000 | 239.40 | 178.32 |
61 | 417.72 | 203.2672 | 203.26 | 214.46 | 0.00 | 607.22 | 442.6672 | 442.66 | 392.78 |
91 | 417.72 | 145.3685 | 145.36 | 272.36 | 0.00 | 334.86 | 588.0357 | 588.02 | 665.14 |
122 | 417.69 | 82.8377 | 82.83 | 334.86 | 0.00 | 0.00 | 670.8733 | 670.85 | 1,000.00 |
As an extra step, the library calculates a number of statistics for the schedule, including the total interest accrued, the total fees and charges, the total payments made, and the final principal balance. The full output for this schedule, including stats, is available in the Output section in the page Unit-Test Outputs, under Compliance. This particular example is defined as ComplianceTest023.
namespace FSharp
--------------------
namespace Microsoft.FSharp
<summary> convenience functions and options to help with calculations </summary>
<summary> a .NET Framework polyfill equivalent to the DateOnly structure in .NET Core </summary>
<summary> functions for generating a regular payment schedule, with payment amounts, interest and APR </summary>
<summary> an unambiguous way to represent regular date intervals and generate schedules based on them note: unit-period definitions are based on US federal legislation but the definitions are universally applicable </summary>
module BasicParameters from FSharp.Finance.Personal.Scheduling
<summary> parameters for creating a payment schedule </summary>
--------------------
[<Struct>] type BasicParameters = { EvaluationDate: Date StartDate: Date Principal: int64<Cent> ScheduleConfig: ScheduleConfig PaymentConfig: BasicConfig FeeConfig: BasicConfig voption InterestConfig: BasicConfig }
<summary> parameters for creating a payment schedule </summary>
[<Struct>] type Date = new: year: int * month: int * day: int -> Date val Year: int val Month: int val Day: int member AddDays: i: int -> Date member AddMonths: i: int -> Date member AddYears: i: int -> Date member ToDateTime: unit -> DateTime static member (-) : d1: Date * d2: Date -> TimeSpan static member DaysInMonth: year: int * month: int -> int ...
<summary> the date at the customer's location - ensure any time-zone conversion is performed before using this - as all calculations are date-only with no time component, summer time or other such time artefacts </summary>
--------------------
Date ()
new: year: int * month: int * day: int -> Date
module Cent from FSharp.Finance.Personal.Calculation
<summary> utility functions for base currency unit values </summary>
--------------------
[<Measure>] type Cent
<summary> the base unit of a currency (cent, penny, øre etc.) </summary>
module ScheduleConfig from FSharp.Finance.Personal.Scheduling
<summary> whether a payment plan is generated according to a regular schedule or is an irregular array of payments </summary>
--------------------
[<Struct>] type ScheduleConfig = | AutoGenerateSchedule of AutoGenerateSchedule: AutoGenerateSchedule | FixedSchedules of FixedSchedules: FixedSchedule array | CustomSchedule of CustomSchedule: PaymentMap
<summary> whether a payment plan is generated according to a regular schedule or is an irregular array of payments </summary>
union case ScheduleConfig.AutoGenerateSchedule: AutoGenerateSchedule: AutoGenerateSchedule -> ScheduleConfig
<summary> a schedule based on a unit-period config with a specific number of payments with an auto-calculated amount, optionally limited to a maximum duration </summary>
--------------------
[<Struct>] type AutoGenerateSchedule = { UnitPeriodConfig: Config ScheduleLength: ScheduleLength }
<summary> a regular schedule based on a unit-period config with a specific number of payments with an auto-calculated amount </summary>
<summary> (multi-)monthly: every n months starting on the date given by year, month and day, which tracks month-end (see config) </summary>
<summary> defines the length of a payment schedule, either by the number of payments or by the maximum duration </summary>
module LevelPaymentOption from FSharp.Finance.Personal.Scheduling
<summary> when calculating the level payments, whether the final payment should be lower or higher than the level payment </summary>
--------------------
[<Struct>] type LevelPaymentOption = | LowerFinalPayment | SimilarFinalPayment | HigherFinalPayment member Html: string with get
<summary> when calculating the level payments, whether the final payment should be lower or higher than the level payment </summary>
<summary> the final payment must be lower than the level payment </summary>
module Rounding from FSharp.Finance.Personal.Calculation
<summary> the type of rounding, specifying midpoint-rounding where necessary </summary>
--------------------
[<Struct>] type Rounding = | NoRounding | RoundUp | RoundDown | RoundWith of MidpointRounding member Html: string with get
<summary> the type of rounding, specifying midpoint-rounding where necessary </summary>
<summary> round up to the specified precision (= ceiling) </summary>
<summary> methods for calculating interest and unambiguously expressing interest rates, as well as enforcing regulatory caps on interest chargeable </summary>
<summary> the method used to calculate the interest </summary>
<summary> actuarial interest method, where interest is based on the principal balance and the number of days outstanding </summary>
module Rate from FSharp.Finance.Personal.Interest
--------------------
[<Struct>] type Rate = | Zero | Annual of Percent | Daily of Percent member Html: string with get
<summary> the interest rate expressed as either an annual or a daily rate </summary>
<summary> the daily interest rate, or the annual interest rate divided by 365 </summary>
union case Percent.Percent: decimal -> Percent
--------------------
module Percent from FSharp.Finance.Personal.Calculation
<summary> utility functions for percent values </summary>
--------------------
[<Struct>] type Percent = | Percent of decimal member Html: string with get
<summary> a percentage, e.g. 42%, as opposed to its decimal representation 0.42m </summary>
module Amount from FSharp.Finance.Personal.Calculation
<summary> an amount specified either as a simple amount or as a percentage of another amount, optionally restricted to lower and/or upper limits </summary>
--------------------
[<Struct>] type Amount = | Percentage of Percent * Restriction | Simple of int64<Cent> | Unlimited member Html: string with get
<summary> an amount specified either as a simple amount or as a percentage of another amount, optionally restricted to lower and/or upper limits </summary>
<summary> a percentage of the principal, optionally restricted </summary>
module Restriction from FSharp.Finance.Personal.Calculation
<summary> the type of restriction placed on a possible value </summary>
--------------------
[<Struct>] type Restriction = | NoLimit | LowerLimit of int64<Cent> | UpperLimit of int64<Cent> | WithinRange of MinValue: int64<Cent> * MaxValue: int64<Cent> member Html: string with get
<summary> the type of restriction placed on a possible value </summary>
<summary> does not constrain values at all </summary>
<summary> round down to the specified precision (= floor) </summary>
<summary> calculating the APR according to various country-specific regulations </summary>
<summary> the calculation method used to determine the APR </summary>
<summary> calculates the APR according to UK FCA rules to the stated decimal precision (note that this is two places more than the percent precision) </summary>
<summary> calculates the number of days between two offset days on which interest is chargeable </summary>
module BasicSchedule from FSharp.Finance.Personal.Scheduling
<summary> a schedule of payments, with statistics </summary>
--------------------
type BasicSchedule = { EvaluationDay: int<OffsetDay> Items: BasicItem array Stats: InitialStats }
<summary> a schedule of payments, with statistics </summary>
<summary> formats the schedule items as an HTML table (stats can be rendered separately) </summary>
<summary> add-on interest method, where the interest accrued over the loan is added to the initial balance and the interest is paid off before the principal balance </summary>
<summary> generates a map of offset days and payments based on a start date and payment schedule </summary>
<summary> the start date of the schedule, typically the day on which the principal is advanced </summary>
<summary> the scheduled payments or the parameters for generating them </summary>
module Map from FSharp.Finance.Personal.Calculation
<summary> functions for working with maps </summary>
--------------------
module Map from Microsoft.FSharp.Collections
--------------------
type Map<'Key,'Value (requires comparison)> = interface IReadOnlyDictionary<'Key,'Value> interface IReadOnlyCollection<KeyValuePair<'Key,'Value>> interface IEnumerable interface IStructuralEquatable interface IComparable interface IEnumerable<KeyValuePair<'Key,'Value>> interface ICollection<KeyValuePair<'Key,'Value>> interface IDictionary<'Key,'Value> new: elements: ('Key * 'Value) seq -> Map<'Key,'Value> member Add: key: 'Key * value: 'Value -> Map<'Key,'Value> ...
--------------------
new: elements: ('Key * 'Value) seq -> Map<'Key,'Value>
module Array from FSharp.Finance.Personal.Calculation
<summary> functions for working with arrays </summary>
--------------------
module Array from Microsoft.FSharp.Collections
val string: value: 'T -> string
--------------------
type string = System.String
<summary> options relating to interest </summary>
<summary> the standard rate of interest </summary>
<summary> calculates the daily interest rate from the annual one </summary>
<summary> convert a percent value to a decimal, e.g. 50% -> 0.5 </summary>
<summary> convert an integer cent value to a decimal cent value, e.g. for precise interest calculation, 1234¢ -> 1234.0000¢ </summary>
<summary> the principal </summary>
val decimal: value: 'T -> decimal (requires member op_Explicit)
--------------------
type decimal = System.Decimal
--------------------
type decimal<'Measure> = decimal
module Fraction from FSharp.Finance.Personal.Calculation
<summary> a fraction expressed as a numerator and denominator </summary>
--------------------
[<Struct>] type Fraction = | Zero | Simple of Numerator: int * Denominator: int
<summary> a fraction </summary>
<summary> a simple fraction expressed as a numerator and denominator </summary>
<summary> options relating to scheduled payments </summary>
<summary> how to round payments </summary>
<summary> derive a rounded cent value from a decimal according to the specified rounding method </summary>
module ScheduledPayment from FSharp.Finance.Personal.Scheduling
--------------------
type ScheduledPayment = { Original: int64<Cent> voption Rescheduled: RescheduledPayment voption PreviousRescheduled: RescheduledPayment array Adjustment: int64<Cent> Metadata: Map<string,obj> } member Html: string with get
<summary> any original or rescheduled payment, affecting how any payment due is calculated </summary>
<summary> a quick convenient method to create a basic scheduled payment </summary>
<summary> the method for calculating interest </summary>
<summary> the principal balance carried forward </summary>
<summary> the total amount of the payment </summary>
<summary> the scheduled payment </summary>
<summary> raise to the standard currency unit, e.g. 1234¢ -> $12.34 </summary>
module BasicItem from FSharp.Finance.Personal.Scheduling
<summary> a scheduled payment item, with running calculations of interest and principal balance </summary>
--------------------
type BasicItem = { Day: int<OffsetDay> ScheduledPayment: ScheduledPayment ActuarialInterest: decimal<Cent> InterestPortion: int64<Cent> PrincipalPortion: int64<Cent> InterestBalance: int64<Cent> PrincipalBalance: int64<Cent> TotalActuarialInterest: decimal<Cent> TotalInterest: int64<Cent> TotalPrincipal: int64<Cent> }
<summary> a scheduled payment item, with running calculations of interest and principal balance </summary>
<summary> a default value with no data </summary>
<summary> iteratively solves for a given input using a generator function until the output hits zero or within a set tolerance, optionally relaxing the tolerance until a solution is found note: the generator function should return a tuple of the result and a relevant value (as the result is converging on zero it is not a very relevant value) </summary>
<summary> converts the level-payment option to a target tolerance for use in the bisection method solver </summary>
<summary> what tolerance to use for the final principal balance when calculating the level payments </summary>
module ToleranceSteps from FSharp.Finance.Personal.Calculation
<summary> lower and upper bounds, as well as a step value, for tolerance when using the solver </summary>
--------------------
[<Struct>] type ToleranceSteps = { MinTolerance: decimal ToleranceStep: decimal MaxTolerance: decimal }
<summary> lower and upper bounds, as well as a step value, for tolerance when using the solver </summary>
<summary> tolerance steps for solving for payment value </summary>
<summary> the result obtained from the array solver </summary>
<summary> a solution was found, returning the solution, the number of iterations required and the final tolerance used </summary>
<summary> any original payment </summary>
module ValueOption from Microsoft.FSharp.Core
--------------------
[<Struct>] type ValueOption<'T> = | ValueNone | ValueSome of 'T static member Some: value: 'T -> 'T voption static member op_Implicit: value: 'T -> 'T voption member IsNone: bool with get member IsSome: bool with get member Value: 'T with get static member None: 'T voption with get
<summary> lower to the base currency unit, e.g. $12.34 -> 1234¢ </summary>
module OffsetDay from FSharp.Finance.Personal.DateDay
<summary> functions for converting offset days to and from dates </summary>
--------------------
[<Measure>] type OffsetDay
<summary> the offset of a date from the start date, in days </summary>
module InitialStats from FSharp.Finance.Personal.Scheduling
<summary> statistics resulting from the basic schedule calculations </summary>
--------------------
[<Struct>] type InitialStats = { InitialInterestBalance: int64<Cent> LastScheduledPaymentDay: int<OffsetDay> LevelPayment: int64<Cent> FinalPayment: int64<Cent> ScheduledPaymentTotal: int64<Cent> PrincipalTotal: int64<Cent> InterestTotal: int64<Cent> InitialApr: Percent InitialCostToBorrowingRatio: Percent }
<summary> final statistics based on the payments being made on time and in full </summary>
<summary> handle any principal balance overpayment (due to rounding) on the final payment of a schedule </summary>