Files
fin-tracker/backend/tests/ledger/test_lots.py
T
Dmitry 012a40981f feat(ledger): единый леджер событий и FIFO-лоты
Одна таблица event, куда маппится каждый брокерский источник: quantity знаковый
по эффекту на позицию, amount — по эффекту на кэш, dedupe_key UNIQUE делает
повторный импорт пустой операцией. Аналитика читает только confirmed.

lots.apply() — чистая функция без сессии, rebuild.py её обвязка с БД. FIFO по
(счёт, инструмент), как требует ст. 214.1 НК; комиссии капитализируются в
покупку и вычитаются из продажи, НКД в себестоимость не входит — это деньги,
авансированные продавцу и возвращаемые купоном.

Короткие продажи — тоже лоты: продажа без остатка открывает короткий лот,
покупка его закрывает, прибыль равна падению цены. В данных такое есть (TATN
продан 22.08 и выкуплен 29.08); считать это ошибкой значило бы оставить
фантомный длинный лот навсегда и потерять реализованную прибыль.

Всё пересобирается с нуля на каждом refresh: объёмы личные, это секунды, зато
исчезает целый класс багов расхождения инкремента с леджером.
2026-09-18 13:44:31 +03:00

244 lines
8.1 KiB
Python

"""FIFO engine rules, on synthetic events — no database, no source."""
from datetime import date, timedelta
from decimal import Decimal
from fintracker.ledger.lots import LDV_DAYS, LedgerEvent, apply
from fintracker.models.ledger import EventKind
D = Decimal
ACC, INS = 1, 10
_seq = iter(range(1, 10_000))
def ev(kind: EventKind, day: str, *, qty=None, amount="0", price=None, **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=D(price) if price is not None else None,
price_currency="RUB",
amount=D(amount),
currency="RUB",
fee=D(over.pop("fee")) if "fee" in over else None,
accrued_interest=D(over.pop("nkd")) if "nkd" in over else None,
ratio=D(over.pop("ratio")) if "ratio" in over else None,
exchange_traded=over.pop("exchange_traded", True),
)
def buy(day, qty, amount, **kw):
return ev(EventKind.buy, day, qty=qty, amount=amount, **kw)
def sell(day, qty, amount, **kw):
return ev(EventKind.sell, day, qty=f"-{qty}", amount=amount, **kw)
def test_fifo_consumes_the_oldest_lot_first():
result = apply(
[
buy("2025-01-10", "10", "-1000"),
buy("2025-02-10", "10", "-1500"),
sell("2025-03-10", "10", "1400"),
]
)
assert len(result.disposals) == 1
disposal = result.disposals[0]
assert disposal.lot.open_date == date(2025, 1, 10) # the January lot, not February
assert disposal.cost == D(1000)
assert disposal.realized_pnl_native == D(400)
# the February lot is untouched
assert [lot.qty_remaining for lot in result.lots] == [D(0), D(10)]
def test_a_sale_spanning_two_lots_splits_into_two_disposals():
result = apply(
[
buy("2025-01-10", "10", "-1000"),
buy("2025-02-10", "10", "-2000"),
sell("2025-03-10", "15", "2250"),
]
)
assert [d.qty for d in result.disposals] == [D(10), D(5)]
# 10 @ cost 100 sold at 150 -> +500; 5 @ cost 200 sold at 150 -> -250
assert [d.realized_pnl_native for d in result.disposals] == [D(500), D(-250)]
assert sum(d.realized_pnl_native for d in result.disposals) == D(250)
def test_fees_are_capitalised_into_cost_and_deducted_from_proceeds():
"""The tax-relevant number: a buy costs more than the price, a sale nets less."""
result = apply(
[buy("2025-01-10", "10", "-1000", fee="10"), sell("2025-02-10", "10", "1000", fee="10")]
)
disposal = result.disposals[0]
assert disposal.cost == D(1010)
assert disposal.proceeds == D(990)
assert disposal.realized_pnl_native == D(-20) # a flat round-trip still loses both fees
def test_accrued_interest_is_not_part_of_the_lot_cost():
"""НКД is advanced to the seller and returned by the next coupon — not an investment."""
result = apply([buy("2025-01-10", "10", "-1050", nkd="50")])
assert result.lots[0].cost_per_unit == D(100)
def test_split_multiplies_quantity_and_divides_cost():
result = apply(
[buy("2025-01-10", "10", "-1000"), ev(EventKind.stock_split, "2025-02-01", ratio="10")]
)
lot = result.lots[0]
assert lot.qty_remaining == D(100)
assert lot.cost_per_unit == D(10)
assert lot.qty_remaining * lot.cost_per_unit == D(1000) # total cost unchanged
def test_amortisation_lowers_cost_per_unit_without_touching_quantity():
result = apply(
[buy("2025-01-10", "10", "-10000"), ev(EventKind.amortization, "2025-06-01", amount="2000")]
)
lot = result.lots[0]
assert lot.qty_remaining == D(10)
assert lot.cost_per_unit == D(800)
def test_amortised_bond_repaid_at_par_shows_no_phantom_loss():
"""Without amortisation lowering the basis, the repayment would look like a big loss."""
result = apply(
[
buy("2025-01-10", "10", "-10000"),
ev(EventKind.amortization, "2025-06-01", amount="2000"),
ev(EventKind.repayment, "2025-12-01", qty="-10", amount="8000"),
]
)
assert result.disposals[0].realized_pnl_native == D(0)
def test_ldv_marks_only_holdings_of_three_years_or_more():
open_day = date(2021, 1, 10)
long_enough = open_day + timedelta(days=LDV_DAYS)
result = apply(
[buy(open_day.isoformat(), "10", "-1000"), sell(long_enough.isoformat(), "10", "1500")]
)
assert result.disposals[0].ldv_eligible is True
def test_ldv_does_not_apply_a_day_early():
open_day = date(2021, 1, 10)
too_soon = open_day + timedelta(days=LDV_DAYS - 1)
result = apply(
[buy(open_day.isoformat(), "10", "-1000"), sell(too_soon.isoformat(), "10", "1500")]
)
assert result.disposals[0].ldv_eligible is False
def test_ldv_does_not_apply_to_otc_papers():
open_day = date(2021, 1, 10)
long_enough = open_day + timedelta(days=LDV_DAYS)
result = apply(
[
buy(open_day.isoformat(), "10", "-1000", exchange_traded=False),
sell(long_enough.isoformat(), "10", "1500"),
]
)
assert result.disposals[0].ldv_eligible is False
def test_selling_more_than_held_reports_a_shortfall_instead_of_inventing_a_lot():
result = apply([buy("2025-01-10", "5", "-500"), sell("2025-02-10", "8", "800")])
assert result.disposals[0].qty == D(5)
assert [s.qty for s in result.shortfalls] == [D(3)]
def test_a_same_day_buy_is_available_to_a_same_day_sale():
"""Feeds do not order same-day operations; the engine must not depend on luck."""
sale = sell("2025-01-10", "10", "1200", id=1)
purchase = buy("2025-01-10", "10", "-1000", id=2)
result = apply([sale, purchase])
assert not result.shortfalls
assert result.disposals[0].realized_pnl_native == D(200)
def test_accounts_keep_independent_queues():
"""Tax is computed per account, so the same paper elsewhere must not be consumed."""
result = apply(
[
buy("2025-01-10", "10", "-1000", account_id=1),
buy("2025-01-11", "10", "-2000", account_id=2),
sell("2025-02-10", "10", "1500", account_id=2),
]
)
disposal = result.disposals[0]
assert disposal.lot.account_id == 2
assert disposal.cost == D(2000) # account 2's own lot, not account 1's cheaper one
def test_transfer_in_opens_a_lot_and_transfer_out_closes_one():
result = apply(
[
ev(EventKind.transfer_in, "2025-01-10", qty="10", amount="0", price="100"),
ev(EventKind.transfer_out, "2025-02-10", qty="-4", amount="0", price="120"),
]
)
assert result.lots[0].cost_per_unit == D(100)
assert result.disposals[0].qty == D(4)
def test_a_short_round_trip_realises_the_fall_in_price():
"""Real case (TATN): sold on 22.08, bought back on 29.08 — profit is the price drop."""
result = apply(
[
sell("2025-08-22", "10", "6751"),
buy("2025-08-29", "10", "-6505"),
]
)
assert not result.shortfalls
assert result.disposals[0].realized_pnl_native == D(246)
assert sum(lot.signed_qty_remaining for lot in result.lots) == D(0)
def test_an_open_short_is_reported_as_a_shortfall():
"""Either an open short or history starting mid-way — both need a human to look."""
result = apply([sell("2025-08-22", "10", "6751")])
assert [s.qty for s in result.shortfalls] == [D(10)]
assert result.lots[0].signed_qty_remaining == D(-10)
def test_a_short_is_never_ldv_eligible():
result = apply(
[
sell("2021-01-10", "10", "1500"),
buy((date(2021, 1, 10) + timedelta(days=LDV_DAYS)).isoformat(), "10", "-1000"),
]
)
assert result.disposals[0].ldv_eligible is False
def test_selling_past_a_long_position_opens_a_short_for_the_excess():
result = apply([buy("2025-01-10", "5", "-500"), sell("2025-02-10", "8", "800")])
assert result.disposals[0].qty == D(5)
assert [s.qty for s in result.shortfalls] == [D(3)]
assert sum(lot.signed_qty_remaining for lot in result.lots) == D(-3)