Skip to content

Commit 1649c26

Browse files
committed
Use date for market currency conversion
1 parent 0d439af commit 1649c26

File tree

7 files changed

+222
-13
lines changed

7 files changed

+222
-13
lines changed

src/fava_portfolio_returns/api/investments.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818

1919
def group_stats(p: FilteredPortfolio, start_date: datetime.date, end_date: datetime.date):
2020
balance = p.balance_at(end_date)
21-
cost_value = cost_value_of_inv(p.pricer, p.target_currency, balance)
21+
cost_value = cost_value_of_inv(p.pricer, p.target_currency, balance, end_date)
2222
market_value = market_value_of_inv(p.pricer, p.target_currency, balance, end_date, record=True)
2323
# reduce to units (i.e. removing cost attribute) after calculating market value, because convert.get_value() only works with positions held at cost
2424
# this will convert for example CORP -> USD (cost currency) -> EUR (target currency), instead of CORP -> EUR.

src/fava_portfolio_returns/api/portfolio.py

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@
1212
from fava_portfolio_returns.core.portfolio import FilteredPortfolio
1313
from fava_portfolio_returns.core.utils import cost_value_of_inv
1414
from fava_portfolio_returns.core.utils import get_prices
15-
from fava_portfolio_returns.core.utils import inv_to_currency
1615
from fava_portfolio_returns.core.utils import market_value_of_inv
1716

1817

