fix(income): погашения облигаций попадают в календарь выплат
У облигаций из ленты операций не было ни даты погашения, ни номинала, а погашение строилось именно от них. moex_payouts дописывает оба поля из паспорта бумаги (только пустые). Аналитика берёт дату погашения из графика номинала, если поля нет, считает сумму по номиналу накануне, а последний шаг амортизации не учитывает дважды.
This commit is contained in:
@@ -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."""
|
||||||
|
|||||||
@@ -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
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user