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