"""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/RU000A.json").mock( return_value=httpx.Response( 200, json=block( "description", ["name", "title", "value"], [ ["MATDATE", "Дата погашения", "2028-05-05"], ["FACEVALUE", "Номинальная стоимость", "750"], ["FACEUNIT", "Валюта номинала", "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_fills_the_maturity_and_nominal_the_instrument_lacks( app, mock_http, run_sync ): """The redemption on the income calendar is built from these two fields.""" instrument_id = await seed(AssetClass.bond, "RU000A") mock_iss(mock_http) await run_sync(MoexPayoutsSource(), settings=Settings()) (instrument,) = await rows_of(Instrument, id=instrument_id) assert instrument.maturity_date == date(2028, 5, 5) assert instrument.nominal == D(750) assert instrument.nominal_currency == "RUB" async def test_a_value_already_on_the_instrument_is_not_overwritten(app, mock_http, run_sync): instrument_id = await seed(AssetClass.bond, "RU000A") async with get_sessionmaker()() as session: instrument = await session.get(Instrument, instrument_id) assert instrument is not None instrument.nominal = D(1000) await session.commit() mock_iss(mock_http) await run_sync(MoexPayoutsSource(), settings=Settings()) (instrument,) = await rows_of(Instrument, id=instrument_id) assert instrument.nominal == D(1000) # what was there stays, whatever MOEX says today assert instrument.maturity_date == date(2028, 5, 5) # only the missing field is filled 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")