Ctrl K
ComunidadGuías…

tests/test_sale_simulator.py

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
"""Simulating a sale: what selling units today would bring home after tax.

The ATO's method (decisions.md #148): each sale's capital gain is its proceeds
less its cost base, brokerage included; the year's gains are reduced by its
losses and then by losses carried in from earlier years, non-discountable gains
first; the 50% discount applies to what is left of gains on parcels held over
twelve months; and the resulting net capital gain is taxed at the marginal
rate, and the Medicare levy on top. So the tax a sale costs is what it ADDS to
the year's net capital gain, times each. Every figure below is worked out by
hand.
"""

from __future__ import annotations

from decimal import Decimal

import pytest
from freezegun import freeze_time
from sqlalchemy import func, select

import factories as fac
from app import queries, simulate, tenancy
from app.models import Portfolio, Trade

TODAY = "2026-10-17"          # FY2027: 1 July 2026 to 30 June 2027
RATES = {"income_tax_rate": Decimal(30), "medicare_levy": Decimal(2)}


@pytest.fixture
def alpha(pf):
    """Two parcels: 100 at $10 held over a year by today, 100 at $12 not."""
    inst = fac.make_instrument(pf, "ALPHA", asset_class="share")
    fac.add_trade(pf, inst, "2025-01-06", "buy", 100, "10.00", brokerage="10")
    fac.add_trade(pf, inst, "2026-03-02", "buy", 100, "12.00", brokerage="10")
    fac.add_prices(pf, inst, [("2026-10-16", "15.00")])
    return inst


def _sell(pf, inst, units, *, rates=RATES, **kwargs):
    return simulate.sale(pf, {inst.id: Decimal(units)}, **rates, **kwargs)


@freeze_time(TODAY)
def test_a_sale_is_matched_fifo_and_taxed_on_what_it_adds(pf, alpha):
    """150 units at $15, less the last trade's $10 brokerage: 2,240. FIFO takes
    all of the first parcel (1,010, held over a year) and half the second
    (605). Gains 483.33 discountable and 141.67 not; the discount halves the
    first, so 383.335 is added to the year, taxed at 32%."""
    sale = _sell(pf, alpha, 150)
    [line] = sale.lines

    assert (line.units, line.value, line.brokerage_aud, line.cost_base, line.gain) == (
        150, Decimal("2250.00"), Decimal("10.00"), Decimal("1615.00"), Decimal("625.00"))
    assert (sale.gain, sale.discount, sale.losses_used, sale.taxable) == (
        Decimal("625.00"), Decimal("241.665"), 0, Decimal("383.335"))
    assert (sale.income_tax, sale.levy, sale.tax, sale.take_home) == (
        Decimal("115.00"), Decimal("7.67"), Decimal("122.67"), Decimal("2117.33"))


@freeze_time(TODAY)
def test_a_loss_carried_in_comes_off_before_the_discount(pf, alpha):
    """BETAX lost 270 in FY2025, carried to now. It takes the 141.67 that has
    no discount first, then 128.33 of the discountable gain: 355.00 left,
    halved to 177.50."""
    beta = fac.make_instrument(pf, "BETAX", asset_class="share")
    fac.add_trade(pf, beta, "2025-02-03", "buy", 50, "20.00", brokerage="10")
    fac.add_trade(pf, beta, "2025-06-02", "sell", 50, "15.00", brokerage="10")

    sale = _sell(pf, alpha, 150)

    assert (sale.carried_in, sale.losses_used, sale.discount, sale.taxable) == (
        Decimal("270.00"), Decimal("270.00"), Decimal("177.50"), Decimal("177.50"))
    assert sale.tax == Decimal("56.80")


@freeze_time(TODAY)
def test_this_years_sales_so_far_are_counted(pf, alpha):
    """GAMMA lost 120 in August. It comes off the gain with no discount."""
    gamma = fac.make_instrument(pf, "GAMMA", asset_class="share")
    fac.add_trade(pf, gamma, "2026-07-01", "buy", 10, "100.00", brokerage="10")
    fac.add_trade(pf, gamma, "2026-08-03", "sell", 10, "90.00", brokerage="10")

    sale = _sell(pf, alpha, 150)

    assert (sale.year_so_far, sale.losses_used, sale.taxable) == (
        Decimal("-120.00"), Decimal("120.00"), Decimal("263.335"))


