Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/fava_portfolio_returns/api/investments.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
16 changes: 11 additions & 5 deletions src/fava_portfolio_returns/api/portfolio.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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:
Expand All @@ -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
74 changes: 74 additions & 0 deletions src/fava_portfolio_returns/core/currencies_test.py
Original file line number Diff line number Diff line change
@@ -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),
]
2 changes: 1 addition & 1 deletion src/fava_portfolio_returns/core/portfolio.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
11 changes: 7 additions & 4 deletions src/fava_portfolio_returns/core/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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(
Expand Down Expand Up @@ -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}")
97 changes: 97 additions & 0 deletions src/fava_portfolio_returns/returns/twr_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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),
]
33 changes: 31 additions & 2 deletions src/fava_portfolio_returns/test/test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down