├── .gitignore ├── .pylintrc ├── example ├── PortfolioSummary.png └── example.beancount ├── LICENSE ├── setup.py ├── README.md ├── templates └── PortfolioSummary.html ├── __init__.py └── irr.py /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | *.pyc 3 | *.sw[op] 4 | -------------------------------------------------------------------------------- /.pylintrc: -------------------------------------------------------------------------------- 1 | [FORMAT] 2 | max-line-length=120 3 | max-locals=25 4 | max-args=10 5 | max-branches=15 6 | -------------------------------------------------------------------------------- /example/PortfolioSummary.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PhracturedBlue/fava-portfolio-summary/HEAD/example/PortfolioSummary.png -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015-2016 Dominik Aumayr 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from os import path 2 | from setuptools import find_packages, setup 3 | 4 | with open(path.join(path.dirname(__file__), 'README.md')) as readme: 5 | LONG_DESCRIPTION = readme.read() 6 | 7 | setup( 8 | name='fava_portfolio_summary', 9 | use_scm_version=True, 10 | setup_requires=['setuptools_scm'], 11 | description='Fava extension to display portfolio summaries including MWRR and TWRR', 12 | long_description=LONG_DESCRIPTION, 13 | long_description_content_type='text/markdown', 14 | url='https://github.com/PhracturedBlue/fava-portfolio-summary', 15 | author='PhracturedBlue', 16 | author_email='rc2012@pblue.org', 17 | license='MIT', 18 | keywords='fava beancount accounting investment mwrr mwr twrr twr irr', 19 | packages=['fava_portfolio_summary'], 20 | package_dir={'fava_portfolio_summary': '.'}, 21 | package_data={'': ['templates/PortfolioSummary.html']}, 22 | include_package_data=True, 23 | install_requires=[ 24 | 'beancount>=2.3.4', 25 | 'fava>=1.20', 26 | ], 27 | zip_safe=False, 28 | classifiers=[ 29 | 'Development Status :: 3 - Alpha', 30 | 'Intended Audience :: Financial and Insurance Industry', 31 | 'License :: OSI Approved :: MIT License', 32 | 'Natural Language :: English', 33 | 'Programming Language :: Python :: 3 :: Only', 34 | 'Programming Language :: Python :: 3.7', 35 | 'Topic :: Internet :: WWW/HTTP :: Dynamic Content', 36 | 'Topic :: Office/Business :: Financial :: Accounting', 37 | 'Topic :: Office/Business :: Financial :: Investment', 38 | ], 39 | ) 40 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Fava Portfolio Summary 2 | This is a Fava extension to display a grouped portfolio view in Fava for a set of Beancount accounts. 3 | 4 | It can also calculate MWRR (Money-Weighted Rate of Return) or TWRR (Time-Weighted Rate of Return) 5 | 6 | The display is similar to the Balance Sheet in Fava, however it allows grouping accounts, and calculating 7 | partial balances. 8 | 9 | The MWRR (and especially TWRR) calculations can be very slow to calculate. By default, MWRR is enabled, and TWRR is disabled 10 | 11 | ![Screenshot](example/PortfolioSummary.png) 12 | 13 | ## Installation 14 | Assuming Fava is already installed, use: 15 | ``` 16 | pip install git+https://github.com/PhracturedBlue/fava-portfolio-summary 17 | ``` 18 | 19 | ## Configuration 20 | In the beancount file, configure via: 21 | ``` 22 | 2000-01-01 custom "fava-extension" "fava_portfolio_summary" "{ 23 | 'metadata-key': 'portfolio', 24 | 'account-groups': ( 25 | { 'name': 'cash', 'cols': ['balance', 'allocation'] }, 26 | { 'name': 'investment', 'mwr': False }, 27 | 'retirement-pretax', 28 | 'retirement-roth'), 29 | 'internal': ( 30 | '.*:PnL', 31 | 'Income:Investments:Dividends', 32 | 'Income:Bank:Interest'), 33 | 'mwr': 'children', 34 | 'twr': False, 35 | }" 36 | ``` 37 | * `metadata-key`: Name of key used to group accounts 38 | * `account-groups`: Either a string or a dictionary with the `name` key identifying the name of the group 39 | * If specified as a dictionary, the `internal`, `mwr`, `twr`, and 'cols' keys can be specied on a per-group basis 40 | * `internal` (optional): List of regex patterns denoting 'internal' accounts that should be ignored for cash-flow purposes 41 | during MWRR/TWRR calculation. More information about selecting internal accounts can be found 42 | [here](https://github.com/hoostus/portfolio-returns#external-vs-internal-cashflows) 43 | * `mwr` (optional): Enable MWRR calculation for all accounts (can be overridden at the group level). 44 | Possible values: (True, False, 'children') Defaults to *True* 45 | * `twr` (optional): Enable TWRR calculation for all accounts (can be overridden at the group level). 46 | Possible values: (True, False, 'children') Defaults to *False* 47 | * `cols` (optional): Ordered list of columns to display. Available columns: 48 | `units`, `cost`, `balance`, `pnl`, `dividends`, `change`, `mwr`, `twr`, `allocation` 49 | 50 | Additionally each top-level account (that is to be displayed) needs to be marked with the appropriate group: 51 | ``` 52 | 2000-01-01 open Assets:Investments:Fidelity401k:PreTax 53 | portfolio: "retirement-pretax" 54 | ``` 55 | For each top level account, all transactions of the account and any child accounts will be considered (I.e. for the example above, 56 | `Assets:Investments:Fidelity401k:PreTax` and any accouunt matching `Assets:Investments:Fidelity401k:PreTax:.*` will be summarized) 57 | 58 | ## Related Projects 59 | * https://github.com/hoostus/portfolio-returns 60 | * https://github.com/seltzered/fava-classy-portfolio 61 | -------------------------------------------------------------------------------- /templates/PortfolioSummary.html: -------------------------------------------------------------------------------- 1 | {% import 'macros/_commodity_macros.html' as commodity_macros %} 2 | 3 | {% macro account_name(ledger, account_name, last_segment=False) -%} 4 | 5 | {{- account_name.split(':')[-1] if last_segment else account_name -}} 6 | 7 | {%- if ledger.accounts[account_name].uptodate_status %} 8 | 9 | {{ indicator(ledger, account_name) }} 10 | {{ last_account_activity(ledger, account_name) }} 11 | {% endif %} 12 | {% endmacro %} 13 | 14 | 28 | 29 |

Portfolio Summary Report

30 |
31 | {% for title, data in extension.portfolio_accounts() %} 32 | {% set types = data[0] %} 33 | {% set rows = data[1] %} 34 |

{{title}}

