feat(investments): asset classes with target allocation, charts and rebalance

- Asset classes (name, optional target %) managed under Management > Investments,
  chosen per account
- Charts tab: allocation by asset class and currency, value against invested,
  return by investment; off-target classes flagged by the 5/25 rule
- Rebalance tab: what to buy or sell per class to reach the targets
- Investment page: price against average cost with trades, monthly earnings
  and income
This commit is contained in:
Herculino Trotta committed 2026-11-08 02:26:40 -03:00
1 parent 7902c4fc4e
commit 7038ce8e18
18 files changed
+1085 -63

No files matched your search

+66 -2
View File
@@ -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",
@@ -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'),
),
]
+48 -1
View File
@@ -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,
+1
View File
@@ -53,6 +53,7 @@ class AccountSerializer(serializers.ModelSerializer):
"exchange_currency_id",
"is_asset",
"is_investment",
"investment_class",
"income_entities",
"income_categories",
"income_tags",
+85
View File
@@ -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}
+127 -1
View File
@@ -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(
+31
View File
@@ -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/<int:pk>/edit/",
views.investment_class_edit,
name="investment_class_edit",
),
path(
"investments/classes/<int:pk>/delete/",
views.investment_class_delete,
name="investment_class_delete",
),
path(
"investments/<int:account_id>/",
views.investment_detail_index,
+199 -8
View File
@@ -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"})
+9 -1
View File
@@ -150,7 +150,7 @@
<c-components.sidebar-collapsible-panel
title="{% translate 'Management' %}"
icon="fa-solid fa-toolbox"
active="tags_index||entities_index||categories_index||accounts_index||account_groups_index||currencies_index||exchange_rates_index||rules_index||import_profiles_index||automatic_exchange_rates_index||export_index||users_index">
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">
<c-components.sidebar-menu-header title="{% translate 'Transactions' %}"></c-components.sidebar-menu-header>
<c-components.sidebar-menu-item
title="{% translate 'Categories' %}"
@@ -185,6 +185,14 @@
icon="fa-solid fa-wallet">
</c-components.sidebar-menu-item>
<c-components.sidebar-menu-header title="{% translate 'Investments' %}"></c-components.sidebar-menu-header>
<c-components.sidebar-menu-item
title="{% translate 'Asset classes' %}"
url='investment_classes_index'
active="investment_classes_index"
icon="fa-solid fa-chart-pie">
</c-components.sidebar-menu-item>
<c-components.sidebar-menu-header title="{% translate 'Currencies' %}"></c-components.sidebar-menu-header>
<c-components.sidebar-menu-item
title="{% translate 'Currencies' %}"
@@ -0,0 +1,11 @@
{% extends 'extends/offcanvas.html' %}
{% load i18n %}
{% load crispy_forms_tags %}
{% block title %}{% if object.pk %}{% translate 'Edit asset class' %}{% else %}{% translate 'Add asset class' %}{% endif %}{% endblock %}
{% block body %}
<form hx-post="{% if object.pk %}{% url 'investment_class_edit' pk=object.pk %}{% else %}{% url 'investment_class_add' %}{% endif %}" hx-target="#generic-offcanvas" novalidate>
{% crispy form %}
</form>
{% endblock %}
@@ -0,0 +1,78 @@
{% load i18n %}
<c-ui.fab-single-action
url="{% url 'investment_class_add' %}"
hx_target="#generic-offcanvas">
</c-ui.fab-single-action>
<div class="container">
<div class="text-3xl font-bold font-mono w-full mb-3">
{% spaceless %}
<div>{% translate 'Asset classes' %}</div>
{% endspaceless %}
</div>
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
{% if classes %}
{% if total_target and total_target != 100 %}
<div role="alert" class="alert alert-warning alert-soft">
<i class="fa-solid fa-triangle-exclamation"></i>
<span>{% blocktranslate with total=total_target|floatformat:"-2" %}Targets add up to {{ total }}%. Set them to 100% to see the rebalance.{% endblocktranslate %}</span>
</div>
{% endif %}
<div class="overflow-x-auto">
<table class="table table-zebra">
<thead>
<tr>
<th scope="col" class="table-col-auto"></th>
<th scope="col">{% translate 'Name' %}</th>
<th scope="col" class="text-end">{% translate 'Target' %}</th>
<th scope="col" class="text-end">{% translate 'Investments' %}</th>
</tr>
</thead>
<tbody>
{% for cls in classes %}
<tr>
<td class="table-col-auto">
<div class="join" role="group" aria-label="{% translate 'Actions' %}">
<a class="btn btn-secondary btn-sm join-item"
role="button"
data-tippy-content="{% translate "Edit" %}"
hx-get="{% url 'investment_class_edit' pk=cls.id %}"
hx-target="#generic-offcanvas">
<i class="fa-solid fa-pencil fa-fw"></i></a>
<a class="btn btn-error btn-sm join-item"
role="button"
data-tippy-content="{% translate "Delete" %}"
hx-delete="{% url 'investment_class_delete' pk=cls.id %}"
hx-trigger='confirmed'
data-bypass-on-ctrl="true"
data-title="{% translate "Are you sure?" %}"
data-text="{% translate "Its investments will be left without an asset class." %}"
data-confirm-text="{% translate "Yes, delete it!" %}"
_="install prompt_swal"><i class="fa-solid fa-trash fa-fw"></i></a>
</div>
</td>
<td>{{ cls.name }}</td>
<td class="text-end tabular-nums">{% if cls.target is not None %}{{ cls.target|floatformat:"-2" }}%{% else %}<span class="text-base-content/50">-</span>{% endif %}</td>
<td class="text-end tabular-nums">{{ cls.accounts.count }}</td>
</tr>
{% endfor %}
</tbody>
{% if total_target %}
<tfoot>
<tr>
<td></td>
<td>{% translate 'Total' %}</td>
<td class="text-end tabular-nums">{{ total_target|floatformat:"-2" }}%</td>
<td></td>
</tr>
</tfoot>
{% endif %}
</table>
</div>
{% else %}
<c-msg.empty title="{% translate "No asset classes" %}" remove-padding></c-msg.empty>
{% endif %}
</div>
</div>
</div>
@@ -0,0 +1,8 @@
{% extends "layouts/base.html" %}
{% load i18n %}
{% block title %}{% translate 'Asset classes' %}{% endblock %}
{% block content %}
<div hx-get="{% url 'investment_classes_list' %}" hx-trigger="load, updated from:window" class="show-loading"></div>
{% endblock %}
@@ -0,0 +1,18 @@
{% load i18n %}
<div class="flex flex-col sm:flex-row items-center gap-5">
<div class="size-40 shrink-0"><canvas data-chart="{{ kind }}" aria-label="{% translate 'Allocation chart' %}" role="img"></canvas></div>
<ul class="w-full flex flex-col gap-2 text-sm">
{% for share in shares %}
<li class="flex items-center gap-2">
<span class="size-2.5 rounded-full shrink-0" style="background: {{ share.color }}"></span>
<span class="truncate">{{ share.name }}</span>
{% if share.plan.off %}
<i class="fa-solid fa-triangle-exclamation text-warning text-xs"
data-tippy-content="{% blocktranslate with share=share.plan.share|floatformat:1 target=share.plan.target|floatformat:'-2' %}{{ share }}% of your investments with a target, against a target of {{ target }}%{% endblocktranslate %}"></i>
{% endif %}
<span class="ms-auto tabular-nums text-base-content/60">{{ share.share|floatformat:1 }}%</span>
<span class="w-28 text-end tabular-nums whitespace-nowrap"><c-amount.display :amount="share.amount" :prefix="c.prefix" :suffix="c.suffix" :decimal_places="c.decimal_places" divless></c-amount.display></span>
</li>
{% endfor %}
</ul>
</div>
@@ -0,0 +1,149 @@
{% load i18n %}
{% for chart in charts %}
{% with c=chart.currency %}
{% if charts|length > 1 %}<div class="font-semibold mb-2 {% if not forloop.first %}mt-6{% endif %}">{{ c.name }}</div>{% endif %}
<div class="grid grid-cols-1 lg:grid-cols-2 gap-3" data-investment-charts="{{ chart.data_id }}">
{{ chart.data|json_script:chart.data_id }}
<div class="card bg-base-100 card-border shadow">
<div class="card-body p-5">
<div class="font-semibold">{% translate 'By asset class' %}</div>
{% include 'investments/fragments/_allocation.html' with shares=chart.classes kind='classes' %}
</div>
</div>
<div class="card bg-base-100 card-border shadow">
<div class="card-body p-5">
<div class="font-semibold">{% translate 'By currency' %}</div>
{% include 'investments/fragments/_allocation.html' with shares=chart.currencies kind='currencies' %}
</div>
</div>
<div class="card bg-base-100 card-border shadow lg:col-span-2">
<div class="card-body p-5">
<div class="font-semibold">{% translate 'Value and invested' %}</div>
<div class="h-64"><canvas data-chart="history"></canvas></div>
</div>
</div>
<div class="card bg-base-100 card-border shadow lg:col-span-2">
<div class="card-body p-5">
<div class="font-semibold">{% translate 'Return by investment' %}</div>
<div style="height: {{ chart.returns_height }}px"><canvas data-chart="returns"></canvas></div>
</div>
</div>
</div>
{% endwith %}
{% empty %}
<c-msg.empty title="{% translate 'Nothing to chart yet' %}"></c-msg.empty>
{% endfor %}
<script>
document.querySelectorAll('[data-investment-charts]').forEach((root) => {
const data = JSON.parse(document.getElementById(root.dataset.investmentCharts).textContent);
const ink = chartInk(75);
const muted = chartInk(50);
const grid = chartInk(12);
const fmt = chartMoney(data.currency);
const tick = (v) => fmt(v, 0);
// A few cents below zero shouldn't stretch the axis down a whole step
const floor = (values) => (Math.min(...values) > -Math.max(...values) * 0.01 ? 0 : undefined);
const canvas = (name) => root.querySelector(`[data-chart="${name}"]`);
for (const kind of ['classes', 'currencies']) {
const el = canvas(kind);
if (!el) continue;
const total = data[kind].reduce((sum, row) => sum + row[1], 0);
new Chart(el, {
type: 'doughnut',
data: {
labels: data[kind].map((row) => row[0]),
datasets: [{
data: data[kind].map((row) => row[1]),
backgroundColor: data[kind].map((row) => row[2]),
borderColor: getComputedStyle(root).getPropertyValue('--color-base-100') || '#fff',
borderWidth: 2,
}],
},
options: {
responsive: true,
maintainAspectRatio: false,
cutout: '65%',
plugins: {
legend: { display: false },
tooltip: { callbacks: { label: (ctx) => ` ${fmt(ctx.raw)} · ${(ctx.raw / total * 100).toFixed(1)}%` } },
},
},
});
}
new Chart(canvas('history'), {
type: 'line',
data: {
labels: data.history.labels,
datasets: [
{
label: '{% translate "Value" %}',
data: data.history.value,
borderColor: '#2a78d6',
backgroundColor: 'rgba(42, 120, 214, 0.12)',
fill: true,
borderWidth: 2,
pointRadius: 0,
pointHoverRadius: 4,
},
{
label: '{% translate "Invested" %}',
data: data.history.invested,
borderColor: muted,
stepped: true,
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: ink, maxTicksLimit: 6, maxRotation: 0 } },
y: { beginAtZero: true, min: floor(data.history.value), border: { display: false }, grid: { color: grid }, ticks: { color: ink, maxTicksLimit: 5, callback: tick } },
},
plugins: {
legend: { align: 'end', labels: { color: ink, boxWidth: 12, boxHeight: 2 } },
tooltip: { callbacks: { label: (ctx) => ` ${ctx.dataset.label}: ${fmt(ctx.raw)}` } },
},
},
});
new Chart(canvas('returns'), {
type: 'bar',
data: {
labels: data.returns.map((row) => row[0]),
datasets: [{
data: data.returns.map((row) => row[1]),
backgroundColor: data.returns.map((row) => (row[1] < 0 ? '#d03b3b' : '#0ca30c')),
borderRadius: 4,
borderSkipped: false,
barThickness: 14,
}],
},
options: {
indexAxis: 'y',
responsive: true,
maintainAspectRatio: false,
scales: {
x: { border: { display: false }, grid: { color: grid }, ticks: { color: ink, maxTicksLimit: 5, callback: tick } },
y: { grid: { display: false }, border: { display: false }, ticks: { color: ink } },
},
plugins: {
legend: { display: false },
tooltip: { callbacks: { label: (ctx) => ` ${fmt(ctx.raw)}` } },
},
},
});
});
</script>
+120 -50
View File
@@ -96,57 +96,127 @@
{% 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>
{{ chart|json_script:"investment-chart-data" }}
{% if chart.price or chart.earnings or chart.income %}
<div class="tabs tabs-box tabs-sm w-fit mb-3" role="tablist" id="investment-chart-tabs">
<input type="radio" name="investment_chart" class="tab" role="tab" aria-label="{% translate 'Value' %}" value="value" checked>
{% if chart.price %}<input type="radio" name="investment_chart" class="tab" role="tab" aria-label="{% translate 'Price' %}" value="price">{% endif %}
{% if chart.earnings %}<input type="radio" name="investment_chart" class="tab" role="tab" aria-label="{% translate 'Earnings' %}" value="earnings">{% endif %}
{% if chart.income %}<input type="radio" name="investment_chart" class="tab" role="tab" aria-label="{% translate 'Income' %}" value="income">{% endif %}
</div>
{% endif %}
<div class="h-64" data-panel="value"><canvas data-chart="value"></canvas></div>
{% if chart.price %}<div class="h-64 hidden" data-panel="price"><canvas data-chart="price"></canvas></div>{% endif %}
{% if chart.earnings %}<div class="h-64 hidden" data-panel="earnings"><canvas data-chart="earnings"></canvas></div>{% endif %}
{% if chart.income %}<div class="h-64 hidden" data-panel="income"><canvas data-chart="income"></canvas></div>{% endif %}
</div>
<script>
(() => {
const data = JSON.parse(document.getElementById('investment-chart-data').textContent);
const root = document.getElementById('investment-chart-data').parentElement;
const ink = chartInk(75);
const muted = chartInk(50);
const grid = chartInk(12);
const fmt = chartMoney(data.currency);
const blue = '#2a78d6';
const canvas = (name) => root.querySelector(`[data-chart="${name}"]`);
const options = (extra = {}) => ({
responsive: true,
maintainAspectRatio: false,
interaction: { mode: 'index', intersect: false },
scales: {
x: { grid: { display: false }, ticks: { color: ink, maxTicksLimit: 6, maxRotation: 0 } },
y: { border: { display: false }, grid: { color: grid }, ticks: { color: ink, maxTicksLimit: 5, callback: (v) => fmt(v, 0) } },
},
plugins: {
legend: {
align: 'end',
labels: {
color: ink, boxWidth: 12, boxHeight: 2,
// A series with no points (no sales yet) has nothing to name
filter: (item, chart) => chart.datasets[item.datasetIndex].data.some((v) => v !== null),
},
},
tooltip: { callbacks: { label: (ctx) => ctx.raw === null ? null : ` ${ctx.dataset.label}: ${fmt(ctx.raw)}` } },
},
...extra,
});
const line = (label, values, color, more = {}) => ({
label, data: values, borderColor: color, borderWidth: 2, pointRadius: 0, pointHoverRadius: 4, pointStyle: 'line', spanGaps: true, ...more,
});
const dashed = { borderWidth: 1.5, borderDash: [4, 4], stepped: true };
const bars = (label, values) => ({
label,
data: values,
backgroundColor: values.map((v) => (v < 0 ? '#d03b3b' : '#0ca30c')),
borderRadius: 4,
maxBarThickness: 24,
});
new Chart(canvas('value'), {
type: 'line',
data: {
labels: data.labels,
datasets: [
line('{% translate "Value" %}', data.value, blue, { fill: true, backgroundColor: 'rgba(42, 120, 214, 0.12)' }),
line('{% translate "Invested" %}', data.invested, muted, dashed),
],
},
options: (() => { const o = options(); o.scales.y.beginAtZero = true; return o; })(),
});
if (data.price) {
const marker = (label, values, color, style) => ({
type: 'line', label, data: values, showLine: false, pointStyle: style, pointRadius: 6, pointHoverRadius: 8,
backgroundColor: color, borderColor: chartInk(100), borderWidth: 1,
});
new Chart(canvas('price'), {
type: 'line',
data: {
labels: data.labels,
datasets: [
line('{% translate "Price" %}', data.price, blue),
line('{% translate "Average cost" %}', data.average_cost, muted, dashed),
marker('{% translate "Buy" %}', data.buys, '#0ca30c', 'triangle'),
marker('{% translate "Sell" %}', data.sells, '#d03b3b', 'rectRot'),
],
},
options: (() => { const o = options(); o.plugins.legend.labels.usePointStyle = true; o.plugins.legend.labels.boxHeight = 8; return o; })(),
});
}
if (data.earnings) {
const opts = options();
// The month's result against what was invested at the end of the month before
opts.plugins.tooltip.callbacks.label = (ctx) => {
const base = data.invested[Math.max(ctx.dataIndex - 1, 0)];
const share = base ? ` (${(ctx.raw / base * 100).toFixed(2)}%)` : '';
return ` ${fmt(ctx.raw)}${share}`;
};
opts.plugins.legend.display = false;
new Chart(canvas('earnings'), {
type: 'bar',
data: { labels: data.labels, datasets: [bars('{% translate "Earnings" %}', data.earnings)] },
options: opts,
});
}
if (data.income) {
const opts = options();
opts.plugins.legend.display = false;
new Chart(canvas('income'), {
type: 'bar',
data: { labels: data.labels, datasets: [bars('{% translate "Income" %}', data.income)] },
options: opts,
});
}
root.querySelectorAll('input[name=investment_chart]').forEach((tab) => tab.addEventListener('change', () => {
root.querySelectorAll('[data-panel]').forEach((panel) => panel.classList.toggle('hidden', panel.dataset.panel !== tab.value));
}));
})();
</script>
<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>
@@ -17,6 +17,9 @@
{% endfor %}
</div>
<div role="tablist" class="tabs tabs-border">
<input type="radio" name="investment_tabs" role="tab" class="tab" aria-label="{% translate 'Holdings' %}" checked="checked"/>
<div role="tabpanel" class="tab-content pt-4">
{% 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">
@@ -85,6 +88,18 @@
</div>
</div>
{% endfor %}
</div>
<input type="radio" name="investment_tabs" role="tab" class="tab" aria-label="{% translate 'Charts' %}"
hx-get="{% url 'investments_charts' %}" hx-trigger="click once" hx-target="#investment-charts" hx-indicator="#investment-charts"/>
<div role="tabpanel" class="tab-content pt-4">
<div id="investment-charts" class="show-loading min-h-40"></div>
</div>
<input type="radio" name="investment_tabs" role="tab" class="tab" aria-label="{% translate 'Rebalance' %}"
hx-get="{% url 'investments_rebalance' %}" hx-trigger="click once" hx-target="#investment-rebalance" hx-indicator="#investment-rebalance"/>
<div role="tabpanel" class="tab-content pt-4">
<div id="investment-rebalance" class="show-loading min-h-40"></div>
</div>
</div>
{% else %}
<c-msg.empty
title="{% translate 'No investments yet' %}"
@@ -0,0 +1,62 @@
{% load i18n %}
{% if not targeted %}
<c-msg.empty
title="{% translate 'No allocation targets' %}"
subtitle="{% translate 'Set a target for your asset classes to see what to buy and sell' %}"></c-msg.empty>
<div class="text-center"><a class="btn btn-sm" href="{% url 'investment_classes_index' %}">{% translate 'Asset classes' %}</a></div>
{% elif total_target != 100 %}
<div role="alert" class="alert alert-warning alert-soft">
<i class="fa-solid fa-triangle-exclamation"></i>
<span>{% blocktranslate with total=total_target|floatformat:"-2" %}Targets add up to {{ total }}%. Set them to 100% to see the rebalance.{% endblocktranslate %}</span>
<a class="btn btn-sm" href="{% url 'investment_classes_index' %}">{% translate 'Asset classes' %}</a>
</div>
{% else %}
{% for plan in plans %}
{% with c=plan.currency %}
<div class="card bg-base-100 card-border shadow mb-4">
{% if plans|length > 1 %}<div class="font-semibold px-5 pt-4">{{ c.name }}</div>{% endif %}
<div class="overflow-x-auto">
<table class="table">
<thead>
<tr class="text-xs text-base-content/50">
<th class="font-normal">{% translate 'Asset class' %}</th>
<th class="font-normal text-end">{% translate 'Value' %}</th>
<th class="font-normal text-end">{% translate 'Now' %}</th>
<th class="font-normal text-end">{% translate 'Target' %}</th>
<th class="font-normal text-end">{% translate 'To reach the target' %}</th>
</tr>
</thead>
<tbody>
{% for row in plan.rows %}
<tr>
<td class="font-medium">
{{ row.class.name }}
{% if row.off %}<i class="fa-solid fa-triangle-exclamation text-warning text-xs" data-tippy-content="{% translate 'Off target' %}"></i>{% endif %}
</td>
<td class="text-end tabular-nums whitespace-nowrap"><c-amount.display :amount="row.value" :prefix="c.prefix" :suffix="c.suffix" :decimal_places="c.decimal_places" divless></c-amount.display></td>
<td class="text-end tabular-nums">{{ row.share|floatformat:1 }}%</td>
<td class="text-end tabular-nums text-base-content/70">{{ row.target|floatformat:"-2" }}%</td>
<td class="text-end tabular-nums whitespace-nowrap">
{% if row.move > 0 %}
<span class="text-success">{% translate 'Buy' %}</span> <c-amount.display :amount="row.size" :prefix="c.prefix" :suffix="c.suffix" :decimal_places="c.decimal_places" divless></c-amount.display>
{% elif row.move < 0 %}
<span class="text-error">{% translate 'Sell' %}</span> <c-amount.display :amount="row.size" :prefix="c.prefix" :suffix="c.suffix" :decimal_places="c.decimal_places" divless></c-amount.display>
{% else %}
<span class="text-base-content/50">-</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% if plan.left_out %}
<div class="text-xs text-base-content/60 px-5 pb-4">
{% translate 'Left out: investments without an asset class or a target' %} ·
<c-amount.display :amount="plan.left_out" :prefix="c.prefix" :suffix="c.suffix" :decimal_places="c.decimal_places" divless></c-amount.display>
</div>
{% endif %}
</div>
{% endwith %}
{% endfor %}
{% endif %}
+20
View File
@@ -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}`;
};