mirror of
https://github.com/eitchtee/WYGIWYH.git
synced 2026-11-13 12:23:44 +01:00
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:
1 parent
7902c4fc4e
commit
7038ce8e18
18 files changed
+1085
-63
No files matched your search
@@ -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'),
|
||||
),
|
||||
]
|
||||
@@ -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,
|
||||
|
||||
@@ -53,6 +53,7 @@ class AccountSerializer(serializers.ModelSerializer):
|
||||
"exchange_currency_id",
|
||||
"is_asset",
|
||||
"is_investment",
|
||||
"investment_class",
|
||||
"income_entities",
|
||||
"income_categories",
|
||||
"income_tags",
|
||||
|
||||
@@ -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}
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"})
|
||||
@@ -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>
|
||||
@@ -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 %}
|
||||
@@ -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}`;
|
||||
};
|
||||
Reference in new issue
Block a user