@@ -61,6 +60,9 @@ def portfolio_values(
6160
if posting.meta["category"] is Cat.ASSET and posting.cost:
6261
# ex. (CORP, USD)
6362
currency_pairs.add((posting.units.currency, posting.cost.currency))
63+
# also add data points for indirect conversion to target currency
64+
if posting.cost.currency != p.target_currency:
65+
currency_pairs.add((posting.cost.currency, p.target_currency))
6466

6567
def first(x):
6668
return x[0]
@@ -96,7 +98,7 @@ def first(x):
9698
# Iterate computing the balance.
9799
values: list[PortfolioValue] = []
98100
balance = Inventory()
99-
cf_balance = Inventory()
101+
cf_balance_converted = Decimal(0.0)
100102
for date, group in itertools.groupby(entry_dates, key=first):
101103
# Update balances.
102104
for _, entry in group:
@@ -106,14 +108,18 @@ def first(x):
106108
if posting.meta["category"] is Cat.ASSET:
107109
balance.add_position(posting)
108110
for flow in produce_cash_flows_general(entry, ""):
109-
cf_balance.add_amount(flow.amount)
111+
# Convert flow amount to the target_currency at the date of the flow
112+
cash_amount_converted = p.pricer.convert_amount(flow.amount, p.target_currency, date)
113+
if cash_amount_converted.currency != p.target_currency:
114+
raise ValueError(f"Can't convert {cash_amount_converted.currency} to {p.target_currency} at {date}")
115+
cf_balance_converted += cash_amount_converted.number
110116

111117
if date >= first_date:
112118
# Clamp start_date in case we cut off data at the beginning.
113119
clamp_date = max(date, start_date)
114120
market = market_value_of_inv(p.pricer, p.target_currency, balance, clamp_date)
115-
cost = cost_value_of_inv(p.pricer, p.target_currency, balance)
116-
cash = -inv_to_currency(p.pricer, p.target_currency, cf_balance) # sum of cash flows
121+
cost = cost_value_of_inv(p.pricer, p.target_currency, balance, clamp_date)
122+
cash = -cf_balance_converted # sum of cash flows
117123
values.append(PortfolioValue(date=clamp_date, market=market, cost=cost, cash=cash))
118124

119125
return values
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import datetime
2+
import unittest
3+
4+
from fava_portfolio_returns.returns.simple import SimpleReturns
5+
from fava_portfolio_returns.test.test import BEANGROW_CONFIG_CORP
6+
from fava_portfolio_returns.test.test import load_portfolio_str
7+
8+
9+
class CurrenciesTest(unittest.TestCase):
10+
def test_indirect_currency_conversion(self):
11+
p = load_portfolio_str(
12+
"""
13+
plugin "beancount.plugins.auto_accounts"
14+
plugin "beancount.plugins.implicit_prices"
15+
16+
2020-01-01 commodity CORP
17+
name: "Example Stock"
18+
19+
2020-01-01 commodity CURRENCY_BASE
20+
2020-01-01 commodity CURRENCY_TARGET
21+
22+
2020-01-01 price CURRENCY_BASE 2 CURRENCY_TARGET
23+
24+
2020-01-01 * "Buy 50 CORP @ 2 CURRENCY_BASE"
25+
Assets:Cash -100.00 CURRENCY_BASE
26+
Assets:CORP 50 CORP {2 CURRENCY_BASE}
27+
28+
2020-01-04 price CURRENCY_BASE 3 CURRENCY_TARGET
29+
30+
2020-02-01 price CORP 3 CURRENCY_BASE
31+
2020-03-03 price CURRENCY_BASE 5 CURRENCY_TARGET
32+
""",
33+
BEANGROW_CONFIG_CORP,
34+
)
35+
36+
p.target_currency = "CURRENCY_BASE"
37+
38+
assert p.cash_at(datetime.date(2020, 1, 1)) == 100
39+
assert p.cash_at(datetime.date(2020, 1, 5)) == 100
40+
assert p.cash_at(datetime.date(2020, 2, 1)) == 100
41+
assert p.cash_at(datetime.date(2020, 3, 3)) == 100
42+
43+
returns_base = SimpleReturns().series(p, datetime.date(2020, 1, 1), datetime.date(2020, 4, 1))
44+
assert returns_base == [
45+
(datetime.date(2020, 1, 1), 0.0),
46+
# investment has grown
47+
(datetime.date(2020, 2, 1), 0.5),
48+
]
49+
50+
p.target_currency = "CURRENCY_TARGET"
51+
52+
# Cost basis of 100 CURRENCY_BASE = 200 CURRENCY_TARGET
53+
assert p.cash_at(datetime.date(2020, 1, 1)) == 200
54+
# Cost basis of 100 CURRENCY_BASE = 300 CURRENCY_TARGET
55+
assert p.cash_at(datetime.date(2020, 1, 5)) == 300
56+
# Cost basis of 100 CURRENCY_BASE = 300 CURRENCY_TARGET
57+
assert p.cash_at(datetime.date(2020, 2, 1)) == 300
58+
# Cost basis of 100 CURRENCY_BASE = 500 CURRENCY_TARGET
59+
assert p.cash_at(datetime.date(2020, 3, 3)) == 500
60+
61+
returns_target = SimpleReturns().series(p, datetime.date(2020, 1, 1), datetime.date(2020, 4, 1))
62+
# Note more data points since the currency rates are now affecting how the portfolio evaluations are done
63+
assert returns_target == [
64+
# 200 CURRENCY_TARGET = 100 CURRENCY_BASE invested
65+
(datetime.date(2020, 1, 1), 0.0),
66+
# currency rate changing doesn't change the cost basis
67+
# (300 CURRENCY_TARGET invested)
68+
(datetime.date(2020, 1, 4), 0.0),
69+
# investment has acrually grown
70+
# (300 CURRENCY_TARGET invested, 50 CORP valued at 150 CURRENCY_BASE = 450 CURRENCY_TARGET)
71+
(datetime.date(2020, 2, 1), 0.5),
72+
# currency rate changing again doesn't change the cost basis (500 CURRENCY_TARGET invested)
73+
(datetime.date(2020, 3, 3), 0.5),
74+
]

src/fava_portfolio_returns/core/portfolio.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -176,7 +176,7 @@ def cash_at(self, date: datetime.date) -> Decimal:
176176
if cash_flow.date > date:
177177
break
178178
balance.add_amount(cash_flow.amount)
179-
return -inv_to_currency(self.pricer, self.target_currency, balance)
179+
return -inv_to_currency(self.pricer, self.target_currency, balance, date)
180180

181181

182182
def get_target_currency(account_data_list: list[AccountData]) -> str:

src/fava_portfolio_returns/core/utils.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,9 @@ def __init__(self, source: str, target: str, date: Optional[datetime.date]):
2020
)
2121

2222

23-
def cost_value_of_inv(pricer: Pricer, target_currency: str, balance: Inventory) -> Decimal:
23+
def cost_value_of_inv(pricer: Pricer, target_currency: str, balance: Inventory, date: datetime.date) -> Decimal:
2424
cost_balance = balance.reduce(convert.get_cost)
25-
return inv_to_currency(pricer, target_currency, cost_balance)
25+
return inv_to_currency(pricer, target_currency, cost_balance, date)
2626

