Skip to content
Open
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
81 changes: 40 additions & 41 deletions src/fava_portfolio_returns/api/compare.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@
logger = logging.getLogger(__name__)


class Series(NamedTuple):
name: str
data: list[tuple[datetime.date, float]]


@dataclass
class DatedSeries:
name: str
Expand All @@ -21,18 +26,32 @@ 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(
p: FilteredPortfolio, start_date: datetime.date, end_date: datetime.date, method: str, compare_with: list[str]
):
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:
Expand All @@ -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:
Expand All @@ -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
35 changes: 35 additions & 0 deletions src/fava_portfolio_returns/api/compare_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)),
],
),
]
10 changes: 10 additions & 0 deletions src/fava_portfolio_returns/test/ledger/beangrow.pbtxt
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
Original file line number Diff line number Diff line change
@@ -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
Loading