"""The AU capital-gains engine: FIFO parcel matching, the 12-month discount boundary, loss ordering inside a financial year, and the FY date helpers. Every expected figure is worked out by hand in the comment beside it. The scenarios are deliberately tiny — a three-trade instrument whose arithmetic fits on one line is a far better regression test than a realistic one whose "expected" value could only ever be obtained by running the code. The reference portfolio appears here too, but only where its FY2024 figures add something the small scenarios can't: the loss-ordering counterfactual, and a disposal whose parcels straddle the discount boundary. """ from __future__ import annotations import datetime as dt import re from decimal import Decimal import pytest from freezegun import freeze_time from sqlalchemy import select from sqlalchemy.orm import selectinload import fixture_portfolio as ref from app import fyreport from app.models import Instrument from factories import add_dividend, add_drp, add_prices, add_trade, make_instrument def _disposals(session, inst): """FIFO disposals for `inst`, with its trades freshly loaded. Local to this file on purpose (the shared helpers are off limits): the factories attach trades by foreign key and never touch `instrument.trades`, so the collection has to be re-read before the engine walks it. This is the same selectinload `fy_cgt` does for itself. """ return fyreport.instrument_disposals( session.scalars( select(Instrument) .where(Instrument.id == inst.id) .options(selectinload(Instrument.trades)) ).one() ) def _two_parcels(session): """100 units at $2.00 (2020) then 100 at $3.00 (2021). No brokerage, so every unit cost is a round number and the FIFO split can be read straight off the trades. """ inst = make_instrument(session, "ACME", asset_class="share") add_trade(session, inst, "2020-01-01", "buy", 100, "2.00") add_trade(session, inst, "2021-01-01", "buy", 100, "3.00") return inst # --------------------------------------------------------------------------- # # FIFO parcel matching # --------------------------------------------------------------------------- # def test_fifo_consumes_the_oldest_parcel_first(pf): inst = _two_parcels(pf) add_trade(pf, inst, "2023-01-01", "sell", 150, "5.00") (disposal,) = _disposals(pf, inst) # 150 units: all 100 of the 2020 parcel, then 50 of the 2021 one. assert [p.acquired for p in disposal.parcels] == [ dt.date(2020, 1, 1), dt.date(2021, 1, 1), ] assert [p.quantity for p in disposal.parcels] == [100, 50] assert disposal.cost_base == Decimal("350.00") # 100 x 2.00 + 50 x 3.00 assert disposal.proceeds == Decimal("750.00") # 150 x 5.00, no brokerage assert disposal.gain == Decimal("400.00") # 750.00 - 350.00 def test_a_part_used_parcel_leaves_its_remainder_for_a_later_sell(pf): inst = _two_parcels(pf) add_trade(pf, inst, "2023-01-01", "sell", 150, "5.00") add_trade(pf, inst, "2023-06-01", "sell", 50, "6.00") first, second = _disposals(pf, inst) # The first sell used 50 of the 100-unit 2021 parcel; 50 units at $3.00 are # still sitting there for the second. assert first.parcels[-1].quantity == 50 assert [p.acquired for p in second.parcels] == [dt.date(2021, 1, 1)] assert second.parcels[0].quantity == 50 assert second.cost_base == Decimal("150.00") # 50 x 3.00 assert second.proceeds == Decimal("300.00") # 50 x 6.00 assert second.gain == Decimal("150.00") # 300.00 - 150.00 def test_buy_brokerage_joins_the_cost_base_and_sell_brokerage_leaves_the_proceeds(pf): inst = make_instrument(pf, "WIDGET", asset_class="share") add_trade(pf, inst, "2022-01-01", "buy", 100, "5.00", brokerage="10.00") add_trade(pf, inst, "2024-01-02", "sell", 100, "8.00", brokerage="20.00") (disposal,) = _disposals(pf, inst) assert disposal.cost_base == Decimal("510.00") # 100 x 5.00 + 10.00 = 5.10 a unit assert disposal.proceeds == Decimal("780.00") # 100 x 8.00 - 20.00 = 7.80 a unit assert disposal.gain == Decimal("270.00") # 780.00 - 510.00 # Without brokerage on both sides the gain would read 300.00 — the $30 of # fees is exactly what the two adjustments are worth. assert disposal.gain != Decimal("300.00") def test_a_drp_allocation_is_an_acquisition_parcel_like_any_buy(pf): inst = make_instrument(pf, "NOVA", asset_class="etf", drp=True) add_trade(pf, inst, "2022-01-01", "buy", 100, "10.00") # A $60 distribution taken as 5 units at $12.00. add_drp(pf, inst, "2022-07-01", "60.00", 5, "12.00") add_trade(pf, inst, "2024-01-02", "sell", 102, "20.00") (disposal,) = _disposals(pf, inst) bought, reinvested = disposal.parcels assert bought.acquired == dt.date(2022, 1, 1) assert reinvested.acquired == dt.date(2022, 7, 1) assert reinvested.quantity == 2 # 102 sold - 100 in the buy parcel # Reinvested units are free of cash outlay but NOT free for CGT: the $12.00 # a unit the distribution bought them at is their cost base. assert reinvested.cost_base == Decimal("24.00") # 2 x 12.00 assert reinvested.proceeds == Decimal("40.00") # 2 x 20.00 assert disposal.cost_base == Decimal("1024.00") # 100 x 10.00 + 24.00 def test_selling_more_than_is_held_raises_naming_the_ticker(pf): inst = make_instrument(pf, "QUARK", asset_class="share") add_trade(pf, inst, "2023-01-01", "buy", 100, "1.00") add_trade(pf, inst, "2024-01-01", "sell", 150, "2.00") # Silently matching only what it can find would understate the gain, so the # engine refuses rather than guessing at the missing parcel. with pytest.raises(ValueError, match="QUARK"): _disposals(pf, inst) def test_a_year_that_depends_on_a_sale_of_more_than_was_held_is_refused(pf): """Every way of writing a trade refuses such a sale, but a ledger from before they did can hold one. Its gain has no parcels to match, and a year's gains without it are not the year's gains, so the year has none: the sale is named for somebody to fix (decisions.md #5).""" quark = make_instrument(pf, "QUARK", asset_class="share") add_trade(pf, quark, "2023-01-01", "buy", 100, "1.00") add_trade(pf, quark, "2024-01-01", "sell", 150, "2.00") # 50 more than held acme = make_instrument(pf, "ACME", asset_class="share") add_trade(pf, acme, "2022-01-01", "buy", 100, "10.00") add_trade(pf, acme, "2024-03-01", "sell", 100, "15.00") # a gain of its own cgt = fyreport.fy_cgt(pf, 2024) (sale,) = cgt.oversold assert (sale.instrument.ticker, sale.trade.date, sale.held) == ( "QUARK", dt.date(2024, 1, 1), Decimal("100")) assert cgt.disposals == [] assert (cgt.net_capital_gain, cgt.gains_discountable, cgt.losses) == (0, 0, 0) def test_a_sale_of_more_than_was_held_is_named_in_the_years_that_depend_on_it(pf): quark = make_instrument(pf, "QUARK", asset_class="share") add_trade(pf, quark, "2020-01-01", "buy", 100, "1.00") add_trade(pf, quark, "2021-03-01", "sell", 50, "2.00") # FY2021, matched add_trade(pf, quark, "2024-01-01", "sell", 100, "2.00") # FY2024, only 50 held add_trade(pf, quark, "2025-01-01", "buy", 100, "3.00") # FY2025, nothing sold add_trade(pf, quark, "2026-01-01", "sell", 10, "4.00") # FY2026 def named(fy): return [sale.trade.date for sale in fyreport.fy_cgt(pf, fy).oversold] # A year before it is matched as usual. Every year after it depends on # it: a later sale's parcels on what the oversold one used up, and any # year, one that sold nothing included, on the loss it would have carried. assert [d.date for d in fyreport.fy_cgt(pf, 2021).disposals] == [dt.date(2021, 3, 1)] assert named(2021) == [] assert named(2024) == [dt.date(2024, 1, 1)] assert named(2025) == [dt.date(2024, 1, 1)] assert named(2026) == [dt.date(2024, 1, 1)] def test_parcel_proceeds_sum_to_the_net_sale_proceeds(pf): """What the ATO schedule reports as proceeds has to be what was received.""" inst = make_instrument(pf, "PIXEL", asset_class="share") for day in ("2020-01-01", "2020-01-02", "2020-01-03"): add_trade(pf, inst, day, "buy", 1, "1.00") # 3 x 5.00 less 1.00 brokerage = 14.00 received, i.e. 4.6666... a unit. add_trade(pf, inst, "2024-01-02", "sell", 3, "5.00", brokerage="1.00") (disposal,) = _disposals(pf, inst) # Per-unit-then-round gave each one-unit parcel 4.67 and reported 14.01 # against 14.00 of money. Both halves are asserted: the header figure is # what was received, and the rows under it add up to it — a schedule whose # lines do not sum to its total is exactly what an accountant queries. assert disposal.proceeds == Decimal("14.00") assert sum(p.proceeds for p in disposal.parcels) == Decimal("14.00") # The leftover cent lands on a parcel rather than on nobody. assert sorted(p.proceeds for p in disposal.parcels) == [ Decimal("4.66"), Decimal("4.67"), Decimal("4.67"), ] def test_parcel_cost_bases_sum_to_what_was_paid_for_the_parcel(pf): """The same rounding fault, one column across, and just as visible. A parcel bought for 10.00 and sold a unit at a time reports a 3.33 cost base three times — 9.99 against money that was 10.00. A cost base a cent light is a gain a cent heavy, which is the direction that costs you. """ inst = make_instrument(pf, "PRISM", asset_class="share") # 3 units at 3.00 plus 1.00 brokerage = 10.00 paid, i.e. 3.3333... a unit. add_trade(pf, inst, "2020-01-01", "buy", 3, "3.00", brokerage="1.00") for day in ("2024-01-02", "2024-01-03", "2024-01-04"): add_trade(pf, inst, day, "sell", 1, "5.00") disposals = _disposals(pf, inst) spent = sum(p.cost_base for d in disposals for p in d.parcels) assert spent == Decimal("10.00") # --------------------------------------------------------------------------- # # The 12-month discount boundary # --------------------------------------------------------------------------- # def test_one_year_after_is_the_same_day_of_the_next_year(): assert fyreport._one_year_after(dt.date(2023, 6, 15)) == dt.date(2024, 6, 15) def test_one_year_after_a_leap_day_lands_on_28_february(): """29 February has no anniversary, so the engine uses 28 February. Paired with the strict ">" test that makes 1 March the first discountable day — 366 days held, the same count every other acquisition date needs. """ assert fyreport._one_year_after(dt.date(2024, 2, 29)) == dt.date(2025, 2, 28) def test_a_sale_exactly_one_year_after_acquisition_is_not_discountable(pf): """The discount wants MORE than 12 months, so the anniversary itself misses.""" inst = make_instrument(pf, "ORBIT", asset_class="share") add_trade(pf, inst, "2023-06-15", "buy", 100, "1.00") add_trade(pf, inst, "2024-06-15", "sell", 100, "2.00") # 366 days would be the 16th (disposal,) = _disposals(pf, inst) assert disposal.parcels[0].discountable is False def test_a_sale_one_year_and_a_day_after_acquisition_is_discountable(pf): inst = make_instrument(pf, "ORBIT", asset_class="share") add_trade(pf, inst, "2023-06-15", "buy", 100, "1.00") add_trade(pf, inst, "2024-06-16", "sell", 100, "2.00") (disposal,) = _disposals(pf, inst) assert disposal.parcels[0].discountable is True def test_a_leap_day_acquisition_becomes_discountable_on_1_march(pf): inst = make_instrument(pf, "HELIX", asset_class="share") add_trade(pf, inst, "2024-02-29", "buy", 200, "1.00") add_trade(pf, inst, "2025-02-28", "sell", 100, "2.00") # 365 days held add_trade(pf, inst, "2025-03-01", "sell", 100, "2.00") # 366 days held on_28_feb, on_1_mar = _disposals(pf, inst) assert on_28_feb.parcels[0].discountable is False assert on_1_mar.parcels[0].discountable is True # --------------------------------------------------------------------------- # # fy_cgt: window, discount, loss ordering # --------------------------------------------------------------------------- # def test_only_disposals_inside_the_fy_window_count(pf): inst = make_instrument(pf, "NOVA", asset_class="share") add_trade(pf, inst, "2020-01-01", "buy", 300, "1.00") add_trade(pf, inst, "2023-06-30", "sell", 100, "2.00") # last day of FY2023 add_trade(pf, inst, "2023-07-01", "sell", 100, "2.00") # first day of FY2024 add_trade(pf, inst, "2024-06-30", "sell", 100, "2.00") # last day of FY2024 # Both ends of the window are inclusive, and a FY with no sells is empty # even though the instrument still has parcels sitting in it. assert [d.date for d in fyreport.fy_cgt(pf, 2023).disposals] == [dt.date(2023, 6, 30)] assert [d.date for d in fyreport.fy_cgt(pf, 2024).disposals] == [ dt.date(2023, 7, 1), dt.date(2024, 6, 30), ] assert fyreport.fy_cgt(pf, 2025).disposals == [] def test_the_fifty_percent_discount_halves_the_surviving_discountable_gain(pf): inst = make_instrument(pf, "ACME", asset_class="share") add_trade(pf, inst, "2022-01-01", "buy", 100, "10.00") add_trade(pf, inst, "2024-03-01", "sell", 100, "15.00") cgt = fyreport.fy_cgt(pf, 2024) assert cgt.gains_discountable == Decimal("500.00") # 1500.00 - 1000.00, held 2 years assert cgt.gains_other == 0 assert cgt.discount == Decimal("250.00") # 500.00 / 2 assert cgt.net_capital_gain == Decimal("250.00") # 500.00 - 250.00 def test_losses_offset_non_discountable_gains_before_discountable_ones(pf): # Two NOVA parcels — one long held, one fresh — sold a day apart so FIFO # matches each sell to exactly one of them. nova = make_instrument(pf, "NOVA", asset_class="share") add_trade(pf, nova, "2022-01-01", "buy", 100, "1.00") add_trade(pf, nova, "2024-01-10", "buy", 100, "1.00") add_trade(pf, nova, "2024-03-01", "sell", 100, "5.00") # 500 - 100 = 400 gain, >12m add_trade(pf, nova, "2024-03-02", "sell", 100, "2.00") # 200 - 100 = 100 gain, <12m vertex = make_instrument(pf, "VERTEX", asset_class="share") add_trade(pf, vertex, "2023-05-01", "buy", 100, "3.00") add_trade(pf, vertex, "2024-05-01", "sell", 100, "2.00") # 200 - 300 = 100 loss cgt = fyreport.fy_cgt(pf, 2024) assert cgt.gains_discountable == Decimal("400.00") assert cgt.gains_other == Decimal("100.00") assert cgt.losses == Decimal("100.00") # The whole loss lands on the undiscounted gain, where a dollar of loss # cancels a dollar of tax instead of only fifty cents of it. assert cgt.losses_applied_other == Decimal("100.00") assert cgt.losses_applied_discountable == 0 assert cgt.discount == Decimal("200.00") # 400.00 / 2, nothing taken off it assert cgt.net_capital_gain == Decimal("200.00") # (100-100) + 400 - 200 # The other order would spend the loss on the discountable gain: 400 - 100 # = 300 survives, discount 150, and the 100 non-discountable gain stands — # 100 + 300 - 150 = 250.00, i.e. 50.00 more assessable income. assert cgt.net_capital_gain != Decimal("250.00") @freeze_time(ref.TODAY) def test_reference_fy2024_applies_losses_in_the_taxpayer_favourable_order(pf): ref.build_reference(pf) cgt = fyreport.fy_cgt(pf, ref.FY2024) # ZULU's 220.00 loss: BETAX's undiscounted 91.67 goes first, then the # remaining 128.33 reduces the discountable 283.33 to 155.00. assert cgt.losses_applied_other == ref.FY2024_GAINS_OTHER assert cgt.losses_applied_discountable == Decimal("128.33") # 220.00 - 91.67 assert cgt.net_capital_gain == ref.FY2024_NET_CAPITAL_GAIN assert cgt.net_capital_gain != ref.FY2024_NET_IF_LOSSES_APPLIED_TO_DISCOUNTABLE_FIRST assert cgt.discount != ref.FY2024_DISCOUNT_IF_LOSSES_APPLIED_TO_DISCOUNTABLE_FIRST @freeze_time(ref.TODAY) def test_reference_fy2024_disposals_carry_hand_computed_parcels(pf): ref.build_reference(pf) disposals = fyreport.fy_cgt(pf, ref.FY2024).disposals assert [d.instrument.ticker for d in disposals] == ["BETAX", "ZULU"] # date order betax, zulu = disposals # BETAX sold 150 at 8.00 less 10.00 brokerage = 1190.00, i.e. 7.93333... a unit. old, new = betax.parcels assert old.acquired == dt.date(2023, 1, 10) assert old.quantity == 100 assert old.cost_base == Decimal("510.00") # 100 x (100x5.00 + 10.00)/100 = 5.10 assert old.proceeds == Decimal("793.33") # 100 x 7.93333... assert old.discountable is True # sold 2024-06-10 > 2024-01-10 assert new.acquired == dt.date(2023, 9, 10) assert new.quantity == 50 assert new.cost_base == Decimal("305.00") # 50 x (100x6.00 + 10.00)/100 = 6.10 assert new.proceeds == Decimal("396.67") # 50 x 7.93333... assert new.discountable is False # sold 2024-06-10 < 2024-09-10 # One parcel straddling nothing: ZULU sold out at 200 x 9.00 - 10.00 = 1790.00 # against a 200 x 10.05 = 2010.00 cost base. (parcel,) = zulu.parcels assert parcel.cost_base == Decimal("2010.00") assert zulu.proceeds == Decimal("1790.00") assert zulu.gain == Decimal("-220.00") def test_a_fy_netting_to_a_loss_reports_net_capital_loss_and_no_gain(pf): winner = make_instrument(pf, "ACME", asset_class="share") add_trade(pf, winner, "2022-01-01", "buy", 100, "1.00") add_trade(pf, winner, "2024-03-01", "sell", 100, "2.00") # 200 - 100 = 100 gain, >12m loser = make_instrument(pf, "WIDGET", asset_class="share") add_trade(pf, loser, "2023-01-01", "buy", 100, "5.00") add_trade(pf, loser, "2024-03-01", "sell", 100, "2.00") # 200 - 500 = 300 loss cgt = fyreport.fy_cgt(pf, 2024) # 300.00 of losses swallows the 100.00 gain whole and 200.00 is left over. # A carried-forward loss is never discounted, so the discount is nil rather # than half of anything. assert cgt.discount == 0 assert cgt.net_capital_gain == 0 assert cgt.net_capital_loss == Decimal("200.00") # 300.00 - 100.00 def _sale(pf, ticker: str, bought: str, sold: str, cost: str, price: str): """100 units bought on `bought` at `cost` and sold on `sold` at `price`.""" inst = make_instrument(pf, ticker, asset_class="share") add_trade(pf, inst, bought, "buy", 100, cost) add_trade(pf, inst, sold, "sell", 100, price) return inst def test_an_earlier_years_loss_is_applied_before_the_discount(pf): """s102-5(1): this year's losses, then earlier years' net capital losses, then the discount. A FY2024 loss of 300 left FY2025's discountable gain of 300 standing at 150; the net capital gain is nil.""" _sale(pf, "WIDGET", "2023-01-02", "2024-03-01", "5.00", "2.00") # FY2024: 300 loss _sale(pf, "ACME", "2022-01-03", "2024-12-02", "1.00", "4.00") # FY2025: 300 gain, >12m earlier = fyreport.fy_cgt(pf, 2024) later = fyreport.fy_cgt(pf, 2025) assert (earlier.net_capital_loss, earlier.losses_carried_out) == (300, 300) assert (later.losses, later.losses_carried_in, later.losses_carried_applied) == (0, 300, 300) assert (later.discount, later.net_capital_gain, later.losses_carried_out) == (0, 0, 0) def test_what_an_earlier_loss_does_not_use_carries_on(pf): _sale(pf, "WIDGET", "2023-01-02", "2024-03-01", "5.00", "4.00") # FY2024: 100 loss _sale(pf, "ACME", "2022-01-03", "2024-12-02", "1.00", "4.00") # FY2025: 300 gain later = fyreport.fy_cgt(pf, 2025) # 300 − 100 carried = 200, halved by the discount. assert (later.losses_carried_applied, later.discount, later.net_capital_gain) == ( 100, 100, 100) assert later.losses_carried_out == 0 def test_an_earlier_loss_goes_to_the_gains_with_no_discount_first(pf): """The taxpayer-favourable order, as for this year's own losses: a dollar of loss on an undiscounted gain saves a dollar, on a discounted one fifty cents.""" _sale(pf, "WIDGET", "2023-01-02", "2024-03-01", "5.00", "4.00") # FY2024: 100 loss _sale(pf, "ACME", "2022-01-03", "2024-12-02", "1.00", "4.00") # FY2025: 300, >12m _sale(pf, "NOVA", "2024-09-02", "2025-02-03", "1.00", "1.50") # FY2025: 50, <12m later = fyreport.fy_cgt(pf, 2025) # 50 of the 100 cancels the 50 with no discount; 50 comes off the 300, # and the 250 left is halved: 125. assert (later.losses_carried_applied, later.discount, later.net_capital_gain) == ( 100, 125, 125) def test_this_years_losses_come_off_before_an_earlier_years(pf): _sale(pf, "WIDGET", "2023-01-02", "2024-03-01", "5.00", "4.00") # FY2024: 100 loss _sale(pf, "ACME", "2022-01-03", "2024-12-02", "1.00", "4.00") # FY2025: 300 gain _sale(pf, "GAMMA", "2024-08-01", "2025-01-06", "3.00", "2.00") # FY2025: 100 loss later = fyreport.fy_cgt(pf, 2025) assert (later.losses_applied_discountable, later.losses_carried_applied) == (100, 100) assert later.net_capital_gain == 50 # (300 − 100 − 100) / 2 def test_a_years_own_net_loss_is_what_its_losses_leave_after_its_gains(pf): """This year's losses meet its gains before an earlier year's do, so the year's own net capital loss is what is left of them, and the earlier loss carries on untouched beside it.""" _sale(pf, "WIDGET", "2023-01-02", "2024-03-01", "5.00", "4.50") # FY2024: 50 loss _sale(pf, "ACME", "2022-01-03", "2024-12-02", "1.00", "2.00") # FY2025: 100 gain _sale(pf, "GAMMA", "2024-08-01", "2025-01-06", "3.00", "1.50") # FY2025: 150 loss later = fyreport.fy_cgt(pf, 2025) assert (later.net_capital_loss, later.losses_carried_applied, later.losses_carried_out) == (50, 0, 100) def test_a_year_that_nets_to_a_loss_carries_it_with_the_earlier_ones(pf): """Nothing was left to set an earlier loss against, so all of it carries on, with this year's own.""" _sale(pf, "WIDGET", "2023-01-02", "2024-03-01", "5.00", "4.00") # FY2024: 100 loss _sale(pf, "GAMMA", "2024-08-01", "2025-01-06", "3.00", "2.50") # FY2025: 50 loss _sale(pf, "ACME", "2022-01-03", "2025-12-01", "1.00", "3.00") # FY2026: 200 gain middle = fyreport.fy_cgt(pf, 2025) last = fyreport.fy_cgt(pf, 2026) assert (middle.net_capital_loss, middle.losses_carried_in, middle.losses_carried_out) == ( 50, 100, 150) assert (last.losses_carried_in, last.net_capital_gain, last.losses_carried_out) == ( 150, 25, 0) def test_a_year_with_no_sales_passes_an_earlier_loss_on(pf): _sale(pf, "WIDGET", "2023-01-02", "2024-03-01", "5.00", "4.00") # FY2024: 100 loss _sale(pf, "ACME", "2022-01-03", "2025-12-01", "1.00", "3.00") # FY2026: 200 gain quiet = fyreport.fy_cgt(pf, 2025) assert (quiet.losses_carried_in, quiet.losses_carried_out) == (100, 100) assert fyreport.fy_cgt(pf, 2026).net_capital_gain == 50 def test_a_year_that_cannot_be_worked_out_leaves_the_years_after_it_unknown(pf): """Whether it made a loss to carry is not known, so neither is any later year's net gain: it names the same sale.""" oversold = make_instrument(pf, "OVER", asset_class="share") add_trade(pf, oversold, "2023-01-02", "buy", 100, "5.00") add_trade(pf, oversold, "2024-03-01", "sell", 150, "4.00") # FY2024: refused _sale(pf, "ACME", "2022-01-03", "2024-12-02", "1.00", "4.00") # FY2025: 300 gain later = fyreport.fy_cgt(pf, 2025) assert [sale.trade.date for sale in later.oversold] == [dt.date(2024, 3, 1)] assert (later.disposals, later.net_capital_gain) == ([], 0) @freeze_time("2026-08-03") def test_the_fy_pages_and_the_api_show_a_loss_carried_forward(client, session_factory): """A loss year read as a gain of nothing, under a green arrow.""" from test_api import bearer, issue_key from test_routes import bind_to_only_portfolio, make_login make_login(client, session_factory) with session_factory() as s: portfolio = bind_to_only_portfolio(s) _sale(s, "WIDGET", "2023-01-02", "2024-03-01", "5.00", "2.00") # FY2024: 300 loss _sale(s, "ACME", "2022-01-03", "2024-12-02", "1.00", "4.00") # FY2025: 300 gain s.commit() user_id, portfolio_id = portfolio.members[0].user_id, portfolio.id html = {"accept": "text/html"} loss_year = client.get("/", params={"fy": 2024}, headers=html).text gain_year = client.get("/", params={"fy": 2025}, headers=html).text api = client.get("/api/v1/fy/2025", headers=bearer(issue_key(session_factory, portfolio_id, created_by=user_id))).json()["cgt"] tile = re.search(r'