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:
@@ -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)
|
||||
Reference in New Issue
Block a user