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