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).
This commit is contained in:
Dmitry
2026-09-19 10:42:50 +03:00
parent ff3b76871d
commit 15f5812ea4
42 changed files with 10607 additions and 3 deletions
+144
View File
@@ -0,0 +1,144 @@
"""`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)