diff --git a/app/apps/accounts/forms.py b/app/apps/accounts/forms.py index 945732d6..fbb3495a 100644 --- a/app/apps/accounts/forms.py +++ b/app/apps/accounts/forms.py @@ -1,6 +1,7 @@ import calendar -from apps.accounts.models import Account, AccountGroup +from apps.accounts.models import Account, AccountGroup, InvestmentClass +from apps.common.middleware.thread_local import get_current_user from apps.common.fields.forms.dynamic_select import ( DynamicModelChoiceField, DynamicModelMultipleChoiceField, @@ -20,7 +21,8 @@ from crispy_forms.bootstrap import FormActions from crispy_forms.helper import FormHelper from crispy_forms.layout import HTML, Column, Div, Field, Layout, Row from django import forms -from django.db.models import Q +from django.db.models import Q, Sum +from django.template.defaultfilters import floatformat from django.utils import timezone from django.utils.translation import gettext_lazy as _ @@ -55,6 +57,52 @@ class AccountGroupForm(forms.ModelForm): ) +class InvestmentClassForm(forms.ModelForm): + class Meta: + model = InvestmentClass + fields = ["name", "target"] + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.helper = FormHelper() + self.helper.form_tag = False + self.helper.form_method = "post" + self.helper.layout = Layout( + "name", + "target", + FormActions( + NoClassSubmit( + "submit", + _("Update") if self.instance.pk else _("Add"), + css_class="btn btn-primary", + ), + ), + ) + + def clean_name(self): + name = self.cleaned_data["name"] + # owner isn't a form field, so Django skips the (owner, name) check + if InvestmentClass.objects.filter(name=name).exclude(pk=self.instance.pk).exists(): + raise forms.ValidationError(_("You already have an asset class with this name.")) + return name + + def clean_target(self): + target = self.cleaned_data["target"] + if target: + others = ( + InvestmentClass.objects.exclude(pk=self.instance.pk) + .aggregate(total=Sum("target"))["total"] + or 0 + ) + if others + target > 100: + raise forms.ValidationError( + _("Targets can't add up to more than 100%%. Other classes already " + "take %(taken)s%%.") % {"taken": floatformat(others, -2)} + ) + return target + + class AccountForm(forms.ModelForm): group = DynamicModelChoiceField( create_field="name", @@ -63,6 +111,14 @@ class AccountForm(forms.ModelForm): required=False, ) + investment_class = DynamicModelChoiceField( + create_field="name", + label=_("Asset class"), + model=InvestmentClass, + required=False, + help_text=Account._meta.get_field("investment_class").help_text, + ) + class Meta: model = Account fields = [ @@ -73,6 +129,7 @@ class AccountForm(forms.ModelForm): "is_asset", "is_archived", "is_investment", + "investment_class", "income_entities", "income_categories", "income_tags", @@ -89,6 +146,12 @@ class AccountForm(forms.ModelForm): super().__init__(*args, **kwargs) self.fields["group"].queryset = AccountGroup.objects.all() + self.fields["investment_class"].queryset = InvestmentClass.all_objects.filter( + Q(owner=get_current_user()) + | Q(owner=None) + # Keep a class from the account's owner when it's shared with us + | Q(accounts=self.instance.id or 0) + ).distinct() selectable = Q(active=True) if self.instance.id: selectable |= Q(investment_accounts=self.instance.id) @@ -124,6 +187,7 @@ class AccountForm(forms.ModelForm): Switch("is_investment"), # Only relevant for investments: shown while the switch above is on Div( + "investment_class", "income_entities", "income_categories", "income_tags", diff --git a/app/apps/accounts/migrations/0020_investment_classes.py b/app/apps/accounts/migrations/0020_investment_classes.py new file mode 100644 index 00000000..274abe34 --- /dev/null +++ b/app/apps/accounts/migrations/0020_investment_classes.py @@ -0,0 +1,38 @@ +# Generated by Django 5.2.17 on 2026-11-08 03:26 + +import django.core.validators +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0019_income_categories_and_tags'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='InvestmentClass', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=255, verbose_name='Name')), + ('target', models.DecimalField(blank=True, decimal_places=2, help_text='Share of your investments you want in this class. Leave it empty to leave the class out of the targets.', max_digits=5, null=True, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(100)], verbose_name='Target allocation (%)')), + ('owner', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_owned', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'Asset class', + 'verbose_name_plural': 'Asset classes', + 'db_table': 'investment_classes', + 'ordering': ['name', 'id'], + 'unique_together': {('owner', 'name')}, + }, + ), + migrations.AddField( + model_name='account', + name='investment_class', + field=models.ForeignKey(blank=True, help_text='Groups investments in the allocation charts, e.g. Fixed income, Stocks, Real estate.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='accounts', to='accounts.investmentclass', verbose_name='Asset class'), + ), + ] diff --git a/app/apps/accounts/models.py b/app/apps/accounts/models.py index df1f45ef..e30d9afd 100644 --- a/app/apps/accounts/models.py +++ b/app/apps/accounts/models.py @@ -1,10 +1,16 @@ from django.conf import settings from django.core.exceptions import ValidationError +from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models from django.utils.translation import gettext_lazy as _ from apps.common.middleware.thread_local import get_current_user -from apps.common.models import SharedObject, SharedObjectManager +from apps.common.models import ( + OwnedObject, + OwnedObjectManager, + SharedObject, + SharedObjectManager, +) from apps.transactions.models import Transaction @@ -25,6 +31,35 @@ class AccountGroup(SharedObject): return self.name +class InvestmentClass(OwnedObject): + name = models.CharField(max_length=255, verbose_name=_("Name")) + target = models.DecimalField( + max_digits=5, + decimal_places=2, + null=True, + blank=True, + validators=[MinValueValidator(0), MaxValueValidator(100)], + verbose_name=_("Target allocation (%)"), + help_text=_( + "Share of your investments you want in this class. Leave it empty to " + "leave the class out of the targets." + ), + ) + + objects = OwnedObjectManager() + all_objects = models.Manager() # Unfiltered manager + + class Meta: + verbose_name = _("Asset class") + verbose_name_plural = _("Asset classes") + db_table = "investment_classes" + unique_together = (("owner", "name"),) + ordering = ["name", "id"] + + def __str__(self): + return self.name + + class Account(SharedObject): name = models.CharField(max_length=255, verbose_name=_("Name")) group = models.ForeignKey( @@ -66,6 +101,18 @@ class Account(SharedObject): "value (fixed income, property, pensions)." ), ) + investment_class = models.ForeignKey( + InvestmentClass, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="accounts", + verbose_name=_("Asset class"), + help_text=_( + "Groups investments in the allocation charts, e.g. Fixed income, Stocks, " + "Real estate." + ), + ) income_entities = models.ManyToManyField( "transactions.TransactionEntity", blank=True, diff --git a/app/apps/api/serializers/accounts.py b/app/apps/api/serializers/accounts.py index 5fb44229..51df267d 100644 --- a/app/apps/api/serializers/accounts.py +++ b/app/apps/api/serializers/accounts.py @@ -53,6 +53,7 @@ class AccountSerializer(serializers.ModelSerializer): "exchange_currency_id", "is_asset", "is_investment", + "investment_class", "income_entities", "income_categories", "income_tags", diff --git a/app/apps/investments/services.py b/app/apps/investments/services.py index 51f244b9..a6371ada 100644 --- a/app/apps/investments/services.py +++ b/app/apps/investments/services.py @@ -45,6 +45,10 @@ class HoldingPerformance: # Unlinked transactions counted as gains or losses, on accounts tracked by value result_ids: set = field(default_factory=set) history: list = field(default_factory=list) + # (date, "buy" or "sell", unit price) for linked trades on unit-priced accounts + trades: list = field(default_factory=list) + # (date, signed amount) for the gains and losses of an account tracked by value + results: 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. @@ -165,6 +169,12 @@ def get_holding_performance(account, as_of=None, with_history=False): else None ) external = not (perf.priced or partner or tx.id in perf.result_ids) + if perf.priced and partner_value is not None and tx.amount: + kind = "buy" if tx.type == Transaction.Type.INCOME else "sell" + perf.trades.append((tx.date, kind, partner_value / tx.amount)) + elif not (perf.priced or partner or external): + sign = 1 if tx.type == Transaction.Type.INCOME else -1 + perf.results.append((tx.date, sign * tx.amount)) if tx.type == Transaction.Type.INCOME: if external: @@ -300,6 +310,8 @@ def _snapshot(perf, account, date): "date": date, "invested": perf.cost_basis, "value": perf.quantity * price if price is not None else None, + "price": price, + "average_cost": perf.average_cost, } @@ -428,3 +440,76 @@ def summarize(performances, convert=False): else [] ) return list(totals.values()) + + +def portfolio_charts(performances): + """Chart data per total currency (as in summarize with ``convert``): value by + asset class and by currency, return by investment, and value against invested + at each month end. Classes are InvestmentClass objects, None for holdings + without one. Unpriced holdings are left out of the value figures.""" + charts = {} + for perf in performances: + figures = in_home_currency(perf) or _native(perf) + path = figures.get("path", [perf.currency]) + chart = charts.setdefault( + figures["currency"].id, + { + "currency": figures["currency"], + "classes": {}, + "currencies": {}, + "returns": [], + "history": {}, + }, + ) + value = figures["market_value"] + if value: + key = perf.account.investment_class + chart["classes"][key] = chart["classes"].get(key, ZERO) + value + name = perf.currency.name + chart["currencies"][name] = chart["currencies"].get(name, ZERO) + value + chart["returns"].append((perf.account.name, figures["total_return"])) + # shortcut: one rate lookup per holding, month and hop; cache rates per + # date if portfolios with long histories make this tab slow. + for point in perf.history: + totals = chart["history"].setdefault(point["date"], [ZERO, ZERO]) + for i, amount in enumerate((point["value"], point["invested"])): + if amount is not None: + totals[i] += _along(amount, path, point["date"]) or ZERO + for chart in charts.values(): + for key in ("classes", "currencies"): + chart[key] = sorted(chart[key].items(), key=lambda item: -item[1]) + chart["returns"].sort(key=lambda item: -item[1]) + chart["history"] = sorted(chart["history"].items()) + return list(charts.values()) + + +def drift_limit(target): + """How far a class may drift from its target, in percentage points: the + common 5/25 rule, 5 points or a quarter of the target, whichever is less.""" + return min(Decimal(5), target / 4) + + +def rebalance(classes, targeted): + """What to buy or sell so the classes in ``targeted`` (InvestmentClass with a + target) match their targets, given ``classes`` as (class, value) from + portfolio_charts. Holdings without a class or target are left out and their + value is returned as ``left_out``. ``move`` is positive to buy.""" + values = dict(classes) + total = sum((values.get(cls, ZERO) for cls in targeted), ZERO) + rows = [] + for cls in targeted: + value = values.get(cls, ZERO) + share = value / total * 100 if total else ZERO + move = total * cls.target / 100 - value + rows.append( + { + "class": cls, + "value": value, + "share": share, + "target": cls.target, + "move": move, + "off": abs(share - cls.target) > drift_limit(cls.target), + } + ) + left_out = sum(values.values(), ZERO) - total + return {"total": total, "rows": rows, "left_out": left_out} diff --git a/app/apps/investments/tests/test_services.py b/app/apps/investments/tests/test_services.py index 28e67ad4..5ac80688 100644 --- a/app/apps/investments/tests/test_services.py +++ b/app/apps/investments/tests/test_services.py @@ -6,7 +6,7 @@ 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.accounts.models import Account, InvestmentClass 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 @@ -88,6 +88,10 @@ class HoldingPerformanceTests(TestCase): self.assertEqual(perf.withdrawn, D("700")) self.assertEqual(perf.total_return, D("480")) self.assertFalse(perf.estimated) + self.assertEqual( + perf.trades, + [(self.d1, "buy", D("100")), (self.d2, "buy", D("120")), (self.d3, "sell", D("140"))], + ) def test_unlinked_inflow_on_priced_account_is_costed_at_market(self): self.rate("90", self.d1) @@ -112,6 +116,7 @@ class HoldingPerformanceTests(TestCase): perf = get_holding_performance(cdb) self.assertFalse(perf.priced) + self.assertEqual(perf.results, [(self.d2, D("100")), (self.d3, D("-22.50"))]) self.assertEqual(perf.market_value, D("1077.50")) self.assertEqual(perf.cost_basis, D("1000")) self.assertEqual(perf.unrealized, D("77.50")) @@ -177,6 +182,8 @@ class HoldingPerformanceTests(TestCase): ) self.assertEqual(perf.history[-1]["value"], D("1200")) self.assertEqual(perf.history[0]["invested"], D("1000")) + self.assertEqual(perf.history[-1]["price"], D("120")) + self.assertEqual(perf.history[-1]["average_cost"], D("100")) def test_summarize_groups_by_currency(self): transfer(self.cash, "1000", self.etf, "10", self.d1) @@ -186,6 +193,67 @@ class HoldingPerformanceTests(TestCase): self.assertEqual(totals[0]["market_value"], D("1100")) self.assertEqual(totals[0]["total_return"], D("100")) + def test_portfolio_charts_group_by_asset_class(self): + from apps.investments.services import portfolio_charts + + stocks = InvestmentClass.objects.create(name="Stocks") + self.etf.investment_class = stocks + self.etf.save() + cdb = Account.objects.create(name="CDB", currency=self.eur, is_investment=True) + transfer(self.cash, "1000", self.etf, "10", self.d1) + transfer(self.cash, "500", cdb, "500", self.d1) + self.rate("110", self.d1) + + perfs = [get_holding_performance(a, with_history=True) for a in (self.etf, cdb)] + [chart] = portfolio_charts(perfs) + + self.assertEqual(chart["classes"], [(stocks, D("1100")), (None, D("500"))]) + # Priced in euros, so that is its currency exposure + self.assertEqual(chart["currencies"], [("Euro", D("1600"))]) + self.assertEqual(chart["returns"][0], ("VWCE", D("100"))) + self.assertEqual(chart["history"][-1][1], [D("1600"), D("1500")]) + + def test_rebalance_moves_to_targets(self): + from apps.investments.services import rebalance + + stocks = InvestmentClass.objects.create(name="Stocks", target=D("60")) + bonds = InvestmentClass.objects.create(name="Bonds", target=D("30")) + gold = InvestmentClass.objects.create(name="Gold", target=D("10")) + loose = InvestmentClass.objects.create(name="Loose") # no target + + plan = rebalance( + [(stocks, D("700")), (bonds, D("300")), (loose, D("50")), (None, D("25"))], + [stocks, bonds, gold], + ) + + self.assertEqual(plan["total"], D("1000")) + self.assertEqual(plan["left_out"], D("75")) + moves = {row["class"].name: row["move"] for row in plan["rows"]} + self.assertEqual(moves, {"Stocks": D("-100"), "Bonds": D("0"), "Gold": D("100")}) + self.assertEqual(sum(moves.values()), 0) + # 70% vs 60%: past the 5 points; gold at 0% vs 10%: past 2.5 (a quarter) + off = {row["class"].name: row["off"] for row in plan["rows"]} + self.assertEqual(off, {"Stocks": True, "Bonds": False, "Gold": True}) + + def test_class_names_are_unique(self): + from apps.accounts.forms import InvestmentClassForm + + stocks = InvestmentClass.objects.create(name="Stocks") + self.assertFalse(InvestmentClassForm({"name": "Stocks"}).is_valid()) + self.assertTrue(InvestmentClassForm({"name": "Stocks"}, instance=stocks).is_valid()) + + def test_targets_cannot_pass_100(self): + from apps.accounts.forms import InvestmentClassForm + + InvestmentClass.objects.create(name="Stocks", target=D("70")) + bonds = InvestmentClass.objects.create(name="Bonds", target=D("30")) + + self.assertFalse(InvestmentClassForm({"name": "Gold", "target": "1"}).is_valid()) + # Editing a class doesn't count its own current target + self.assertTrue( + InvestmentClassForm({"name": "Bonds", "target": "30"}, instance=bonds).is_valid() + ) + def test_xirr_and_money_weighted_return(self): from apps.investments.services import money_weighted_return, xirr @@ -281,6 +349,64 @@ class InvestmentViewTests(TestCase): ) self.assertEqual(response.status_code, 200) + def test_shared_account_keeps_its_owners_class(self): + from apps.accounts.forms import AccountForm + + cls = InvestmentClass.all_objects.create(name="Stocks", owner=self.owner) + self.account.investment_class = cls + self.account.save() + self.account.shared_with.add(self.stranger) + + write_current_user(self.stranger) + try: + form = AccountForm( + {"name": "CDB", "currency": self.account.currency_id, + "is_investment": "on", "investment_class": str(cls.id)}, + instance=self.account, + ) + self.assertTrue(form.is_valid(), form.errors) + self.assertEqual(form.cleaned_data["investment_class"], cls) + finally: + delete_current_user() + # Not a new class named after the id + self.assertEqual(InvestmentClass.all_objects.count(), 1) + + def test_rebalance_and_class_pages_render(self): + self.client.force_login(self.owner) + stocks = InvestmentClass.all_objects.create(name="Stocks", target=D("60"), owner=self.owner) + self.account.investment_class = stocks + self.account.save() + + # Targets short of 100%: a warning instead of amounts + response = self.client.get(reverse("investments_rebalance"), **self.HTMX) + self.assertContains(response, "alert-warning") + + response = self.client.post( + reverse("investment_class_add"), {"name": "Bonds", "target": "40"}, **self.HTMX + ) + self.assertEqual(response.status_code, 204) + response = self.client.get(reverse("investments_rebalance"), **self.HTMX) + self.assertContains(response, "Bonds") + self.assertNotContains(response, "alert-warning") + + response = self.client.get(reverse("investment_classes_list"), **self.HTMX) + self.assertContains(response, "Stocks") + response = self.client.get( + reverse("investment_class_edit", args=[stocks.id]), **self.HTMX + ) + self.assertContains(response, "Stocks") + # Someone else's class is out of reach + self.client.force_login(self.stranger) + response = self.client.get( + reverse("investment_class_edit", args=[stocks.id]), **self.HTMX + ) + self.assertEqual(response.status_code, 404) + + def test_charts_render(self): + self.client.force_login(self.owner) + response = self.client.get(reverse("investments_charts"), **self.HTMX) + self.assertContains(response, "investment-charts-0") + def test_stranger_cannot_see_detail(self): self.client.force_login(self.stranger) response = self.client.get( diff --git a/app/apps/investments/urls.py b/app/apps/investments/urls.py index e3864d62..25c52f19 100644 --- a/app/apps/investments/urls.py +++ b/app/apps/investments/urls.py @@ -5,6 +5,37 @@ from . import views urlpatterns = [ path("investments/", views.investments_index, name="investments_index"), path("investments/list/", views.investments_list, name="investments_list"), + path("investments/charts/", views.investments_charts, name="investments_charts"), + path( + "investments/rebalance/", + views.investments_rebalance, + name="investments_rebalance", + ), + path( + "investments/classes/", + views.investment_classes_index, + name="investment_classes_index", + ), + path( + "investments/classes/list/", + views.investment_classes_list, + name="investment_classes_list", + ), + path( + "investments/classes/add/", + views.investment_class_add, + name="investment_class_add", + ), + path( + "investments/classes//edit/", + views.investment_class_edit, + name="investment_class_edit", + ), + path( + "investments/classes//delete/", + views.investment_class_delete, + name="investment_class_delete", + ), path( "investments//", views.investment_detail_index, diff --git a/app/apps/investments/views.py b/app/apps/investments/views.py index 339a951a..db0dec6c 100644 --- a/app/apps/investments/views.py +++ b/app/apps/investments/views.py @@ -1,16 +1,23 @@ -import json - +from django.contrib import messages from django.contrib.auth.decorators import login_required -from django.shortcuts import render +from django.http import HttpResponse +from django.shortcuts import get_object_or_404, render from django.utils.formats import date_format +from django.utils.translation import gettext as _ +from django.views.decorators.http import require_http_methods -from apps.accounts.models import Account +from apps.accounts.forms import InvestmentClassForm +from apps.accounts.models import Account, InvestmentClass from apps.common.decorators.htmx import only_htmx +from apps.common.functions.format import get_format from apps.common.functions.permissions import READ, get_shared_object_or_error from apps.investments.services import ( + ZERO, get_holding_performance, in_home_currency, income_entity_ids, + portfolio_charts, + rebalance, summarize, ) from apps.transactions.models import Transaction @@ -52,6 +59,123 @@ def investments_list(request): ) +def _money(currency): + """What chartMoney (charts.js) needs to format amounts like the rest of the app.""" + return { + "prefix": currency.prefix, + "suffix": currency.suffix, + "decimals": currency.decimal_places, + "decimal": get_format("DECIMAL_SEPARATOR"), + "thousand": get_format("THOUSAND_SEPARATOR"), + } + + +def _number(amount): + return None if amount is None else float(amount) + + +def _per_month(history, entries): + """Sums (date, amount) entries into the months of ``history``, zero when empty.""" + sums = {(p["date"].year, p["date"].month): ZERO for p in history} + for date, amount in entries: + if (date.year, date.month) in sums: + sums[(date.year, date.month)] += amount + return [float(amount) for amount in sums.values()] + + +def _trades_per_month(history, trades, kind): + """The last trade price of each month, None in months without one.""" + prices = {(p["date"].year, p["date"].month): None for p in history} + for date, trade_kind, price in trades: + if trade_kind == kind and (date.year, date.month) in prices: + prices[(date.year, date.month)] = float(price) + return list(prices.values()) + + +# Categorical slots in fixed order; past the last one, the rest fold into "Other" +PALETTE = ["#2a78d6", "#eb6834", "#1baf7a", "#eda100", "#e87ba4", "#008300", "#6250d6"] +OTHER_COLOR = "#898781" + + +def _shares(items): + colors = PALETTE[: len(items)] + if len(items) > len(PALETTE): + rest = sum(amount for _name, amount in items[len(PALETTE) - 1 :]) + items = items[: len(PALETTE) - 1] + [(_("Other"), rest)] + colors = PALETTE[:-1] + [OTHER_COLOR] + total = sum(amount for _name, amount in items) + return [ + { + "key": name, + "name": str(name), + "amount": amount, + "share": amount / total * 100 if total else 0, + "color": color, + } + for (name, amount), color in zip(items, colors) + ] + + +@only_htmx +@login_required +def investments_charts(request): + performances = [ + get_holding_performance(account, with_history=True) for account in _holdings() + ] + charts = portfolio_charts(performances) + targeted = _targeted() + # Shares only mean something against targets that add up to 100% + if sum(cls.target for cls in targeted) != 100: + targeted = [] + for i, chart in enumerate(charts): + chart["data_id"] = f"investment-charts-{i}" + chart["returns_height"] = max(128, 32 * len(chart["returns"])) + plan = {row["class"]: row for row in rebalance(chart["classes"], targeted)["rows"]} + chart["classes"] = _shares( + [(cls or _("Unclassified"), amount) for cls, amount in chart["classes"]] + ) + for share in chart["classes"]: + share["plan"] = plan.get(share["key"]) + chart["currencies"] = _shares(chart["currencies"]) + chart["data"] = { + "currency": _money(chart["currency"]), + "classes": [[s["name"], float(s["amount"]), s["color"]] for s in chart["classes"]], + "currencies": [[s["name"], float(s["amount"]), s["color"]] for s in chart["currencies"]], + "returns": [[name, float(amount)] for name, amount in chart["returns"]], + "history": { + "labels": [date_format(d, "M Y") for d, _t in chart["history"]], + "value": [float(t[0]) for _d, t in chart["history"]], + "invested": [float(t[1]) for _d, t in chart["history"]], + }, + } + return render(request, "investments/fragments/charts.html", {"charts": charts}) + + +def _targeted(): + return list(InvestmentClass.objects.filter(target__isnull=False)) + + +@only_htmx +@login_required +def investments_rebalance(request): + targeted = _targeted() + total_target = sum(cls.target for cls in targeted) + plans = [] + for chart in portfolio_charts([get_holding_performance(a) for a in _holdings()]): + plan = rebalance(chart["classes"], targeted) + plan["currency"] = chart["currency"] + for row in plan["rows"]: + # Fractions of a cent shouldn't show as "Buy 0.00" + row["move"] = round(row["move"], chart["currency"].decimal_places) + row["size"] = abs(row["move"]) + plans.append(plan) + return render( + request, + "investments/fragments/rebalance.html", + {"plans": plans, "total_target": total_target, "targeted": targeted}, + ) + + @login_required def investment_detail_index(request, account_id): account = get_shared_object_or_error( @@ -85,10 +209,19 @@ def investment_detail(request, account_id): .order_by("-date", "-id")[:20] ) + history = perf.history + income_flows = [(d, amount) for d, amount, kind in perf.flows if kind == "income"] 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], + "currency": _money(perf.currency), + "labels": [date_format(p["date"], "M Y") for p in history], + "value": [_number(p["value"]) for p in history], + "invested": [float(p["invested"]) for p in history], + "price": [_number(p["price"]) for p in history] if perf.priced else None, + "average_cost": [_number(p["average_cost"]) for p in history], + "buys": _trades_per_month(history, perf.trades, "buy"), + "sells": _trades_per_month(history, perf.trades, "sell"), + "earnings": _per_month(history, perf.results) if perf.results else None, + "income": _per_month(history, income_flows) if income_flows else None, } return render( @@ -101,6 +234,64 @@ def investment_detail(request, account_id): "transactions": transactions, "income": income, "income_entities": list(account.income_entities.all()), - "chart": json.dumps(chart), + "chart": chart, }, ) + + +@login_required +@require_http_methods(["GET"]) +def investment_classes_index(request): + return render(request, "investment_classes/pages/index.html") + + +@only_htmx +@login_required +@require_http_methods(["GET"]) +def investment_classes_list(request): + classes = InvestmentClass.objects.all() + return render( + request, + "investment_classes/fragments/list.html", + { + "classes": classes, + "total_target": sum(cls.target or 0 for cls in classes), + }, + ) + + +@only_htmx +@login_required +@require_http_methods(["GET", "POST"]) +def investment_class_add(request): + return _investment_class_form(request, InvestmentClass(), _("Asset class added successfully")) + + +@only_htmx +@login_required +@require_http_methods(["GET", "POST"]) +def investment_class_edit(request, pk): + cls = get_object_or_404(InvestmentClass, pk=pk) + return _investment_class_form(request, cls, _("Asset class updated successfully")) + + +def _investment_class_form(request, instance, success): + form = InvestmentClassForm(request.POST or None, instance=instance) + if request.method == "POST" and form.is_valid(): + form.save() + messages.success(request, success) + return HttpResponse(status=204, headers={"HX-Trigger": "updated, hide_offcanvas"}) + return render( + request, + "investment_classes/fragments/form.html", + {"form": form, "object": instance}, + ) + + +@only_htmx +@login_required +@require_http_methods(["DELETE"]) +def investment_class_delete(request, pk): + get_object_or_404(InvestmentClass, pk=pk).delete() + messages.success(request, _("Asset class deleted successfully")) + return HttpResponse(status=204, headers={"HX-Trigger": "updated, hide_offcanvas"}) diff --git a/app/templates/includes/sidebar.html b/app/templates/includes/sidebar.html index 05d44934..0abea173 100644 --- a/app/templates/includes/sidebar.html +++ b/app/templates/includes/sidebar.html @@ -150,7 +150,7 @@ + active="tags_index||entities_index||categories_index||accounts_index||account_groups_index||investment_classes_index||currencies_index||exchange_rates_index||rules_index||import_profiles_index||automatic_exchange_rates_index||export_index||users_index"> + + + + + {% crispy form %} + +{% endblock %} diff --git a/app/templates/investment_classes/fragments/list.html b/app/templates/investment_classes/fragments/list.html new file mode 100644 index 00000000..e40ba58c --- /dev/null +++ b/app/templates/investment_classes/fragments/list.html @@ -0,0 +1,78 @@ +{% load i18n %} + + +
+
+ {% spaceless %} +
{% translate 'Asset classes' %}
+ {% endspaceless %} +
+ +
+
+ {% if classes %} + {% if total_target and total_target != 100 %} + + {% endif %} +
+ + + + + + + + + + + {% for cls in classes %} + + + + + + + {% endfor %} + + {% if total_target %} + + + + + + + + + {% endif %} +
{% translate 'Name' %}{% translate 'Target' %}{% translate 'Investments' %}
+
+ + + +
+
{{ cls.name }}{% if cls.target is not None %}{{ cls.target|floatformat:"-2" }}%{% else %}-{% endif %}{{ cls.accounts.count }}
{% translate 'Total' %}{{ total_target|floatformat:"-2" }}%
+
+ {% else %} + + {% endif %} +
+
+
diff --git a/app/templates/investment_classes/pages/index.html b/app/templates/investment_classes/pages/index.html new file mode 100644 index 00000000..5e86b514 --- /dev/null +++ b/app/templates/investment_classes/pages/index.html @@ -0,0 +1,8 @@ +{% extends "layouts/base.html" %} +{% load i18n %} + +{% block title %}{% translate 'Asset classes' %}{% endblock %} + +{% block content %} +
+{% endblock %} diff --git a/app/templates/investments/fragments/_allocation.html b/app/templates/investments/fragments/_allocation.html new file mode 100644 index 00000000..3e21bc23 --- /dev/null +++ b/app/templates/investments/fragments/_allocation.html @@ -0,0 +1,18 @@ +{% load i18n %} +
+
+
    + {% for share in shares %} +
  • + + {{ share.name }} + {% if share.plan.off %} + + {% endif %} + {{ share.share|floatformat:1 }}% + +
  • + {% endfor %} +
