mirror of
https://github.com/eitchtee/WYGIWYH.git
synced 2026-11-13 12:23:44 +01:00
- 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
298 lines
10 KiB
Python
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"})
|