diff --git a/fava_investor/__init__.py b/fava_investor/__init__.py index d2faa98..23a00bd 100644 --- a/fava_investor/__init__.py +++ b/fava_investor/__init__.py @@ -8,6 +8,7 @@ from .modules.cashdrag import libcashdrag from .modules.summarizer import libsummarizer from .modules.minimizegains import libminimizegains +from .modules.performance import libperformance from .common.favainvestorapi import FavaInvestorAPI @@ -50,6 +51,10 @@ def build_minimizegains(self): accapi = FavaInvestorAPI() return libminimizegains.find_minimized_gains(accapi, self.config.get('minimizegains', {})) + def build_performance(self): + accapi = FavaInvestorAPI() + return libperformance.find_xirrs(accapi, self.config.get('performance', {})) + def recently_sold_at_loss(self): accapi = FavaInvestorAPI() return libtlh.recently_sold_at_loss(accapi, self.config.get('tlh', {})) diff --git a/fava_investor/cli/investor.py b/fava_investor/cli/investor.py index 0f885ba..4f39e74 100755 --- a/fava_investor/cli/investor.py +++ b/fava_investor/cli/investor.py @@ -8,6 +8,7 @@ import fava_investor.modules.summarizer.summarizer as summarizer import fava_investor.modules.tlh.tlh as tlh import fava_investor.modules.minimizegains.minimizegains as minimizegains +import fava_investor.modules.performance.performance as performance @click.group() @@ -21,6 +22,7 @@ def cli(): cli.add_command(summarizer.summarizer) cli.add_command(tlh.tlh) cli.add_command(minimizegains.minimizegains) +cli.add_command(performance.performance) if __name__ == '__main__': diff --git a/fava_investor/examples/huge-example.beancount b/fava_investor/examples/huge-example.beancount index a19bde8..53c65d0 100644 --- a/fava_investor/examples/huge-example.beancount +++ b/fava_investor/examples/huge-example.beancount @@ -15,6 +15,12 @@ option "operating_currency" "USD" 'wash_pattern': 'Assets:US', }, + 'performance' : { + 'account_field': 'account', + 'accounts_pattern': 'Assets:US:Vanguard', + 'accuracy': 2, + }, + 'asset_alloc_by_account': [{ 'title': 'Allocation by Account', 'pattern_type': 'account_name', diff --git a/fava_investor/modules/performance/README.md b/fava_investor/modules/performance/README.md new file mode 100644 index 0000000..054052f --- /dev/null +++ b/fava_investor/modules/performance/README.md @@ -0,0 +1,21 @@ +# Performance +_Show XIRR of investments_ + +## Introduction +This modules shows the XIRR of chosen investments and the summary of all the investments chosen. + +## Using this module +- Accounts contains account name +- XIRR contains XIRR of investments + +## Limitations +XIRR are calculated using a maximum of 10 newton iterations for speed, so while the output is close to the final value, the accuracy cannot be guaranteed. + +## Example configuration: +``` + 'performance' : { + 'account_field': 'account', + 'accounts_pattern': 'Assets:Investments', + 'accuracy': 2, + }, +``` diff --git a/fava_investor/modules/performance/TODO.md b/fava_investor/modules/performance/TODO.md new file mode 100644 index 0000000..e69de29 diff --git a/fava_investor/modules/performance/__init__.py b/fava_investor/modules/performance/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/fava_investor/modules/performance/example.beancount b/fava_investor/modules/performance/example.beancount new file mode 100644 index 0000000..47fcdb0 --- /dev/null +++ b/fava_investor/modules/performance/example.beancount @@ -0,0 +1,61 @@ +option "title" "Test" +option "operating_currency" "USD" +option "render_commas" "True" +option "booking_method" "FIFO" + +2010-01-01 open Assets:Investments:BNCT +2010-01-01 open Assets:Investments:COFE +2010-01-01 open Assets:Bank +2010-01-01 open Income:Gains + +2010-01-01 custom "fava-extension" "fava_investor" "{ + 'performance' : { + 'account_field': 'account', + 'accounts_pattern': 'Assets:Investments', + 'accuracy': 2, + }, +}" + + +2010-01-01 commodity BNCT +2010-01-01 commodity COFE + +2020-01-01 * "Buy stock" + Assets:Investments:BNCT 1000 BNCT {100 USD} + Assets:Investments:COFE 1000 COFE {10 USD} + Assets:Bank + +2021-03-12 * "Buy stock" + Assets:Investments:BNCT 1000 BNCT {100 USD} + Assets:Investments:COFE 1000 COFE {10 USD} + Assets:Bank + +2022-01-01 * "Sell stock" + Assets:Investments:BNCT -1500 BNCT {} @ 100 USD + Assets:Investments:COFE -1500 COFE {} @ 10 USD + Assets:Bank 165000 USD + Income:Gains + +2022-06-01 * "Sell stock" + Assets:Investments:BNCT -500 BNCT {} @ 160 USD + Assets:Bank 88000 USD + Income:Gains + +2024-07-14 * "Sell stock" + Assets:Investments:COFE -500 COFE {} @ 25 USD + Assets:Bank 12500 USD + Income:Gains + + +2020-01-01 price BNCT 100 USD +2021-03-12 price BNCT 100 USD +2022-01-01 price BNCT 100 USD +2022-06-01 price BNCT 160 USD + +2020-01-01 price COFE 10 USD +2021-03-12 price COFE 10 USD +2022-01-01 price COFE 10 USD +2022-06-01 price COFE 16 USD +2023-01-01 price COFE 20 USD +2024-01-01 price COFE 25 USD + diff --git a/fava_investor/modules/performance/libperformance.py b/fava_investor/modules/performance/libperformance.py new file mode 100644 index 0000000..f20a0fc --- /dev/null +++ b/fava_investor/modules/performance/libperformance.py @@ -0,0 +1,97 @@ +#!/bin/env python3 +""" +# Performance +_Calculate XIRR for investments._ + +See accompanying README.txt +""" + +import collections +from datetime import date, timedelta +from fava_investor.common.libinvestor import build_config_table +from beancount.core.number import Decimal +from fava_investor.modules.tlh import libtlh + + +def calculate_error_grad(investments: list[date, Decimal], guess: Decimal) -> tuple[Decimal, Decimal]: + sum: Decimal = 0 + grad: Decimal = 0 + init_date: date = investments[0][0] + for item in investments: + investment_date = item[0] + value = item[1] + time_step = Decimal(((investment_date - init_date)/timedelta(days=1))/365) + sum += value / (1 + guess)**time_step + grad += -time_step * value / (1 + guess)**(time_step + 1) + + return sum, grad + + +def calculate_xirr(investments: list[date, Decimal], accuracy) -> Decimal: + guess = Decimal(0.1) + + for _ in range(10): + value, grad = calculate_error_grad(investments, guess) + correction: Decimal = value / grad + guess -= correction + if abs(value) < 1 and correction < 10**(-accuracy-2): + break + + return round(guess*100, accuracy) + + +def find_xirrs(accapi, options): + account_field = libtlh.get_account_field(options) + accounts_pattern = options.get('accounts_pattern', '') + accuracy = int(options.get('accuracy', 2)) + + currency = accapi.get_operating_currencies()[0] + + sql = f""" + SELECT + {account_field} as account, + CONVERT(value(position, date), '{currency}') as market_value, + date as date + WHERE account_sortkey(account) ~ "^[01]" AND + account ~ '{accounts_pattern}' + """ + rtypes, rrows = accapi.query_func(sql) + if not rtypes: + return [], {}, [[]] + + sql = f""" + SELECT + {account_field} as account, + ONLY('{currency}', NEG(CONVERT(sum(position), '{currency}'))) as market_value, + cost_date as date + WHERE account_sortkey(account) ~ "^[01]" AND + account ~ '{accounts_pattern}' + GROUP BY {account_field}, date, account_sortkey(account) + ORDER BY account_sortkey(account) + """ + remaintypes, remainrows = accapi.query_func(sql) + if not remaintypes: + return [], {}, [[]] + + investments = {} + investments["Summary"] = [] + for row in rrows: + if row.account not in investments.keys(): + investments[row.account] = [] + investments[row.account].append([row.date, row.market_value.number]) + investments["Summary"].append([row.date, row.market_value.number]) + for row in remainrows: + if row.market_value.number == 0: + continue + investments[row.account].append([date.today(), row.market_value.number]) + investments["Summary"].append([date.today(), row.market_value.number]) + + xirr = {key: calculate_xirr(investments[key], accuracy) for key in investments} + + retrow_types = [('Account', str), ('XIRR', Decimal)] + RetRow = collections.namedtuple('RetRow', [i[0] for i in retrow_types]) + rrows = [RetRow(key, value) for key, value in xirr.items()] + + tables = [build_config_table(options)] + tables.append(('XIRR performance', (retrow_types, rrows, None, None))) + return tables diff --git a/fava_investor/modules/performance/performance.py b/fava_investor/modules/performance/performance.py new file mode 100755 index 0000000..c66c1e3 --- /dev/null +++ b/fava_investor/modules/performance/performance.py @@ -0,0 +1,52 @@ +#!/usr/bin/env python3 +"""Beancount Tool to find lots to sell with lowest gains, to minimize the tax burden.""" + +import fava_investor.modules.performance.libperformance as libpf +import fava_investor.common.beancountinvestorapi as api +from fava_investor.common.clicommon import pretty_print_table, write_table_csv +import click + + +@click.command() +@click.argument('beancount-file', type=click.Path(exists=True), envvar='BEANCOUNT_FILE') +@click.option('--csv-output', help='In addition to summary, output to performance.csv', is_flag=True) +def performance(beancount_file, csv_output): + """Generate XIRR for each investment. + + The BEANCOUNT_FILE environment variable can optionally be set instead of specifying the file on the + command line. + + The configuration for this module is expected to be supplied as a custom directive like so in your + beancount file: + + \b + 2010-01-01 custom "fava-extension" "fava_investor" "{ + 'performance' : { + 'account_field': 'account', + 'accounts_pattern': 'Assets:Investments', + 'accuracy': 2, + }, + }}" + + """ + accapi = api.AccAPI(beancount_file, {}) + config = accapi.get_custom_config('performance') + tables = libpf.find_xirrs(accapi, config) + + # TODO: + # - use same return return API for all of fava_investor + # - ordered dictionary of title: [retrow_types, table] + # - make output printing and csv a common function + + if csv_output: + write_table_csv('performance.csv', tables[1]) + else: + def _gen_output(): + for title, (rtypes, rrows, _, _) in tables: + yield pretty_print_table(title, rtypes, rrows) + + click.echo_via_pager(_gen_output()) + + +if __name__ == '__main__': + performance() diff --git a/fava_investor/modules/performance/test_performance.py b/fava_investor/modules/performance/test_performance.py new file mode 100644 index 0000000..4008883 --- /dev/null +++ b/fava_investor/modules/performance/test_performance.py @@ -0,0 +1,72 @@ +#!/usr/bin/env python3 + +import beancountinvestorapi as api +import sys +import os +from beancount.utils import test_utils +sys.path.append(os.path.join(os.path.dirname(__file__), '.')) +import libperformance as libpf + + +class TestScriptCheck(test_utils.TestCase): + def setUp(self): + self.options = { + 'account_field': 'account', + 'accounts_pattern': 'Assets:Investments', + 'accuracy': 2, + } + + @test_utils.docfile + def test_performance_basic(self, f): + """ +2010-01-01 commodity BNCT +2010-01-01 commodity COFE + +2020-01-01 * "Buy stock" + Assets:Investments:BNCT 1000 BNCT {100 USD} + Assets:Investments:COFE 1000 COFE {10 USD} + Assets:Bank + +2021-03-12 * "Buy stock" + Assets:Investments:BNCT 1000 BNCT {100 USD} + Assets:Investments:COFE 1000 COFE {10 USD} + Assets:Bank + +2022-01-01 * "Sell stock" + Assets:Investments:BNCT -1500 BNCT {} @ 100 USD + Assets:Investments:COFE -1500 COFE {} @ 10 USD + Assets:Bank 165000 USD + Income:Gains + +2022-06-01 * "Sell stock" + Assets:Investments:BNCT -500 BNCT {} @ 160 USD + Assets:Bank 88000 USD + Income:Gains + +2024-07-14 * "Sell stock" + Assets:Investments:COFE -500 COFE {} @ 25 USD + Assets:Bank 12500 USD + Income:Gains + +2020-01-01 price BNCT 100 USD +2021-03-12 price BNCT 100 USD +2022-01-01 price BNCT 100 USD +2022-06-01 price BNCT 160 USD + +2020-01-01 price COFE 10 USD +2021-03-12 price COFE 10 USD +2022-01-01 price COFE 10 USD +2022-06-01 price COFE 16 USD +2023-01-01 price COFE 20 USD +2024-01-01 price COFE 25 USD + + """ + accapi = api.AccAPI(f, {}) + ret = libpf.find_xirrs(accapi, self.options) + title, (retrow_types, xirrs, _, _) = ret[1] + + self.assertEqual(2, len(xirrs)) + self.assertEqual("Assets:Investments:BNCT", xirrs[0].account) + self.assertEqual(9.35, xirrs[0].XIRR) + self.assertEqual("Assets:Investments:COFE", xirrs[1].account) + self.assertEqual(13.70, xirrs[1].XIRR) diff --git a/fava_investor/templates/Investor.html b/fava_investor/templates/Investor.html index 442926e..b19b1ba 100644 --- a/fava_investor/templates/Investor.html +++ b/fava_investor/templates/Investor.html @@ -7,7 +7,8 @@ ('cashdrag', _('Cash Drag')), ('tlh', _('Tax Loss Harvestor')), ('summarizer', _('Summarizer')), - ('minimizegains', _('Gains Minimizer')) + ('minimizegains', _('Gains Minimizer')), + ('performance', _('Performance')) ] %}