mirror of
https://github.com/eitchtee/WYGIWYH.git
synced 2026-11-13 12:23:44 +01:00
fix(investments): count income categories on unit-priced accounts and transfers at their own amount
- Unit-priced accounts now honour their income categories, tags and entities: a matching transaction is a gain or loss, and any other unlinked one is money in or out at that day's market price. Before, every unlinked outflow was a loss and yields were booked as new money in. - A transfer out of an account tracked by value counts its own amount. It used the other side converted at the day's rate, so a coin bought from a savings account moved the coin's price change onto the savings account.
This commit is contained in:
1 parent
448e9c8f5c
commit
fda4bade62
5 files changed
+90
-40
No files matched your search
@@ -0,0 +1,19 @@
|
||||
# Generated by Django 5.2.17 on 2026-11-08 20:59
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('accounts', '0020_investment_classes'),
|
||||
('transactions', '0052_transfer_templates'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='account',
|
||||
name='income_categories',
|
||||
field=models.ManyToManyField(blank=True, help_text="Transactions in this account with any of these categories are its result (interest, taxes, fees). Everything else that isn't a transfer counts as money coming in or going out.", related_name='investment_accounts', to='transactions.transactioncategory', verbose_name='Income categories'),
|
||||
),
|
||||
]
|
||||
@@ -151,8 +151,8 @@ class Account(SharedObject):
|
||||
verbose_name=_("Income categories"),
|
||||
help_text=_(
|
||||
"Transactions in this account with any of these categories are its result "
|
||||
"(interest, taxes, fees). On accounts tracked by value, everything else "
|
||||
"that isn't a transfer counts as money coming in or going out."
|
||||
"(interest, taxes, fees). Everything else that isn't a transfer counts as "
|
||||
"money coming in or going out."
|
||||
),
|
||||
)
|
||||
income_tags = models.ManyToManyField(
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
"""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 on an account tracked by value is money coming in or
|
||||
going out (a salary, a bill), unless it has one of the account's income
|
||||
categories, tags or entities: then it is a gain or a loss (a reconciled balance,
|
||||
interest, a tax withheld).
|
||||
- On a unit-priced account an unlinked outflow is a loss, and 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.
|
||||
- A linked transaction (a transfer) moves money in or out. On a unit-priced
|
||||
account its other side says what was paid or received, which is the cost or
|
||||
the proceeds. On an account tracked by value its own amount is.
|
||||
- An unlinked transaction is money coming in or going out (a salary, a bill, a
|
||||
gift), unless it has one of the account's income categories, tags or entities:
|
||||
then it is a gain or a loss (a reconciled balance, interest, a tax withheld).
|
||||
- On a unit-priced account, money that moves without a known other side is
|
||||
valued at the market price of that day and the result is flagged as estimated.
|
||||
- Cost is tracked with the average cost method.
|
||||
"""
|
||||
|
||||
@@ -42,7 +41,7 @@ class HoldingPerformance:
|
||||
contributed: Decimal = ZERO
|
||||
withdrawn: Decimal = ZERO
|
||||
estimated: bool = False
|
||||
# Unlinked transactions counted as gains or losses, on accounts tracked by value
|
||||
# Unlinked transactions counted as gains or losses
|
||||
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
|
||||
@@ -152,7 +151,6 @@ def get_holding_performance(account, as_of=None, with_history=False):
|
||||
)
|
||||
|
||||
month_ends = _month_ends(transactions, as_of) if with_history else []
|
||||
if not perf.priced:
|
||||
perf.result_ids = _result_ids(account)
|
||||
|
||||
for tx in transactions:
|
||||
@@ -163,34 +161,35 @@ def get_holding_performance(account, as_of=None, with_history=False):
|
||||
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
|
||||
# Money in or out, as opposed to a gain or a loss
|
||||
moved = partner or tx.id not in perf.result_ids
|
||||
partner_value = None
|
||||
if perf.priced and partner:
|
||||
partner_value = _to(
|
||||
partner.amount, partner.account.currency, valuation_currency, tx.date
|
||||
)
|
||||
external = not (perf.priced or partner or tx.id in perf.result_ids)
|
||||
if partner_value is not None:
|
||||
value, estimated = partner_value, False
|
||||
elif perf.priced:
|
||||
# No known other side: that day's market value
|
||||
value = _to(tx.amount, account.currency, valuation_currency, tx.date)
|
||||
estimated = True
|
||||
else:
|
||||
value, estimated = tx.amount, False
|
||||
|
||||
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):
|
||||
elif not (perf.priced or moved):
|
||||
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:
|
||||
cost = tx.amount
|
||||
perf.contributed += cost
|
||||
elif 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:
|
||||
cost = ZERO # a gain
|
||||
if moved:
|
||||
cost = value
|
||||
perf.contributed += cost or ZERO
|
||||
else:
|
||||
cost = ZERO # value-tracked: a gain
|
||||
perf.estimated |= estimated
|
||||
if cost:
|
||||
perf.flows.append((tx.date, -cost, "in"))
|
||||
perf.quantity += tx.amount
|
||||
@@ -200,11 +199,9 @@ def get_holding_performance(account, as_of=None, with_history=False):
|
||||
removed = perf.cost_basis * min(tx.amount, perf.quantity) / perf.quantity
|
||||
else:
|
||||
removed = ZERO
|
||||
if partner or external:
|
||||
proceeds = tx.amount if external else partner_value
|
||||
if proceeds is None:
|
||||
proceeds = _to(tx.amount, account.currency, valuation_currency, tx.date) or ZERO
|
||||
perf.estimated = True
|
||||
if moved:
|
||||
proceeds = value or ZERO
|
||||
perf.estimated |= estimated
|
||||
perf.realized += proceeds - removed
|
||||
perf.withdrawn += proceeds
|
||||
perf.flows.append((tx.date, proceeds, "out"))
|
||||
|
||||
@@ -101,7 +101,41 @@ class HoldingPerformanceTests(TestCase):
|
||||
|
||||
self.assertEqual(perf.cost_basis, D("180"))
|
||||
self.assertTrue(perf.estimated)
|
||||
self.assertEqual(perf.contributed, D("0"))
|
||||
self.assertEqual(perf.contributed, D("180"))
|
||||
|
||||
def test_priced_account_separates_money_from_yield(self):
|
||||
# A foreign-currency account: pay in, interest, then money out unlinked
|
||||
self.rate("100", self.d1)
|
||||
result = TransactionCategory.objects.create(name="Interest")
|
||||
self.etf.income_categories.set([result])
|
||||
tx(self.etf, IN, "10", self.d1) # paid in from outside
|
||||
tx(self.etf, IN, "1", self.d2, category=result)
|
||||
tx(self.etf, EX, "5", self.d3) # taken out, not a loss
|
||||
|
||||
perf = get_holding_performance(self.etf)
|
||||
|
||||
self.assertEqual(perf.contributed, D("1000"))
|
||||
self.assertEqual(perf.withdrawn, D("500"))
|
||||
self.assertEqual(perf.quantity, D("6"))
|
||||
self.assertEqual(perf.market_value, D("600"))
|
||||
# The interest is the whole return: 600 held + 500 out - 1000 in
|
||||
self.assertEqual(perf.total_return, D("100"))
|
||||
self.assertEqual(perf.results, [])
|
||||
|
||||
def test_value_tracked_transfer_counts_its_own_amount(self):
|
||||
# Paying 1000 for units now worth 900 is the units' loss, not the cash's
|
||||
cash = Account.objects.create(
|
||||
name="Savings", currency=self.eur, is_investment=True
|
||||
)
|
||||
tx(cash, IN, "5000", self.d1)
|
||||
transfer(cash, "1000", self.etf, "10", self.d2)
|
||||
self.rate("90", timezone.localdate())
|
||||
|
||||
perf = get_holding_performance(cash)
|
||||
|
||||
self.assertEqual(perf.withdrawn, D("1000"))
|
||||
self.assertEqual(perf.total_return, D("0"))
|
||||
self.assertEqual(get_holding_performance(self.etf).total_return, D("-100"))
|
||||
|
||||
def test_value_tracked_cdb_with_tax_on_redemption(self):
|
||||
cdb = Account.objects.create(
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
<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 %}
|
||||
{% if perf.estimated %}<i class="fa-solid fa-circle-exclamation text-warning ml-1" data-tippy-content="{% translate 'Some units moved in or out without a linked payment, so they were valued at the market price of that day' %}"></i>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -284,7 +284,7 @@
|
||||
{% if tx.type == 'IN' %}{% translate 'Bought / contributed' %}{% else %}{% translate 'Sold / withdrew' %}{% endif %}
|
||||
· {{ p.account.name }}
|
||||
{% else %}
|
||||
{% if not perf.priced and tx.id not in perf.result_ids %}
|
||||
{% if tx.id not in perf.result_ids %}
|
||||
{% if tx.type == 'IN' %}{% translate 'Money in' %}{% else %}{% translate 'Money out' %}{% endif %}
|
||||
{% else %}
|
||||
{% if tx.type == 'IN' %}{% if perf.priced %}{% translate 'Gain' %}{% else %}{% translate 'Earnings' %}{% endif %}{% else %}{% translate 'Loss, tax or fee' %}{% endif %}
|
||||
|
||||
Reference in new issue
Block a user