mirror of
https://github.com/eitchtee/WYGIWYH.git
synced 2026-11-13 12:23:44 +01:00
feat(investments): add investment tracker
Investments are accounts flagged as investments; performance is derived from their transactions. Linked transfers are contributions and withdrawals (average cost, realized gains), unlinked transactions are gains or losses, and income comes from the accounts' income entities. - Unit-priced (exchange currency) or value-tracked accounts - Money-weighted return (XIRR), annualized after a year - Totals converted along each currency's exchange chain, with currency effect - Investments list and detail pages; buy/sell/income use the transfer and transaction forms - GET /api/accounts/<id>/performance/
This commit is contained in:
1 parent
1b9ea452d9
commit
359e5880cc
23 files changed
+1366
-3
No files matched your search
@@ -77,6 +77,7 @@ INSTALLED_APPS = [
|
||||
"apps.rules.apps.RulesConfig",
|
||||
"apps.calendar_view.apps.CalendarViewConfig",
|
||||
"apps.dca.apps.DcaConfig",
|
||||
"apps.investments.apps.InvestmentsConfig",
|
||||
"pwa",
|
||||
"allauth",
|
||||
"allauth.account",
|
||||
|
||||
@@ -93,6 +93,7 @@ urlpatterns = [
|
||||
path("", include("apps.rules.urls")),
|
||||
path("", include("apps.calendar_view.urls")),
|
||||
path("", include("apps.dca.urls")),
|
||||
path("", include("apps.investments.urls")),
|
||||
path("", include("apps.mini_tools.urls")),
|
||||
path("", include("apps.import_app.urls")),
|
||||
path("", include("apps.export_app.urls")),
|
||||
|
||||
@@ -9,12 +9,16 @@ from apps.common.widgets.crispy.daisyui import Switch
|
||||
from apps.common.widgets.crispy.submit import NoClassSubmit
|
||||
from apps.common.widgets.datepicker import AirDatePickerInput, AirMonthYearPickerInput
|
||||
from apps.common.widgets.decimal import ArbitraryDecimalDisplayNumberInput
|
||||
from apps.common.widgets.tom_select import TomSelect
|
||||
from apps.common.widgets.tom_select import TomSelect, TomSelectMultiple
|
||||
from apps.currencies.models import Currency
|
||||
from apps.transactions.models import TransactionCategory, TransactionTag
|
||||
from apps.transactions.models import (
|
||||
TransactionCategory,
|
||||
TransactionEntity,
|
||||
TransactionTag,
|
||||
)
|
||||
from crispy_forms.bootstrap import FormActions
|
||||
from crispy_forms.helper import FormHelper
|
||||
from crispy_forms.layout import Column, Field, Layout, Row
|
||||
from crispy_forms.layout import HTML, Column, Field, Layout, Row
|
||||
from django import forms
|
||||
from django.db.models import Q
|
||||
from django.utils import timezone
|
||||
@@ -68,16 +72,25 @@ class AccountForm(forms.ModelForm):
|
||||
"exchange_currency",
|
||||
"is_asset",
|
||||
"is_archived",
|
||||
"is_investment",
|
||||
"income_entities",
|
||||
]
|
||||
widgets = {
|
||||
"currency": TomSelect(),
|
||||
"exchange_currency": TomSelect(),
|
||||
"income_entities": TomSelectMultiple(),
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.fields["group"].queryset = AccountGroup.objects.all()
|
||||
entities = Q(active=True)
|
||||
if self.instance.id:
|
||||
entities |= Q(investment_accounts=self.instance.id)
|
||||
self.fields["income_entities"].queryset = TransactionEntity.objects.filter(
|
||||
entities
|
||||
).distinct()
|
||||
|
||||
if self.instance.id:
|
||||
qs = Currency.objects.filter(
|
||||
@@ -101,6 +114,17 @@ class AccountForm(forms.ModelForm):
|
||||
Switch("is_archived"),
|
||||
"currency",
|
||||
"exchange_currency",
|
||||
Switch("is_investment"),
|
||||
# Only relevant for investments: shown while the switch above is on
|
||||
Field(
|
||||
"income_entities",
|
||||
wrapper_class="" if self.instance.is_investment else "hidden",
|
||||
),
|
||||
HTML(
|
||||
'<div class="hidden" _="on change from closest <form/> '
|
||||
"if #id_is_investment.checked remove .hidden from #div_id_income_entities "
|
||||
'else add .hidden to #div_id_income_entities end"></div>'
|
||||
),
|
||||
)
|
||||
|
||||
if self.instance and self.instance.pk:
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
# Generated by Django 5.2.17 on 2026-11-05 01:12
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('accounts', '0016_account_untracked_by'),
|
||||
('transactions', '0052_transfer_templates'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='account',
|
||||
name='income_entity',
|
||||
field=models.ForeignKey(blank=True, help_text="Income transactions with this entity (dividends, interest, rent) count towards this investment's return.", null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='investment_accounts', to='transactions.transactionentity', verbose_name='Income entity'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='account',
|
||||
name='is_investment',
|
||||
field=models.BooleanField(default=False, help_text='Show this account in the Investments tracker. Set an exchange currency to price it by units (stocks, ETFs, crypto); leave it empty to track it by value (fixed income, property, pensions).', verbose_name='Investment'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,43 @@
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def copy_income_entity(apps, schema_editor):
|
||||
Account = apps.get_model("accounts", "Account")
|
||||
for account in Account.objects.exclude(income_entity=None):
|
||||
account.income_entities.add(account.income_entity_id)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("accounts", "0017_account_income_entity_account_is_investment"),
|
||||
("transactions", "0052_transfer_templates"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
# Free the "investment_accounts" reverse name for the new field
|
||||
migrations.AlterField(
|
||||
model_name="account",
|
||||
name="income_entity",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="+",
|
||||
to="transactions.transactionentity",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="account",
|
||||
name="income_entities",
|
||||
field=models.ManyToManyField(
|
||||
blank=True,
|
||||
help_text="Income transactions with any of these entities (dividends, interest, rent) count towards this investment's return.",
|
||||
related_name="investment_accounts",
|
||||
to="transactions.transactionentity",
|
||||
verbose_name="Income entities",
|
||||
),
|
||||
),
|
||||
migrations.RunPython(copy_income_entity, migrations.RunPython.noop),
|
||||
migrations.RemoveField(model_name="account", name="income_entity"),
|
||||
]
|
||||
@@ -57,6 +57,25 @@ class Account(SharedObject):
|
||||
"Asset accounts count towards your Net Worth, but not towards your month."
|
||||
),
|
||||
)
|
||||
is_investment = models.BooleanField(
|
||||
default=False,
|
||||
verbose_name=_("Investment"),
|
||||
help_text=_(
|
||||
"Show this account in the Investments tracker. Set an exchange currency to "
|
||||
"price it by units (stocks, ETFs, crypto); leave it empty to track it by "
|
||||
"value (fixed income, property, pensions)."
|
||||
),
|
||||
)
|
||||
income_entities = models.ManyToManyField(
|
||||
"transactions.TransactionEntity",
|
||||
blank=True,
|
||||
related_name="investment_accounts",
|
||||
verbose_name=_("Income entities"),
|
||||
help_text=_(
|
||||
"Income transactions with any of these entities (dividends, interest, "
|
||||
"rent) count towards this investment's return."
|
||||
),
|
||||
)
|
||||
is_archived = models.BooleanField(
|
||||
default=False,
|
||||
verbose_name=_("Archived"),
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
from decimal import Decimal
|
||||
|
||||
from django.db.models import Q
|
||||
from rest_framework import serializers
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
@@ -50,6 +52,8 @@ class AccountSerializer(serializers.ModelSerializer):
|
||||
"exchange_currency",
|
||||
"exchange_currency_id",
|
||||
"is_asset",
|
||||
"is_investment",
|
||||
"income_entities",
|
||||
]
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
@@ -76,3 +80,44 @@ class AccountBalanceSerializer(serializers.Serializer):
|
||||
projected_balance = serializers.DecimalField(max_digits=20, decimal_places=10)
|
||||
currency = CurrencySerializer()
|
||||
|
||||
|
||||
|
||||
class InvestmentPerformanceSerializer(serializers.Serializer):
|
||||
"""Serializer for an investment account's performance."""
|
||||
|
||||
currency = CurrencySerializer()
|
||||
priced = serializers.BooleanField()
|
||||
quantity = serializers.DecimalField(max_digits=42, decimal_places=30)
|
||||
price = serializers.DecimalField(max_digits=42, decimal_places=30, allow_null=True)
|
||||
price_date = serializers.DateTimeField(allow_null=True)
|
||||
average_cost = serializers.DecimalField(
|
||||
max_digits=42, decimal_places=30, allow_null=True
|
||||
)
|
||||
cost_basis = serializers.DecimalField(max_digits=42, decimal_places=30)
|
||||
market_value = serializers.DecimalField(
|
||||
max_digits=42, decimal_places=30, allow_null=True
|
||||
)
|
||||
unrealized = serializers.DecimalField(
|
||||
max_digits=42, decimal_places=30, allow_null=True
|
||||
)
|
||||
realized = serializers.DecimalField(max_digits=42, decimal_places=30)
|
||||
income = serializers.DecimalField(max_digits=42, decimal_places=30)
|
||||
contributed = serializers.DecimalField(max_digits=42, decimal_places=30)
|
||||
withdrawn = serializers.DecimalField(max_digits=42, decimal_places=30)
|
||||
total_return = serializers.DecimalField(max_digits=42, decimal_places=30)
|
||||
return_percentage = serializers.DecimalField(
|
||||
max_digits=42, decimal_places=30, allow_null=True
|
||||
)
|
||||
estimated = serializers.BooleanField()
|
||||
money_weighted_return = serializers.SerializerMethodField(
|
||||
help_text="Return weighted by when money went in and out, in percent: "
|
||||
"annual (XIRR) when the first contribution is a year old or more, "
|
||||
"otherwise over the period held."
|
||||
)
|
||||
money_weighted_annualized = serializers.SerializerMethodField()
|
||||
|
||||
def get_money_weighted_return(self, perf) -> Decimal | None:
|
||||
return perf.money_weighted and perf.money_weighted["percentage"]
|
||||
|
||||
def get_money_weighted_annualized(self, perf) -> bool | None:
|
||||
return perf.money_weighted and perf.money_weighted["annualized"]
|
||||
@@ -11,7 +11,9 @@ from apps.api.serializers import (
|
||||
AccountGroupSerializer,
|
||||
AccountSerializer,
|
||||
AccountBalanceSerializer,
|
||||
InvestmentPerformanceSerializer,
|
||||
)
|
||||
from apps.investments.services import get_holding_performance
|
||||
|
||||
|
||||
class AccountGroupViewSet(viewsets.ModelViewSet):
|
||||
@@ -38,6 +40,11 @@ class AccountGroupViewSet(viewsets.ModelViewSet):
|
||||
description="Returns the current and projected balance for the account, along with currency data.",
|
||||
responses={200: AccountBalanceSerializer},
|
||||
),
|
||||
performance=extend_schema(
|
||||
summary="Get investment performance",
|
||||
description="Returns quantity, cost basis, market value, realized/unrealized gains, income and total return, in the account's exchange currency (or its own currency when it has none).",
|
||||
responses={200: InvestmentPerformanceSerializer},
|
||||
),
|
||||
)
|
||||
class AccountViewSet(viewsets.ModelViewSet):
|
||||
"""ViewSet for managing accounts."""
|
||||
@@ -51,6 +58,7 @@ class AccountViewSet(viewsets.ModelViewSet):
|
||||
"currency": ["exact"],
|
||||
"exchange_currency": ["exact", "isnull"],
|
||||
"is_asset": ["exact"],
|
||||
"is_investment": ["exact"],
|
||||
"is_archived": ["exact"],
|
||||
"owner": ["exact"],
|
||||
}
|
||||
@@ -80,3 +88,11 @@ class AccountViewSet(viewsets.ModelViewSet):
|
||||
)
|
||||
|
||||
return Response(serializer.data)
|
||||
|
||||
@action(detail=True, methods=["get"], permission_classes=[IsAuthenticated])
|
||||
def performance(self, request, pk=None):
|
||||
"""Get investment performance (cost basis, value, returns) for an account."""
|
||||
account = self.get_object()
|
||||
return Response(
|
||||
InvestmentPerformanceSerializer(get_holding_performance(account)).data
|
||||
)
|
||||
Whitespace-only changes.
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class InvestmentsConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "apps.investments"
|
||||
@@ -0,0 +1,391 @@
|
||||
"""Investment performance, derived from an account's transactions.
|
||||
|
||||
Rules (see the account's "Investment" help text):
|
||||
- A linked transaction (a transfer) moves money in or out. Its other side says
|
||||
what was paid or received, which is the cost or the proceeds.
|
||||
- An unlinked transaction is a gain or a loss: a balance reconciliation, interest,
|
||||
a tax withheld. On a unit-priced account an unlinked inflow (a gift, an opening
|
||||
balance) has no known cost, so it is costed at the market price of that day
|
||||
and the result is flagged as estimated.
|
||||
- Cost is tracked with the average cost method.
|
||||
"""
|
||||
|
||||
import datetime
|
||||
from dataclasses import dataclass, field
|
||||
from decimal import Decimal
|
||||
from functools import cached_property
|
||||
|
||||
from django.db.models import Q
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.currencies.models import ExchangeRate
|
||||
from apps.currencies.utils.convert import convert, get_exchange_rate
|
||||
from apps.transactions.models import Transaction
|
||||
|
||||
ZERO = Decimal("0")
|
||||
|
||||
|
||||
@dataclass
|
||||
class HoldingPerformance:
|
||||
account: object
|
||||
currency: object # valuation currency
|
||||
priced: bool # True: units x price. False: tracked by value.
|
||||
quantity: Decimal = ZERO
|
||||
cost_basis: Decimal = ZERO
|
||||
price: Decimal | None = None
|
||||
price_date: datetime.datetime | None = None
|
||||
realized: Decimal = ZERO
|
||||
income: Decimal = ZERO
|
||||
contributed: Decimal = ZERO
|
||||
withdrawn: Decimal = ZERO
|
||||
estimated: bool = False
|
||||
history: 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.
|
||||
flows: list = field(default_factory=list)
|
||||
as_of: datetime.date | None = None
|
||||
|
||||
@cached_property
|
||||
def money_weighted(self):
|
||||
return money_weighted_return(self.flows, self.market_value, self.as_of)
|
||||
|
||||
@property
|
||||
def average_cost(self):
|
||||
return self.cost_basis / self.quantity if self.quantity else None
|
||||
|
||||
@property
|
||||
def market_value(self):
|
||||
if self.price is None:
|
||||
return None
|
||||
return self.quantity * self.price
|
||||
|
||||
@property
|
||||
def unrealized(self):
|
||||
value = self.market_value
|
||||
return None if value is None else value - self.cost_basis
|
||||
|
||||
@property
|
||||
def total_return(self):
|
||||
return (self.unrealized or ZERO) + self.realized + self.income
|
||||
|
||||
@property
|
||||
def return_percentage(self):
|
||||
if not self.contributed:
|
||||
return None
|
||||
return self.total_return / self.contributed * 100
|
||||
|
||||
@property
|
||||
def unrealized_percentage(self):
|
||||
if not self.cost_basis or self.unrealized is None:
|
||||
return None
|
||||
return self.unrealized / self.cost_basis * 100
|
||||
|
||||
|
||||
def _to(amount, from_currency, to_currency, date):
|
||||
if from_currency == to_currency or not amount:
|
||||
return amount
|
||||
return convert(amount, from_currency, to_currency, date)[0]
|
||||
|
||||
|
||||
def _price(account, valuation_currency, date):
|
||||
if account.currency == valuation_currency:
|
||||
return Decimal("1"), None
|
||||
rate = get_exchange_rate(account.currency, valuation_currency, date)
|
||||
if rate is None:
|
||||
return None, None
|
||||
return rate.effective_rate, rate.date
|
||||
|
||||
|
||||
def income_entity_ids(account):
|
||||
# The join table, not account.income_entities: that goes through the
|
||||
# entities' per-user manager and comes back empty without a current user
|
||||
return list(
|
||||
account.income_entities.through.objects.filter(account=account).values_list(
|
||||
"transactionentity_id", flat=True
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def get_holding_performance(account, as_of=None, with_history=False):
|
||||
"""Performance of an investment account up to ``as_of`` (today by default),
|
||||
in the account's exchange currency, or its own currency if it has none."""
|
||||
today = timezone.localdate()
|
||||
as_of = as_of or today
|
||||
valuation_currency = account.exchange_currency or account.currency
|
||||
perf = HoldingPerformance(
|
||||
account=account,
|
||||
currency=valuation_currency,
|
||||
priced=account.exchange_currency_id is not None,
|
||||
)
|
||||
|
||||
transactions = (
|
||||
Transaction.objects.filter(account=account, is_paid=True, date__lte=as_of)
|
||||
.select_related("linked_transaction__account__currency")
|
||||
.order_by("date", "id")
|
||||
)
|
||||
|
||||
month_ends = _month_ends(transactions, as_of) if with_history else []
|
||||
|
||||
for tx in transactions:
|
||||
while month_ends and month_ends[0] < tx.date:
|
||||
perf.history.append(_snapshot(perf, account, month_ends.pop(0)))
|
||||
|
||||
# shortcut: only the forward link is read; WYGIWYH transfers link both ways.
|
||||
partner = tx.linked_transaction
|
||||
if partner and (partner.account_id == account.id or partner.deleted):
|
||||
partner = None
|
||||
partner_value = (
|
||||
_to(partner.amount, partner.account.currency, valuation_currency, tx.date)
|
||||
if partner
|
||||
else None
|
||||
)
|
||||
|
||||
if tx.type == Transaction.Type.INCOME:
|
||||
if partner_value is not None:
|
||||
cost = partner_value
|
||||
perf.contributed += cost
|
||||
elif perf.priced or partner:
|
||||
# No known cost: price it at that day's market value
|
||||
cost = _to(tx.amount, account.currency, valuation_currency, tx.date)
|
||||
perf.estimated = True
|
||||
if partner:
|
||||
perf.contributed += cost or ZERO
|
||||
else:
|
||||
cost = ZERO # value-tracked: an unlinked inflow is a gain
|
||||
if cost:
|
||||
perf.flows.append((tx.date, -cost, "in"))
|
||||
perf.quantity += tx.amount
|
||||
perf.cost_basis += cost or ZERO
|
||||
else:
|
||||
if perf.quantity > 0:
|
||||
removed = perf.cost_basis * min(tx.amount, perf.quantity) / perf.quantity
|
||||
else:
|
||||
removed = ZERO
|
||||
if partner:
|
||||
proceeds = partner_value
|
||||
if proceeds is None:
|
||||
proceeds = _to(tx.amount, account.currency, valuation_currency, tx.date) or ZERO
|
||||
perf.estimated = True
|
||||
perf.realized += proceeds - removed
|
||||
perf.withdrawn += proceeds
|
||||
perf.flows.append((tx.date, proceeds, "out"))
|
||||
perf.cost_basis -= removed
|
||||
# unlinked outflow: a loss, tax or fee; cost stays, so value drops
|
||||
perf.quantity -= tx.amount
|
||||
if perf.quantity <= 0 and perf.cost_basis:
|
||||
perf.realized -= perf.cost_basis
|
||||
perf.cost_basis = ZERO
|
||||
|
||||
for month_end in month_ends:
|
||||
perf.history.append(_snapshot(perf, account, month_end))
|
||||
|
||||
perf.price, perf.price_date = _price(
|
||||
account,
|
||||
valuation_currency,
|
||||
timezone.now() if as_of == today else as_of,
|
||||
)
|
||||
|
||||
entity_ids = income_entity_ids(account)
|
||||
if entity_ids:
|
||||
income = Transaction.objects.filter(
|
||||
entities__in=entity_ids,
|
||||
type=Transaction.Type.INCOME,
|
||||
is_paid=True,
|
||||
date__lte=as_of,
|
||||
).exclude(account=account).distinct().select_related("account__currency")
|
||||
for tx in income:
|
||||
amount = _to(tx.amount, tx.account.currency, valuation_currency, tx.date)
|
||||
if amount:
|
||||
perf.income += amount
|
||||
perf.flows.append((tx.date, amount, "income"))
|
||||
|
||||
perf.as_of = as_of
|
||||
return perf
|
||||
|
||||
|
||||
def xirr(flows):
|
||||
"""Annual rate r where the dated flows' present value is zero, as a float
|
||||
(0.138 = 13.8% a year). None when it can't be solved."""
|
||||
if not flows:
|
||||
return None
|
||||
start = min(flow[0] for flow in flows)
|
||||
points = [((flow[0] - start).days / 365, float(flow[1])) for flow in flows]
|
||||
if not (any(a < 0 for _, a in points) and any(a > 0 for _, a in points)):
|
||||
return None
|
||||
|
||||
def npv(rate):
|
||||
return sum(a / (1 + rate) ** t for t, a in points)
|
||||
|
||||
# Bisection: slower than Newton but can't diverge on odd cash flows
|
||||
low, high = -0.9999, 100.0
|
||||
npv_low = npv(low)
|
||||
if npv_low * npv(high) > 0:
|
||||
return None
|
||||
for _ in range(200):
|
||||
mid = (low + high) / 2
|
||||
npv_mid = npv(mid)
|
||||
if npv_low * npv_mid <= 0:
|
||||
high = mid
|
||||
else:
|
||||
low, npv_low = mid, npv_mid
|
||||
return (low + high) / 2
|
||||
|
||||
|
||||
def money_weighted_return(flows, market_value, as_of):
|
||||
"""Return that accounts for when money went in and out, as
|
||||
``{"percentage": Decimal, "annualized": bool}`` or None.
|
||||
|
||||
Over a year or more it is annualized (XIRR). Below a year an annual rate
|
||||
exaggerates, so it is turned back into the return over the period held."""
|
||||
if not flows or market_value is None:
|
||||
return None
|
||||
rate = xirr(flows + [(as_of, market_value)])
|
||||
if rate is None:
|
||||
return None
|
||||
days = (as_of - min(flow[0] for flow in flows)).days
|
||||
if days >= 365:
|
||||
return {"percentage": Decimal(str(round(rate * 100, 4))), "annualized": True}
|
||||
period = (1 + rate) ** (days / 365) - 1
|
||||
return {"percentage": Decimal(str(round(period * 100, 4))), "annualized": False}
|
||||
|
||||
|
||||
def _month_ends(transactions, as_of):
|
||||
first = transactions.values_list("date", flat=True).first()
|
||||
if not first:
|
||||
return []
|
||||
ends = []
|
||||
year, month = first.year, first.month
|
||||
while True:
|
||||
next_first = datetime.date(year + month // 12, month % 12 + 1, 1)
|
||||
end = next_first - datetime.timedelta(days=1)
|
||||
if end >= as_of:
|
||||
ends.append(as_of)
|
||||
return ends
|
||||
ends.append(end)
|
||||
year, month = next_first.year, next_first.month
|
||||
|
||||
|
||||
def _snapshot(perf, account, date):
|
||||
price, _ = _price(account, perf.currency, date)
|
||||
return {
|
||||
"date": date,
|
||||
"invested": perf.cost_basis,
|
||||
"value": perf.quantity * price if price is not None else None,
|
||||
}
|
||||
|
||||
|
||||
def conversion_path(currency):
|
||||
"""``currency`` followed by its exchange currencies (USD -> BRL -> ...), for as
|
||||
long as an exchange rate between each step exists."""
|
||||
path = [currency]
|
||||
while True:
|
||||
target = path[-1].exchange_currency
|
||||
if (
|
||||
target is None
|
||||
or target in path
|
||||
or get_exchange_rate(path[-1], target, timezone.now()) is None
|
||||
):
|
||||
return path
|
||||
path.append(target)
|
||||
|
||||
|
||||
def _along(amount, path, date):
|
||||
for from_currency, to_currency in zip(path, path[1:]):
|
||||
amount = _to(amount, from_currency, to_currency, date)
|
||||
return amount
|
||||
|
||||
|
||||
def _native(perf):
|
||||
return {
|
||||
"currency": perf.currency,
|
||||
"market_value": perf.market_value,
|
||||
"cost_basis": perf.cost_basis,
|
||||
"income": perf.income,
|
||||
"total_return": perf.total_return,
|
||||
"currency_effect": ZERO,
|
||||
"flows": perf.flows,
|
||||
}
|
||||
|
||||
|
||||
def in_home_currency(perf):
|
||||
"""The holding converted along its currency's exchange chain, each cash flow
|
||||
at its own date's rate, so currency moves count towards the return.
|
||||
None when its currency converts to nothing."""
|
||||
path = conversion_path(perf.currency)
|
||||
if len(path) == 1:
|
||||
return None
|
||||
flows = [(d, _along(a, path, d), kind) for d, a, kind in perf.flows]
|
||||
value = None
|
||||
if perf.market_value is not None:
|
||||
value = _along(perf.market_value, path, perf.as_of)
|
||||
total_return = (value or ZERO) + sum((a for _, a, _ in flows), ZERO)
|
||||
|
||||
put_in = -sum((a for _, a, k in flows if k == "in"), ZERO)
|
||||
put_in_native = -sum((a for _, a, k in perf.flows if k == "in"), ZERO)
|
||||
# shortcut: what you still hold is valued at the average rate you paid in,
|
||||
# not lot by lot; exact for buy-and-hold, approximate after partial sales.
|
||||
cost_basis = (
|
||||
perf.cost_basis * put_in / put_in_native
|
||||
if put_in_native
|
||||
else _along(perf.cost_basis, path, perf.as_of)
|
||||
)
|
||||
return {
|
||||
"currency": path[-1],
|
||||
"path": path,
|
||||
"market_value": value,
|
||||
"cost_basis": cost_basis,
|
||||
"income": sum((a for _, a, k in flows if k == "income"), ZERO),
|
||||
"total_return": total_return,
|
||||
# what the return gained or lost from the exchange rate moving
|
||||
"currency_effect": total_return
|
||||
- _along(perf.total_return, path, perf.as_of),
|
||||
# With a single rate every date converts the same, so the effect reads 0
|
||||
"has_rate_history": all(
|
||||
ExchangeRate.objects.filter(
|
||||
Q(from_currency=a, to_currency=b) | Q(from_currency=b, to_currency=a)
|
||||
)[:2].count()
|
||||
> 1
|
||||
for a, b in zip(path, path[1:])
|
||||
),
|
||||
"flows": flows,
|
||||
"money_weighted": money_weighted_return(flows, value, perf.as_of),
|
||||
}
|
||||
|
||||
|
||||
def summarize(performances, convert=False):
|
||||
"""Totals per currency. With ``convert``, holdings are first converted along
|
||||
their exchange chain (see in_home_currency); the ones that can't be stay in
|
||||
their own currency."""
|
||||
totals = {}
|
||||
for perf in performances:
|
||||
figures = (convert and in_home_currency(perf)) or _native(perf)
|
||||
total = totals.setdefault(
|
||||
figures["currency"].id,
|
||||
{
|
||||
"currency": figures["currency"],
|
||||
"market_value": ZERO,
|
||||
"cost_basis": ZERO,
|
||||
"total_return": ZERO,
|
||||
"income": ZERO,
|
||||
"currency_effect": ZERO,
|
||||
"converted_from": [],
|
||||
"has_rate_history": True,
|
||||
"flows": [],
|
||||
"as_of": perf.as_of,
|
||||
},
|
||||
)
|
||||
for key in ("cost_basis", "total_return", "income", "currency_effect"):
|
||||
total[key] += figures[key]
|
||||
total["has_rate_history"] &= figures.get("has_rate_history", True)
|
||||
if figures["currency"] != perf.currency and perf.currency not in total["converted_from"]:
|
||||
total["converted_from"].append(perf.currency)
|
||||
# A holding without a price has no end value, so its flows would skew the rate
|
||||
if figures["market_value"] is not None:
|
||||
total["market_value"] += figures["market_value"]
|
||||
total["flows"] += figures["flows"]
|
||||
for total in totals.values():
|
||||
total["money_weighted"] = money_weighted_return(
|
||||
total["flows"], total["market_value"], total["as_of"]
|
||||
)
|
||||
return list(totals.values())
|
||||
Whitespace-only changes.
@@ -0,0 +1,243 @@
|
||||
import datetime
|
||||
from decimal import Decimal
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
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.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
|
||||
from apps.transactions.models import Transaction, TransactionEntity
|
||||
|
||||
D = Decimal
|
||||
IN, EX = Transaction.Type.INCOME, Transaction.Type.EXPENSE
|
||||
|
||||
|
||||
def tx(account, type_, amount, date, **kwargs):
|
||||
return Transaction.objects.create(
|
||||
account=account, type=type_, amount=D(amount), date=date, **kwargs
|
||||
)
|
||||
|
||||
|
||||
def transfer(from_account, from_amount, to_account, to_amount, date):
|
||||
out = tx(from_account, EX, from_amount, date)
|
||||
inc = tx(to_account, IN, to_amount, date, linked_transaction=out)
|
||||
out.linked_transaction = inc
|
||||
out.save()
|
||||
return out, inc
|
||||
|
||||
|
||||
class HoldingPerformanceTests(TestCase):
|
||||
def setUp(self):
|
||||
self.eur = Currency.objects.create(code="EUR", name="Euro", decimal_places=2)
|
||||
self.brl = Currency.objects.create(code="BRL", name="Real", decimal_places=2)
|
||||
self.vwce = Currency.objects.create(
|
||||
code="VWCE.DE", name="VWCE", decimal_places=4, exchange_currency=self.eur
|
||||
)
|
||||
self.cash = Account.objects.create(name="Cash", currency=self.eur)
|
||||
self.etf = Account.objects.create(
|
||||
name="VWCE",
|
||||
currency=self.vwce,
|
||||
exchange_currency=self.eur,
|
||||
is_investment=True,
|
||||
is_asset=True,
|
||||
)
|
||||
self.d1 = datetime.date(2026, 1, 10)
|
||||
self.d2 = datetime.date(2026, 2, 10)
|
||||
self.d3 = datetime.date(2026, 3, 10)
|
||||
|
||||
def rate(self, value, date):
|
||||
ExchangeRate.objects.create(
|
||||
from_currency=self.vwce,
|
||||
to_currency=self.eur,
|
||||
rate=D(value),
|
||||
date=timezone.make_aware(datetime.datetime.combine(date, datetime.time())),
|
||||
)
|
||||
|
||||
def test_priced_average_cost_realized_and_income(self):
|
||||
transfer(self.cash, "1000", self.etf, "10", self.d1)
|
||||
transfer(self.cash, "1200", self.etf, "10", self.d2)
|
||||
transfer(self.etf, "5", self.cash, "700", self.d3)
|
||||
self.rate("130", timezone.localdate())
|
||||
|
||||
entity = TransactionEntity.objects.create(name="Vanguard")
|
||||
other = TransactionEntity.objects.create(name="Vanguard JCP")
|
||||
self.etf.income_entities.set([entity, other])
|
||||
dividend = tx(self.cash, IN, "30", self.d3)
|
||||
dividend.entities.add(entity, other) # matches twice, counted once
|
||||
|
||||
perf = get_holding_performance(self.etf)
|
||||
|
||||
self.assertTrue(perf.priced)
|
||||
self.assertEqual(perf.quantity, D("15"))
|
||||
self.assertEqual(perf.cost_basis, D("1650"))
|
||||
self.assertEqual(perf.average_cost, D("110"))
|
||||
self.assertEqual(perf.realized, D("150")) # 700 - 5 * 110
|
||||
self.assertEqual(perf.market_value, D("1950"))
|
||||
self.assertEqual(perf.unrealized, D("300"))
|
||||
self.assertEqual(perf.income, D("30"))
|
||||
self.assertEqual(perf.contributed, D("2200"))
|
||||
self.assertEqual(perf.withdrawn, D("700"))
|
||||
self.assertEqual(perf.total_return, D("480"))
|
||||
self.assertFalse(perf.estimated)
|
||||
|
||||
def test_unlinked_inflow_on_priced_account_is_costed_at_market(self):
|
||||
self.rate("90", self.d1)
|
||||
tx(self.etf, IN, "2", self.d1)
|
||||
|
||||
perf = get_holding_performance(self.etf)
|
||||
|
||||
self.assertEqual(perf.cost_basis, D("180"))
|
||||
self.assertTrue(perf.estimated)
|
||||
self.assertEqual(perf.contributed, D("0"))
|
||||
|
||||
def test_value_tracked_cdb_with_tax_on_redemption(self):
|
||||
cdb = Account.objects.create(
|
||||
name="CDB", currency=self.brl, is_investment=True, is_asset=True
|
||||
)
|
||||
checking = Account.objects.create(name="Checking", currency=self.brl)
|
||||
transfer(checking, "1000", cdb, "1000", self.d1)
|
||||
tx(cdb, IN, "100", self.d2) # balance reconciliation: yield
|
||||
tx(cdb, EX, "22.50", self.d3) # income tax withheld
|
||||
|
||||
perf = get_holding_performance(cdb)
|
||||
self.assertFalse(perf.priced)
|
||||
self.assertEqual(perf.market_value, D("1077.50"))
|
||||
self.assertEqual(perf.cost_basis, D("1000"))
|
||||
self.assertEqual(perf.unrealized, D("77.50"))
|
||||
|
||||
transfer(cdb, "1077.50", checking, "1077.50", self.d3)
|
||||
perf = get_holding_performance(cdb)
|
||||
self.assertEqual(perf.quantity, D("0"))
|
||||
self.assertEqual(perf.cost_basis, D("0"))
|
||||
self.assertEqual(perf.realized, D("77.50"))
|
||||
self.assertEqual(perf.total_return, D("77.50"))
|
||||
|
||||
def test_history_has_one_point_per_month_end(self):
|
||||
transfer(self.cash, "1000", self.etf, "10", self.d1)
|
||||
self.rate("100", self.d1)
|
||||
self.rate("120", self.d3)
|
||||
|
||||
perf = get_holding_performance(
|
||||
self.etf, as_of=datetime.date(2026, 3, 15), with_history=True
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
[p["date"] for p in perf.history],
|
||||
[
|
||||
datetime.date(2026, 1, 31),
|
||||
datetime.date(2026, 2, 28),
|
||||
datetime.date(2026, 3, 15),
|
||||
],
|
||||
)
|
||||
self.assertEqual(perf.history[-1]["value"], D("1200"))
|
||||
self.assertEqual(perf.history[0]["invested"], D("1000"))
|
||||
|
||||
def test_summarize_groups_by_currency(self):
|
||||
transfer(self.cash, "1000", self.etf, "10", self.d1)
|
||||
self.rate("110", timezone.localdate())
|
||||
totals = summarize([get_holding_performance(self.etf)])
|
||||
self.assertEqual(len(totals), 1)
|
||||
self.assertEqual(totals[0]["market_value"], D("1100"))
|
||||
self.assertEqual(totals[0]["total_return"], D("100"))
|
||||
|
||||
def test_xirr_and_money_weighted_return(self):
|
||||
from apps.investments.services import money_weighted_return, xirr
|
||||
|
||||
start, year = datetime.date(2025, 1, 1), datetime.date(2026, 1, 1)
|
||||
self.assertAlmostEqual(xirr([(start, D(-1000)), (year, D(1100))]), 0.10, places=6)
|
||||
self.assertIsNone(xirr([(start, D(-1000))]))
|
||||
|
||||
# A year or more: annualized
|
||||
mw = money_weighted_return([(start, D(-1000), "in")], D(1100), year)
|
||||
self.assertTrue(mw["annualized"])
|
||||
self.assertAlmostEqual(float(mw["percentage"]), 10, places=3)
|
||||
|
||||
# Less than a year: the return over the period, not an inflated annual rate
|
||||
half = datetime.date(2025, 7, 2)
|
||||
mw = money_weighted_return([(start, D(-1000), "in")], D(1050), half)
|
||||
self.assertFalse(mw["annualized"])
|
||||
self.assertAlmostEqual(float(mw["percentage"]), 5, places=3)
|
||||
|
||||
# Money added late weighs less: +100 on 1000 held all year beats the
|
||||
# simple +100 / 2000 = 5% when the second 1000 only came in at the end
|
||||
flows = [(start, D(-1000), "in"), (datetime.date(2025, 12, 31), D(-1000), "in")]
|
||||
mw = money_weighted_return(flows, D(2100), year)
|
||||
self.assertGreater(float(mw["percentage"]), 9.9)
|
||||
|
||||
def test_home_currency_counts_the_exchange_rate_move(self):
|
||||
from apps.investments.services import in_home_currency
|
||||
|
||||
usd = Currency.objects.create(code="USD", name="Dollar", decimal_places=2)
|
||||
usd.exchange_currency = self.brl
|
||||
usd.save()
|
||||
etf = Currency.objects.create(
|
||||
code="VT", name="VT", decimal_places=4, exchange_currency=usd
|
||||
)
|
||||
cash = Account.objects.create(name="USD cash", currency=usd)
|
||||
holding = Account.objects.create(
|
||||
name="VT", currency=etf, exchange_currency=usd, is_investment=True
|
||||
)
|
||||
aware = lambda d: timezone.make_aware(datetime.datetime.combine(d, datetime.time()))
|
||||
ExchangeRate.objects.create(from_currency=usd, to_currency=self.brl, rate=D("5"), date=aware(self.d1))
|
||||
ExchangeRate.objects.create(from_currency=usd, to_currency=self.brl, rate=D("6"), date=aware(timezone.localdate()))
|
||||
ExchangeRate.objects.create(from_currency=etf, to_currency=usd, rate=D("100"), date=aware(timezone.localdate()))
|
||||
transfer(cash, "1000", holding, "10", self.d1)
|
||||
|
||||
perf = get_holding_performance(holding)
|
||||
self.assertEqual(perf.total_return, D("0")) # flat in dollars
|
||||
|
||||
home = in_home_currency(perf)
|
||||
self.assertEqual(home["currency"], self.brl)
|
||||
self.assertEqual(home["market_value"], D("6000"))
|
||||
self.assertEqual(home["cost_basis"], D("5000"))
|
||||
self.assertEqual(home["total_return"], D("1000"))
|
||||
self.assertEqual(home["currency_effect"], D("1000")) # all of it from the dollar
|
||||
|
||||
totals = summarize([perf], convert=True)
|
||||
self.assertEqual([t["currency"] for t in totals], [self.brl])
|
||||
self.assertEqual(totals[0]["converted_from"], [usd])
|
||||
# No rate to anything: stays in its own currency
|
||||
self.assertEqual(summarize([get_holding_performance(self.etf)], convert=True)[0]["currency"], self.eur)
|
||||
|
||||
|
||||
@override_settings(
|
||||
STORAGES={
|
||||
"default": {"BACKEND": "django.core.files.storage.FileSystemStorage"},
|
||||
"staticfiles": {
|
||||
"BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage"
|
||||
},
|
||||
},
|
||||
WHITENOISE_AUTOREFRESH=True,
|
||||
DEMO=False,
|
||||
)
|
||||
class InvestmentViewTests(TestCase):
|
||||
HTMX = {"HTTP_HX_REQUEST": "true"}
|
||||
|
||||
def setUp(self):
|
||||
User = get_user_model()
|
||||
self.owner = User.objects.create_user(email="o@test.com", password="pw")
|
||||
self.stranger = User.objects.create_user(email="s@test.com", password="pw")
|
||||
brl = Currency.objects.create(code="BRL", name="Real", decimal_places=2)
|
||||
self.account = Account.all_objects.create(
|
||||
name="CDB", currency=brl, is_investment=True, owner=self.owner
|
||||
)
|
||||
|
||||
def test_owner_sees_list_and_detail(self):
|
||||
self.client.force_login(self.owner)
|
||||
response = self.client.get(reverse("investments_list"), **self.HTMX)
|
||||
self.assertContains(response, "CDB")
|
||||
response = self.client.get(
|
||||
reverse("investment_detail", args=[self.account.id]), **self.HTMX
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_stranger_cannot_see_detail(self):
|
||||
self.client.force_login(self.stranger)
|
||||
response = self.client.get(
|
||||
reverse("investment_detail_index", args=[self.account.id])
|
||||
)
|
||||
self.assertEqual(response.status_code, 404)
|
||||
@@ -0,0 +1,18 @@
|
||||
from django.urls import path
|
||||
|
||||
from . import views
|
||||
|
||||
urlpatterns = [
|
||||
path("investments/", views.investments_index, name="investments_index"),
|
||||
path("investments/list/", views.investments_list, name="investments_list"),
|
||||
path(
|
||||
"investments/<int:account_id>/",
|
||||
views.investment_detail_index,
|
||||
name="investment_detail_index",
|
||||
),
|
||||
path(
|
||||
"investments/<int:account_id>/details/",
|
||||
views.investment_detail,
|
||||
name="investment_detail",
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,106 @@
|
||||
import json
|
||||
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.shortcuts import render
|
||||
from django.utils.formats import date_format
|
||||
|
||||
from apps.accounts.models import Account
|
||||
from apps.common.decorators.htmx import only_htmx
|
||||
from apps.common.functions.permissions import READ, get_shared_object_or_error
|
||||
from apps.investments.services import (
|
||||
get_holding_performance,
|
||||
in_home_currency,
|
||||
income_entity_ids,
|
||||
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),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@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]
|
||||
)
|
||||
|
||||
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],
|
||||
}
|
||||
|
||||
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": json.dumps(chart),
|
||||
},
|
||||
)
|
||||
@@ -114,6 +114,12 @@
|
||||
</c-components.sidebar-menu-item>
|
||||
|
||||
<c-components.sidebar-menu-header title="{% translate 'Tools' %}"></c-components.sidebar-menu-header>
|
||||
<c-components.sidebar-menu-item
|
||||
title="{% translate 'Investments' %}"
|
||||
url='investments_index'
|
||||
active="investments_index||investment_detail_index"
|
||||
icon="fa-solid fa-chart-pie">
|
||||
</c-components.sidebar-menu-item>
|
||||
<c-components.sidebar-menu-item
|
||||
title="{% translate 'Dollar Cost Average Tracker' %}"
|
||||
url='dca_strategy_index'
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
{% load i18n %}{% if mw %}<span class="whitespace-nowrap" data-tippy-content="{% if mw.annualized %}{% translate 'Annual return, weighted by when money went in and out' %}{% else %}{% translate 'Return since the first contribution, weighted by when money went in and out' %}{% endif %}">{% include 'investments/fragments/_percentage.html' with value=mw.percentage %}{% if mw.annualized %}<span class="text-base-content/50">{% translate '/yr' %}</span>{% endif %}</span>{% endif %}
|
||||
@@ -0,0 +1 @@
|
||||
{% if value is not None %}<span class="tabular-nums font-medium whitespace-nowrap {% if value > 0 %}text-income{% elif value < 0 %}text-expense{% else %}text-base-content/60{% endif %} {{ css }}">{% if value > 0 %}+{% endif %}{{ value|floatformat:"2g" }}%</span>{% endif %}
|
||||
@@ -0,0 +1,38 @@
|
||||
{% load i18n %}
|
||||
{% with c=total.currency %}
|
||||
<div class="card bg-base-100 card-border shadow">
|
||||
<div class="card-body p-5 gap-4">
|
||||
<div class="flex flex-wrap items-end justify-between gap-x-6 gap-y-3">
|
||||
<div>
|
||||
<div class="text-xs uppercase tracking-wide text-base-content/60">{% translate 'Market value' %} · {{ c.name }}</div>
|
||||
<div class="text-3xl font-bold tabular-nums">
|
||||
<c-amount.display :amount="total.market_value" :prefix="c.prefix" :suffix="c.suffix" :decimal_places="c.decimal_places" divless></c-amount.display>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sm:text-right">
|
||||
<div class="text-xs uppercase tracking-wide text-base-content/60">{% translate 'Total return' %}</div>
|
||||
<div class="text-lg tabular-nums flex items-baseline gap-2 sm:justify-end">
|
||||
<c-amount.display :amount="total.total_return" :prefix="c.prefix" :suffix="c.suffix" :decimal_places="c.decimal_places" color="auto" divless></c-amount.display>
|
||||
<span class="text-sm">{% include 'investments/fragments/_mwr.html' with mw=total.money_weighted %}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-x-6 gap-y-1 text-sm text-base-content/60">
|
||||
<div>{% translate 'Invested' %} <span class="text-base-content tabular-nums"><c-amount.display :amount="total.cost_basis" :prefix="c.prefix" :suffix="c.suffix" :decimal_places="c.decimal_places" divless></c-amount.display></span></div>
|
||||
<div>{% translate 'Income' %} <span class="text-base-content tabular-nums"><c-amount.display :amount="total.income" :prefix="c.prefix" :suffix="c.suffix" :decimal_places="c.decimal_places" divless></c-amount.display></span></div>
|
||||
{% if total.converted_from %}
|
||||
{% if total.has_rate_history %}
|
||||
<div data-tippy-content="{% translate 'How much of the return came from exchange rates moving since you invested' %}">{% translate 'Currency effect' %} <span class="tabular-nums"><c-amount.display :amount="total.currency_effect" :prefix="c.prefix" :suffix="c.suffix" :decimal_places="c.decimal_places" color="auto" divless></c-amount.display></span></div>
|
||||
{% else %}
|
||||
<div>{% translate 'Currency effect' %} <i class="fa-solid fa-triangle-exclamation text-warning text-xs" data-tippy-content="{% translate 'Every date is converted with the same rate. Add historical exchange rates (or an automatic rate service) to see how much exchange rates moved your return.' %}"></i></div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if total.converted_from %}
|
||||
<div class="text-xs text-base-content/40 -mt-2">
|
||||
{% translate 'Includes holdings in' %} {% for currency in total.converted_from %}{{ currency.name }}{% if not forloop.last %}, {% endif %}{% endfor %}, {% translate 'converted at the rate of each date' %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endwith %}
|
||||
@@ -0,0 +1,270 @@
|
||||
{% load i18n %}
|
||||
{% with c=perf.currency a=account.currency %}
|
||||
{# Buy/sell are transfers so the tracker sees them; income uses the account's income entity #}
|
||||
<c-components.fab>
|
||||
<c-components.fab_menu_button
|
||||
color="success" hx_target="#generic-offcanvas" hx_trigger="click"
|
||||
hx_vals='{"to_account": {{ account.id }}}'
|
||||
url="{% url 'transactions_transfer' %}"
|
||||
icon="fa-solid fa-arrow-right-to-bracket"
|
||||
title="{% translate 'Buy / Contribute' %}"></c-components.fab_menu_button>
|
||||
<c-components.fab_menu_button
|
||||
color="error" hx_target="#generic-offcanvas" hx_trigger="click"
|
||||
hx_vals='{"from_account": {{ account.id }}}'
|
||||
url="{% url 'transactions_transfer' %}"
|
||||
icon="fa-solid fa-arrow-right-from-bracket"
|
||||
title="{% translate 'Sell / Withdraw' %}"></c-components.fab_menu_button>
|
||||
{% if income_entities %}
|
||||
<c-components.fab_menu_button
|
||||
color="success" hx_target="#generic-offcanvas" hx_trigger="click"
|
||||
hx_vals='{"type": "IN", "entities": "{{ income_entities.0.name|escapejs }}"}'
|
||||
url="{% url 'transaction_add' %}"
|
||||
icon="fa-solid fa-hand-holding-dollar"
|
||||
title="{% translate 'Dividend / Income' %}"></c-components.fab_menu_button>
|
||||
{% endif %}
|
||||
{% if not perf.priced %}
|
||||
<c-components.fab_menu_button
|
||||
color="info" hx_target="#generic-offcanvas" hx_trigger="click"
|
||||
url="{% url 'account_reconciliation' %}"
|
||||
icon="fa-solid fa-scale-balanced"
|
||||
title="{% translate 'Update value' %}"></c-components.fab_menu_button>
|
||||
{% endif %}
|
||||
</c-components.fab>
|
||||
<div class="container pb-20">
|
||||
<div class="flex flex-wrap justify-between items-start gap-3 mb-4">
|
||||
<div class="min-w-0">
|
||||
<div class="text-3xl font-bold font-mono flex items-center gap-2">
|
||||
<a href="{% url 'investments_index' %}" hx-boost="true" class="no-underline text-base-content/40 hover:text-base-content transition-colors duration-150" aria-label="{% translate 'Investments' %}"><i class="fa-solid fa-arrow-left fa-fw text-lg"></i></a>
|
||||
<span class="truncate">{{ account.name }}</span>
|
||||
<button class="btn btn-ghost btn-sm btn-square text-base-content/40 hover:text-base-content"
|
||||
aria-label="{% translate 'Edit account' %}" data-tippy-content="{% translate 'Edit account' %}"
|
||||
hx-get="{% url 'account_edit' pk=account.id %}" hx-target="#generic-offcanvas">
|
||||
<i class="fa-solid fa-pencil text-base"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="text-sm text-base-content/60 mt-1">
|
||||
{% if account.group %}{{ account.group.name }} · {% endif %}
|
||||
{% if perf.priced %}{{ a.name }} → {{ c.name }}{% else %}{% translate 'Tracked by value' %}{% endif %}
|
||||
{% if perf.estimated %}<i class="fa-solid fa-circle-exclamation text-warning ml-1" data-tippy-content="{% translate 'Some incoming units have no linked payment, so their cost was estimated from the market price of that day' %}"></i>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card bg-base-100 card-border shadow mb-4">
|
||||
<div class="card-body p-5 gap-5">
|
||||
<div class="flex flex-wrap items-end justify-between gap-x-6 gap-y-3">
|
||||
<div>
|
||||
<div class="text-xs uppercase tracking-wide text-base-content/60">{% translate 'Market value' %}</div>
|
||||
<div class="text-4xl font-bold tabular-nums">
|
||||
{% if perf.market_value is not None %}<c-amount.display :amount="perf.market_value" :prefix="c.prefix" :suffix="c.suffix" :decimal_places="c.decimal_places" divless></c-amount.display>{% else %}-{% endif %}
|
||||
</div>
|
||||
<div class="text-sm tabular-nums mt-1 flex items-baseline gap-2">
|
||||
<c-amount.display :amount="perf.total_return" :prefix="c.prefix" :suffix="c.suffix" :decimal_places="c.decimal_places" color="auto" divless></c-amount.display>
|
||||
{% include 'investments/fragments/_mwr.html' with mw=perf.money_weighted %}
|
||||
<span class="text-base-content/50">{% translate 'total return' %}</span>
|
||||
</div>
|
||||
{% if home %}
|
||||
{% with h=home.currency %}
|
||||
<div class="text-sm tabular-nums mt-1 flex flex-wrap items-baseline gap-x-2 text-base-content/60">
|
||||
<span>{% translate 'In' %} {{ h.name }}:</span>
|
||||
<c-amount.display :amount="home.market_value" :prefix="h.prefix" :suffix="h.suffix" :decimal_places="h.decimal_places" divless></c-amount.display>
|
||||
<span>·</span>
|
||||
<c-amount.display :amount="home.total_return" :prefix="h.prefix" :suffix="h.suffix" :decimal_places="h.decimal_places" color="auto" divless></c-amount.display>
|
||||
{% include 'investments/fragments/_mwr.html' with mw=home.money_weighted %}
|
||||
{% if home.has_rate_history %}
|
||||
<span data-tippy-content="{% translate 'How much of the return came from exchange rates moving since you invested' %}">· {% translate 'currency effect' %}
|
||||
<c-amount.display :amount="home.currency_effect" :prefix="h.prefix" :suffix="h.suffix" :decimal_places="h.decimal_places" color="auto" divless></c-amount.display>
|
||||
</span>
|
||||
{% else %}
|
||||
<span>· {% translate 'currency effect' %} <i class="fa-solid fa-triangle-exclamation text-warning text-xs" data-tippy-content="{% translate 'Every date is converted with the same rate. Add historical exchange rates (or an automatic rate service) to see how much exchange rates moved your return.' %}"></i></span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endwith %}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if perf.priced %}
|
||||
<div class="sm:text-right text-sm text-base-content/60 tabular-nums">
|
||||
<div><c-amount.display :amount="perf.quantity" :prefix="a.prefix" :suffix="a.suffix" :decimal_places="a.decimal_places" divless></c-amount.display></div>
|
||||
<div>
|
||||
{% if perf.price is not None %}
|
||||
× <c-amount.display :amount="perf.price" :prefix="c.prefix" :suffix="c.suffix" :decimal_places="c.decimal_places" divless></c-amount.display>
|
||||
{% if perf.price_date %}<span class="text-base-content/40"> · {{ perf.price_date|date:"SHORT_DATE_FORMAT" }}</span>{% endif %}
|
||||
{% else %}
|
||||
<span class="text-error">{% translate "No exchange rate available" %}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% 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>
|
||||
|
||||
<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>
|
||||
<dt class="text-xs text-base-content/50">{% translate 'Invested' %}</dt>
|
||||
<dd><c-amount.display :amount="perf.cost_basis" :prefix="c.prefix" :suffix="c.suffix" :decimal_places="c.decimal_places" divless></c-amount.display></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-xs text-base-content/50">{% if perf.priced %}{% translate 'Average cost' %}{% else %}{% translate 'Put in / taken out' %}{% endif %}</dt>
|
||||
<dd>
|
||||
{% if perf.priced %}
|
||||
{% if perf.average_cost is not None %}<c-amount.display :amount="perf.average_cost" :prefix="c.prefix" :suffix="c.suffix" :decimal_places="c.decimal_places" divless></c-amount.display>{% else %}-{% endif %}
|
||||
{% else %}
|
||||
<c-amount.display :amount="perf.contributed" :prefix="c.prefix" :suffix="c.suffix" :decimal_places="c.decimal_places" divless></c-amount.display>
|
||||
<span class="text-base-content/50">/</span>
|
||||
<c-amount.display :amount="perf.withdrawn" :prefix="c.prefix" :suffix="c.suffix" :decimal_places="c.decimal_places" divless></c-amount.display>
|
||||
{% endif %}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-xs text-base-content/50">{% if perf.priced %}{% translate 'Unrealized' %}{% else %}{% translate 'Earnings' %}{% endif %}</dt>
|
||||
<dd>
|
||||
{% if perf.unrealized is not None %}
|
||||
<c-amount.display :amount="perf.unrealized" :prefix="c.prefix" :suffix="c.suffix" :decimal_places="c.decimal_places" color="auto" divless></c-amount.display>
|
||||
{% include 'investments/fragments/_percentage.html' with value=perf.unrealized_percentage css="text-xs" %}
|
||||
{% else %}-{% endif %}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-xs text-base-content/50">{% translate 'Realized' %}</dt>
|
||||
<dd><c-amount.display :amount="perf.realized" :prefix="c.prefix" :suffix="c.suffix" :decimal_places="c.decimal_places" color="auto" divless></c-amount.display></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-xs text-base-content/50">{% translate 'Income' %}{% if income_entities %} · {{ income_entities|join:", " }}{% endif %}</dt>
|
||||
<dd><c-amount.display :amount="perf.income" :prefix="c.prefix" :suffix="c.suffix" :decimal_places="c.decimal_places" color="auto" divless></c-amount.display></dd>
|
||||
</div>
|
||||
{% if perf.priced %}
|
||||
<div>
|
||||
<dt class="text-xs text-base-content/50">{% translate 'Put in / taken out' %}</dt>
|
||||
<dd>
|
||||
<c-amount.display :amount="perf.contributed" :prefix="c.prefix" :suffix="c.suffix" :decimal_places="c.decimal_places" divless></c-amount.display>
|
||||
<span class="text-base-content/50">/</span>
|
||||
<c-amount.display :amount="perf.withdrawn" :prefix="c.prefix" :suffix="c.suffix" :decimal_places="c.decimal_places" divless></c-amount.display>
|
||||
</dd>
|
||||
</div>
|
||||
{% endif %}
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 {% if income %}xl:grid-cols-3{% endif %} gap-4">
|
||||
<div class="card bg-base-100 card-border shadow {% if income %}xl:col-span-2{% endif %}">
|
||||
<div class="px-5 pt-4 pb-2 font-semibold">{% translate 'Movements' %}</div>
|
||||
{% if transactions %}
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table text-sm">
|
||||
<tbody>
|
||||
{% for tx in transactions %}
|
||||
{% with p=tx.linked_transaction %}
|
||||
<tr class="{% if not tx.is_paid %}opacity-50{% endif %}">
|
||||
<td class="whitespace-nowrap text-base-content/60 tabular-nums w-28">{{ tx.date|date:"SHORT_DATE_FORMAT" }}</td>
|
||||
<td>
|
||||
<div>{{ tx.description }}</div>
|
||||
<div class="text-xs text-base-content/50">
|
||||
{% if p %}
|
||||
{% if tx.type == 'IN' %}{% translate 'Bought / contributed' %}{% else %}{% translate 'Sold / withdrew' %}{% endif %}
|
||||
· {{ p.account.name }}
|
||||
{% else %}
|
||||
{% if tx.type == 'IN' %}{% if perf.priced %}{% translate 'Gain' %}{% else %}{% translate 'Earnings' %}{% endif %}{% else %}{% translate 'Loss, tax or fee' %}{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</td>
|
||||
<td class="text-end tabular-nums whitespace-nowrap">
|
||||
<c-amount.display :amount="tx.amount" :prefix="a.prefix" :suffix="a.suffix" :decimal_places="a.decimal_places" color="{% if tx.type == 'IN' %}green{% else %}red{% endif %}" divless></c-amount.display>
|
||||
{% if p and p.account.currency != a %}
|
||||
<div class="text-xs text-base-content/50">
|
||||
<c-amount.display :amount="p.amount" :prefix="p.account.currency.prefix" :suffix="p.account.currency.suffix" :decimal_places="p.account.currency.decimal_places" divless></c-amount.display>
|
||||
</div>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="w-8 px-1">
|
||||
<button class="btn btn-ghost btn-xs btn-square text-base-content/40 hover:text-base-content"
|
||||
aria-label="{% translate 'Edit' %}" data-tippy-content="{% translate 'Edit' %}"
|
||||
hx-get="{% if p %}{% url 'transaction_transfer_edit' transaction_id=tx.id %}{% else %}{% url 'transaction_edit' transaction_id=tx.id %}{% endif %}" hx-target="#generic-offcanvas"><i class="fa-solid fa-pencil"></i></button>
|
||||
</td>
|
||||
</tr>
|
||||
{% endwith %}
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<c-msg.empty title="{% translate 'No movements yet' %}" subtitle="{% translate 'Use the + button to buy or contribute' %}"></c-msg.empty>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if income %}
|
||||
<div class="card bg-base-100 card-border shadow">
|
||||
<div class="px-5 pt-4 pb-2 font-semibold">{% translate 'Income' %}</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table text-sm">
|
||||
<tbody>
|
||||
{% for tx in income %}
|
||||
<tr class="{% if not tx.is_paid %}opacity-50{% endif %}">
|
||||
<td class="whitespace-nowrap text-base-content/60 tabular-nums">{{ tx.date|date:"SHORT_DATE_FORMAT" }}</td>
|
||||
<td>{{ tx.description }}<div class="text-xs text-base-content/50">{{ tx.account.name }}</div></td>
|
||||
<td class="text-end tabular-nums whitespace-nowrap"><c-amount.display :amount="tx.amount" :prefix="tx.account.currency.prefix" :suffix="tx.account.currency.suffix" :decimal_places="tx.account.currency.decimal_places" color="green" divless></c-amount.display></td>
|
||||
<td class="w-8 px-1">
|
||||
<button class="btn btn-ghost btn-xs btn-square text-base-content/40 hover:text-base-content"
|
||||
aria-label="{% translate 'Edit' %}" data-tippy-content="{% translate 'Edit' %}"
|
||||
hx-get="{% url 'transaction_edit' transaction_id=tx.id %}" hx-target="#generic-offcanvas"><i class="fa-solid fa-pencil"></i></button>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endwith %}
|
||||
@@ -0,0 +1,93 @@
|
||||
{% load i18n %}
|
||||
<c-ui.fab-single-action
|
||||
url="{% url 'transactions_transfer' %}"
|
||||
hx_target="#generic-offcanvas">
|
||||
</c-ui.fab-single-action>
|
||||
<div class="container pb-20">
|
||||
<div class="text-3xl font-bold font-mono w-full mb-4">
|
||||
{% spaceless %}
|
||||
<div>{% translate 'Investments' %}</div>
|
||||
{% endspaceless %}
|
||||
</div>
|
||||
|
||||
{% if groups %}
|
||||
<div class="grid grid-cols-1 {% if totals|length > 1 %}lg:grid-cols-2{% endif %} gap-3 mb-6">
|
||||
{% for total in totals %}
|
||||
{% include 'investments/fragments/_totals.html' %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{% 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">
|
||||
<span class="font-semibold">{% if item.group %}{{ item.group.name }}{% else %}{% translate 'Ungrouped' %}{% endif %}</span>
|
||||
<div class="flex flex-wrap gap-4 text-sm">
|
||||
{% for total in item.totals %}
|
||||
<div class="flex items-baseline gap-2 tabular-nums">
|
||||
<c-amount.display :amount="total.market_value" :prefix="total.currency.prefix" :suffix="total.currency.suffix" :decimal_places="total.currency.decimal_places" divless></c-amount.display>
|
||||
{% include 'investments/fragments/_mwr.html' with mw=total.money_weighted %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table table-fixed">
|
||||
<colgroup>
|
||||
<col>
|
||||
<col class="w-36 hidden md:table-column">
|
||||
<col class="w-36 hidden sm:table-column">
|
||||
<col class="w-36">
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr class="text-xs text-base-content/50">
|
||||
<th class="font-normal">{% translate 'Investment' %}</th>
|
||||
<th class="font-normal text-end hidden md:table-cell">{% translate 'Invested' %}</th>
|
||||
<th class="font-normal text-end hidden sm:table-cell">{% translate 'Value' %}</th>
|
||||
<th class="font-normal text-end"><span class="sm:hidden">{% translate 'Value' %}</span><span class="hidden sm:inline">{% translate 'Return' %}</span></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for perf in item.holdings %}
|
||||
{% with c=perf.currency a=perf.account.currency %}
|
||||
<tr class="cursor-pointer transition-colors duration-150 hover:bg-base-200/60"
|
||||
_="on click go to url '{% url 'investment_detail_index' account_id=perf.account.id %}'">
|
||||
<td>
|
||||
<div class="font-medium truncate">
|
||||
{{ perf.account.name }}
|
||||
{% if perf.estimated %}<i class="fa-solid fa-circle-exclamation text-warning text-xs" data-tippy-content="{% translate 'Some costs were estimated from market prices' %}"></i>{% endif %}
|
||||
</div>
|
||||
<div class="text-xs text-base-content/50 tabular-nums truncate">
|
||||
{% if perf.priced %}
|
||||
<c-amount.display :amount="perf.quantity" :prefix="a.prefix" :suffix="a.suffix" :decimal_places="a.decimal_places" divless></c-amount.display>
|
||||
{% if perf.price is not None %}× <c-amount.display :amount="perf.price" :prefix="c.prefix" :suffix="c.suffix" :decimal_places="c.decimal_places" divless></c-amount.display>{% else %}· <span class="text-error">{% translate 'No exchange rate available' %}</span>{% endif %}
|
||||
{% else %}
|
||||
{% translate 'Tracked by value' %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</td>
|
||||
<td class="text-end tabular-nums whitespace-nowrap text-base-content/70 hidden md:table-cell">
|
||||
<c-amount.display :amount="perf.cost_basis" :prefix="c.prefix" :suffix="c.suffix" :decimal_places="c.decimal_places" divless></c-amount.display>
|
||||
</td>
|
||||
<td class="text-end tabular-nums whitespace-nowrap hidden sm:table-cell">
|
||||
{% if perf.market_value is not None %}<c-amount.display :amount="perf.market_value" :prefix="c.prefix" :suffix="c.suffix" :decimal_places="c.decimal_places" divless></c-amount.display>{% else %}-{% endif %}
|
||||
</td>
|
||||
<td class="text-end tabular-nums whitespace-nowrap">
|
||||
{# Phones show value + return %, wider screens split them into columns #}
|
||||
<div class="sm:hidden font-medium">{% if perf.market_value is not None %}<c-amount.display :amount="perf.market_value" :prefix="c.prefix" :suffix="c.suffix" :decimal_places="c.decimal_places" divless></c-amount.display>{% else %}-{% endif %}</div>
|
||||
<div class="hidden sm:block"><c-amount.display :amount="perf.total_return" :prefix="c.prefix" :suffix="c.suffix" :decimal_places="c.decimal_places" color="auto" divless></c-amount.display></div>
|
||||
<div class="text-xs">{% include 'investments/fragments/_mwr.html' with mw=perf.money_weighted %}</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endwith %}
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<c-msg.empty
|
||||
title="{% translate 'No investments yet' %}"
|
||||
subtitle="{% translate 'Mark an account as an investment in its settings to see it here' %}"></c-msg.empty>
|
||||
{% endif %}
|
||||
</div>
|
||||
@@ -0,0 +1,8 @@
|
||||
{% extends "layouts/base.html" %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block title %}{{ account.name }} :: {% translate 'Investments' %}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div hx-get="{% url 'investment_detail' account_id=account.id %}" hx-trigger="load, updated from:window" class="show-loading" id="investment-details"></div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,8 @@
|
||||
{% extends "layouts/base.html" %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block title %}{% translate 'Investments' %}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div hx-get="{% url 'investments_list' %}" hx-trigger="load, updated from:window" class="show-loading"></div>
|
||||
{% endblock %}
|
||||
Reference in new issue
Block a user