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,454 @@
|
||||
"""T-Invest payout feeds: what the three RPCs mean once they are flattened.
|
||||
|
||||
The mapping tests are pure — the sync ones run against the real database with a stand-in
|
||||
client, so no test here touches the network or needs a token.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, date, datetime, timedelta
|
||||
from decimal import Decimal
|
||||
from typing import ClassVar
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
|
||||
from factories import make_account, make_event
|
||||
from fintracker.analytics import today_local
|
||||
from fintracker.config import Settings
|
||||
from fintracker.db import get_sessionmaker
|
||||
from fintracker.models import (
|
||||
AccountKind,
|
||||
AccountRole,
|
||||
AssetClass,
|
||||
BondNominalSchedule,
|
||||
CorporateAction,
|
||||
CorporateActionKind,
|
||||
CorporateActionStatus,
|
||||
EventKind,
|
||||
Instrument,
|
||||
RawTinvestEvent,
|
||||
)
|
||||
from fintracker.sources.tinvest import sync_events
|
||||
from fintracker.sources.tinvest.client import BondCouponRow, BondEventRow, DividendRow
|
||||
from fintracker.sources.tinvest.sync_events import (
|
||||
TinvestEventsSource,
|
||||
coupon_payout,
|
||||
dividend_payout,
|
||||
msk_date,
|
||||
nominal_schedule,
|
||||
status_for,
|
||||
)
|
||||
|
||||
D = Decimal
|
||||
TODAY = today_local()
|
||||
PAST = TODAY - timedelta(days=30)
|
||||
FUTURE = TODAY + timedelta(days=30)
|
||||
|
||||
|
||||
def utc(day: date) -> datetime:
|
||||
"""How the API stamps a trading day: midnight UTC, i.e. 3 a.m. in Moscow."""
|
||||
return datetime(day.year, day.month, day.day, tzinfo=UTC)
|
||||
|
||||
|
||||
def dividend(**over) -> DividendRow:
|
||||
base = {
|
||||
"instrument_uid": "uid-share",
|
||||
"amount": D("45.24"),
|
||||
"currency": "RUB",
|
||||
"payment_date": utc(date(2026, 7, 24)),
|
||||
"declared_date": utc(date(2026, 5, 30)),
|
||||
"record_date": utc(date(2026, 7, 10)),
|
||||
"last_buy_date": utc(date(2026, 7, 9)),
|
||||
"dividend_type": "Regular Cash",
|
||||
"regularity": "Annual",
|
||||
"payload": {"dividend_net": {"value": "45.24", "currency": "rub"}},
|
||||
}
|
||||
return DividendRow(**{**base, **over})
|
||||
|
||||
|
||||
def coupon(**over) -> BondCouponRow:
|
||||
base = {
|
||||
"instrument_uid": "uid-bond",
|
||||
"coupon_number": 7,
|
||||
"coupon_date": utc(date(2026, 11, 5)),
|
||||
"fix_date": utc(date(2026, 11, 4)),
|
||||
"pay_one_bond": D("34.90"),
|
||||
"currency": "RUB",
|
||||
"coupon_type": "COUPON_TYPE_CONSTANT",
|
||||
"coupon_period": 182,
|
||||
"payload": {"coupon_number": 7},
|
||||
}
|
||||
return BondCouponRow(**{**base, **over})
|
||||
|
||||
|
||||
def redemption(day: date, amount: str, **over) -> BondEventRow:
|
||||
base = {
|
||||
"instrument_uid": "uid-bond",
|
||||
"event_type": "EVENT_TYPE_MTY",
|
||||
"event_number": 1,
|
||||
"event_date": utc(day),
|
||||
"fix_date": utc(day),
|
||||
"pay_date": utc(day),
|
||||
"pay_one_bond": D(amount),
|
||||
"currency": "RUB",
|
||||
"payload": {},
|
||||
}
|
||||
return BondEventRow(**{**base, **over})
|
||||
|
||||
|
||||
# --- mapping -------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_a_midnight_utc_stamp_is_the_moscow_trading_day():
|
||||
"""Read as UTC, every payout would move one day earlier than the exchange printed it."""
|
||||
assert msk_date(datetime(2026, 7, 10, 0, 0, tzinfo=UTC)) == date(2026, 7, 10)
|
||||
assert msk_date(datetime(2026, 7, 9, 21, 30, tzinfo=UTC)) == date(2026, 7, 10)
|
||||
assert msk_date(None) is None
|
||||
|
||||
|
||||
def test_dividend_carries_every_date_the_feed_states():
|
||||
payout = dividend_payout(dividend(), instrument_id=7, today=TODAY)
|
||||
|
||||
assert payout is not None
|
||||
assert payout.kind is CorporateActionKind.dividend
|
||||
assert payout.record_date == date(2026, 7, 10)
|
||||
assert payout.pay_date == date(2026, 7, 24)
|
||||
# last_buy_date is the last day a purchase still earns it — the ex-side date on offer
|
||||
assert payout.ex_date == date(2026, 7, 9)
|
||||
assert payout.amount_per_unit == D("45.24")
|
||||
assert payout.currency == "RUB"
|
||||
assert payout.source == "tinvest"
|
||||
assert payout.source_id == "div:2026-07-10"
|
||||
|
||||
|
||||
def test_dividend_without_any_date_is_dropped():
|
||||
assert (
|
||||
dividend_payout(
|
||||
dividend(record_date=None, payment_date=None, declared_date=None),
|
||||
instrument_id=7,
|
||||
today=TODAY,
|
||||
)
|
||||
is None
|
||||
)
|
||||
|
||||
|
||||
def test_a_past_payout_is_paid_and_a_future_one_announced():
|
||||
assert status_for(PAST, TODAY) is CorporateActionStatus.paid
|
||||
assert status_for(FUTURE, TODAY) is CorporateActionStatus.announced
|
||||
assert status_for(None, TODAY) is CorporateActionStatus.announced
|
||||
|
||||
|
||||
def test_coupon_maps_to_the_coupon_kind_keyed_on_its_number():
|
||||
warnings: list[str] = []
|
||||
payout = coupon_payout(coupon(), instrument_id=9, today=TODAY, warnings=warnings)
|
||||
|
||||
assert payout is not None
|
||||
assert payout.kind is CorporateActionKind.coupon
|
||||
assert payout.pay_date == date(2026, 11, 5)
|
||||
assert payout.record_date == date(2026, 11, 4)
|
||||
assert payout.amount_per_unit == D("34.90")
|
||||
assert payout.source_id == "cpn:7"
|
||||
assert warnings == []
|
||||
|
||||
|
||||
def test_an_unknown_coupon_type_warns_instead_of_becoming_a_plain_coupon():
|
||||
warnings: list[str] = []
|
||||
payout = coupon_payout(
|
||||
coupon(coupon_type="COUPON_TYPE_UNSPECIFIED"),
|
||||
instrument_id=9,
|
||||
today=TODAY,
|
||||
warnings=warnings,
|
||||
)
|
||||
|
||||
assert payout is None
|
||||
assert len(warnings) == 1
|
||||
assert "COUPON_TYPE_UNSPECIFIED" in warnings[0]
|
||||
|
||||
|
||||
def test_a_zero_coupon_is_not_a_payout():
|
||||
warnings: list[str] = []
|
||||
assert (
|
||||
coupon_payout(
|
||||
coupon(pay_one_bond=D(0), coupon_type="COUPON_TYPE_DISCOUNT"),
|
||||
instrument_id=9,
|
||||
today=TODAY,
|
||||
warnings=warnings,
|
||||
)
|
||||
is None
|
||||
)
|
||||
assert warnings == []
|
||||
|
||||
|
||||
def test_redemptions_run_the_nominal_down_to_zero():
|
||||
"""An amortised bond repays the principal in slices; the nominal after each is what is left."""
|
||||
warnings: list[str] = []
|
||||
points = nominal_schedule(
|
||||
[
|
||||
redemption(date(2027, 5, 5), "250"),
|
||||
redemption(date(2026, 5, 5), "250"), # out of order on purpose
|
||||
redemption(date(2028, 5, 5), "500"),
|
||||
],
|
||||
instrument_id=9,
|
||||
currency="RUB",
|
||||
warnings=warnings,
|
||||
)
|
||||
|
||||
assert [(p.effective_date, p.nominal) for p in points] == [
|
||||
(date(2026, 5, 5), D(750)),
|
||||
(date(2027, 5, 5), D(500)),
|
||||
(date(2028, 5, 5), D(0)),
|
||||
]
|
||||
assert all(p.source == "tinvest" and p.currency == "RUB" for p in points)
|
||||
assert warnings == []
|
||||
|
||||
|
||||
def test_a_bullet_bond_gets_a_single_point_at_maturity():
|
||||
points = nominal_schedule(
|
||||
[redemption(date(2029, 3, 1), "1000")], instrument_id=9, currency="RUB", warnings=[]
|
||||
)
|
||||
assert [(p.effective_date, p.nominal) for p in points] == [(date(2029, 3, 1), D(0))]
|
||||
|
||||
|
||||
def test_an_unknown_bond_event_type_warns_and_is_skipped():
|
||||
warnings: list[str] = []
|
||||
points = nominal_schedule(
|
||||
[redemption(date(2026, 5, 5), "250", event_type="EVENT_TYPE_CONV")],
|
||||
instrument_id=9,
|
||||
currency="RUB",
|
||||
warnings=warnings,
|
||||
)
|
||||
|
||||
assert points == []
|
||||
assert "EVENT_TYPE_CONV" in warnings[0]
|
||||
|
||||
|
||||
def test_a_redemption_with_no_money_is_warned_about_not_read_as_zero():
|
||||
"""A silent zero would shift every later nominal upward by the missing slice."""
|
||||
warnings: list[str] = []
|
||||
points = nominal_schedule(
|
||||
[
|
||||
redemption(date(2026, 5, 5), "250", pay_one_bond=None),
|
||||
redemption(date(2027, 5, 5), "750"),
|
||||
],
|
||||
instrument_id=9,
|
||||
currency="RUB",
|
||||
warnings=warnings,
|
||||
)
|
||||
|
||||
assert [p.nominal for p in points] == [D(0)]
|
||||
assert "без суммы" in warnings[0]
|
||||
|
||||
|
||||
def test_every_mapped_amount_is_a_decimal_never_a_float():
|
||||
payout = dividend_payout(dividend(), instrument_id=7, today=TODAY)
|
||||
points = nominal_schedule(
|
||||
[redemption(date(2026, 5, 5), "250"), redemption(date(2027, 5, 5), "750")],
|
||||
instrument_id=9,
|
||||
currency="RUB",
|
||||
warnings=[],
|
||||
)
|
||||
assert payout is not None
|
||||
assert isinstance(payout.amount_per_unit, Decimal)
|
||||
assert all(isinstance(p.nominal, Decimal) and not isinstance(p.nominal, float) for p in points)
|
||||
|
||||
|
||||
# --- sync ----------------------------------------------------------------------------
|
||||
|
||||
|
||||
class FakeClient:
|
||||
"""Stands in for `TinvestClient`: same three coroutines, no gRPC and no token."""
|
||||
|
||||
calls: ClassVar[list[str]] = []
|
||||
|
||||
def __init__(self, token: str, **_: object) -> None:
|
||||
self.token = token
|
||||
|
||||
async def __aenter__(self) -> FakeClient:
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *exc: object) -> None:
|
||||
return None
|
||||
|
||||
async def dividends(self, uid: str, **_: object) -> list[DividendRow]:
|
||||
FakeClient.calls.append(f"dividends:{uid}")
|
||||
return [dividend(instrument_uid=uid)] if uid == "uid-share" else []
|
||||
|
||||
async def bond_coupons(self, uid: str, **_: object) -> list[BondCouponRow]:
|
||||
FakeClient.calls.append(f"coupons:{uid}")
|
||||
return [
|
||||
coupon(instrument_uid=uid),
|
||||
coupon(instrument_uid=uid, coupon_number=8, coupon_date=utc(date(2027, 5, 5))),
|
||||
]
|
||||
|
||||
async def bond_events(self, uid: str, **_: object) -> list[BondEventRow]:
|
||||
FakeClient.calls.append(f"events:{uid}")
|
||||
return [redemption(date(2027, 5, 5), "250"), redemption(date(2028, 5, 5), "750")]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_client(monkeypatch):
|
||||
FakeClient.calls = []
|
||||
monkeypatch.setattr(sync_events, "TinvestClient", FakeClient)
|
||||
return FakeClient
|
||||
|
||||
|
||||
async def seed(asset_class: AssetClass, uid: str, ticker: str) -> int:
|
||||
"""One instrument with a confirmed ledger event — which is what puts it in scope."""
|
||||
account_id = await make_account(
|
||||
name="Брокерский",
|
||||
kind=AccountKind.broker,
|
||||
role=AccountRole.investment,
|
||||
balance=None,
|
||||
include_in_net_worth=False,
|
||||
source="tinvest",
|
||||
source_id=f"tinv-{ticker}",
|
||||
)
|
||||
async with get_sessionmaker()() as session:
|
||||
instrument = Instrument(
|
||||
asset_class=asset_class,
|
||||
tinvest_uid=uid,
|
||||
ticker=ticker,
|
||||
board="TQBR",
|
||||
name=ticker,
|
||||
currency="RUB",
|
||||
)
|
||||
session.add(instrument)
|
||||
await session.commit()
|
||||
await session.refresh(instrument)
|
||||
instrument_id = instrument.id
|
||||
await make_event(
|
||||
PAST, account_id=account_id, kind=EventKind.buy, instrument_id=instrument_id, quantity=10
|
||||
)
|
||||
return instrument_id
|
||||
|
||||
|
||||
async def rows(model, **where):
|
||||
async with get_sessionmaker()() as session:
|
||||
stmt = select(model)
|
||||
for column, value in where.items():
|
||||
stmt = stmt.where(getattr(model, column) == value)
|
||||
return list((await session.execute(stmt)).scalars().all())
|
||||
|
||||
|
||||
def settings() -> Settings:
|
||||
return Settings(tinvest_token="test-token")
|
||||
|
||||
|
||||
async def test_a_share_run_writes_the_raw_row_and_the_corporate_action(app, fake_client, run_sync):
|
||||
instrument_id = await seed(AssetClass.share, "uid-share", "SBER")
|
||||
|
||||
result = await run_sync(TinvestEventsSource(), settings=settings())
|
||||
|
||||
assert result.counts["dividends"] == 1
|
||||
action = (await rows(CorporateAction, instrument_id=instrument_id))[0]
|
||||
assert action.kind is CorporateActionKind.dividend
|
||||
assert action.source == "tinvest"
|
||||
assert action.source_id == "div:2026-07-10"
|
||||
assert action.amount_per_unit == D("45.24")
|
||||
assert action.record_date == date(2026, 7, 10)
|
||||
raw = (await rows(RawTinvestEvent, instrument_uid="uid-share"))[0]
|
||||
assert raw.kind == "dividend"
|
||||
assert raw.payload["dividend_net"]["value"] == "45.24"
|
||||
|
||||
|
||||
async def test_a_bond_run_writes_coupons_and_a_descending_nominal_schedule(
|
||||
app, fake_client, run_sync
|
||||
):
|
||||
instrument_id = await seed(AssetClass.bond, "uid-bond", "RU000A")
|
||||
|
||||
await run_sync(TinvestEventsSource(), settings=settings())
|
||||
|
||||
actions = await rows(CorporateAction, instrument_id=instrument_id)
|
||||
assert {a.kind for a in actions} == {CorporateActionKind.coupon}
|
||||
assert {a.source_id for a in actions} == {"cpn:7", "cpn:8"}
|
||||
schedule = sorted(
|
||||
await rows(BondNominalSchedule, instrument_id=instrument_id),
|
||||
key=lambda p: p.effective_date,
|
||||
)
|
||||
assert [(p.effective_date, p.nominal, p.source) for p in schedule] == [
|
||||
(date(2027, 5, 5), D(750), "tinvest"),
|
||||
(date(2028, 5, 5), D(0), "tinvest"),
|
||||
]
|
||||
|
||||
|
||||
async def test_a_bond_never_writes_an_amortization_row(app, fake_client, run_sync):
|
||||
"""`ledger/corporate_actions.py` owns that kind and prunes anything it did not derive."""
|
||||
instrument_id = await seed(AssetClass.bond, "uid-bond", "RU000A")
|
||||
|
||||
await run_sync(TinvestEventsSource(), settings=settings())
|
||||
|
||||
actions = await rows(CorporateAction, instrument_id=instrument_id)
|
||||
assert not any(
|
||||
a.kind in (CorporateActionKind.amortization, CorporateActionKind.repayment) for a in actions
|
||||
)
|
||||
|
||||
|
||||
async def test_a_share_is_never_asked_for_coupons(app, fake_client, run_sync):
|
||||
await seed(AssetClass.share, "uid-share", "SBER")
|
||||
|
||||
await run_sync(TinvestEventsSource(), settings=settings())
|
||||
|
||||
assert FakeClient.calls == ["dividends:uid-share"]
|
||||
|
||||
|
||||
async def test_a_second_run_stores_no_duplicates(app, fake_client, run_sync):
|
||||
instrument_id = await seed(AssetClass.bond, "uid-bond", "RU000A")
|
||||
|
||||
await run_sync(TinvestEventsSource(), settings=settings())
|
||||
await run_sync(TinvestEventsSource(), settings=settings())
|
||||
|
||||
assert len(await rows(CorporateAction, instrument_id=instrument_id)) == 2
|
||||
assert len(await rows(BondNominalSchedule, instrument_id=instrument_id)) == 2
|
||||
assert len(await rows(RawTinvestEvent, instrument_uid="uid-bond")) == 4
|
||||
|
||||
|
||||
async def test_a_ledger_derived_paid_row_is_not_overwritten_by_the_feed(app, fake_client, run_sync):
|
||||
"""Money that arrived outranks an announcement — and the feed cannot even reach that row."""
|
||||
instrument_id = await seed(AssetClass.bond, "uid-bond", "RU000A")
|
||||
async with get_sessionmaker()() as session:
|
||||
session.add(
|
||||
CorporateAction(
|
||||
instrument_id=instrument_id,
|
||||
kind=CorporateActionKind.amortization,
|
||||
status=CorporateActionStatus.paid,
|
||||
ex_date=date(2027, 5, 5),
|
||||
pay_date=date(2027, 5, 5),
|
||||
amount_per_unit=D("250"),
|
||||
currency="RUB",
|
||||
source="tinvest",
|
||||
source_id="2027-05-05",
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
await run_sync(TinvestEventsSource(), settings=settings())
|
||||
|
||||
derived = [
|
||||
a
|
||||
for a in await rows(CorporateAction, instrument_id=instrument_id)
|
||||
if a.kind is CorporateActionKind.amortization
|
||||
]
|
||||
assert len(derived) == 1
|
||||
assert derived[0].status is CorporateActionStatus.paid
|
||||
assert derived[0].amount_per_unit == D("250")
|
||||
|
||||
|
||||
async def test_an_instrument_outside_the_ledger_is_never_asked_about(app, fake_client, run_sync):
|
||||
async with get_sessionmaker()() as session:
|
||||
session.add(
|
||||
Instrument(
|
||||
asset_class=AssetClass.share,
|
||||
tinvest_uid="uid-never-held",
|
||||
ticker="GAZP",
|
||||
board="TQBR",
|
||||
name="GAZP",
|
||||
currency="RUB",
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
result = await run_sync(TinvestEventsSource(), settings=settings())
|
||||
|
||||
assert FakeClient.calls == []
|
||||
assert result.changed is False
|
||||
Reference in New Issue
Block a user