hledger/hledger/Hledger/Cli/Commands/Roi.hs
Simon Michael e035730afb imp: roi: limit large decimals to 8 digits by default (precisiongeddon)
With valuation now preserving more decimal digits, roi could show
excessively precise decimals if there was no known display precision
for the valuation commodity. Now in that situation it limits the
precision to a maximum of 8 digits.
2023-11-08 13:59:18 -08:00

345 lines
16 KiB
Haskell

{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE ParallelListComp #-}
{-# LANGUAGE TemplateHaskell #-}
{-# LANGUAGE TupleSections #-}
{-# LANGUAGE RecordWildCards #-}
{-# LANGUAGE ViewPatterns #-}
{-|
The @roi@ command prints internal rate of return and time-weighted rate of return for and investment.
-}
module Hledger.Cli.Commands.Roi (
roimode
, roi
) where
import Control.Monad
import System.Exit
import Data.Time.Calendar
import Text.Printf
import Data.Bifunctor (second)
import Data.Either (fromLeft, fromRight, isLeft)
import Data.Function (on)
import Data.List
import Numeric.RootFinding
import Data.Decimal
import qualified Data.Text as T
import qualified Data.Text.Lazy.IO as TL
import System.Console.CmdArgs.Explicit as CmdArgs
import Text.Tabular.AsciiWide as Tab
import Hledger
import Hledger.Cli.CliOptions
roimode = hledgerCommandMode
$(embedFileRelative "Hledger/Cli/Commands/Roi.txt")
[flagNone ["cashflow"] (setboolopt "cashflow") "show all amounts that were used to compute returns"
,flagReq ["investment"] (\s opts -> Right $ setopt "investment" s opts) "QUERY"
"query to select your investment transactions"
,flagReq ["profit-loss","pnl"] (\s opts -> Right $ setopt "pnl" s opts) "QUERY"
"query to select profit-and-loss or appreciation/valuation transactions"
]
[generalflagsgroup1]
hiddenflags
([], Just $ argsFlag "[QUERY]")
-- One reporting span,
data OneSpan = OneSpan
Day -- start date, inclusive
Day -- end date, exclusive
MixedAmount -- value of investment at the beginning of day on spanBegin_
MixedAmount -- value of investment at the end of day on spanEnd_
[(Day,MixedAmount)] -- all deposits and withdrawals (but not changes of value) in the DateSpan [spanBegin_,spanEnd_)
[(Day,MixedAmount)] -- all PnL changes of the value of investment in the DateSpan [spanBegin_,spanEnd_)
deriving (Show)
roi :: CliOpts -> Journal -> IO ()
roi CliOpts{rawopts_=rawopts, reportspec_=rspec@ReportSpec{_rsReportOpts=ReportOpts{..}}} j = do
-- We may be converting posting amounts to value, per hledger_options.m4.md "Effect of --value on reports".
let
-- lbl = lbl_ "roi"
today = _rsDay rspec
priceOracle = journalPriceOracle infer_prices_ j
styles = journalCommodityStylesWith HardRounding j
mixedAmountValue periodlast date =
-- These calculations can generate very precise decimals. To avoid showing too many digits:
-- If we have no style for the valuation commodity, generate one that will limit the precision ?
-- But it's not easy to find out the valuation commodity (or commodities) here if it's implicit,
-- as that information is buried in the price graph.
-- Instead, do what we don't like to do: hard code a max precision, overriding commodity styles.
mixedAmountSetPrecisionMax defaultMaxPrecision
. maybe id (mixedAmountApplyValuation priceOracle styles periodlast today date) value_
. maybe id (mixedAmountToCost styles) conversionop_
let
ropts = _rsReportOpts rspec
wd = whichDate ropts
showCashFlow = boolopt "cashflow" rawopts
prettyTables = pretty_
makeQuery flag = do
q <- either usageError (return . fst) . parseQuery today . T.pack $ stringopt flag rawopts
return . simplifyQuery $ And [queryFromFlags ropts{period_=PeriodAll}, q]
investmentsQuery <- makeQuery "investment"
pnlQuery <- makeQuery "pnl"
let
filteredj = filterJournalTransactions investmentsQuery j
trans = dbg3 "investments" $ jtxns filteredj
when (null trans) $ do
putStrLn "No relevant transactions found. Check your investments query"
exitFailure
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"
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
-- Spans are [begin,end), and end is 1 day after the actual end date we are interested in
let
b = fromEFDay begin
e = fromEFDay end
cashFlowApplyCostValue = map (\(d,amt) -> (d,mixedAmountValue e d amt))
valueBefore =
mixedAmountValue e b $
total trans (And [ investmentsQuery
, Date (DateSpan Nothing (Just begin))])
valueAfter =
mixedAmountValue e e $
total trans (And [investmentsQuery
, Date (DateSpan Nothing (Just end))])
priceDates = dbg3 "priceDates" $ nub $ filter (spanContainsDate spn) priceDirectiveDates
cashFlow =
((map (,nullmixedamt) priceDates)++) $
cashFlowApplyCostValue $
calculateCashFlow wd trans (And [ Not investmentsQuery
, Not pnlQuery
, Date spn ] )
pnl =
cashFlowApplyCostValue $
calculateCashFlow wd trans (And [ Not investmentsQuery
, pnlQuery
, Date spn ] )
thisSpan = dbg3 "processing span" $
OneSpan b e valueBefore valueAfter cashFlow pnl
irr <- internalRateOfReturn styles showCashFlow prettyTables thisSpan
(periodTwr, annualizedTwr) <- timeWeightedReturn styles showCashFlow prettyTables investmentsQuery trans mixedAmountValue thisSpan
let cashFlowAmt = maNegate . maSum $ map snd cashFlow
let smallIsZero x = if abs x < 0.01 then 0.0 else x
return [ showDate b
, showDate (addDays (-1) e)
, T.pack $ showMixedAmount $ styleAmounts styles $ valueBefore
, T.pack $ showMixedAmount $ styleAmounts styles $ cashFlowAmt
-- , T.pack $ showMixedAmount $
-- -- dbg0With (lbl "cashflow after styling".showMixedAmountOneLine) $
-- mapMixedAmount (amountSetFullPrecisionOr (Just defaultMaxPrecision)) $
-- styleAmounts (styles
-- -- & dbg0With (lbl "styles".show))
-- cashFlowAmt
-- -- & dbg0With (lbl "cashflow before styling".showMixedAmountOneLine)
, T.pack $ showMixedAmount $ styleAmounts styles $ valueAfter
, T.pack $ showMixedAmount $ styleAmounts styles $ (valueAfter `maMinus` (valueBefore `maPlus` cashFlowAmt))
, T.pack $ printf "%0.2f%%" $ smallIsZero irr
, T.pack $ printf "%0.2f%%" $ smallIsZero periodTwr
, T.pack $ printf "%0.2f%%" $ smallIsZero annualizedTwr ]
periodRows <- forM spans processSpan
totalRow <- processSpan fullPeriod
let rowTitles = Tab.Group Tab.NoLine (map (Header . T.pack . show) (take (length periodRows) [1..]))
let isSingleSpan = length spans == 1
let table = Table
(if isSingleSpan
then rowTitles
else Tab.Group Tab.SingleLine [ rowTitles, Tab.Group Tab.NoLine [ Header "Total" ]]
)
(Tab.Group Tab.DoubleLine
[ Tab.Group Tab.SingleLine [Header "Begin", Header "End"]
, Tab.Group Tab.SingleLine [Header "Value (begin)", Header "Cashflow", Header "Value (end)", Header "PnL"]
, Tab.Group Tab.SingleLine [Header "IRR"]
, Tab.Group Tab.SingleLine [Header "TWR/period", Header "TWR/year"]])
(if isSingleSpan then periodRows else periodRows ++ [totalRow])
TL.putStrLn $ Tab.render prettyTables id id id table
timeWeightedReturn styles showCashFlow prettyTables investmentsQuery trans mixedAmountValue (OneSpan begin end valueBeforeAmt valueAfter cashFlow pnl) = do
let valueBefore = unMix valueBeforeAmt
let initialUnitPrice = 100 :: Decimal
let initialUnits = valueBefore / initialUnitPrice
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
Right amt' -> mixedAmountIsZero amt'
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
sort
$ map (\date_cash -> let (dates, cash) = unzip date_cash in (head dates, maSum cash))
$ groupBy ((==) `on` fst)
$ sortOn fst
$ map (second maNegate)
$ datedAmounts
let units =
tail $
scanl
(\(_, _, unitPrice, unitBalance) (date, amt) ->
let valueOnDate = unMix $ mixedAmountValue end date $ total trans (And [investmentsQuery, Date (DateSpan Nothing (Just $ Exact date))])
in
case amt of
Right amt' ->
-- we are buying or selling
let unitsBoughtOrSold = unMix amt' / unitPrice
in (valueOnDate, unitsBoughtOrSold, unitPrice, unitBalance + unitsBoughtOrSold)
Left pnl' ->
-- PnL change
let valueAfterDate = valueOnDate + unMix pnl'
unitPrice' = valueAfterDate/unitBalance
in (valueOnDate, 0, unitPrice', unitBalance))
(0, 0, initialUnitPrice, initialUnits)
$ dbg3 "changes" changes
let finalUnitBalance = if null units then initialUnits else let (_,_,_,u) = last units in u
finalUnitPrice = if finalUnitBalance == 0 then
if null units then initialUnitPrice
else let (_,_,lastUnitPrice,_) = last units in lastUnitPrice
else (unMix valueAfter) / finalUnitBalance
-- Technically, totalTWR should be (100*(finalUnitPrice - initialUnitPrice) / initialUnitPrice), but initalUnitPrice is 100, so 100/100 == 1
totalTWR = roundTo 2 $ (finalUnitPrice - initialUnitPrice)
(startYear, _, _) = toGregorian begin
years = fromIntegral (diffDays end begin) / (if isLeapYear startYear then 366 else 365) :: Double
annualizedTWR = 100*((1+(realToFrac totalTWR/100))**(1/years)-1) :: Double
when showCashFlow $ do
printf "\nTWR cash flow for %s - %s\n" (showDate begin) (showDate (addDays (-1) end))
let (dates', amts) = unzip changes
cashflows' = map (fromRight nullmixedamt) amts
pnls = map (fromLeft nullmixedamt) amts
(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 initialUnitPrice unitPrices'
unitBalances = add initialUnits unitBalances'
TL.putStr $ Tab.render prettyTables id id T.pack
(Table
(Tab.Group NoLine (map (Header . showDate) dates))
(Tab.Group DoubleLine [ Tab.Group Tab.SingleLine [Tab.Header "Portfolio value", Tab.Header "Unit balance"]
, Tab.Group Tab.SingleLine [Tab.Header "Pnl", Tab.Header "Cashflow", Tab.Header "Unit price", Tab.Header "Units"]
, Tab.Group Tab.SingleLine [Tab.Header "New Unit Balance"]])
[ [val, oldBalance, pnl', cashflow, prc, udelta, balance]
| val <- map showDecimal valuesOnDate
| oldBalance <- map showDecimal (0:unitBalances)
| balance <- map showDecimal unitBalances
| pnl' <- map (showMixedAmount . styleAmounts styles) pnls
| cashflow <- map (showMixedAmount . 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"
(showMixedAmount $ styleAmounts styles valueAfter) (showDecimal finalUnitBalance) (showDecimal finalUnitPrice) (showDecimal totalTWR) years annualizedTWR
return ((realToFrac totalTWR) :: Double, annualizedTWR)
internalRateOfReturn styles showCashFlow prettyTables (OneSpan begin end valueBefore valueAfter cashFlow _pnl) = do
let prefix = (begin, maNegate valueBefore)
postfix = (end, valueAfter)
totalCF = filter (maIsNonZero . snd) $ prefix : (sortOn fst cashFlow) ++ [postfix]
when showCashFlow $ do
printf "\nIRR cash flow for %s - %s\n" (showDate begin) (showDate (addDays (-1) end))
let (dates, amts) = unzip totalCF
TL.putStrLn $ Tab.render prettyTables id id id
(Table
(Tab.Group Tab.NoLine (map (Header . showDate) dates))
(Tab.Group Tab.SingleLine [Header "Amount"])
(map ((:[]) . T.pack . showMixedAmount . styleAmounts styles) amts))
-- 0% is always a solution, so require at least something here
case totalCF of
[] -> return 0
_ -> case ridders (RiddersParam 100 (AbsTol 0.00001))
(0.000000000001,10000)
(interestSum end totalCF) of
Root rate -> return ((rate-1)*100)
NotBracketed -> error' $ "Error (NotBracketed): No solution for Internal Rate of Return (IRR).\n"
++ " Possible causes: IRR is huge (>1000000%), balance of investment becomes negative at some point in time."
SearchFailed -> error' $ "Error (SearchFailed): Failed to find solution for Internal Rate of Return (IRR).\n"
++ " Either search does not converge to a solution, or converges too slowly."
type CashFlow = [(Day, MixedAmount)]
interestSum :: Day -> CashFlow -> Double -> Double
interestSum referenceDay cf rate = sum $ map go cf
where go (t,m) = realToFrac (unMix m) * rate ** (fromIntegral (referenceDay `diffDays` t) / 365)
calculateCashFlow :: WhichDate -> [Transaction] -> Query -> CashFlow
calculateCashFlow wd trans query =
[ (postingDateOrDate2 wd p, pamount p) | p <- filter (matchesPosting query) (concatMap realPostings trans), maIsNonZero (pamount p) ]
total :: [Transaction] -> Query -> MixedAmount
total trans query = sumPostings . filter (matchesPosting query) $ concatMap realPostings trans
unMix :: MixedAmount -> Quantity
unMix a =
case (unifyMixedAmount $ mixedAmountCost a) of
Just a' -> aquantity a'
Nothing -> error' $ "Amounts could not be converted to a single cost basis: " ++ show (map showAmount $ amounts a) ++
"\nConsider using --value to force all costs to be in a single commodity." ++
"\nFor example, \"--cost --value=end,<commodity> --infer-market-prices\", where commodity is the one that was used to pay for the investment."
-- Show Decimal rounded to two decimal places, unless it has less places already. This ensures that "2" won't be shown as "2.00"
showDecimal :: Decimal -> String
showDecimal d = if d == rounded then show d else show rounded
where
rounded = roundTo 2 d