From 359e5880cc886b0ded0c9ddeaa5a4c275cca7bdb Mon Sep 17 00:00:00 2001 From: Herculino Trotta Date: Thu, 5 Nov 2026 00:14:42 -0300 Subject: [PATCH] feat(investments): add investment tracker Investments are accounts flagged as investments; performance is derived from their transactions. Linked transfers are contributions and withdrawals (average cost, realized gains), unlinked transactions are gains or losses, and income comes from the accounts' income entities. - Unit-priced (exchange currency) or value-tracked accounts - Money-weighted return (XIRR), annualized after a year - Totals converted along each currency's exchange chain, with currency effect - Investments list and detail pages; buy/sell/income use the transfer and transaction forms - GET /api/accounts//performance/ --- app/WYGIWYH/settings.py | 1 + app/WYGIWYH/urls.py | 1 + app/apps/accounts/forms.py | 30 +- ...unt_income_entity_account_is_investment.py | 25 ++ .../migrations/0018_income_entities.py | 43 ++ app/apps/accounts/models.py | 19 + app/apps/api/serializers/accounts.py | 45 ++ app/apps/api/views/accounts.py | 16 + app/apps/investments/__init__.py | 0 app/apps/investments/apps.py | 6 + app/apps/investments/services.py | 391 ++++++++++++++++++ app/apps/investments/tests/__init__.py | 0 app/apps/investments/tests/test_services.py | 243 +++++++++++ app/apps/investments/urls.py | 18 + app/apps/investments/views.py | 106 +++++ app/templates/includes/sidebar.html | 6 + app/templates/investments/fragments/_mwr.html | 1 + .../investments/fragments/_percentage.html | 1 + .../investments/fragments/_totals.html | 38 ++ .../investments/fragments/detail.html | 270 ++++++++++++ app/templates/investments/fragments/list.html | 93 +++++ app/templates/investments/pages/detail.html | 8 + app/templates/investments/pages/index.html | 8 + 23 files changed, 1366 insertions(+), 3 deletions(-) create mode 100644 app/apps/accounts/migrations/0017_account_income_entity_account_is_investment.py create mode 100644 app/apps/accounts/migrations/0018_income_entities.py create mode 100644 app/apps/investments/__init__.py create mode 100644 app/apps/investments/apps.py create mode 100644 app/apps/investments/services.py create mode 100644 app/apps/investments/tests/__init__.py create mode 100644 app/apps/investments/tests/test_services.py create mode 100644 app/apps/investments/urls.py create mode 100644 app/apps/investments/views.py create mode 100644 app/templates/investments/fragments/_mwr.html create mode 100644 app/templates/investments/fragments/_percentage.html create mode 100644 app/templates/investments/fragments/_totals.html create mode 100644 app/templates/investments/fragments/detail.html create mode 100644 app/templates/investments/fragments/list.html create mode 100644 app/templates/investments/pages/detail.html create mode 100644 app/templates/investments/pages/index.html diff --git a/app/WYGIWYH/settings.py b/app/WYGIWYH/settings.py index f7756b73..24393480 100644 --- a/app/WYGIWYH/settings.py +++ b/app/WYGIWYH/settings.py @@ -77,6 +77,7 @@ INSTALLED_APPS = [ "apps.rules.apps.RulesConfig", "apps.calendar_view.apps.CalendarViewConfig", "apps.dca.apps.DcaConfig", + "apps.investments.apps.InvestmentsConfig", "pwa", "allauth", "allauth.account", diff --git a/app/WYGIWYH/urls.py b/app/WYGIWYH/urls.py index 0a442852..5726903d 100644 --- a/app/WYGIWYH/urls.py +++ b/app/WYGIWYH/urls.py @@ -93,6 +93,7 @@ urlpatterns = [ path("", include("apps.rules.urls")), path("", include("apps.calendar_view.urls")), path("", include("apps.dca.urls")), + path("", include("apps.investments.urls")), path("", include("apps.mini_tools.urls")), path("", include("apps.import_app.urls")), path("", include("apps.export_app.urls")), diff --git a/app/apps/accounts/forms.py b/app/apps/accounts/forms.py index 59030301..d445d6ce 100644 --- a/app/apps/accounts/forms.py +++ b/app/apps/accounts/forms.py @@ -9,12 +9,16 @@ from apps.common.widgets.crispy.daisyui import Switch from apps.common.widgets.crispy.submit import NoClassSubmit from apps.common.widgets.datepicker import AirDatePickerInput, AirMonthYearPickerInput from apps.common.widgets.decimal import ArbitraryDecimalDisplayNumberInput -from apps.common.widgets.tom_select import TomSelect +from apps.common.widgets.tom_select import TomSelect, TomSelectMultiple from apps.currencies.models import Currency -from apps.transactions.models import TransactionCategory, TransactionTag +from apps.transactions.models import ( + TransactionCategory, + TransactionEntity, + TransactionTag, +) from crispy_forms.bootstrap import FormActions from crispy_forms.helper import FormHelper -from crispy_forms.layout import Column, Field, Layout, Row +from crispy_forms.layout import HTML, Column, Field, Layout, Row from django import forms from django.db.models import Q from django.utils import timezone @@ -68,16 +72,25 @@ class AccountForm(forms.ModelForm): "exchange_currency", "is_asset", "is_archived", + "is_investment", + "income_entities", ] widgets = { "currency": TomSelect(), "exchange_currency": TomSelect(), + "income_entities": TomSelectMultiple(), } def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.fields["group"].queryset = AccountGroup.objects.all() + entities = Q(active=True) + if self.instance.id: + entities |= Q(investment_accounts=self.instance.id) + self.fields["income_entities"].queryset = TransactionEntity.objects.filter( + entities + ).distinct() if self.instance.id: qs = Currency.objects.filter( @@ -101,6 +114,17 @@ class AccountForm(forms.ModelForm): Switch("is_archived"), "currency", "exchange_currency", + Switch("is_investment"), + # Only relevant for investments: shown while the switch above is on + Field( + "income_entities", + wrapper_class="" if self.instance.is_investment else "hidden", + ), + HTML( + '' + ), ) if self.instance and self.instance.pk: diff --git a/app/apps/accounts/migrations/0017_account_income_entity_account_is_investment.py b/app/apps/accounts/migrations/0017_account_income_entity_account_is_investment.py new file mode 100644 index 00000000..56649971 --- /dev/null +++ b/app/apps/accounts/migrations/0017_account_income_entity_account_is_investment.py @@ -0,0 +1,25 @@ +# Generated by Django 5.2.17 on 2026-11-05 01:12 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0016_account_untracked_by'), + ('transactions', '0052_transfer_templates'), + ] + + operations = [ + migrations.AddField( + model_name='account', + name='income_entity', + field=models.ForeignKey(blank=True, help_text="Income transactions with this entity (dividends, interest, rent) count towards this investment's return.", null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='investment_accounts', to='transactions.transactionentity', verbose_name='Income entity'), + ), + migrations.AddField( + model_name='account', + name='is_investment', + field=models.BooleanField(default=False, help_text='Show this account in the Investments tracker. Set an exchange currency to price it by units (stocks, ETFs, crypto); leave it empty to track it by value (fixed income, property, pensions).', verbose_name='Investment'), + ), + ] diff --git a/app/apps/accounts/migrations/0018_income_entities.py b/app/apps/accounts/migrations/0018_income_entities.py new file mode 100644 index 00000000..fa4c91e7 --- /dev/null +++ b/app/apps/accounts/migrations/0018_income_entities.py @@ -0,0 +1,43 @@ +import django.db.models.deletion +from django.db import migrations, models + + +def copy_income_entity(apps, schema_editor): + Account = apps.get_model("accounts", "Account") + for account in Account.objects.exclude(income_entity=None): + account.income_entities.add(account.income_entity_id) + + +class Migration(migrations.Migration): + dependencies = [ + ("accounts", "0017_account_income_entity_account_is_investment"), + ("transactions", "0052_transfer_templates"), + ] + + operations = [ + # Free the "investment_accounts" reverse name for the new field + migrations.AlterField( + model_name="account", + name="income_entity", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to="transactions.transactionentity", + ), + ), + migrations.AddField( + model_name="account", + name="income_entities", + field=models.ManyToManyField( + blank=True, + help_text="Income transactions with any of these entities (dividends, interest, rent) count towards this investment's return.", + related_name="investment_accounts", + to="transactions.transactionentity", + verbose_name="Income entities", + ), + ), + migrations.RunPython(copy_income_entity, migrations.RunPython.noop), + migrations.RemoveField(model_name="account", name="income_entity"), + ] diff --git a/app/apps/accounts/models.py b/app/apps/accounts/models.py index 55c064c0..4f676b29 100644 --- a/app/apps/accounts/models.py +++ b/app/apps/accounts/models.py @@ -57,6 +57,25 @@ class Account(SharedObject): "Asset accounts count towards your Net Worth, but not towards your month." ), ) + is_investment = models.BooleanField( + default=False, + verbose_name=_("Investment"), + help_text=_( + "Show this account in the Investments tracker. Set an exchange currency to " + "price it by units (stocks, ETFs, crypto); leave it empty to track it by " + "value (fixed income, property, pensions)." + ), + ) + income_entities = models.ManyToManyField( + "transactions.TransactionEntity", + blank=True, + related_name="investment_accounts", + verbose_name=_("Income entities"), + help_text=_( + "Income transactions with any of these entities (dividends, interest, " + "rent) count towards this investment's return." + ), + ) is_archived = models.BooleanField( default=False, verbose_name=_("Archived"), diff --git a/app/apps/api/serializers/accounts.py b/app/apps/api/serializers/accounts.py index db95f256..0506982d 100644 --- a/app/apps/api/serializers/accounts.py +++ b/app/apps/api/serializers/accounts.py @@ -1,3 +1,5 @@ +from decimal import Decimal + from django.db.models import Q from rest_framework import serializers from rest_framework.permissions import IsAuthenticated @@ -50,6 +52,8 @@ class AccountSerializer(serializers.ModelSerializer): "exchange_currency", "exchange_currency_id", "is_asset", + "is_investment", + "income_entities", ] def __init__(self, *args, **kwargs): @@ -76,3 +80,44 @@ class AccountBalanceSerializer(serializers.Serializer): projected_balance = serializers.DecimalField(max_digits=20, decimal_places=10) currency = CurrencySerializer() + + +class InvestmentPerformanceSerializer(serializers.Serializer): + """Serializer for an investment account's performance.""" + + currency = CurrencySerializer() + priced = serializers.BooleanField() + quantity = serializers.DecimalField(max_digits=42, decimal_places=30) + price = serializers.DecimalField(max_digits=42, decimal_places=30, allow_null=True) + price_date = serializers.DateTimeField(allow_null=True) + average_cost = serializers.DecimalField( + max_digits=42, decimal_places=30, allow_null=True + ) + cost_basis = serializers.DecimalField(max_digits=42, decimal_places=30) + market_value = serializers.DecimalField( + max_digits=42, decimal_places=30, allow_null=True + ) + unrealized = serializers.DecimalField( + max_digits=42, decimal_places=30, allow_null=True + ) + realized = serializers.DecimalField(max_digits=42, decimal_places=30) + income = serializers.DecimalField(max_digits=42, decimal_places=30) + contributed = serializers.DecimalField(max_digits=42, decimal_places=30) + withdrawn = serializers.DecimalField(max_digits=42, decimal_places=30) + total_return = serializers.DecimalField(max_digits=42, decimal_places=30) + return_percentage = serializers.DecimalField( + max_digits=42, decimal_places=30, allow_null=True + ) + estimated = serializers.BooleanField() + money_weighted_return = serializers.SerializerMethodField( + help_text="Return weighted by when money went in and out, in percent: " + "annual (XIRR) when the first contribution is a year old or more, " + "otherwise over the period held." + ) + money_weighted_annualized = serializers.SerializerMethodField() + + def get_money_weighted_return(self, perf) -> Decimal | None: + return perf.money_weighted and perf.money_weighted["percentage"] + + def get_money_weighted_annualized(self, perf) -> bool | None: + return perf.money_weighted and perf.money_weighted["annualized"] diff --git a/app/apps/api/views/accounts.py b/app/apps/api/views/accounts.py index f7e44e1b..a62da741 100644 --- a/app/apps/api/views/accounts.py +++ b/app/apps/api/views/accounts.py @@ -11,7 +11,9 @@ from apps.api.serializers import ( AccountGroupSerializer, AccountSerializer, AccountBalanceSerializer, + InvestmentPerformanceSerializer, ) +from apps.investments.services import get_holding_performance class AccountGroupViewSet(viewsets.ModelViewSet): @@ -38,6 +40,11 @@ class AccountGroupViewSet(viewsets.ModelViewSet): description="Returns the current and projected balance for the account, along with currency data.", responses={200: AccountBalanceSerializer}, ), + performance=extend_schema( + summary="Get investment performance", + description="Returns quantity, cost basis, market value, realized/unrealized gains, income and total return, in the account's exchange currency (or its own currency when it has none).", + responses={200: InvestmentPerformanceSerializer}, + ), ) class AccountViewSet(viewsets.ModelViewSet): """ViewSet for managing accounts.""" @@ -51,6 +58,7 @@ class AccountViewSet(viewsets.ModelViewSet): "currency": ["exact"], "exchange_currency": ["exact", "isnull"], "is_asset": ["exact"], + "is_investment": ["exact"], "is_archived": ["exact"], "owner": ["exact"], } @@ -80,3 +88,11 @@ class AccountViewSet(viewsets.ModelViewSet): ) return Response(serializer.data) + + @action(detail=True, methods=["get"], permission_classes=[IsAuthenticated]) + def performance(self, request, pk=None): + """Get investment performance (cost basis, value, returns) for an account.""" + account = self.get_object() + return Response( + InvestmentPerformanceSerializer(get_holding_performance(account)).data + ) diff --git a/app/apps/investments/__init__.py b/app/apps/investments/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/app/apps/investments/apps.py b/app/apps/investments/apps.py new file mode 100644 index 00000000..4b50cd38 --- /dev/null +++ b/app/apps/investments/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class InvestmentsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.investments" diff --git a/app/apps/investments/services.py b/app/apps/investments/services.py new file mode 100644 index 00000000..45d411b4 --- /dev/null +++ b/app/apps/investments/services.py @@ -0,0 +1,391 @@ +"""Investment performance, derived from an account's transactions. + +Rules (see the account's "Investment" help text): +- A linked transaction (a transfer) moves money in or out. Its other side says + what was paid or received, which is the cost or the proceeds. +- An unlinked transaction is a gain or a loss: a balance reconciliation, interest, + a tax withheld. On a unit-priced account an unlinked inflow (a gift, an opening + balance) has no known cost, so it is costed at the market price of that day + and the result is flagged as estimated. +- Cost is tracked with the average cost method. +""" + +import datetime +from dataclasses import dataclass, field +from decimal import Decimal +from functools import cached_property + +from django.db.models import Q +from django.utils import timezone + +from apps.currencies.models import ExchangeRate +from apps.currencies.utils.convert import convert, get_exchange_rate +from apps.transactions.models import Transaction + +ZERO = Decimal("0") + + +@dataclass +class HoldingPerformance: + account: object + currency: object # valuation currency + priced: bool # True: units x price. False: tracked by value. + quantity: Decimal = ZERO + cost_basis: Decimal = ZERO + price: Decimal | None = None + price_date: datetime.datetime | None = None + realized: Decimal = ZERO + income: Decimal = ZERO + contributed: Decimal = ZERO + withdrawn: Decimal = ZERO + estimated: bool = False + history: list = field(default_factory=list) + # (date, amount, kind) in the valuation currency: negative = money in ("in"), + # positive = money out ("out" sales/withdrawals, "income"). Used for + # money-weighted returns and currency conversion. + flows: list = field(default_factory=list) + as_of: datetime.date | None = None + + @cached_property + def money_weighted(self): + return money_weighted_return(self.flows, self.market_value, self.as_of) + + @property + def average_cost(self): + return self.cost_basis / self.quantity if self.quantity else None + + @property + def market_value(self): + if self.price is None: + return None + return self.quantity * self.price + + @property + def unrealized(self): + value = self.market_value + return None if value is None else value - self.cost_basis + + @property + def total_return(self): + return (self.unrealized or ZERO) + self.realized + self.income + + @property + def return_percentage(self): + if not self.contributed: + return None + return self.total_return / self.contributed * 100 + + @property + def unrealized_percentage(self): + if not self.cost_basis or self.unrealized is None: + return None + return self.unrealized / self.cost_basis * 100 + + +def _to(amount, from_currency, to_currency, date): + if from_currency == to_currency or not amount: + return amount + return convert(amount, from_currency, to_currency, date)[0] + + +def _price(account, valuation_currency, date): + if account.currency == valuation_currency: + return Decimal("1"), None + rate = get_exchange_rate(account.currency, valuation_currency, date) + if rate is None: + return None, None + return rate.effective_rate, rate.date + + +def income_entity_ids(account): + # The join table, not account.income_entities: that goes through the + # entities' per-user manager and comes back empty without a current user + return list( + account.income_entities.through.objects.filter(account=account).values_list( + "transactionentity_id", flat=True + ) + ) + + +def get_holding_performance(account, as_of=None, with_history=False): + """Performance of an investment account up to ``as_of`` (today by default), + in the account's exchange currency, or its own currency if it has none.""" + today = timezone.localdate() + as_of = as_of or today + valuation_currency = account.exchange_currency or account.currency + perf = HoldingPerformance( + account=account, + currency=valuation_currency, + priced=account.exchange_currency_id is not None, + ) + + transactions = ( + Transaction.objects.filter(account=account, is_paid=True, date__lte=as_of) + .select_related("linked_transaction__account__currency") + .order_by("date", "id") + ) + + month_ends = _month_ends(transactions, as_of) if with_history else [] + + for tx in transactions: + while month_ends and month_ends[0] < tx.date: + perf.history.append(_snapshot(perf, account, month_ends.pop(0))) + + # shortcut: only the forward link is read; WYGIWYH transfers link both ways. + partner = tx.linked_transaction + if partner and (partner.account_id == account.id or partner.deleted): + partner = None + partner_value = ( + _to(partner.amount, partner.account.currency, valuation_currency, tx.date) + if partner + else None + ) + + if tx.type == Transaction.Type.INCOME: + if partner_value is not None: + cost = partner_value + perf.contributed += cost + elif perf.priced or partner: + # No known cost: price it at that day's market value + cost = _to(tx.amount, account.currency, valuation_currency, tx.date) + perf.estimated = True + if partner: + perf.contributed += cost or ZERO + else: + cost = ZERO # value-tracked: an unlinked inflow is a gain + if cost: + perf.flows.append((tx.date, -cost, "in")) + perf.quantity += tx.amount + perf.cost_basis += cost or ZERO + else: + if perf.quantity > 0: + removed = perf.cost_basis * min(tx.amount, perf.quantity) / perf.quantity + else: + removed = ZERO + if partner: + proceeds = partner_value + if proceeds is None: + proceeds = _to(tx.amount, account.currency, valuation_currency, tx.date) or ZERO + perf.estimated = True + perf.realized += proceeds - removed + perf.withdrawn += proceeds + perf.flows.append((tx.date, proceeds, "out")) + perf.cost_basis -= removed + # unlinked outflow: a loss, tax or fee; cost stays, so value drops + perf.quantity -= tx.amount + if perf.quantity <= 0 and perf.cost_basis: + perf.realized -= perf.cost_basis + perf.cost_basis = ZERO + + for month_end in month_ends: + perf.history.append(_snapshot(perf, account, month_end)) + + perf.price, perf.price_date = _price( + account, + valuation_currency, + timezone.now() if as_of == today else as_of, + ) + + entity_ids = income_entity_ids(account) + if entity_ids: + income = Transaction.objects.filter( + entities__in=entity_ids, + type=Transaction.Type.INCOME, + is_paid=True, + date__lte=as_of, + ).exclude(account=account).distinct().select_related("account__currency") + for tx in income: + amount = _to(tx.amount, tx.account.currency, valuation_currency, tx.date) + if amount: + perf.income += amount + perf.flows.append((tx.date, amount, "income")) + + perf.as_of = as_of + return perf + + +def xirr(flows): + """Annual rate r where the dated flows' present value is zero, as a float + (0.138 = 13.8% a year). None when it can't be solved.""" + if not flows: + return None + start = min(flow[0] for flow in flows) + points = [((flow[0] - start).days / 365, float(flow[1])) for flow in flows] + if not (any(a < 0 for _, a in points) and any(a > 0 for _, a in points)): + return None + + def npv(rate): + return sum(a / (1 + rate) ** t for t, a in points) + + # Bisection: slower than Newton but can't diverge on odd cash flows + low, high = -0.9999, 100.0 + npv_low = npv(low) + if npv_low * npv(high) > 0: + return None + for _ in range(200): + mid = (low + high) / 2 + npv_mid = npv(mid) + if npv_low * npv_mid <= 0: + high = mid + else: + low, npv_low = mid, npv_mid + return (low + high) / 2 + + +def money_weighted_return(flows, market_value, as_of): + """Return that accounts for when money went in and out, as + ``{"percentage": Decimal, "annualized": bool}`` or None. + + Over a year or more it is annualized (XIRR). Below a year an annual rate + exaggerates, so it is turned back into the return over the period held.""" + if not flows or market_value is None: + return None + rate = xirr(flows + [(as_of, market_value)]) + if rate is None: + return None + days = (as_of - min(flow[0] for flow in flows)).days + if days >= 365: + return {"percentage": Decimal(str(round(rate * 100, 4))), "annualized": True} + period = (1 + rate) ** (days / 365) - 1 + return {"percentage": Decimal(str(round(period * 100, 4))), "annualized": False} + + +def _month_ends(transactions, as_of): + first = transactions.values_list("date", flat=True).first() + if not first: + return [] + ends = [] + year, month = first.year, first.month + while True: + next_first = datetime.date(year + month // 12, month % 12 + 1, 1) + end = next_first - datetime.timedelta(days=1) + if end >= as_of: + ends.append(as_of) + return ends + ends.append(end) + year, month = next_first.year, next_first.month + + +def _snapshot(perf, account, date): + price, _ = _price(account, perf.currency, date) + return { + "date": date, + "invested": perf.cost_basis, + "value": perf.quantity * price if price is not None else None, + } + + +def conversion_path(currency): + """``currency`` followed by its exchange currencies (USD -> BRL -> ...), for as + long as an exchange rate between each step exists.""" + path = [currency] + while True: + target = path[-1].exchange_currency + if ( + target is None + or target in path + or get_exchange_rate(path[-1], target, timezone.now()) is None + ): + return path + path.append(target) + + +def _along(amount, path, date): + for from_currency, to_currency in zip(path, path[1:]): + amount = _to(amount, from_currency, to_currency, date) + return amount + + +def _native(perf): + return { + "currency": perf.currency, + "market_value": perf.market_value, + "cost_basis": perf.cost_basis, + "income": perf.income, + "total_return": perf.total_return, + "currency_effect": ZERO, + "flows": perf.flows, + } + + +def in_home_currency(perf): + """The holding converted along its currency's exchange chain, each cash flow + at its own date's rate, so currency moves count towards the return. + None when its currency converts to nothing.""" + path = conversion_path(perf.currency) + if len(path) == 1: + return None + flows = [(d, _along(a, path, d), kind) for d, a, kind in perf.flows] + value = None + if perf.market_value is not None: + value = _along(perf.market_value, path, perf.as_of) + total_return = (value or ZERO) + sum((a for _, a, _ in flows), ZERO) + + put_in = -sum((a for _, a, k in flows if k == "in"), ZERO) + put_in_native = -sum((a for _, a, k in perf.flows if k == "in"), ZERO) + # shortcut: what you still hold is valued at the average rate you paid in, + # not lot by lot; exact for buy-and-hold, approximate after partial sales. + cost_basis = ( + perf.cost_basis * put_in / put_in_native + if put_in_native + else _along(perf.cost_basis, path, perf.as_of) + ) + return { + "currency": path[-1], + "path": path, + "market_value": value, + "cost_basis": cost_basis, + "income": sum((a for _, a, k in flows if k == "income"), ZERO), + "total_return": total_return, + # what the return gained or lost from the exchange rate moving + "currency_effect": total_return + - _along(perf.total_return, path, perf.as_of), + # With a single rate every date converts the same, so the effect reads 0 + "has_rate_history": all( + ExchangeRate.objects.filter( + Q(from_currency=a, to_currency=b) | Q(from_currency=b, to_currency=a) + )[:2].count() + > 1 + for a, b in zip(path, path[1:]) + ), + "flows": flows, + "money_weighted": money_weighted_return(flows, value, perf.as_of), + } + + +def summarize(performances, convert=False): + """Totals per currency. With ``convert``, holdings are first converted along + their exchange chain (see in_home_currency); the ones that can't be stay in + their own currency.""" + totals = {} + for perf in performances: + figures = (convert and in_home_currency(perf)) or _native(perf) + total = totals.setdefault( + figures["currency"].id, + { + "currency": figures["currency"], + "market_value": ZERO, + "cost_basis": ZERO, + "total_return": ZERO, + "income": ZERO, + "currency_effect": ZERO, + "converted_from": [], + "has_rate_history": True, + "flows": [], + "as_of": perf.as_of, + }, + ) + for key in ("cost_basis", "total_return", "income", "currency_effect"): + total[key] += figures[key] + total["has_rate_history"] &= figures.get("has_rate_history", True) + if figures["currency"] != perf.currency and perf.currency not in total["converted_from"]: + total["converted_from"].append(perf.currency) + # A holding without a price has no end value, so its flows would skew the rate + if figures["market_value"] is not None: + total["market_value"] += figures["market_value"] + total["flows"] += figures["flows"] + for total in totals.values(): + total["money_weighted"] = money_weighted_return( + total["flows"], total["market_value"], total["as_of"] + ) + return list(totals.values()) diff --git a/app/apps/investments/tests/__init__.py b/app/apps/investments/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/app/apps/investments/tests/test_services.py b/app/apps/investments/tests/test_services.py new file mode 100644 index 00000000..930b53be --- /dev/null +++ b/app/apps/investments/tests/test_services.py @@ -0,0 +1,243 @@ +import datetime +from decimal import Decimal + +from django.contrib.auth import get_user_model +from django.test import TestCase, override_settings +from django.urls import reverse +from django.utils import timezone + +from apps.accounts.models import Account +from apps.common.middleware.thread_local import delete_current_user, write_current_user +from apps.currencies.models import Currency, ExchangeRate +from apps.investments.services import get_holding_performance, summarize +from apps.transactions.models import Transaction, TransactionEntity + +D = Decimal +IN, EX = Transaction.Type.INCOME, Transaction.Type.EXPENSE + + +def tx(account, type_, amount, date, **kwargs): + return Transaction.objects.create( + account=account, type=type_, amount=D(amount), date=date, **kwargs + ) + + +def transfer(from_account, from_amount, to_account, to_amount, date): + out = tx(from_account, EX, from_amount, date) + inc = tx(to_account, IN, to_amount, date, linked_transaction=out) + out.linked_transaction = inc + out.save() + return out, inc + + +class HoldingPerformanceTests(TestCase): + def setUp(self): + self.eur = Currency.objects.create(code="EUR", name="Euro", decimal_places=2) + self.brl = Currency.objects.create(code="BRL", name="Real", decimal_places=2) + self.vwce = Currency.objects.create( + code="VWCE.DE", name="VWCE", decimal_places=4, exchange_currency=self.eur + ) + self.cash = Account.objects.create(name="Cash", currency=self.eur) + self.etf = Account.objects.create( + name="VWCE", + currency=self.vwce, + exchange_currency=self.eur, + is_investment=True, + is_asset=True, + ) + self.d1 = datetime.date(2026, 1, 10) + self.d2 = datetime.date(2026, 2, 10) + self.d3 = datetime.date(2026, 3, 10) + + def rate(self, value, date): + ExchangeRate.objects.create( + from_currency=self.vwce, + to_currency=self.eur, + rate=D(value), + date=timezone.make_aware(datetime.datetime.combine(date, datetime.time())), + ) + + def test_priced_average_cost_realized_and_income(self): + transfer(self.cash, "1000", self.etf, "10", self.d1) + transfer(self.cash, "1200", self.etf, "10", self.d2) + transfer(self.etf, "5", self.cash, "700", self.d3) + self.rate("130", timezone.localdate()) + + entity = TransactionEntity.objects.create(name="Vanguard") + other = TransactionEntity.objects.create(name="Vanguard JCP") + self.etf.income_entities.set([entity, other]) + dividend = tx(self.cash, IN, "30", self.d3) + dividend.entities.add(entity, other) # matches twice, counted once + + perf = get_holding_performance(self.etf) + + self.assertTrue(perf.priced) + self.assertEqual(perf.quantity, D("15")) + self.assertEqual(perf.cost_basis, D("1650")) + self.assertEqual(perf.average_cost, D("110")) + self.assertEqual(perf.realized, D("150")) # 700 - 5 * 110 + self.assertEqual(perf.market_value, D("1950")) + self.assertEqual(perf.unrealized, D("300")) + self.assertEqual(perf.income, D("30")) + self.assertEqual(perf.contributed, D("2200")) + self.assertEqual(perf.withdrawn, D("700")) + self.assertEqual(perf.total_return, D("480")) + self.assertFalse(perf.estimated) + + def test_unlinked_inflow_on_priced_account_is_costed_at_market(self): + self.rate("90", self.d1) + tx(self.etf, IN, "2", self.d1) + + perf = get_holding_performance(self.etf) + + self.assertEqual(perf.cost_basis, D("180")) + self.assertTrue(perf.estimated) + self.assertEqual(perf.contributed, D("0")) + + def test_value_tracked_cdb_with_tax_on_redemption(self): + cdb = Account.objects.create( + name="CDB", currency=self.brl, is_investment=True, is_asset=True + ) + checking = Account.objects.create(name="Checking", currency=self.brl) + transfer(checking, "1000", cdb, "1000", self.d1) + tx(cdb, IN, "100", self.d2) # balance reconciliation: yield + tx(cdb, EX, "22.50", self.d3) # income tax withheld + + perf = get_holding_performance(cdb) + self.assertFalse(perf.priced) + self.assertEqual(perf.market_value, D("1077.50")) + self.assertEqual(perf.cost_basis, D("1000")) + self.assertEqual(perf.unrealized, D("77.50")) + + transfer(cdb, "1077.50", checking, "1077.50", self.d3) + perf = get_holding_performance(cdb) + self.assertEqual(perf.quantity, D("0")) + self.assertEqual(perf.cost_basis, D("0")) + self.assertEqual(perf.realized, D("77.50")) + self.assertEqual(perf.total_return, D("77.50")) + + def test_history_has_one_point_per_month_end(self): + transfer(self.cash, "1000", self.etf, "10", self.d1) + self.rate("100", self.d1) + self.rate("120", self.d3) + + perf = get_holding_performance( + self.etf, as_of=datetime.date(2026, 3, 15), with_history=True + ) + + self.assertEqual( + [p["date"] for p in perf.history], + [ + datetime.date(2026, 1, 31), + datetime.date(2026, 2, 28), + datetime.date(2026, 3, 15), + ], + ) + self.assertEqual(perf.history[-1]["value"], D("1200")) + self.assertEqual(perf.history[0]["invested"], D("1000")) + + def test_summarize_groups_by_currency(self): + transfer(self.cash, "1000", self.etf, "10", self.d1) + self.rate("110", timezone.localdate()) + totals = summarize([get_holding_performance(self.etf)]) + self.assertEqual(len(totals), 1) + self.assertEqual(totals[0]["market_value"], D("1100")) + self.assertEqual(totals[0]["total_return"], D("100")) + + def test_xirr_and_money_weighted_return(self): + from apps.investments.services import money_weighted_return, xirr + + start, year = datetime.date(2025, 1, 1), datetime.date(2026, 1, 1) + self.assertAlmostEqual(xirr([(start, D(-1000)), (year, D(1100))]), 0.10, places=6) + self.assertIsNone(xirr([(start, D(-1000))])) + + # A year or more: annualized + mw = money_weighted_return([(start, D(-1000), "in")], D(1100), year) + self.assertTrue(mw["annualized"]) + self.assertAlmostEqual(float(mw["percentage"]), 10, places=3) + + # Less than a year: the return over the period, not an inflated annual rate + half = datetime.date(2025, 7, 2) + mw = money_weighted_return([(start, D(-1000), "in")], D(1050), half) + self.assertFalse(mw["annualized"]) + self.assertAlmostEqual(float(mw["percentage"]), 5, places=3) + + # Money added late weighs less: +100 on 1000 held all year beats the + # simple +100 / 2000 = 5% when the second 1000 only came in at the end + flows = [(start, D(-1000), "in"), (datetime.date(2025, 12, 31), D(-1000), "in")] + mw = money_weighted_return(flows, D(2100), year) + self.assertGreater(float(mw["percentage"]), 9.9) + + def test_home_currency_counts_the_exchange_rate_move(self): + from apps.investments.services import in_home_currency + + usd = Currency.objects.create(code="USD", name="Dollar", decimal_places=2) + usd.exchange_currency = self.brl + usd.save() + etf = Currency.objects.create( + code="VT", name="VT", decimal_places=4, exchange_currency=usd + ) + cash = Account.objects.create(name="USD cash", currency=usd) + holding = Account.objects.create( + name="VT", currency=etf, exchange_currency=usd, is_investment=True + ) + aware = lambda d: timezone.make_aware(datetime.datetime.combine(d, datetime.time())) + ExchangeRate.objects.create(from_currency=usd, to_currency=self.brl, rate=D("5"), date=aware(self.d1)) + ExchangeRate.objects.create(from_currency=usd, to_currency=self.brl, rate=D("6"), date=aware(timezone.localdate())) + ExchangeRate.objects.create(from_currency=etf, to_currency=usd, rate=D("100"), date=aware(timezone.localdate())) + transfer(cash, "1000", holding, "10", self.d1) + + perf = get_holding_performance(holding) + self.assertEqual(perf.total_return, D("0")) # flat in dollars + + home = in_home_currency(perf) + self.assertEqual(home["currency"], self.brl) + self.assertEqual(home["market_value"], D("6000")) + self.assertEqual(home["cost_basis"], D("5000")) + self.assertEqual(home["total_return"], D("1000")) + self.assertEqual(home["currency_effect"], D("1000")) # all of it from the dollar + + totals = summarize([perf], convert=True) + self.assertEqual([t["currency"] for t in totals], [self.brl]) + self.assertEqual(totals[0]["converted_from"], [usd]) + # No rate to anything: stays in its own currency + self.assertEqual(summarize([get_holding_performance(self.etf)], convert=True)[0]["currency"], self.eur) + + +@override_settings( + STORAGES={ + "default": {"BACKEND": "django.core.files.storage.FileSystemStorage"}, + "staticfiles": { + "BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage" + }, + }, + WHITENOISE_AUTOREFRESH=True, + DEMO=False, +) +class InvestmentViewTests(TestCase): + HTMX = {"HTTP_HX_REQUEST": "true"} + + def setUp(self): + User = get_user_model() + self.owner = User.objects.create_user(email="o@test.com", password="pw") + self.stranger = User.objects.create_user(email="s@test.com", password="pw") + brl = Currency.objects.create(code="BRL", name="Real", decimal_places=2) + self.account = Account.all_objects.create( + name="CDB", currency=brl, is_investment=True, owner=self.owner + ) + + def test_owner_sees_list_and_detail(self): + self.client.force_login(self.owner) + response = self.client.get(reverse("investments_list"), **self.HTMX) + self.assertContains(response, "CDB") + response = self.client.get( + reverse("investment_detail", args=[self.account.id]), **self.HTMX + ) + self.assertEqual(response.status_code, 200) + + def test_stranger_cannot_see_detail(self): + self.client.force_login(self.stranger) + response = self.client.get( + reverse("investment_detail_index", args=[self.account.id]) + ) + self.assertEqual(response.status_code, 404) diff --git a/app/apps/investments/urls.py b/app/apps/investments/urls.py new file mode 100644 index 00000000..e3864d62 --- /dev/null +++ b/app/apps/investments/urls.py @@ -0,0 +1,18 @@ +from django.urls import path + +from . import views + +urlpatterns = [ + path("investments/", views.investments_index, name="investments_index"), + path("investments/list/", views.investments_list, name="investments_list"), + path( + "investments//", + views.investment_detail_index, + name="investment_detail_index", + ), + path( + "investments//details/", + views.investment_detail, + name="investment_detail", + ), +] diff --git a/app/apps/investments/views.py b/app/apps/investments/views.py new file mode 100644 index 00000000..339a951a --- /dev/null +++ b/app/apps/investments/views.py @@ -0,0 +1,106 @@ +import json + +from django.contrib.auth.decorators import login_required +from django.shortcuts import render +from django.utils.formats import date_format + +from apps.accounts.models import Account +from apps.common.decorators.htmx import only_htmx +from apps.common.functions.permissions import READ, get_shared_object_or_error +from apps.investments.services import ( + get_holding_performance, + in_home_currency, + income_entity_ids, + summarize, +) +from apps.transactions.models import Transaction + + +def _holdings(): + return Account.objects.filter(is_investment=True, is_archived=False).select_related( + "group", "currency", "exchange_currency" + ) + + +@login_required +def investments_index(request): + return render(request, "investments/pages/index.html") + + +@only_htmx +@login_required +def investments_list(request): + performances = [get_holding_performance(account) for account in _holdings()] + + groups = {} + for perf in performances: + group = perf.account.group + groups.setdefault(group.id if group else None, (group, []))[1].append(perf) + + return render( + request, + "investments/fragments/list.html", + { + "groups": [ + {"group": group, "holdings": items, "totals": summarize(items)} + for group, items in sorted( + groups.values(), key=lambda g: (g[0] is None, g[0] and g[0].name) + ) + ], + "totals": summarize(performances, convert=True), + }, + ) + + +@login_required +def investment_detail_index(request, account_id): + account = get_shared_object_or_error( + Account, request, id=account_id, level=READ, is_investment=True + ) + return render(request, "investments/pages/detail.html", {"account": account}) + + +@only_htmx +@login_required +def investment_detail(request, account_id): + account = get_shared_object_or_error( + Account, request, id=account_id, level=READ, is_investment=True + ) + perf = get_holding_performance(account, with_history=True) + transactions = ( + Transaction.objects.filter(account=account) + .select_related("linked_transaction__account__currency", "category") + .order_by("-date", "-id")[:50] + ) + income = [] + entity_ids = income_entity_ids(account) + if entity_ids: + income = ( + Transaction.objects.filter( + entities__in=entity_ids, type=Transaction.Type.INCOME + ) + .exclude(account=account) + .distinct() + .select_related("account__currency") + .order_by("-date", "-id")[:20] + ) + + chart = { + "labels": [date_format(p["date"], "M Y") for p in perf.history], + "value": [float(p["value"]) if p["value"] is not None else None for p in perf.history], + "invested": [float(p["invested"]) for p in perf.history], + } + + return render( + request, + "investments/fragments/detail.html", + { + "account": account, + "perf": perf, + "home": in_home_currency(perf), + "transactions": transactions, + "income": income, + "income_entities": list(account.income_entities.all()), + "chart": json.dumps(chart), + }, + ) diff --git a/app/templates/includes/sidebar.html b/app/templates/includes/sidebar.html index 1edaf52b..05d44934 100644 --- a/app/templates/includes/sidebar.html +++ b/app/templates/includes/sidebar.html @@ -114,6 +114,12 @@ + + {% include 'investments/fragments/_percentage.html' with value=mw.percentage %}{% if mw.annualized %}{% translate '/yr' %}{% endif %}{% endif %} diff --git a/app/templates/investments/fragments/_percentage.html b/app/templates/investments/fragments/_percentage.html new file mode 100644 index 00000000..3bc2415c --- /dev/null +++ b/app/templates/investments/fragments/_percentage.html @@ -0,0 +1 @@ +{% if value is not None %}{% if value > 0 %}+{% endif %}{{ value|floatformat:"2g" }}%{% endif %} diff --git a/app/templates/investments/fragments/_totals.html b/app/templates/investments/fragments/_totals.html new file mode 100644 index 00000000..4592309c --- /dev/null +++ b/app/templates/investments/fragments/_totals.html @@ -0,0 +1,38 @@ +{% load i18n %} +{% with c=total.currency %} +
+
+
+
+
{% translate 'Market value' %} · {{ c.name }}
+
+ +
+
+
+
{% translate 'Total return' %}
+
+ + {% include 'investments/fragments/_mwr.html' with mw=total.money_weighted %} +
+
+
+
+
{% translate 'Invested' %}
+
{% translate 'Income' %}
+ {% if total.converted_from %} + {% if total.has_rate_history %} +
{% translate 'Currency effect' %}
+ {% else %} +
{% translate 'Currency effect' %}
+ {% endif %} + {% endif %} +
+ {% if total.converted_from %} +
+ {% translate 'Includes holdings in' %} {% for currency in total.converted_from %}{{ currency.name }}{% if not forloop.last %}, {% endif %}{% endfor %}, {% translate 'converted at the rate of each date' %} +
+ {% endif %} +
+
+{% endwith %} diff --git a/app/templates/investments/fragments/detail.html b/app/templates/investments/fragments/detail.html new file mode 100644 index 00000000..1861d10c --- /dev/null +++ b/app/templates/investments/fragments/detail.html @@ -0,0 +1,270 @@ +{% load i18n %} +{% with c=perf.currency a=account.currency %} +{# Buy/sell are transfers so the tracker sees them; income uses the account's income entity #} + + + + {% if income_entities %} + + {% endif %} + {% if not perf.priced %} + + {% endif %} + +
+
+
+
+ + {{ account.name }} + +
+
+ {% if account.group %}{{ account.group.name }} · {% endif %} + {% if perf.priced %}{{ a.name }} → {{ c.name }}{% else %}{% translate 'Tracked by value' %}{% endif %} + {% if perf.estimated %}{% endif %} +
+
+
+ +
+
+
+
+
{% translate 'Market value' %}
+
+ {% if perf.market_value is not None %}{% else %}-{% endif %} +
+
+ + {% include 'investments/fragments/_mwr.html' with mw=perf.money_weighted %} + {% translate 'total return' %} +
+ {% if home %} + {% with h=home.currency %} +
+ {% translate 'In' %} {{ h.name }}: + + · + + {% include 'investments/fragments/_mwr.html' with mw=home.money_weighted %} + {% if home.has_rate_history %} + · {% translate 'currency effect' %} + + + {% else %} + · {% translate 'currency effect' %} + {% endif %} +
+ {% endwith %} + {% endif %} +
+ {% if perf.priced %} +
+
+
+ {% if perf.price is not None %} + × + {% if perf.price_date %} · {{ perf.price_date|date:"SHORT_DATE_FORMAT" }}{% endif %} + {% else %} + {% translate "No exchange rate available" %} + {% endif %} +
+
+ {% endif %} +
+ +
+ +
+ +
+
+
{% translate 'Invested' %}
+
+
+
+
{% if perf.priced %}{% translate 'Average cost' %}{% else %}{% translate 'Put in / taken out' %}{% endif %}
+
+ {% if perf.priced %} + {% if perf.average_cost is not None %}{% else %}-{% endif %} + {% else %} + + / + + {% endif %} +
+
+
+
{% if perf.priced %}{% translate 'Unrealized' %}{% else %}{% translate 'Earnings' %}{% endif %}
+
+ {% if perf.unrealized is not None %} + + {% include 'investments/fragments/_percentage.html' with value=perf.unrealized_percentage css="text-xs" %} + {% else %}-{% endif %} +
+
+
+
{% translate 'Realized' %}
+
+
+
+
{% translate 'Income' %}{% if income_entities %} · {{ income_entities|join:", " }}{% endif %}
+
+
+ {% if perf.priced %} +
+
{% translate 'Put in / taken out' %}
+
+ + / + +
+
+ {% endif %} +
+
+
+ +
+
+
{% translate 'Movements' %}
+ {% if transactions %} +
+ + + {% for tx in transactions %} + {% with p=tx.linked_transaction %} + + + + + + + {% endwith %} + {% endfor %} + +
{{ tx.date|date:"SHORT_DATE_FORMAT" }} +
{{ tx.description }}
+
+ {% if p %} + {% if tx.type == 'IN' %}{% translate 'Bought / contributed' %}{% else %}{% translate 'Sold / withdrew' %}{% endif %} + · {{ p.account.name }} + {% else %} + {% if tx.type == 'IN' %}{% if perf.priced %}{% translate 'Gain' %}{% else %}{% translate 'Earnings' %}{% endif %}{% else %}{% translate 'Loss, tax or fee' %}{% endif %} + {% endif %} +
+
+ + {% if p and p.account.currency != a %} +
+ +
+ {% endif %} +
+ +
+
+ {% else %} + + {% endif %} +
+ {% if income %} +
+
{% translate 'Income' %}
+
+ + + {% for tx in income %} + + + + + + + {% endfor %} + +
{{ tx.date|date:"SHORT_DATE_FORMAT" }}{{ tx.description }}
{{ tx.account.name }}
+ +
+
+
+ {% endif %} +
+
+{% endwith %} diff --git a/app/templates/investments/fragments/list.html b/app/templates/investments/fragments/list.html new file mode 100644 index 00000000..c11add13 --- /dev/null +++ b/app/templates/investments/fragments/list.html @@ -0,0 +1,93 @@ +{% load i18n %} + + +
+
+ {% spaceless %} +
{% translate 'Investments' %}
+ {% endspaceless %} +
+ + {% if groups %} +
+ {% for total in totals %} + {% include 'investments/fragments/_totals.html' %} + {% endfor %} +
+ + {% for item in groups %} +
+
+ {% if item.group %}{{ item.group.name }}{% else %}{% translate 'Ungrouped' %}{% endif %} +
+ {% for total in item.totals %} +
+ + {% include 'investments/fragments/_mwr.html' with mw=total.money_weighted %} +
+ {% endfor %} +
+
+
+ + + + + + + + + + + + + + + + + {% for perf in item.holdings %} + {% with c=perf.currency a=perf.account.currency %} + + + + + + + {% endwith %} + {% endfor %} + +
{% translate 'Investment' %}{% translate 'Value' %}
+
+ {{ perf.account.name }} + {% if perf.estimated %}{% endif %} +
+
+ {% if perf.priced %} + + {% if perf.price is not None %}× {% else %}· {% translate 'No exchange rate available' %}{% endif %} + {% else %} + {% translate 'Tracked by value' %} + {% endif %} +
+
+ {# Phones show value + return %, wider screens split them into columns #} +
{% if perf.market_value is not None %}{% else %}-{% endif %}
+ +
{% include 'investments/fragments/_mwr.html' with mw=perf.money_weighted %}
+
+
+
+ {% endfor %} + {% else %} + + {% endif %} +
diff --git a/app/templates/investments/pages/detail.html b/app/templates/investments/pages/detail.html new file mode 100644 index 00000000..20f0532f --- /dev/null +++ b/app/templates/investments/pages/detail.html @@ -0,0 +1,8 @@ +{% extends "layouts/base.html" %} +{% load i18n %} + +{% block title %}{{ account.name }} :: {% translate 'Investments' %}{% endblock %} + +{% block content %} +
+{% endblock %} diff --git a/app/templates/investments/pages/index.html b/app/templates/investments/pages/index.html new file mode 100644 index 00000000..ac48ee38 --- /dev/null +++ b/app/templates/investments/pages/index.html @@ -0,0 +1,8 @@ +{% extends "layouts/base.html" %} +{% load i18n %} + +{% block title %}{% translate 'Investments' %}{% endblock %} + +{% block content %} +
+{% endblock %}