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/<id>/performance/
This commit is contained in:
Herculino Trotta committed 2026-11-05 11:07:32 -03:00
1 parent 1b9ea452d9
commit 359e5880cc
23 files changed
+1366 -3

No files matched your search

+1
View File
@@ -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",
+1
View File
@@ -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")),
+27 -3
View File
@@ -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(
'<div class="hidden" _="on change from closest <form/> '
"if #id_is_investment.checked remove .hidden from #div_id_income_entities "
'else add .hidden to #div_id_income_entities end"></div>'
),
)
if self.instance and self.instance.pk:
@@ -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'),
),
]
@@ -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"),
]
+19
View File
@@ -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"),
+45
View File
@@ -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"]
+16
View File
@@ -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
)
View File
Whitespace-only changes.
+6
View File
@@ -0,0 +1,6 @@
from django.apps import AppConfig
class InvestmentsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.investments"
+391
View File
@@ -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())
Whitespace-only changes.
+243
View File
@@ -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)
+18
View File
@@ -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/<int:account_id>/",
views.investment_detail_index,
name="investment_detail_index",
),
path(
"investments/<int:account_id>/details/",
views.investment_detail,
name="investment_detail",
),
]
+106
View File
@@ -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),
},
)
+6
View File
@@ -114,6 +114,12 @@
</c-components.sidebar-menu-item>
<c-components.sidebar-menu-header title="{% translate 'Tools' %}"></c-components.sidebar-menu-header>
<c-components.sidebar-menu-item
title="{% translate 'Investments' %}"
url='investments_index'
active="investments_index||investment_detail_index"
icon="fa-solid fa-chart-pie">
</c-components.sidebar-menu-item>
<c-components.sidebar-menu-item
title="{% translate 'Dollar Cost Average Tracker' %}"
url='dca_strategy_index'
@@ -0,0 +1 @@
{% load i18n %}{% if mw %}<span class="whitespace-nowrap" data-tippy-content="{% if mw.annualized %}{% translate 'Annual return, weighted by when money went in and out' %}{% else %}{% translate 'Return since the first contribution, weighted by when money went in and out' %}{% endif %}">{% include 'investments/fragments/_percentage.html' with value=mw.percentage %}{% if mw.annualized %}<span class="text-base-content/50">{% translate '/yr' %}</span>{% endif %}</span>{% endif %}
@@ -0,0 +1 @@
{% if value is not None %}<span class="tabular-nums font-medium whitespace-nowrap {% if value > 0 %}text-income{% elif value < 0 %}text-expense{% else %}text-base-content/60{% endif %} {{ css }}">{% if value > 0 %}+{% endif %}{{ value|floatformat:"2g" }}%</span>{% endif %}
@@ -0,0 +1,38 @@
{% load i18n %}
{% with c=total.currency %}
<div class="card bg-base-100 card-border shadow">
<div class="card-body p-5 gap-4">
<div class="flex flex-wrap items-end justify-between gap-x-6 gap-y-3">
<div>
<div class="text-xs uppercase tracking-wide text-base-content/60">{% translate 'Market value' %} · {{ c.name }}</div>
<div class="text-3xl font-bold tabular-nums">
<c-amount.display :amount="total.market_value" :prefix="c.prefix" :suffix="c.suffix" :decimal_places="c.decimal_places" divless></c-amount.display>
</div>
</div>
<div class="sm:text-right">
<div class="text-xs uppercase tracking-wide text-base-content/60">{% translate 'Total return' %}</div>
<div class="text-lg tabular-nums flex items-baseline gap-2 sm:justify-end">
<c-amount.display :amount="total.total_return" :prefix="c.prefix" :suffix="c.suffix" :decimal_places="c.decimal_places" color="auto" divless></c-amount.display>
<span class="text-sm">{% include 'investments/fragments/_mwr.html' with mw=total.money_weighted %}</span>
</div>
</div>
</div>
<div class="flex flex-wrap gap-x-6 gap-y-1 text-sm text-base-content/60">
<div>{% translate 'Invested' %} <span class="text-base-content tabular-nums"><c-amount.display :amount="total.cost_basis" :prefix="c.prefix" :suffix="c.suffix" :decimal_places="c.decimal_places" divless></c-amount.display></span></div>
<div>{% translate 'Income' %} <span class="text-base-content tabular-nums"><c-amount.display :amount="total.income" :prefix="c.prefix" :suffix="c.suffix" :decimal_places="c.decimal_places" divless></c-amount.display></span></div>
{% if total.converted_from %}
{% if total.has_rate_history %}
<div data-tippy-content="{% translate 'How much of the return came from exchange rates moving since you invested' %}">{% translate 'Currency effect' %} <span class="tabular-nums"><c-amount.display :amount="total.currency_effect" :prefix="c.prefix" :suffix="c.suffix" :decimal_places="c.decimal_places" color="auto" divless></c-amount.display></span></div>
{% else %}
<div>{% translate 'Currency effect' %} <i class="fa-solid fa-triangle-exclamation text-warning text-xs" data-tippy-content="{% translate 'Every date is converted with the same rate. Add historical exchange rates (or an automatic rate service) to see how much exchange rates moved your return.' %}"></i></div>
{% endif %}
{% endif %}
</div>
{% if total.converted_from %}
<div class="text-xs text-base-content/40 -mt-2">
{% 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' %}
</div>
{% endif %}
</div>
</div>
{% endwith %}
@@ -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 #}
<c-components.fab>
<c-components.fab_menu_button
color="success" hx_target="#generic-offcanvas" hx_trigger="click"
hx_vals='{"to_account": {{ account.id }}}'
url="{% url 'transactions_transfer' %}"
icon="fa-solid fa-arrow-right-to-bracket"
title="{% translate 'Buy / Contribute' %}"></c-components.fab_menu_button>
<c-components.fab_menu_button
color="error" hx_target="#generic-offcanvas" hx_trigger="click"
hx_vals='{"from_account": {{ account.id }}}'
url="{% url 'transactions_transfer' %}"
icon="fa-solid fa-arrow-right-from-bracket"
title="{% translate 'Sell / Withdraw' %}"></c-components.fab_menu_button>
{% if income_entities %}
<c-components.fab_menu_button
color="success" hx_target="#generic-offcanvas" hx_trigger="click"
hx_vals='{"type": "IN", "entities": "{{ income_entities.0.name|escapejs }}"}'
url="{% url 'transaction_add' %}"
icon="fa-solid fa-hand-holding-dollar"
title="{% translate 'Dividend / Income' %}"></c-components.fab_menu_button>
{% endif %}
{% if not perf.priced %}
<c-components.fab_menu_button
color="info" hx_target="#generic-offcanvas" hx_trigger="click"
url="{% url 'account_reconciliation' %}"
icon="fa-solid fa-scale-balanced"
title="{% translate 'Update value' %}"></c-components.fab_menu_button>
{% endif %}
</c-components.fab>
<div class="container pb-20">
<div class="flex flex-wrap justify-between items-start gap-3 mb-4">
<div class="min-w-0">
<div class="text-3xl font-bold font-mono flex items-center gap-2">
<a href="{% url 'investments_index' %}" hx-boost="true" class="no-underline text-base-content/40 hover:text-base-content transition-colors duration-150" aria-label="{% translate 'Investments' %}"><i class="fa-solid fa-arrow-left fa-fw text-lg"></i></a>
<span class="truncate">{{ account.name }}</span>
<button class="btn btn-ghost btn-sm btn-square text-base-content/40 hover:text-base-content"
aria-label="{% translate 'Edit account' %}" data-tippy-content="{% translate 'Edit account' %}"
hx-get="{% url 'account_edit' pk=account.id %}" hx-target="#generic-offcanvas">
<i class="fa-solid fa-pencil text-base"></i>
</button>
</div>
<div class="text-sm text-base-content/60 mt-1">
{% if account.group %}{{ account.group.name }} · {% endif %}
{% if perf.priced %}{{ a.name }} → {{ c.name }}{% else %}{% translate 'Tracked by value' %}{% endif %}
{% if perf.estimated %}<i class="fa-solid fa-circle-exclamation text-warning ml-1" data-tippy-content="{% translate 'Some incoming units have no linked payment, so their cost was estimated from the market price of that day' %}"></i>{% endif %}
</div>
</div>
</div>
<div class="card bg-base-100 card-border shadow mb-4">
<div class="card-body p-5 gap-5">
<div class="flex flex-wrap items-end justify-between gap-x-6 gap-y-3">
<div>
<div class="text-xs uppercase tracking-wide text-base-content/60">{% translate 'Market value' %}</div>
<div class="text-4xl font-bold tabular-nums">
{% if perf.market_value is not None %}<c-amount.display :amount="perf.market_value" :prefix="c.prefix" :suffix="c.suffix" :decimal_places="c.decimal_places" divless></c-amount.display>{% else %}-{% endif %}
</div>
<div class="text-sm tabular-nums mt-1 flex items-baseline gap-2">
<c-amount.display :amount="perf.total_return" :prefix="c.prefix" :suffix="c.suffix" :decimal_places="c.decimal_places" color="auto" divless></c-amount.display>
{% include 'investments/fragments/_mwr.html' with mw=perf.money_weighted %}
<span class="text-base-content/50">{% translate 'total return' %}</span>
</div>
{% if home %}
{% with h=home.currency %}
<div class="text-sm tabular-nums mt-1 flex flex-wrap items-baseline gap-x-2 text-base-content/60">
<span>{% translate 'In' %} {{ h.name }}:</span>
<c-amount.display :amount="home.market_value" :prefix="h.prefix" :suffix="h.suffix" :decimal_places="h.decimal_places" divless></c-amount.display>
<span>·</span>
<c-amount.display :amount="home.total_return" :prefix="h.prefix" :suffix="h.suffix" :decimal_places="h.decimal_places" color="auto" divless></c-amount.display>
{% include 'investments/fragments/_mwr.html' with mw=home.money_weighted %}
{% if home.has_rate_history %}
<span data-tippy-content="{% translate 'How much of the return came from exchange rates moving since you invested' %}">· {% translate 'currency effect' %}
<c-amount.display :amount="home.currency_effect" :prefix="h.prefix" :suffix="h.suffix" :decimal_places="h.decimal_places" color="auto" divless></c-amount.display>
</span>
{% else %}
<span>· {% translate 'currency effect' %} <i class="fa-solid fa-triangle-exclamation text-warning text-xs" data-tippy-content="{% translate 'Every date is converted with the same rate. Add historical exchange rates (or an automatic rate service) to see how much exchange rates moved your return.' %}"></i></span>
{% endif %}
</div>
{% endwith %}
{% endif %}
</div>
{% if perf.priced %}
<div class="sm:text-right text-sm text-base-content/60 tabular-nums">
<div><c-amount.display :amount="perf.quantity" :prefix="a.prefix" :suffix="a.suffix" :decimal_places="a.decimal_places" divless></c-amount.display></div>
<div>
{% if perf.price is not None %}
× <c-amount.display :amount="perf.price" :prefix="c.prefix" :suffix="c.suffix" :decimal_places="c.decimal_places" divless></c-amount.display>
{% if perf.price_date %}<span class="text-base-content/40"> · {{ perf.price_date|date:"SHORT_DATE_FORMAT" }}</span>{% endif %}
{% else %}
<span class="text-error">{% translate "No exchange rate available" %}</span>
{% endif %}
</div>
</div>
{% endif %}
</div>
<div class="h-64"
_="on htmx:afterSettle from #investment-details
set ctx to #investmentChart.getContext('2d')
set chartData to {{ chart }}
js(ctx, chartData)
const muted = 'rgba(127, 127, 127, 0.6)';
new Chart(ctx, {
type: 'line',
data: {
labels: chartData.labels,
datasets: [
{
label: '{% translate "Value" %}',
data: chartData.value,
borderColor: 'rgba(54, 162, 235, 1)',
backgroundColor: 'rgba(54, 162, 235, 0.12)',
fill: true,
tension: 0,
borderWidth: 2,
pointRadius: 0,
pointHoverRadius: 4
},
{
label: '{% translate "Invested" %}',
data: chartData.invested,
borderColor: muted,
stepped: true,
fill: false,
borderWidth: 1.5,
borderDash: [4, 4],
pointRadius: 0,
pointHoverRadius: 3
}
]
},
options: {
responsive: true,
maintainAspectRatio: false,
interaction: { mode: 'index', intersect: false },
scales: {
x: { grid: { display: false }, ticks: { color: muted, maxTicksLimit: 6, maxRotation: 0 } },
y: { beginAtZero: true, border: { display: false }, grid: { color: 'rgba(127, 127, 127, 0.1)' }, ticks: { display: false } }
},
plugins: {
legend: { align: 'end', labels: { color: muted, boxWidth: 12, boxHeight: 2 } }
}
}
})
end">
<canvas id="investmentChart"></canvas>
</div>
<dl class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-x-6 gap-y-4 border-t border-base-content/10 pt-4 text-sm tabular-nums">
<div>
<dt class="text-xs text-base-content/50">{% translate 'Invested' %}</dt>
<dd><c-amount.display :amount="perf.cost_basis" :prefix="c.prefix" :suffix="c.suffix" :decimal_places="c.decimal_places" divless></c-amount.display></dd>
</div>
<div>
<dt class="text-xs text-base-content/50">{% if perf.priced %}{% translate 'Average cost' %}{% else %}{% translate 'Put in / taken out' %}{% endif %}</dt>
<dd>
{% if perf.priced %}
{% if perf.average_cost is not None %}<c-amount.display :amount="perf.average_cost" :prefix="c.prefix" :suffix="c.suffix" :decimal_places="c.decimal_places" divless></c-amount.display>{% else %}-{% endif %}
{% else %}
<c-amount.display :amount="perf.contributed" :prefix="c.prefix" :suffix="c.suffix" :decimal_places="c.decimal_places" divless></c-amount.display>
<span class="text-base-content/50">/</span>
<c-amount.display :amount="perf.withdrawn" :prefix="c.prefix" :suffix="c.suffix" :decimal_places="c.decimal_places" divless></c-amount.display>
{% endif %}
</dd>
</div>
<div>
<dt class="text-xs text-base-content/50">{% if perf.priced %}{% translate 'Unrealized' %}{% else %}{% translate 'Earnings' %}{% endif %}</dt>
<dd>
{% if perf.unrealized is not None %}
<c-amount.display :amount="perf.unrealized" :prefix="c.prefix" :suffix="c.suffix" :decimal_places="c.decimal_places" color="auto" divless></c-amount.display>
{% include 'investments/fragments/_percentage.html' with value=perf.unrealized_percentage css="text-xs" %}
{% else %}-{% endif %}
</dd>
</div>
<div>
<dt class="text-xs text-base-content/50">{% translate 'Realized' %}</dt>
<dd><c-amount.display :amount="perf.realized" :prefix="c.prefix" :suffix="c.suffix" :decimal_places="c.decimal_places" color="auto" divless></c-amount.display></dd>
</div>
<div>
<dt class="text-xs text-base-content/50">{% translate 'Income' %}{% if income_entities %} · {{ income_entities|join:", " }}{% endif %}</dt>
<dd><c-amount.display :amount="perf.income" :prefix="c.prefix" :suffix="c.suffix" :decimal_places="c.decimal_places" color="auto" divless></c-amount.display></dd>
</div>
{% if perf.priced %}
<div>
<dt class="text-xs text-base-content/50">{% translate 'Put in / taken out' %}</dt>
<dd>
<c-amount.display :amount="perf.contributed" :prefix="c.prefix" :suffix="c.suffix" :decimal_places="c.decimal_places" divless></c-amount.display>
<span class="text-base-content/50">/</span>
<c-amount.display :amount="perf.withdrawn" :prefix="c.prefix" :suffix="c.suffix" :decimal_places="c.decimal_places" divless></c-amount.display>
</dd>
</div>
{% endif %}
</dl>
</div>
</div>
<div class="grid grid-cols-1 {% if income %}xl:grid-cols-3{% endif %} gap-4">
<div class="card bg-base-100 card-border shadow {% if income %}xl:col-span-2{% endif %}">
<div class="px-5 pt-4 pb-2 font-semibold">{% translate 'Movements' %}</div>
{% if transactions %}
<div class="overflow-x-auto">
<table class="table text-sm">
<tbody>
{% for tx in transactions %}
{% with p=tx.linked_transaction %}
<tr class="{% if not tx.is_paid %}opacity-50{% endif %}">
<td class="whitespace-nowrap text-base-content/60 tabular-nums w-28">{{ tx.date|date:"SHORT_DATE_FORMAT" }}</td>
<td>
<div>{{ tx.description }}</div>
<div class="text-xs text-base-content/50">
{% 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 %}
</div>
</td>
<td class="text-end tabular-nums whitespace-nowrap">
<c-amount.display :amount="tx.amount" :prefix="a.prefix" :suffix="a.suffix" :decimal_places="a.decimal_places" color="{% if tx.type == 'IN' %}green{% else %}red{% endif %}" divless></c-amount.display>
{% if p and p.account.currency != a %}
<div class="text-xs text-base-content/50">
<c-amount.display :amount="p.amount" :prefix="p.account.currency.prefix" :suffix="p.account.currency.suffix" :decimal_places="p.account.currency.decimal_places" divless></c-amount.display>
</div>
{% endif %}
</td>
<td class="w-8 px-1">
<button class="btn btn-ghost btn-xs btn-square text-base-content/40 hover:text-base-content"
aria-label="{% translate 'Edit' %}" data-tippy-content="{% translate 'Edit' %}"
hx-get="{% if p %}{% url 'transaction_transfer_edit' transaction_id=tx.id %}{% else %}{% url 'transaction_edit' transaction_id=tx.id %}{% endif %}" hx-target="#generic-offcanvas"><i class="fa-solid fa-pencil"></i></button>
</td>
</tr>
{% endwith %}
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<c-msg.empty title="{% translate 'No movements yet' %}" subtitle="{% translate 'Use the + button to buy or contribute' %}"></c-msg.empty>
{% endif %}
</div>
{% if income %}
<div class="card bg-base-100 card-border shadow">
<div class="px-5 pt-4 pb-2 font-semibold">{% translate 'Income' %}</div>
<div class="overflow-x-auto">
<table class="table text-sm">
<tbody>
{% for tx in income %}
<tr class="{% if not tx.is_paid %}opacity-50{% endif %}">
<td class="whitespace-nowrap text-base-content/60 tabular-nums">{{ tx.date|date:"SHORT_DATE_FORMAT" }}</td>
<td>{{ tx.description }}<div class="text-xs text-base-content/50">{{ tx.account.name }}</div></td>
<td class="text-end tabular-nums whitespace-nowrap"><c-amount.display :amount="tx.amount" :prefix="tx.account.currency.prefix" :suffix="tx.account.currency.suffix" :decimal_places="tx.account.currency.decimal_places" color="green" divless></c-amount.display></td>
<td class="w-8 px-1">
<button class="btn btn-ghost btn-xs btn-square text-base-content/40 hover:text-base-content"
aria-label="{% translate 'Edit' %}" data-tippy-content="{% translate 'Edit' %}"
hx-get="{% url 'transaction_edit' transaction_id=tx.id %}" hx-target="#generic-offcanvas"><i class="fa-solid fa-pencil"></i></button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endif %}
</div>
</div>
{% endwith %}
@@ -0,0 +1,93 @@
{% load i18n %}
<c-ui.fab-single-action
url="{% url 'transactions_transfer' %}"
hx_target="#generic-offcanvas">
</c-ui.fab-single-action>
<div class="container pb-20">
<div class="text-3xl font-bold font-mono w-full mb-4">
{% spaceless %}
<div>{% translate 'Investments' %}</div>
{% endspaceless %}
</div>
{% if groups %}
<div class="grid grid-cols-1 {% if totals|length > 1 %}lg:grid-cols-2{% endif %} gap-3 mb-6">
{% for total in totals %}
{% include 'investments/fragments/_totals.html' %}
{% endfor %}
</div>
{% for item in groups %}
<div class="card bg-base-100 card-border shadow mb-4">
<div class="flex flex-wrap justify-between items-baseline gap-2 px-5 pt-4 pb-2">
<span class="font-semibold">{% if item.group %}{{ item.group.name }}{% else %}{% translate 'Ungrouped' %}{% endif %}</span>
<div class="flex flex-wrap gap-4 text-sm">
{% for total in item.totals %}
<div class="flex items-baseline gap-2 tabular-nums">
<c-amount.display :amount="total.market_value" :prefix="total.currency.prefix" :suffix="total.currency.suffix" :decimal_places="total.currency.decimal_places" divless></c-amount.display>
{% include 'investments/fragments/_mwr.html' with mw=total.money_weighted %}
</div>
{% endfor %}
</div>
</div>
<div class="overflow-x-auto">
<table class="table table-fixed">
<colgroup>
<col>
<col class="w-36 hidden md:table-column">
<col class="w-36 hidden sm:table-column">
<col class="w-36">
</colgroup>
<thead>
<tr class="text-xs text-base-content/50">
<th class="font-normal">{% translate 'Investment' %}</th>
<th class="font-normal text-end hidden md:table-cell">{% translate 'Invested' %}</th>
<th class="font-normal text-end hidden sm:table-cell">{% translate 'Value' %}</th>
<th class="font-normal text-end"><span class="sm:hidden">{% translate 'Value' %}</span><span class="hidden sm:inline">{% translate 'Return' %}</span></th>
</tr>
</thead>
<tbody>
{% for perf in item.holdings %}
{% with c=perf.currency a=perf.account.currency %}
<tr class="cursor-pointer transition-colors duration-150 hover:bg-base-200/60"
_="on click go to url '{% url 'investment_detail_index' account_id=perf.account.id %}'">
<td>
<div class="font-medium truncate">
{{ perf.account.name }}
{% if perf.estimated %}<i class="fa-solid fa-circle-exclamation text-warning text-xs" data-tippy-content="{% translate 'Some costs were estimated from market prices' %}"></i>{% endif %}
</div>
<div class="text-xs text-base-content/50 tabular-nums truncate">
{% if perf.priced %}
<c-amount.display :amount="perf.quantity" :prefix="a.prefix" :suffix="a.suffix" :decimal_places="a.decimal_places" divless></c-amount.display>
{% if perf.price is not None %}× <c-amount.display :amount="perf.price" :prefix="c.prefix" :suffix="c.suffix" :decimal_places="c.decimal_places" divless></c-amount.display>{% else %}· <span class="text-error">{% translate 'No exchange rate available' %}</span>{% endif %}
{% else %}
{% translate 'Tracked by value' %}
{% endif %}
</div>
</td>
<td class="text-end tabular-nums whitespace-nowrap text-base-content/70 hidden md:table-cell">
<c-amount.display :amount="perf.cost_basis" :prefix="c.prefix" :suffix="c.suffix" :decimal_places="c.decimal_places" divless></c-amount.display>
</td>
<td class="text-end tabular-nums whitespace-nowrap hidden sm:table-cell">
{% if perf.market_value is not None %}<c-amount.display :amount="perf.market_value" :prefix="c.prefix" :suffix="c.suffix" :decimal_places="c.decimal_places" divless></c-amount.display>{% else %}-{% endif %}
</td>
<td class="text-end tabular-nums whitespace-nowrap">
{# Phones show value + return %, wider screens split them into columns #}
<div class="sm:hidden font-medium">{% if perf.market_value is not None %}<c-amount.display :amount="perf.market_value" :prefix="c.prefix" :suffix="c.suffix" :decimal_places="c.decimal_places" divless></c-amount.display>{% else %}-{% endif %}</div>
<div class="hidden sm:block"><c-amount.display :amount="perf.total_return" :prefix="c.prefix" :suffix="c.suffix" :decimal_places="c.decimal_places" color="auto" divless></c-amount.display></div>
<div class="text-xs">{% include 'investments/fragments/_mwr.html' with mw=perf.money_weighted %}</div>
</td>
</tr>
{% endwith %}
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endfor %}
{% else %}
<c-msg.empty
title="{% translate 'No investments yet' %}"
subtitle="{% translate 'Mark an account as an investment in its settings to see it here' %}"></c-msg.empty>
{% endif %}
</div>
@@ -0,0 +1,8 @@
{% extends "layouts/base.html" %}
{% load i18n %}
{% block title %}{{ account.name }} :: {% translate 'Investments' %}{% endblock %}
{% block content %}
<div hx-get="{% url 'investment_detail' account_id=account.id %}" hx-trigger="load, updated from:window" class="show-loading" id="investment-details"></div>
{% endblock %}
@@ -0,0 +1,8 @@
{% extends "layouts/base.html" %}
{% load i18n %}
{% block title %}{% translate 'Investments' %}{% endblock %}
{% block content %}
<div hx-get="{% url 'investments_list' %}" hx-trigger="load, updated from:window" class="show-loading"></div>
{% endblock %}