Files
fin-tracker/backend/tests/analytics/test_tax.py
T
Dmitry 15f5812ea4 feat(analytics): доходы, ребалансировка, налоги, бенчмарки и цели — фаза 4
Второй источник выплат: sources/tinvest/sync_events.py (GetDividends,
GetBondCoupons, GetBondEvents) и sources/moex/payouts.py (ISS bondization +
dividends). Приоритет между ними — pricing/payouts.resolve_payouts, решается
на чтении, а не на записи: corporate_action уникален по (instrument_id, kind,
source, source_id), обе версии сосуществуют, и правило можно поменять без
ресинка истории. Амортизация от MOEX идёт в bond_nominal_schedule, а не
в corporate_action — этим типом безраздельно владеет
ledger/corporate_actions.py.

analytics/income.py — metric_income_monthly (факт) и metric_income_calendar
(прошлое и прогноз) с basis paid/announced/history на каждой строке, три
источника числа не смешиваются. analytics/rebalance.py — сделки по
portfolio_target пропорционально внутри бакета, лоты только вниз, покупки не
занимают у ещё не свершившихся продаж. analytics/tax.py — оценка, не замена
справки брокера: дивиденды/купоны gross, реализованный результат из
lot_disposal с переоценкой каждой ноги на свою дату. analytics/benchmarks.py —
TWR индекса на сетке портфеля, kind (price/total_return) не скрывается.
analytics/goals.py — прогресс цели и нужный взнос по trailing XIRR.

Четыре шага зарегистрированы в register_steps: benchmarks после returns
(общая сетка дат), rebalance после allocation (её веса, не пересчитывает),
income и tax после lots (нужен lot_disposal).
2026-09-19 10:42:50 +03:00

368 lines
12 KiB
Python

