Skip to content

Commit

Permalink
promotional interest rates
Browse files Browse the repository at this point in the history
  • Loading branch information
simontreanor committed Apr 11, 2024
1 parent 5507e07 commit 2d8c967
Show file tree
Hide file tree
Showing 23 changed files with 435 additions and 258 deletions.
26 changes: 17 additions & 9 deletions src/Amortisation.fs
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,6 @@ module Amortisation =
let calculate sp intendedPurpose (appliedPayments: AppliedPayment array) =
let asOfDay = (sp.AsOfDate - sp.StartDate).Days * 1<OffsetDay>

let dailyInterestRate = sp.Interest.Rate |> Interest.Rate.daily
let totalInterestCap = sp.Interest.Cap.Total |> Interest.Cap.total sp.Principal

let feesTotal = Fees.total sp.Principal sp.FeesAndCharges.Fees |> Cent.fromDecimalCent (ValueSome sp.Calculation.RoundingOptions.FeesRounding)
Expand All @@ -158,24 +157,33 @@ module Amortisation =
else
OpenBalance

let earlySettlementDate = match intendedPurpose with IntendedPurpose.Quote _ -> ValueSome sp.AsOfDate | _ -> ValueNone

let isWithinGracePeriod d = int d <= int sp.Interest.InitialGracePeriod

let isSettledWithinGracePeriod =
earlySettlementDate
|> ValueOption.map ((OffsetDay.fromDate sp.StartDate) >> int >> isWithinGracePeriod)
|> ValueOption.defaultValue false

let dailyInterestRates fromDay toDay = Interest.dailyRates sp.StartDate isSettledWithinGracePeriod sp.Interest.StandardRate sp.Interest.PromotionalRates fromDay toDay

appliedPayments
|> Array.scan(fun ((si: ScheduleItem), (a: Accumulator)) ap ->
let advances = if ap.AppliedPaymentDay = 0<OffsetDay> then [| sp.Principal |] else [||] // note: assumes single advance on day 0L<Cent>

let earlySettlementDate = match intendedPurpose with IntendedPurpose.Quote _ -> ValueSome sp.AsOfDate | _ -> ValueNone
let interestChargeableDays = Interest.chargeableDays sp.StartDate earlySettlementDate sp.Interest.InitialGracePeriod sp.Interest.Holidays si.OffsetDay ap.AppliedPaymentDay

let newInterest =
if si.PrincipalBalance <= 0L<Cent> then
match sp.Calculation.NegativeInterestOption with
| ApplyNegativeInterest ->
let dailyInterestRate = sp.Interest.RateOnNegativeBalance |> ValueOption.map Interest.Rate.daily |> ValueOption.defaultValue (Percent 0m)
Interest.calculate 0m<Cent> (si.PrincipalBalance + si.FeesBalance) dailyInterestRate interestChargeableDays
dailyInterestRates si.OffsetDay ap.AppliedPaymentDay
|> Array.map(fun dr -> { dr with InterestRate = sp.Interest.RateOnNegativeBalance |> ValueOption.defaultValue Interest.Rate.Zero })
|> Interest.calculate (si.PrincipalBalance + si.FeesBalance) ValueNone (ValueSome sp.Calculation.RoundingOptions.InterestRounding)
| DoNotApplyNegativeInterest ->
0m<Cent>
else
let dailyInterestCap = sp.Interest.Cap.Daily |> Interest.Cap.daily (si.PrincipalBalance + si.FeesBalance) interestChargeableDays
Interest.calculate dailyInterestCap (si.PrincipalBalance + si.FeesBalance) dailyInterestRate interestChargeableDays
dailyInterestRates si.OffsetDay ap.AppliedPaymentDay
|> Interest.calculate (si.PrincipalBalance + si.FeesBalance) sp.Interest.Cap.Daily (ValueSome sp.Calculation.RoundingOptions.InterestRounding)

let cappedNewInterest = if a.CumulativeInterest + newInterest >= totalInterestCap then totalInterestCap - a.CumulativeInterest else newInterest

Expand Down Expand Up @@ -503,7 +511,7 @@ module Amortisation =
EffectiveInterestRate =
if finalPaymentDay = 0<OffsetDay> || principalTotal + feesTotal - feesRefund = 0L<Cent> then 0m
else (decimal interestTotal / decimal (principalTotal + feesTotal - feesRefund)) / decimal finalPaymentDay
|> Percent |> Interest.Daily
|> Percent |> Interest.Rate.Daily
}

