From 6a1537196003f1cc79490bdfa5001c8487345363 Mon Sep 17 00:00:00 2001 From: Dmitry Date: Sun, 20 Sep 2026 10:46:27 +0300 Subject: [PATCH] =?UTF-8?q?fix(income):=20=D0=BF=D0=BE=D0=B3=D0=B0=D1=88?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=BE=D0=B1=D0=BB=D0=B8=D0=B3=D0=B0?= =?UTF-8?q?=D1=86=D0=B8=D0=B9=20=D0=BF=D0=BE=D0=BF=D0=B0=D0=B4=D0=B0=D1=8E?= =?UTF-8?q?=D1=82=20=D0=B2=20=D0=BA=D0=B0=D0=BB=D0=B5=D0=BD=D0=B4=D0=B0?= =?UTF-8?q?=D1=80=D1=8C=20=D0=B2=D1=8B=D0=BF=D0=BB=D0=B0=D1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit У облигаций из ленты операций не было ни даты погашения, ни номинала, а погашение строилось именно от них. moex_payouts дописывает оба поля из паспорта бумаги (только пустые). Аналитика берёт дату погашения из графика номинала, если поля нет, считает сумму по номиналу накануне, а последний шаг амортизации не учитывает дважды. --- backend/src/fintracker/analytics/income.py | 25 +++++++- backend/src/fintracker/sources/moex/client.py | 19 ++++++ .../src/fintracker/sources/moex/payouts.py | 60 +++++++++++++++++-- backend/tests/analytics/test_income.py | 35 +++++++++++ backend/tests/sources/test_moex_payouts.py | 45 ++++++++++++++ 5 files changed, 177 insertions(+), 7 deletions(-) diff --git a/backend/src/fintracker/analytics/income.py b/backend/src/fintracker/analytics/income.py index 3960eb8..ad83a3a 100644 --- a/backend/src/fintracker/analytics/income.py +++ b/backend/src/fintracker/analytics/income.py @@ -190,6 +190,17 @@ def nominal_at(schedule: Sequence[tuple[date, Decimal]], d: date) -> Decimal | N return current +def final_redemption_date(schedule: Sequence[tuple[date, Decimal]]) -> date | None: + """The day the published schedule takes the nominal to zero, if it does. + + A bond's amortisation plan ends with the redemption itself, and each row records what the + nominal *becomes* — so the last row is zero exactly on the maturity date. + """ + if schedule and schedule[-1][1] == ZERO: + return schedule[-1][0] + return None + + def coupon_per_unit( declared: Decimal, base_nominal: Decimal | None, nominal_on_date: Decimal | None ) -> Decimal: @@ -366,7 +377,9 @@ def bond_entries( previous: Decimal | None = None for effective, value in schedule: - if previous is not None and start <= effective <= end and value < previous: + # the step that takes the nominal to zero is the redemption, built below: counted here + # as well it would put the last payment on the calendar twice + if previous is not None and start <= effective <= end and 0 < value < previous: step = previous - value out.append( Entry( @@ -383,9 +396,15 @@ def bond_entries( ) previous = value - maturity = facts.maturity_date + # the passport date when the instrument has one; otherwise the day the published schedule + # runs the nominal down to zero, which is the same day by construction + maturity = facts.maturity_date or final_redemption_date(schedule) if maturity is not None and start <= maturity <= end: - par = nominal_at(schedule, maturity) or facts.nominal + # what is repaid at maturity is the nominal still standing the day before: the schedule + # entry for the maturity date itself says what is left AFTER it, i.e. nothing + par = nominal_at(schedule, maturity - timedelta(days=1)) + if par is None: + par = facts.nominal if par is not None: out.append( Entry( diff --git a/backend/src/fintracker/sources/moex/client.py b/backend/src/fintracker/sources/moex/client.py index dcdea86..0189054 100644 --- a/backend/src/fintracker/sources/moex/client.py +++ b/backend/src/fintracker/sources/moex/client.py @@ -111,6 +111,15 @@ class AmortisationRow: currency: str | None +@dataclass(frozen=True) +class BondDescription: + maturity_date: date | None + face_value: Decimal | None + """The nominal in force today (`FACEVALUE`), not the issue par: it is what a percent quote + is a percent of.""" + face_unit: str | None + + def new_http_client() -> httpx.AsyncClient: # trust_env=False: see NETWORK NOTE above return httpx.AsyncClient(trust_env=False, timeout=TIMEOUT) @@ -222,6 +231,16 @@ class MoexClient: ] return coupons, amortisations + async def bond_description(self, secid: str) -> BondDescription: + """The bond's passport: when it matures and what its nominal is today.""" + payload = await self._get(f"/securities/{secid}.json", **{"iss.only": "description"}) + fields = {str(row.get("name")): row.get("value") for row in _rows(payload, "description")} + return BondDescription( + maturity_date=_date(fields.get("MATDATE")), + face_value=_decimal(fields.get("FACEVALUE")), + face_unit=_currency(fields.get("FACEUNIT")), + ) + def _rows(payload: dict[str, Any], block: str) -> list[dict[str, Any]]: """Turn ISS's {columns, data} block into dicts, so fields are read by name.""" diff --git a/backend/src/fintracker/sources/moex/payouts.py b/backend/src/fintracker/sources/moex/payouts.py index aaef7d2..535ee62 100644 --- a/backend/src/fintracker/sources/moex/payouts.py +++ b/backend/src/fintracker/sources/moex/payouts.py @@ -16,6 +16,9 @@ What it writes, and what it deliberately does not: * amortisations -> `bond_nominal_schedule(source='moex')`, and NOT `corporate_action(kind=amortization)`: that kind belongs to `ledger/corporate_actions.py`, whose prune deletes every row in it the ledger does not imply. +* a bond's passport (`/securities/{secid}.json`, `description`) -> `instrument.maturity_date` + and `nominal`, only where they are empty: the operations feed leaves both blank, and the + redemption on the income calendar cannot be built without them. **The amortisation plan is read as a run-out, not as a column.** ISS states `value` (repaid per bond) and `facevalue` per row, but which side of the payment `facevalue` stands on is not @@ -38,7 +41,7 @@ from datetime import date from decimal import Decimal import httpx -from sqlalchemy import select +from sqlalchemy import select, update from sqlalchemy.ext.asyncio import AsyncSession from fintracker.analytics import today_local @@ -100,6 +103,8 @@ class Target: secid: str asset_class: AssetClass currency: str + maturity_date: date | None = None + nominal: Decimal | None = None async def fetch_dividends(client: httpx.AsyncClient, secid: str) -> list[MoexDividendRow]: @@ -220,7 +225,14 @@ class MoexPayoutsSource: log.info("moex_payouts: no priceable instruments in the ledger yet") return SyncResult(cursor_after=today.isoformat(), counts={"payouts": 0}, changed=False) - counts = {"instruments": 0, "coupons": 0, "dividends": 0, "payouts": 0, "nominals": 0} + counts = { + "instruments": 0, + "coupons": 0, + "dividends": 0, + "payouts": 0, + "nominals": 0, + "bond_facts": 0, + } warnings: list[str] = [] payouts: list[PayoutRow] = [] nominals: list[NominalPoint] = [] @@ -231,6 +243,7 @@ class MoexPayoutsSource: if target.asset_class in BOND_CLASSES: rows = await self._bond(moex, target, today, payouts, nominals, warnings) counts["coupons"] += rows + counts["bond_facts"] += await self._bond_facts(session, moex, target, warnings) else: counts["dividends"] += await self._dividends( http, target, today, payouts, warnings @@ -250,9 +263,44 @@ class MoexPayoutsSource: cursor_after=today.isoformat(), counts=counts, warnings=warnings, - changed=bool(counts["payouts"] or counts["nominals"]), + changed=bool(counts["payouts"] or counts["nominals"] or counts["bond_facts"]), ) + async def _bond_facts( + self, session: AsyncSession, moex: MoexClient, target: Target, warnings: list[str] + ) -> int: + """Fill the maturity date and the nominal a bond is missing, from its MOEX passport. + + Neither is on the instrument when it came from the operations feed (T-Invest's + `GetInstrumentBy` states neither), and the redemption on the income calendar is built + from exactly these two. A value already there is never overwritten: it may have been + corrected by hand. Returns the number of fields written. + """ + if target.maturity_date is not None and target.nominal is not None: + return 0 + description = None + for secid in _secid_candidates(target.secid): + try: + description = await moex.bond_description(secid) + except (MoexError, httpx.HTTPError) as err: + warnings.append(f"{secid}: {err}") + continue + break + if description is None: + return 0 + + values: dict[str, object] = {} + if target.maturity_date is None and description.maturity_date is not None: + values["maturity_date"] = description.maturity_date + if target.nominal is None and description.face_value is not None: + values["nominal"] = description.face_value + values["nominal_currency"] = description.face_unit or target.currency + if values: + await session.execute( + update(Instrument).where(Instrument.id == target.instrument_id).values(**values) + ) + return len(values) + async def _bond( self, moex: MoexClient, @@ -320,6 +368,8 @@ async def load_targets(session: AsyncSession) -> list[Target]: Instrument.ticker, Instrument.asset_class, Instrument.currency, + Instrument.maturity_date, + Instrument.nominal, ) .join(Event, Event.instrument_id == Instrument.id) .where( @@ -327,7 +377,7 @@ async def load_targets(session: AsyncSession) -> list[Target]: Instrument.ticker.is_not(None), Instrument.asset_class.in_(BOND_CLASSES | DIVIDEND_CLASSES), ) - .group_by(Instrument.id, Instrument.ticker, Instrument.asset_class, Instrument.currency) + .group_by(Instrument.id) ) ).all() return [ @@ -336,6 +386,8 @@ async def load_targets(session: AsyncSession) -> list[Target]: secid=r.ticker, asset_class=r.asset_class, currency=r.currency, + maturity_date=r.maturity_date, + nominal=r.nominal, ) for r in rows ] diff --git a/backend/tests/analytics/test_income.py b/backend/tests/analytics/test_income.py index 16fef06..d9f35bd 100644 --- a/backend/tests/analytics/test_income.py +++ b/backend/tests/analytics/test_income.py @@ -117,6 +117,41 @@ def test_bond_entries_cover_coupon_amortisation_and_redemption(): assert all(e.basis is IncomeBasis.schedule for e in entries) +def test_a_bullet_bond_is_redeemed_on_the_day_its_schedule_reaches_zero(): + """The OFZ 26226 case: no `maturity_date` on the instrument, one schedule row — zero.""" + facts = BondFacts( + nominal=D(1000), + nominal_schedule=((date(2026, 10, 7), D(0)),), + maturity_date=None, + currency="RUB", + ) + entries = bond_entries(1, facts, [], D(5), start=date(2026, 9, 20), end=date(2027, 9, 20)) + assert [(e.kind, e.expected_date, e.per_unit, e.amount) for e in entries] == [ + ("repayment", date(2026, 10, 7), D(1000), D(5000)) + ] + + +def test_the_last_amortisation_is_the_redemption_and_is_not_counted_twice(): + facts = BondFacts( + nominal=D(1000), + nominal_schedule=( + (date(2026, 10, 1), D(500)), + (date(2027, 1, 1), D(200)), + (date(2027, 4, 1), D(0)), + ), + maturity_date=None, + currency="RUB", + ) + entries = bond_entries(1, facts, [], D(10), start=date(2026, 9, 20), end=date(2027, 9, 20)) + by_kind = [(e.kind, e.expected_date, e.amount) for e in entries] + # the first row of a plan has no earlier nominal to step down from, so it is not a step; + # the step to 200 is one, and the final 200 is repaid once — as the redemption + assert by_kind == [ + ("amortization", date(2027, 1, 1), D(3000)), + ("repayment", date(2027, 4, 1), D(2000)), + ] + + def test_an_announced_payout_displaces_the_projection_of_the_same_payment(): announced = [ Entry( diff --git a/backend/tests/sources/test_moex_payouts.py b/backend/tests/sources/test_moex_payouts.py index 86f2c98..5ee3e50 100644 --- a/backend/tests/sources/test_moex_payouts.py +++ b/backend/tests/sources/test_moex_payouts.py @@ -286,6 +286,20 @@ def mock_iss(mock_http) -> None: ), ) ) + 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, @@ -298,6 +312,37 @@ def mock_iss(mock_http) -> None: ) +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)