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:
@@ -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
|
||||
Reference in New Issue
Block a user