/// generates an amortisation schedule and final statistics
Expand Down
11 changes: 8 additions & 3 deletions src/Calculation.fs
Original file line number Diff line number Diff line change
Expand Up @@ -76,10 +76,15 @@ module Calculation =
let powi (power: int) (base': decimal) = decimal (Math.Pow(double base', double power))

/// raises a decimal to a decimal power
let powm (power: decimal) (base': decimal) = decimal (Math.Pow(double base', double power))
let powm (power: decimal) (base': decimal) = Math.Pow(double base', double power)

/// round a value to n decimal places
let roundTo (places: int) (m: decimal) = Math.Round(m, places)
let roundTo rounding (places: int) (m: decimal) =
match rounding with
| ValueSome (RoundDown) -> 10m |> powi places |> fun f -> if f = 0m then 0m else m * f |> floor |> fun m -> m / f
| ValueSome (RoundUp) -> 10m |> powi places |> fun f -> if f = 0m then 0m else m * f |> ceil |> fun m -> m / f
| ValueSome (Round mpr) -> Math.Round(m, places, mpr)
| ValueNone -> m

/// how to round calculated interest and payments
[<Struct>]
Expand All @@ -103,7 +108,7 @@ module Calculation =

/// a holiday, i.e. a period when no interest and/or charges are accrued
[<RequireQualifiedAccess; Struct>]
type Holiday = {
type DateRange = {
/// the first date of the holiday period
Start: Date
/// the last date of the holiday period
Expand Down
9 changes: 8 additions & 1 deletion src/Currency.fs
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,14 @@ module Currency =
m
|> Rounding.round rounding
|> int64
|> (( * ) 1L<Cent>)
|> ( * ) 1L<Cent>

/// round a decimal cent value to the specified number of places
let roundTo rounding decimalPlaces (m: decimal<Cent>) =
m
|> decimal
|> roundTo rounding decimalPlaces
|> ( * ) 1m<Cent>

/// lower to the base currency unit, e.g. $12.34 -> 1234¢
let fromDecimal (m: decimal) = round (ValueSome (Round MidpointRounding.AwayFromZero)) (m * 100m)
Expand Down
1 change: 0 additions & 1 deletion src/DateDay.fs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ namespace FSharp.Finance.Personal
open System

/// a .NET Framework polyfill equivalent to the DateOnly structure in .NET Core
[<AutoOpen>]
module DateDay =

/// 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
Expand Down
7 changes: 5 additions & 2 deletions src/FeesAndCharges.fs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ module FeesAndCharges =
| FacilitationFee of FacilitationFee:Amount
/// a fee charged by a Credit Access Business (CAB) or Credit Services Organisation (CSO) assisting access to third-party financial products
| CabOrCsoFee of CabOrCsoFee:Amount
/// a fee charged by a bank or building society for arranging a mortgage
| MortageFee of MortageFee:Amount
/// any other type of product fee
| CustomFee of FeeType:string * FeeAmount:Amount

Expand All @@ -27,6 +29,7 @@ module FeesAndCharges =
|> Array.sumBy(function
| Fee.FacilitationFee amount
| Fee.CabOrCsoFee amount
| Fee.MortageFee amount
| Fee.CustomFee (_, amount) -> amount |> Amount.total baseAmount
)

Expand Down Expand Up @@ -76,7 +79,7 @@ module FeesAndCharges =
/// determines whether charge are applicable on a given day
let areApplicable (startDate: Date) holidays (onDay: int<OffsetDay>) =
holidays
|> Array.collect(fun (ih: Holiday) ->
|> Array.collect(fun (ih: DateRange) ->
[| (ih.Start - startDate).Days .. (ih.End - startDate).Days |]
)
|> Array.exists(fun d -> d = int onDay)
Expand All @@ -100,7 +103,7 @@ module FeesAndCharges =
/// a list of penalty charges applicable to a product
Charges: Charge array
/// any period during which charges are not payable
ChargesHolidays: Holiday array
ChargesHolidays: DateRange array
/// whether to group charges by type per day
ChargesGrouping: ChargesGrouping
/// the number of days' grace period after which late-payment charges apply
Expand Down
121 changes: 76 additions & 45 deletions src/Interest.fs
Original file line number Diff line number Diff line change
Expand Up @@ -11,34 +11,42 @@ module Interest =
open DateDay
open Percentages

/// calculate the interest accrued on a balance at a particular interest rate over a number of days, optionally capped by a daily amount
let calculate (dailyCap: decimal<Cent>) (balance: int64<Cent>) (dailyRate: Percent) (chargeableDays: int<DurationDay>) =
decimal balance * Percent.toDecimal dailyRate * decimal chargeableDays
|> min (decimal dailyCap)
|> ( * ) 1m<Cent>

/// the interest rate expressed as either an annual or a daily rate
[<Struct>]
[<RequireQualifiedAccess; Struct>]
type Rate =
/// a zero rate
| Zero
/// the annual interest rate, or the daily interest rate multiplied by 365
| Annual of Annual:Percent
/// the daily interest rate, or the annual interest rate divided by 365
| Daily of Daily:Percent
with
/// used to pretty-print the interest rate for debugging
static member serialise = function
| Zero -> $"ZeroPc"
| Annual (Percent air) -> $"AnnualInterestRate{air}pc"
| Daily (Percent dir) -> $"DailyInterestRate{dir}pc"

/// calculates the annual interest rate from the daily one
static member annual = function
| Zero -> Percent 0m
| Annual (Percent air) -> air |> Percent
| Daily (Percent dir) -> dir * 365m |> Percent

[<RequireQualifiedAccess>]
module Rate =
/// used to pretty-print the interest rate for debugging
let serialise = function
| Annual (Percent air) -> $"AnnualInterestRate{air}pc"
| Daily (Percent dir) -> $"DailyInterestRate{dir}pc"
/// calculates the annual interest rate from the daily one
let annual = function
| Annual (Percent air) -> air |> Percent
| Daily (Percent dir) -> dir * 365m |> Percent
/// calculates the daily interest rate from the annual one
let daily = function
| Annual (Percent air) -> air / 365m |> Percent
| Daily (Percent dir) -> dir |> Percent
/// calculates the daily interest rate from the annual one
static member daily = function
| Zero -> Percent 0m
| Annual (Percent air) -> air / 365m |> Percent
| Daily (Percent dir) -> dir |> Percent

/// the daily interest rate
[<Struct>]
type DailyRate = {
/// the day expressed as an offset from the start date
RateDay: int<OffsetDay>
/// the interest rate applicable on the given day
InterestRate: Rate
}

/// the interest cap options
[<RequireQualifiedAccess; Struct>]
Expand Down Expand Up @@ -66,39 +74,62 @@ module Interest =
| ValueSome amount -> Amount.total initialPrincipal amount
| ValueNone -> decimal Int64.MaxValue * 1m<Cent>

/// calculates the daily interest cap
static member daily (balance: int64<Cent>) (interestChargeableDays: int<DurationDay>) = function
| ValueSome amount -> Amount.total balance amount * decimal interestChargeableDays
| ValueNone -> decimal Int64.MaxValue * 1m<Cent>
/// a promotional interest rate valid during the specified date range
[<RequireQualifiedAccess; Struct>]
type PromotionalRate = {
DateRange: DateRange
Rate: Rate
}
with
/// creates a map of offset days and promotional interest rates
static member toMap (startDate: Date) promotionalRates =
promotionalRates
|> Array.collect(fun pr ->
[| (pr.DateRange.Start - startDate).Days .. (pr.DateRange.End - startDate).Days |]
|> Array.map(fun d -> d, pr.Rate)
)
|> Map.ofArray

/// interest options
[<Struct>]
type Options = {
/// the rate of interest
Rate: Rate
/// the standard rate of interest
StandardRate: Rate
/// any total or daily caps on interest
Cap: Cap
/// any grace period at the start of a product, if a product is settled before which no interest is payable
/// any grace period at the start of a product, during which if a product is settled no interest is payable
InitialGracePeriod: int<DurationDay>
/// any date ranges during which no interest is applicable
Holidays: Holiday array
/// any promotional or introductory offers during which a different interest rate is applicable
PromotionalRates: PromotionalRate array
/// the interest rate applicable for any period in which a refund is owing
RateOnNegativeBalance: Rate voption
}

/// calculates the number of interest-chargeable days between two dates
let chargeableDays (startDate: Date) (earlySettlementDate: Date voption) (gracePeriod: int<DurationDay>) holidays (fromDay: int<OffsetDay>) (toDay: int<OffsetDay>) =
let interestFreeDays =
holidays
|> Array.collect(fun (ih: Holiday) ->
[| (ih.Start - startDate).Days .. (ih.End - startDate).Days |]
)
|> Array.filter(fun d -> d >= int fromDay && d <= int toDay)
let isWithinGracePeriod d = d <= int gracePeriod
let isSettledWithinGracePeriod = earlySettlementDate |> ValueOption.map(fun sd -> isWithinGracePeriod (sd - startDate).Days) |> ValueOption.defaultValue false
[| int fromDay .. int toDay |]
|> Array.filter(fun d -> not (isSettledWithinGracePeriod && isWithinGracePeriod d))
|> Array.filter(fun d -> interestFreeDays |> Array.exists ((=) d) |> not)
|> Array.length
|> fun l -> (max 0 (l - 1)) * 1<DurationDay>
/// calculates the interest chargeable on a range of days
let dailyRates (startDate: Date) isSettledWithinGracePeriod standardRate promotionalRates (fromDay: int<OffsetDay>) (toDay: int<OffsetDay>) =
let promoRates = promotionalRates |> PromotionalRate.toMap startDate

[| int fromDay + 1 .. int toDay |]
|> Array.map(fun d ->
let offsetDay = d * 1<OffsetDay>
if isSettledWithinGracePeriod then
{ RateDay = offsetDay; InterestRate = Rate.Zero }
else
match promoRates |> Map.tryFind d with
| Some rate -> { RateDay = offsetDay; InterestRate = rate }
| None -> { RateDay = offsetDay; InterestRate = standardRate }
)

/// calculate the interest accrued on a balance at a particular interest rate over a number of days, optionally capped by a daily amount
let calculate (balance: int64<Cent>) (dailyInterestCap: Amount voption) interestRounding (dailyRates: DailyRate array) =
let dailyCap = Cap.total balance dailyInterestCap

dailyRates
|> Array.sumBy (fun dr ->
dr.InterestRate
|> Rate.daily
|> Percent.toDecimal
|> fun r -> decimal balance * r * 1m<Cent>
|> min dailyCap
)
|> Cent.roundTo interestRounding 8
6 changes: 2 additions & 4 deletions src/PaymentSchedule.fs
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,6 @@ module PaymentSchedule =

let fees = Fees.total sp.Principal sp.FeesAndCharges.Fees |> Cent.fromDecimalCent (ValueSome sp.Calculation.RoundingOptions.FeesRounding)

let dailyInterestRate = sp.Interest.Rate |> Interest.Rate.daily
let totalInterestCap = sp.Interest.Cap.Total |> Interest.Cap.total sp.Principal |> Cent.fromDecimalCent (ValueSome sp.Calculation.RoundingOptions.InterestRounding)

let roughPayment = if paymentCount = 0 then 0m else (decimal sp.Principal + decimal fees) / decimal paymentCount
Expand All @@ -151,9 +150,8 @@ module PaymentSchedule =
schedule <-
paymentDays
|> Array.scan(fun si d ->
let interestChargeableDays = Interest.chargeableDays sp.StartDate ValueNone sp.Interest.InitialGracePeriod sp.Interest.Holidays si.Day d
let dailyInterestCap = sp.Interest.Cap.Daily |> Interest.Cap.daily si.Balance interestChargeableDays
let interest = Interest.calculate dailyInterestCap si.Balance dailyInterestRate interestChargeableDays |> decimal |> Cent.round (ValueSome sp.Calculation.RoundingOptions.InterestRounding)
let dailyRates = Interest.dailyRates sp.StartDate false sp.Interest.StandardRate sp.Interest.PromotionalRates si.Day d
let interest = Interest.calculate si.Balance sp.Interest.Cap.Daily (ValueSome sp.Calculation.RoundingOptions.InterestRounding) dailyRates |> decimal |> Cent.round (ValueSome sp.Calculation.RoundingOptions.InterestRounding)
let interest' = if si.AggregateInterest + interest >= totalInterestCap then totalInterestCap - si.AggregateInterest else interest
let payment' = Cent.round (ValueSome sp.Calculation.RoundingOptions.PaymentRounding) payment
let principalPortion = payment' - interest'
Expand Down
4 changes: 3 additions & 1 deletion src/Percentages.fs
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
namespace FSharp.Finance.Personal

open System

/// a way to unambiguously express percentages and avoid potential confusion with decimal values
module Percentages =

Expand All @@ -14,7 +16,7 @@ module Percentages =
/// create a percent value from a decimal
let fromDecimal (m: decimal) = m * 100m |> Percent
/// round a percent value to two decimal places
let round (places: int) (Percent p) = roundTo places p |> Percent
let round (places: int) (Percent p) = roundTo (MidpointRounding.AwayFromZero |> Round |> ValueSome) places p |> Percent
/// convert a percent value to a decimal
let toDecimal (Percent p) = p / 100m
/// multiply two percentages together
Expand Down
6 changes: 3 additions & 3 deletions src/Quotes.fs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ module Quotes =

[<Struct>]
type QuoteResult =
| PaymentQuote of PaymentQuote: int64<Cent> * OfWhichPrincipal: int64<Cent> * OfWhichFees: int64<Cent> * OfWhichInterest: int64<Cent> * OfWhichCharges: int64<Cent> * ProRatedFees: int64<Cent>
| PaymentQuote of PaymentQuote: int64<Cent> * OfWhichPrincipal: int64<Cent> * OfWhichFees: int64<Cent> * OfWhichInterest: int64<Cent> * OfWhichCharges: int64<Cent> * FeesDue: int64<Cent>
| AwaitPaymentConfirmation
| UnableToGenerateQuote

Expand Down Expand Up @@ -41,7 +41,7 @@ module Quotes =
elif pendingPayments <> 0L<Cent> then
AwaitPaymentConfirmation
else
let principalPortion, feesPortion, interestPortion, chargesPortion, proRatedFees =
let principalPortion, feesPortion, interestPortion, chargesPortion, feesDue =
if si.GeneratedPayment.Value = 0L<Cent> then
0L<Cent>, 0L<Cent>, 0L<Cent>, 0L<Cent>, si.FeesDue
else
Expand All @@ -56,7 +56,7 @@ module Quotes =
si.GeneratedPayment.Value, 0L<Cent>, 0L<Cent>, 0L<Cent>, si.FeesDue
else
si.PrincipalPortion, si.FeesPortion, si.InterestPortion, si.ChargesPortion, si.FeesDue
PaymentQuote (si.GeneratedPayment.Value, principalPortion, feesPortion, interestPortion, chargesPortion, proRatedFees)
PaymentQuote (si.GeneratedPayment.Value, principalPortion, feesPortion, interestPortion, chargesPortion, feesDue)
return {
QuoteType = quoteType
QuoteResult = quoteResult
Expand Down
Loading

0 comments on commit 2d8c967

Please sign in to comment.