fix(income): погашения облигаций попадают в календарь выплат

У облигаций из ленты операций не было ни даты погашения, ни номинала, а погашение строилось именно от них. moex_payouts дописывает оба поля из паспорта бумаги (только пустые). Аналитика берёт дату погашения из графика номинала, если поля нет, считает сумму по номиналу накануне, а последний шаг амортизации не учитывает дважды.
This commit is contained in:
Dmitry
2026-09-20 10:46:27 +03:00
parent d2df86ce33
commit 6a15371960
5 changed files with 177 additions and 7 deletions
+22 -3
View File
@@ -190,6 +190,17 @@ def nominal_at(schedule: Sequence[tuple[date, Decimal]], d: date) -> Decimal | N
return current 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( def coupon_per_unit(
declared: Decimal, base_nominal: Decimal | None, nominal_on_date: Decimal | None declared: Decimal, base_nominal: Decimal | None, nominal_on_date: Decimal | None
) -> Decimal: ) -> Decimal:
@@ -366,7 +377,9 @@ def bond_entries(
previous: Decimal | None = None previous: Decimal | None = None
for effective, value in schedule: 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 step = previous - value
out.append( out.append(
Entry( Entry(
@@ -383,9 +396,15 @@ def bond_entries(
) )
previous = value 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: 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: if par is not None:
out.append( out.append(
Entry( Entry(
@@ -111,6 +111,15 @@ class AmortisationRow:
currency: str | None 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: def new_http_client() -> httpx.AsyncClient:
# trust_env=False: see NETWORK NOTE above # trust_env=False: see NETWORK NOTE above
return httpx.AsyncClient(trust_env=False, timeout=TIMEOUT) return httpx.AsyncClient(trust_env=False, timeout=TIMEOUT)
@@ -222,6 +231,16 @@ class MoexClient:
] ]
return coupons, amortisations 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]]: 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.""" """Turn ISS's {columns, data} block into dicts, so fields are read by name."""
+56 -4
View File
@@ -16,6 +16,9 @@ What it writes, and what it deliberately does not:
* amortisations -> `bond_nominal_schedule(source='moex')`, and NOT * amortisations -> `bond_nominal_schedule(source='moex')`, and NOT
`corporate_action(kind=amortization)`: that kind belongs to `ledger/corporate_actions.py`, `corporate_action(kind=amortization)`: that kind belongs to `ledger/corporate_actions.py`,
whose prune deletes every row in it the ledger does not imply. 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 **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 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 from decimal import Decimal
import httpx import httpx
from sqlalchemy import select from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from fintracker.analytics import today_local from fintracker.analytics import today_local
@@ -100,6 +103,8 @@ class Target:
secid: str secid: str
asset_class: AssetClass asset_class: AssetClass
currency: str currency: str
maturity_date: date | None = None
nominal: Decimal | None = None
async def fetch_dividends(client: httpx.AsyncClient, secid: str) -> list[MoexDividendRow]: 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") log.info("moex_payouts: no priceable instruments in the ledger yet")
return SyncResult(cursor_after=today.isoformat(), counts={"payouts": 0}, changed=False) 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] = [] warnings: list[str] = []
payouts: list[PayoutRow] = [] payouts: list[PayoutRow] = []
nominals: list[NominalPoint] = [] nominals: list[NominalPoint] = []
@@ -231,6 +243,7 @@ class MoexPayoutsSource:
if target.asset_class in BOND_CLASSES: if target.asset_class in BOND_CLASSES:
rows = await self._bond(moex, target, today, payouts, nominals, warnings) rows = await self._bond(moex, target, today, payouts, nominals, warnings)
counts["coupons"] += rows counts["coupons"] += rows
counts["bond_facts"] += await self._bond_facts(session, moex, target, warnings)
else: else:
counts["dividends"] += await self._dividends( counts["dividends"] += await self._dividends(
http, target, today, payouts, warnings http, target, today, payouts, warnings
@@ -250,9 +263,44 @@ class MoexPayoutsSource:
cursor_after=today.isoformat(), cursor_after=today.isoformat(),
counts=counts, counts=counts,
warnings=warnings, 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( async def _bond(
self, self,
moex: MoexClient, moex: MoexClient,
@@ -320,6 +368,8 @@ async def load_targets(session: AsyncSession) -> list[Target]:
Instrument.ticker, Instrument.ticker,
Instrument.asset_class, Instrument.asset_class,
Instrument.currency, Instrument.currency,
Instrument.maturity_date,
Instrument.nominal,
) )
.join(Event, Event.instrument_id == Instrument.id) .join(Event, Event.instrument_id == Instrument.id)
.where( .where(
@@ -327,7 +377,7 @@ async def load_targets(session: AsyncSession) -> list[Target]:
Instrument.ticker.is_not(None), Instrument.ticker.is_not(None),
Instrument.asset_class.in_(BOND_CLASSES | DIVIDEND_CLASSES), Instrument.asset_class.in_(BOND_CLASSES | DIVIDEND_CLASSES),
) )
.group_by(Instrument.id, Instrument.ticker, Instrument.asset_class, Instrument.currency) .group_by(Instrument.id)
) )
).all() ).all()
return [ return [
@@ -336,6 +386,8 @@ async def load_targets(session: AsyncSession) -> list[Target]:
secid=r.ticker, secid=r.ticker,
asset_class=r.asset_class, asset_class=r.asset_class,
currency=r.currency, currency=r.currency,
maturity_date=r.maturity_date,
nominal=r.nominal,
) )
for r in rows for r in rows
] ]
+35
View File
@@ -117,6 +117,41 @@ def test_bond_entries_cover_coupon_amortisation_and_redemption():
assert all(e.basis is IncomeBasis.schedule for e in entries) 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(): def test_an_announced_payout_displaces_the_projection_of_the_same_payment():
announced = [ announced = [
Entry( Entry(
@@ -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( mock_http.get(url__startswith=f"{ISS}/securities/SBER/dividends").mock(
return_value=httpx.Response( return_value=httpx.Response(
200, 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): async def test_a_bond_run_writes_coupons_and_the_nominal_schedule(app, mock_http, run_sync):
instrument_id = await seed(AssetClass.bond, "RU000A") instrument_id = await seed(AssetClass.bond, "RU000A")
mock_iss(mock_http) mock_iss(mock_http)