From 1649c26a7a70685765b6cf0d9617af945ba96350 Mon Sep 17 00:00:00 2001 From: Evernight <786357+Evernight@users.noreply.github.com> Date: Thu, 28 Aug 2025 00:05:52 +0200 Subject: [PATCH] Use date for market currency conversion --- src/fava_portfolio_returns/api/investments.py | 2 +- src/fava_portfolio_returns/api/portfolio.py | 16 ++- .../core/currencies_test.py | 74 ++++++++++++++ src/fava_portfolio_returns/core/portfolio.py | 2 +- src/fava_portfolio_returns/core/utils.py | 11 ++- .../returns/twr_test.py | 97 +++++++++++++++++++ src/fava_portfolio_returns/test/test.py | 33 ++++++- 7 files changed, 222 insertions(+), 13 deletions(-) create mode 100644 src/fava_portfolio_returns/core/currencies_test.py diff --git a/src/fava_portfolio_returns/api/investments.py b/src/fava_portfolio_returns/api/investments.py index afd8fee..926f215 100644 --- a/src/fava_portfolio_returns/api/investments.py +++ b/src/fava_portfolio_returns/api/investments.py @@ -18,7 +18,7 @@ def group_stats(p: FilteredPortfolio, start_date: datetime.date, end_date: datetime.date): balance = p.balance_at(end_date) - cost_value = cost_value_of_inv(p.pricer, p.target_currency, balance) + cost_value = cost_value_of_inv(p.pricer, p.target_currency, balance, end_date) market_value = market_value_of_inv(p.pricer, p.target_currency, balance, end_date, record=True) # reduce to units (i.e. removing cost attribute) after calculating market value, because convert.get_value() only works with positions held at cost # this will convert for example CORP -> USD (cost currency) -> EUR (target currency), instead of CORP -> EUR. diff --git a/src/fava_portfolio_returns/api/portfolio.py b/src/fava_portfolio_returns/api/portfolio.py index c024014..2e16cfa 100644 --- a/src/fava_portfolio_returns/api/portfolio.py +++ b/src/fava_portfolio_returns/api/portfolio.py @@ -12,7 +12,6 @@ from fava_portfolio_returns.core.portfolio import FilteredPortfolio from fava_portfolio_returns.core.utils import cost_value_of_inv from fava_portfolio_returns.core.utils import get_prices -from fava_portfolio_returns.core.utils import inv_to_currency from fava_portfolio_returns.core.utils import market_value_of_inv @@ -61,6 +60,9 @@ def portfolio_values( if posting.meta["category"] is Cat.ASSET and posting.cost: # ex. (CORP, USD) currency_pairs.add((posting.units.currency, posting.cost.currency)) + # also add data points for indirect conversion to target currency + if posting.cost.currency != p.target_currency: + currency_pairs.add((posting.cost.currency, p.target_currency)) def first(x): return x[0] @@ -96,7 +98,7 @@ def first(x): # Iterate computing the balance. values: list[PortfolioValue] = [] balance = Inventory() - cf_balance = Inventory() + cf_balance_converted = Decimal(0.0) for date, group in itertools.groupby(entry_dates, key=first): # Update balances. for _, entry in group: @@ -106,14 +108,18 @@ def first(x): if posting.meta["category"] is Cat.ASSET: balance.add_position(posting) for flow in produce_cash_flows_general(entry, ""): - cf_balance.add_amount(flow.amount) + # Convert flow amount to the target_currency at the date of the flow + cash_amount_converted = p.pricer.convert_amount(flow.amount, p.target_currency, date) + if cash_amount_converted.currency != p.target_currency: + raise ValueError(f"Can't convert {cash_amount_converted.currency} to {p.target_currency} at {date}") + cf_balance_converted += cash_amount_converted.number if date >= first_date: # Clamp start_date in case we cut off data at the beginning. clamp_date = max(date, start_date) market = market_value_of_inv(p.pricer, p.target_currency, balance, clamp_date) - cost = cost_value_of_inv(p.pricer, p.target_currency, balance) - cash = -inv_to_currency(p.pricer, p.target_currency, cf_balance) # sum of cash flows + cost = cost_value_of_inv(p.pricer, p.target_currency, balance, clamp_date) + cash = -cf_balance_converted # sum of cash flows values.append(PortfolioValue(date=clamp_date, market=market, cost=cost, cash=cash)) return values diff --git a/src/fava_portfolio_returns/core/currencies_test.py b/src/fava_portfolio_returns/core/currencies_test.py new file mode 100644 index 0000000..7de55ca --- /dev/null +++ b/src/fava_portfolio_returns/core/currencies_test.py @@ -0,0 +1,74 @@ +import datetime +import unittest + +from fava_portfolio_returns.returns.simple import SimpleReturns +from fava_portfolio_returns.test.test import BEANGROW_CONFIG_CORP +from fava_portfolio_returns.test.test import load_portfolio_str + + +class CurrenciesTest(unittest.TestCase): + def test_indirect_currency_conversion(self): + p = load_portfolio_str( + """ +plugin "beancount.plugins.auto_accounts" +plugin "beancount.plugins.implicit_prices" + +2020-01-01 commodity CORP + name: "Example Stock" + +2020-01-01 commodity CURRENCY_BASE +2020-01-01 commodity CURRENCY_TARGET + +2020-01-01 price CURRENCY_BASE 2 CURRENCY_TARGET + +2020-01-01 * "Buy 50 CORP @ 2 CURRENCY_BASE" + Assets:Cash -100.00 CURRENCY_BASE + Assets:CORP 50 CORP {2 CURRENCY_BASE} + +2020-01-04 price CURRENCY_BASE 3 CURRENCY_TARGET + +2020-02-01 price CORP 3 CURRENCY_BASE +2020-03-03 price CURRENCY_BASE 5 CURRENCY_TARGET + """, + BEANGROW_CONFIG_CORP, + ) + + p.target_currency = "CURRENCY_BASE" + + assert p.cash_at(datetime.date(2020, 1, 1)) == 100 + assert p.cash_at(datetime.date(2020, 1, 5)) == 100 + assert p.cash_at(datetime.date(2020, 2, 1)) == 100 + assert p.cash_at(datetime.date(2020, 3, 3)) == 100 + + returns_base = SimpleReturns().series(p, datetime.date(2020, 1, 1), datetime.date(2020, 4, 1)) + assert returns_base == [ + (datetime.date(2020, 1, 1), 0.0), + # investment has grown + (datetime.date(2020, 2, 1), 0.5), + ] + + p.target_currency = "CURRENCY_TARGET" + + # Cost basis of 100 CURRENCY_BASE = 200 CURRENCY_TARGET + assert p.cash_at(datetime.date(2020, 1, 1)) == 200 + # Cost basis of 100 CURRENCY_BASE = 300 CURRENCY_TARGET + assert p.cash_at(datetime.date(2020, 1, 5)) == 300 + # Cost basis of 100 CURRENCY_BASE = 300 CURRENCY_TARGET + assert p.cash_at(datetime.date(2020, 2, 1)) == 300 + # Cost basis of 100 CURRENCY_BASE = 500 CURRENCY_TARGET + assert p.cash_at(datetime.date(2020, 3, 3)) == 500 + + returns_target = SimpleReturns().series(p, datetime.date(2020, 1, 1), datetime.date(2020, 4, 1)) + # Note more data points since the currency rates are now affecting how the portfolio evaluations are done + assert returns_target == [ + # 200 CURRENCY_TARGET = 100 CURRENCY_BASE invested + (datetime.date(2020, 1, 1), 0.0), + # currency rate changing doesn't change the cost basis + # (300 CURRENCY_TARGET invested) + (datetime.date(2020, 1, 4), 0.0), + # investment has acrually grown + # (300 CURRENCY_TARGET invested, 50 CORP valued at 150 CURRENCY_BASE = 450 CURRENCY_TARGET) + (datetime.date(2020, 2, 1), 0.5), + # currency rate changing again doesn't change the cost basis (500 CURRENCY_TARGET invested) + (datetime.date(2020, 3, 3), 0.5), + ] diff --git a/src/fava_portfolio_returns/core/portfolio.py b/src/fava_portfolio_returns/core/portfolio.py index 81dccce..48c9709 100644 --- a/src/fava_portfolio_returns/core/portfolio.py +++ b/src/fava_portfolio_returns/core/portfolio.py @@ -176,7 +176,7 @@ def cash_at(self, date: datetime.date) -> Decimal: if cash_flow.date > date: break balance.add_amount(cash_flow.amount) - return -inv_to_currency(self.pricer, self.target_currency, balance) + return -inv_to_currency(self.pricer, self.target_currency, balance, date) def get_target_currency(account_data_list: list[AccountData]) -> str: diff --git a/src/fava_portfolio_returns/core/utils.py b/src/fava_portfolio_returns/core/utils.py index 91a1036..28a91cf 100644 --- a/src/fava_portfolio_returns/core/utils.py +++ b/src/fava_portfolio_returns/core/utils.py @@ -20,9 +20,9 @@ def __init__(self, source: str, target: str, date: Optional[datetime.date]): ) -def cost_value_of_inv(pricer: Pricer, target_currency: str, balance: Inventory) -> Decimal: +def cost_value_of_inv(pricer: Pricer, target_currency: str, balance: Inventory, date: datetime.date) -> Decimal: cost_balance = balance.reduce(convert.get_cost) - return inv_to_currency(pricer, target_currency, cost_balance) + return inv_to_currency(pricer, target_currency, cost_balance, date) def market_value_of_inv( @@ -36,7 +36,7 @@ def market_value_of_inv( value_balance = balance.reduce(convert.get_value, pricer.price_map, date) # then convert to target currency - return inv_to_currency(pricer, target_currency, value_balance) + return inv_to_currency(pricer, target_currency, value_balance, date) def inv_to_currency( @@ -71,4 +71,7 @@ def get_prices(pricer: Pricer, pair: tuple[str, str]) -> list[tuple[datetime.dat """ :param tuple pair: (currency, target_currency), e.g. (CORP, USD) """ - return prices.get_all_prices(pricer.price_map, pair) + try: + return prices.get_all_prices(pricer.price_map, pair) + except Exception: + raise ValueError(f"Missing prices for currency pair {pair}") diff --git a/src/fava_portfolio_returns/returns/twr_test.py b/src/fava_portfolio_returns/returns/twr_test.py index 5d49e41..ebf2b10 100644 --- a/src/fava_portfolio_returns/returns/twr_test.py +++ b/src/fava_portfolio_returns/returns/twr_test.py @@ -2,6 +2,7 @@ import unittest from fava_portfolio_returns.returns.twr import TWR +from fava_portfolio_returns.test.test import BEANGROW_CONFIG_CORP_CASH_FLOWS from fava_portfolio_returns.test.test import BEANGROW_CONFIG_CORPAB from fava_portfolio_returns.test.test import approx2 from fava_portfolio_returns.test.test import load_portfolio_file @@ -54,3 +55,99 @@ def test_series_savings_plan(self): (datetime.date(2020, 3, 1), 1.0), (datetime.date(2020, 4, 1), 1.5), ] + + def test_twr_with_changing_target_currency(self): + portfolio_str = """ +plugin "beancount.plugins.auto_accounts" +plugin "beancount.plugins.implicit_prices" + +2020-01-01 commodity CORP + name: "Example Stock" + +2020-01-01 commodity CURRENCY_BASE +2020-01-01 commodity CURRENCY_TARGET + +2020-01-01 price CURRENCY_BASE 2 CURRENCY_TARGET + +2020-01-01 * "Buy 100 CORP @ 2 CURRENCY_BASE" + Assets:Cash -200.00 CURRENCY_BASE + Assets:CORP:SingleCashFlow 100 CORP {2 CURRENCY_BASE} + +2020-01-01 * "Buy 50 CORP @ 2 CURRENCY_BASE" + Assets:Cash -100.00 CURRENCY_BASE + Assets:CORP:MultipleCashFlows 50 CORP {2 CURRENCY_BASE} + +2020-01-04 price CURRENCY_BASE 3 CURRENCY_TARGET +2020-02-01 price CORP 3 CURRENCY_BASE + +2020-01-04 price CURRENCY_BASE 4 CURRENCY_TARGET + +2020-03-01 * "Buy 50 CORP @ 3 CURRENCY_BASE" + Assets:Cash -150.00 CURRENCY_BASE + Assets:CORP:MultipleCashFlows 50 CORP {3 CURRENCY_BASE} + +2020-03-03 price CURRENCY_BASE 1.5 CURRENCY_TARGET + +2020-03-15 price CORP 1.5 CURRENCY_BASE + +2020-03-20 price CURRENCY_BASE 2 CURRENCY_TARGET + """ + p_single = load_portfolio_str( + portfolio_str, + BEANGROW_CONFIG_CORP_CASH_FLOWS, + investment_filter=["a:Assets:CORP:SingleCashFlow"], + ) + p_multiple = load_portfolio_str( + portfolio_str, + BEANGROW_CONFIG_CORP_CASH_FLOWS, + investment_filter=["a:Assets:CORP:MultipleCashFlows"], + ) + + p_single.target_currency = "CURRENCY_BASE" + returns_single_base = TWR().series(p_single, datetime.date(2020, 1, 1), datetime.date(2020, 4, 1)) + assert returns_single_base == [ + (datetime.date(2020, 1, 1), 0.0), + (datetime.date(2020, 2, 1), 0.5), + (datetime.date(2020, 3, 1), 0.5), + (datetime.date(2020, 3, 15), -0.25), + ] + + p_single.target_currency = "CURRENCY_TARGET" + returns_single_target = TWR().series(p_single, datetime.date(2020, 1, 1), datetime.date(2020, 4, 1)) + # Note more data points because the currency conversion dates are involved as well + assert returns_single_target == [ + (datetime.date(2020, 1, 1), 0.0), + (datetime.date(2020, 1, 4), 1.0), + (datetime.date(2020, 2, 1), 2.0), + (datetime.date(2020, 3, 1), 2.0), + (datetime.date(2020, 3, 3), 0.125), + (datetime.date(2020, 3, 15), -0.4375), + # Note that as the conversion rate between currencies returns to original, the TWR becomes the same as with + # CURRENCY_BASE + (datetime.date(2020, 3, 20), -0.25), + ] + + p_multiple.target_currency = "CURRENCY_BASE" + returns_multiple_base = TWR().series(p_multiple, datetime.date(2020, 1, 1), datetime.date(2020, 4, 1)) + # results should be identical to p_single.target_currency = "CURRENCY_BASE" case because of the TWR definition + assert returns_multiple_base == [ + (datetime.date(2020, 1, 1), 0.0), + (datetime.date(2020, 2, 1), 0.5), + (datetime.date(2020, 3, 1), 0.5), + (datetime.date(2020, 3, 15), -0.25), + ] + + p_multiple.target_currency = "CURRENCY_TARGET" + returns_multiple_target = TWR().series(p_multiple, datetime.date(2020, 1, 1), datetime.date(2020, 4, 1)) + # results should be identical to p_single.target_currency = "CURRENCY_TARGET" case because of the TWR definition + assert returns_multiple_target == [ + (datetime.date(2020, 1, 1), 0.0), + (datetime.date(2020, 1, 4), 1.0), + (datetime.date(2020, 2, 1), 2.0), + (datetime.date(2020, 3, 1), 2.0), + (datetime.date(2020, 3, 3), 0.125), + (datetime.date(2020, 3, 15), -0.4375), + # Note that as the conversion rate between currencies returns to original, the TWR becomes the same as with + # CURRENCY_BASE + (datetime.date(2020, 3, 20), -0.25), + ] diff --git a/src/fava_portfolio_returns/test/test.py b/src/fava_portfolio_returns/test/test.py index aa60872..0ec78d2 100644 --- a/src/fava_portfolio_returns/test/test.py +++ b/src/fava_portfolio_returns/test/test.py @@ -48,14 +48,43 @@ } """ +BEANGROW_CONFIG_CORP_CASH_FLOWS = """ +investments { + investment { + currency: "CORP" + asset_account: "Assets:CORP:SingleCashFlow" + dividend_accounts: "Income:CORP:Dividend" + cash_accounts: "Assets:Cash" + } + investment { + currency: "CORP" + asset_account: "Assets:CORP:MultipleCashFlows" + dividend_accounts: "Income:CORP:Dividend" + cash_accounts: "Assets:Cash" + } +} +groups { + group { + name: "CORP single cash flow" + investment: "Assets:CORP:SingleCashFlow" + } + group { + name: "CORP multiple cash flows" + investment: "Assets:CORP:MultipleCashFlows" + } +} +""" + -def load_portfolio_str(beancount: str, beangrow: str, target_currency="USD") -> FilteredPortfolio: +def load_portfolio_str( + beancount: str, beangrow: str, target_currency="USD", investment_filter=None +) -> FilteredPortfolio: entries, errors, options_map = loader.load_string(beancount) if errors: raise ValueError(errors) p = Portfolio(entries, options_map, beangrow) - return p.filter([], target_currency) + return p.filter(investment_filter if investment_filter else [], target_currency) def load_portfolio_file(ledger_path: str | Path, target_currency="USD", investment_filter=[]) -> FilteredPortfolio: