roi: simplify/speed up (no longer checks every day with P directive)

This commit is contained in:
Dmitry Astapov 2024-12-16 14:19:10 +00:00 committed by Simon Michael
parent c6f84b31c0
commit dde5a59049
2 changed files with 130 additions and 106 deletions

View File

@ -20,14 +20,13 @@ import System.Exit
import Data.Time.Calendar import Data.Time.Calendar
import Text.Printf import Text.Printf
import Data.Bifunctor (second) import Data.Bifunctor (second)
import Data.Either (fromLeft, fromRight, isLeft)
import Data.Function (on) import Data.Function (on)
import Data.List import Data.List
import Numeric.RootFinding import Numeric.RootFinding
import Data.Decimal import Data.Decimal
import qualified Data.Text as T import qualified Data.Text as T
import qualified Data.Text.Lazy.IO as TL import qualified Data.Text.Lazy.IO as TL
import Safe (headDef, tailDef) import Safe (headDef, lastMay)
import System.Console.CmdArgs.Explicit as CmdArgs import System.Console.CmdArgs.Explicit as CmdArgs
import Text.Tabular.AsciiWide as Tab import Text.Tabular.AsciiWide as Tab
@ -99,8 +98,6 @@ roi CliOpts{rawopts_=rawopts, reportspec_=rspec@ReportSpec{_rsReportOpts=ReportO
let (fullPeriod, spans) = reportSpan filteredj rspec let (fullPeriod, spans) = reportSpan filteredj rspec
let priceDirectiveDates = dbg3 "priceDirectiveDates" $ map pddate $ jpricedirectives j
let processSpan (DateSpan Nothing _) = error "Undefined start of the period - will be unable to compute the rates of return" let processSpan (DateSpan Nothing _) = error "Undefined start of the period - will be unable to compute the rates of return"
processSpan (DateSpan _ Nothing) = error "Undefined end of the period - will be unable to compute the rates of return" processSpan (DateSpan _ Nothing) = error "Undefined end of the period - will be unable to compute the rates of return"
processSpan spn@(DateSpan (Just begin) (Just end)) = do processSpan spn@(DateSpan (Just begin) (Just end)) = do
@ -120,9 +117,7 @@ roi CliOpts{rawopts_=rawopts, reportspec_=rspec@ReportSpec{_rsReportOpts=ReportO
total trans (And [investmentsQuery total trans (And [investmentsQuery
, Date (DateSpan Nothing (Just end))]) , Date (DateSpan Nothing (Just end))])
priceDates = dbg3 "priceDates" $ nub $ filter (spanContainsDate spn) priceDirectiveDates cashFlow = dbg3 "cashFlow" $
cashFlow =
((map (,nullmixedamt) priceDates)++) $
cashFlowApplyCostValue $ cashFlowApplyCostValue $
calculateCashFlow wd trans (And [ Not investmentsQuery calculateCashFlow wd trans (And [ Not investmentsQuery
, Not pnlQuery , Not pnlQuery
@ -179,116 +174,116 @@ roi CliOpts{rawopts_=rawopts, reportspec_=rspec@ReportSpec{_rsReportOpts=ReportO
TL.putStrLn $ Tab.render prettyTables id id id table TL.putStrLn $ Tab.render prettyTables id id id table
timeWeightedReturn styles showCashFlow prettyTables investmentsQuery trans mixedAmountValue (OneSpan begin end valueBeforeAmt valueAfter cashFlow pnl) = do -- Entry for TWR computation, capturing all cashflows that are potentially accompanied by pnl change on the same day (if not, it is zero)
let valueBefore = unMix valueBeforeAmt data TwrEntry = TwrEntry { twrDate :: Day, twrCashflow :: Decimal, twrValueAfter :: Decimal, twrPnl :: Decimal } deriving (Eq, Show)
let initialUnitCost = 100 :: Decimal
let initialUnits = valueBefore / initialUnitCost
let changes =
-- If cash flow and PnL changes happen on the same day, this
-- will sort PnL changes to come before cash flows (on any
-- given day), so that we will have better unit price computed
-- first for processing cash flow. This is why pnl changes are Left
-- and cashflows are Right.
-- However, if the very first date in the changes list has both
-- PnL and CashFlow, we would not be able to apply pnl change to 0 unit,
-- which would lead to an error. We make sure that we have at least one
-- cashflow entry at the front, and we know that there would be at most
-- one for the given date, by construction. Empty CashFlows added
-- because of a begin date before the first transaction are not seen as
-- a valid cashflow entry at the front.
zeroUnitsNeedsCashflowAtTheFront
$ sort
$ datedCashflows ++ datedPnls
where
zeroUnitsNeedsCashflowAtTheFront changes1 =
if initialUnits > 0 then changes1
else
let (leadingEmptyCashFlows, rest) = span isEmptyCashflow changes1
(leadingPnls, rest') = span (isLeft . snd) rest
(firstCashflow, rest'') = splitAt 1 rest'
in leadingEmptyCashFlows ++ firstCashflow ++ leadingPnls ++ rest''
isEmptyCashflow (_date, amt) = case amt of timeWeightedReturn _styles showCashFlow prettyTables investmentsQuery trans mixedAmountValue (OneSpan begin end valueBeforeAmt valueAfterAmt cashflows pnls) = do
Right amt' -> mixedAmountIsZero amt' let datedCashflows =
Left _ -> False
datedPnls = map (second Left) $ aggregateByDate pnl
datedCashflows = map (second Right) $ aggregateByDate cashFlow
aggregateByDate datedAmounts =
-- Aggregate all entries for a single day, assuming that intraday interest is negligible -- Aggregate all entries for a single day, assuming that intraday interest is negligible
sort dbg3 "datedCashflows"
$ sort
$ map (\datecashes -> let (dates, cash) = unzip datecashes in (headDef (error' "Roi.hs: datecashes was null, please report a bug") dates, maSum cash)) $ map (\datecashes -> let (dates, cash) = unzip datecashes in (headDef (error' "Roi.hs: datecashes was null, please report a bug") dates, maSum cash))
$ groupBy ((==) `on` fst) $ groupBy ((==) `on` fst)
$ sortOn fst $ sortOn fst
$ map (second maNegate) $ map (second maNegate)
$ datedAmounts $ cashflows
let units = valueBefore = unMix valueBeforeAmt
tailDef (error' "Roi.hs units was null, please report a bug") $ valueAfter = unMix valueAfterAmt
scanl
(\(_, _, unitCost, unitBalance) (date, amt) -> investmentPostings = concatMap (filter (matchesPosting investmentsQuery) . realPostings) trans
let valueOnDate = unMix $ mixedAmountValue end date $ total trans (And [investmentsQuery, Date (DateSpan Nothing (Just $ Exact date))])
totalInvestmentPostingsTill date = sumPostings $ filter (matchesPosting (Date (DateSpan Nothing (Just $ Exact date)))) investmentPostings
-- filter span is (-infinity, date+1), which gives us effectively (-infinity, date]
valueAfterDate date = unMix $ mixedAmountValue end date $ totalInvestmentPostingsTill (addDays 1 date)
-- We are dividing the period [begin, end) into subperiods on each cashflow, and then compute
-- the rate of return for each subperiod. For this we need to know the value of the investment
-- at the beginning and end of each subperiod, adjusted for cashflow.
--
-- Subperiods are going to be [valueBefore ... (c_0,v_0)][... (c_1, v_1)][... (c_2,v_2)] ... [... (c_n,v_n)][... valueAfter]
-- , where v_i is the value of investment computed immediately after cashflow c_i
addEnd cflows =
case lastMay cflows of
Nothing -> cflows
Just entry ->
let end_ = addDays (-1) end in
if twrDate entry < end_ then cflows++[TwrEntry end_ 0 valueAfter (pnlOn end_)] else cflows
pnlOn date = unMix $ maNegate $ sum $ map snd $ filter ((==date).fst) pnls
twrEntries =
dbg3 "twrEntries"
$ addEnd
$ concatMap (\(date,cashflow) ->
let pnl = pnlOn date
cash = unMix cashflow
value_ = valueAfterDate date - pnl - cash -- valueAfterDate includes both cashflow and pnl on date, if any
in in
case amt of -- if we had PnL postings on the same day as cashflow,
Right amt' -> -- we want to account for them separately. If pnl is positive, we apply pnl first, and if pnl was negative
-- we are buying or selling -- we apply cashflow first, in an attempt to avoid having negative valuations and ugly debug output (and
let unitsBoughtOrSold = unMix amt' / unitCost -- computations as well)
in (valueOnDate, unitsBoughtOrSold, unitCost, unitBalance + unitsBoughtOrSold) if pnl == 0 then [TwrEntry date cash (value_ + cash) 0]
Left pnl' -> else if pnl > 0
-- PnL change then [TwrEntry date 0 (value_ + pnl) pnl, TwrEntry date cash (value_ + cash + pnl) 0]
let valueAfterDate = valueOnDate + unMix pnl' else [TwrEntry date cash (value_ + cash) 0, TwrEntry date 0 (value_ + cash + pnl) pnl]
unitCost' = ) datedCashflows
if unitBalance == 0 then initialUnitCost -- everything was sold, let's reset the cost to initial cost
else valueAfterDate/unitBalance
in (valueOnDate, 0, unitCost', unitBalance))
(0, 0, initialUnitCost, initialUnits)
$ dbg3 "changes" changes
let finalUnitBalance = if null units then initialUnits else let (_,_,_,u) = last units in u -- Calculate interest for each subperiod, adjusting the value at the start of the period by the cashflow
finalUnitCost = if finalUnitBalance == 0 then -- For subperiods [valueBefore ... (c_0,v_0)][... (c_1, v_1)][... (c_2,v_2)] ... [... (c_n,v_n)][... valueAfter], the computation is going to be
if null units then initialUnitCost -- 1 + twr = (v_0 - c_0)/valueBefore + (v_1 - c_1) / v_0 + ... + valueAfter/v_n
else let (_,_,lastUnitCost,_) = last units in lastUnitCost -- See https://en.wikipedia.org/wiki/Time-weighted_return#Time-weighted_return_compensating_for_external_flows
else (unMix valueAfter) / finalUnitBalance let calculateSubPeriods _ [] = []
-- Technically, totalTWR should be (100*(finalUnitCost - initialUnitCost) / initialUnitCost), but initalUnitCost is 100, so 100/100 == 1 calculateSubPeriods prev (curr:rest) =
totalTWR = roundTo 2 $ (finalUnitCost - initialUnitCost) let adjustedEnd = twrValueAfter curr - twrCashflow curr in
let subPeriodReturn =
if twrValueAfter prev == 0 || adjustedEnd == 0
then 1
else adjustedEnd / (twrValueAfter prev)
in (subPeriodReturn, (prev, curr)) : calculateSubPeriods curr rest
let subPeriods = dbg3 "subPeriods" $ calculateSubPeriods (TwrEntry begin 0 valueBefore (pnlOn begin)) twrEntries
-- Compute overall time-weighted rate of return
let twr =
dbg3 "twr" $
if subPeriods == []
then if valueBefore == 0 then 0 else (valueAfter - valueBefore)/valueBefore
else (product $ map fst subPeriods) - 1
(startYear, _, _) = toGregorian begin (startYear, _, _) = toGregorian begin
years = fromIntegral (diffDays end begin) / (if isLeapYear startYear then 366 else 365) :: Double years = fromIntegral (diffDays end begin) / (if isLeapYear startYear then 366 else 365) :: Double
annualizedTWR = 100*((1+(realToFrac totalTWR/100))**(1/years)-1) :: Double periodTWR = roundTo 2 $ 100 * twr
annualizedTWR = 100*((1+(realToFrac twr))**(1/years)-1) :: Double
when showCashFlow $ do when showCashFlow $ do
printf "\nTWR cash flow for %s - %s\n" (showDate begin) (showDate (addDays (-1) end)) printf "\nTWR cash flow entries and subperiod rates for period %s - %s\n" (showDate begin) (showDate (addDays (-1) end))
let (dates', amts) = unzip changes let showDecimalT = T.pack . showDecimal
cashflows' = map (fromRight nullmixedamt) amts let dates = map twrDate twrEntries
pnls = map (fromLeft nullmixedamt) amts TL.putStrLn $ Tab.render prettyTables id id id
(valuesOnDate,unitsBoughtOrSold', unitPrices', unitBalances') = unzip4 units
add x lst = if valueBefore/=0 then x:lst else lst
dates = add begin dates'
cashflows = add valueBeforeAmt cashflows'
unitsBoughtOrSold = add initialUnits unitsBoughtOrSold'
unitPrices = add initialUnitCost unitPrices'
unitBalances = add initialUnits unitBalances'
TL.putStr $ Tab.render prettyTables id id T.pack
(Table (Table
(Tab.Group NoLine (map (Header . showDate) dates)) (Tab.Group Tab.NoLine (map (Header . showDate) dates))
(Tab.Group DoubleLine [ Tab.Group Tab.SingleLine [Tab.Header "Portfolio value", Tab.Header "Unit balance"] (Tab.Group Tab.SingleLine [Header "Amount", Header "PnL on this day", Header "Value afterwards" ])
, Tab.Group Tab.SingleLine [Tab.Header "Pnl", Tab.Header "Cashflow", Tab.Header "Unit price", Tab.Header "Units"] ( [ [ showDecimalT (twrCashflow e), showDecimalT (twrPnl e), showDecimalT (twrValueAfter e) ]
, Tab.Group Tab.SingleLine [Tab.Header "New Unit Balance"]]) | e <- twrEntries ]))
[ [val, oldBalance, pnl', cashflow, prc, udelta, balance]
| val <- map showDecimal valuesOnDate
| oldBalance <- map showDecimal (0:unitBalances)
| balance <- map showDecimal unitBalances
| pnl' <- map (showMixedAmountOneLineWithoutCost False . styleAmounts styles) pnls
| cashflow <- map (showMixedAmountOneLineWithoutCost False . styleAmounts styles) cashflows
| prc <- map showDecimal unitPrices
| udelta <- map showDecimal unitsBoughtOrSold ])
printf "Final unit price: %s/%s units = %s\nTotal TWR: %s%%.\nPeriod: %.2f years.\nAnnualized TWR: %.2f%%\n\n" TL.putStr $ Tab.render prettyTables T.pack id id
(showMixedAmountOneLineWithoutCost False $ styleAmounts styles valueAfter) (showDecimal finalUnitBalance) (showDecimal finalUnitCost) (showDecimal totalTWR) years annualizedTWR (Table
(Tab.Group Tab.NoLine [ Header (show n) | n <-[1..length subPeriods]])
(Tab.Group DoubleLine [ Tab.Group Tab.SingleLine [Tab.Header "Subperiod start", Tab.Header "Subperiod end"]
, Tab.Group Tab.SingleLine [Tab.Header "Value at start", Tab.Header "Cashflow", Tab.Header "PnL postings", Tab.Header "Value at end"]
, Tab.Group Tab.SingleLine [Tab.Header "Subperiod return rate"]])
[ [ showDate (twrDate prev), showDate (twrDate curr)
, showDecimalT (twrValueAfter prev - twrCashflow prev), showDecimalT (twrCashflow prev), showDecimalT (twrPnl prev), showDecimalT (twrValueAfter curr - twrCashflow curr)
, showDecimalT rate ]
| (rate, (prev, curr)) <- subPeriods
])
return ((realToFrac totalTWR) :: Double, annualizedTWR) printf "Total period TWR: %s%%.\nPeriod: %.2f years.\nAnnualized TWR: %.2f%%\n\n"
(showDecimal periodTWR) years annualizedTWR
return ((realToFrac periodTWR) :: Double, annualizedTWR)
internalRateOfReturn styles showCashFlow prettyTables (OneSpan begin end valueBefore valueAfter cashFlow _pnl) = do internalRateOfReturn styles showCashFlow prettyTables (OneSpan begin end valueBefore valueAfter cashFlow _pnl) = do
let prefix = (begin, maNegate valueBefore) let prefix = (begin, maNegate valueBefore)

View File

@ -170,7 +170,7 @@ $ hledger -f- roi --inv investment --pnl pnl -b 2017 -e 2018 -Q
| 1 || 2017-01-01 | 2017-03-31 || 0 | $100 | $100 | 0 || 0.00% || 0.00% | 0.00% | | 1 || 2017-01-01 | 2017-03-31 || 0 | $100 | $100 | 0 || 0.00% || 0.00% | 0.00% |
| 2 || 2017-04-01 | 2017-06-30 || $100 | 0 | $110 | $10 || 46.56% || 10.00% | 46.56% | | 2 || 2017-04-01 | 2017-06-30 || $100 | 0 | $110 | $10 || 46.56% || 10.00% | 46.56% |
| 3 || 2017-07-01 | 2017-09-30 || $110 | $100 | $210 | 0 || 0.00% || 0.00% | 0.00% | | 3 || 2017-07-01 | 2017-09-30 || $110 | $100 | $210 | 0 || 0.00% || 0.00% | 0.00% |
| 4 || 2017-10-01 | 2017-12-31 || $210 | $-50 | $155 | $-5 || -11.83% || -3.12% | -11.82% | | 4 || 2017-10-01 | 2017-12-31 || $210 | $-50 | $155 | $-5 || -11.83% || -3.12% | -11.83% |
+-------++------------+------------++---------------+----------+-------------+-----++---------++------------+----------+ +-------++------------+------------++---------------+----------+-------------+-----++---------++------------+----------+
| Total || 2017-01-01 | 2017-12-31 || 0 | $150 | $155 | $5 || 3.64% || 6.56% | 6.56% | | Total || 2017-01-01 | 2017-12-31 || 0 | $150 | $155 | $5 || 3.64% || 6.56% | 6.56% |
+-------++------------+------------++---------------+----------+-------------+-----++---------++------------+----------+ +-------++------------+------------++---------------+----------+-------------+-----++---------++------------+----------+
@ -277,7 +277,7 @@ $ hledger -f - roi --inv assets:investment --pnl income:investment --value=then,
+---++------------+------------++---------------+---------------+---------------+--------------++---------++------------+----------+ +---++------------+------------++---------------+---------------+---------------+--------------++---------++------------+----------+
| || Begin | End || Value (begin) | Cashflow | Value (end) | PnL || IRR || TWR/period | TWR/year | | || Begin | End || Value (begin) | Cashflow | Value (end) | PnL || IRR || TWR/period | TWR/year |
+===++============+============++===============+===============+===============+==============++=========++============+==========+ +===++============+============++===============+===============+===============+==============++=========++============+==========+
| 1 || 2020-12-02 | 2021-01-02 || 0 | $131.23359580 | $148.89009204 | $17.65649624 || 321.99% || 13.45% | 323.47% | | 1 || 2020-12-02 | 2021-01-02 || 0 | $131.23359580 | $148.89009204 | $17.65649624 || 321.99% || 13.45% | 323.66% |
+---++------------+------------++---------------+---------------+---------------+--------------++---------++------------+----------+ +---++------------+------------++---------------+---------------+---------------+--------------++---------++------------+----------+
>= >=
@ -376,7 +376,7 @@ P 2023-01-01 C 1B
P 2023-12-31 C 2B P 2023-12-31 C 2B
$ hledger -f - roi --inv investment --pnl income --value='end,B' -b2023 -e2024 $ hledger -f - roi --inv investment --pnl income --value='then,B' -b2023 -e2024
+---++------------+------------++---------------+----------+-------------+-----++--------++------------+----------+ +---++------------+------------++---------------+----------+-------------+-----++--------++------------+----------+
| || Begin | End || Value (begin) | Cashflow | Value (end) | PnL || IRR || TWR/period | TWR/year | | || Begin | End || Value (begin) | Cashflow | Value (end) | PnL || IRR || TWR/period | TWR/year |
+===++============+============++===============+==========+=============+=====++========++============+==========+ +===++============+============++===============+==========+=============+=====++========++============+==========+
@ -384,3 +384,32 @@ $ hledger -f - roi --inv investment --pnl income --value='end,B' -b2023 -e2024
+---++------------+------------++---------------+----------+-------------+-----++--------++------------+----------+ +---++------------+------------++---------------+----------+-------------+-----++--------++------------+----------+
>= 0 >= 0
# ** 16. Correcly process dates with just pricing changes
<
D 1,000.00 EUR
2018-07-01 investment
assets:bank
investments:iShares Core MSCI World 1 "IE00B4L5Y983"
P 2018-12-28 "IE00B4L5Y983" 43.11000000 "EUR"
P 2019-06-28 "IE00B4L5Y983" 50.93000000 "EUR"
2019-07-01 investment
assets:bank
investments:iShares Core MSCI World 10 "IE00B4L5Y983"
P 2019-12-30 "IE00B4L5Y983" 56.59000000 "EUR"
$ hledger -f - roi --value then --begin 2019 --end 2020 --inv investmen --pnl '"profit and loss"' -p 'every 2 quarters'
+-------++------------+------------++---------------+------------+-------------+-----------++--------++------------+----------+
| || Begin | End || Value (begin) | Cashflow | Value (end) | PnL || IRR || TWR/period | TWR/year |
+=======++============+============++===============+============+=============+===========++========++============+==========+
| 1 || 2019-01-01 | 2019-06-30 || 43.11 EUR | 0 | 50.93 EUR | 7.82 EUR || 39.96% || 18.14% | 39.96% |
| 2 || 2019-07-01 | 2019-12-31 || 50.93 EUR | 509.30 EUR | 622.49 EUR | 62.26 EUR || 23.25% || 11.11% | 23.25% |
+-------++------------+------------++---------------+------------+-------------+-----------++--------++------------+----------+
| Total || 2019-01-01 | 2019-12-31 || 43.11 EUR | 509.30 EUR | 622.49 EUR | 70.08 EUR || 24.51% || 31.27% | 31.27% |
+-------++------------+------------++---------------+------------+-------------+-----------++--------++------------+----------+
>= 0