Второй источник выплат: 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).
368 lines
12 KiB
Python
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
|