35 | 36 |
    37 |
  1. 38 |

    39 | 40 | {% for name, type in types %} 41 | {% if name != "account" %} 42 | {{ name }} 43 | {% endif %} 44 | {% endfor %} 45 |

    46 |
  2. 47 | {% for row in rows recursive %} 48 | 49 |

    50 | 53 | {% for name, type in types %} 54 | {% if name != "account" %} 55 | {% if name != "PnL" and name != "balance" %} 56 | {{ '' if not row[name] else ('{}%' if type == 'Percent' else '{:,}').format(row[name]) }} 57 | {% elif name == "balance" %} 58 | {{ '' if not row[name] else ('{}%' if type == 'Percent' else '{:,}').format(row[name]) }}{% if row['last-date'] != None %}
    ({{row['last-date']}}){% endif %}
    59 | {% elif name == "PnL" and row[name] >=0 %} {{ '' if not row[name] else ('{}%' if type == 'Percent' else '{:,}').format(row[name]) }} 60 | {% elif name == "PnL" and row[name] <=0 %} {{ '' if not row[name] else ('{}%' if type == 'Percent' else '{:,}').format(row[name]) }} 61 | {% endif %} 62 | {% endif %} 63 | {% endfor %} 64 |

    65 | {% if row.children %} 66 |
      {{ loop(row.children) }}
    67 | {% endif %} 68 | 69 | {% endfor %} 70 |