@freeze_time(TODAY)
def test_a_loss_on_the_sale_lowers_the_years_tax(pf, alpha):
    """GAMMA made 480 in August. Selling 100 ALPHA at $8 loses 220, which comes
    off it: 220 less is taxed, 70.40 saved, and that is part of what the sale
    is worth."""
    gamma = fac.make_instrument(pf, "GAMMA", asset_class="share")
    fac.add_trade(pf, gamma, "2026-07-01", "buy", 10, "100.00", brokerage="10")
    fac.add_trade(pf, gamma, "2026-08-03", "sell", 10, "150.00", brokerage="10")
    fac.add_prices(pf, alpha, [("2026-10-17", "8.00")])

    sale = _sell(pf, alpha, 100)

    assert (sale.gain, sale.taxable, sale.tax) == (
        Decimal("-220.00"), Decimal("-220.00"), Decimal("-70.40"))
    assert sale.take_home == Decimal("860.40")       # 800 − 10 + 70.40


@freeze_time(TODAY)
def test_a_loss_with_nothing_to_offset_carries_forward(pf, alpha):
    fac.add_prices(pf, alpha, [("2026-10-17", "8.00")])

    sale = _sell(pf, alpha, 100)

    assert (sale.taxable, sale.tax, sale.take_home) == (0, 0, Decimal("790.00"))
    assert sale.carried_forward == Decimal("220.00")


@freeze_time(TODAY)
def test_brokerage_is_what_the_trade_form_would_suggest(pf, alpha):
    """The portfolio's fee when it has one: $9.50 plus 0.1%, at least $15, so
    15.00 on 2,250. A foreign holding takes the other-currencies fee, in its
    own currency. A brokerage typed in is used instead."""
    portfolio = pf.get(Portfolio, tenancy.current_portfolio_id(pf))
    portfolio.brokerage_flat, portfolio.brokerage_percent = Decimal("9.50"), Decimal("0.1")
    portfolio.brokerage_minimum = Decimal(15)
    portfolio.foreign_brokerage_flat = Decimal(5)
    nova = fac.make_instrument(pf, "NOVA", exchange="NASDAQ", currency="USD")
    fac.add_trade(pf, nova, "2025-01-06", "buy", 10, "100.00", brokerage="5", fx_rate="1.60")
    fac.add_prices(pf, nova, [("2026-10-16", "120.00")])
    fac.add_fx(pf, "USDAUD", "2026-10-16", "1.50")

    sale = simulate.sale(pf, {alpha.id: Decimal(150), nova.id: Decimal(10)}, **RATES)
    typed = simulate.sale(pf, {alpha.id: Decimal(150)}, brokerage={alpha.id: Decimal(0)},
                          **RATES)

    lines = {line.holding.instrument.ticker: line for line in sale.lines}
    assert (lines["ALPHA"].brokerage, lines["NOVA"].brokerage) == (Decimal("15.00"),
                                                                  Decimal("5.00"))
    assert lines["NOVA"].brokerage_aud == Decimal("7.50")
    assert [line.brokerage for line in typed.lines if line.units] == [0]


@freeze_time(TODAY)
def test_a_foreign_holding_is_sold_at_todays_rate_against_its_own(pf):
    """Bought at 1.60, sold at 1.50: (1,200 − 5) × 1.50 = 1,792.50 against a
    cost base of 1,005 × 1.60 = 1,608.00."""
    nova = fac.make_instrument(pf, "NOVA", exchange="NASDAQ", currency="USD")
    fac.add_trade(pf, nova, "2025-01-06", "buy", 10, "100.00", brokerage="5", fx_rate="1.60")
    fac.add_prices(pf, nova, [("2026-10-16", "120.00")])
    fac.add_fx(pf, "USDAUD", "2026-10-16", "1.50")

    [line] = _sell(pf, nova, 10).lines

    assert (line.value, line.cost_base, line.gain) == (
        Decimal("1800.00"), Decimal("1608.00"), Decimal("184.50"))


@freeze_time(TODAY)
def test_a_holding_that_cannot_be_valued_is_not_offered(pf, alpha):
    """No price, or no rate to convert one: the sale's figures would be
    guesses (decisions.md #5)."""
    omega = fac.make_instrument(pf, "OMEGA", asset_class="share")
    fac.add_trade(pf, omega, "2025-01-06", "buy", 10, "5.00")
    nova = fac.make_instrument(pf, "NOVA", exchange="NASDAQ", currency="USD")
    fac.add_trade(pf, nova, "2025-01-06", "buy", 10, "100.00", fx_rate="1.60")
    fac.add_prices(pf, nova, [("2026-10-16", "120.00")])

    sale = simulate.sale(pf, {}, **RATES)

    assert {line.holding.instrument.ticker: line.why_not for line in sale.lines} == {
        "ALPHA": None, "NOVA": "no exchange rate", "OMEGA": "no price"}


