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
+320
View File
@@ -0,0 +1,320 @@
"""`/income` over a small portfolio: one paid dividend, one announced, one bond coupon.
The router is not wired into `create_app` yet (that is done separately), so the fixture mounts
it on the same application the rest of the API tests use.
"""
from collections.abc import AsyncIterator
from datetime import UTC, date, datetime, timedelta
from decimal import Decimal
from typing import Any
import pytest
from httpx import ASGITransport, AsyncClient
from factories import make_account, make_event, make_instrument, refresh
from fintracker.analytics import today_local
from fintracker.analytics.income import add_months, rebuild_income
from fintracker.db import get_sessionmaker
from fintracker.models import (
AccountKind,
AccountRole,
AssetClass,
BondNominalSchedule,
CorporateAction,
CorporateActionKind,
CorporateActionStatus,
Event,
EventKind,
Instrument,
)
D = Decimal
PREFIX = "/api/v1/income"
async def make_bond(*, ticker: str, maturity: date | None = None) -> int:
async with get_sessionmaker()() as session:
bond = Instrument(
asset_class=AssetClass.bond,
ticker=ticker,
board="TQOB",
name=ticker,
currency="RUB",
nominal=D(1000),
nominal_currency="RUB",
maturity_date=maturity,
)
session.add(bond)
await session.commit()
await session.refresh(bond)
return bond.id
async def make_payout(
d: date,
*,
account_id: int,
instrument_id: int,
amount: str,
kind: EventKind = EventKind.dividend,
tax: str | None = None,
) -> None:
async with get_sessionmaker()() as session:
session.add(
Event(
account_id=account_id,
instrument_id=instrument_id,
kind=kind,
ts=datetime.combine(d, datetime.min.time(), tzinfo=UTC),
trade_date=d,
amount=D(amount),
currency="RUB",
tax=D(tax) if tax is not None else None,
tax_currency="RUB" if tax is not None else None,
source="tinvest",
source_id=f"pay-{instrument_id}-{d}-{amount}",
dedupe_key=f"tinvest:pay-{instrument_id}-{d}-{amount}",
)
)
await session.commit()
async def make_action(
*,
instrument_id: int,
kind: CorporateActionKind,
status: CorporateActionStatus,
pay_date: date,
record_date: date | None = None,
amount_per_unit: str,
) -> None:
async with get_sessionmaker()() as session:
session.add(
CorporateAction(
instrument_id=instrument_id,
kind=kind,
status=status,
pay_date=pay_date,
record_date=record_date,
amount_per_unit=D(amount_per_unit),
currency="RUB",
source="moex",
source_id=f"{kind}-{pay_date}",
)
)
await session.commit()
async def make_nominal(instrument_id: int, effective: date, nominal: str) -> None:
async with get_sessionmaker()() as session:
session.add(
BondNominalSchedule(
instrument_id=instrument_id,
effective_date=effective,
nominal=D(nominal),
currency="RUB",
source="moex",
)
)
await session.commit()
async def rebuild() -> None:
async with get_sessionmaker()() as session:
await rebuild_income(session)
await session.commit()
@pytest.fixture
async def income_client(app, user) -> AsyncIterator[AsyncClient]:
from fintracker.api.routers import income
app.include_router(income.router, prefix="/api/v1")
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
r = await c.post("/api/v1/auth/login", json=user)
assert r.status_code == 200, r.text
c.headers["Authorization"] = f"Bearer {r.json()['access_token']}"
yield c
@pytest.fixture
async def portfolio(app) -> dict[str, int]:
"""A share that paid twice and has a declared payout, and an amortising bond."""
today = today_local()
account = await make_account(
name="Брокерский",
kind=AccountKind.broker,
role=AccountRole.investment,
balance=None,
include_in_net_worth=False,
source="tinvest",
)
share = await make_instrument(ticker="SBER", name="Сбербанк России")
bond = await make_bond(ticker="RU000API", maturity=add_months(today, 30))
await make_event(
add_months(today, -20),
account_id=account,
kind=EventKind.buy,
instrument_id=share,
quantity="20",
price="250",
amount="-5000",
)
await make_event(
add_months(today, -20),
account_id=account,
kind=EventKind.buy,
instrument_id=bond,
quantity="10",
price="1000",
amount="-10000",
)
await make_payout(
add_months(today, -13), account_id=account, instrument_id=share, amount="696.80", tax="104"
)
await make_payout(
add_months(today, -1), account_id=account, instrument_id=share, amount="696.80", tax="104"
)
await make_action(
instrument_id=share,
kind=CorporateActionKind.dividend,
status=CorporateActionStatus.announced,
record_date=today + timedelta(days=21),
pay_date=today + timedelta(days=24),
amount_per_unit="34.84",
)
await make_nominal(bond, add_months(today, -24), "1000")
await make_action(
instrument_id=bond,
kind=CorporateActionKind.coupon,
status=CorporateActionStatus.announced,
pay_date=add_months(today, 2),
amount_per_unit="40",
)
await refresh()
await rebuild()
return {"account": account, "share": share, "bond": bond}
def floats(value: Any, path: str = "$") -> list[str]:
"""Every place a float leaked into the payload — money must travel as a string."""
if isinstance(value, bool):
return []
if isinstance(value, float):
return [path]
if isinstance(value, dict):
return [p for k, v in value.items() for p in floats(v, f"{path}.{k}")]
if isinstance(value, list):
return [p for i, v in enumerate(value) for p in floats(v, f"{path}[{i}]")]
return []
async def test_calendar_shows_the_future_with_money_as_strings_and_a_basis_on_every_row(
income_client: AsyncClient, portfolio: dict[str, int]
):
r = await income_client.get(f"{PREFIX}/calendar")
assert r.status_code == 200, r.text
body = r.json()
assert floats(body) == []
assert body["currency"] == "RUB"
assert body["entries"], body
for entry in body["entries"]:
assert isinstance(entry["amount"], str)
assert isinstance(entry["qty"], str)
assert entry["basis"] in {"schedule", "announced", "history"}
by_key = {(e["kind"], e["basis"]): e for e in body["entries"]}
# the declared autumn dividend, and next year's payment that only history knows about
announced = by_key[("dividend", "announced")]
assert announced["amount"] == "696.8000000000"
assert announced["ticker"] == "SBER"
assert announced["per_unit"] == "34.8400000000"
assert ("dividend", "history") in by_key
assert by_key[("coupon", "schedule")]["amount"] == "400.0000000000"
assert set(body["by_basis"]) == {"announced", "history", "schedule"}
total = sum(D(v) for v in body["by_basis"].values())
assert D(body["total_expected_rub"]) == total
async def test_include_paid_adds_the_history_rows_and_nothing_else(
income_client: AsyncClient, portfolio: dict[str, int]
):
today = today_local()
window = {"date_from": str(add_months(today, -24)), "date_to": str(add_months(today, 12))}
without = (await income_client.get(f"{PREFIX}/calendar", params=window)).json()
assert {e["basis"] for e in without["entries"]} == {"announced", "history", "schedule"}
with_paid = (
await income_client.get(f"{PREFIX}/calendar", params={**window, "include_paid": "true"})
).json()
paid = [e for e in with_paid["entries"] if e["basis"] == "paid"]
assert len(paid) == 2
assert paid[0]["tax_withheld"] == "104.0000000000"
# a payment already received is not an expectation: the totals must not move
assert with_paid["total_expected_rub"] == without["total_expected_rub"]
assert "paid" not in with_paid["by_basis"]
async def test_history_groups_by_month_and_totals_the_tax(
income_client: AsyncClient, portfolio: dict[str, int]
):
r = await income_client.get(f"{PREFIX}/history")
assert r.status_code == 200, r.text
body = r.json()
assert floats(body) == []
assert [row["kind"] for row in body["rows"]] == ["dividend", "dividend"]
assert {row["payment_count"] for row in body["rows"]} == {1}
assert D(body["totals"]["amount_rub"]) == D("1393.60")
assert D(body["totals"]["tax_withheld_rub"]) == D("208")
filtered = (await income_client.get(f"{PREFIX}/history", params={"kind": "coupon"})).json()
assert filtered["rows"] == []
async def test_forecast_splits_every_month_by_basis(
income_client: AsyncClient, portfolio: dict[str, int]
):
r = await income_client.get(f"{PREFIX}/forecast", params={"months": 12})
assert r.status_code == 200, r.text
body = r.json()
assert floats(body) == []
assert body["months"], body
bases = {basis for month in body["months"] for basis in month["by_basis"]}
assert bases <= {"schedule", "announced", "history"}
for month in body["months"]:
assert D(month["amount_rub"]) == sum(D(v) for v in month["by_basis"].values())
assert D(body["total_rub"]) == sum(D(m["amount_rub"]) for m in body["months"])
assert isinstance(body["warnings"], list)
async def test_forecast_rejects_a_horizon_the_table_was_not_built_for(
income_client: AsyncClient, portfolio: dict[str, int]
):
assert (await income_client.get(f"{PREFIX}/forecast", params={"months": 0})).status_code == 422
assert (await income_client.get(f"{PREFIX}/forecast", params={"months": 37})).status_code == 422
async def test_an_unknown_scope_is_a_404_not_an_empty_calendar(
income_client: AsyncClient, portfolio: dict[str, int]
):
r = await income_client.get(f"{PREFIX}/calendar", params={"scope": "account:999"})
assert r.status_code == 404
async def test_the_endpoints_require_a_token(income_client: AsyncClient, app):
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as anon:
assert (await anon.get(f"{PREFIX}/calendar")).status_code == 401
async def test_the_endpoints_are_pure_reads_and_repeat_themselves(
income_client: AsyncClient, portfolio: dict[str, int]
):
first = (await income_client.get(f"{PREFIX}/forecast")).json()
second = (await income_client.get(f"{PREFIX}/forecast")).json()
assert first == second