71 |
72 | {% endfor %} 73 | -------------------------------------------------------------------------------- /example/example.beancount: -------------------------------------------------------------------------------- 1 | option "title" "Example Beancount file" 2 | option "operating_currency" "USD" 3 | 4 | 2010-01-01 custom "fava-extension" "portfolio_summary" "{ 5 | 'metadata-key': 'portfolio', 6 | 'account-groups': ( 7 | { 'name': 'cash', 'cols': ['balance', 'allocation']}, 8 | 'pretax', 9 | 'roth'), 10 | 'internal': ('.*:PnL',), 11 | 'twr': True, 12 | 'mwr': 'children', 13 | }" 14 | 15 | 1792-01-01 commodity USD 16 | 2015-12-01 commodity ABC 17 | 2015-12-01 commodity XYZ 18 | 19 | 2015-12-01 price ABC 10.00 USD 20 | 2016-01-01 price ABC 11.00 USD 21 | 2016-06-01 price ABC 9.00 USD 22 | 2016-12-01 price ABC 12.00 USD 23 | 2017-06-01 price ABC 15.00 USD 24 | 2017-12-01 price ABC 22.00 USD 25 | 2017-12-31 price ABC 20.00 USD 26 | 27 | 2015-12-01 price XYZ 100.00 USD 28 | 2016-01-01 price XYZ 90.00 USD 29 | 2016-06-01 price XYZ 80.00 USD 30 | 2016-12-01 price XYZ 70.00 USD 31 | 2017-06-01 price XYZ 100.00 USD 32 | 2017-12-01 price XYZ 110.00 USD 33 | 2017-12-31 price XYZ 115.00 USD 34 | 35 | 2015-12-01 open Income:PnL 36 | 2015-12-01 open Equity:Opening-Balances 37 | 38 | 2015-12-01 open Assets:Cash 39 | portfolio: "cash" 40 | 2015-12-01 open Assets:Brokerage1:PreTax 41 | portfolio: "pretax" 42 | 2015-12-01 open Assets:Brokerage1:PreTax:ABC 43 | 2015-12-01 open Assets:Brokerage1:PreTax:XYZ 44 | 45 | 2015-12-01 open Assets:Brokerage1:Roth 46 | portfolio: "roth" 47 | 2015-12-01 open Assets:Brokerage1:Roth:ABC 48 | 2015-12-01 open Assets:Brokerage1:Roth:XYZ 49 | 50 | 2015-12-01 open Assets:Brokerage2:PreTax 51 | portfolio: "pretax" 52 | 2015-12-01 open Assets:Brokerage2:Roth 53 | portfolio: "roth" 54 | 55 | 2015-12-01 * "Opening balance" 56 | Assets:Cash 1,000,000 USD 57 | Equity:Opening-Balances 58 | 59 | 2015-12-01 * "Opening balance" 60 | Assets:Brokerage1:Roth:XYZ 1000 XYZ {10 USD} 61 | Equity:Opening-Balances 62 | 63 | 2015-12-01 * "Opening balance" 64 | Assets:Brokerage1:PreTax:ABC 1000 ABC { 10 USD} 65 | Equity:Opening-Balances 66 | 67 | 2015-12-01 * "Opening balance" 68 | Assets:Brokerage2:PreTax 1000 ABC { 5 USD } 69 | Equity:Opening-Balances 70 | 71 | 2015-12-01 * "Opening balance" 72 | Assets:Brokerage2:Roth 1000 XYZ { 50 USD } 73 | Equity:Opening-Balances 74 | 75 | ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; 76 | 77 | 2016-01-01 * "Buy 1000 shares" 78 | Assets:Brokerage1:Roth:ABC 1000 ABC {11 USD} 79 | Assets:Cash 80 | 81 | 2016-06-01 * "Buy 1000 shares" 82 | Assets:Brokerage1:PreTax:XYZ 1000 XYZ {80 USD} 83 | Assets:Cash 84 | 85 | 2016-12-01 * "Buy 500 shares" 86 | Assets:Brokerage2:PreTax 500 ABC {12 USD} 87 | Assets:Cash 88 | 89 | 2017-06-01 * "Buy 200 shares" 90 | Assets:Brokerage2:PreTax 200 ABC {15 USD} 91 | Assets:Cash 92 | 93 | 2017-12-01 * "Sell 5000 shares, Buy 100 Shares" 94 | Assets:Brokerage2:Roth -500 XYZ {50 USD} @ 110 USD 95 | Assets:Brokerage2:Roth 2500 ABC {22 USD} 96 | Income:PnL -30,000 USD 97 | 98 | ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; 99 | 2018-01-01 * "Closeout account" 100 | Assets:Brokerage1:PreTax:ABC -1000 ABC {10 USD} @ 20 USD 101 | Assets:Brokerage1:PreTax:XYZ -1000 XYZ {80 USD} @ 115 USD 102 | Income:PnL -45,000 USD 103 | Assets:Cash 104 | 105 | 2018-01-01 * "Closeout account" 106 | Assets:Brokerage1:Roth:ABC -1000 ABC {11 USD} @ 20 USD 107 | Assets:Brokerage1:Roth:XYZ -1000 XYZ {10 USD} @ 115 USD 108 | Income:PnL -114,000 USD 109 | Assets:Cash 110 | 111 | 2018-01-01 * "Closeout account" 112 | Assets:Brokerage2:PreTax -1000 ABC {5 USD} @ 20 USD 113 | Assets:Brokerage2:PreTax -500 ABC {12 USD} @ 20 USD 114 | Assets:Brokerage2:PreTax -200 ABC {15 USD} @ 20 USD 115 | Income:PnL -114,000 USD 116 | Assets:Cash 117 | 118 | 2018-01-01 * "Closeout account" 119 | Assets:Brokerage2:Roth -2500 ABC {22 USD} @ 20 USD 120 | Assets:Brokerage2:Roth -500 XYZ {50 USD} @ 115 USD 121 | Income:PnL -27,000 USD 122 | Assets:Cash 123 | -------------------------------------------------------------------------------- /__init__.py: -------------------------------------------------------------------------------- 1 | """PortfolioSummary extension for Fava 2 | Report out summary information for groups of portfolios 3 | Similar extensions: 4 | https://github.com/scauligi/refried 5 | https://github.com/seltzered/fava-classy-portfolio 6 | https://github.com/redstreet/fava_investor 7 | https://github.com/redstreet/fava_tax_loss_harvester 8 | 9 | IRR calculation copied from: 10 | https://github.com/hoostus/portfolio-returns 11 | 12 | This is a simple example of Fava's extension reports system. 13 | """ 14 | from datetime import datetime, timedelta 15 | import json 16 | 17 | import re 18 | import time 19 | from collections.abc import Iterable 20 | from xmlrpc.client import DateTime 21 | 22 | from beancount.core.number import Decimal 23 | from beancount.core.number import ZERO 24 | from beancount.core.data import Transaction 25 | 26 | from fava.ext import FavaExtensionBase 27 | from fava.helpers import FavaAPIError 28 | from fava.core.conversion import cost_or_value 29 | from fava.core.query_shell import QueryShell 30 | from fava.context import g 31 | from .irr import IRR 32 | 33 | 34 | class PortfolioSummary(FavaExtensionBase): # pragma: no cover 35 | """Report out summary information for groups of portfolios""" 36 | 37 | report_title = "Portfolio Summary" 38 | 39 | def __init__(self, *args, **kwargs): 40 | super().__init__(*args, **kwargs) 41 | self.accounts = None 42 | self.irr_cache = {} 43 | self.dividend_cache = {} 44 | 45 | def portfolio_accounts(self): 46 | """An account tree based on matching regex patterns.""" 47 | if self.ledger.accounts is not self.accounts: 48 | # self.ledger.accounts should be reset every time the databse is loaded 49 | self.dividend_cache = {} 50 | self.irr_cache = {} 51 | self.accounts = self.ledger.accounts 52 | portfolio_summary = PortfolioSummaryInstance(self.ledger, self.config, self.irr_cache, self.dividend_cache) 53 | return portfolio_summary.run() 54 | 55 | class PortfolioSummaryInstance: # pragma: no cover 56 | """Thread-safe instance of Portfolio Summary""" 57 | # pylint: disable=too-many-instance-attributes 58 | 59 | def __init__(self, ledger, config, irr_cache, dividend_cache): 60 | self.ledger = ledger 61 | self.config = config 62 | self.irr_cache = irr_cache 63 | self.dividend_cache = dividend_cache 64 | self.operating_currency = self.ledger.options["operating_currency"][0] 65 | self.irr = IRR(self.ledger.all_entries, g.ledger.prices, self.operating_currency, errors=self.ledger.errors) 66 | self.all_mwr_accounts = set() 67 | self.dividends_elapsed = 0 68 | self.total = { 69 | 'account': 'Total', 70 | 'balance': ZERO, 71 | 'cost': ZERO, 72 | 'pnl':ZERO, 73 | 'dividends':ZERO, 74 | 'allocation': 100, 75 | 'mwr': ZERO, 76 | 'twr': ZERO, 77 | 'children': [], 78 | 'last-date':None 79 | } 80 | self.all_cols = ["units", "cost", "balance", "pnl", "dividends", "change", "mwr", "twr", "allocation"] 81 | 82 | def run(self): 83 | """Calculdate summary""" 84 | all_mwr_internal = set() 85 | tree = g.filtered.root_tree 86 | portfolios = [] 87 | _t0 = time.time() 88 | seen_cols = {} # Use a dict instead of a set to preserve order 89 | for res in self.parse_config(): 90 | if len(res) == 1: 91 | cols = [_ for _ in res[0] if _ in seen_cols] 92 | break 93 | key, pattern, internal, cols, mwr, twr = res 94 | seen_cols.update({_: None for _ in cols}) 95 | try: 96 | title, portfolio = self._account_metadata_pattern( 97 | tree, key, pattern, internal, mwr, twr, "dividends" in cols) 98 | except Exception as _e: 99 | # We re-raise to prevent masking the error. Should this be a FavaAPIError? 100 | raise Exception from _e 101 | all_mwr_internal |= internal 102 | portfolios.append((title, (self._get_types(cols), [portfolio]))) 103 | 104 | self.total['change'] = round((float(self.total['balance'] - self.total['cost']) / 105 | (float(self.total['cost'])+.00001)) * 100, 2) 106 | self.total['pnl'] = round(float(self.total['balance'] - self.total['cost']), 2) 107 | if 'mwr' in seen_cols or 'twr' in seen_cols: 108 | self.total['mwr'], self.total['twr'] = self._calculate_irr_twr( 109 | self.all_mwr_accounts, all_mwr_internal, 'mwr' in seen_cols, 'twr' in seen_cols) 110 | 111 | portfolios = [("All portfolios", (self._get_types(cols), [self.total]))] + portfolios 112 | print(f"Done: Elapsed: {time.time() - _t0:.2f} (mwr/twr: {self.irr.elapsed():.2f}, " 113 | f"dividends: {self.dividends_elapsed: .2f})") 114 | return portfolios 115 | 116 | def parse_config(self): 117 | """Parse configuration options""" 118 | # pylint: disable=unsubscriptable-object not-an-iterable unsupported-membership-test 119 | keys = ('metadata-key', 'account-groups', 'internal', 'mwr', 'twr', 'dividends', 'cols') 120 | if not isinstance(self.config, dict): 121 | raise FavaAPIError("Portfolio List: Config should be a dictionary.") 122 | for key in ('metadata-key', 'account-groups'): 123 | if key not in self.config: 124 | raise FavaAPIError(f"Portfolio List: '{key}' is required key.") 125 | for key in self.config: 126 | if key not in keys: 127 | raise FavaAPIError(f"Portfolio List: '{key}' is an invalid key.") 128 | internal = self.config.get('internal', set()) 129 | if isinstance(internal, (tuple, list)): 130 | internal = set(internal) 131 | elif not isinstance(internal, set): 132 | raise FavaAPIError("Portfolio List: 'internal' must be a list.") 133 | cols = self.config.get('cols', self.all_cols.copy()) 134 | for col in cols: 135 | if col not in self.all_cols: 136 | raise FavaAPIError(f"Portfolio List: '{col}' is not a valid column. " 137 | f"Must be one of {self.all_cols}") 138 | mwr = self.config.get('mwr', 'mwr' in cols) 139 | # twr and dividends are expensive to calculate, so default to disabled 140 | twr = self.config.get('twr', 'twr' in self.config.get('cols', [])) 141 | dividends = self.config.get('dividends', 'dividends' in self.config.get('cols', [])) 142 | if isinstance(mwr, str) and mwr != "children": 143 | raise FavaAPIError("Portfolio List: 'mwr' must be one of (True, False, 'children')") 144 | if isinstance(twr, str) and twr != "children": 145 | raise FavaAPIError("Portfolio List: 'twr' must be one of (True, False, 'children')") 146 | if isinstance(dividends, str): 147 | raise FavaAPIError("Portfolio List: 'dividends' must be one of (True, False)") 148 | 149 | for group in self.config['account-groups']: 150 | yield self.config['metadata-key'], *self._parse_group(group, internal, cols, mwr, twr, dividends) 151 | 152 | yield [cols] 153 | 154 | def _parse_group(self, group, internal, cols, mwr, twr, dividends): 155 | grp_internal = internal.copy() 156 | if isinstance(group, dict): 157 | try: 158 | grp_internal |= set(group.get('internal', set())) 159 | grp_cols = group.get('cols', cols.copy()) 160 | grp_mwr = group.get('mwr', mwr if 'mwr' in grp_cols else False) 161 | grp_twr = group.get('twr', twr if 'twr' in grp_cols else False) 162 | grp_dividends = group.get('dividends', dividends if 'dividends' in cols else False) 163 | for col in cols: 164 | if col not in self.all_cols: 165 | raise FavaAPIError(f"Portfolio List: '{col}' is not a valid column. " 166 | f"Must be one of {self.all_cols}") 167 | group = group['name'] 168 | except Exception as _e: 169 | raise FavaAPIError(f"Portfolio List: Error parsing group {str(group)}: {str(_e)}") from _e 170 | else: 171 | grp_mwr = mwr 172 | grp_twr = twr 173 | grp_dividends = dividends 174 | grp_cols = cols.copy() 175 | if not grp_mwr and 'mwr' in grp_cols: 176 | grp_cols.remove("mwr") 177 | if not grp_twr and 'twr' in grp_cols: 178 | grp_cols.remove("twr") 179 | if not grp_dividends and 'dividends' in grp_cols: 180 | grp_cols.remove("dividends") 181 | return group, grp_internal, grp_cols, grp_mwr, grp_twr 182 | 183 | @staticmethod 184 | def _get_types(cols): 185 | col_map = { 186 | "units": str(Decimal), 187 | "cost": str(Decimal), 188 | "balance": str(Decimal), 189 | "pnl": str(Decimal), 190 | "dividends": str(Decimal), 191 | "change": 'Percent', 192 | "mwr": 'Percent', 193 | "twr": 'Percent', 194 | "allocation": 'Percent', 195 | } 196 | types = [] 197 | types.append(("account", str(str))) 198 | for col in cols: 199 | types.append((col, col_map[col])) 200 | return types 201 | 202 | def _account_metadata_pattern(self, tree, metadata_key, pattern, internal, mwr, twr, dividends): 203 | """ 204 | Returns portfolio info based on matching account open metadata. 205 | 206 | Args: 207 | tree: Ledger root tree node. 208 | metadata_key: Metadata key to match for in account open. 209 | pattern: Metadata value's regex pattern to match for. 210 | Return: 211 | Data structured for use with a querytable - (types, rows). 212 | """ 213 | # pylint: disable=too-many-arguments 214 | title = f"{pattern.upper()} portfolios" 215 | selected_accounts = [] 216 | regexer = re.compile(pattern) 217 | accounts = self.ledger.all_entries_by_type.Open 218 | accounts = sorted(accounts, key=lambda x: x.account) 219 | last_seen = None 220 | for entry in accounts: 221 | if entry.account not in tree: 222 | continue 223 | if (metadata_key in entry.meta) and ( 224 | regexer.match(entry.meta[metadata_key]) is not None 225 | ): 226 | selected_accounts.append({'account': tree[entry.account], 'children': []}) 227 | last_seen = entry.account + ':' 228 | elif last_seen and entry.account.startswith(last_seen): 229 | selected_accounts[-1]['children'].append(tree[entry.account]) 230 | 231 | portfolio_data = self._portfolio_data(selected_accounts, internal, mwr, twr, dividends) 232 | return title, portfolio_data 233 | 234 | 235 | def _process_dividends(self,account,currency): 236 | parent_name = ":".join(account.name.split(":")[:-1]) 237 | cache_key = (account.name, currency,g.filtered.end_date) 238 | if cache_key in self.dividend_cache: 239 | return self.dividend_cache[cache_key] 240 | query = ( 241 | f"SELECT SUM(CONVERT(COST(position),'{self.operating_currency}')) AS dividends " 242 | f"FROM HAS_ACCOUNT('{currency}') AND HAS_ACCOUNT('{parent_name}') WHERE LEAF(account) = 'Dividends'") 243 | if g.filtered.end_date: 244 | query += f" AND date < {g.filtered.end_date}" 245 | start = time.time() 246 | result = self.ledger.query_shell.execute_query(query) 247 | self.dividends_elapsed += time.time() - start 248 | dividends = ZERO 249 | if len(result[2])>0: 250 | for row_cost in result[2]: 251 | if len(row_cost.dividends.get_positions())==1: 252 | dividends+=round(abs(row_cost.dividends.get_positions()[0].units.number),2) 253 | self.dividend_cache[cache_key] = dividends 254 | return dividends 255 | 256 | def _process_node(self, node, dividends): 257 | # pylint: disable=too-many-locals 258 | row = {} 259 | 260 | row["account"] = node.name 261 | row['children'] = [] 262 | row["last-date"] = None 263 | row['pnl'] = ZERO 264 | row['dividends'] = ZERO 265 | date = g.filtered.end_date 266 | balance = cost_or_value(node.balance, "at_value", g.ledger.prices, date=date) 267 | cost = cost_or_value(node.balance, "at_cost", g.ledger.prices, date=date) 268 | #### ADD Units to the report 269 | units = cost_or_value(node.balance, "units", g.ledger.prices, date=date) 270 | ### Get row currency 271 | row_currency = None 272 | if len(list(units.values())) > 0: 273 | row["units"] = list(units.values())[0] 274 | row_currency = list(units.keys())[0] 275 | #### END of UNITS 276 | if dividends: 277 | if row_currency is not None and row_currency not in self.ledger.options["operating_currency"]: 278 | row['dividends'] = self._process_dividends(node,row_currency) 279 | 280 | if self.operating_currency in balance and self.operating_currency in cost: 281 | balance_dec = round(balance[self.operating_currency], 2) 282 | cost_dec = round(cost[self.operating_currency], 2) 283 | row["balance"] = balance_dec 284 | row["cost"] = cost_dec 285 | 286 | #### ADD other Currencies 287 | elif (row_currency is not None and self.operating_currency not in balance 288 | and self.operating_currency not in cost): 289 | total_currency_cost = ZERO 290 | total_currency_value = ZERO 291 | 292 | result = self.ledger.query_shell.execute_query( 293 | "SELECT " 294 | f"convert(cost(position),'{self.operating_currency}',cost_date) AS cost, " 295 | f"convert(value(position) ,'{self.operating_currency}',today()) AS value " 296 | f"WHERE currency = '{row_currency}' AND account ='{node.name}' " 297 | "ORDER BY currency, cost_date") 298 | if len(result) == 3: 299 | for row_cost,row_value in result[2]: 300 | total_currency_cost+=row_cost.number 301 | total_currency_value+=row_value.number 302 | row["balance"] = round(total_currency_value, 2) 303 | row["cost"] = round(total_currency_cost, 2) 304 | 305 | ### GET LAST CURRENCY PRICE DATE 306 | if row_currency is not None and row_currency != self.operating_currency: 307 | try: 308 | dict_dates = g.filtered.prices(self.operating_currency,row_currency) 309 | if len(dict_dates) >0: 310 | row["last-date"] = dict_dates[-1][0] 311 | except KeyError: 312 | pass 313 | return row 314 | 315 | def _portfolio_data(self, nodes, internal, mwr, twr, dividends): 316 | """ 317 | Turn a portfolio of tree nodes into querytable-style data. 318 | 319 | Args: 320 | nodes: Account tree nodes. 321 | Return: 322 | types: Tuples of column names and types as strings. 323 | rows: Dictionaries of row data by column names. 324 | """ 325 | 326 | rows = [] 327 | mwr_accounts = set() 328 | total = { 329 | 'account': 'Total', 330 | 'balance': ZERO, 331 | 'cost': ZERO, 332 | 'pnl':ZERO, 333 | 'dividends':ZERO, 334 | 'children': [], 335 | 'last-date':None 336 | } 337 | rows.append(total) 338 | 339 | for node in nodes: 340 | parent = self._process_node(node['account'], dividends) 341 | if 'balance' not in parent: 342 | parent['balance'] = ZERO 343 | parent['cost'] = ZERO 344 | total['children'].append(parent) 345 | rows.append(parent) 346 | for child in node['children']: 347 | row = self._process_node(child, dividends) 348 | if 'balance' not in row: 349 | continue 350 | parent['balance'] += row['balance'] 351 | parent['cost'] += row['cost'] 352 | parent['dividends'] += row['dividends'] 353 | if mwr == "children" or twr == "children": 354 | row['mwr'], row['twr'] = self._calculate_irr_twr( 355 | [row['account']], internal, mwr == "children", twr == "children") 356 | parent['children'].append(row) 357 | rows.append(row) 358 | total['balance'] += parent['balance'] 359 | total['cost'] += parent['cost'] 360 | total['dividends'] += parent['dividends'] 361 | if mwr or twr: 362 | pattern = parent['account'] + '(:.*)?' 363 | mwr_accounts.add(pattern) 364 | parent['mwr'], parent['twr'] = self._calculate_irr_twr([pattern], internal, mwr, twr) 365 | 366 | for row in rows: 367 | if "balance" in row and total['balance'] > 0: 368 | row["allocation"] = round((row["balance"] / total['balance']) * 100, 2) 369 | row["change"] = round((float(row['balance'] - row['cost']) / (float(row['cost'])+.00001)) * 100, 2) 370 | row["pnl"] = round(float(row['balance'] - row['cost']),2) 371 | self.total['balance'] += total['balance'] 372 | self.total['cost'] += total['cost'] 373 | self.total['dividends'] += total['dividends'] 374 | if mwr or twr: 375 | total['mwr'], total['twr'] = self._calculate_irr_twr(mwr_accounts, internal, mwr, twr) 376 | self.all_mwr_accounts |= mwr_accounts 377 | return total 378 | 379 | def _calculate_irr_twr(self, patterns, internal, calc_mwr, calc_twr): 380 | cache_key = (",".join(patterns), ",".join(internal), g.filtered.end_date, calc_mwr, calc_twr) 381 | if cache_key in self.irr_cache: 382 | return self.irr_cache[cache_key] 383 | mwr, twr = self.irr.calculate( 384 | patterns, internal_patterns=internal, 385 | start_date=None, end_date=g.filtered.end_date, mwr=calc_mwr, twr=calc_twr) 386 | if mwr: 387 | mwr = round(100 * mwr, 2) 388 | if twr: 389 | twr = round(100 * twr, 2) 390 | self.irr_cache[cache_key] = [mwr, twr] 391 | print(f'mwr: {mwr} twr: {twr}') 392 | return mwr, twr 393 | -------------------------------------------------------------------------------- /irr.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """ 3 | Calculate the MWRR and/or TWRR for a list of portfolios 4 | 5 | This code is originally from https://github.com/hoostus/portfolio-returns 6 | Copyright Justus Pendleton 7 | It was originally licensed under the Parity Public License 7.0. 8 | This file is dual licensed under the Parity Public License 7.0 and the MIT License 9 | as permitted by the Parity Public License 10 | """ 11 | # pylint: disable=logging-fstring-interpolation broad-except 12 | import argparse 13 | import logging 14 | import sys 15 | import functools 16 | import operator 17 | import collections 18 | import datetime 19 | import re 20 | import time 21 | from pprint import pprint 22 | 23 | from decimal import Decimal 24 | from dateutil.relativedelta import relativedelta 25 | import beancount.loader 26 | import beancount.utils 27 | import beancount.core 28 | import beancount.core.getters 29 | import beancount.core.data 30 | import beancount.core.convert 31 | import beancount.parser 32 | from fava.helpers import BeancountError 33 | import fava.core.conversion 34 | import fava.core.inventory 35 | 36 | # https://github.com/peliot/XIRR-and-XNPV/blob/master/financial.py 37 | try: 38 | from scipy.optimize import newton as secant_method # pylint: disable=import-error 39 | except Exception: 40 | def secant_method(f, x0, tol=0.0001): 41 | """ 42 | Solve for x where f(x)=0, given starting x0 and tolerance. 43 | """ 44 | # pylint: disable=invalid-name 45 | x1 = x0 * 1.1 46 | while abs(x1 - x0)/abs(x1) > tol: 47 | x0, x1 = x1, x1 - f(x1) * (x1 - x0)/(f(x1) - f(x0)) 48 | return x1 49 | 50 | def xnpv(rate,cashflows): 51 | """ 52 | Calculate the net present value of a series of cashflows at irregular intervals. 53 | Arguments 54 | --------- 55 | * rate: the discount rate to be applied to the cash flows 56 | * cashflows: a list object in which each element is a tuple of the form (date, amount), where date is a 57 | python datetime.date object and amount is an integer or floating point number. Cash outflows 58 | (investments) are represented with negative amounts, and cash inflows (returns) are positive amounts. 59 | 60 | Returns 61 | ------- 62 | * returns a single value which is the NPV of the given cash flows. 63 | Notes 64 | --------------- 65 | * The Net Present Value is the sum of each of cash flows discounted back to the date of the first cash flow. The 66 | discounted value of a given cash flow is A/(1+r)**(t-t0), where A is the amount, r is the discout rate, and 67 | (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 68 | being added to the sum (t). 69 | * This function is equivalent to the Microsoft Excel function of the same name. 70 | """ 71 | # pylint: disable=invalid-name 72 | chron_order = sorted(cashflows, key = lambda x: x[0]) 73 | t0 = chron_order[0][0] #t0 is the date of the first cash flow 74 | 75 | return sum([cf/(1+rate)**((t-t0).days/365.0) for (t,cf) in chron_order]) 76 | 77 | def xirr(cashflows,guess=0.1): 78 | """ 79 | Calculate the Internal Rate of Return of a series of cashflows at irregular intervals. 80 | Arguments 81 | --------- 82 | * cashflows: a list object in which each element is a tuple of the form (date, amount), where date is a 83 | python datetime.date object and amount is an integer or floating point number. Cash outflows 84 | (investments) are represented with negative amounts, and cash inflows (returns) are positive amounts. 85 | * guess (optional, default = 0.1): a guess to be used as a starting point for the numerical solution. 86 | Returns 87 | -------- 88 | * Returns the IRR as a single value 89 | 90 | Notes 91 | ---------------- 92 | * The Internal Rate of Return (IRR) is the discount rate at which the Net Present Value (NPV) of a series of cash 93 | flows is equal to zero. The NPV of the series of cash flows is determined using the xnpv function in this module. 94 | The discount rate at which NPV equals zero is found using the secant method of numerical solution. 95 | * This function is equivalent to the Microsoft Excel function of the same name. 96 | * For users that do not have the scipy module installed, there is an alternate version (commented out) that uses 97 | the secant_method function defined in the module rather than the scipy.optimize module's numerical solver. Both 98 | use the same method of calculation so there should be no difference in performance, but the secant_method 99 | function does not fail gracefully in cases where there is no solution, so the scipy.optimize.newton version is 100 | preferred. 101 | """ 102 | try: 103 | return secant_method(lambda r: xnpv(r,cashflows),guess) 104 | except Exception as _e: 105 | logging.error("No solution found for IRR: %s", _e) 106 | return 0.0 107 | 108 | def xtwrr(periods, debug=False): 109 | """Calculate TWRR from a set of date-ordered periods""" 110 | dates = sorted(periods.keys()) 111 | last = float(periods[dates[0]][0]) 112 | mean = 1.0 113 | if debug: 114 | print("Date start-balance cashflow end-balance partial") 115 | for date in dates[1:]: 116 | cur_bal = float(periods[date][0]) 117 | cashflow = float(periods[date][1]) 118 | partial = 1.0 119 | # cashflow occurs on end date, so remove it from the current balance 120 | if last != 0: 121 | partial = 1 + (max(cur_bal - cashflow, 0.0) - last) / last 122 | if debug: 123 | print(f"{date.strftime('%Y-%m-%d')} {last:-15.2f} {cashflow:-11.2f} {cur_bal:-14.2f} {partial:-10.2f}") 124 | mean *= partial 125 | last = cur_bal 126 | mean = mean - 1.0 127 | days = (dates[-1] - dates[0]).days 128 | if days == 0: 129 | return 0.0 130 | twrr = (1 + mean) ** (365.0 / days) - 1 131 | return twrr 132 | 133 | def fmt_d(num): 134 | """Decimal formatter""" 135 | return f'${num:,.0f}' 136 | 137 | def fmt_pct(num): 138 | """Percent formatter""" 139 | return f'{num*100:.2f}%' 140 | 141 | def add_position(position, inventory): 142 | """Add a posting to the inventory""" 143 | if isinstance(position, beancount.core.data.Posting): 144 | inventory.add_position(position) 145 | elif isinstance(position, beancount.core.data.TxnPosting): 146 | inventory.add_position(position.posting) 147 | else: 148 | raise Exception("Not a Posting or TxnPosting", position) 149 | 150 | 151 | class IRR: 152 | """Wrapper class to allow caching results of multiple calculations to improve performance""" 153 | # pylint: disable=too-many-instance-attributes 154 | def __init__(self, entries, price_map, currency, errors=None): 155 | self.all_entries = entries 156 | self.price_map = price_map 157 | self.currency = currency 158 | self.market_value = {} 159 | self.times = [0, 0, 0, 0, 0, 0, 0] 160 | # The following reset after each calculate call() 161 | self.remaining = collections.deque() 162 | self.inventory = beancount.core.inventory.Inventory() 163 | self.interesting = {} 164 | self.internal = {} 165 | self.patterns = None 166 | self.internal_patterns = None 167 | self.errors = errors 168 | 169 | def _error(self, msg, meta=None): 170 | if self.errors: 171 | if not any(_.source == meta and _.message == msg and _.entry is None for _ in self.errors): 172 | self.errors.append(BeancountError(meta, msg, None)) 173 | 174 | def elapsed(self): 175 | """Elapsed time of all runs of calculate()""" 176 | return sum(self.times) 177 | 178 | def iter_interesting_postings(self, date, entries): 179 | """Iterator for 'interesting' postings up-to a specified date""" 180 | if entries: 181 | remaining_postings = collections.deque(entries) 182 | else: 183 | remaining_postings = self.remaining 184 | while remaining_postings: 185 | entry = remaining_postings.popleft() 186 | if entry.date > date: 187 | remaining_postings.appendleft(entry) 188 | break 189 | for _p in entry.postings: 190 | if self.is_interesting_posting(_p): 191 | yield _p 192 | 193 | def get_inventory_as_of_date(self, date, postings): 194 | """Get postings up-to a specified date""" 195 | if postings: 196 | inventory = beancount.core.inventory.Inventory() 197 | else: 198 | inventory = self.inventory 199 | for _p in self.iter_interesting_postings(date, postings): 200 | add_position(_p, inventory) 201 | return inventory 202 | 203 | def get_value_as_of(self, postings, date): 204 | """Get balance for a list of postings at a specified date""" 205 | inventory = self.get_inventory_as_of_date(date, postings) 206 | #balance = inventory.reduce(beancount.core.convert.convert_position, self.currency, self.price_map, date) 207 | balance = fava.core.inventory.CounterInventory() 208 | if date not in self.market_value: 209 | self.market_value[date] = {} 210 | date_cache = self.market_value[date] 211 | for position in inventory: 212 | value = date_cache.get(position) 213 | if not value: 214 | value = fava.core.conversion.convert_position(position, self.currency, self.price_map, date) 215 | if value.currency != self.currency: 216 | # try to convert position via cost 217 | if position.cost and position.cost.currency == self.currency: 218 | value = beancount.core.amount.Amount(position.cost.number * position.units.number, 219 | self.currency) 220 | else: 221 | continue 222 | date_cache[position] = value 223 | balance.add_amount(value) 224 | amount = fava.core.conversion.units(balance) 225 | return amount.get(self.currency, Decimal('0.00')) 226 | 227 | def is_interesting_posting(self, posting): 228 | """ Is this posting for an account we care about? """ 229 | if posting.account not in self.interesting: 230 | self.interesting[posting.account] = bool(self.patterns.search(posting.account)) 231 | return self.interesting[posting.account] 232 | 233 | def is_internal_account(self, posting): 234 | """ Is this an internal account that should be ignored? """ 235 | if posting.account not in self.internal: 236 | self.internal[posting.account] = bool(self.internal_patterns.search(posting.account)) 237 | return self.internal[posting.account] 238 | 239 | def is_interesting_entry(self, entry): 240 | """ Do any of the postings link to any of the accounts we care about? """ 241 | for posting in entry.postings: 242 | if self.is_interesting_posting(posting): 243 | return True 244 | return False 245 | 246 | def calculate(self, patterns, internal_patterns=None, start_date=None, end_date=None, 247 | mwr=True, twr=False, 248 | cashflows=None, inflow_accounts=None, outflow_accounts=None, 249 | debug_twr=False): 250 | """Calulate MWRR or TWRR for a set of accounts""" 251 | ## pylint: disable=too-many-branches too-many-statements too-many-locals too-many-arguments 252 | self.interesting.clear() 253 | self.internal.clear() 254 | self.inventory.clear() 255 | if cashflows is None: 256 | cashflows = [] 257 | if inflow_accounts is None: 258 | inflow_accounts = set() 259 | if outflow_accounts is None: 260 | outflow_accounts = set() 261 | if not start_date: 262 | start_date = datetime.date.min 263 | if not end_date: 264 | end_date = datetime.date.today() 265 | elapsed = [0, 0, 0, 0, 0, 0, 0, 0] 266 | elapsed[0] = time.time() 267 | if internal_patterns: 268 | self.internal_patterns = re.compile(fr'^(?:{ "|".join(internal_patterns) })$') 269 | else: 270 | self.internal_patterns = re.compile('^$') 271 | 272 | self.patterns = re.compile(fr'^(?:{ "|".join(patterns) })$') 273 | 274 | elapsed[1] = time.time() 275 | only_txns = beancount.core.data.filter_txns(self.all_entries) 276 | elapsed[2] = time.time() 277 | interesting_txns = filter(self.is_interesting_entry, only_txns) 278 | elapsed[3] = time.time() 279 | # pull it into a list, instead of an iterator, because we're going to reuse it several times 280 | interesting_txns = list(interesting_txns) 281 | self.remaining = collections.deque(interesting_txns) 282 | twrr_periods = {} 283 | 284 | #p1 = get_inventory_as_of_date(datetime.date(2000, 3, 31), interesting_txns) 285 | #p2 = get_inventory_as_of_date(datetime.date(2000, 4, 17), interesting_txns) 286 | #p1a = get_inventory_as_of_date(datetime.date(2000, 3, 31), None) 287 | #p2a = get_inventory_as_of_date(datetime.date(2000, 4, 17), None) 288 | 289 | for entry in interesting_txns: 290 | if not start_date <= entry.date <= end_date: 291 | continue 292 | 293 | cashflow = Decimal(0) 294 | # Imagine an entry that looks like 295 | # [Posting(account=Assets:Brokerage, amount=100), 296 | # Posting(account=Income:Dividend, amount=-100)] 297 | # We want that to net out to $0 298 | # But an entry like 299 | # [Posting(account=Assets:Brokerage, amount=100), 300 | # Posting(account=Assets:Bank, amount=-100)] 301 | # should net out to $100 302 | # we loop over all postings in the entry. if the posting 303 | # is for an account we care about e.g. Assets:Brokerage then 304 | # we track the cashflow. But we *also* look for "internal" 305 | # cashflows and subtract them out. This will leave a net $0 306 | # if all the cashflows are internal. 307 | for posting in entry.postings: 308 | # convert_position uses the price-map to do price conversions, but this does not necessarily 309 | # accurately represent the cost at transaction time (due to intra-day variations). That 310 | # could cause inacuracy, but since the cashflow is applied to the daily balance, it is more 311 | # important to be consistent with values 312 | converted = fava.core.conversion.convert_position( 313 | posting, self.currency, self.price_map, entry.date) 314 | if converted.currency != self.currency: 315 | # If the price_map does not contain a valid price, see if it can be calculated from cost 316 | # This must align with get_value_as_of() 317 | if posting.cost and posting.cost.currency == self.currency: 318 | value = posting.cost.number * posting.units.number 319 | else: 320 | logging.error(f'Could not convert posting {converted} from {entry.date} at ' 321 | f'{posting.meta["filename"]}:{posting.meta["lineno"]} to {self.currency}. ' 322 | 'IRR will be wrong.') 323 | self._error( 324 | f"Could not convert posting {converted} from {entry.date}, IRR will be wrong", 325 | posting.meta) 326 | continue 327 | else: 328 | value = converted.number 329 | 330 | if self.is_interesting_posting(posting): 331 | cashflow += value 332 | elif self.is_internal_account(posting): 333 | cashflow += value 334 | else: 335 | if value > 0: 336 | outflow_accounts.add(posting.account) 337 | else: 338 | inflow_accounts.add(posting.account) 339 | # calculate net cashflow & the date 340 | if cashflow.quantize(Decimal('.01')) != 0: 341 | cashflows.append((entry.date, cashflow)) 342 | if twr: 343 | if entry.date not in twrr_periods: 344 | twrr_periods[entry.date] = [self.get_value_as_of(None, entry.date), 0] 345 | twrr_periods[entry.date][1] += cashflow 346 | 347 | elapsed[4] = time.time() 348 | start_value = self.get_value_as_of(interesting_txns, start_date) 349 | if start_date not in twrr_periods and start_date != datetime.date.min: 350 | twrr_periods[start_date] = [start_value, 0] # We want the after-cashflow value 351 | # the start_value will include any cashflows that occurred on that date... 352 | # this leads to double-counting them, since they'll also appear in our cashflows 353 | # list. So we need to deduct them from start_value 354 | opening_txns = [amount for (date, amount) in cashflows if date == start_date] 355 | start_value -= functools.reduce(operator.add, opening_txns, 0) 356 | end_value = self.get_value_as_of(None, end_date) 357 | if end_date not in twrr_periods: 358 | twrr_periods[end_date] = [end_value, 0] 359 | # if starting balance isn't $0 at starting time period then we need a cashflow 360 | if start_value != 0: 361 | cashflows.insert(0, (start_date, start_value)) 362 | # if ending balance isn't $0 at end of time period then we need a cashflow 363 | if end_value != 0: 364 | cashflows.append((end_date, -end_value)) 365 | irr = None 366 | twrr = None 367 | elapsed[5] = time.time() 368 | if mwr: 369 | if cashflows: 370 | # we need to coerce everything to a float for xirr to work... 371 | irr = xirr([(d, float(f)) for (d,f) in cashflows]) 372 | if isinstance(irr, complex): 373 | logging.error(f'IRR has complex component for the time period {start_date} -> {end_date}') 374 | irr = None 375 | else: 376 | logging.error(f'No cashflows found during the time period {start_date} -> {end_date}') 377 | elapsed[6] = time.time() 378 | if twr and twrr_periods: 379 | twrr = xtwrr(twrr_periods, debug=debug_twr) 380 | elapsed[7] = time.time() 381 | for i in range(7): 382 | delta = elapsed[i+1] - elapsed[i] 383 | self.times[i] += delta 384 | # print(f"T{i}: delta") 385 | return irr, twrr 386 | 387 | def main(): 388 | """Entrypoint""" 389 | ## pylint: disable=too-many-branches too-many-statements 390 | logging.basicConfig(format='%(levelname)s: %(message)s') 391 | parser = argparse.ArgumentParser( 392 | description="Calculate return data." 393 | ) 394 | parser.add_argument('bean', help='Path to the beancount file.') 395 | parser.add_argument('--currency', default='USD', help='Currency to use for calculating returns.') 396 | parser.add_argument('--account', action='append', default=[], 397 | help='Regex pattern of accounts to include when calculating returns. Can be specified multiple times.') 398 | parser.add_argument('--internal', action='append', default=[], 399 | help='Regex pattern of accounts that represent internal cashflows (i.e. dividends or interest)') 400 | 401 | parser.add_argument('--from', dest='date_from', type=lambda d: datetime.datetime.strptime(d, '%Y-%m-%d').date(), 402 | help='Start date: YYYY-MM-DD, 2016-12-31') 403 | parser.add_argument('--to', dest='date_to', type=lambda d: datetime.datetime.strptime(d, '%Y-%m-%d').date(), 404 | help='End date YYYY-MM-DD, 2016-12-31') 405 | 406 | date_range = parser.add_mutually_exclusive_group() 407 | date_range.add_argument('--year', default=False, type=int, help='Year. Shorthand for --from/--to.') 408 | date_range.add_argument('--ytd', action='store_true') 409 | date_range.add_argument('--1year', action='store_true') 410 | date_range.add_argument('--2year', action='store_true') 411 | date_range.add_argument('--3year', action='store_true') 412 | date_range.add_argument('--5year', action='store_true') 413 | date_range.add_argument('--10year', action='store_true') 414 | 415 | parser.add_argument('--debug-inflows', action='store_true', 416 | help='Print list of all inflow accounts in transactions.') 417 | parser.add_argument('--debug-outflows', action='store_true', 418 | help='Print list of all outflow accounts in transactions.') 419 | parser.add_argument('--debug-cashflows', action='store_true', 420 | help='Print list of all cashflows used for the IRR calculation.') 421 | parser.add_argument('--debug-twr', action='store_true', 422 | help='Print calculations for TWR.') 423 | 424 | args = parser.parse_args() 425 | 426 | shortcuts = ['year', 'ytd', '1year', '2year', '3year', '5year', '10year'] 427 | shortcut_used = functools.reduce(operator.__or__, [getattr(args, x) for x in shortcuts]) 428 | if shortcut_used and (args.date_from or args.date_to): 429 | raise Exception('Date shortcut options mutually exclusive with --to/--from options') 430 | 431 | if args.year: 432 | args.date_from = datetime.date(args.year, 1, 1) 433 | args.date_to = datetime.date(args.year, 12, 31) 434 | 435 | if args.ytd: 436 | today = datetime.date.today() 437 | args.date_from = datetime.date(today.year, 1, 1) 438 | args.date_to = today 439 | 440 | if getattr(args, '1year'): 441 | today = datetime.date.today() 442 | args.date_from = today + relativedelta(years=-1) 443 | args.date_to = today 444 | 445 | if getattr(args, '2year'): 446 | today = datetime.date.today() 447 | args.date_from = today + relativedelta(years=-2) 448 | args.date_to = today 449 | 450 | if getattr(args, '3year'): 451 | today = datetime.date.today() 452 | args.date_from = today + relativedelta(years=-3) 453 | args.date_to = today 454 | 455 | if getattr(args, '5year'): 456 | today = datetime.date.today() 457 | args.date_from = today + relativedelta(years=-5) 458 | args.date_to = today 459 | 460 | if getattr(args, '10year'): 461 | today = datetime.date.today() 462 | args.date_from = today + relativedelta(years=-10) 463 | args.date_to = today 464 | 465 | entries, _errors, _options = beancount.loader.load_file(args.bean, logging.info, log_errors=sys.stderr) 466 | price_map = beancount.core.prices.build_price_map(entries) 467 | 468 | cashflows = [] 469 | inflow_accounts = set() 470 | outflow_accounts = set() 471 | irr, twr = IRR(entries, price_map, args.currency).calculate( 472 | args.account, internal_patterns=args.internal, start_date=args.date_from, end_date=args.date_to, 473 | mwr=True, twr=True, 474 | cashflows=cashflows, inflow_accounts=inflow_accounts, outflow_accounts=outflow_accounts, 475 | debug_twr=args.debug_twr) 476 | if irr: 477 | print(f"IRR: {irr}") 478 | if twr: 479 | print(f"TWR: {twr}") 480 | if args.debug_cashflows: 481 | pprint(cashflows) 482 | if args.debug_inflows: 483 | print('>> [inflows]') 484 | pprint(inflow_accounts) 485 | if args.debug_outflows: 486 | print('<< [outflows]') 487 | pprint(outflow_accounts) 488 | 489 | if __name__ == '__main__': 490 | main() 491 | --------------------------------------------------------------------------------