diff --git a/src/fava_portfolio_returns/api/compare.py b/src/fava_portfolio_returns/api/compare.py index 5d99c15..bbfde94 100644 --- a/src/fava_portfolio_returns/api/compare.py +++ b/src/fava_portfolio_returns/api/compare.py @@ -10,6 +10,11 @@ logger = logging.getLogger(__name__) +class Series(NamedTuple): + name: str + data: list[tuple[datetime.date, float]] + + @dataclass class DatedSeries: name: str @@ -21,10 +26,24 @@ def __init__(self, name, data): self.data = data self.dates = frozenset(date for date, _ in data) - -class Series(NamedTuple): - name: str - data: list[tuple[datetime.date, float]] + def get_performance_starting_at_date(self, start_date: datetime.date, normalization_method: str) -> Series: + start_from = None + for i, (date, value) in enumerate(self.data): + if date == start_date: + first_value = value + start_from = i + break + performance = [] + for date, value in self.data[start_from:]: + if normalization_method == "twr": + performance.append((date, (value + 1.0) / (first_value + 1.0) - 1.0)) + elif normalization_method == "simple": + performance.append((date, value - first_value)) + elif normalization_method == "price": + performance.append((date, value / first_value - 1.0)) + else: + raise ValueError(f"Invalid normalization method '{normalization_method}'") + return Series(name=self.name, data=performance) def compare_chart( @@ -32,7 +51,7 @@ def compare_chart( ): returns_method = RETURN_METHODS.get(method) if not returns_method: - raise ValueError(f"Invalid method {method}") + raise ValueError(f"Invalid method '{method}'") group_series: list[DatedSeries] = [DatedSeries(name="Returns", data=returns_method.series(p, start_date, end_date))] for group in p.portfolio.investments_config.groups: @@ -42,13 +61,6 @@ def compare_chart( DatedSeries(name=f"(GRP) {group.name}", data=returns_method.series(fp, start_date, end_date)) ) - price_series: list[DatedSeries] = [] - for currency in p.portfolio.investments_config.currencies: - if currency.id in compare_with: - prices = get_prices(p.pricer, currency.currency, p.target_currency) - prices_filtered = [(date, float(value)) for date, value in prices if start_date <= date <= end_date] - price_series.append(DatedSeries(name=f"{currency.name} ({currency.currency})", data=prices_filtered)) - account_series: list[DatedSeries] = [] for account in p.portfolio.investments_config.accounts: if account.id in compare_with: @@ -57,45 +69,32 @@ def compare_chart( DatedSeries(name=f"(ACC) {account.assetAccount}", data=returns_method.series(fp, start_date, end_date)) ) + price_series: list[DatedSeries] = [] + for currency in p.portfolio.investment_groups.currencies: + if currency.id in compare_with: + prices = get_prices(p.pricer, (currency.currency, p.target_currency)) + prices_filtered = [(date, float(value)) for date, value in prices if start_date <= date <= end_date] + price_series.append(DatedSeries(name=f"{currency.name} ({currency.currency})", data=prices_filtered)) + # find first common date common_date = None for date in sorted(group_series[0].dates): - if all(date in s.dates for s in group_series[1:]) and all(date in s.dates for s in price_series): + if ( + all(date in s.dates for s in group_series[1:]) + and all(date in s.dates for s in price_series) + and all(date in s.dates for s in account_series) + ): common_date = date break else: raise ValueError("No overlapping start date found for the selected series.") - # cut off data before common date - for group_serie in group_series: - for i, (date, _) in enumerate(group_serie.data): - if date == common_date: - group_serie.data = group_serie.data[i:] - break - for price_serie in price_series: - for i, (date, _) in enumerate(price_serie.data): - if date == common_date: - price_serie.data = price_serie.data[i:] - break - for account_serie in account_series: - for i, (date, _) in enumerate(account_serie.data): - if date == common_date: - account_serie.data = account_serie.data[i:] - break - - # compute performance relative to first data point series: list[Series] = [] for group_serie in group_series: - first_return = group_serie.data[0][1] - performance = [(date, returns - first_return) for date, returns in group_serie.data] - series.append(Series(name=group_serie.name, data=performance)) - for price_serie in price_series: - first_price = price_serie.data[0][1] - performance = [(date, float(price / first_price - 1)) for date, price in price_serie.data] - series.append(Series(name=price_serie.name, data=performance)) + series.append(group_serie.get_performance_starting_at_date(common_date, normalization_method=method)) for account_serie in account_series: - first_return = account_serie.data[0][1] - performance = [(date, returns - first_return) for date, returns in account_serie.data] - series.append(Series(name=account_serie.name, data=performance)) + series.append(account_serie.get_performance_starting_at_date(common_date, normalization_method=method)) + for price_serie in price_series: + series.append(price_serie.get_performance_starting_at_date(common_date, normalization_method="price")) return series diff --git a/src/fava_portfolio_returns/api/compare_test.py b/src/fava_portfolio_returns/api/compare_test.py index 5b14ed6..3df6fd3 100644 --- a/src/fava_portfolio_returns/api/compare_test.py +++ b/src/fava_portfolio_returns/api/compare_test.py @@ -134,3 +134,38 @@ def test_linear_growth_stock_twr(self): ], ), ] + + def test_portfolio_common_date_doesnt_change_twr(self): + # test to prevent wrong TWR calculation if some dates in the beginning of the series have to be omitted + # (if the common date is later than the first date) + p = load_portfolio_file("portfolio_vs_currency", investment_filter=["c:CORP"]) + # common date is 2020-02-05 since CORN doesn't have earlier pricing or transactions + # presence of c:CORN in the filter changes the starting date from 2020-02-01 to 2020-02-05 + series = compare_chart(p, datetime.date(2020, 1, 1), datetime.date(2020, 3, 15), "twr", ["c:CORP", "c:CORN"]) + # Note that TWR Returns of the portfolio and stock price should be identical because there were no fees or + # any other cash flows that would create the difference + assert series == [ + Series( + name="Returns", + data=[ + (datetime.date(2020, 2, 5), 0.0), + (datetime.date(2020, 2, 10), 0.25), + (datetime.date(2020, 3, 1), approx2(0.67)), + ], + ), + Series( + name="CORN (CORN)", + data=[ + (datetime.date(2020, 2, 5), 0.0), + (datetime.date(2020, 3, 10), 0.5), + ], + ), + Series( + name="CORP (CORP)", + data=[ + (datetime.date(2020, 2, 5), 0.0), + (datetime.date(2020, 2, 10), 0.25), + (datetime.date(2020, 3, 1), approx2(0.67)), + ], + ), + ] diff --git a/src/fava_portfolio_returns/test/ledger/beangrow.pbtxt b/src/fava_portfolio_returns/test/ledger/beangrow.pbtxt index f6a334a..58fda88 100644 --- a/src/fava_portfolio_returns/test/ledger/beangrow.pbtxt +++ b/src/fava_portfolio_returns/test/ledger/beangrow.pbtxt @@ -5,10 +5,20 @@ investments { dividend_accounts: "Income:CORP:Dividend" cash_accounts: "Assets:Cash" } + investment { + currency: "CORN" + asset_account: "Assets:CORN" + dividend_accounts: "Income:CORN:Dividend" + cash_accounts: "Assets:Cash" + } } groups { group { name: "CORP" investment: "Assets:CORP" } + group { + name: "CORN" + investment: "Assets:CORN" + } } diff --git a/src/fava_portfolio_returns/test/ledger/portfolio_vs_currency.beancount b/src/fava_portfolio_returns/test/ledger/portfolio_vs_currency.beancount new file mode 100644 index 0000000..a73cce0 --- /dev/null +++ b/src/fava_portfolio_returns/test/ledger/portfolio_vs_currency.beancount @@ -0,0 +1,31 @@ +; uv run fava --debug src/fava_portfolio_returns/test/ledger/portfolio_vs_currency.beancount +option "title" "Target Currency" +option "operating_currency" "USD" +option "operating_currency" "EUR" +plugin "beancount.plugins.auto_accounts" +plugin "beancount.plugins.implicit_prices" +2020-01-01 custom "fava-extension" "fava_portfolio_returns" + +2020-01-01 commodity EUR +2020-01-01 commodity CORP +2020-01-01 commodity CORN + +2020-02-01 price CORP 1 USD +2020-02-01 price EUR 2 USD + +2020-02-01 * "Buy 100 CORP" + Assets:Cash -100.00 USD + Assets:CORP 100 CORP {1 USD} + +2020-02-05 price CORP 1.2 USD + +2020-02-05 * "Buy 100 CORN" + Assets:Cash -400.00 USD + Assets:CORN 100 CORN {4 USD} + +2020-02-10 * "Buy 100 CORP" + Assets:Cash -150.00 USD + Assets:CORP 100 CORP {1.5 USD} + +2020-03-01 price CORP 2 USD +2020-03-10 price CORN 6 USD