mirror of
https://github.com/simonmichael/hledger.git
synced 2024-11-07 21:15:19 +03:00
cli: command to compute return on investment
This commit is contained in:
parent
2960c9209f
commit
3397ccdd4c
@ -74,6 +74,7 @@ import Hledger.Cli.Commands.Printunique
|
||||
import Hledger.Cli.Commands.Register
|
||||
import Hledger.Cli.Commands.Registermatch
|
||||
import Hledger.Cli.Commands.Rewrite
|
||||
import Hledger.Cli.Commands.Roi
|
||||
import Hledger.Cli.Commands.Stats
|
||||
import Hledger.Cli.Commands.Tags
|
||||
|
||||
@ -102,6 +103,7 @@ builtinCommands = [
|
||||
,(registermode , register)
|
||||
,(registermatchmode , registermatch)
|
||||
,(rewritemode , rewrite)
|
||||
,(roimode , roi)
|
||||
,(statsmode , stats)
|
||||
,(tagsmode , tags)
|
||||
,(testmode , testcmd)
|
||||
|
244
hledger/Hledger/Cli/Commands/Roi.hs
Normal file
244
hledger/Hledger/Cli/Commands/Roi.hs
Normal file
@ -0,0 +1,244 @@
|
||||
{-# LANGUAGE QuasiQuotes,ParallelListComp #-}
|
||||
{-|
|
||||
|
||||
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.Function (on)
|
||||
import Data.List
|
||||
import Data.Ord
|
||||
import Statistics.Math.RootFinding
|
||||
import Data.Decimal
|
||||
import Data.String.Here
|
||||
import System.Console.CmdArgs.Explicit as CmdArgs
|
||||
|
||||
import Text.Tabular as Tbl
|
||||
import Text.Tabular.AsciiWide as Ascii
|
||||
|
||||
import Hledger
|
||||
import Hledger.Cli.CliOptions
|
||||
|
||||
|
||||
roimode = (defCommandMode $ ["roi"]) {
|
||||
modeHelp = "shows return on investment for your portfolio."
|
||||
,modeHelpSuffix=lines [here|
|
||||
This command will show you time-weighted (TWR) and money-weighted (IRR) rate of return on your investments.
|
||||
|
||||
Command assumes that you have account(s) that hold nothing but your investments and whenever you record current appraisal/valuation of these investments you offset unrealized profit and loss into account(s) that, again, hold nothing but unrealized profit and loss.
|
||||
|
||||
Any transactions affecting balance of investment account(s) and not originating from unrealized profit and loss account(s) are assumed to be your investments or withdrawals.
|
||||
|
||||
At a minimum, you need to supply query (which could be just an account name) to select your investments with `--inv`, and another query to identify your profit and loss transactions with `--pnl`.
|
||||
|
||||
Command will compute and display internalized rate of return (IRR) and time-weighted rate of return (TWR) for your investments for the time period requested. Both rates of return are annualized before display, regardless of the length of reporting interval.
|
||||
|]
|
||||
,modeGroupFlags = CmdArgs.Group {
|
||||
groupUnnamed = [
|
||||
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"
|
||||
]
|
||||
, groupHidden = []
|
||||
,groupNamed = [generalflagsgroup1]
|
||||
}
|
||||
}
|
||||
|
||||
-- One reporting span,
|
||||
data OneSpan = OneSpan
|
||||
Day -- start date, inclusive
|
||||
Day -- end date, exclusive
|
||||
Quantity -- value of investment at the beginning of day on spanBegin_
|
||||
Quantity -- value of investment at the end of day on spanEnd_
|
||||
[(Day,Quantity)] -- all deposits and withdrawals (but not changes of value) in the DateSpan [spanBegin_,spanEnd_)
|
||||
deriving (Show)
|
||||
|
||||
|
||||
roi :: CliOpts -> Journal -> IO ()
|
||||
roi CliOpts{rawopts_=rawopts, reportopts_=ropts} j = do
|
||||
d <- getCurrentDay
|
||||
let
|
||||
investmentsQuery = queryFromOpts d $ ropts{query_ = stringopt "investment" rawopts,period_=PeriodAll}
|
||||
pnlQuery = queryFromOpts d $ ropts{query_ = stringopt "pnl" rawopts,period_=PeriodAll}
|
||||
showCashFlow = boolopt "cashflow" rawopts
|
||||
prettyTables = pretty_tables_ ropts
|
||||
|
||||
trans = dbg3 "investments" $ jtxns $ filterJournalTransactions investmentsQuery j
|
||||
|
||||
journalSpan =
|
||||
let dates = map transactionDate2 trans in
|
||||
DateSpan (Just $ minimum dates) (Just $ addDays 1 $ maximum dates)
|
||||
|
||||
requestedSpan = periodAsDateSpan $ period_ ropts
|
||||
requestedInterval = interval_ ropts
|
||||
|
||||
wholeSpan = spanDefaultsFrom requestedSpan journalSpan
|
||||
|
||||
when (null trans) $ do
|
||||
putStrLn "No relevant transactions found. Check your investments query"
|
||||
exitFailure
|
||||
|
||||
let spans = case requestedInterval of
|
||||
NoInterval -> [wholeSpan]
|
||||
interval ->
|
||||
splitSpan interval $
|
||||
spanIntersect journalSpan wholeSpan
|
||||
|
||||
tableBody <- (flip mapM) spans $ \(DateSpan (Just spanBegin) (Just spanEnd)) -> do
|
||||
-- Spans are [spanBegin,spanEnd), and spanEnd is 1 day after then actual end date we are interested in
|
||||
let
|
||||
valueBefore =
|
||||
total trans (And [ investmentsQuery
|
||||
, Date (DateSpan Nothing (Just spanBegin))])
|
||||
|
||||
valueAfter =
|
||||
total trans (And [investmentsQuery
|
||||
, Date (DateSpan Nothing (Just spanEnd))])
|
||||
|
||||
cashFlow =
|
||||
calculateCashFlow trans (And [ Not investmentsQuery
|
||||
, Not pnlQuery
|
||||
, Date (DateSpan (Just spanBegin) (Just spanEnd)) ] )
|
||||
|
||||
thisSpan = dbg3 "processing span" $
|
||||
OneSpan spanBegin spanEnd valueBefore valueAfter cashFlow
|
||||
|
||||
irr <- internalRateOfReturn showCashFlow prettyTables thisSpan
|
||||
twr <- timeWeightedReturn showCashFlow prettyTables investmentsQuery trans thisSpan
|
||||
let cashFlowAmt = negate $ sum $ map snd cashFlow
|
||||
return [ showDate spanBegin
|
||||
, showDate (addDays (-1) spanEnd)
|
||||
, show valueBefore
|
||||
, show cashFlowAmt
|
||||
, show valueAfter
|
||||
, show (valueAfter - (valueBefore + cashFlowAmt))
|
||||
, printf "%0.2f%%" irr
|
||||
, printf "%0.2f%%" twr ]
|
||||
|
||||
let table = Table
|
||||
(Tbl.Group NoLine (map (Header . show) (take (length tableBody) [1..])))
|
||||
(Tbl.Group DoubleLine
|
||||
[ Tbl.Group SingleLine [Header "Begin", Header "End"]
|
||||
, Tbl.Group SingleLine [Header "Value (begin)", Header "Cashflow", Header "Value (end)", Header "PnL"]
|
||||
, Tbl.Group SingleLine [Header "IRR", Header "TWR"]])
|
||||
tableBody
|
||||
|
||||
putStrLn $ Ascii.render prettyTables id id id table
|
||||
|
||||
timeWeightedReturn showCashFlow prettyTables investmentsQuery trans (OneSpan spanBegin spanEnd valueBefore valueAfter cashFlow) = do
|
||||
let initialUnitPrice = 100
|
||||
let initialUnits = valueBefore / initialUnitPrice
|
||||
let cashflow =
|
||||
-- Aggregate all entries for a single day, assuming that intraday interest is negligible
|
||||
map (\date_cash -> let (dates, cash) = unzip date_cash in (head dates, sum cash))
|
||||
$ groupBy ((==) `on` fst)
|
||||
$ sortBy (comparing fst)
|
||||
$ map (\(d,a) -> (d, negate a))
|
||||
$ filter ((/=0).snd) cashFlow
|
||||
|
||||
let units =
|
||||
tail $
|
||||
(flip scanl)
|
||||
(0,0,0,initialUnits)
|
||||
(\(_,_,_,unitBalance) (date, amt) ->
|
||||
let valueOnDate =
|
||||
total trans (And [investmentsQuery, Date (DateSpan Nothing (Just date))])
|
||||
unitPrice = if unitBalance == 0.0 then initialUnitPrice else valueOnDate / unitBalance
|
||||
unitsBoughtOrSold = amt / unitPrice
|
||||
in
|
||||
(valueOnDate, unitsBoughtOrSold, unitPrice, unitBalance + unitsBoughtOrSold)
|
||||
)
|
||||
cashflow
|
||||
|
||||
let finalUnitBalance = if null units then initialUnits else let (_,_,_,u) = last units in u
|
||||
finalUnitPrice = valueAfter / finalUnitBalance
|
||||
totalTWR = roundTo 2 $ (finalUnitPrice - initialUnitPrice)
|
||||
years = (fromIntegral $ diffDays spanEnd spanBegin)/365 :: Double
|
||||
annualizedTWR = 100*((1+(realToFrac totalTWR/100))**(1/years)-1) :: Double
|
||||
|
||||
let s d = show $ roundTo 2 d
|
||||
when showCashFlow $ do
|
||||
printf "\nTWR cash flow for %s - %s\n" (showDate spanBegin) (showDate (addDays (-1) spanEnd))
|
||||
let (dates', amounts') = unzip cashflow
|
||||
(valuesOnDate',unitsBoughtOrSold', unitPrices', unitBalances') = unzip4 units
|
||||
add x lst = if valueBefore/=0 then x:lst else lst
|
||||
dates = add spanBegin dates'
|
||||
amounts = add valueBefore amounts'
|
||||
unitsBoughtOrSold = add initialUnits unitsBoughtOrSold'
|
||||
unitPrices = add initialUnitPrice unitPrices'
|
||||
unitBalances = add initialUnits unitBalances'
|
||||
valuesOnDate = add 0 valuesOnDate'
|
||||
|
||||
putStr $ Ascii.render prettyTables id id id
|
||||
(Table
|
||||
(Tbl.Group NoLine (map (Header . showDate) dates))
|
||||
(Tbl.Group DoubleLine [ Tbl.Group SingleLine [Header "Portfolio value", Header "Unit balance"]
|
||||
, Tbl.Group SingleLine [Header "Cash", Header "Unit price", Header "Units"]
|
||||
, Tbl.Group SingleLine [Header "New Unit Balance"]])
|
||||
[ [value, oldBalance, amt, prc, udelta, balance]
|
||||
| value <- map s valuesOnDate
|
||||
| oldBalance <- map s (0:unitBalances)
|
||||
| balance <- map s unitBalances
|
||||
| amt <- map s amounts
|
||||
| prc <- map s unitPrices
|
||||
| udelta <- map s unitsBoughtOrSold ])
|
||||
|
||||
printf "Final unit price: %s/%s=%s U.\nTotal TWR: %s%%.\nPeriod: %.2f years.\nAnnualized TWR: %.2f%%\n\n" (s valueAfter) (s finalUnitBalance) (s finalUnitPrice) (s totalTWR) years annualizedTWR
|
||||
|
||||
return annualizedTWR
|
||||
|
||||
|
||||
internalRateOfReturn showCashFlow prettyTables (OneSpan spanBegin spanEnd valueBefore valueAfter cashFlow) = do
|
||||
let prefix = (spanBegin, negate valueBefore)
|
||||
|
||||
postfix = (spanEnd, valueAfter)
|
||||
|
||||
totalCF = filter ((/=0) . snd) $ prefix : (sortBy (comparing fst) cashFlow) ++ [postfix]
|
||||
|
||||
when showCashFlow $ do
|
||||
printf "\nIRR cash flow for %s - %s\n" (showDate spanBegin) (showDate (addDays (-1) spanEnd))
|
||||
let (dates, amounts) = unzip totalCF
|
||||
putStrLn $ Ascii.render prettyTables id id id
|
||||
(Table
|
||||
(Tbl.Group NoLine (map (Header . showDate) dates))
|
||||
(Tbl.Group SingleLine [Header "Amount"])
|
||||
(map ((:[]) . show) amounts))
|
||||
|
||||
-- 0% is always a solution, so require at least something here
|
||||
case ridders 0.00001 (0.000000000001,10000) (interestSum spanEnd totalCF) of
|
||||
Root rate -> return ((rate-1)*100)
|
||||
NotBracketed -> error "Error: No solution -- not bracketed."
|
||||
SearchFailed -> error "Error: Failed to find solution."
|
||||
|
||||
type CashFlow = [(Day, Quantity)]
|
||||
|
||||
interestSum :: Day -> CashFlow -> Double -> Double
|
||||
interestSum referenceDay cf rate = sum $ map go cf
|
||||
where go (t,m) = (fromRational $ toRational m) * (rate ** (fromIntegral (referenceDay `diffDays` t) / 365))
|
||||
|
||||
|
||||
calculateCashFlow :: [Transaction] -> Query -> CashFlow
|
||||
calculateCashFlow trans query = map go trans
|
||||
where
|
||||
go t = (transactionDate2 t, total [t] query)
|
||||
|
||||
total :: [Transaction] -> Query -> Quantity
|
||||
total trans query = unMix $ sumPostings $ filter (matchesPosting query) $ concatMap realPostings trans
|
||||
|
||||
unMix :: MixedAmount -> Quantity
|
||||
unMix a =
|
||||
case (normaliseMixedAmount $ costOfMixedAmount a) of
|
||||
(Mixed [a]) -> aquantity a
|
||||
_ -> error "MixedAmount failed to normalize"
|
||||
|
@ -2,7 +2,7 @@
|
||||
--
|
||||
-- see: https://github.com/sol/hpack
|
||||
--
|
||||
-- hash: 4950997d3067d59116c10a1928630e2e39d4616f722d9eb3a1bf4e85733c5d1b
|
||||
-- hash: 70e6e178ba5d2d6601ebf07e79fdcc19d2480a0544225da23ee3155e928fd85c
|
||||
|
||||
name: hledger
|
||||
version: 1.10.99
|
||||
@ -104,6 +104,7 @@ library
|
||||
Hledger.Cli.Commands.Register
|
||||
Hledger.Cli.Commands.Registermatch
|
||||
Hledger.Cli.Commands.Rewrite
|
||||
Hledger.Cli.Commands.Roi
|
||||
Hledger.Cli.Commands.Stats
|
||||
Hledger.Cli.Commands.Tags
|
||||
Hledger.Cli.CompoundBalanceCommand
|
||||
@ -141,6 +142,7 @@ library
|
||||
, safe >=0.2
|
||||
, shakespeare >=2.0.2.2
|
||||
, split >=0.1
|
||||
, statistics
|
||||
, tabular >=0.2
|
||||
, temporary
|
||||
, text >=0.11
|
||||
@ -191,6 +193,7 @@ executable hledger
|
||||
, safe >=0.2
|
||||
, shakespeare >=2.0.2.2
|
||||
, split >=0.1
|
||||
, statistics
|
||||
, tabular >=0.2
|
||||
, temporary
|
||||
, text >=0.11
|
||||
@ -244,6 +247,7 @@ test-suite test
|
||||
, safe >=0.2
|
||||
, shakespeare >=2.0.2.2
|
||||
, split >=0.1
|
||||
, statistics
|
||||
, tabular >=0.2
|
||||
, temporary
|
||||
, test-framework
|
||||
@ -298,6 +302,7 @@ benchmark bench
|
||||
, safe >=0.2
|
||||
, shakespeare >=2.0.2.2
|
||||
, split >=0.1
|
||||
, statistics
|
||||
, tabular >=0.2
|
||||
, temporary
|
||||
, text >=0.11
|
||||
|
@ -757,6 +757,10 @@ Helps ledger-autosync detect already-seen transactions when importing.
|
||||
## rewrite
|
||||
Print all transactions, adding custom postings to the matched ones.
|
||||
|
||||
## roi
|
||||
Shows time-weighted (TWR) and money-weighted (IRR) rate of return on your investments.
|
||||
See `roi --help` for more.
|
||||
|
||||
## stats
|
||||
Show some journal statistics.
|
||||
|
||||
|
@ -104,6 +104,7 @@ dependencies:
|
||||
- safe >=0.2
|
||||
- shakespeare >=2.0.2.2
|
||||
- split >=0.1
|
||||
- statistics
|
||||
- tabular >=0.2
|
||||
- temporary
|
||||
- text >=0.11
|
||||
@ -149,6 +150,7 @@ library:
|
||||
- Hledger.Cli.Commands.Register
|
||||
- Hledger.Cli.Commands.Registermatch
|
||||
- Hledger.Cli.Commands.Rewrite
|
||||
- Hledger.Cli.Commands.Roi
|
||||
- Hledger.Cli.Commands.Stats
|
||||
- Hledger.Cli.Commands.Tags
|
||||
- Hledger.Cli.CompoundBalanceCommand
|
||||
|
Loading…
Reference in New Issue
Block a user