feat(ledger): корпоративные действия из операций брокера

derive() выводит сплиты, амортизации и погашения из самого леджера и складывает их в
corporate_action; rewrite_splits схлопывает пару безденежных transfer_out/transfer_in
в синтетический split. Без этого перерегистрация бумаги реализовалась бы как фиктивный
round-trip: срок владения обнулился бы, а с ним и право на ЛДВ.

amount_per_unit считается пулом по инструменту и дате: одно действие эмитента приходит
отдельной операцией на каждый счёт, и делить надо на суммарную позицию. Позиция для
деления реплеится внутри модуля, потому что шаг обязан идти ДО лотов — именно лоты
потребляют то, что он пишет. Нет позиции — NULL и находка corporate_action_unpriced,
не подставленное число.

Модуль пишет и удаляет только split, amortization и repayment. Дивиденды и купоны он
не трогает: их календарь придёт из фида эмитента, и чистка снесла бы объявленные.

Живых сплитов и погашений в данных нет — эти ветки покрыты только синтетикой. Из
реального есть девять BOND_REPAYMENT, которые пулятся в семь амортизаций.
This commit is contained in:
Dmitry
2026-09-18 15:06:06 +03:00
parent ff4d63e829
commit eb4eae5beb
4 changed files with 676 additions and 2 deletions
@@ -0,0 +1,212 @@
"""Corporate actions derived from the ledger, and what they do to the lots.
The pure half runs on synthetic events; the database half checks that a second refresh
writes the same rows instead of a second copy of them.
"""
from datetime import date
from decimal import Decimal
from sqlalchemy import select
from factories import make_account, make_event, make_instrument
from fintracker.db import get_sessionmaker
from fintracker.ledger.corporate_actions import derive, rebuild_corporate_actions, rewrite_splits
from fintracker.ledger.lots import LedgerEvent, apply
from fintracker.models import AssetClass, CorporateAction, CorporateActionKind, EventKind
D = Decimal
ACC, INS = 1, 10
_seq = iter(range(1, 10_000))
def ev(kind: EventKind, day: str, *, qty=None, amount="0", **over) -> LedgerEvent:
return LedgerEvent(
id=over.pop("id", next(_seq)),
account_id=over.pop("account_id", ACC),
instrument_id=over.pop("instrument_id", INS),
kind=kind,
trade_date=date.fromisoformat(day),
quantity=D(qty) if qty is not None else None,
price=None,
price_currency=None,
amount=D(amount),
currency="RUB",
fee=None,
ratio=D(over.pop("ratio")) if "ratio" in over else None,
meta=over.pop("meta", None),
)
def buy(day, qty, amount, **kw):
return ev(EventKind.buy, day, qty=qty, amount=amount, **kw)
# --- the pure derivation ----------------------------------------------------------------
def test_a_cashless_transfer_pair_is_read_as_a_split():
actions = derive(
[
buy("2025-01-10", "10", "-1000"),
ev(EventKind.transfer_out, "2025-06-02", qty="-10"),
ev(EventKind.transfer_in, "2025-06-02", qty="100"),
]
).actions
assert [a.kind for a in actions] == [CorporateActionKind.stock_split]
assert actions[0].ratio == D(10)
assert actions[0].action_date == date(2025, 6, 2)
assert actions[0].source_id == "2025-06-02"
def test_a_transfer_pair_that_moved_money_is_a_trade_not_a_split():
actions = derive(
[
buy("2025-01-10", "10", "-1000"),
ev(EventKind.transfer_out, "2025-06-02", qty="-10", amount="1500"),
ev(EventKind.transfer_in, "2025-06-02", qty="100", amount="-1500"),
]
).actions
assert actions == []
def test_a_split_ratio_stated_in_meta_is_used():
actions = derive(
[
buy("2025-01-10", "10", "-1000"),
ev(EventKind.stock_split, "2025-06-02", meta={"split_ratio": "10"}),
]
).actions
assert [a.ratio for a in actions] == [D(10)]
assert actions[0].leg_event_ids == () # nothing to suppress: no position event to drop
def test_a_split_of_ten_multiplies_quantity_and_keeps_the_position_cost():
events = [
buy("2025-01-10", "10", "-1000"),
ev(EventKind.transfer_out, "2025-06-02", qty="-10"),
ev(EventKind.transfer_in, "2025-06-02", qty="100"),
]
result = apply(rewrite_splits(events, derive(events).actions))
assert len(result.lots) == 1 # the pair became a split, not a sale and a purchase
lot = result.lots[0]
assert lot.qty_remaining == D(100)
assert lot.cost_per_unit == D(10)
assert lot.cost_per_unit * lot.qty_remaining == D(1000)
assert result.disposals == [] # and so nothing was realised
def test_partial_amortisation_is_priced_per_unit_and_lowers_the_cost():
events = [
buy("2025-01-10", "4", "-4000"),
ev(EventKind.amortization, "2025-07-01", amount="1000"),
]
actions = derive(events).actions
assert [a.kind for a in actions] == [CorporateActionKind.amortization]
assert actions[0].amount_per_unit == D(250)
assert actions[0].currency == "RUB"
lot = apply(events).lots[0]
assert lot.qty_remaining == D(4) # amortisation returns principal, not paper
assert lot.cost_per_unit == D(750)
def test_amortisation_pools_the_same_bond_across_accounts():
actions = derive(
[
buy("2025-01-10", "4", "-4000"),
buy("2025-01-10", "6", "-6000", account_id=2),
ev(EventKind.amortization, "2025-07-01", amount="1000"),
ev(EventKind.amortization, "2025-07-01", amount="1500", account_id=2),
]
).actions
assert len(actions) == 1 # one action on the paper, not one per account
assert actions[0].amount_per_unit == D(250)
def test_a_repayment_closes_the_lots_at_par_and_is_recorded_as_one():
events = [
buy("2025-01-10", "4", "-4000"),
ev(EventKind.amortization, "2025-07-01", amount="1000"),
ev(EventKind.repayment, "2025-12-01", qty="-4", amount="3000"),
]
actions = [a for a in derive(events).actions if a.kind is CorporateActionKind.repayment]
assert [a.amount_per_unit for a in actions] == [D(750)]
assert actions[0].action_date == date(2025, 12, 1)
result = apply(events)
assert result.lots[0].qty_remaining == D(0)
assert result.lots[0].closed_at == date(2025, 12, 1)
# cost fell to 750/unit with the amortisation, par came back at 750 — no phantom loss
assert sum(d.realized_pnl_native for d in result.disposals) == D(0)
def test_a_payout_with_no_position_stays_unpriced_instead_of_guessing():
result = derive([ev(EventKind.amortization, "2025-07-01", amount="1000")])
assert [a.amount_per_unit for a in result.actions] == [None]
assert result.unpriced == result.actions
# --- the session wrapper ----------------------------------------------------------------
async def _bond_with_amortisation() -> None:
account_id = await make_account(name="Брокерский", source="tinvest")
instrument_id = await make_instrument(ticker="RU000A", asset_class=AssetClass.bond)
await make_event(
date(2025, 1, 10),
account_id=account_id,
instrument_id=instrument_id,
kind=EventKind.buy,
quantity=4,
amount="-4000",
source_id="buy-1",
)
await make_event(
date(2025, 7, 1),
account_id=account_id,
instrument_id=instrument_id,
kind=EventKind.amortization,
amount="1000",
source_id="amort-1",
)
async def _rebuild() -> list[CorporateAction]:
async with get_sessionmaker()() as session:
await rebuild_corporate_actions(session)
await session.commit()
async with get_sessionmaker()() as session:
return list((await session.execute(select(CorporateAction))).scalars())
async def test_rebuild_writes_the_derived_action(app):
await _bond_with_amortisation()
rows = await _rebuild()
assert len(rows) == 1
assert rows[0].kind is CorporateActionKind.amortization
assert rows[0].amount_per_unit == D(250)
assert rows[0].ex_date == date(2025, 7, 1)
assert rows[0].pay_date == date(2025, 7, 1)
assert rows[0].source == "tinvest"
async def test_a_second_rebuild_updates_in_place_instead_of_duplicating(app):
await _bond_with_amortisation()
first = await _rebuild()
second = await _rebuild()
assert len(second) == 1
assert [r.id for r in second] == [r.id for r in first]
assert second[0].amount_per_unit == D(250)