Второй источник выплат: 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).
145 lines
5.2 KiB
Python
145 lines
5.2 KiB
Python
"""`GET /tax` and `GET /tax/lots` — the screen that prices selling before the three-year mark.
|
|
|
|
The router is not wired into `api/app.py` by this module's author, so the tests mount it on a
|
|
copy of the application. That keeps the check honest about the routes' own behaviour while
|
|
leaving the inclusion order to whoever owns `app.py`.
|
|
"""
|
|
|
|
import json
|
|
from collections.abc import AsyncIterator
|
|
from datetime import timedelta
|
|
from decimal import Decimal
|
|
|
|
import pytest
|
|
from httpx import ASGITransport, AsyncClient
|
|
|
|
from factories import make_account, make_event, make_instrument, make_price
|
|
from fintracker.analytics import today_local
|
|
from fintracker.analytics.tax import rebuild_tax_year
|
|
from fintracker.api.routers import tax as tax_router
|
|
from fintracker.db import get_sessionmaker
|
|
from fintracker.ledger.rebuild import rebuild_lots
|
|
from fintracker.models import AccountKind, AccountRole, AssetClass, EventKind
|
|
from fintracker.pricing.fx import rebuild_fx_daily
|
|
|
|
D = Decimal
|
|
LDV_DAYS = 3 * 365
|
|
|
|
|
|
@pytest.fixture
|
|
async def tax_client(app, auth_headers) -> AsyncIterator[AsyncClient]:
|
|
"""The application plus the tax router, which `app.py` does not include yet."""
|
|
app.include_router(tax_router.router, prefix="/api/v1")
|
|
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
|
|
c.headers.update(auth_headers)
|
|
yield c
|
|
|
|
|
|
@pytest.fixture
|
|
async def lots(app) -> dict[str, object]:
|
|
"""Two open lots of the same paper: one past the ЛДВ mark, one still short of it."""
|
|
t = today_local()
|
|
old_date = t - timedelta(days=LDV_DAYS + 30)
|
|
young_date = t - timedelta(days=400)
|
|
|
|
account = await make_account(
|
|
name="ИИС",
|
|
kind=AccountKind.broker,
|
|
role=AccountRole.investment,
|
|
balance=None,
|
|
include_in_net_worth=False,
|
|
source="tinvest",
|
|
)
|
|
old_share = await make_instrument(ticker="SBER", name="Сбербанк", asset_class=AssetClass.share)
|
|
young_share = await make_instrument(ticker="GAZP", name="Газпром", asset_class=AssetClass.share)
|
|
await make_event(
|
|
old_date,
|
|
account_id=account,
|
|
kind=EventKind.buy,
|
|
instrument_id=old_share,
|
|
quantity="10",
|
|
price="100",
|
|
amount="-1000",
|
|
)
|
|
await make_event(
|
|
young_date,
|
|
account_id=account,
|
|
kind=EventKind.buy,
|
|
instrument_id=young_share,
|
|
quantity="20",
|
|
price="240",
|
|
amount="-4800",
|
|
)
|
|
await make_price(t, instrument_id=old_share, close="150")
|
|
await make_price(t, instrument_id=young_share, close="275.89")
|
|
|
|
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()
|
|
return {
|
|
"account": account,
|
|
"old": old_share,
|
|
"young": young_share,
|
|
"old_date": old_date,
|
|
"young_date": young_date,
|
|
"today": t,
|
|
}
|
|
|
|
|
|
async def test_lots_show_the_days_left_to_the_exemption(tax_client, lots):
|
|
r = await tax_client.get("/api/v1/tax/lots")
|
|
assert r.status_code == 200, r.text
|
|
body = r.json()
|
|
assert body["estimated"] is True
|
|
assert body["tax_rate"] == "0.13"
|
|
assert body["disclaimer"]
|
|
|
|
by_ticker = {lot["ticker"]: lot for lot in body["lots"]}
|
|
young = by_ticker["GAZP"]
|
|
ldv_date = lots["young_date"] + timedelta(days=LDV_DAYS)
|
|
assert young["ldv_date"] == ldv_date.isoformat()
|
|
assert young["days_to_ldv"] == (ldv_date - lots["today"]).days
|
|
assert young["ldv_eligible"] is False
|
|
# 20 x 275.89 = 5517.80 against a cost of 4800 -> 717.80 unrealised, 13 % of it is 93.31
|
|
assert young["market_value_rub"] == "5517.80"
|
|
assert young["cost_rub"] == "4800.00"
|
|
assert young["unrealized_gain_rub"] == "717.80"
|
|
assert young["tax_if_sold_now_rub"] == "93.31"
|
|
|
|
|
|
async def test_a_lot_past_three_years_costs_nothing_to_sell(tax_client, lots):
|
|
body = (await tax_client.get("/api/v1/tax/lots")).json()
|
|
old = {lot["ticker"]: lot for lot in body["lots"]}["SBER"]
|
|
|
|
assert old["ldv_eligible"] is True
|
|
assert old["days_to_ldv"] == 0
|
|
# 10 x 150 = 1500 against 1000 is a real gain, and art. 219.1 makes it untaxed
|
|
assert old["unrealized_gain_rub"] == "500.00"
|
|
assert old["tax_if_sold_now_rub"] == "0.00"
|
|
|
|
|
|
async def test_the_year_summary_is_marked_an_estimate(tax_client, lots):
|
|
r = await tax_client.get("/api/v1/tax", params={"year": lots["today"].year})
|
|
assert r.status_code == 200, r.text
|
|
body = r.json()
|
|
assert body["estimated"] is True
|
|
assert body["tax_rate"] == "0.13"
|
|
assert "брокер" in body["disclaimer"]
|
|
# nothing was sold, so the year is empty — but it is still a well-formed answer
|
|
assert body["totals"]["estimated_tax_rub"] == "0.00"
|
|
assert body["accounts"] == []
|
|
|
|
|
|
async def test_no_float_reaches_the_wire(tax_client, lots):
|
|
def reject_float(raw: str) -> None:
|
|
raise AssertionError(f"float in the response: {raw}")
|
|
|
|
for path in ("/api/v1/tax", "/api/v1/tax/lots"):
|
|
r = await tax_client.get(path)
|
|
assert r.status_code == 200, r.text
|
|
json.loads(r.text, parse_float=reject_float)
|