"""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