Ctrl K
ComunidadGuías…

app/simulate.py

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

Archivo original ↗Preguntar a KiBot
"""Selling today, worked out and not recorded: what a sale of some units of
each holding would bring home after capital gains tax (decisions.md #148).

The sale goes through the same FIFO walk and the same netting of the year as
the FY report, over each holding's own trades plus one sell, dated today at the
latest price and today's rate. The sells are plain objects on stand-ins for the
instruments, so nothing here ever reaches the session.

The tax is what the sale ADDS to this financial year's net capital gain, at the
income tax rate and the Medicare levy. The year's sales so far and the losses carried in are counted as
the ATO counts them, so a gain a carried loss already covers is not taxed again,
and a loss that lowers the year's gain lowers its tax.
"""

from __future__ import annotations

import datetime as dt
from dataclasses import dataclass, field
from decimal import ROUND_DOWN, Decimal
from types import SimpleNamespace

from . import brokerage as fees
from . import clock, cpi, fyreport, queries, tenancy
from .models import Portfolio

ZERO = Decimal(0)
CENTS = Decimal("0.01")
# The least the gains from 1 July 2027 are taxed at, in percent (#151).
MINIMUM_RATE = Decimal(30)


@dataclass
class Line:
    """One holding, and what selling `units` of it would do. Money is AUD
    unless it says native."""

    holding: queries.Holding
    units: Decimal = ZERO
    brokerage: Decimal | None = None      # native: as typed, or else suggested
    suggested: Decimal | None = None      # native: what the trade form would offer
    value: Decimal | None = None
    brokerage_aud: Decimal | None = None
    cost_base: Decimal | None = None      # what was paid: `Disposal.cost`
    indexation: Decimal | None = None     # what indexing took off the gain
    gain: Decimal | None = None           # None when a parcel sold has no rate
    # Why it is not offered: any figure for it would be a guess (decisions.md #5).
    why_not: str | None = None


@dataclass
class Sale:
    lines: list[Line] = field(default_factory=list)
    income_tax_rate: Decimal | None = None
    medicare_levy: Decimal | None = None
    value: Decimal = ZERO
    brokerage: Decimal = ZERO
    cost_base: Decimal | None = ZERO
    indexation: Decimal = ZERO
    gain: Decimal | None = ZERO
    # What the sale does to this financial year. None when the year cannot be
    # worked out: a sale of more than was held.
    discount: Decimal | None = None
    losses_used: Decimal | None = None
    taxable: Decimal | None = None          # added to the year's net capital gain
    income_tax: Decimal | None = None
    levy: Decimal | None = None             # None when no levy is set
    minimum_tax: Decimal | None = None      # what it adds to the year's top-up
    tax: Decimal | None = None              # the three together
    take_home: Decimal | None = None
    year_so_far: Decimal | None = None      # the year's earlier sales, net
    carried_in: Decimal | None = None       # losses carried in from earlier years
    carried_forward: Decimal | None = None  # added to the loss carried on
    withheld: list[str] = field(default_factory=list)
    awaiting_cpi: list[str] = field(default_factory=list)
    unvalued: list[str] = field(default_factory=list)
    oversold: list = field(default_factory=list)
    # The quarter whose CPI stood in for the sale's, not published yet (#150).
    cpi_estimate: dt.date | None = None

    @property
    def selling(self) -> list[Line]:
        return [line for line in self.lines if line.units]


def why_not(holding: queries.Holding) -> str | None:
    """Why this holding cannot be sold here, or None."""
    if holding.oversold:
        return "a sale of more than was held"
    if holding.price is None:
        return "no price"
    if holding.value_aud is None:
        return "no exchange rate"
    return None


def fill(holdings: list[queries.Holding], percent: Decimal) -> dict[int, Decimal]:
    """The units of each holding that sell `percent` of the portfolio: that
    share of each, rounded down so a sale is never more than was asked for, in
    whole units of a share or a fund and a coin's to its eighth place."""
    out = {}
    for holding in holdings:
        if not holding.units or why_not(holding):
            continue
        if percent >= 100:
            out[holding.instrument.id] = holding.units
            continue
        step = queries.UNIT if holding.instrument.asset_class == "crypto" else Decimal(1)
        out[holding.instrument.id] = (holding.units * percent / 100).quantize(
            step, rounding=ROUND_DOWN)
    return out


