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
+365
View File
@@ -0,0 +1,365 @@
"""MOEX ISS as the second payout feed: bondization and the dividend register.
Every request goes through respx — the ISS is never actually called.
"""
from __future__ import annotations
from datetime import date, timedelta
from decimal import Decimal
import httpx
import respx
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,
)
from fintracker.sources.moex.client import AmortisationRow, CouponRow, MoexClient
from fintracker.sources.moex.payouts import (
MoexDividendRow,
MoexPayoutsSource,
coupon_payout,
dividend_payout,
fetch_dividends,
nominal_schedule,
)
ISS = "https://iss.moex.com/iss"
D = Decimal
TODAY = today_local()
PAST = TODAY - timedelta(days=30)
FUTURE = TODAY + timedelta(days=30)
def block(name: str, columns: list[str], data: list[list]) -> dict:
return {name: {"columns": columns, "data": data}}
def bondization(coupons: list[list], amortisations: list[list] | None = None) -> dict:
return {
"coupons": {
"columns": ["coupondate", "value", "valueprc", "faceunit"],
"data": coupons,
},
"amortizations": {
"columns": ["amortdate", "value", "facevalue", "faceunit"],
"data": amortisations or [],
},
}
# --- mapping -------------------------------------------------------------------------
def test_a_coupon_row_maps_to_a_moex_sourced_corporate_action():
payout = coupon_payout(
CouponRow(coupon_date=FUTURE, value=D("34.90"), value_pct=D("7.0"), currency="RUB"),
instrument_id=9,
today=TODAY,
)
assert payout is not None
assert payout.kind is CorporateActionKind.coupon
assert payout.source == "moex"
assert payout.source_id == f"cpn:{FUTURE.isoformat()}"
assert payout.pay_date == FUTURE
assert payout.amount_per_unit == D("34.90")
assert payout.currency == "RUB"
assert payout.status is CorporateActionStatus.announced
def test_a_coupon_already_paid_is_stored_as_paid():
payout = coupon_payout(
CouponRow(coupon_date=PAST, value=D("34.90"), value_pct=None, currency="RUB"),
instrument_id=9,
today=TODAY,
)
assert payout is not None
assert payout.status is CorporateActionStatus.paid
def test_a_floating_coupon_without_a_rate_keeps_its_date_and_loses_its_amount():
"""The date is published long before the rate is fixed, and the calendar needs it."""
payout = coupon_payout(
CouponRow(coupon_date=FUTURE, value=None, value_pct=None, currency="RUB"),
instrument_id=9,
today=TODAY,
)
assert payout is not None
assert payout.amount_per_unit is None
def test_a_dividend_register_row_states_the_record_date_and_nothing_else():
payout = dividend_payout(
MoexDividendRow(
secid="SBER", registry_close_date=date(2026, 7, 10), value=D("52"), currency="RUB"
),
instrument_id=7,
today=TODAY,
)
assert payout is not None
assert payout.record_date == date(2026, 7, 10)
assert payout.pay_date is None
assert payout.source_id == "div:2026-07-10"
assert payout.amount_per_unit == D("52")
def test_amortisations_run_the_nominal_down_to_zero():
warnings: list[str] = []
points = nominal_schedule(
[
AmortisationRow(
amort_date=date(2028, 5, 5), value=D(500), face_value=D(500), currency="SUR"
),
AmortisationRow(
amort_date=date(2026, 5, 5), value=D(250), face_value=D(1000), currency="SUR"
),
AmortisationRow(
amort_date=date(2027, 5, 5), value=D(250), face_value=D(750), currency="SUR"
),
],
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 == "moex" for p in points)
assert warnings == []
def test_a_plan_that_does_not_add_up_to_the_stated_nominal_is_reported():
warnings: list[str] = []
nominal_schedule(
[
AmortisationRow(
amort_date=date(2026, 5, 5), value=D(250), face_value=D(1000), currency="SUR"
),
AmortisationRow(
amort_date=date(2027, 5, 5), value=D(250), face_value=D(750), currency="SUR"
),
],
instrument_id=9,
currency="RUB",
warnings=warnings,
secid="RU000A",
)
assert len(warnings) == 1
assert "1000" in warnings[0]
def test_nominal_points_are_decimal_never_float():
points = nominal_schedule(
[
AmortisationRow(
amort_date=date(2026, 5, 5), value=D("1000"), face_value=None, currency=None
)
],
instrument_id=9,
currency="RUB",
warnings=[],
)
assert all(isinstance(p.nominal, Decimal) and not isinstance(p.nominal, float) for p in points)
# --- client --------------------------------------------------------------------------
@respx.mock
async def test_bondization_gives_the_whole_coupon_schedule_not_just_the_near_ones():
"""This is why bonds are read from MOEX: the plan runs to maturity, in one request."""
schedule = [
[(TODAY + timedelta(days=30 * n)).isoformat(), 34.9, 7.0, "SUR"] for n in range(1, 25)
]
respx.get(url__startswith=f"{ISS}/securities/RU000A/bondization").mock(
return_value=httpx.Response(200, json=bondization(schedule))
)
async with MoexClient() as moex:
coupons, amortisations = await moex.bondization("RU000A")
within_a_year = [
c for c in coupons if c.coupon_date and c.coupon_date <= TODAY + timedelta(days=365)
]
assert len(within_a_year) >= 12
assert all(c.value == D("34.9") for c in within_a_year)
assert amortisations == []
@respx.mock
async def test_the_dividend_extract_is_read_by_column_name():
respx.get(url__startswith=f"{ISS}/securities/SBER/dividends").mock(
return_value=httpx.Response(
200,
json=block(
"dividends",
["secid", "isin", "registryclosedate", "value", "currencyid"],
[["SBER", "RU0009029540", "2026-07-10", 34.84, "SUR"]],
),
)
)
async with httpx.AsyncClient(trust_env=False) as http:
rows = await fetch_dividends(http, "SBER")
assert rows == [
MoexDividendRow(
secid="SBER",
registry_close_date=date(2026, 7, 10),
value=D("34.84"),
currency="RUB", # ISS says SUR
)
]
# --- sync ----------------------------------------------------------------------------
async def seed(asset_class: AssetClass, ticker: str) -> int:
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,
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_of(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 mock_iss(mock_http) -> None:
mock_http.get(url__startswith=f"{ISS}/securities/RU000A/bondization").mock(
return_value=httpx.Response(
200,
json=bondization(
[
["2027-05-05", 34.9, 7.0, "SUR"],
["2028-05-05", 34.9, 7.0, "SUR"],
],
[
["2027-05-05", 250, 1000, "SUR"],
["2028-05-05", 750, 750, "SUR"],
],
),
)
)
mock_http.get(url__startswith=f"{ISS}/securities/SBER/dividends").mock(
return_value=httpx.Response(
200,
json=block(
"dividends",
["secid", "registryclosedate", "value", "currencyid"],
[["SBER", "2026-07-10", 34.84, "SUR"]],
),
)
)
async def test_a_bond_run_writes_coupons_and_the_nominal_schedule(app, mock_http, run_sync):
instrument_id = await seed(AssetClass.bond, "RU000A")
mock_iss(mock_http)
await run_sync(MoexPayoutsSource(), settings=Settings())
actions = await rows_of(CorporateAction, instrument_id=instrument_id)
assert {(a.kind, a.source, a.source_id) for a in actions} == {
(CorporateActionKind.coupon, "moex", "cpn:2027-05-05"),
(CorporateActionKind.coupon, "moex", "cpn:2028-05-05"),
}
schedule = sorted(
await rows_of(BondNominalSchedule, instrument_id=instrument_id),
key=lambda p: p.effective_date,
)
assert [(p.effective_date, p.nominal) for p in schedule] == [
(date(2027, 5, 5), D(750)),
(date(2028, 5, 5), D(0)),
]
async def test_a_share_run_writes_the_register_dividend(app, mock_http, run_sync):
instrument_id = await seed(AssetClass.share, "SBER")
mock_iss(mock_http)
await run_sync(MoexPayoutsSource(), settings=Settings())
actions = await rows_of(CorporateAction, instrument_id=instrument_id)
assert [(a.kind, a.source, a.amount_per_unit) for a in actions] == [
(CorporateActionKind.dividend, "moex", D("34.84"))
]
async def test_a_second_run_stores_no_duplicates(app, mock_http, run_sync):
instrument_id = await seed(AssetClass.bond, "RU000A")
mock_iss(mock_http)
await run_sync(MoexPayoutsSource(), settings=Settings())
await run_sync(MoexPayoutsSource(), settings=Settings())
assert len(await rows_of(CorporateAction, instrument_id=instrument_id)) == 2
assert len(await rows_of(BondNominalSchedule, instrument_id=instrument_id)) == 2
async def test_moex_wins_the_nominal_row_a_weaker_source_already_wrote(app, mock_http, run_sync):
"""`bond_nominal_schedule` is keyed without `source`, so precedence is decided on write."""
instrument_id = await seed(AssetClass.bond, "RU000A")
async with get_sessionmaker()() as session:
session.add(
BondNominalSchedule(
instrument_id=instrument_id,
effective_date=date(2027, 5, 5),
nominal=D(800),
currency="RUB",
source="tinvest",
)
)
await session.commit()
mock_iss(mock_http)
await run_sync(MoexPayoutsSource(), settings=Settings())
schedule = await rows_of(BondNominalSchedule, instrument_id=instrument_id)
row = next(p for p in schedule if p.effective_date == date(2027, 5, 5))
assert (row.nominal, row.source) == (D(750), "moex")
@@ -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