tests/test_gain_measures.py
Rama main · réplica de lectura del repositorio original.
"""Two measures of gain, each labelled for what it is (decisions.md #54).
A holding's gain is UNREALISED: value less what the units still held cost,
the oldest sold first. The portfolio's chart and its period figures are TOTAL
RETURN: value plus sale proceeds plus cash income, less everything put in.
Before, the Gain tile and column measured value against every buy, sold or
not, which is neither: buy 100 at $10, receive $100, sell 50 at $20, and the
price now $20 showed a gain of 0, the chart 110% and the unrealised-CGT export
500, for one position.
"""
from __future__ import annotations
import re
from decimal import Decimal
from freezegun import freeze_time
import factories as fac
from app import exports, fyreport, queries
from test_routes import bind_to_only_portfolio, make_login
HTML = {"accept": "text/html"}
TODAY = "2026-08-03"
def _review_case(s):
"""The review's case: 100 at $10, $100 paid in cash, 50 sold at $20, and
$20 now."""
acme = fac.make_instrument(s, "ACME")
fac.add_trade(s, acme, "2025-01-06", "buy", 100, "10.00")
fac.add_dividend(s, acme, "2025-03-03", "100.00")
fac.add_trade(s, acme, "2025-06-02", "sell", 50, "20.00")
fac.add_prices(s, acme, [("2025-01-06", "10.00"), ("2025-06-02", "20.00"),
("2026-08-03", "20.00")])
return acme
@freeze_time(TODAY)
def test_a_holding_measures_the_units_it_still_has(pf):
_review_case(pf)
[holding] = queries.all_holdings(pf)
totals = queries.totals([holding])
# 50 units held, bought at $10: cost base 500, worth 1,000.
assert (holding.cost, holding.cost_aud, holding.value_aud) == (500, 500, 1000)
assert (holding.gain_aud, holding.gain_pct) == (500, 1)
assert (totals.cost, totals.gain, totals.gain_pct) == (500, 500, 1)
@freeze_time(TODAY)
def test_the_chart_measures_total_return(pf):
"""Value 1,000 + proceeds 1,000 + income 100 − invested 1,000 = 1,100."""
_review_case(pf)
series = queries.portfolio_series(pf)
assert (series["gain"][-1], series["gain_pct"][-1]) == (1100.0, 1.1)
@freeze_time(TODAY)
def test_the_holding_and_the_unrealised_cgt_export_agree(pf):
_review_case(pf)
_headers, rows = exports.unrealised_cgt(pf)
holdings_headers, holdings_rows = exports.holdings(pf)
row = dict(zip(holdings_headers, holdings_rows[0]))
assert [r[7] for r in rows] == [Decimal("500.00")]
assert (row["Cost base (AUD)"], row["Unrealised gain (AUD)"]) == (
Decimal("500.00"), Decimal("500.00"))
@freeze_time(TODAY)
def test_a_reinvested_unit_counts_at_what_it_cost(pf):
"""A reinvested unit was paid for with the distribution, which is its cost
base. Counted as free, the distribution was in the gain as well as in the
Dividends column."""
acme = fac.make_instrument(pf, "ACME")
fac.add_trade(pf, acme, "2025-01-06", "buy", 100, "10.00", brokerage="10.00")
fac.add_drp(pf, acme, "2025-07-01", "60.00", 5, "12.00")
fac.add_prices(pf, acme, [("2026-08-03", "12.00")])
[holding] = queries.all_holdings(pf)
assert (holding.cost_aud, holding.value_aud, holding.gain_aud) == (1070, 1260, 190)
assert holding.invested == 1010 # every buy: what a closed position cost
@freeze_time(TODAY)
def test_a_parcel_sold_in_full_has_no_gain_in_the_ledger(pf):
"""The ledger's per-parcel gain is on the units of it still held, so a
parcel the FIFO sold has none, and one sold in part counts what is left."""
acme = fac.make_instrument(pf, "ACME")
fac.add_trade(pf, acme, "2025-01-06", "buy", 100, "10.00", brokerage="10.00")
fac.add_trade(pf, acme, "2025-02-03", "buy", 100, "11.00", brokerage="10.00")
fac.add_trade(pf, acme, "2025-06-02", "sell", 150, "20.00", brokerage="10.00")
fac.add_prices(pf, acme, [("2026-08-03", "15.00")])
_inst, holding, events = queries.ledger(pf, "ACME")
gains = {(e.date.isoformat(), e.kind): (e.gain, e.gain_pct) for e in events}
assert gains[("2025-01-06", "buy")] == (None, None)
# 50 of the second parcel: cost (1,100 + 10) / 2 = 555, worth 750.
assert gains[("2025-02-03", "buy")] == (Decimal("195"), Decimal("195") / Decimal("555"))
assert holding.gain == Decimal("195")
@freeze_time(TODAY)
def test_a_holding_with_a_sale_of_more_than_was_held_has_no_cost(client, session_factory):
"""Written before every form refused one: there is no FIFO cost for what
remains, so the holding is left out of the totals and named, and the page
says what to do."""
make_login(client, session_factory)
with session_factory() as s:
bind_to_only_portfolio(s)
acme = fac.make_instrument(s, "ACME")
fac.add_trade(s, acme, "2025-01-06", "buy", 100, "10.00")
fac.add_trade(s, acme, "2025-02-03", "sell", 150, "12.00")
fac.add_trade(s, acme, "2025-03-03", "buy", 100, "10.00")
fac.add_prices(s, acme, [("2026-08-03", "11.00")])
s.commit()
with session_factory() as s:
bind_to_only_portfolio(s)
[holding] = queries.all_holdings(s)
totals = queries.totals([holding])
page = client.get("/", headers=HTML).text
assert (holding.oversold, holding.units, holding.cost, holding.gain) == (
True, Decimal(50), None, None)
assert (totals.oversold, totals.excluded, totals.cost) == (["ACME"], [], 0)
assert ("Totals exclude ACME — a sale of more\nunits than were held. Correct it or "
"record the missing buy.") in page
@freeze_time(TODAY)
def test_the_fy_snapshot_measures_the_units_held_at_its_end(pf):
"""At 30 June 2025, 50 units held, bought at $10, worth $20: 500 on 500."""
_review_case(pf)
[row] = fyreport.fy_report(pf, 2025)["snapshot"]
assert (row.cost_base, row.value_aud, row.gain_aud, row.gain_pct) == (
500, 1000, 500, 1)
assert row.invested_cum == 1000 # every buy to date, for the API's `invested`
@freeze_time(TODAY)
def test_a_trade_with_no_rate_is_converted_as_the_fy_page_converts_it(pf):
"""The dashboard took only each trade's own rate and left the holding out
without one, while the FY page took the stored series for its date: one
holding, two answers (decisions.md #5)."""
nova = fac.make_instrument(pf, "NOVA", exchange="NASDAQ", currency="USD")
fac.add_trade(pf, nova, "2026-07-01", "buy", 10, "100.00", fx_rate=None)
fac.add_fx(pf, "USDAUD", "2026-06-30", "1.50")
fac.add_fx(pf, "USDAUD", "2026-07-31", "1.60")
fac.add_prices(pf, nova, [("2026-08-03", "110.00")])
[holding] = queries.all_holdings(pf)
assert (holding.cost_aud, holding.value_aud) == (1500, 1760)
assert queries.totals([holding]).excluded == []
@freeze_time(TODAY)
def test_the_pages_name_each_measure(client, session_factory):
make_login(client, session_factory)
with session_factory() as s:
bind_to_only_portfolio(s)
_review_case(s)
s.commit()
dashboard = client.get("/", headers=HTML).text
fy = client.get("/?fy=2025", headers=HTML).text
holding = client.get("/holding/ACME", headers=HTML).text
tiles = re.findall(r'<span class="label">([^<]+)</span>', dashboard)
assert tiles[:3] == ["Cost base", "Total value", "Unrealised gain"]
assert ">Unrealised gain<" in re.sub(r"<span class=\"sortarrow\".*?</span>", "",
dashboard.split('class="holdings"', 1)[1])
assert 'aria-label="Total return: ' in dashboard
assert "Cost base (AUD)" in fy and "Unrealised gain (AUD)" in fy
assert '<span class="label">Cost base</span>' in holding