├── .gitignore ├── fava_classy_portfolio ├── __init__.py └── templates │ └── FavaClassyPortfolio.html ├── pyproject.toml ├── readme-screenshot.png ├── readme.md ├── setup.cfg └── setup.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Custom 2 | 3 | # OS generated files 4 | .DS_Store 5 | 6 | # IDE generated files 7 | .vscode 8 | 9 | # Output 10 | output/ 11 | 12 | # Byte-compiled / optimized / DLL files 13 | __pycache__/ 14 | *.py[cod] 15 | *$py.class 16 | 17 | # C extensions 18 | *.so 19 | 20 | # Distribution / packaging 21 | .Python 22 | build/ 23 | develop-eggs/ 24 | dist/ 25 | downloads/ 26 | eggs/ 27 | .eggs/ 28 | lib/ 29 | lib64/ 30 | parts/ 31 | sdist/ 32 | var/ 33 | wheels/ 34 | *.egg-info/ 35 | .installed.cfg 36 | *.egg 37 | MANIFEST 38 | 39 | # PyInstaller 40 | # Usually these files are written by a python script from a template 41 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 42 | *.manifest 43 | *.spec 44 | 45 | # Installer logs 46 | pip-log.txt 47 | pip-delete-this-directory.txt 48 | 49 | # Unit test / coverage reports 50 | htmlcov/ 51 | .tox/ 52 | .coverage 53 | .coverage.* 54 | .cache 55 | nosetests.xml 56 | coverage.xml 57 | *.cover 58 | .hypothesis/ 59 | .pytest_cache/ 60 | 61 | # Translations 62 | *.mo 63 | *.pot 64 | 65 | # Django stuff: 66 | *.log 67 | local_settings.py 68 | db.sqlite3 69 | 70 | # Flask stuff: 71 | instance/ 72 | .webassets-cache 73 | 74 | # Scrapy stuff: 75 | .scrapy 76 | 77 | # Sphinx documentation 78 | docs/_build/ 79 | 80 | # PyBuilder 81 | target/ 82 | 83 | # Jupyter Notebook 84 | .ipynb_checkpoints 85 | 86 | # pyenv 87 | .python-version 88 | 89 | # celery beat schedule file 90 | celerybeat-schedule 91 | 92 | # SageMath parsed files 93 | *.sage.py 94 | 95 | # Environments 96 | .env 97 | .venv 98 | env/ 99 | venv/ 100 | ENV/ 101 | env.bak/ 102 | venv.bak/ 103 | 104 | # Spyder project settings 105 | .spyderproject 106 | .spyproject 107 | 108 | # Rope project settings 109 | .ropeproject 110 | 111 | # mkdocs documentation 112 | /site 113 | 114 | # mypy 115 | .mypy_cache/ 116 | 117 | -------------------------------------------------------------------------------- /fava_classy_portfolio/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Classy Portfolio extension for Fava. 3 | """ 4 | import re 5 | import datetime 6 | import traceback 7 | import sys 8 | 9 | from beancount.core.data import iter_entry_dates, Open, Commodity 10 | from beancount.core.number import ZERO, D, Decimal 11 | from beancount.core import prices, convert, amount 12 | 13 | from flask import g 14 | 15 | from fava.ext import FavaExtensionBase 16 | from fava.template_filters import cost_or_value 17 | from fava.core.tree import Tree 18 | from fava.helpers import FavaAPIException 19 | from fava.core.conversion import get_market_value 20 | from fava.application import app 21 | 22 | 23 | class AccountsDict(dict): 24 | pass 25 | 26 | 27 | class DecimalPercent(Decimal): 28 | pass 29 | 30 | 31 | class DecimalIncomeGainLoss(Decimal): 32 | pass 33 | 34 | 35 | class DecimalPercentGainLoss(Decimal): 36 | pass 37 | 38 | 39 | class FavaClassyPortfolio(FavaExtensionBase): # pragma: no cover 40 | """Fava Extension Report that prints out a portfolio list based 41 | on asset-class and asset-subclass metadata. 42 | """ 43 | 44 | report_title = "Classy Portfolio" 45 | 46 | def load_report(self): 47 | self.commodity_dict = { 48 | entry.currency: entry for entry in self.ledger.all_entries_by_type.Commodity 49 | } 50 | 51 | def portfolio_accounts(self, begin=None, end=None): 52 | """An account tree based on matching regex patterns.""" 53 | portfolios = [] 54 | 55 | try: 56 | self.load_report() 57 | 58 | if begin: 59 | tree = Tree(iter_entry_dates(self.ledger.entries, begin, end)) 60 | else: 61 | tree = self.ledger.root_tree 62 | 63 | for option in self.config: 64 | opt_key = option[0] 65 | if opt_key == "account_name_pattern": 66 | portfolio = self._account_name_pattern(tree, end, option[1]) 67 | elif opt_key == "account_open_metadata_pattern": 68 | portfolio = self._account_metadata_pattern( 69 | tree, end, option[1][0], option[1][1] 70 | ) 71 | else: 72 | exception = FavaAPIException("Classy Portfolio: Invalid option.") 73 | raise (exception) 74 | 75 | portfolio = ( 76 | portfolio[0], # title 77 | portfolio[1], # subtitle 78 | ( 79 | insert_rowspans(portfolio[2][0], portfolio[2][1], True), 80 | portfolio[2][1], 81 | ), # portfolio data 82 | ) 83 | portfolios.append(portfolio) 84 | 85 | except Exception as exc: 86 | traceback.print_exc(file=sys.stdout) 87 | 88 | return portfolios 89 | 90 | def _account_name_pattern(self, tree, date, pattern): 91 | """ 92 | Returns portfolio info based on matching account name. 93 | 94 | Args: 95 | tree: Ledger root tree node. 96 | date: Date. 97 | pattern: Account name regex pattern. 98 | Return: 99 | Data structured for use with a querytable (types, rows). 100 | """ 101 | title = pattern.capitalize() 102 | subtitle = "Account names matching: '" + pattern + "'" 103 | selected_accounts = [] 104 | regexer = re.compile(pattern) 105 | for acct in tree.keys(): 106 | if (regexer.match(acct) is not None) and (acct not in selected_accounts): 107 | selected_accounts.append(acct) 108 | 109 | selected_nodes = [tree[x] for x in selected_accounts] 110 | portfolio_data = self._portfolio_data(selected_nodes, date) 111 | return (title, subtitle, portfolio_data) 112 | 113 | def _account_metadata_pattern(self, tree, date, metadata_key, pattern): 114 | """ 115 | Returns portfolio info based on matching account open metadata. 116 | 117 | Args: 118 | tree: Ledger root tree node. 119 | date: Date. 120 | metadata_key: Metadata key to match for in account open. 121 | pattern: Metadata value's regex pattern to match for. 122 | Return: 123 | Data structured for use with a querytable - (types, rows). 124 | """ 125 | title = pattern.capitalize() 126 | subtitle = ( 127 | "Accounts with '" + metadata_key + "' metadata matching: '" + pattern + "'" 128 | ) 129 | selected_accounts = [] 130 | regexer = re.compile(pattern) 131 | for entry in self.ledger.all_entries_by_type.Open: 132 | if (metadata_key in entry.meta) and ( 133 | regexer.match(entry.meta[metadata_key]) is not None 134 | ): 135 | selected_accounts.append(entry.account) 136 | 137 | selected_nodes = [tree[x] for x in selected_accounts] 138 | portfolio_data = self._portfolio_data(selected_nodes, date) 139 | return (title, subtitle, portfolio_data) 140 | 141 | def _asset_info(self, node, date): 142 | """ 143 | Additional info on an asset (price, gain/loss) 144 | """ 145 | account_cost_conv = self._convert_cost(node, date) 146 | account_cost = account_cost_conv.number 147 | 148 | account_balance_market_value_node = node.balance.reduce( 149 | convert.convert_position, 150 | self.operating_currency, 151 | g.ledger.price_map, 152 | datetime.date.today(), 153 | ) 154 | account_balance_market_value = account_balance_market_value_node.get( 155 | self.operating_currency, ZERO 156 | ) 157 | 158 | # Calculate unrealized gain/loss 159 | # (follow beancount convention that negative values are income) 160 | account_income_gain_loss_unrealized = ( 161 | account_cost - account_balance_market_value 162 | ) 163 | 164 | # Calculate unrealized gain/loss (percentage) 165 | account_gain_loss_unrealized_percentage = ( 166 | (account_income_gain_loss_unrealized * D(-1.0)) / account_cost 167 | ) * D(100.0) 168 | 169 | return ( 170 | account_balance_market_value, 171 | account_income_gain_loss_unrealized, 172 | account_gain_loss_unrealized_percentage, 173 | ) 174 | 175 | def _account_latest_price(self, node): 176 | # Get latest price date 177 | quote_price = list(node.balance.keys())[0] 178 | if quote_price[1] is None: 179 | latest_price = None 180 | else: 181 | base = quote_price[0] 182 | currency = quote_price[1][1] 183 | latest_price = prices.get_latest_price(g.ledger.price_map, (currency, base)) 184 | return latest_price 185 | 186 | def _convert_cost(self, node, date) -> amount.Amount: 187 | account_cost_node = node.balance.reduce(convert.get_cost) 188 | cur, num = list(account_cost_node.items())[0] 189 | amt = amount.Amount(num, cur) 190 | account_cost_amt = convert.convert_amount( 191 | amt, self.operating_currency, g.ledger.price_map, date 192 | ) 193 | return account_cost_amt 194 | 195 | def _portfolio_data(self, nodes, date): 196 | """ 197 | Turn a portfolio of tree nodes into portfolio_table-style data, 198 | looking at account 'asset_class' and 'asset_subclass' data. 199 | 200 | Args: 201 | nodes: Account tree nodes. 202 | date: Date. 203 | Return: 204 | types: Tuples of column names and types as strings. 205 | rows: Dictionaries of row data by column names. 206 | """ 207 | errors = [] 208 | self.operating_currency = self.ledger.options["operating_currency"][0] 209 | 210 | types = [ 211 | ("portfolio_total", str(Decimal)), 212 | ("asset_classes", str(dict)), 213 | ("portfolio_allocation", str(DecimalPercent)), 214 | ("asset_class_total", str(Decimal)), 215 | ("asset_subclasses", str(dict)), 216 | ("asset_class_allocation", str(DecimalPercent)), 217 | ("asset_subclass_total", str(Decimal)), 218 | ("accounts", str(AccountsDict)), 219 | ("asset_subclass_allocation", str(DecimalPercent)), 220 | ("balance_market_value", str(Decimal)), 221 | ("income_gain_loss", str(DecimalIncomeGainLoss)), 222 | ("gain_loss_percentage", str(DecimalPercentGainLoss)), 223 | ("latest_price_date", str(datetime.date)), 224 | ] 225 | 226 | portfolio_tree = {} 227 | portfolio_tree["portfolio_total"] = ZERO 228 | portfolio_tree["asset_classes"] = {} 229 | for node in nodes: 230 | account_name = node.name 231 | commodity = node_commodity(node) 232 | if (commodity in self.commodity_dict) and ( 233 | "asset-class" in self.commodity_dict[commodity].meta 234 | ): 235 | asset_class = self.commodity_dict[commodity].meta["asset-class"] 236 | else: 237 | asset_class = "noclass" 238 | 239 | if (commodity in self.commodity_dict) and ( 240 | "asset-subclass" in self.commodity_dict[commodity].meta 241 | ): 242 | asset_subclass = self.commodity_dict[commodity].meta["asset-subclass"] 243 | else: 244 | asset_subclass = "nosubclass" 245 | 246 | if asset_class not in portfolio_tree["asset_classes"]: 247 | portfolio_tree["asset_classes"][asset_class] = {} 248 | portfolio_tree["asset_classes"][asset_class][ 249 | "portfolio_allocation" 250 | ] = ZERO 251 | portfolio_tree["asset_classes"][asset_class]["asset_class_total"] = ZERO 252 | portfolio_tree["asset_classes"][asset_class]["asset_subclasses"] = {} 253 | if ( 254 | asset_subclass 255 | not in portfolio_tree["asset_classes"][asset_class]["asset_subclasses"] 256 | ): 257 | portfolio_tree["asset_classes"][asset_class]["asset_subclasses"][ 258 | asset_subclass 259 | ] = {} 260 | portfolio_tree["asset_classes"][asset_class]["asset_subclasses"][ 261 | asset_subclass 262 | ]["asset_subclass_total"] = ZERO 263 | portfolio_tree["asset_classes"][asset_class]["asset_subclasses"][ 264 | asset_subclass 265 | ]["portfolio_allocation"] = ZERO 266 | portfolio_tree["asset_classes"][asset_class]["asset_subclasses"][ 267 | asset_subclass 268 | ]["asset_subclass_asset_class_allocation"] = ZERO 269 | portfolio_tree["asset_classes"][asset_class]["asset_subclasses"][ 270 | asset_subclass 271 | ]["accounts"] = {} 272 | 273 | # Insert account-level balances and 274 | # Sum totals for later calculating allocation 275 | account_data = {} 276 | # Get balance market value at today's date, if possible. 277 | 278 | # Calculate cost 279 | account_cost_conv = self._convert_cost(node, date) 280 | account_cost_node = {account_cost_conv.currency: account_cost_conv.number} 281 | 282 | if self.operating_currency in account_cost_node: 283 | 284 | account_cost = account_cost_node[self.operating_currency] 285 | latest_price = self._account_latest_price(node) 286 | if latest_price is None or latest_price[0] is None: 287 | latest_price_date = None 288 | account_balance_market_value = account_cost 289 | # assume there's no gain loss 290 | account_data["balance_market_value"] = account_cost 291 | account_data["income_gain_loss"] = None 292 | account_data["gain_loss_percentage"] = None 293 | account_data["latest_price_date"] = None 294 | else: 295 | latest_price_date = latest_price[0] 296 | ( 297 | account_balance_market_value, 298 | account_income_gain_loss_unrealized, 299 | account_gain_loss_unrealized_percentage, 300 | ) = self._asset_info(node, date) 301 | 302 | account_data["balance_market_value"] = account_balance_market_value 303 | account_data[ 304 | "income_gain_loss" 305 | ] = account_income_gain_loss_unrealized 306 | account_data[ 307 | "gain_loss_percentage" 308 | ] = account_gain_loss_unrealized_percentage 309 | account_data["latest_price_date"] = latest_price_date 310 | 311 | portfolio_tree["asset_classes"][asset_class]["asset_subclasses"][ 312 | asset_subclass 313 | ]["accounts"][account_name] = account_data 314 | 315 | # Accumulate sums 316 | portfolio_tree["portfolio_total"] += account_balance_market_value 317 | portfolio_tree["asset_classes"][asset_class][ 318 | "asset_class_total" 319 | ] += account_balance_market_value 320 | portfolio_tree["asset_classes"][asset_class]["asset_subclasses"][ 321 | asset_subclass 322 | ]["asset_subclass_total"] += account_balance_market_value 323 | 324 | elif len(account_cost_node) == 0: 325 | # Assume account is empty 326 | account_data["balance_market_value"] = ZERO 327 | account_data["income_gain_loss"] = ZERO 328 | account_data["gain_loss_percentage"] = ZERO 329 | account_data["latest_price_date"] = None 330 | portfolio_tree["asset_classes"][asset_class]["asset_subclasses"][ 331 | asset_subclass 332 | ]["accounts"][account_name] = account_data 333 | else: 334 | errors.append( 335 | "account " 336 | + account_name 337 | + " has balances not in operating currency " 338 | + self.operating_currency 339 | ) 340 | 341 | # Now that account balances and totals are calculated, 342 | # Traverse and calculate portfolio-level info. 343 | for asset_class in portfolio_tree["asset_classes"]: 344 | asset_class_dict = portfolio_tree["asset_classes"][asset_class] 345 | 346 | asset_class_dict["portfolio_allocation"] = ( 347 | ZERO 348 | if portfolio_tree["portfolio_total"] == ZERO 349 | else round( 350 | ( 351 | asset_class_dict["asset_class_total"] 352 | / portfolio_tree["portfolio_total"] 353 | ) 354 | * 100, 355 | 2, 356 | ) 357 | ) 358 | 359 | for asset_subclass in asset_class_dict["asset_subclasses"]: 360 | asset_subclass_dict = asset_class_dict["asset_subclasses"][ 361 | asset_subclass 362 | ] 363 | 364 | asset_subclass_dict["portfolio_allocation"] = ( 365 | ZERO 366 | if portfolio_tree["portfolio_total"] == ZERO 367 | else round( 368 | ( 369 | asset_subclass_dict["asset_subclass_total"] 370 | / portfolio_tree["portfolio_total"] 371 | ) 372 | * 100, 373 | 2, 374 | ) 375 | ) 376 | 377 | asset_subclass_dict["asset_class_allocation"] = ( 378 | ZERO 379 | if asset_class_dict["asset_class_total"] == ZERO 380 | else round( 381 | ( 382 | asset_subclass_dict["asset_subclass_total"] 383 | / asset_class_dict["asset_class_total"] 384 | ) 385 | * 100, 386 | 2, 387 | ) 388 | ) 389 | 390 | for account in asset_subclass_dict["accounts"]: 391 | account_dict = asset_subclass_dict["accounts"][account] 392 | 393 | account_dict["portfolio_allocation"] = ( 394 | ZERO 395 | if portfolio_tree["portfolio_total"] == ZERO 396 | else round( 397 | ( 398 | account_dict["balance_market_value"] 399 | / portfolio_tree["portfolio_total"] 400 | ) 401 | * 100, 402 | 2, 403 | ) 404 | ) 405 | 406 | account_dict["asset_class_allocation"] = ( 407 | ZERO 408 | if asset_class_dict["asset_class_total"] == ZERO 409 | else round( 410 | ( 411 | account_dict["balance_market_value"] 412 | / asset_class_dict["asset_class_total"] 413 | ) 414 | * 100, 415 | 2, 416 | ) 417 | ) 418 | 419 | account_dict["asset_subclass_allocation"] = ( 420 | ZERO 421 | if asset_subclass_dict["asset_subclass_total"] == ZERO 422 | else round( 423 | ( 424 | account_dict["balance_market_value"] 425 | / asset_subclass_dict["asset_subclass_total"] 426 | ) 427 | * 100, 428 | 2, 429 | ) 430 | ) 431 | 432 | return portfolio_tree, types, errors 433 | 434 | 435 | def node_commodity(node): 436 | """ 437 | Return the common 'commodity' in an account. 438 | Return 'mixed_commodities' if an account has multiple commodities. 439 | """ 440 | if len(node.balance): 441 | currencies = [cost[0] for cost in list(node.balance.keys())] 442 | ref_currency = currencies[0] 443 | for currency in currencies: 444 | if currency != ref_currency: 445 | return "mixed_commodities" 446 | return ref_currency 447 | else: 448 | return "" 449 | 450 | 451 | def insert_rowspans(data, coltypes, isStart): 452 | new_data = {} 453 | colcount = 0 454 | 455 | if isStart: 456 | # if starting, we start traversing the data by coltype 457 | for coltype in coltypes: 458 | if coltype[1] == "": 459 | # Recurse and call rowspans again 460 | new_data_inner = insert_rowspans( 461 | data[coltype[0]], coltypes[(colcount + 1) :], False 462 | ) 463 | 464 | # Collect the results 465 | new_data[coltype[0]] = new_data_inner 466 | rowsum = 0 467 | for value in new_data_inner.values(): 468 | rowsum += value[1]["rowspan"] 469 | 470 | # append sum of columns to prior columns 471 | for i in list(range(0, colcount, 1)): 472 | new_data[coltypes[i][0]] = ( 473 | new_data[coltypes[i][0]][0], 474 | {"rowspan": rowsum}, 475 | ) 476 | break 477 | 478 | else: 479 | # assume non-dict, row-span of 1 as placeholder 480 | new_data[coltype[0]] = (data[coltype[0]], {"rowspan": 1}) 481 | 482 | colcount = colcount + 1 483 | 484 | else: 485 | # Assume start data is a (multi-key) dictionary and we need to go 486 | # through the keys 487 | 488 | # reformat data for dict to have rowspan data 489 | for key in data.keys(): 490 | new_data[key] = (data[key], {"rowspan": 1}) 491 | 492 | for coltype in coltypes: 493 | if (coltype[1] == "") or ( 494 | coltype[1] == "" 495 | ): 496 | # Return length of each key. 497 | for key in data.keys(): 498 | new_data_inner = insert_rowspans( 499 | data[key][coltype[0]], coltypes[(colcount + 1) :], False 500 | ) 501 | new_data[key][0][coltype[0]] = new_data_inner 502 | 503 | rowsum = 0 504 | for value in new_data_inner.values(): 505 | rowsum += value[1]["rowspan"] 506 | 507 | # Backpropagate rowspans to earlier coltypes... 508 | for i in list(range(0, colcount, 1)): 509 | new_data[key][0][coltypes[i][0]] = ( 510 | new_data[key][0][coltypes[i][0]][0], 511 | {"rowspan": rowsum}, 512 | ) 513 | # ...including the dictionary key 514 | new_data[key] = (new_data[key][0], {"rowspan": rowsum}) 515 | break 516 | 517 | else: 518 | # placeholder for each key 519 | for key in data.keys(): 520 | new_data[key][0][coltype[0]] = ( 521 | data[key][coltype[0]], 522 | {"rowspan": 1}, 523 | ) 524 | 525 | colcount = colcount + 1 526 | 527 | return new_data 528 | -------------------------------------------------------------------------------- /fava_classy_portfolio/templates/FavaClassyPortfolio.html: -------------------------------------------------------------------------------- 1 | 52 | 53 | {% set sort_type = { 54 | "": 'num', 55 | "": 'num', 56 | "": 'num', 57 | } %} 58 | 59 | {% macro ptheader(value, type_) %} 60 | {% set type = type_|string %} 61 | {% set name = value|replace("_","
") %} 62 | {% autoescape false %} 63 | {% if type == "" %} 64 | 65 | <{{ name }} 66 | 67 | {% elif type == "" %} 68 | 69 | {{ name }} 70 | 71 | {% elif type == "" %} 72 | 73 | {{ name }} 74 | 75 | {% else %} 76 | 77 | {{ name }} 78 | 79 | {% endif %} 80 | {% endautoescape %} 81 | {% endmacro %} 82 | 83 | {% macro ptcell(name, value, type_, rowspan=1) %} 84 | {% set type = type_|string %} 85 | {% if type == "" %} 86 | 87 | {% for position in value|sort(attribute='units.currency') %} 88 | {{ position.units|format_currency }}
89 | {% endfor %} 90 | 91 | {% elif type == "" %} 92 | {{ value }} 93 | {% elif type == "account_name" %} 94 | {# NOTE: rowspan expected to be 1 for account names 95 | for proper layout #} 96 | {{ value }} 97 | {% elif type == "" or type == "" %} 98 | {{ value|format_currency }} 99 | {% elif type == "" %} 100 | {{ value|format_currency }} 101 | {% elif type == "" %} 102 | 103 | {% if value %} 104 | {{ ((value|format_currency) + '%')}} 105 | {% endif %} 106 | 107 | {% elif type == "" %} 108 | {{ value|format_currency }} 109 | {% elif type == "" %} 110 | 111 | {% if value %} 112 | {{ ((value|format_currency) + '%')}} 113 | {% endif %} 114 | 115 | {% elif type == "" %} 116 | {{ value|upper }} 117 | {% elif type == "" %} 118 | {{ value }} 119 | {% elif type == "" %} 120 | {{ value|join(',') }} 121 | {% elif type == "" %} 122 | {{ value or '' }} 123 | {% elif type == "" %} 124 | {{ value.units|format_currency }} 125 | {% else %} 126 | {{ value }} 127 | {% endif %} 128 | {% endmacro %} 129 | 130 | {% macro portfoliotable_iter(types, data, isStart, dictType, filter_empty=None) %} 131 | 132 | {% if isStart == True %} 133 | 134 | {% for colname, coltype in types %} 135 | {% set colcount = loop %} 136 | {% if coltype == "" %} 137 | {# recurse, provide dict with types after dict #} 138 | {{ portfoliotable_iter( types[(colcount.index0 + 1):], data[colname], False, coltype ) }} 139 | {% break %} 140 | {% elif coltype == "" %} 141 | {# recurse, provide dict with types after dict #} 142 | {{ portfoliotable_iter( types[(colcount.index0 + 1):], data[colname], False, coltype ) }} 143 | {% break %} 144 | {% else %} 145 | {# Just print the cell, generally don't expect this case #} 146 | {{ ptcell(colname, data[colname][0], coltype, data[colname][1]["rowspan"]) }} 147 | {% endif %} 148 | {% endfor %} 149 | 150 | 151 | {% else %} 152 | {# assume already in a row, have keys #} 153 | {% for key, value in data.items() %} 154 | {% set rowcount = loop %} 155 | {% if rowcount.index0 != 0 %} 156 | 157 | {% endif %} 158 | {# print td cell key #} 159 | {% if dictType == "" %} 160 | {{ ptcell("key", key, "", value[1]["rowspan"])}} 161 | {% elif dictType == "" %} 162 | {{ ptcell("key", key, "account_name", value[1]["rowspan"])}} 163 | {% endif %} 164 | {% set data_inner = value[0] %} 165 | {% for colname, coltype in types %} 166 | {% set colcount = loop %} 167 | {% if coltype == "" %} 168 | {{ portfoliotable_iter( types[(colcount.index0 + 1):], data_inner[colname], False, coltype ) }} 169 | {% break %} 170 | {% elif coltype == "" %} 171 | {{ portfoliotable_iter( types[(colcount.index0 + 1):], data_inner[colname], False, coltype ) }} 172 | {% break %} 173 | {% else %} 174 | {{ ptcell(colname, data_inner[colname][0], coltype, data_inner[colname][1]["rowspan"]) }} 175 | {% endif %} 176 | {% endfor %} 177 | {% if rowcount.index0 != 0 %} 178 | 179 | {% endif %} 180 | {# {% set rowcount = rowcount + 1 %} #} 181 | {% endfor %} 182 | {% endif %} 183 | {# {% set colcount.index = colcount.index + 1 %} #} 184 | 185 | 186 | {% endmacro %} 187 | 188 | 189 | {# portfoliotable takes in a tree-like breakdown of data 190 | 191 | types - array of tuples of key name and type. 192 | ordering dictates column order. 193 | A type of dict signals that data in subsequent columns 194 | will be kept within the dictionary's value. 195 | 196 | data - dictionary of data with key names and values 197 | with rowspan information. 198 | #} 199 | {% macro portfoliotable(data, types, filter_empty=None) %} 200 | {% if types %} 201 | 202 | 203 | 204 | {% for name, type in types %} 205 | {{ptheader(name, type)}} 206 | {% endfor %} 207 | 208 | 209 | 210 | {# TYPES 211 |
212 | {{types|pprint}} 213 |
214 | DATA 215 |
216 | {{data|pprint}} 217 | #} 218 | {{ portfoliotable_iter(types, data, True) }} 219 | 220 | 221 |
222 | {% endif %} 223 | {% endmacro %} 224 | 225 | 226 |

Classy Portfolio

227 |
228 | {% for portfolio in extension.portfolio_accounts(None, None) %} 229 |

{{portfolio[0]}}

230 | {{portfolio[1]}} 231 | {{ portfoliotable(*portfolio[2]) }} 232 |
233 | {% endfor %} 234 | 235 | 236 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools", "wheel"] 3 | -------------------------------------------------------------------------------- /readme-screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seltzered/fava-classy-portfolio/6fd98b41697f9b710eeda5695d2f2078997d2874/readme-screenshot.png -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # Fava Classy Portfolio Extension 2 | 3 | ![Classy Portfolio Screenshot](./readme-screenshot.png) 4 | 5 | ## Warning: Prototype Code 6 | This is a prototype I'm throwing out there due to interest from the Beancount Community (see https://groups.google.com/forum/#!msg/beancount/XNjjnML_vsA/e_MAEhgCBgAJ ). Issues / PR's welcome! So far this is just an early prototype to get a table breakdown view done, there's still work to be done on numeric features (e.g. gain/loss, tax, etc.). 7 | 8 | ## Introduction 9 | Classy Portfolio is an Extension for Fava, a web interface for the Beancount plaintext accounting software. The extension displays a list of different portfolios (e.g. 'taxable' vs. 'retirement'), with breakdowns using 'asset-class' and 'asset-subclass' metadata labels on commodities. 10 | 11 | It's recommended (though not required) to use this extension along with the [beancount metadata spray plugin](https://github.com/seltzered/beancount-plugins-metadata-spray) to ease with tagging multiple accounts with portfolio metadata. 12 | 13 | An example file is available at https://github.com/seltzered/fava-classy-portfolio-demo. 14 | 15 | ## Compatibility with Fava 16 | This currently works with [fava 1.19](https://github.com/beancount/fava). Since this extension depends on internal fava API's compatibility may change. 17 | 18 | ## Usage 19 | 20 | 1. Clone this repo into the same directory as your beancount ledger. 21 | 22 | 2. In your ledger file, add asset-class and asset-subclass metadata strings to commodity entries. Should look similar to: 23 | 24 | 2010-01-01 commodity VTI 25 | name: "Vanguard Total Market Index ETF" 26 | asset-class: "Stock" 27 | asset-subclass: "US-Total Domestic Market" 28 | price: "USD:yahoo/VTI" 29 | 30 | 3. In your ledger file, add portfolio metadata strings to account entries. Should look similar to: 31 | 32 | 2010-01-01 open Assets:Vanguard:RothIRA:VTI 33 | portfolio: "retirement" 34 | ...or if you're using the metadata spray plugin: 35 | 36 | 2010-01-01 open Assets:Vanguard:RothIRA:VTI 37 | plugin "beancount_plugins_metadata_spray.plugins.metadata_spray" "{ 38 | 'sprays': [{ 'spray_type': 'account_open', 39 | 'replace_type': 'return_error', 40 | 'pattern': 'Assets:Vanguard:RothIRA:.*', 41 | 'metadata_dict': {'portfolio': 'retirement'} 42 | }] 43 | }" 44 | 45 | 4. In your ledger file, instantiate the extension with a line similar to: 46 | 47 | 2010-01-01 custom "fava-extension" "fava_classy_portfolio" "[ 48 | ('account_open_metadata_pattern', ('portfolio', 'retirement')) 49 | ]" 50 | 51 | 5. Because this is an early prototype, you will need to clone/run fava from latest source. See first section "PROTOTYPE: modification to fava required" in this document on modifying fava. 52 | 53 | 6. Run fava on the ledger file. A link in the sidebar should appear. 54 | 55 | ## References 56 | 57 | This extension was somewhat inspired by the [Beancount Portfolio Allocation](https://github.com/ghislainbourgeois/beancount_portfolio_allocation) plugin, which I believe follows similar conventions on metadata labeling. 58 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = fava-classy-portfolio 3 | 4 | [options] 5 | packages = fava_classy_portfolio 6 | python_requires = >= 3.6 7 | include_package_data = True 8 | 9 | [tool.black] 10 | line-length = 88 11 | include = '\.pyi?$' 12 | exclude = .git,.hg,.mypy_cache,.tox,.venv,_build,buck-out,build,dist 13 | 14 | [flake8] 15 | max-line-length = 88 16 | exclude = .tox,.eggs,ci/templates,build,dist, __init__.py 17 | ignore=E741,F403,E265,W504,E226,W503,E501,E203 18 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | from setuptools import setup 4 | 5 | setup() 6 | --------------------------------------------------------------------------------