+
diff --git a/app/templates/investments/fragments/charts.html b/app/templates/investments/fragments/charts.html new file mode 100644 index 00000000..76268512 --- /dev/null +++ b/app/templates/investments/fragments/charts.html @@ -0,0 +1,149 @@ +{% load i18n %} +{% for chart in charts %} + {% with c=chart.currency %} + {% if charts|length > 1 %}
{{ c.name }}
{% endif %} +
+ {{ chart.data|json_script:chart.data_id }} + +
+
+
{% translate 'By asset class' %}
+ {% include 'investments/fragments/_allocation.html' with shares=chart.classes kind='classes' %} +
+
+ +
+
+
{% translate 'By currency' %}
+ {% include 'investments/fragments/_allocation.html' with shares=chart.currencies kind='currencies' %} +
+
+ +
+
+
{% translate 'Value and invested' %}
+
+
+
+ +
+
+
{% translate 'Return by investment' %}
+
+
+
+
+ {% endwith %} +{% empty %} + +{% endfor %} + + diff --git a/app/templates/investments/fragments/detail.html b/app/templates/investments/fragments/detail.html index 9495a6ab..98f1ba04 100644 --- a/app/templates/investments/fragments/detail.html +++ b/app/templates/investments/fragments/detail.html @@ -96,57 +96,127 @@ {% endif %} -
- +
+ {{ chart|json_script:"investment-chart-data" }} + {% if chart.price or chart.earnings or chart.income %} +
+ + {% if chart.price %}{% endif %} + {% if chart.earnings %}{% endif %} + {% if chart.income %}{% endif %} +
+ {% endif %} +
+ {% if chart.price %}{% endif %} + {% if chart.earnings %}{% endif %} + {% if chart.income %}{% endif %}
+
diff --git a/app/templates/investments/fragments/list.html b/app/templates/investments/fragments/list.html index c11add13..4f6404c2 100644 --- a/app/templates/investments/fragments/list.html +++ b/app/templates/investments/fragments/list.html @@ -17,6 +17,9 @@ {% endfor %}
+
+ +
{% for item in groups %}
@@ -85,6 +88,18 @@
{% endfor %} +
+ +
+
+
+ +
+
+
+
{% else %} + +{% elif total_target != 100 %} + +{% else %} + {% for plan in plans %} + {% with c=plan.currency %} +
+ {% if plans|length > 1 %}
{{ c.name }}
{% endif %} +
+ + + + + + + + + + + + {% for row in plan.rows %} + + + + + + + + {% endfor %} + +
{% translate 'Asset class' %}{% translate 'Value' %}{% translate 'Now' %}{% translate 'Target' %}{% translate 'To reach the target' %}
+ {{ row.class.name }} + {% if row.off %}{% endif %} + {{ row.share|floatformat:1 }}%{{ row.target|floatformat:"-2" }}% + {% if row.move > 0 %} + {% translate 'Buy' %} + {% elif row.move < 0 %} + {% translate 'Sell' %} + {% else %} + - + {% endif %} +
+
+ {% if plan.left_out %} +
+ {% translate 'Left out: investments without an asset class or a target' %} · + +
+ {% endif %} +
+ {% endwith %} + {% endfor %} +{% endif %} diff --git a/frontend/src/js/charts.js b/frontend/src/js/charts.js index ceb318e6..62b8664c 100644 --- a/frontend/src/js/charts.js +++ b/frontend/src/js/charts.js @@ -4,3 +4,23 @@ import zoomPlugin from 'chartjs-plugin-zoom'; Chart.register(SankeyController, Flow, zoomPlugin); window.Chart = Chart; + +// The theme's text color at a given strength, resolved to a value canvas can draw +// with. shortcut: read once per chart, so a theme switch shows on the next render. +window.chartInk = (percent) => { + const probe = document.createElement('span'); + probe.style.color = `color-mix(in oklab, var(--color-base-content) ${percent}%, transparent)`; + document.body.appendChild(probe); + const color = getComputedStyle(probe).color; + probe.remove(); + return color; +}; + +// Formats amounts with the user's separators, as the amounts elsewhere in the app. +// currency: {prefix, suffix, decimals, decimal, thousand} +window.chartMoney = (currency) => (v, decimals = currency.decimals) => { + const [whole, fraction] = Math.abs(v).toFixed(decimals).split('.'); + const grouped = whole.replace(/\B(?=(\d{3})+(?!\d))/g, currency.thousand); + const number = fraction ? `${grouped}${currency.decimal}${fraction}` : grouped; + return `${v < 0 ? '-' : ''}${currency.prefix}${number}${currency.suffix}`; +};