packages feed

hledger-interest-2.0.0: tests/calculation-tests.hs

{-# LANGUAGE OverloadedStrings #-}

module Main (main) where

import Control.Monad (zipWithM_)
import Data.Decimal (Decimal)
import Data.Ratio ((%))
import qualified Data.Text as T
import Data.Time.Calendar (Day, fromGregorian)
import Hledger.Interest
import Test.Tasty (TestTree, defaultMain, testGroup)
import Test.Tasty.HUnit (Assertion, assertBool, testCase, (@?=))

main :: IO ()
main = defaultMain $ testGroup "Independent calculations"
  [ dayCountTests
  , balanceTests
  , testGroup "Accrual"
      [ testCase "repayment changes the next period, not the payment day" $ do
          -- 3650 at 10% earns 1 per day in 2023. After repaying 1000,
          -- ten more days earn 2650 * 10% * 10 / 365 = 530/73.
          let cfg = baseConfig
          assertInterest cfg (compute cfg [(day 2023 1 1, 3650), (day 2023 1 11, -1000)] (day 2023 1 21))
            [(day 2023 1 11, 10), (day 2023 1 21, 530 % 73)]
      , testCase "same-day repayments do not accrue another day" $ do
          -- Both repayments leave 2000; the next ten days earn 400/73.
          let cfg = baseConfig
          assertInterest cfg (compute cfg
            [(day 2023 1 1, 3650), (day 2023 1 11, -1000), (day 2023 1 11, -650)] (day 2023 1 21))
            [(day 2023 1 11, 10), (day 2023 1 21, 400 % 73)]
      , testCase "February 29th is included with a 366-day denominator" $ do
          -- The loan day is excluded; February 29th and March 1st each
          -- earn 3660 * 10% / 366 = 1.
          let cfg = baseConfig
          assertInterest cfg (compute cfg [(day 2024 2 28, 3660)] (day 2024 3 1))
            [(day 2024 3 1, 2)]
      , testCase "year-end postings compound only in the principal account" $ do
          -- December 31st earns 1. The next two days earn
          -- 3651 * 10% * 2 / 366 = 1217/610 after compounding.
          let cfg = baseConfig { targetAccount = "Assets:Loan" }
          assertInterest cfg (compute cfg [(day 2023 12 30, 3650)] (day 2024 1 2))
            [(day 2023 12 31, 1), (day 2024 1 2, 1217 % 610)]
      , testCase "a separate interest account does not compound" $ do
          -- The same two leap-year days use the original principal:
          -- 3650 * 10% * 2 / 366 = 365/183.
          let cfg = baseConfig
          assertInterest cfg (compute cfg [(day 2023 12 30, 3650)] (day 2024 1 2))
            [(day 2023 12 31, 1), (day 2024 1 2, 365 % 183)]
      ]
  , testGroup "Internal year splits"
      [ testCase name $ do
          -- No intermediate posting: 1 non-leap-year day plus 2 leap-year
          -- days earn 1 + 365/183 = 548/183 on unchanged principal.
          let cfg = baseConfig { targetAccount = "Assets:Loan"
                               , postingMode = mode }
          assertInterest cfg (compute cfg [(day 2023 12 30, 3650)] (day 2024 1 2))
            [(day 2024 1 2, 548 % 183)]
      | (name, mode) <-
          [("rate-change posting", PostOnRateChanges), ("transaction-only posting", PostOnTransactions)]
      ]
  , testGroup "Integrated day counts"
      [ testCase name $ do
          -- Accrual starts February 28th, after the February 27th loan.
          -- At 3600 and 10%, every synthetic 360-day-convention day earns 1.
          -- Actual/Actual uses 32 actual days: 360 * 32 / 365 = 2304/73.
          let cfg = baseConfig { dayCountConvention = convention }
          assertInterest cfg (compute cfg [(day 2023 2 27, 3600)] (day 2023 3 31))
            [(day 2023 3 31, expected)]
      | (name, convention, expected) <-
          [("actual", diffAct, 2304 % 73), ("raw 30/360", diff30_360, 34),
           ("30E/360", diff30E_360, 33), ("30E/360 ISDA", diff30E_360isda, 33)]
      ]
  , testGroup "Periods start after an adjusted day"
      [ testCase name $ do
          -- Accrual starts January 31st, which 30E/360 counts as the 30th,
          -- so the period must be counted from the loan day (issue #29).
          -- Actual/Actual uses 29 actual days: 360 * 29 / 365 = 2088/73.
          let cfg = baseConfig { dayCountConvention = convention }
          assertInterest cfg (compute cfg [(day 2023 1 30, 3600)] (day 2023 2 28))
            [(day 2023 2 28, expected)]
      | (name, convention, expected) <-
          [("actual", diffAct, 2088 % 73), ("raw 30/360", diff30_360, 28),
           ("30E/360", diff30E_360, 28), ("30E/360 ISDA", diff30E_360isda, 30)]
      ]
  , testGroup "Monthly periods add up to a 360-day year"
      [ testCase name $ do
          -- Payments on the 30th (February 28th) split a year into twelve
          -- periods; at 1 per synthetic day they must earn exactly 360.
          let cfg = baseConfig { dayCountConvention = convention }
              payments = [(day 2023 m (if m == 2 then 28 else 30), 0) | m <- [1 .. 12]]
              txns = compute cfg ((day 2022 12 30, 3600) : payments) (day 2023 12 30)
          sum [aquantity a | t <- txns, p <- tpostings t, paccount p == targetAccount cfg,
                             a <- amounts (pamount p)] @?= 360
      | (name, convention) <-
          [("raw 30/360", diff30_360), ("30E/360", diff30E_360), ("30E/360 ISDA", diff30E_360isda)]
      ]
  , testGroup "Rate changes"
      [ testCase "new rate applies on its effective date" $ do
          -- January 2nd-10th: 9 days at 1/day; January 11th-20th:
          -- 10 days at 2/day. A separate account keeps principal at 3600.
          let cfg = rateChangeConfig
          assertInterest cfg (compute cfg [(day 2023 1 1, 3600)] (day 2023 1 20))
            [(day 2023 1 10, 9), (day 2023 1 20, 20)]
      , testCase "posted interest compounds at a rate change" $ do
          -- The second period uses 3609: 3609 * 20% * 10 / 360 = 20.05.
          let cfg = rateChangeConfig { targetAccount = "Assets:Loan" }
          assertInterest cfg (compute cfg [(day 2023 1 1, 3600)] (day 2023 1 20))
            [(day 2023 1 10, 9), (day 2023 1 20, 20.05)]
      , testCase "deferred interest combines rates without compounding" $ do
          let cfg = rateChangeConfig { targetAccount = "Assets:Loan", postingMode = PostOnTransactions }
          assertInterest cfg (compute cfg [(day 2023 1 1, 3600)] (day 2023 1 20))
            [(day 2023 1 20, 29)]
      , testCase "dates before the first rate earn zero" $ do
          let cfg = baseConfig { dayCountConvention = diff30E_360
                               , interestRate = scheduled [(day 2023 1 11, 0.1)] }
          assertInterest cfg (compute cfg [(day 2023 1 1, 3600)] (day 2023 1 20))
            [(day 2023 1 10, 0), (day 2023 1 20, 10)]
      ]
  , testGroup "Balance-change dates (review regressions, #31)"
      [ testCase "postings without a transaction link use the transaction date" $ do
          -- Library callers may build transactions from bare postings
          -- (ptransaction = Nothing). Ten days on 3650 earn 10, then ten
          -- days on 1825 earn 5; nothing may be dated before the loan.
          let loan = principalChange (day 2023 1 1) 3650
              repayment = principalChange (day 2023 1 11) (-1825)
              txns = runComputer baseConfig $ do
                processTransaction loan
                processTransaction repayment
                computeInterest (day 2023 1 21)
          assertInterest baseConfig txns [(day 2023 1 11, 10), (day 2023 1 21, 5)]
      , testCase "a linked posting's own date takes precedence" $ do
          -- The loan transaction is dated January 1st, its posting January
          -- 11th: 3650 * 10% * 21 / 365 = 21 through February 1st.
          let loan = txnTieKnot (principalChange (day 2023 1 1) 3650)
                { tpostings = [ (mkPosting "Assets:Loan" 3650) { pdate = Just (day 2023 1 11) }
                              , mkPosting "Equity:Funding" (-3650) ] }
              txns = runComputer baseConfig $ do
                processTransaction loan
                computeInterest (day 2023 2 1)
          assertInterest baseConfig txns [(day 2023 2 1, 21)]
      , testCase "an unlinked posting's own date takes precedence" $ do
          let loan = (principalChange (day 2023 1 1) 3650)
                { tpostings = [ (mkPosting "Assets:Loan" 3650) { pdate = Just (day 2023 1 11) }
                              , mkPosting "Equity:Funding" (-3650) ] }
              txns = runComputer baseConfig $ do
                processTransaction loan
                computeInterest (day 2023 2 1)
          assertInterest baseConfig txns [(day 2023 2 1, 21)]
      ]
  , testGroup "Signs"
      [ testCase name $ do
          -- Ten 360-day-convention days on +/-3600 at +/-10%.
          let cfg = baseConfig { dayCountConvention = diff30E_360, interestRate = constant rate }
          assertInterest cfg (compute cfg [(day 2023 1 1, principal)] (day 2023 1 11))
            [(day 2023 1 11, expected)]
      | (name, principal, rate, expected) <-
          [("negative principal", -3600, 0.1, -10),
           ("negative rate", 3600, -0.1, -10),
           ("negative principal and rate", -3600, -0.1, 10)]
      ]
  , testGroup "Rounding policy"
      [ testCase "sub-cent quantities are posted and compounded unrounded" $ do
          -- 100 * 1.44% / 360 = 0.004. After posting it, the next day
          -- earns 100.004 * 1.44% / 360 = 0.00400016, not 0.004.
          let cfg = baseConfig { dayCountConvention = diff30E_360
                               , interestRate = constant 0.0144
                               , targetAccount = "Assets:Loan" }
              txns = compute cfg [(day 2023 1 1, 100), (day 2023 1 2, 0)] (day 2023 1 3)
          assertInterest cfg txns [(day 2023 1 2, 0.004), (day 2023 1 3, 0.00400016)]
          assertBool "Both sub-cent postings should display zero to two decimal places"
            (all (T.isInfixOf "EUR 0.00\n" . showTransaction) txns)
      , testCase "deferred accrual is summed before display rounding" $ do
          -- Repeat the rate on January 3rd to force two calculation segments.
          -- Each earns 0.004 on unchanged principal; their sum is 0.008,
          -- which displays as 0.01 rather than two separately rounded zeros.
          let cfg = baseConfig { dayCountConvention = diff30E_360
                               , interestRate = scheduled [(day 2023 1 1, 0.0144), (day 2023 1 3, 0.0144)]
                               , targetAccount = "Assets:Loan", postingMode = PostOnTransactions }
              txns = compute cfg [(day 2023 1 1, 100)] (day 2023 1 3)
          assertInterest cfg txns [(day 2023 1 3, 0.008)]
          assertBool "The combined posting should display EUR 0.01"
            (any (T.isInfixOf "EUR 0.01\n" . showTransaction) txns)
      ]
  ]

dayCountTests :: TestTree
dayCountTests = testGroup "Day-count definitions"
  [ testCase (name ++ ": " ++ label) $ convention from to @?= expected
  | (label, from, to, counts) <-
      [("non-leap February end", day 2023 2 28, day 2023 3 31, [31,33,32,30]),
       ("leap February end", day 2024 2 29, day 2024 3 31, [31,32,31,30]),
       ("start on the 31st", day 2023 1 31, day 2023 2 15, [15,14,15,15]),
       ("end in February", day 2023 1 15, day 2023 2 28, [44,43,43,45]),
       ("cross December 31st", day 2023 12 31, day 2024 1 1, [1,0,1,1])]
  , ((name, convention), expected) <- zip conventions counts
  ]
  where
    conventions = [("actual", diffAct), ("raw 30/360", diff30_360),
                   ("30E/360", diff30E_360), ("30E/360 ISDA", diff30E_360isda)]

baseConfig :: Config
baseConfig = Config
  { interestAccount = "Assets:Loan"
  , sourceAccount = "Income:Interest"
  , targetAccount = "Assets:Interest"
  , dayCountConvention = diffAct
  , interestRate = constant 0.1
  , postingMode = PostAtAllBoundaries
  }

rateChangeConfig :: Config
rateChangeConfig = baseConfig
  { dayCountConvention = diff30E_360
  , interestRate = scheduled [(day 2023 1 1, 0.1), (day 2023 1 11, 0.2)]
  }

day :: Integer -> Int -> Int -> Day
day = fromGregorian

compute :: Config -> [(Day, Decimal)] -> Day -> [Transaction]
compute cfg changes cutoff = runComputer cfg $ do
  mapM_ (processTransaction . uncurry principalChange) changes
  computeInterest cutoff

principalChange :: Day -> Decimal -> Transaction
principalChange date quantity = nulltransaction
  { tdate = date
  , tpostings = [mkPosting "Assets:Loan" quantity, mkPosting "Equity:Funding" (negate quantity)]
  }

mkPosting :: AccountName -> Decimal -> Posting
mkPosting = currencyPosting "EUR"

currencyPosting :: CommoditySymbol -> AccountName -> Decimal -> Posting
currencyPosting currency account q = nullposting
  { paccount = account
  , pamount = mixed [nullamt { acommodity = currency, aquantity = q
                            , astyle = amountstyle { asprecision = Precision 2, ascommodityspaced = True } }]
  }

-- Expected values are hand-derived rational numbers, not output captured from
-- the implementation. Compare internal quantities before any display rounding.
assertInterest :: Config -> [Transaction] -> [(Day, Rational)] -> Assertion
assertInterest cfg txns expected = do
  let actual = [(tdate t, aquantity a) | t <- txns, p <- tpostings t,
                paccount p == targetAccount cfg, a <- amounts (pamount p)]
  map fst actual @?= map fst expected
  zipWithM_ assertQuantity (map snd expected) (map snd actual)
  where
    -- Decimal division is finite precision. This tolerance is far below a
    -- monetary rounding unit, while allowing recurring rational fractions.
    assertQuantity rational quantity = assertBool
      ("Expected " ++ show rational ++ "; got " ++ show quantity)
      (abs (toRational quantity - rational) < 1 % (10 ^ (30 :: Int)))

balanceTests :: TestTree
balanceTests = testGroup "Balance transitions"
  [ testGroup name
      [ testCase "full repayment and a new loan" $ do
          -- First ten days earn 10. The balance is then zero across a rate
          -- change. Ten synthetic days on the new 7200 at 20% earn 40.
          let cfg = plain { interestRate = scheduled [(day 2023 1 1, 0.1), (day 2023 1 16, 0.2)] }
          assertInterest cfg (compute cfg
            [(day 2023 1 1, 3600), (day 2023 1 11, -3600), (day 2023 1 21, 7200)] cutoff)
            [(day 2023 1 11, 10), (cutoff, 40)]
      , testCase "repayment clears compounded interest too" $ do
          let cfg = compound { interestRate = scheduled [(day 2023 1 1, 0.1), (day 2023 1 16, 0.2)] }
          assertInterest cfg (compute cfg
            [(day 2023 1 1, 3600), (day 2023 1 11, -3610), (day 2023 1 21, 7200)] cutoff)
            [(day 2023 1 11, 10), (cutoff, 40)]
      , testCase "overpayment reverses interest" $
          assertInterest plain (compute plain
            [(day 2023 1 1, 3600), (day 2023 1 11, -7200)] cutoff)
            [(day 2023 1 11, 10), (cutoff, -20)]
      , testCase "overpayment retains unrounded compounded balance" $
          -- After posting 10 and repaying 7200, -3590 earns -3590/180.
          assertInterest compound (compute compound
            [(day 2023 1 1, 3600), (day 2023 1 11, -7200)] cutoff)
            [(day 2023 1 11, 10), (cutoff, (-359) % 18)]
      , testCase "all selected-account postings contribute" $ do
          -- Net opening principal 1800 earns 5; the net repayment of 900
          -- leaves 900, earning another 5 over twenty synthetic days.
          let balanceChange date quantities = nulltransaction
                { tdate = date, tpostings = map (mkPosting "Assets:Loan") quantities ++
                    [mkPosting "Equity:Funding" (negate (sum quantities))] }
              txns = runComputer plain $ do
                processTransaction (balanceChange (day 2023 1 1) [3600, -1800])
                processTransaction (balanceChange (day 2023 1 11) [-400, -500])
                computeInterest cutoff
          assertInterest plain txns [(day 2023 1 11, 5), (cutoff, 5)]
      , testCase "currencies accrue independently" $ do
          -- EUR: ten days on 3600, then twenty on 2400 = 10 + 40/3.
          -- USD: ten days on 7200, then twenty on 3600 = 20 + 20.
          let balanceChange date euroAmount dollarAmount = nulltransaction
                { tdate = date, tpostings =
                    [currencyPosting currency account quantity
                    | (currency, q) <- [("EUR", euroAmount), ("USD", dollarAmount)]
                    , (account, quantity) <- [("Assets:Loan", q), ("Equity:Funding", negate q)]] }
              txns = runComputer plain $ do
                processTransaction (balanceChange (day 2023 1 1) 3600 7200)
                processTransaction (balanceChange (day 2023 1 11) (-1200) (-3600))
                computeInterest cutoff
              currencyInterest currency =
                [t { tpostings = [p { pamount = mixed (filter ((== currency) . acommodity) (amounts (pamount p))) }
                                 | p <- tpostings t] } | t <- txns]
          assertInterest plain (currencyInterest "EUR") [(day 2023 1 11, 10), (cutoff, 40 % 3)]
          assertInterest plain (currencyInterest "USD") [(day 2023 1 11, 20), (cutoff, 20)]
      ]
  | (name, mode) <- [("default", PostAtAllBoundaries), ("rate changes", PostOnRateChanges),
                     ("transactions", PostOnTransactions)]
  , let plain = baseConfig { dayCountConvention = diff30E_360, postingMode = mode }
        compound = plain { targetAccount = "Assets:Loan" }
  ]
  where cutoff = day 2023 2 1