├── .gitignore ├── LICENSE.md ├── README.md ├── cashflows.py ├── conversion.bean ├── example.bean ├── irr.py ├── nop.bean └── test_cashflows.py /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | test.sh 3 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # The Parity Public License 7.0.0 2 | 3 | Contributor: Justus Pendleton 4 | 5 | Source Code: https://github.com/hoostus/portfolio-returns 6 | 7 | ## Purpose 8 | 9 | This license allows you to use and share this software for free, but you have to share software that builds on it alike. 10 | 11 | ## Agreement 12 | 13 | In order to receive this license, you have to agree to its rules. Those rules are both obligations under that agreement and conditions to your license. Don't do anything with this software that triggers a rule you can't or won't follow. 14 | 15 | ## Notices 16 | 17 | Make sure everyone who gets a copy of any part of this software from you, with or without changes, also gets the text of this license and the contributor and source code lines above. 18 | 19 | ## Copyleft 20 | 21 | [Contribute](#contribute) software you develop, operate, or analyze with this software, including changes or additions to this software. When in doubt, [contribute](#contribute). 22 | 23 | ## Prototypes 24 | 25 | You don't have to [contribute](#contribute) any change, addition, or other software that meets all these criteria: 26 | 27 | 1. You don't use it for more than thirty days. 28 | 29 | 2. You don't share it outside the team developing it, other than for non-production user testing. 30 | 31 | 3. You don't develop, operate, or analyze other software with it for anyone outside the team developing it. 32 | 33 | ## Reverse Engineering 34 | 35 | You may use this software to operate and analyze software you can't [contribute](#contribute) in order to develop alternatives you can and do [contribute](#contribute). 36 | 37 | ## Contribute 38 | 39 | To [contribute](#contribute) software: 40 | 41 | 1. Publish all source code for the software in the preferred form for making changes through a freely accessible distribution system widely used for similar source code so the contributor and others can find and copy it. 42 | 43 | 2. Make sure every part of the source code is available under this license or another license that allows everything this license does, such as [the Blue Oak Model License 1.0.0](https://blueoakcouncil.org/license/1.0.0), [the Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0.html), [the MIT license](https://spdx.org/licenses/MIT.html), or [the two-clause BSD license](https://spdx.org/licenses/BSD-2-Clause.html). 44 | 45 | 3. Take these steps within thirty days. 46 | 47 | 4. Note that this license does _not_ allow you to change the license terms for this software. You must follow [Notices](#notices). 48 | 49 | ## Excuse 50 | 51 | You're excused for unknowingly breaking [Copyleft](#copyleft) if you [contribute](#contribute) as required, or stop doing anything requiring this license, within thirty days of learning you broke the rule. You're excused for unknowingly breaking [Notices](#notices) if you take all practical steps to comply within thirty days of learning you broke the rule. 52 | 53 | ## Defense 54 | 55 | Don't make any legal claim against anyone accusing this software, with or without changes, alone or with other technology, of infringing any patent. 56 | 57 | ## Copyright 58 | 59 | The contributor licenses you to do everything with this software that would otherwise infringe their copyright in it. 60 | 61 | ## Patent 62 | 63 | The contributor licenses you to do everything with this software that would otherwise infringe any patents they can license or become able to license. 64 | 65 | ## Reliability 66 | 67 | The contributor can't revoke this license. 68 | 69 | ## No Liability 70 | 71 | ***As far as the law allows, this software comes as is, without any warranty or condition, and the contributor won't be liable to anyone for any damages related to this software or this license, under any kind of legal claim.*** 72 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Beancount Returns Calculator 2 | ============================ 3 | This will calculator money-weighted and time-weighted returns 4 | for a portfolio for [beancount](http://furius.ca/beancount/), the 5 | double-entry plaintext accounting software. 6 | 7 | Table of Contents 8 | ================= 9 | 10 | * [Beancount Returns Calculator](#beancount-returns-calculator) 11 | * [Table of Contents](#table-of-contents) 12 | * [Quick Usage](#quick-usage) 13 | * [Dependencies](#dependencies) 14 | * [Introduction](#introduction) 15 | * [Time-weighted Returns](#time-weighted-returns) 16 | * [Money-weighted Returns](#money-weighted-returns) 17 | * [Illustrated Example of the Difference](#illustrated-example-of-the-difference) 18 | * [External vs. internal cashflows](#external-vs-internal-cashflows) 19 | * [Note on capital gains](#note-on-capital-gains) 20 | * [Multi-currency issues](#multi-currency-issues) 21 | * [Parameters in more detail](#parameters-in-more-detail) 22 | * [TODOs & Bugs](#todos--bugs) 23 | 24 | Quick Usage 25 | =========== 26 | ```sh 27 | python returns.py 28 | --account Assets:US:Vanguard 29 | --account Assets:US:Fidelity 30 | --internal Income:CapitalGainsDistribution 31 | --internal Income:Dividends 32 | --year 2018 33 | portfolio.bean 34 | ``` 35 | 36 | Dependencies 37 | ============ 38 | * [dateutil](https://dateutil.readthedocs.io/en/stable/) - used for relative date processing 39 | * [scipy](https://www.scipy.org/) - for Internal Rate of Return (XIRR) calculations 40 | * [beancount](http://furius.ca/beancount/) - obviously :) 41 | 42 | Introduction 43 | ============ 44 | This script will determine the rate of return for a portfolio held without beancount. 45 | You can specify which accounts & which timeframes you are interested in. We calculate 46 | "money-weighted returns", which means taking into account the timing & value of cashflows. 47 | In particular, this means you -- the user -- need to tell us which beancount accounts 48 | are real cashflows in & out of the account. See below for more on this. 49 | 50 | Time-weighted Returns 51 | ===================== 52 | **Warning. Time-weighted returns are not implemented.** 53 | 54 | The time-weighted rate of return is the geometric mean of a series of *equal-length* holding periods. 55 | 56 | Time-weighted rates of return **do not** take into account the impact of cash flows into and out of the portfolio. 57 | 58 | Time-weighted rates of return attempt to remove the impact of cash flows when calculating the return. This makes it ideal for calculating the performance of broad market indices or the impact of a fund manager on the performance of an investment. Time-weighting is important in this context as fund managers do not control the timing of cash flows into and out of their fund – investors control that – so it is not reasonable to include that effect when evaluating the performance of the fund manager. 59 | 60 | To calculate the time-weighted return we calculate the holding period return (HPR) of **each day** during the full time period and then find the geometric mean across all of the HPRs. 61 | 62 | The formula for a single holding period return is: 63 | ```HPR = ((MV1 - MV0 + D1 - CF1)/MV0)``` 64 | 65 | * HPR: Holding Period Return 66 | * MV1: The market value at the end of the period 67 | * MV0: The market value at the start of the period 68 | * D1: The value of any dividends or interest inflows 69 | * CF1: Cash flows (i.e. deposits subtracted out or withdrawals added back in) 70 | 71 | Money-weighted Returns 72 | ======================= 73 | The money-weighted rate of return is the Internal Rate of Return (IRR or, in spreadsheets, XIRR). 74 | 75 | Money-weighted returns take into account the timing & size of cash flows into and out of the portfolio, in addition to the performance of the underlying portfolio itself. Money-weighted returns can change significantly depending on the timing of large cash flows in & out of the portfolio. 76 | 77 | The money-weighted return does not split the time period up into equal-length sub-periods. Instead it searches (via mathematical optimization techniques) for the discount rate that equals the cost of the investment plus all of the cash flows generated. 78 | 79 | For the vast majority of investors a money-weighted rate of return is the most appropriate method of measuring the performance of your portfolio as you control inflows and outflows of the investment portfolio. 80 | 81 | Illustrated Example of the Difference 82 | ===================================== 83 | If you don't buy any new shares, sell any shares, and all dividends are reinvested, then the money-weighted return and the time-weighted return will be the same over a given time period. 84 | 85 | Since most people will be buying or selling shares, in practice they will differ. 86 | 87 | Imagine you invest like: 88 | 1. On January 1st you buy 100 shares of FOO at $100. 89 | 1. On January 2nd you buy 100 more shares of FOO, this time at $500 each for $50,000. 90 | 1. On January 3rd you sell 100 shares of FOO, this time at $50 each for $5,000. 91 | 1. On January 4th, you do nothing. The price of FOO returns to $100. 92 | 93 | The time-weighted return is 0%, since it ignores the impact of cash flows and just sees that the starting value (100 shares @ $100) is exactly the same as the ending value (100 shares @ $100). 94 | 95 | Date|Total Amount|Shares|Share price|Holding Period Return 96 | ----|------------|------|-----------|------------------- 97 | Jan 1 | $10,000 | 100 | $100| n/a 98 | Jan 2 | $100,000 | 200 | $500| (100,000-10,000-50,000)/10,000 = 400% 99 | Jan 3 | $5,000 | 100 | $50| (5,000-100,000+5,000)/100,000 = -90% 100 | Jan 4 | $10,000 | 100 | $100| (10,000-5,000)/5,000 = 100% 101 | 102 | The geometric mean of the Holding Period Returns is 103 | ``` 104 | =((1 + 4.00) * (1 - .90) * (1 + 1.00)) - 1 105 | =0 106 | ``` 107 | 108 | Since you bought some shares for $50,000 and sold them for $5,000 you don't **feel** like the return was 0%, though. 109 | 110 | The money-weighted return for the same investment is -52%. 111 | 112 | External vs. internal cashflows 113 | =============================== 114 | When calculating money-weighted returns we need to distinguish "real", or external, cashflows 115 | from "apparent", or internal, cashflows. 116 | 117 | Imagine that your portfolio pays you a dividend but your account is set to automatically 118 | reinvest dividends. Even though there is an apparent cashflow between accounts nothing has 119 | actually changed; from the rate of return perspective it is as if the money never left 120 | your portfolio. 121 | 122 | So we need to know which accounts to ignore when looking for cashflows. In practice, 123 | this is limited to three kinds of things: 124 | 125 | * Interest that is reinvested 126 | * Dividends that are reinvested 127 | * Capital gains distributions that are reinvested 128 | 129 | Note on capital gains 130 | ===================== 131 | There is a difference between a "capital gains distribution" and a "capital gain". 132 | 133 | A "capital gains distribution" is when the fund family gives you money. This should 134 | be treated as a dividend, as an "internal cashflow". It is generated by the internal 135 | operation of the fund. 136 | 137 | A "capital gain" is what happens when *you* sell a fund. This is an *external* 138 | cashflow. 139 | 140 | Even though they are identical for **tax purposes**, they are different for the 141 | purposes of rate of return calculations. You need to ensure they going to two 142 | separate accounts in beancount. 143 | 144 | Multi-currency issues 145 | ===================== 146 | TBD. I have no idea if this works at all with multiple-currencies.... 147 | 148 | Parameters in more detail 149 | ========================= 150 | * --currency. In order to generate meaningful cashflows we need to convert the securities we hold into a currency. You need to tell the script which currency to use. USD is the default if you don't specify anything. 151 | * --account. Accounts to calculate the rate of return for. This can be specified multiple times. This takes a regular expression to match account names. So "^Assets:US:.\*" would match Assets:US:Schwab and Assets:US:MerrillLynch 152 | * --internal. Accounts to treat as "internal" when deciding whether to ignore cashflows. This is also takes a regular expression and can be specified multiple times. 153 | * --to. The start date to use when calculating returns. If not specified, uses the earliest date found in the beancount file. 154 | * --from. The end date to use when calculating returns. If not specified, uses today. 155 | * --year. A shortcut to easily calculate returns for a single calendar year. 156 | * --ytd. A shortcut to calculate returns for the year-to-date. 157 | * --1year, --2year, --3year, --5year, --10year. A shortcut to calculate returns for various time periods. 158 | * --debug-inflows. List all of the accounts that generated an outflow. Useful for debugging whether you've specified all of the --internal accounts you need to. 159 | * --debug-outflows. List all of the accounts that generated an inflow. Useful for debugging whether you've specified all of the --internal accounts you need to. 160 | * --debug-cashflows. List all of the date & amount of cashflows used for the rate of return calculation. 161 | 162 | TODOs & Bugs 163 | ============ 164 | - [ ] Generate growth of $10,000 chart. 165 | - [ ] Definitely needs more testing. 166 | - [ ] Add way to specify individual commodities and track just those 167 | - [ ] As I write up the documentation, I become less certain about the need 168 | to specify internal accounts, if we just track those cashflows won't it have no 169 | effect on the rate of return? I take out $100 and then put $100 back in the same day? 170 | - [ ] double check whether I'm right about capital gains distributions 171 | -------------------------------------------------------------------------------- /cashflows.py: -------------------------------------------------------------------------------- 1 | """Extract cashflows from transactions. 2 | 3 | """ 4 | import datetime 5 | import functools 6 | import logging 7 | import operator 8 | import re 9 | import dataclasses 10 | 11 | from dateutil.relativedelta import relativedelta 12 | from decimal import Decimal 13 | from typing import List, Optional, Set 14 | 15 | import beancount 16 | from beancount.core.data import Account, Currency, Transaction 17 | 18 | def add_position(p, inventory): 19 | if isinstance(p, beancount.core.data.Posting): 20 | inventory.add_position(p) 21 | elif isinstance(p, beancount.core.data.TxnPosting): 22 | inventory.add_position(p.posting) 23 | else: 24 | raise Exception("Not a Posting or TxnPosting", p) 25 | 26 | def is_interesting_posting(posting, interesting_accounts): 27 | """ Is this posting for an account we care about? """ 28 | for pattern in interesting_accounts: 29 | if re.match(pattern, posting.account): 30 | return True 31 | return False 32 | 33 | def is_internal_account(posting, internal_accounts): 34 | for pattern in internal_accounts: 35 | if re.match(pattern, posting.account): 36 | return True 37 | return False 38 | 39 | def is_interesting_entry(entry, interesting_accounts): 40 | """ Do any of the postings link to any of the accounts we care about? """ 41 | accounts = [p.account for p in entry.postings] 42 | for posting in entry.postings: 43 | if is_interesting_posting(posting, interesting_accounts): 44 | return True 45 | return False 46 | 47 | def iter_interesting_postings(date, entries, interesting_accounts): 48 | for e in entries: 49 | if e.date <= date: 50 | for p in e.postings: 51 | if is_interesting_posting(p, interesting_accounts): 52 | yield p 53 | 54 | def get_inventory_as_of_date(date, entries, interesting_accounts): 55 | inventory = beancount.core.inventory.Inventory() 56 | for p in iter_interesting_postings(date, entries, interesting_accounts): 57 | add_position(p, inventory) 58 | return inventory 59 | 60 | def get_value_as_of(postings, date, currency, price_map, interesting_accounts): 61 | inventory = get_inventory_as_of_date(date, postings, interesting_accounts) 62 | balance = inventory.reduce(beancount.core.convert.convert_position, currency, price_map, date) 63 | amount = balance.get_currency_units(currency) 64 | return amount.number 65 | 66 | @dataclasses.dataclass 67 | class Cashflow: 68 | date: datetime.date 69 | amount: Decimal 70 | inflow_accounts: Set[Account] = dataclasses.field(default_factory=set) 71 | outflow_accounts: Set[Account] = dataclasses.field(default_factory=set) 72 | entry: Optional[Transaction] = None 73 | 74 | def get_cashflows(entries: List[Transaction], interesting_accounts: List[str], internal_accounts: 75 | List[str], date_from: Optional[datetime.date], date_to: datetime.date, 76 | currency: Currency) -> List[Cashflow]: 77 | """Extract a series of cashflows affecting 'interesting_accounts'. 78 | 79 | A cashflow is represented by any transaction involving (1) an account in 'interesting_accounts' 80 | and (2) an account not in 'interesting_accounts' or 'internal_accounts'. Positive cashflows 81 | indicate inflows, and negative cashflows indicate outflows. 82 | 83 | 'interesting_accounts' and 'internal_accounts' are regular expressions that must match at the 84 | beginning of account names. 85 | 86 | Return a list of cashflows that occurred between 'date_from' and 'date_to', inclusive. If 87 | 'interesting_accounts' had a balance at the beginning of 'date_from', the first cashflow will 88 | represent the market value of that balance as an inflow. The cashflows will be denominated in 89 | units of 'currency'. 90 | 91 | """ 92 | price_map = beancount.core.prices.build_price_map(entries) 93 | only_txns = beancount.core.data.filter_txns(entries) 94 | interesting_txns = [txn for txn in only_txns if is_interesting_entry(txn, interesting_accounts)] 95 | # pull it into a list, instead of an iterator, because we're going to reuse it several times 96 | interesting_txns = list(interesting_txns) 97 | 98 | cashflows = [] 99 | 100 | for entry in interesting_txns: 101 | if date_from is not None and not date_from <= entry.date: continue 102 | if not entry.date <= date_to: continue 103 | 104 | cashflow = Decimal(0) 105 | inflow_accounts = set() 106 | outflow_accounts = set() 107 | # Imagine an entry that looks like 108 | # [Posting(account=Assets:Brokerage, amount=100), 109 | # Posting(account=Income:Dividend, amount=-100)] 110 | # We want that to net out to $0 111 | # But an entry like 112 | # [Posting(account=Assets:Brokerage, amount=100), 113 | # Posting(account=Assets:Bank, amount=-100)] 114 | # should net out to $100 115 | # we loop over all postings in the entry. if the posting 116 | # if for an account we care about e.g. Assets:Brokerage then 117 | # we track the cashflow. But we *also* look for "internal" 118 | # cashflows and subtract them out. This will leave a net $0 119 | # if all the cashflows are internal. 120 | 121 | for posting in entry.postings: 122 | converted = beancount.core.convert.convert_amount( 123 | beancount.core.convert.get_weight(posting), currency, price_map, entry.date) 124 | if converted.currency != currency: 125 | logging.error(f'Could not convert posting {converted} from {entry.date} on line {posting.meta["lineno"]} to {currency}. IRR will be wrong.') 126 | continue 127 | value = converted.number 128 | 129 | if is_interesting_posting(posting, interesting_accounts): 130 | cashflow += value 131 | elif is_internal_account(posting, internal_accounts): 132 | cashflow += value 133 | else: 134 | if value > 0: 135 | outflow_accounts.add(posting.account) 136 | else: 137 | inflow_accounts.add(posting.account) 138 | # calculate net cashflow & the date 139 | if cashflow.quantize(Decimal('.01')) != 0: 140 | cashflows.append(Cashflow(date=entry.date, amount=cashflow, 141 | inflow_accounts=inflow_accounts, 142 | outflow_accounts=outflow_accounts, 143 | entry=entry)) 144 | 145 | if date_from is not None: 146 | start_value = get_value_as_of(interesting_txns, date_from + relativedelta(days=-1), 147 | currency, price_map, interesting_accounts) 148 | # if starting balance isn't $0 at starting time period then we need a cashflow 149 | if start_value != 0: 150 | cashflows.insert(0, Cashflow(date=date_from, amount=start_value)) 151 | end_value = get_value_as_of(interesting_txns, date_to, currency, price_map, interesting_accounts) 152 | # if ending balance isn't $0 at end of time period then we need a cashflow 153 | if end_value != 0: 154 | cashflows.append(Cashflow(date=date_to, amount=-end_value)) 155 | 156 | return cashflows 157 | -------------------------------------------------------------------------------- /conversion.bean: -------------------------------------------------------------------------------- 1 | option "operating_currency" "USD" 2 | plugin "beancount.plugins.auto_accounts" 3 | 4 | 2018-01-01 * "Buy" 5 | Assets:Investments 100 HOOLI {1 USD} 6 | Assets:Bank 7 | 8 | 2018-04-01 * "Conversion" 9 | Assets:Investments -100 HOOLI {} 10 | Assets:Investments 50 IOOLI {2 USD} 11 | 12 | 2018-12-31 price HOOLI 1.5 USD 13 | 2018-12-31 price IOOLI 3 USD 14 | 15 | ; Expected output: 50% 16 | ; Actual output: 150% 17 | ; python irr.py --account Assets:Investments conversion.bean --from 2018-01-01 --to 2018-12-31 18 | ; this gives the wrong answer because beancount.core.convert_position() in irr.py 19 | ; doesn't generate a USD value because there's nothing about IOOLI in the pricemap 20 | 2018-04-01 price IOOLI 2 USD 21 | -------------------------------------------------------------------------------- /example.bean: -------------------------------------------------------------------------------- 1 | option "title" "Example Beancount file" 2 | option "operating_currency" "USD" 3 | 4 | 1792-01-01 commodity USD 5 | 2015-12-01 commodity ABC 6 | 7 | 2015-12-01 price ABC 1.00 USD 8 | 2016-12-01 price ABC 2.00 USD 9 | 2017-12-01 price ABC 1.25 USD 10 | 11 | 2015-12-01 open Equity:Opening-Balances 12 | 2015-12-01 open Assets:Brokerage 13 | 2015-12-01 open Assets:Cash 14 | 2015-12-01 open Income:CapitalGains 15 | 16 | 2015-12-01 * "Opening balance" 17 | Assets:Cash 3,000 USD 18 | Equity:Opening-Balances 19 | 20 | 2015-12-01 * "Buy 1,000 shares" 21 | Assets:Brokerage 1,000 ABC {1.00 USD} 22 | Assets:Cash -1,000 USD 23 | 24 | 2016-12-01 * "Buy 1,000 more shares" 25 | Assets:Brokerage 1,000 ABC {2.00 USD} 26 | Assets:Cash -2,000 USD 27 | 28 | 2017-12-01 * "Sell 2,000 shares" 29 | Assets:Brokerage -1,000 ABC {1.00 USD} @ 1.25 USD 30 | Assets:Brokerage -1,000 ABC {2.00 USD} @ 1.25 USD 31 | Assets:Cash 2,500 USD 32 | Income:CapitalGains 500 USD 33 | 34 | ; xirr is -0.129094555 (according to Excel) 35 | ; python irr.py --account Assets:Brokerage example.bean 36 | -------------------------------------------------------------------------------- /irr.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import logging 4 | import sys 5 | import itertools 6 | import functools 7 | import operator 8 | import math 9 | import collections 10 | import datetime 11 | import re 12 | from decimal import Decimal 13 | from dateutil.relativedelta import relativedelta 14 | from pprint import pprint 15 | from scipy import optimize 16 | from typing import Any, Dict, List, NamedTuple, Optional, Set, Union 17 | import beancount.loader 18 | import beancount.utils 19 | import beancount.core 20 | import beancount.core.getters 21 | import beancount.core.data 22 | import beancount.core.convert 23 | import beancount.parser 24 | from pprint import pprint 25 | from cashflows import get_cashflows 26 | 27 | # https://github.com/peliot/XIRR-and-XNPV/blob/master/financial.py 28 | 29 | def xnpv(rate,cashflows): 30 | """ 31 | Calculate the net present value of a series of cashflows at irregular intervals. 32 | Arguments 33 | --------- 34 | * rate: the discount rate to be applied to the cash flows 35 | * cashflows: a list object in which each element is a tuple of the form (date, amount), where date is a python datetime.date object and amount is an integer or floating point number. Cash outflows (investments) are represented with negative amounts, and cash inflows (returns) are positive amounts. 36 | 37 | Returns 38 | ------- 39 | * returns a single value which is the NPV of the given cash flows. 40 | Notes 41 | --------------- 42 | * The Net Present Value is the sum of each of cash flows discounted back to the date of the first cash flow. The discounted value of a given cash flow is A/(1+r)**(t-t0), where A is the amount, r is the discout rate, and (t-t0) is the time in years from the date of the first cash flow in the series (t0) to the date of the cash flow being added to the sum (t). 43 | * This function is equivalent to the Microsoft Excel function of the same name. 44 | """ 45 | 46 | chron_order = sorted(cashflows, key = lambda x: x[0]) 47 | t0 = chron_order[0][0] #t0 is the date of the first cash flow 48 | 49 | return sum([cf/(1+rate)**((t-t0).days/365.0) for (t,cf) in chron_order]) 50 | 51 | def xirr(cashflows,guess=0.1): 52 | """ 53 | Calculate the Internal Rate of Return of a series of cashflows at irregular intervals. 54 | Arguments 55 | --------- 56 | * cashflows: a list object in which each element is a tuple of the form (date, amount), where date is a python datetime.date object and amount is an integer or floating point number. Cash outflows (investments) are represented with negative amounts, and cash inflows (returns) are positive amounts. 57 | * guess (optional, default = 0.1): a guess at the solution to be used as a starting point for the numerical solution. 58 | Returns 59 | -------- 60 | * Returns the IRR as a single value 61 | 62 | Notes 63 | ---------------- 64 | * The Internal Rate of Return (IRR) is the discount rate at which the Net Present Value (NPV) of a series of cash flows is equal to zero. The NPV of the series of cash flows is determined using the xnpv function in this module. The discount rate at which NPV equals zero is found using the secant method of numerical solution. 65 | * This function is equivalent to the Microsoft Excel function of the same name. 66 | * For users that do not have the scipy module installed, there is an alternate version (commented out) that uses the secant_method function defined in the module rather than the scipy.optimize module's numerical solver. Both use the same method of calculation so there should be no difference in performance, but the secant_method function does not fail gracefully in cases where there is no solution, so the scipy.optimize.newton version is preferred. 67 | """ 68 | return optimize.newton(lambda r: xnpv(r,cashflows),guess) 69 | 70 | def fmt_d(n): 71 | return '${:,.0f}'.format(n) 72 | 73 | def fmt_pct(n): 74 | return '{0:.2f}%'.format(n*100) 75 | 76 | if __name__ == '__main__': 77 | logging.basicConfig(format='%(levelname)s: %(message)s') 78 | import argparse 79 | parser = argparse.ArgumentParser( 80 | description="Calculate return data." 81 | ) 82 | parser.add_argument('bean', help='Path to the beancount file.') 83 | parser.add_argument('--currency', default='USD', help='Currency to use for calculating returns.') 84 | parser.add_argument('--account', action='append', default=[], help='Regex pattern of accounts to include when calculating returns. Can be specified multiple times.') 85 | parser.add_argument('--internal', action='append', default=[], help='Regex pattern of accounts that represent internal cashflows (i.e. dividends or interest)') 86 | 87 | parser.add_argument('--from', dest='date_from', type=lambda d: datetime.datetime.strptime(d, '%Y-%m-%d').date(), help='Start date: YYYY-MM-DD, 2016-12-31') 88 | parser.add_argument('--to', dest='date_to', type=lambda d: datetime.datetime.strptime(d, '%Y-%m-%d').date(), help='End date YYYY-MM-DD, 2016-12-31') 89 | 90 | date_range = parser.add_mutually_exclusive_group() 91 | date_range.add_argument('--year', default=False, type=int, help='Year. Shorthand for --from/--to.') 92 | date_range.add_argument('--ytd', action='store_true') 93 | date_range.add_argument('--1year', action='store_true') 94 | date_range.add_argument('--2year', action='store_true') 95 | date_range.add_argument('--3year', action='store_true') 96 | date_range.add_argument('--5year', action='store_true') 97 | date_range.add_argument('--10year', action='store_true') 98 | 99 | parser.add_argument('--debug-inflows', action='store_true', help='Print list of all inflow accounts in transactions.') 100 | parser.add_argument('--debug-outflows', action='store_true', help='Print list of all outflow accounts in transactions.') 101 | parser.add_argument('--debug-cashflows', action='store_true', help='Print list of all cashflows used for the IRR calculation.') 102 | 103 | args = parser.parse_args() 104 | 105 | shortcuts = ['year', 'ytd', '1year', '2year', '3year', '5year', '10year'] 106 | shortcut_used = functools.reduce(operator.__or__, [getattr(args, x) for x in shortcuts]) 107 | if shortcut_used and (args.date_from or args.date_to): 108 | raise(parser.error('Date shortcut options mutually exclusive with --to/--from options')) 109 | 110 | if args.year: 111 | args.date_from = datetime.date(args.year, 1, 1) 112 | args.date_to = datetime.date(args.year, 12, 31) 113 | 114 | if args.ytd: 115 | today = datetime.date.today() 116 | args.date_from = datetime.date(today.year, 1, 1) 117 | args.date_to = today 118 | 119 | if getattr(args, '1year'): 120 | today = datetime.date.today() 121 | args.date_from = today + relativedelta(years=-1) 122 | args.date_to = today 123 | 124 | if getattr(args, '2year'): 125 | today = datetime.date.today() 126 | args.date_from = today + relativedelta(years=-2) 127 | args.date_to = today 128 | 129 | if getattr(args, '3year'): 130 | today = datetime.date.today() 131 | args.date_from = today + relativedelta(years=-3) 132 | args.date_to = today 133 | 134 | if getattr(args, '5year'): 135 | today = datetime.date.today() 136 | args.date_from = today + relativedelta(years=-5) 137 | args.date_to = today 138 | 139 | if getattr(args, '10year'): 140 | today = datetime.date.today() 141 | args.date_from = today + relativedelta(years=-10) 142 | args.date_to = today 143 | 144 | entries, errors, options = beancount.loader.load_file(args.bean, logging.info, log_errors=sys.stderr) 145 | 146 | if not args.date_to: 147 | args.date_to = datetime.date.today() 148 | 149 | cashflows = get_cashflows( 150 | entries=entries, interesting_accounts=args.account, internal_accounts=args.internal, 151 | date_from=args.date_from, date_to=args.date_to, currency=args.currency) 152 | 153 | if cashflows: 154 | # we need to coerce everything to a float for xirr to work... 155 | r = xirr([(f.date, float(f.amount)) for f in cashflows]) 156 | print(fmt_pct(r)) 157 | else: 158 | logging.error(f'No cashflows found during the time period {args.date_from} -> {args.date_to}') 159 | 160 | if args.debug_cashflows: 161 | pprint([(f.date, f.amount) for f in cashflows]) 162 | if args.debug_inflows: 163 | print('>> [inflows]') 164 | pprint(set().union(*[f.inflow_accounts for f in cashflows])) 165 | if args.debug_outflows: 166 | print('<< [outflows]') 167 | pprint(set().union(*[f.outflow_accounts for f in cashflows])) 168 | -------------------------------------------------------------------------------- /nop.bean: -------------------------------------------------------------------------------- 1 | option "operating_currency" "USD" 2 | plugin "beancount.plugins.auto_accounts" 3 | plugin "beancount.plugins.implicit_prices" 4 | 5 | 2018-01-01 * "Buy" 6 | Assets:Investments 100 HOOLI {2 USD} 7 | Assets:Bank 8 | 9 | ; python irr.py --account Assets:Investments nop.bean --from 2018-01-01 --to 2018-12-31 --debug-cashflows 10 | ; currently fails with a stack trace 11 | -------------------------------------------------------------------------------- /test_cashflows.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | import datetime 3 | from decimal import Decimal 4 | from typing import List 5 | 6 | from beancount import loader 7 | from cashflows import Cashflow, get_cashflows 8 | 9 | 10 | def simplify_cashflows(cashflows: List[Cashflow]) -> List[Cashflow]: 11 | """For ease of comparison, strip the context from each cashflow in 'cashflows'. 12 | 13 | """ 14 | return [Cashflow(date=f.date, amount=f.amount, inflow_accounts=f.inflow_accounts, 15 | outflow_accounts=f.outflow_accounts) for f in cashflows] 16 | 17 | 18 | class TestCashflows(unittest.TestCase): 19 | 20 | @loader.load_doc() 21 | def test_simple(self, entries, errors, options_map): 22 | """ 23 | 1792-01-01 commodity USD 24 | 2015-12-01 commodity ABC 25 | 26 | 2015-12-01 open Equity:Opening-Balances 27 | 2015-12-01 open Assets:Brokerage 28 | 2015-12-01 open Assets:Cash 29 | 2015-12-01 open Income:CapitalGains 30 | 31 | 2015-12-01 * "Opening balance" 32 | Assets:Cash 3,000 USD 33 | Equity:Opening-Balances 34 | 35 | 2015-12-01 price ABC 1.00 USD 36 | 37 | 2015-12-01 * "Buy 1,000 shares" 38 | Assets:Brokerage 1,000 ABC {1.00 USD} 39 | Assets:Cash -1,000 USD 40 | 41 | 2016-12-01 price ABC 2.00 USD 42 | 43 | 2016-12-01 * "Buy 1,000 more shares" 44 | Assets:Brokerage 1,000 ABC {2.00 USD} 45 | Assets:Cash -2,000 USD 46 | 47 | 2017-12-01 price ABC 1.50 USD 48 | 49 | 2017-12-01 * "Sell 2,000 shares" 50 | Assets:Brokerage -1,000 ABC {1.00 USD} 51 | Assets:Brokerage -1,000 ABC {2.00 USD} 52 | Assets:Cash 2,500 USD 53 | Income:CapitalGains 500 USD 54 | """ 55 | expected_cashflows = [ 56 | Cashflow( 57 | date=datetime.date(2015, 12, 1), 58 | amount=Decimal(1000), 59 | inflow_accounts=set(['Assets:Cash']), 60 | ), 61 | Cashflow( 62 | date=datetime.date(2016, 12, 1), 63 | amount=Decimal(2000), 64 | inflow_accounts=set(['Assets:Cash']), 65 | ), 66 | Cashflow( 67 | date=datetime.date(2017, 12, 1), 68 | amount=Decimal(-2500), 69 | outflow_accounts=set(['Assets:Cash']), 70 | ), 71 | ] 72 | actual_cashflows = get_cashflows( 73 | entries=entries, interesting_accounts=['Assets:Brokerage'], 74 | internal_accounts=['Income:CapitalGains'], date_from=datetime.date(2015, 12, 1), 75 | date_to=datetime.date(2017, 12, 1), currency='USD') 76 | self.assertEqual(expected_cashflows, simplify_cashflows(actual_cashflows)) 77 | 78 | # Test 'date_from=None', which should be equivalent. 79 | actual_cashflows = get_cashflows( 80 | entries=entries, interesting_accounts=['Assets:Brokerage'], 81 | internal_accounts=['Income:CapitalGains'], date_from=None, 82 | date_to=datetime.date(2017, 12, 1), currency='USD') 83 | self.assertEqual(expected_cashflows, simplify_cashflows(actual_cashflows)) 84 | 85 | @loader.load_doc() 86 | def test_stock_conversion(self, entries, errors, options_map): 87 | """ 88 | 2018-01-01 commodity USD 89 | 2018-01-01 commodity HOOLI 90 | 91 | 2018-01-01 open Assets:Brokerage 92 | 2018-01-01 open Assets:Cash 93 | 94 | 2018-01-01 * "Buy" 95 | Assets:Brokerage 100 HOOLI {1 USD} 96 | Assets:Cash 97 | 98 | 2018-04-01 commodity IOOLI 99 | 100 | 2018-04-01 * "Conversion" 101 | Assets:Brokerage -100 HOOLI {} 102 | Assets:Brokerage 50 IOOLI {2 USD} 103 | 104 | 2018-12-31 price HOOLI 1.5 USD 105 | 2018-12-31 price IOOLI 3 USD 106 | """ 107 | expected_cashflows = [ 108 | Cashflow( 109 | date=datetime.date(2018, 1, 1), 110 | amount=Decimal(100), 111 | inflow_accounts=set(['Assets:Cash']), 112 | ), 113 | Cashflow( 114 | date=datetime.date(2018, 12, 31), 115 | amount=Decimal(-150), 116 | ), 117 | ] 118 | actual_cashflows = get_cashflows( 119 | entries=entries, interesting_accounts=['Assets:Brokerage'], internal_accounts=[], 120 | date_from=datetime.date(2018, 1, 1), date_to=datetime.date(2018, 12, 31), 121 | currency='USD') 122 | self.assertEqual(expected_cashflows, simplify_cashflows(actual_cashflows)) 123 | 124 | @loader.load_doc() 125 | def test_multi_currency(self, entries, errors, options_map): 126 | """ 127 | 1792-01-01 commodity USD 128 | 1999-01-01 commodity EUR 129 | 2015-12-01 commodity ABC 130 | 131 | 2015-12-01 open Equity:Opening-Balances 132 | 2015-12-01 open Assets:Brokerage 133 | 2015-12-01 open Assets:Cash 134 | 2015-12-01 open Income:CapitalGains 135 | 2015-12-01 open Income:Dividends 136 | 137 | 2015-12-01 * "Opening balance" 138 | Assets:Cash 3,000 USD 139 | Equity:Opening-Balances 140 | 141 | 2015-12-01 price EUR 2 USD 142 | 143 | 2015-12-01 * "Buy in EUR" 144 | Assets:Brokerage 500 ABC {1.00 EUR} 145 | Assets:Cash -1,000 USD @ 0.5 EUR 146 | 147 | 2016-06-01 * "Receive dividend in EUR" 148 | Assets:Brokerage 50 EUR 149 | Income:Dividends -100 USD @ 0.5 EUR 150 | 151 | 2016-12-01 price EUR 1 USD 152 | 153 | 2016-12-01 * "Buy more in EUR" 154 | Assets:Brokerage 1,000 ABC {2.00 EUR} 155 | Assets:Cash -2,000 USD @ 1 EUR 156 | 157 | 2018-12-01 * "Sell and withdraw all holdings" 158 | Assets:Brokerage -500 ABC {1.00 EUR} 159 | Assets:Brokerage -1,000 ABC {2.00 EUR} 160 | Assets:Brokerage -50 EUR 161 | Income:CapitalGains -1,000 USD @ 1 EUR 162 | Assets:Cash 3,550 USD @ 1 EUR 163 | """ 164 | expected_cashflows = [ 165 | Cashflow( 166 | date=datetime.date(2015, 12, 1), 167 | amount=Decimal('1000.00'), 168 | inflow_accounts=set(['Assets:Cash']), 169 | ), 170 | Cashflow( 171 | date=datetime.date(2016, 12, 1), 172 | amount=Decimal('2000.00'), 173 | inflow_accounts=set(['Assets:Cash']), 174 | ), 175 | Cashflow( 176 | date=datetime.date(2018, 12, 1), 177 | amount=Decimal('-3550.00'), 178 | outflow_accounts=set(['Assets:Cash']), 179 | ), 180 | ] 181 | actual_cashflows = get_cashflows( 182 | entries=entries, interesting_accounts=['Assets:Brokerage'], 183 | internal_accounts=['Income:CapitalGains', 'Income:Dividends'], 184 | date_from=datetime.date(2015, 12, 1), date_to=datetime.date(2018, 12, 1), 185 | currency='USD') 186 | self.maxDiff = None 187 | self.assertEqual(expected_cashflows, simplify_cashflows(actual_cashflows)) 188 | --------------------------------------------------------------------------------