def sale(session, units: dict[int, Decimal], *, brokerage: dict[int, Decimal] | None = None,
         income_tax_rate: Decimal | None = None, medicare_levy: Decimal | None = None,
         income_support: bool | None = None,
         holdings: list[queries.Holding] | None = None) -> Sale:
    """Selling `units` of each holding (by instrument id) today, with the
    brokerage typed for some of them, the trade form's suggestion for the rest,
    and the year's tax at the two rates, in percent. Without an income tax rate
    there is no tax figure; without a levy, none is added. The minimum tax is
    lifted by income support, the portfolio's setting unless given.
    `holdings` are the open positions, when the caller has them already."""
    today = clock.today()
    fx_book = queries.FxBook(session)
    # A sale today is in a quarter whose CPI is months away: the latest stands in.
    fx_book.cpi = cpi.CpiBook(session, estimate=True)
    portfolio = session.get(Portfolio, tenancy.current_portfolio_id(session))
    last = {False: queries.last_brokerage(session),
            True: queries.last_brokerage(session, foreign=True)}
    typed = brokerage or {}
    out = Sale(income_tax_rate=income_tax_rate, medicare_levy=medicare_levy)
    standins = {}
    if holdings is None:
        holdings = queries.split_positions(queries.all_holdings(session))[0]
    for holding in holdings:
        inst = holding.instrument
        line = Line(holding, why_not=why_not(holding))
        out.lines.append(line)
        if line.why_not or not units.get(inst.id):
            continue
        line.units = units[inst.id]
        fx = fx_book.rate(inst.currency, today)
        line.suggested = fees.for_trade(portfolio, inst.currency, line.units * holding.price,
                                        last[fees.is_foreign(inst.currency)])
        line.brokerage = typed.get(inst.id, line.suggested if line.suggested is not None
                                   else ZERO)
        # Last in the day's timeline: a sale typed in now happens after
        # anything already recorded for today.
        sell = SimpleNamespace(id=None, date=today, time=dt.time.max, type="sell",
                               quantity=line.units, unit_price=holding.price,
                               brokerage=line.brokerage, fx_rate=fx)
        standins[inst.id] = SimpleNamespace(
            id=inst.id, ticker=inst.ticker, exchange=inst.exchange, currency=inst.currency,
            trades=[*inst.trades, sell], actions=list(inst.actions))
        sold = fyreport.walk(standins[inst.id], fx_book).disposals[-1]
        line.value = (line.units * holding.price * fx).quantize(CENTS)
        line.brokerage_aud = line.value - sold.proceeds
        line.cost_base, line.indexation, line.gain = sold.cost, sold.indexation, sold.gain

    selling = out.selling
    out.value = sum((line.value for line in selling), ZERO)
    out.brokerage = sum((line.brokerage_aud for line in selling), ZERO)
    out.cost_base = _total(line.cost_base for line in selling)
    out.indexation = sum((line.indexation or ZERO for line in selling), ZERO)
    out.gain = _total(line.gain for line in selling)

    fy = fyreport.current_fy(today)
    instruments = fyreport.cgt_instruments(session)
    before = fyreport.fy_cgt(session, fy, fx_book, instruments=instruments)
    after = fyreport.fy_cgt(session, fy, fx_book,
                            instruments=[standins.get(i.id, i) for i in instruments])
    out.cpi_estimate = fx_book.cpi.estimated
    if before.oversold or after.oversold:
        out.oversold = after.oversold or before.oversold
        return out
    for name in ("withheld", "awaiting_cpi", "unvalued"):
        setattr(out, name, sorted(set(getattr(before, name)) | set(getattr(after, name))))
    out.year_so_far = (before.gains_discountable + before.gains_other
                       + before.gains_from_july_2027 - before.losses)
    out.carried_in = before.losses_carried_in
    out.taxable = after.net_capital_gain - before.net_capital_gain
    out.discount = after.discount - before.discount
    out.carried_forward = after.losses_carried_out - before.losses_carried_out
    if out.gain is not None:
        out.losses_used = out.gain - out.discount - out.taxable
    if income_tax_rate is not None:
        out.income_tax = (out.taxable * income_tax_rate / 100).quantize(CENTS)
        if medicare_levy is not None:
            out.levy = (out.taxable * medicare_levy / 100).quantize(CENTS)
        exempt = portfolio.income_support if income_support is None else income_support
        out.minimum_tax = ZERO if exempt else (
            _top_up(after.minimum_tax_gain, income_tax_rate)
            - _top_up(before.minimum_tax_gain, income_tax_rate))
        out.tax = out.income_tax + (out.levy or ZERO) + out.minimum_tax
        out.take_home = out.value - out.brokerage - out.tax
    return out


def _top_up(gain: Decimal, rate: Decimal) -> Decimal:
    """The year's minimum tax on `gain`, its gains from 1 July 2027 after
    losses: 30% of them less the income tax at `rate` on them, rounded down
    to a dollar, when that is more than nil (ITAA 1997 s 119-10). One rate
    stands for the tax the gain adds, as it does for the year's (#147)."""
    extra = gain * (MINIMUM_RATE - rate) / 100
    return extra.quantize(Decimal(1), rounding=ROUND_DOWN) if extra > 0 else ZERO


def _total(parts) -> Decimal | None:
    """The sum, or None when any part is unknown (decisions.md #5)."""
    total = ZERO
    for part in parts:
        if part is None:
            return None
        total += part
    return total