@freeze_time(TODAY)
def test_units_bought_today_can_be_sold_today(pf, alpha):
    """The sale happens now, after anything already recorded for today."""
    fac.add_trade(pf, alpha, TODAY, "buy", 50, "15.00", brokerage="10")

    [line] = _sell(pf, alpha, 250).lines

    assert (line.units, line.cost_base) == (250, Decimal("2980.00"))   # 1,010 + 1,210 + 760


@freeze_time(TODAY)
def test_a_holding_with_a_sale_of_more_than_was_held_is_not_offered(pf, alpha):
    """It has no FIFO cost to sell from (decisions.md #54)."""
    beta = fac.make_instrument(pf, "BETAX", asset_class="share")
    fac.add_trade(pf, beta, "2025-02-03", "buy", 10, "20.00")
    fac.add_trade(pf, beta, "2025-03-03", "sell", 15, "21.00")
    fac.add_trade(pf, beta, "2025-04-01", "buy", 20, "22.00")
    fac.add_prices(pf, beta, [("2026-10-16", "23.00")])

    sale = simulate.sale(pf, {beta.id: Decimal(5)}, **RATES)

    assert {line.holding.instrument.ticker: (line.why_not, line.units)
            for line in sale.lines} == {
        "ALPHA": (None, 0), "BETAX": ("a sale of more than was held", 0)}


@freeze_time(TODAY)
def test_without_a_rate_there_is_no_tax_figure(pf, alpha):
    sale = _sell(pf, alpha, 150, rates={})

    assert (sale.taxable, sale.tax, sale.take_home) == (Decimal("383.335"), None, None)


@freeze_time(TODAY)
def test_without_a_levy_none_is_added(pf, alpha):
    sale = _sell(pf, alpha, 150, rates={"income_tax_rate": Decimal(30)})

    assert (sale.income_tax, sale.levy, sale.tax) == (Decimal("115.00"), None,
                                                      Decimal("115.00"))


@freeze_time(TODAY)
def test_selling_everything_costs_what_the_holding_says_it_cost(pf, alpha):
    """The dashboard's cost base and the sale's come from one FIFO walk, a
    split and a return of capital included."""
    fac.add_action(pf, alpha, "2026-04-01", "split", ratio=(2, 1))
    fac.add_action(pf, alpha, "2026-05-01", "capital_return", amount="40.00")
    [holding] = queries.all_holdings(pf)

    [line] = _sell(pf, alpha, holding.units).lines

    assert (line.units, line.cost_base) == (400, holding.cost_aud)


@freeze_time(TODAY)
def test_nothing_is_recorded(pf, alpha):
    before = pf.scalar(select(func.count()).select_from(Trade))

    _sell(pf, alpha, 150)

    assert pf.scalar(select(func.count()).select_from(Trade)) == before
    assert not pf.new and not pf.dirty


@freeze_time(TODAY)
def test_a_percentage_of_the_portfolio_is_taken_from_every_holding(pf, alpha):
    """Half of each: whole units of a share, rounded down so the sale is never
    more than asked; a coin to its eighth place; all of it at 100%. One that
    cannot be valued is not offered, so it is not filled either."""
    beta = fac.make_instrument(pf, "BETAX", asset_class="share")
    fac.add_trade(pf, beta, "2025-02-03", "buy", 33, "20.00")
    coin = fac.make_instrument(pf, "ZULU", exchange="CRYPTO", asset_class="crypto")
    fac.add_trade(pf, coin, "2025-02-03", "buy", "0.12345679", "100.00")
    for inst in (beta, coin):
        fac.add_prices(pf, inst, [("2026-10-16", "30.00")])
    omega = fac.make_instrument(pf, "OMEGA", asset_class="share")
    fac.add_trade(pf, omega, "2025-01-06", "buy", 10, "5.00")
    # A reinvestment can leave part of a unit, which 100% still sells.
    fac.add_trade(pf, alpha, "2026-06-30", "drp", "0.5", "14.00")
    holdings = queries.all_holdings(pf)

    half = simulate.fill(holdings, Decimal(50))
    everything = simulate.fill(holdings, Decimal(100))

    assert half == {alpha.id: 100, beta.id: 16, coin.id: Decimal("0.06172839")}
    assert everything == {alpha.id: Decimal("200.5"), beta.id: 33,
                          coin.id: Decimal("0.12345679")}