Files
WYGIWYH/app/apps/investments/views.py
T
Herculino Trotta 7038ce8e18 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
2026-11-08 02:26:40 -03:00

298 lines
10 KiB
Python

from django.contrib import messages
from django.contrib.auth.decorators import login_required
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.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
def _holdings():
return Account.objects.filter(is_investment=True, is_archived=False).select_related(
"group", "currency", "exchange_currency"
)
@login_required
def investments_index(request):
return render(request, "investments/pages/index.html")
@only_htmx
@login_required
def investments_list(request):
performances = [get_holding_performance(account) for account in _holdings()]
groups = {}
for perf in performances:
group = perf.account.group
groups.setdefault(group.id if group else None, (group, []))[1].append(perf)
return render(
request,
"investments/fragments/list.html",
{
"groups": [
{"group": group, "holdings": items, "totals": summarize(items)}
for group, items in sorted(
groups.values(), key=lambda g: (g[0] is None, g[0] and g[0].name)
)
],
"totals": summarize(performances, convert=True),
},
)
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(
Account, request, id=account_id, level=READ, is_investment=True
)
return render(request, "investments/pages/detail.html", {"account": account})
@only_htmx
@login_required
def investment_detail(request, account_id):
account = get_shared_object_or_error(
Account, request, id=account_id, level=READ, is_investment=True
)
perf = get_holding_performance(account, with_history=True)
transactions = (
Transaction.objects.filter(account=account)
.select_related("linked_transaction__account__currency", "category")
.order_by("-date", "-id")[:50]
)
income = []
entity_ids = income_entity_ids(account)
if entity_ids:
income = (
Transaction.objects.filter(
entities__in=entity_ids, type=Transaction.Type.INCOME
)
.exclude(account=account)
.distinct()
.select_related("account__currency")
.order_by("-date", "-id")[:20]
)
history = perf.history
income_flows = [(d, amount) for d, amount, kind in perf.flows if kind == "income"]
chart = {
"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(
request,
"investments/fragments/detail.html",
{
"account": account,
"perf": perf,
"home": in_home_currency(perf),
"transactions": transactions,
"income": income,
"income_entities": list(account.income_entities.all()),
"chart": 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"})