2727

2828
def market_value_of_inv(
@@ -36,7 +36,7 @@ def market_value_of_inv(
3636
value_balance = balance.reduce(convert.get_value, pricer.price_map, date)
3737

3838
# then convert to target currency
39-
return inv_to_currency(pricer, target_currency, value_balance)
39+
return inv_to_currency(pricer, target_currency, value_balance, date)
4040

4141

4242
def inv_to_currency(
@@ -71,4 +71,7 @@ def get_prices(pricer: Pricer, pair: tuple[str, str]) -> list[tuple[datetime.dat
7171
"""
7272
:param tuple pair: (currency, target_currency), e.g. (CORP, USD)
7373
"""
74-
return prices.get_all_prices(pricer.price_map, pair)
74+
try:
75+
return prices.get_all_prices(pricer.price_map, pair)
76+
except Exception:
77+
raise ValueError(f"Missing prices for currency pair {pair}")

src/fava_portfolio_returns/returns/twr_test.py

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import unittest
33

44
from fava_portfolio_returns.returns.twr import TWR
5+
from fava_portfolio_returns.test.test import BEANGROW_CONFIG_CORP_CASH_FLOWS
56
from fava_portfolio_returns.test.test import BEANGROW_CONFIG_CORPAB
67
from fava_portfolio_returns.test.test import approx2
78
from fava_portfolio_returns.test.test import load_portfolio_file
@@ -54,3 +55,99 @@ def test_series_savings_plan(self):
5455
(datetime.date(2020, 3, 1), 1.0),
5556
(datetime.date(2020, 4, 1), 1.5),
5657
]
58+
59+
def test_twr_with_changing_target_currency(self):
60+
portfolio_str = """
61+
plugin "beancount.plugins.auto_accounts"
62+
plugin "beancount.plugins.implicit_prices"
63+
64+
2020-01-01 commodity CORP
65+
name: "Example Stock"
66+
67+
2020-01-01 commodity CURRENCY_BASE
68+
2020-01-01 commodity CURRENCY_TARGET
69+
70+
2020-01-01 price CURRENCY_BASE 2 CURRENCY_TARGET
71+
72+
2020-01-01 * "Buy 100 CORP @ 2 CURRENCY_BASE"
73+
Assets:Cash -200.00 CURRENCY_BASE
74+
Assets:CORP:SingleCashFlow 100 CORP {2 CURRENCY_BASE}
75+
76+
2020-01-01 * "Buy 50 CORP @ 2 CURRENCY_BASE"
77+
Assets:Cash -100.00 CURRENCY_BASE
78+
Assets:CORP:MultipleCashFlows 50 CORP {2 CURRENCY_BASE}
79+
80+
2020-01-04 price CURRENCY_BASE 3 CURRENCY_TARGET
81+
2020-02-01 price CORP 3 CURRENCY_BASE
82+
83+
2020-01-04 price CURRENCY_BASE 4 CURRENCY_TARGET
84+
85+
2020-03-01 * "Buy 50 CORP @ 3 CURRENCY_BASE"
86+
Assets:Cash -150.00 CURRENCY_BASE
87+
Assets:CORP:MultipleCashFlows 50 CORP {3 CURRENCY_BASE}
88+
89+
2020-03-03 price CURRENCY_BASE 1.5 CURRENCY_TARGET
90+
91+
2020-03-15 price CORP 1.5 CURRENCY_BASE
92+
93+
2020-03-20 price CURRENCY_BASE 2 CURRENCY_TARGET
94+
"""
95+
p_single = load_portfolio_str(
96+
portfolio_str,
97+
BEANGROW_CONFIG_CORP_CASH_FLOWS,
98+
investment_filter=["a:Assets:CORP:SingleCashFlow"],
99+
)
100+
p_multiple = load_portfolio_str(
101+
portfolio_str,
102+
BEANGROW_CONFIG_CORP_CASH_FLOWS,
103+
investment_filter=["a:Assets:CORP:MultipleCashFlows"],
104+
)
105+
106+
p_single.target_currency = "CURRENCY_BASE"
107+
returns_single_base = TWR().series(p_single, datetime.date(2020, 1, 1), datetime.date(2020, 4, 1))
108+
assert returns_single_base == [
109+
(datetime.date(2020, 1, 1), 0.0),
110+
(datetime.date(2020, 2, 1), 0.5),
111+
(datetime.date(2020, 3, 1), 0.5),
112+
(datetime.date(2020, 3, 15), -0.25),
113+
]
114+
115+
p_single.target_currency = "CURRENCY_TARGET"
116+
returns_single_target = TWR().series(p_single, datetime.date(2020, 1, 1), datetime.date(2020, 4, 1))
117+
# Note more data points because the currency conversion dates are involved as well
118+
assert returns_single_target == [
119+
(datetime.date(2020, 1, 1), 0.0),
120+
(datetime.date(2020, 1, 4), 1.0),
121+
(datetime.date(2020, 2, 1), 2.0),
122+
(datetime.date(2020, 3, 1), 2.0),
123+
(datetime.date(2020, 3, 3), 0.125),
124+
(datetime.date(2020, 3, 15), -0.4375),
125+
# Note that as the conversion rate between currencies returns to original, the TWR becomes the same as with
126+
# CURRENCY_BASE
127+
(datetime.date(2020, 3, 20), -0.25),
128+
]
129+
130+
p_multiple.target_currency = "CURRENCY_BASE"
131+
returns_multiple_base = TWR().series(p_multiple, datetime.date(2020, 1, 1), datetime.date(2020, 4, 1))
132+
# results should be identical to p_single.target_currency = "CURRENCY_BASE" case because of the TWR definition
133+
assert returns_multiple_base == [
134+
(datetime.date(2020, 1, 1), 0.0),
135+
(datetime.date(2020, 2, 1), 0.5),
136+
(datetime.date(2020, 3, 1), 0.5),
137+
(datetime.date(2020, 3, 15), -0.25),
138+
]
139+
140+
p_multiple.target_currency = "CURRENCY_TARGET"
141+
returns_multiple_target = TWR().series(p_multiple, datetime.date(2020, 1, 1), datetime.date(2020, 4, 1))
142+
# results should be identical to p_single.target_currency = "CURRENCY_TARGET" case because of the TWR definition
143+
assert returns_multiple_target == [
144+
(datetime.date(2020, 1, 1), 0.0),
145+
(datetime.date(2020, 1, 4), 1.0),
146+
(datetime.date(2020, 2, 1), 2.0),
147+
(datetime.date(2020, 3, 1), 2.0),
148+
(datetime.date(2020, 3, 3), 0.125),
149+
(datetime.date(2020, 3, 15), -0.4375),
150+
# Note that as the conversion rate between currencies returns to original, the TWR becomes the same as with
151+
# CURRENCY_BASE
152+
(datetime.date(2020, 3, 20), -0.25),
153+
]

src/fava_portfolio_returns/test/test.py

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,14 +48,43 @@
4848
}
4949
"""
5050

51+
BEANGROW_CONFIG_CORP_CASH_FLOWS = """
52+
investments {
53+
investment {
54+
currency: "CORP"
55+
asset_account: "Assets:CORP:SingleCashFlow"
56+
dividend_accounts: "Income:CORP:Dividend"
57+
cash_accounts: "Assets:Cash"
58+
}
59+
investment {
60+
currency: "CORP"
61+
asset_account: "Assets:CORP:MultipleCashFlows"
62+
dividend_accounts: "Income:CORP:Dividend"
63+
cash_accounts: "Assets:Cash"
64+
}
65+
}
66+
groups {
67+
group {
68+
name: "CORP single cash flow"
69+
investment: "Assets:CORP:SingleCashFlow"
70+
}
71+
group {
72+
name: "CORP multiple cash flows"
73+
investment: "Assets:CORP:MultipleCashFlows"
74+
}
75+
}
76+
"""
77+
5178

52-
def load_portfolio_str(beancount: str, beangrow: str, target_currency="USD") -> FilteredPortfolio:
79+
def load_portfolio_str(
80+
beancount: str, beangrow: str, target_currency="USD", investment_filter=None
81+
) -> FilteredPortfolio:
5382
entries, errors, options_map = loader.load_string(beancount)
5483
if errors:
5584
raise ValueError(errors)
5685

5786
p = Portfolio(entries, options_map, beangrow)
58-
return p.filter([], target_currency)
87+
return p.filter(investment_filter if investment_filter else [], target_currency)
5988

6089

6190
def load_portfolio_file(ledger_path: str | Path, target_currency="USD", investment_filter=[]) -> FilteredPortfolio:

0 commit comments

Comments
 (0)