lib: roi: fix a bug with PnL attributed to the wrong period (fixes #2391)
This commit is contained in:
parent
8b027a48e8
commit
f38bc34d35
@ -25,7 +25,7 @@ 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, lastMay)
|
import Safe (headDef)
|
||||||
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
|
||||||
@ -175,7 +175,7 @@ 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
|
||||||
|
|
||||||
-- Entry for TWR computation, capturing all cashflows that are potentially accompanied by pnl change on the same day (if not, it is zero)
|
-- Entry for TWR computation, capturing all cashflows that are potentially accompanied by pnl change on the same day (if not, it is zero)
|
||||||
data TwrEntry = TwrEntry { twrDate :: Day, twrCashflow :: Decimal, twrValueAfter :: Decimal, twrPnl :: Decimal } deriving (Eq, Show)
|
data TwrPeriod = TwrPeriod { twrStartDate :: Day, twrEndDate :: Day, twrStartValue :: Decimal, twrValueBeforeCashflow :: Decimal, twrPnl :: Decimal, twrCashflow :: Decimal, twrValueAfterCashflow :: Decimal } deriving (Eq, Show)
|
||||||
|
|
||||||
timeWeightedReturn _styles showCashFlow prettyTables investmentsQuery trans mixedAmountValue (OneSpan begin end valueBeforeAmt valueAfterAmt cashflows pnls) = do
|
timeWeightedReturn _styles showCashFlow prettyTables investmentsQuery trans mixedAmountValue (OneSpan begin end valueBeforeAmt valueAfterAmt cashflows pnls) = do
|
||||||
let datedCashflows =
|
let datedCashflows =
|
||||||
@ -188,8 +188,8 @@ timeWeightedReturn _styles showCashFlow prettyTables investmentsQuery trans mixe
|
|||||||
$ map (second maNegate)
|
$ map (second maNegate)
|
||||||
$ cashflows
|
$ cashflows
|
||||||
|
|
||||||
valueBefore = unMix valueBeforeAmt
|
valueBefore = dbg3 ("value at the start of the interval, "++show begin) $ unMix valueBeforeAmt
|
||||||
valueAfter = unMix valueAfterAmt
|
valueAfter = dbg3 ("value at the end of the interval, "++show end) $ unMix valueAfterAmt
|
||||||
|
|
||||||
investmentPostings = concatMap (filter (matchesPosting investmentsQuery) . realPostings) trans
|
investmentPostings = concatMap (filter (matchesPosting investmentsQuery) . realPostings) trans
|
||||||
|
|
||||||
@ -198,60 +198,51 @@ timeWeightedReturn _styles showCashFlow prettyTables investmentsQuery trans mixe
|
|||||||
-- filter span is (-infinity, date+1), which gives us effectively (-infinity, date]
|
-- filter span is (-infinity, date+1), which gives us effectively (-infinity, date]
|
||||||
valueAfterDate date = unMix $ mixedAmountValue end date $ totalInvestmentPostingsTill (addDays 1 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
|
pnlOn date = unMix $ maNegate $ sum $ map snd $ filter ((==date).fst) pnls
|
||||||
|
|
||||||
twrEntries =
|
-- We are dividing the period [begin, end) into subperiods on each cashflow, and then compute
|
||||||
dbg3 "twrEntries"
|
-- the rate of return for each subperiod. For this we need to know the value of the investment
|
||||||
$ addEnd
|
-- at the beginning and end of each subperiod, adjusted for cashflow.
|
||||||
$ concatMap (\(date,cashflow) ->
|
--
|
||||||
let pnl = pnlOn date
|
-- Subperiods are going to be [valueBefore ... (c_0,v_0)][... (c_1, v_1)][... (c_2,v_2)] ... [... (c_n,v_n)][... valueAfter]
|
||||||
cash = unMix cashflow
|
-- , where v_i is the value of investment computed immediately after cashflow c_i
|
||||||
value_ = valueAfterDate date - pnl - cash -- valueAfterDate includes both cashflow and pnl on date, if any
|
--
|
||||||
in
|
|
||||||
-- if we had PnL postings on the same day as cashflow,
|
|
||||||
-- we want to account for them separately. If pnl is positive, we apply pnl first, and if pnl was negative
|
|
||||||
-- we apply cashflow first, in an attempt to avoid having negative valuations and ugly debug output (and
|
|
||||||
-- computations as well)
|
|
||||||
if pnl == 0 then [TwrEntry date cash (value_ + cash) 0]
|
|
||||||
else if pnl > 0
|
|
||||||
then [TwrEntry date 0 (value_ + pnl) pnl, TwrEntry date cash (value_ + cash + pnl) 0]
|
|
||||||
else [TwrEntry date cash (value_ + cash) 0, TwrEntry date 0 (value_ + cash + pnl) pnl]
|
|
||||||
) datedCashflows
|
|
||||||
|
|
||||||
-- Calculate interest for each subperiod, adjusting the value at the start of the period by the cashflow
|
-- Calculate interest for each subperiod, adjusting the value at the start of the period by the cashflow
|
||||||
-- 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
|
-- 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
|
||||||
-- 1 + twr = (v_0 - c_0)/valueBefore + (v_1 - c_1) / v_0 + ... + valueAfter/v_n
|
-- 1 + twr = (v_0 - c_0)/valueBefore + (v_1 - c_1) / v_0 + ... + valueAfter/v_n
|
||||||
-- See https://en.wikipedia.org/wiki/Time-weighted_return#Time-weighted_return_compensating_for_external_flows
|
-- See https://en.wikipedia.org/wiki/Time-weighted_return#Time-weighted_return_compensating_for_external_flows
|
||||||
let calculateSubPeriods _ [] = []
|
let calculateSubPeriods (startDate,startValue) [] =
|
||||||
calculateSubPeriods prev (curr:rest) =
|
let subPeriodReturn =
|
||||||
let adjustedEnd = twrValueAfter curr - twrCashflow curr in
|
if startValue == 0 || valueAfter == 0
|
||||||
let subPeriodReturn =
|
then 0
|
||||||
if twrValueAfter prev == 0 || adjustedEnd == 0
|
else valueAfter/startValue - 1
|
||||||
then 1
|
in
|
||||||
else adjustedEnd / (twrValueAfter prev)
|
[(subPeriodReturn, TwrPeriod startDate end startValue valueAfter 0 0 valueAfter)]
|
||||||
in (subPeriodReturn, (prev, curr)) : calculateSubPeriods curr rest
|
calculateSubPeriods (startDate,startValue) ((date,cashflow):rest) =
|
||||||
|
let (valueBeforeCashflow, valueAfterCashflow, pnl) =
|
||||||
|
let valueAfterPrevDay = valueAfterDate (addDays (-1) date)
|
||||||
|
pnlOnDay = pnlOn date
|
||||||
|
in
|
||||||
|
-- If value was zero at the start of the period, then any PnL on cashflow date would accrue after it, not before.
|
||||||
|
-- If there was some value already, we can assume that PnL contributes to this period's rate
|
||||||
|
if startValue == 0
|
||||||
|
then (valueAfterPrevDay, valueAfterDate date - pnlOnDay, 0)
|
||||||
|
else (valueAfterPrevDay + pnl, valueAfterDate date, pnlOnDay)
|
||||||
|
subPeriodReturn =
|
||||||
|
if valueBeforeCashflow == 0 || startValue == 0
|
||||||
|
then 0
|
||||||
|
else (valueBeforeCashflow / startValue) - 1
|
||||||
|
in
|
||||||
|
(subPeriodReturn, (TwrPeriod startDate date startValue valueBeforeCashflow pnl (unMix cashflow) valueAfterCashflow)) : calculateSubPeriods (date,valueAfterCashflow) rest
|
||||||
|
|
||||||
let subPeriods = dbg3 "subPeriods" $ calculateSubPeriods (TwrEntry begin 0 valueBefore (pnlOn begin)) twrEntries
|
let subPeriods = dbg3 "subPeriods" $ calculateSubPeriods (begin,valueBefore) datedCashflows
|
||||||
|
|
||||||
-- Compute overall time-weighted rate of return
|
-- Compute overall time-weighted rate of return
|
||||||
let twr =
|
let twr =
|
||||||
dbg3 "twr" $
|
dbg3 "twr" $
|
||||||
if subPeriods == []
|
if subPeriods == []
|
||||||
then if valueBefore == 0 then 0 else (valueAfter - valueBefore)/valueBefore
|
then if valueBefore == 0 then 0 else (valueAfter - valueBefore)/valueBefore
|
||||||
else (product $ map fst subPeriods) - 1
|
else foldl (\acc periodRate -> (1+acc)*(1+periodRate)-1) 0 (map fst subPeriods)
|
||||||
(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
|
||||||
periodTWR = roundTo 2 $ 100 * twr
|
periodTWR = roundTo 2 $ 100 * twr
|
||||||
@ -260,24 +251,16 @@ timeWeightedReturn _styles showCashFlow prettyTables investmentsQuery trans mixe
|
|||||||
when showCashFlow $ do
|
when showCashFlow $ do
|
||||||
printf "\nTWR cash flow entries and subperiod rates for period %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 showDecimalT = T.pack . showDecimal
|
let showDecimalT = T.pack . showDecimal
|
||||||
let dates = map twrDate twrEntries
|
|
||||||
TL.putStrLn $ Tab.render prettyTables id id id
|
|
||||||
(Table
|
|
||||||
(Tab.Group Tab.NoLine (map (Header . showDate) dates))
|
|
||||||
(Tab.Group Tab.SingleLine [Header "Amount", Header "PnL on this day", Header "Value afterwards" ])
|
|
||||||
( [ [ showDecimalT (twrCashflow e), showDecimalT (twrPnl e), showDecimalT (twrValueAfter e) ]
|
|
||||||
| e <- twrEntries ]))
|
|
||||||
|
|
||||||
TL.putStr $ Tab.render prettyTables T.pack id id
|
TL.putStr $ Tab.render prettyTables T.pack id id
|
||||||
(Table
|
(Table
|
||||||
(Tab.Group Tab.NoLine [ Header (show n) | n <-[1..length subPeriods]])
|
(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 DoubleLine [ Tab.Group Tab.SingleLine [Tab.Header "Subperiod start", Tab.Header "Cashflow date"]
|
||||||
, 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 "Value at start", Tab.Header "Value before cashflow (inc PnL)", Tab.Header "PnL on day", Tab.Header "Cashflow", Tab.Header "Value after cashflow"]
|
||||||
, Tab.Group Tab.SingleLine [Tab.Header "Subperiod return rate"]])
|
, Tab.Group Tab.SingleLine [Tab.Header "Subperiod rate, %"]])
|
||||||
[ [ showDate (twrDate prev), showDate (twrDate curr)
|
[ [ showDate (twrStartDate sp), showDate (twrEndDate sp)
|
||||||
, showDecimalT (twrValueAfter prev - twrCashflow prev), showDecimalT (twrCashflow prev), showDecimalT (twrPnl prev), showDecimalT (twrValueAfter curr - twrCashflow curr)
|
, showDecimalT (twrStartValue sp), showDecimalT (twrValueBeforeCashflow sp), showDecimalT (twrPnl sp), showDecimalT (twrCashflow sp), showDecimalT (twrValueAfterCashflow sp)
|
||||||
, showDecimalT rate ]
|
, showDecimalT (roundTo 2 (100*rate)) ]
|
||||||
| (rate, (prev, curr)) <- subPeriods
|
| (rate, sp) <- subPeriods
|
||||||
])
|
])
|
||||||
|
|
||||||
printf "Total period TWR: %s%%.\nPeriod: %.2f years.\nAnnualized TWR: %.2f%%\n\n"
|
printf "Total period TWR: %s%%.\nPeriod: %.2f years.\nAnnualized TWR: %.2f%%\n\n"
|
||||||
|
|||||||
@ -413,3 +413,33 @@ $ hledger -f - roi --value then --begin 2019 --end 2020 --inv investmen --pnl '"
|
|||||||
+-------++------------+------------++---------------+------------+-------------+-----------++--------++------------+----------+
|
+-------++------------+------------++---------------+------------+-------------+-----------++--------++------------+----------+
|
||||||
|
|
||||||
>= 0
|
>= 0
|
||||||
|
|
||||||
|
# ** 17. Avoids fencepost problem when allocating PnL to periods
|
||||||
|
<
|
||||||
|
commodity 1,000.00 CHF
|
||||||
|
|
||||||
|
P 2024-11-07 A 1 CHF
|
||||||
|
P 2024-11-08 B 1 CHF
|
||||||
|
|
||||||
|
2024-11-07 buying A
|
||||||
|
assets -20 CHF
|
||||||
|
investments:A 20 A @@ 20 CHF
|
||||||
|
|
||||||
|
2024-11-26 buying B
|
||||||
|
assets -20000 CHF
|
||||||
|
investments:B 19986 B @@ 20000 CHF
|
||||||
|
|
||||||
|
P 2024-12-31 B 1.0018 CHF
|
||||||
|
|
||||||
|
$ hledger -f - roi -M -b 2024-10-01 -e 2024-12-31 --value then,CHF --inv '"investments:"' --pnl STR
|
||||||
|
+-------++------------+------------++---------------+---------------+---------------+------------++--------++------------+----------+
|
||||||
|
| || Begin | End || Value (begin) | Cashflow | Value (end) | PnL || IRR || TWR/period | TWR/year |
|
||||||
|
+=======++============+============++===============+===============+===============+============++========++============+==========+
|
||||||
|
| 1 || 2024-10-01 | 2024-10-31 || 0 | 0 | 0 | 0 || 0.00% || 0.00% | 0.00% |
|
||||||
|
| 2 || 2024-11-01 | 2024-11-30 || 0 | 20,020.00 CHF | 20,006.00 CHF | -14.00 CHF || -4.96% || 0.00% | 0.00% |
|
||||||
|
| 3 || 2024-12-01 | 2024-12-31 || 20,006.00 CHF | 0 | 20,041.97 CHF | 35.97 CHF || 2.14% || 0.18% | 2.14% |
|
||||||
|
+-------++------------+------------++---------------+---------------+---------------+------------++--------++------------+----------+
|
||||||
|
| Total || 2024-10-01 | 2024-12-31 || 0 | 20,020.00 CHF | 20,041.97 CHF | 21.97 CHF || 1.12% || 0.18% | 0.72% |
|
||||||
|
+-------++------------+------------++---------------+---------------+---------------+------------++--------++------------+----------+
|
||||||
|
|
||||||
|
>= 0
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user