We have currently rewritten an Excel calculation engine in F# and are looking to refactor the code to make it a lot more idiomatic and standardised.
One of the big problems with the code is that we have a massive function named companyModel
that builds a CompanyModel
type (a record type that holds some of the cached nested functions) from a Company
type, IntercoTimeSeries
type and DividendTrappedCashSolver
type. The companyModel
function consists of lots of small nested functions (nearly 2,000 lines of code!) that do calculations with the inputs and then produce a CompanyModel
as a result.
One of the positives with the horrific implementation of companyModel
is that all the nested functions have global access to the Company
, IntercoTimeSeries
and DividendTrappedCashSolver
inputs so we don't need to pass these as arguments through all our individual nested functions. The issues with this are that the code is hard to test in isolation and is softly organised using #Region comments instead of say more rigidly using nested modules.
The first idea for refactoring was to replace the nested #Region comments with nested modules and to then put the nested functions in these nested modules.
The companyModel
function could then just call the last few functions in the last nested module to calculate a CompanyModel
result.
However there are a few problems with this. These include:
- We lose global access to
Company
,IntercoTimeSeries
andDividendTrappedCashSolver
inputs and these then have to be passed though all functions where required. Currently F# doesn't allow us to pass global parameters to modules (and nested modules) hence they must be past via the individual functions themselves and hence increasing the number of parameters and making each function more complex. We could maybe replace the modules with class types as these do let us pass global parameters, however unlike modules these can't be nested for organisation purposes. - To complicate matters there is heavy use of memoisation in the nested functions to enable caching of values in prior time periods and to reduce needless function recalculation in general. Having to pass these extra parameters to all the functions could then complicate the memoisation scheme which just doesn't feel right.
What is the best way to refactor the nested functions in the companyModel
function to help with better testing in isolation but not change the logic too much with regards to making the functions more complex with more parameters and complicating the memoisation process?
I have added an extract of the companyModel
function below along with the existing memoisation functions. If any more detail is required such as the types used by the functions let me know but I don't think these should impact a possible refactoring solution.
// Utility.fs
module Utility =
let inline memoise f =
let res = ref Unchecked.defaultof<_>
let dict = System.Collections.Generic.Dictionary<int,_>(Time.timeLineLength)
fun v ->
if dict.TryGetValue(v.Period, res) then
!res
else
let res = f v
dict.Add(v.Period, res)
res
let inline memoise2 f =
let res = ref Unchecked.defaultof<_>
let dict = System.Collections.Generic.Dictionary<int,_>(Time.timeLineLength)
fun u v ->
if dict.TryGetValue(v.Period, res) then
!res
else
let res = f u v
dict.Add(v.Period, res)
res
// CompanyModel.fs
module CompanyModel =
let companyModel (c : Company, i : IntercoTimeSeries, s : DividendTrappedCashSolver) =
// Alias Budget Model Database lookups
let profitAndLoss = c.BudgetModelDatabase.ProfitAndLoss
let balanceSheet = c.BudgetModelDatabase.BalanceSheet
let cash = c.BudgetModelDatabase.Cash
//#region - TimingFlags
//#region -- DividendQuarter
let dividendQuarter (t : Time) =
let currMonth = Time.month t
match c.DividendGroup with
| DividendGroup.Tele ->
if currMonth = 3 then 4
else if currMonth = 6 then 1
else if currMonth = 9 then 2
else if currMonth = 12 then 3
else 0
| _ ->
if currMonth = 2 then 4
else if currMonth = 5 then 1
else if currMonth = 8 then 2
else if currMonth = 11 then 3
else 0
let dividendQuarterFlag t =
dividendQuarter t <> 0
//#endregion DividendQuarter
//#region -- CompanyWindDown
let companyWindDownPeriodStart =
c.FinalMonth
let companyWindDownFlag (t : Time) =
t >= c.FinalMonth
let cashflowTaxWindDownFlag (t : Time) =
t.BudgetMonth >= c.FinalMonth.BudgetMonth - 8
let profitAndLossTaxWindDownFlag (t : Time) =
t.BudgetMonth >= c.FinalMonth.BudgetMonth - 12
//#endregion CompanyWindDown
//#region -- CompanySold
let companySold t =
match c.SoldMonth with
| Some v -> t >= v
| None -> false
let companyNotSold t =
match c.SoldMonth with
| Some v -> t < v
| None -> true
//#endregion CompanySold
//#endregion TimingFlags
//#region - Opening Balances
let openingBalanceCash =
memoise2 (fun closingBalanceCash (t : Time) ->
if (t - 1).IsForecast then
closingBalanceCash
else
balanceSheet.Cash (t - 1)
)
let openingBalanceIntercompanyDebt =
memoise2 (fun closingBalanceIntercompanyDebt (t : Time) ->
if (t - 1).IsForecast then
closingBalanceIntercompanyDebt
else
balanceSheet.IntercompanyLoan (t - 1)
)
let openingBalanceRetainedEarnings =
memoise2 (fun closingBalanceRetainedEarnings (t : Time) ->
if (t - 1).IsForecast then
closingBalanceRetainedEarnings
else
- balanceSheet.RetainedEarnings (t - 1)
)
let openingBalanceShareCapital =
memoise2 (fun closingBalanceShareCapital (t : Time) ->
if (t - 1).IsForecast then
closingBalanceShareCapital
else
- balanceSheet.ShareCapital (t - 1)
- balanceSheet.SharePremium (t - 1)
)
//#endregion
//#region - Shared Functions
let cashBalanceInterestRate t =
Interest.libor t + Interest.cashBalanceInterestMargin
let cashBalanceInterestReceivable =
memoise2 (fun closingBalanceCash (t : Time) ->
if not (companyWindDownFlag t) then
if t.IsForecast then
openingBalanceCash closingBalanceCash t * cashBalanceInterestRate t / 12.0
else
0.0
else
0.0
)
let intercompanyInterestRate t =
Interest.libor t + Interest.intercompanyInterestRate
let intercompanyLoanInterest =
memoise2 (fun closingBalanceIntercompanyDebt (t : Time) ->
if not (companyWindDownFlag (t - 1)) then
(openingBalanceIntercompanyDebt closingBalanceIntercompanyDebt t) * intercompanyInterestRate t / 12.0
else
0.0
)
//#endregion
//#region - Tax
//#region -- Taxable Revenue Profit
//#region --- Taxable Revenue Profit - Before (Budget model) Interest & Capital Allowances
let taxableRevenueProfitExcludingInterestAndCapitalAllowances (t : Time) =
let inline taxableProfitCheck (t : Time) f flag =
if flag then
f t
else
0.0
let adjustedBonus (t : Time) =
- profitAndLoss.Bonus t +
if Time.isAfterModelEnd t then
0.0
else if Time.isEndOfFinancialYear t then
Time.midRangeSumByFast (min (t + 1) Time.modelEnd) (min (t + 12) Time.modelEnd) cash.LtisPayments
else
0.0
taxableProfitCheck t profitAndLoss.Ebt c.TaxableProfitFlags.IncludeEbtAfterIntercompanyRecharges +
- taxableProfitCheck t profitAndLoss.AddBackDepreciationAndAmortisation c.TaxableProfitFlags.IncludeAddBackDepreciationAmortisation +
- taxableProfitCheck t profitAndLoss.LessProfitOnDisposal c.TaxableProfitFlags.IncludeLessProfitOnDisposal +
- taxableProfitCheck t profitAndLoss.LessSurrenderPremiums c.TaxableProfitFlags.IncludeLessSurrenderPremiums +
- taxableProfitCheck t profitAndLoss.LessOtherNonAllowableItems c.TaxableProfitFlags.IncludeLessOtherNonAllowableItems +
taxableProfitCheck t profitAndLoss.OnerousLeaseTaxableProfit c.TaxableProfitFlags.IncludePlusOnerousLeaseTaxableProfit +
- taxableProfitCheck t profitAndLoss.OnerousLeaseAccountingProfit c.TaxableProfitFlags.IncludeLessOnerousLeaseAccountingProfit +
taxableProfitCheck t profitAndLoss.IrrecoverableVatProfitAndLoss c.TaxableProfitFlags.IncludeAddIrrecoverableVat +
taxableProfitCheck t adjustedBonus c.TaxableProfitFlags.IncludeReplaceProfitAndLossBonusWithNextYearsCashBonus
//#endregion Taxable Revenue Profit - Before (Budget model) Interest & Capital Allowances
//#region --- Interest
let intercompanyInterestReceivableForTax =
let intercompanyLoanOpeningBalance =
Time.lastNonZeroBy balanceSheet.IntercompanyLoan
memoise2 (fun closingBalances (t : Time) ->
let interCompanyInterestOnOpeningBalance (t : Time) =
match c.CompanyShortName with
| CompanyShortName.TSP -> 0.0
| _ -> if t.IsForecast then
intercompanyLoanOpeningBalance * cashBalanceInterestRate t / 12.0
else
0.0
if Time.isBeforeModelStart (t - 1) then
0.0
else if (t - 12).IsForecast then
intercompanyLoanInterest (closingBalances.IntercompanyDebt) (t - 12)
else
interCompanyInterestOnOpeningBalance (t - 1))
let rec taxCashBalanceInterestReceivable =
memoise2 (fun closingBalanceCash (t : Time) ->
if Time.isAfterModelEnd (t + 1) then
0.0
else if t.IsForecast then
cashBalanceInterestReceivable closingBalanceCash t
else
taxCashBalanceInterestReceivable closingBalanceCash (t + 1)
)
let rec currentYearInterestReceivable =
memoise2 (fun closingBalanceCash (t : Time) ->
if Time.isAfterModelEnd (t + 11) then
0.0
else if Time.isStartOfFinancialYear t then
Time.midRangeSumByFast t (t + 11) (taxCashBalanceInterestReceivable closingBalanceCash)
else
currentYearInterestReceivable closingBalanceCash (t - 1)
)
let rec assumedMonthlyInterest =
memoise2 (fun closingBalanceCash (t : Time) ->
if Time.isBeforeModelStart (t - 11) then
0.0
else if Time.isEndOfFinancialYear t then
currentYearInterestReceivable closingBalanceCash t - assumedMonthlyInterest closingBalanceCash (t - 1)
else
currentYearInterestReceivable closingBalanceCash (t - 12) / 2.0 / 12.0
)
let cashBalanceInterestReceivableForTax closingBalanceCash (t : Time) =
assumedMonthlyInterest closingBalanceCash (t - 12)
//#endregion Interest
// 1,500 more lines of business logic code here all part of the companyModel function
// ....