"""The tax year, checked against an example worked out by hand — the plan's фаза-4 check.
Everything here is an estimate by construction (the broker is the tax agent), so the tests
are about the two things that make the estimate worth having: that it is reproducible on
paper, and that it never invents a number it does not have.
"""
from datetime import date
from decimal import Decimal
import pytest
from sqlalchemy import select
from factories import (
make_account,
make_cbr_rate,
make_event,
make_instrument,
)
from fintracker.analytics import FINDINGS
from fintracker.analytics.tax import TAX_RATE, rebuild_tax_year
from fintracker.db import get_sessionmaker
from fintracker.ledger.rebuild import rebuild_lots
from fintracker.models import (
AccountKind,
AccountRole,
AssetClass,
EventKind,
LotDisposal,
MetricTaxYear,
)
from fintracker.pricing.fx import rebuild_fx_daily
D = Decimal
async def _rebuild() -> None:
"""The three steps a tax year depends on, without the rest of the refresh."""
FINDINGS.reset()
async with get_sessionmaker()() as session:
await rebuild_fx_daily(session)
await session.commit()
await rebuild_lots(session)
await session.commit()
await rebuild_tax_year(session)
await session.commit()
async def _rows() -> dict[tuple[int, int], MetricTaxYear]:
async with get_sessionmaker()() as session:
found = await session.execute(select(MetricTaxYear))
return {(r.year, r.account_id): r for r in found.scalars()}
async def _payment(
account_id: int,
instrument_id: int,
kind: EventKind,
d: date,
amount: str,
*,
tax: str | None = None,
currency: str = "RUB",
) -> None:
"""A dividend or coupon as a broker reports it: net cash plus the tax it kept back."""
from datetime import UTC, datetime, time
from fintracker.models import Event
key = f"{kind}-{instrument_id}-{d}"
async with get_sessionmaker()() as session:
session.add(
Event(
account_id=account_id,
instrument_id=instrument_id,
kind=kind,
ts=datetime.combine(d, time.min, tzinfo=UTC),
trade_date=d,
amount=D(amount),
currency=currency,
tax=None if tax is None else D(tax),
tax_currency=None if tax is None else currency,
source="tinvest",
source_id=key,
dedupe_key=f"tinvest:{key}",
)
)
await session.commit()
async def _broker_account(name: str = "Брокерский") -> int:
return await make_account(
name=name,
kind=AccountKind.broker,
role=AccountRole.investment,
balance=None,
include_in_net_worth=False,
source="tinvest",
)
@pytest.fixture
async def usd_rates(app) -> None:
"""Two CBR quotes: the dollar rose by 20 % between the purchase and the sale."""
await make_cbr_rate(date(2023, 3, 10), "USD", "75")
await make_cbr_rate(date(2024, 6, 20), "USD", "90")
async def test_the_worked_example_adds_up(usd_rates):
"""Done on paper first; the code has to agree with the paper, not the other way round.
Purchase 10.03.2023: 10 units at $100 = $1000, CBR 75 ₽/$ -> cost 75 000 ₽
Sale 20.06.2024: 10 units at $110 = $1100, CBR 90 ₽/$ -> proceeds 99 000 ₽
Realised in roubles = 24 000 ₽
of which the price move is $100 x 90 = 9 000 ₽
and currency revaluation $1000 x 15 = 15 000 ₽ (in the base, plan §7 q4)
Plus a rouble lot bought 10.01.2020 and sold the same day in 2024 for +1 000 ₽. It is
held over three years, so art. 219.1 takes its result back out of the base.
gain 24 000 + 1 000 = 25 000 ₽
loss 0
ЛДВ exempt 1 000 ₽
base 25 000 - 1 000 = 24 000 ₽
tax 24 000 x 0.13 = 3 120 ₽
"""
account = await _broker_account()
foreign = await make_instrument(ticker="AAPL", name="Apple", currency="USD")
old = await make_instrument(ticker="SBER", name="Сбербанк")
await make_event(
date(2023, 3, 10),
account_id=account,
kind=EventKind.buy,
instrument_id=foreign,
quantity="10",
price="100",
amount="-1000",
currency="USD",
)
await make_event(
date(2024, 6, 20),
account_id=account,
kind=EventKind.sell,
instrument_id=foreign,
quantity="-10",
price="110",
amount="1100",
currency="USD",
)
await make_event(
date(2020, 1, 10),
account_id=account,
kind=EventKind.buy,
instrument_id=old,
quantity="10",
price="100",
amount="-1000",
)
await make_event(
date(2024, 6, 20),
account_id=account,
kind=EventKind.sell,
instrument_id=old,
quantity="-10",
price="200",
amount="2000",
)
await _rebuild()
row = (await _rows())[(2024, account)]
assert row.realized_gain_rub == D("25000.00")
assert row.realized_loss_rub == D("0.00")
assert row.ldv_exempt_rub == D("1000.00")
assert row.taxable_base_rub == D("24000.00")
assert row.estimated_tax_rub == D("3120.00")
# the rate is recorded, not implied, so a future change stays visible in old years
assert row.tax_rate == TAX_RATE == D("0.13")
async def test_the_three_year_lot_is_flagged_by_the_ledgers_own_rule(usd_rates):
"""`ldv_eligible` is computed once, in `ledger/lots.py`; the tax view only reads it."""
account = await _broker_account()
instrument = await make_instrument(ticker="SBER", name="Сбербанк")
await make_event(
date(2020, 1, 10),
account_id=account,
kind=EventKind.buy,
instrument_id=instrument,
quantity="10",
price="100",
amount="-1000",
)
await make_event(
date(2024, 6, 20),
account_id=account,
kind=EventKind.sell,
instrument_id=instrument,
quantity="-10",
price="200",
amount="2000",
)
await _rebuild()
async with get_sessionmaker()() as session:
disposal = (await session.execute(select(LotDisposal))).scalar_one()
assert disposal.holding_days >= 3 * 365
assert disposal.ldv_eligible is True
row = (await _rows())[(2024, account)]
assert row.ldv_exempt_rub == D("1000.00")
assert row.taxable_base_rub == D("0.00")
assert row.estimated_tax_rub == D("0.00")
async def test_currency_revaluation_is_not_the_currency_result(usd_rates):
"""A position flat in dollars still owes tax when the dollar rose — and the two numbers
must not be confused: the base is 15 000 ₽ while the dollar result is exactly zero."""
account = await _broker_account()
instrument = await make_instrument(ticker="AAPL", name="Apple", currency="USD")
await make_event(
date(2023, 3, 10),
account_id=account,
kind=EventKind.buy,
instrument_id=instrument,
quantity="10",
price="100",
amount="-1000",
currency="USD",
)
await make_event(
date(2024, 6, 20),
account_id=account,
kind=EventKind.sell,
instrument_id=instrument,
quantity="-10",
price="100",
amount="1000",
currency="USD",
)
await _rebuild()
async with get_sessionmaker()() as session:
disposal = (await session.execute(select(LotDisposal))).scalar_one()
assert disposal.realized_pnl_native == D(0)
assert disposal.realized_pnl_rub == D("15000.0000000000")
row = (await _rows())[(2024, account)]
assert row.taxable_base_rub == D("15000.00")
assert row.estimated_tax_rub == D("1950.00")
# and the fact that a revaluation happened is said out loud, because a Minfin eurobond
# hiding among these would be taxed differently and cannot be detected automatically
assert any(f.check_name == "tax_currency_revaluation" for f in FINDINGS.items)
async def test_a_leg_without_a_rate_is_left_out_and_reported(usd_rates):
"""No rate means no rouble result. Never a substitute — a finding instead."""
account = await _broker_account()
quoted = await make_instrument(ticker="SBER", name="Сбербанк")
unquoted = await make_instrument(ticker="0700", name="Tencent", currency="HKD", board="SPBHKEX")
await make_event(
date(2024, 2, 1),
account_id=account,
kind=EventKind.buy,
instrument_id=quoted,
quantity="10",
price="100",
amount="-1000",
)
await make_event(
date(2024, 6, 20),
account_id=account,
kind=EventKind.sell,
instrument_id=quoted,
quantity="-10",
price="150",
amount="1500",
)
await make_event(
date(2024, 2, 1),
account_id=account,
kind=EventKind.buy,
instrument_id=unquoted,
quantity="10",
price="100",
amount="-1000",
currency="HKD",
)
await make_event(
date(2024, 6, 20),
account_id=account,
kind=EventKind.sell,
instrument_id=unquoted,
quantity="-10",
price="300",
amount="3000",
currency="HKD",
)
await _rebuild()
row = (await _rows())[(2024, account)]
# only the rouble trade is in the base; the HKD one did not inflate or deflate it
assert row.realized_gain_rub == D("500.00")
assert row.taxable_base_rub == D("500.00")
assert row.estimated_tax_rub == D("65.00")
assert any(f.check_name == "tax_disposal_no_fx" for f in FINDINGS.items)
async def test_dividends_and_coupons_are_gross_with_the_tax_that_was_withheld(app):
"""`amount` is what landed and `tax` is what was taken; gross is their sum."""
account = await _broker_account()
share = await make_instrument(ticker="SBER", name="Сбербанк")
bond = await make_instrument(ticker="SU26238", name="ОФЗ", asset_class=AssetClass.bond)
await _payment(account, share, EventKind.dividend, date(2024, 5, 15), "870", tax="130")
await _payment(account, bond, EventKind.coupon, date(2024, 8, 1), "500")
await _rebuild()
row = (await _rows())[(2024, account)]
assert row.dividends_gross_rub == D("1000.00")
assert row.coupons_gross_rub == D("500.00")
assert row.tax_withheld_rub == D("130.00")
# income is outside the securities base: the agent already withheld on it
assert row.taxable_base_rub == D("0.00")
async def test_every_stored_number_is_a_decimal(usd_rates):
account = await _broker_account()
instrument = await make_instrument(ticker="SBER", name="Сбербанк")
await make_event(
date(2024, 2, 1),
account_id=account,
kind=EventKind.buy,
instrument_id=instrument,
quantity="10",
price="100",
amount="-1000",
)
await make_event(
date(2024, 6, 20),
account_id=account,
kind=EventKind.sell,
instrument_id=instrument,
quantity="-10",
price="150",
amount="1500",
)
await _rebuild()
row = (await _rows())[(2024, account)]
for field in (
"dividends_gross_rub",
"coupons_gross_rub",
"tax_withheld_rub",
"realized_gain_rub",
"realized_loss_rub",
"ldv_exempt_rub",
"taxable_base_rub",
"estimated_tax_rub",
"tax_rate",
):
value = getattr(row, field)
assert isinstance(value, Decimal), field
assert not isinstance(value, float), field