diff --git a/backend/alembic/versions/3b1431df06af_леджер_событий_лоты_цены_и_снапшоты_.py b/backend/alembic/versions/3b1431df06af_леджер_событий_лоты_цены_и_снапшоты_.py new file mode 100644 index 0000000..48a248e --- /dev/null +++ b/backend/alembic/versions/3b1431df06af_леджер_событий_лоты_цены_и_снапшоты_.py @@ -0,0 +1,444 @@ +"""леджер событий, лоты, цены и снапшоты (фаза 2) + +Revision ID: 3b1431df06af +Revises: b1d54240abb8 +Create Date: 2026-09-18 10:59:26.712697 +""" + +from __future__ import annotations + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +revision: str = "3b1431df06af" +down_revision: str | None = "b1d54240abb8" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "raw_tinvest_event", + sa.Column("instrument_uid", sa.String(length=64), nullable=False), + sa.Column("kind", sa.String(length=24), nullable=False), + sa.Column("source_id", sa.String(length=128), nullable=False), + sa.Column("event_date", sa.Date(), nullable=True), + sa.Column("payload", postgresql.JSONB(astext_type=sa.Text()), nullable=False), + sa.Column( + "fetched_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.PrimaryKeyConstraint( + "instrument_uid", "kind", "source_id", name=op.f("pk_raw_tinvest_event") + ), + ) + op.create_table( + "raw_tinvest_instrument", + sa.Column("uid", sa.String(length=64), nullable=False), + sa.Column("kind", sa.String(length=16), nullable=False), + sa.Column("isin", sa.String(length=12), nullable=True), + sa.Column("figi", sa.String(length=12), nullable=True), + sa.Column("ticker", sa.String(length=32), nullable=True), + sa.Column("payload", postgresql.JSONB(astext_type=sa.Text()), nullable=False), + sa.Column( + "fetched_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.PrimaryKeyConstraint("uid", name=op.f("pk_raw_tinvest_instrument")), + ) + op.create_index( + op.f("ix_raw_tinvest_instrument_isin"), "raw_tinvest_instrument", ["isin"], unique=False + ) + op.create_table( + "raw_tinvest_operation", + sa.Column("account_id", sa.String(length=64), nullable=False), + sa.Column("id", sa.String(length=64), nullable=False), + sa.Column("operation_type", sa.String(length=64), nullable=False), + sa.Column("ts", sa.DateTime(timezone=True), nullable=False), + sa.Column("payload", postgresql.JSONB(astext_type=sa.Text()), nullable=False), + sa.Column( + "fetched_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.PrimaryKeyConstraint("account_id", "id", name=op.f("pk_raw_tinvest_operation")), + ) + op.create_index( + op.f("ix_raw_tinvest_operation_operation_type"), + "raw_tinvest_operation", + ["operation_type"], + unique=False, + ) + op.create_table( + "raw_tinvest_snapshot", + sa.Column("account_id", sa.String(length=64), nullable=False), + sa.Column("kind", sa.String(length=16), nullable=False), + sa.Column("captured_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("payload", postgresql.JSONB(astext_type=sa.Text()), nullable=False), + sa.PrimaryKeyConstraint( + "account_id", "kind", "captured_at", name=op.f("pk_raw_tinvest_snapshot") + ), + ) + op.create_table( + "cash_snapshot", + sa.Column("account_id", sa.Integer(), nullable=False), + sa.Column("currency", sa.String(length=3), nullable=False), + sa.Column("as_of", sa.DateTime(timezone=True), nullable=False), + sa.Column("source", sa.String(length=16), nullable=False), + sa.Column("balance", sa.Numeric(precision=24, scale=10), nullable=False), + sa.Column("blocked", sa.Numeric(precision=24, scale=10), nullable=True), + sa.ForeignKeyConstraint( + ["account_id"], + ["account.id"], + name=op.f("fk_cash_snapshot_account_id_account"), + ondelete="CASCADE", + ), + sa.PrimaryKeyConstraint( + "account_id", "currency", "as_of", "source", name=op.f("pk_cash_snapshot") + ), + ) + op.create_table( + "corporate_action", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("instrument_id", sa.Integer(), nullable=False), + sa.Column( + "kind", + sa.Enum( + "dividend", + "coupon", + "amortization", + "repayment", + "split", + "offer", + name="corporate_action_kind", + ), + nullable=False, + ), + sa.Column( + "status", + sa.Enum("forecast", "announced", "paid", "cancelled", name="corporate_action_status"), + nullable=False, + ), + sa.Column("record_date", sa.Date(), nullable=True), + sa.Column("ex_date", sa.Date(), nullable=True), + sa.Column("pay_date", sa.Date(), nullable=True), + sa.Column("amount_per_unit", sa.Numeric(precision=24, scale=10), nullable=True), + sa.Column("currency", sa.String(length=3), nullable=True), + sa.Column("ratio", sa.Numeric(precision=24, scale=10), nullable=True), + sa.Column("source", sa.String(length=16), nullable=False), + sa.Column("source_id", sa.String(length=128), nullable=True), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.ForeignKeyConstraint( + ["instrument_id"], + ["instrument.id"], + name=op.f("fk_corporate_action_instrument_id_instrument"), + ondelete="CASCADE", + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_corporate_action")), + sa.UniqueConstraint( + "instrument_id", + "kind", + "source", + "source_id", + name=op.f("uq_corporate_action_instrument_id_kind_source_source_id"), + ), + ) + op.create_index("ix_corporate_action_pay_date", "corporate_action", ["pay_date"], unique=False) + op.create_table( + "event", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("account_id", sa.Integer(), nullable=False), + sa.Column("instrument_id", sa.Integer(), nullable=True), + sa.Column( + "kind", + sa.Enum( + "buy", + "sell", + "dividend", + "coupon", + "interest", + "tax", + "tax_refund", + "commission", + "deposit", + "withdrawal", + "transfer_in", + "transfer_out", + "split", + "amortization", + "repayment", + "fx_exchange", + "other", + name="event_kind", + ), + nullable=False, + ), + sa.Column( + "status", + sa.Enum("confirmed", "pending", "shadow", "ignored", name="event_status"), + nullable=False, + ), + sa.Column("ts", sa.DateTime(timezone=True), nullable=False), + sa.Column("trade_date", sa.Date(), nullable=False), + sa.Column("settle_date", sa.Date(), nullable=True), + sa.Column("quantity", sa.Numeric(precision=24, scale=10), nullable=True), + sa.Column("price", sa.Numeric(precision=24, scale=10), nullable=True), + sa.Column("price_currency", sa.String(length=3), nullable=True), + sa.Column("amount", sa.Numeric(precision=24, scale=10), nullable=False), + sa.Column("currency", sa.String(length=3), nullable=False), + sa.Column("fee", sa.Numeric(precision=24, scale=10), nullable=True), + sa.Column("fee_currency", sa.String(length=3), nullable=True), + sa.Column("tax", sa.Numeric(precision=24, scale=10), nullable=True), + sa.Column("tax_currency", sa.String(length=3), nullable=True), + sa.Column("accrued_interest", sa.Numeric(precision=24, scale=10), nullable=True), + sa.Column("group_id", sa.UUID(), nullable=True), + sa.Column("source", sa.String(length=32), nullable=False), + sa.Column("source_id", sa.String(length=128), nullable=True), + sa.Column("raw_ref", sa.String(length=128), nullable=True), + sa.Column("dedupe_key", sa.String(length=256), nullable=False), + sa.Column("description", sa.Text(), nullable=True), + sa.Column("meta", postgresql.JSONB(astext_type=sa.Text()), nullable=True), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.ForeignKeyConstraint( + ["account_id"], + ["account.id"], + name=op.f("fk_event_account_id_account"), + ondelete="CASCADE", + ), + sa.ForeignKeyConstraint( + ["instrument_id"], + ["instrument.id"], + name=op.f("fk_event_instrument_id_instrument"), + ondelete="RESTRICT", + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_event")), + ) + op.create_index( + "ix_event_account_trade_date", "event", ["account_id", "trade_date"], unique=False + ) + op.create_index(op.f("ix_event_group_id"), "event", ["group_id"], unique=False) + op.create_index( + "ix_event_instrument_trade_date", "event", ["instrument_id", "trade_date"], unique=False + ) + op.create_index("uq_event_dedupe_key", "event", ["dedupe_key"], unique=True) + op.create_table( + "position_snapshot", + sa.Column("account_id", sa.Integer(), nullable=False), + sa.Column("instrument_id", sa.Integer(), nullable=False), + sa.Column("as_of", sa.DateTime(timezone=True), nullable=False), + sa.Column("source", sa.String(length=16), nullable=False), + sa.Column("qty", sa.Numeric(precision=24, scale=10), nullable=False), + sa.Column("avg_price", sa.Numeric(precision=24, scale=10), nullable=True), + sa.Column("market_value", sa.Numeric(precision=24, scale=10), nullable=True), + sa.Column("currency", sa.String(length=3), nullable=True), + sa.ForeignKeyConstraint( + ["account_id"], + ["account.id"], + name=op.f("fk_position_snapshot_account_id_account"), + ondelete="CASCADE", + ), + sa.ForeignKeyConstraint( + ["instrument_id"], + ["instrument.id"], + name=op.f("fk_position_snapshot_instrument_id_instrument"), + ondelete="CASCADE", + ), + sa.PrimaryKeyConstraint( + "account_id", "instrument_id", "as_of", "source", name=op.f("pk_position_snapshot") + ), + ) + op.create_table( + "price_daily", + sa.Column("instrument_id", sa.Integer(), nullable=False), + sa.Column("d", sa.Date(), nullable=False), + sa.Column("close", sa.Numeric(precision=24, scale=10), nullable=False), + sa.Column("open", sa.Numeric(precision=24, scale=10), nullable=True), + sa.Column("high", sa.Numeric(precision=24, scale=10), nullable=True), + sa.Column("low", sa.Numeric(precision=24, scale=10), nullable=True), + sa.Column("volume", sa.Numeric(precision=24, scale=10), nullable=True), + sa.Column("currency", sa.String(length=3), nullable=False), + sa.Column("source", sa.String(length=16), nullable=False), + sa.Column("price_pct", sa.Numeric(precision=24, scale=10), nullable=True), + sa.Column("accrued_interest", sa.Numeric(precision=24, scale=10), nullable=True), + sa.ForeignKeyConstraint( + ["instrument_id"], + ["instrument.id"], + name=op.f("fk_price_daily_instrument_id_instrument"), + ondelete="CASCADE", + ), + sa.PrimaryKeyConstraint("instrument_id", "d", name=op.f("pk_price_daily")), + ) + op.create_table( + "price_last", + sa.Column("instrument_id", sa.Integer(), nullable=False), + sa.Column("ts", sa.DateTime(timezone=True), nullable=False), + sa.Column("price", sa.Numeric(precision=24, scale=10), nullable=False), + sa.Column("currency", sa.String(length=3), nullable=False), + sa.Column("source", sa.String(length=16), nullable=False), + sa.ForeignKeyConstraint( + ["instrument_id"], + ["instrument.id"], + name=op.f("fk_price_last_instrument_id_instrument"), + ondelete="CASCADE", + ), + sa.PrimaryKeyConstraint("instrument_id", name=op.f("pk_price_last")), + ) + op.create_table( + "price_manual", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("instrument_id", sa.Integer(), nullable=False), + sa.Column("d", sa.Date(), nullable=False), + sa.Column("price", sa.Numeric(precision=24, scale=10), nullable=False), + sa.Column("currency", sa.String(length=3), nullable=False), + sa.Column("note", sa.String(length=256), nullable=True), + sa.ForeignKeyConstraint( + ["instrument_id"], + ["instrument.id"], + name=op.f("fk_price_manual_instrument_id_instrument"), + ondelete="CASCADE", + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_price_manual")), + ) + op.create_table( + "lot", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("account_id", sa.Integer(), nullable=False), + sa.Column("instrument_id", sa.Integer(), nullable=False), + sa.Column("open_event_id", sa.Integer(), nullable=False), + sa.Column("open_date", sa.Date(), nullable=False), + sa.Column("qty_open", sa.Numeric(precision=24, scale=10), nullable=False), + sa.Column("qty_remaining", sa.Numeric(precision=24, scale=10), nullable=False), + sa.Column("cost_per_unit", sa.Numeric(precision=24, scale=10), nullable=False), + sa.Column("cost_currency", sa.String(length=3), nullable=False), + sa.Column("cost_total_rub", sa.Numeric(precision=24, scale=10), nullable=True), + sa.Column("closed_at", sa.Date(), nullable=True), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.ForeignKeyConstraint( + ["account_id"], + ["account.id"], + name=op.f("fk_lot_account_id_account"), + ondelete="CASCADE", + ), + sa.ForeignKeyConstraint( + ["instrument_id"], + ["instrument.id"], + name=op.f("fk_lot_instrument_id_instrument"), + ondelete="CASCADE", + ), + sa.ForeignKeyConstraint( + ["open_event_id"], + ["event.id"], + name=op.f("fk_lot_open_event_id_event"), + ondelete="CASCADE", + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_lot")), + ) + op.create_index( + "ix_lot_account_instrument", "lot", ["account_id", "instrument_id"], unique=False + ) + op.create_table( + "lot_disposal", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("lot_id", sa.Integer(), nullable=False), + sa.Column("close_event_id", sa.Integer(), nullable=False), + sa.Column("close_date", sa.Date(), nullable=False), + sa.Column("qty", sa.Numeric(precision=24, scale=10), nullable=False), + sa.Column("proceeds", sa.Numeric(precision=24, scale=10), nullable=False), + sa.Column("proceeds_currency", sa.String(length=3), nullable=False), + sa.Column("proceeds_rub", sa.Numeric(precision=24, scale=10), nullable=True), + sa.Column("cost_rub", sa.Numeric(precision=24, scale=10), nullable=True), + sa.Column("realized_pnl_native", sa.Numeric(precision=24, scale=10), nullable=False), + sa.Column("realized_pnl_rub", sa.Numeric(precision=24, scale=10), nullable=True), + sa.Column("holding_days", sa.Integer(), nullable=False), + sa.Column("ldv_eligible", sa.Boolean(), nullable=False), + sa.ForeignKeyConstraint( + ["close_event_id"], + ["event.id"], + name=op.f("fk_lot_disposal_close_event_id_event"), + ondelete="CASCADE", + ), + sa.ForeignKeyConstraint( + ["lot_id"], ["lot.id"], name=op.f("fk_lot_disposal_lot_id_lot"), ondelete="CASCADE" + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_lot_disposal")), + ) + op.create_index("ix_lot_disposal_close_event", "lot_disposal", ["close_event_id"], unique=False) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index("ix_lot_disposal_close_event", table_name="lot_disposal") + op.drop_table("lot_disposal") + op.drop_index("ix_lot_account_instrument", table_name="lot") + op.drop_table("lot") + op.drop_table("price_manual") + op.drop_table("price_last") + op.drop_table("price_daily") + op.drop_table("position_snapshot") + op.drop_index("uq_event_dedupe_key", table_name="event") + op.drop_index("ix_event_instrument_trade_date", table_name="event") + op.drop_index(op.f("ix_event_group_id"), table_name="event") + op.drop_index("ix_event_account_trade_date", table_name="event") + op.drop_table("event") + op.drop_index("ix_corporate_action_pay_date", table_name="corporate_action") + op.drop_table("corporate_action") + op.drop_table("cash_snapshot") + op.drop_table("raw_tinvest_snapshot") + op.drop_index( + op.f("ix_raw_tinvest_operation_operation_type"), table_name="raw_tinvest_operation" + ) + op.drop_table("raw_tinvest_operation") + op.drop_index(op.f("ix_raw_tinvest_instrument_isin"), table_name="raw_tinvest_instrument") + op.drop_table("raw_tinvest_instrument") + op.drop_table("raw_tinvest_event") + # ### end Alembic commands ### + for enum_name in ( + "event_kind", + "event_status", + "corporate_action_kind", + "corporate_action_status", + ): + sa.Enum(name=enum_name).drop(op.get_bind(), checkfirst=True) diff --git a/backend/src/fintracker/ledger/__init__.py b/backend/src/fintracker/ledger/__init__.py new file mode 100644 index 0000000..c8c11f3 --- /dev/null +++ b/backend/src/fintracker/ledger/__init__.py @@ -0,0 +1 @@ +"""Derivations over the event ledger: FIFO lots, realised P&L, and source matching.""" diff --git a/backend/src/fintracker/ledger/lots.py b/backend/src/fintracker/ledger/lots.py new file mode 100644 index 0000000..452187a --- /dev/null +++ b/backend/src/fintracker/ledger/lots.py @@ -0,0 +1,299 @@ +"""FIFO lots and realised P&L (plan §1.5). + +`apply()` is a pure function over a sequence of ledger events: no session, no I/O, so the +rules below are testable on a handful of synthetic events. `rebuild_lots()` is the thin +wrapper that loads from the database, runs it, and writes `lot` / `lot_disposal` back. + +Rules, and why they are what they are: + +* **FIFO per (account, instrument)**, as art. 214.1 NK requires for Russian tax. The same + paper on two accounts is two independent queues — brokers compute tax per account. +* **Commissions are capitalised into the purchase and deducted from the proceeds.** That + is what makes realised P&L the number the tax authority expects, rather than a gross + figure that overstates the gain. +* **Accrued interest (НКД) is NOT part of the lot's cost.** It is money advanced to the + seller and returned through the next coupon; folding it into cost would understate coupon + income and overstate the gain on sale. It is tracked separately on the event. +* **A split rewrites the open lots** — quantity times the ratio, cost per unit divided by + it — so the total cost of a position never changes on a split. +* **Amortisation reduces cost per unit**, since part of the principal has come back; the + quantity is untouched. Without this, a fully amortised bond would show a phantom loss at + repayment. +* **Everything is rebuilt from scratch** on each refresh. The volumes are personal (a few + thousand events), the recompute costs seconds, and it removes the entire class of bugs + where an incremental update and the ledger disagree. + +* **Short positions are lots too.** A sale with nothing to consume opens a SHORT lot, and a + later purchase closes it — profit is the fall in price, the mirror of a long. Real data + has these: TATN was sold on 22.08 and bought back on 29.08. Treating the sale as an error + would leave a phantom long lot open forever and lose the realised gain. + +A short lot still open at the end of the replay is the ambiguous case: it is either an open +short or history that starts mid-way (a paper transferred in without a record). It is +reported as a `Shortfall` for `metric_data_quality` to surface rather than guessed at. +""" + +from __future__ import annotations + +from collections import defaultdict +from collections.abc import Sequence +from dataclasses import dataclass, field +from datetime import date +from decimal import Decimal + +from fintracker.models.ledger import EventKind + +ZERO = Decimal(0) +LDV_DAYS = 3 * 365 +"""Holding period qualifying for the long-term holding exemption (art. 219.1 NK).""" + + +@dataclass(frozen=True) +class LedgerEvent: + """What the lot engine needs from an `event` row — deliberately not the ORM object.""" + + id: int + account_id: int + instrument_id: int + kind: EventKind + trade_date: date + quantity: Decimal | None + """Signed by position effect, as stored.""" + price: Decimal | None + price_currency: str | None + amount: Decimal + currency: str + fee: Decimal | None + accrued_interest: Decimal | None = None + ratio: Decimal | None = None + """Split ratio; only read for `stock_split` events.""" + exchange_traded: bool = True + """False for OTC papers, which do not qualify for the LDV exemption.""" + + +@dataclass +class OpenLot: + account_id: int + instrument_id: int + open_event_id: int + open_date: date + qty_open: Decimal + """Always positive; `direction` says which way the position points.""" + qty_remaining: Decimal + cost_per_unit: Decimal + """For a short lot this is the price it was SOLD at — what the buy-back is measured against.""" + cost_currency: str + closed_at: date | None = None + exchange_traded: bool = True + direction: int = 1 + """+1 long, -1 short.""" + + @property + def signed_qty_remaining(self) -> Decimal: + """What the position actually is — negative while a short is open.""" + return self.qty_remaining * self.direction + + +@dataclass +class Disposal: + lot: OpenLot + close_event_id: int + close_date: date + qty: Decimal + proceeds: Decimal + proceeds_currency: str + cost: Decimal + """Cost of the disposed quantity, in the lot's own currency.""" + realized_pnl_native: Decimal + holding_days: int + ldv_eligible: bool + + +@dataclass +class Shortfall: + """A disposal that found no lot to consume — history is incomplete, not wrong.""" + + account_id: int + instrument_id: int + close_event_id: int + close_date: date + qty: Decimal + + +@dataclass +class LotResult: + lots: list[OpenLot] = field(default_factory=list) + disposals: list[Disposal] = field(default_factory=list) + shortfalls: list[Shortfall] = field(default_factory=list) + + +def apply(events: Sequence[LedgerEvent]) -> LotResult: + """Replay events in chronological order into open lots and disposals.""" + result = LotResult() + queues: dict[tuple[int, int], list[OpenLot]] = defaultdict(list) + + for event in sorted(events, key=_order): + key = (event.account_id, event.instrument_id) + queue = queues[key] + + if event.kind in {EventKind.buy, EventKind.transfer_in}: + _trade(event, queue, result, direction=1) + elif event.kind in {EventKind.sell, EventKind.transfer_out, EventKind.repayment}: + _trade(event, queue, result, direction=-1) + elif event.kind is EventKind.stock_split: + _split(event, queue) + elif event.kind is EventKind.amortization: + _amortise(event, queue) + + result.shortfalls = [ + Shortfall( + account_id=lot.account_id, + instrument_id=lot.instrument_id, + close_event_id=lot.open_event_id, + close_date=lot.open_date, + qty=lot.qty_remaining, + ) + for lot in result.lots + if lot.direction == -1 and lot.qty_remaining > ZERO + ] + return result + + +def _order(event: LedgerEvent) -> tuple[date, int, int]: + """Chronological, and within a day: opens before closes, then by event id. + + A same-day buy-then-sell must see the lot it created, and real feeds do not guarantee + the order they hand same-day operations over. + """ + opens_first = 0 if event.kind in {EventKind.buy, EventKind.transfer_in} else 1 + return (event.trade_date, opens_first, event.id) + + +def _trade(event: LedgerEvent, queue: list[OpenLot], result: LotResult, *, direction: int) -> None: + """One trade: first close whatever points the other way, then open a lot with the rest. + + Buying closes short lots before opening a long one, and selling closes long lots before + opening a short. That single rule covers ordinary round-trips and shorts alike. + """ + qty = abs(event.quantity or ZERO) + if qty == ZERO: + return + per_unit = _per_unit(event, qty, direction) + + remaining = _consume(event, queue, result, qty, per_unit, closing=-direction) + if remaining > ZERO: + lot = OpenLot( + account_id=event.account_id, + instrument_id=event.instrument_id, + open_event_id=event.id, + open_date=event.trade_date, + qty_open=remaining, + qty_remaining=remaining, + cost_per_unit=per_unit, + cost_currency=event.price_currency or event.currency, + exchange_traded=event.exchange_traded, + direction=direction, + ) + queue.append(lot) + result.lots.append(lot) + + +def _per_unit(event: LedgerEvent, qty: Decimal, direction: int) -> Decimal: + """Value per unit, fees included on the side they fall. + + A fee raises what a purchase costs and lowers what a sale nets — that asymmetry is why + a flat round-trip still shows a loss, which is what the tax code expects. + """ + return _cash_basis(event, direction) / qty if qty else ZERO + + +def _cash_basis(event: LedgerEvent, direction: int) -> Decimal: + """What the trade was worth per the cash that moved, accrued interest excluded. + + `amount` is preferred because it is what actually hit the account; the price path is the + fallback for feeds reporting no cash effect (a securities transfer). The fee is added on + a buy and subtracted on a sale — T-Invest reports it as a separate operation, so + `amount` does not already contain it either way. + """ + fee = abs(event.fee or ZERO) * direction + if event.amount: + gross = abs(event.amount) - abs(event.accrued_interest or ZERO) + return gross + fee + if event.price is not None: + return event.price * abs(event.quantity or ZERO) + fee + return ZERO + + +def _consume( + event: LedgerEvent, + queue: list[OpenLot], + result: LotResult, + qty: Decimal, + per_unit: Decimal, + *, + closing: int, +) -> Decimal: + """Close lots pointing in the `closing` direction, FIFO; return what is left over.""" + remaining = qty + for lot in queue: + if remaining <= ZERO: + break + if lot.qty_remaining <= ZERO or lot.direction != closing: + continue + taken = min(lot.qty_remaining, remaining) + lot.qty_remaining -= taken + remaining -= taken + if lot.qty_remaining == ZERO: + lot.closed_at = event.trade_date + + opened = lot.cost_per_unit * taken + closed = per_unit * taken + # long: bought at `opened`, sold at `closed`. short: sold at `opened`, bought at + # `closed` — so the profit is the fall in price, and the roles simply swap. + cost, proceeds = (opened, closed) if lot.direction == 1 else (closed, opened) + holding_days = (event.trade_date - lot.open_date).days + result.disposals.append( + Disposal( + lot=lot, + close_event_id=event.id, + close_date=event.trade_date, + qty=taken, + proceeds=proceeds, + proceeds_currency=event.price_currency or event.currency, + cost=cost, + realized_pnl_native=proceeds - cost, + holding_days=holding_days, + ldv_eligible=( + lot.direction == 1 and holding_days >= LDV_DAYS and lot.exchange_traded + ), + ) + ) + return remaining + + +def _split(event: LedgerEvent, queue: list[OpenLot]) -> None: + """Multiply quantities, divide cost per unit: the position's total cost is unchanged.""" + ratio = event.ratio + if not ratio or ratio <= ZERO: + return + for lot in queue: + if lot.qty_remaining <= ZERO: + continue + lot.qty_open *= ratio + lot.qty_remaining *= ratio + lot.cost_per_unit /= ratio + + +def _amortise(event: LedgerEvent, queue: list[OpenLot]) -> None: + """Return of principal lowers what is still invested, proportionally across open lots.""" + paid = abs(event.amount) + open_qty = sum((lot.qty_remaining for lot in queue if lot.qty_remaining > ZERO), start=ZERO) + if paid == ZERO or open_qty == ZERO: + return + per_unit = paid / open_qty + for lot in queue: + if lot.qty_remaining <= ZERO: + continue + # never below zero: an amortisation bigger than the remaining cost means the cost + # basis is already exhausted, and the excess is income, not a negative asset + lot.cost_per_unit = max(lot.cost_per_unit - per_unit, ZERO) diff --git a/backend/src/fintracker/ledger/rebuild.py b/backend/src/fintracker/ledger/rebuild.py new file mode 100644 index 0000000..c356378 --- /dev/null +++ b/backend/src/fintracker/ledger/rebuild.py @@ -0,0 +1,193 @@ +"""Rebuild `lot` / `lot_disposal` from the event ledger (plan §1.5). + +The engine in `lots.py` is pure; this module is the part that talks to the database: it +loads confirmed events, runs the replay, converts to RUB at the rate of each leg's own date, +and replaces the tables wholesale. + +Currency handling follows the project rule: values are kept in the instrument's own currency +and converted at the rate **of the date the money moved** — cost at the lot's open date, +proceeds at the disposal date. A leg with no rate for its day yields NULL rather than a +substituted number, and the gap is reported to data quality. +""" + +from __future__ import annotations + +import logging +from decimal import Decimal + +from sqlalchemy import delete, insert, select +from sqlalchemy.ext.asyncio import AsyncSession + +from fintracker.analytics import FINDINGS +from fintracker.ledger.lots import LedgerEvent, LotResult, apply +from fintracker.models import Event, EventStatus, Instrument, Lot, LotDisposal +from fintracker.models.ledger import POSITION_KINDS, EventKind +from fintracker.models.pricing import CorporateAction, CorporateActionKind +from fintracker.pricing.fx import FxTable + +log = logging.getLogger(__name__) + +#: Kinds the engine reads: position moves plus the corporate actions that reshape lots. +RELEVANT_KINDS = POSITION_KINDS | {EventKind.amortization} + +#: Asset classes that can qualify for the long-term holding exemption. +EXCHANGE_CLASSES = {"share", "bond", "etf", "fund"} + + +async def rebuild_lots(session: AsyncSession) -> None: + """Replace `lot` and `lot_disposal` from scratch, and report what did not add up.""" + events = await _load_events(session) + if not events: + await session.execute(delete(LotDisposal)) + await session.execute(delete(Lot)) + return + + result = apply(events) + fx = await FxTable.load(session) + + await session.execute(delete(LotDisposal)) + await session.execute(delete(Lot)) + + lot_ids: dict[int, int] = {} + for lot in result.lots: + cost_total = lot.cost_per_unit * lot.qty_open + row = { + "account_id": lot.account_id, + "instrument_id": lot.instrument_id, + "open_event_id": lot.open_event_id, + "open_date": lot.open_date, + # stored signed, so summing qty_remaining gives the position directly — a short + # lot must subtract from it rather than look like a holding + "qty_open": lot.qty_open * lot.direction, + "qty_remaining": lot.signed_qty_remaining, + "cost_per_unit": lot.cost_per_unit, + "cost_currency": lot.cost_currency, + "cost_total_rub": fx.to_rub(cost_total, lot.cost_currency, lot.open_date), + "closed_at": lot.closed_at, + } + new_id = (await session.execute(insert(Lot).values(row).returning(Lot.id))).scalar_one() + lot_ids[id(lot)] = new_id + + disposal_rows = [] + for disposal in result.disposals: + cost_rub = fx.to_rub(disposal.cost, disposal.lot.cost_currency, disposal.lot.open_date) + proceeds_rub = fx.to_rub(disposal.proceeds, disposal.proceeds_currency, disposal.close_date) + disposal_rows.append( + { + "lot_id": lot_ids[id(disposal.lot)], + "close_event_id": disposal.close_event_id, + "close_date": disposal.close_date, + "qty": disposal.qty, + "proceeds": disposal.proceeds, + "proceeds_currency": disposal.proceeds_currency, + "proceeds_rub": proceeds_rub, + "cost_rub": cost_rub, + "realized_pnl_native": disposal.realized_pnl_native, + # both legs converted at their own dates; either missing makes it unknowable + "realized_pnl_rub": ( + proceeds_rub - cost_rub + if proceeds_rub is not None and cost_rub is not None + else None + ), + "holding_days": disposal.holding_days, + "ldv_eligible": disposal.ldv_eligible, + } + ) + if disposal_rows: + await session.execute(insert(LotDisposal), disposal_rows) + + _report(result) + log.info( + "lots: %s lots, %s disposals, %s shortfalls", + len(result.lots), + len(result.disposals), + len(result.shortfalls), + ) + + +async def _load_events(session: AsyncSession) -> list[LedgerEvent]: + """Confirmed, instrument-bearing events, with the instrument facts the engine needs.""" + rows = ( + await session.execute( + select( + Event.id, + Event.account_id, + Event.instrument_id, + Event.kind, + Event.trade_date, + Event.quantity, + Event.price, + Event.price_currency, + Event.amount, + Event.currency, + Event.fee, + Event.accrued_interest, + Instrument.asset_class, + Instrument.board, + ) + .join(Instrument, Instrument.id == Event.instrument_id) + .where( + Event.status == EventStatus.confirmed, + Event.kind.in_(RELEVANT_KINDS), + Event.instrument_id.is_not(None), + ) + ) + ).all() + + ratios = await _split_ratios(session) + return [ + LedgerEvent( + id=r.id, + account_id=r.account_id, + instrument_id=r.instrument_id, + kind=r.kind, + trade_date=r.trade_date, + quantity=r.quantity, + price=r.price, + price_currency=r.price_currency, + amount=r.amount or Decimal(0), + currency=r.currency, + fee=r.fee, + accrued_interest=r.accrued_interest, + ratio=ratios.get((r.instrument_id, r.trade_date)), + exchange_traded=str(r.asset_class) in EXCHANGE_CLASSES and bool(r.board), + ) + for r in rows + ] + + +async def _split_ratios(session: AsyncSession) -> dict[tuple[int, object], Decimal]: + """Split ratios by (instrument, date) — the engine cannot infer them from an event.""" + rows = ( + await session.execute( + select( + CorporateAction.instrument_id, CorporateAction.ex_date, CorporateAction.ratio + ).where( + CorporateAction.kind == CorporateActionKind.stock_split, + CorporateAction.ratio.is_not(None), + ) + ) + ).all() + return {(r.instrument_id, r.ex_date): r.ratio for r in rows if r.ex_date} + + +def _report(result: LotResult) -> None: + """Surface what the replay could not reconcile, aggregated per instrument.""" + if result.shortfalls: + per_instrument: dict[int, Decimal] = {} + for shortfall in result.shortfalls: + per_instrument[shortfall.instrument_id] = ( + per_instrument.get(shortfall.instrument_id, Decimal(0)) + shortfall.qty + ) + FINDINGS.add( + "lot_shortfall", + "warn", + f"Продано больше, чем числится в лотах, по {len(per_instrument)} инструментам — " + "история неполная (перевод бумаг или сделки до начала выгрузки)", + count=len(result.shortfalls), + ref={"instruments": sorted(per_instrument)}, + ) + + missing_fx = sum(1 for lot in result.lots if lot.cost_currency not in {"RUB", "rub"}) + if missing_fx: + log.debug("lots: %s lots in a foreign currency", missing_fx) diff --git a/backend/src/fintracker/models/ledger.py b/backend/src/fintracker/models/ledger.py new file mode 100644 index 0000000..6da9723 --- /dev/null +++ b/backend/src/fintracker/models/ledger.py @@ -0,0 +1,182 @@ +"""The single event ledger and FIFO lots (plan §1.4, §1.5). + +Every broker source maps into `event`: T-Invest operations, Sber/VTB report lines, manual +entries. Day-to-day ZenMoney spending stays in `cash_txn` — it is not an investment event. + +Sign conventions, so a reader never has to guess: + +* `quantity` is signed by its effect on the POSITION: + into it, - out of it. +* `amount` is signed by its effect on the ACCOUNT's cash: + received, - paid. + A buy therefore has quantity > 0 and amount < 0. +* `fee` and `tax` are stored positive (they are already reflected inside `amount`), so + summing them never double-counts against the cash effect. + +`dedupe_key` is what makes re-importing safe: every source builds a stable key from its own +identifiers (a T-Invest operation id, a Sber deal number), and the unique index turns a +repeated import into a no-op instead of a doubled ledger. +""" + +from __future__ import annotations + +import enum +import uuid +from datetime import date, datetime +from decimal import Decimal +from typing import Any + +from sqlalchemy import ForeignKey, Index, Integer, String, Text +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column + +from fintracker.db.base import Base, TimestampMixin, db_enum + + +class EventKind(enum.StrEnum): + buy = "buy" + sell = "sell" + dividend = "dividend" + coupon = "coupon" + interest = "interest" + tax = "tax" + tax_refund = "tax_refund" + commission = "commission" + deposit = "deposit" + withdrawal = "withdrawal" + transfer_in = "transfer_in" + transfer_out = "transfer_out" + stock_split = "split" + amortization = "amortization" + repayment = "repayment" + fx_exchange = "fx_exchange" + other = "other" + + +#: Kinds that move money across the portfolio boundary — the cash flows XIRR is computed on. +#: Securities transferred in or out count too, valued at the market on their date. +EXTERNAL_FLOW_KINDS = frozenset( + { + EventKind.deposit, + EventKind.withdrawal, + EventKind.transfer_in, + EventKind.transfer_out, + } +) + +#: Kinds that change a position's quantity, i.e. what `ledger/lots.py` consumes. +POSITION_KINDS = frozenset( + { + EventKind.buy, + EventKind.sell, + EventKind.transfer_in, + EventKind.transfer_out, + EventKind.stock_split, + EventKind.repayment, + } +) + + +class EventStatus(enum.StrEnum): + confirmed = "confirmed" + """The truth for analytics — nothing else is read by a metric.""" + pending = "pending" + """Parsed but not committed yet (an import awaiting the user's confirmation).""" + shadow = "shadow" + """A duplicate from a secondary source, kept for reconciliation only.""" + ignored = "ignored" + + +class Event(TimestampMixin, Base): + __tablename__ = "event" + __table_args__ = ( + Index("uq_event_dedupe_key", "dedupe_key", unique=True), + Index("ix_event_account_trade_date", "account_id", "trade_date"), + Index("ix_event_instrument_trade_date", "instrument_id", "trade_date"), + ) + + id: Mapped[int] = mapped_column(primary_key=True) + account_id: Mapped[int] = mapped_column(ForeignKey("account.id", ondelete="CASCADE")) + instrument_id: Mapped[int | None] = mapped_column( + ForeignKey("instrument.id", ondelete="RESTRICT") + ) + kind: Mapped[EventKind] = mapped_column(db_enum(EventKind, "event_kind")) + status: Mapped[EventStatus] = mapped_column( + db_enum(EventStatus, "event_status"), default=EventStatus.confirmed + ) + + ts: Mapped[datetime] + trade_date: Mapped[date] + """Trade date in MSK — the date every metric groups by, and the FX rate's date.""" + settle_date: Mapped[date | None] + + quantity: Mapped[Decimal | None] + """Signed by position effect: + into the position, - out of it.""" + price: Mapped[Decimal | None] + price_currency: Mapped[str | None] = mapped_column(String(3)) + amount: Mapped[Decimal] + """Signed cash effect on the account: + received, - paid.""" + currency: Mapped[str] = mapped_column(String(3)) + fee: Mapped[Decimal | None] + fee_currency: Mapped[str | None] = mapped_column(String(3)) + tax: Mapped[Decimal | None] + tax_currency: Mapped[str | None] = mapped_column(String(3)) + accrued_interest: Mapped[Decimal | None] + """NKD paid (buy) or received (sell) on top of the bond's clean price.""" + + group_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), index=True) + """Ties the legs of one economic event together, e.g. both sides of an FX exchange.""" + + source: Mapped[str] = mapped_column(String(32)) + """tinvest | report_sber | report_vtb | csv | manual""" + source_id: Mapped[str | None] = mapped_column(String(128)) + raw_ref: Mapped[str | None] = mapped_column(String(128)) + dedupe_key: Mapped[str] = mapped_column(String(256)) + description: Mapped[str | None] = mapped_column(Text) + meta: Mapped[dict[str, Any] | None] + + +class Lot(TimestampMixin, Base): + """One purchase kept open until sold, FIFO per (account, instrument) — art. 214.1 NK. + + Rebuilt from scratch on every refresh by `ledger/lots.py`: the volumes are personal, so a + full recompute costs seconds and removes a whole class of incremental-update bugs. + """ + + __tablename__ = "lot" + __table_args__ = (Index("ix_lot_account_instrument", "account_id", "instrument_id"),) + + id: Mapped[int] = mapped_column(primary_key=True) + account_id: Mapped[int] = mapped_column(ForeignKey("account.id", ondelete="CASCADE")) + instrument_id: Mapped[int] = mapped_column(ForeignKey("instrument.id", ondelete="CASCADE")) + open_event_id: Mapped[int] = mapped_column(ForeignKey("event.id", ondelete="CASCADE")) + open_date: Mapped[date] + qty_open: Mapped[Decimal] + qty_remaining: Mapped[Decimal] + cost_per_unit: Mapped[Decimal] + """Purchase price plus capitalised fees, per unit, in `cost_currency`.""" + cost_currency: Mapped[str] = mapped_column(String(3)) + cost_total_rub: Mapped[Decimal | None] + """Cost in RUB at the CBR rate of `open_date`; NULL when that day has no rate.""" + closed_at: Mapped[date | None] + + +class LotDisposal(Base): + """A sale (or repayment) consuming part of a lot: the realised-P&L and tax record.""" + + __tablename__ = "lot_disposal" + __table_args__ = (Index("ix_lot_disposal_close_event", "close_event_id"),) + + id: Mapped[int] = mapped_column(primary_key=True) + lot_id: Mapped[int] = mapped_column(ForeignKey("lot.id", ondelete="CASCADE")) + close_event_id: Mapped[int] = mapped_column(ForeignKey("event.id", ondelete="CASCADE")) + close_date: Mapped[date] + qty: Mapped[Decimal] + proceeds: Mapped[Decimal] + proceeds_currency: Mapped[str] = mapped_column(String(3)) + proceeds_rub: Mapped[Decimal | None] + cost_rub: Mapped[Decimal | None] + realized_pnl_native: Mapped[Decimal] + realized_pnl_rub: Mapped[Decimal | None] + """NULL when either leg lacked an FX rate — never a substituted number.""" + holding_days: Mapped[int] = mapped_column(Integer) + ldv_eligible: Mapped[bool] + """Held 3+ years on an exchange-traded instrument (art. 219.1 NK).""" diff --git a/backend/src/fintracker/models/tinvest.py b/backend/src/fintracker/models/tinvest.py new file mode 100644 index 0000000..71fcbea --- /dev/null +++ b/backend/src/fintracker/models/tinvest.py @@ -0,0 +1,71 @@ +"""Raw T-Invest payloads (plan §1.1): append-only, idempotent on the source's own ids. + +Keeping the untouched payload means a mapping bug is fixable by re-deriving `event` from +what is already stored, without asking the API for a full history again — which matters +under a 200 req/min limit on Operations. +""" + +from __future__ import annotations + +from datetime import date, datetime +from typing import Any + +from sqlalchemy import String, func +from sqlalchemy.orm import Mapped, mapped_column + +from fintracker.db.base import Base + + +class RawTinvestOperation(Base): + """One `OperationItem` from GetOperationsByCursor, as returned.""" + + __tablename__ = "raw_tinvest_operation" + + account_id: Mapped[str] = mapped_column(String(64), primary_key=True) + """T-Invest account id (the broker's own string), not our `account.id`.""" + id: Mapped[str] = mapped_column(String(64), primary_key=True) + operation_type: Mapped[str] = mapped_column(String(64), index=True) + ts: Mapped[datetime] + payload: Mapped[dict[str, Any]] + fetched_at: Mapped[datetime] = mapped_column(server_default=func.now()) + + +class RawTinvestSnapshot(Base): + """A GetPortfolio / GetPositions capture — the reconciliation counterpart of the ledger.""" + + __tablename__ = "raw_tinvest_snapshot" + + account_id: Mapped[str] = mapped_column(String(64), primary_key=True) + kind: Mapped[str] = mapped_column(String(16), primary_key=True) + """portfolio | positions""" + captured_at: Mapped[datetime] = mapped_column(primary_key=True) + payload: Mapped[dict[str, Any]] + + +class RawTinvestInstrument(Base): + """An instrument as the API describes it, keyed by its stable uid.""" + + __tablename__ = "raw_tinvest_instrument" + + uid: Mapped[str] = mapped_column(String(64), primary_key=True) + kind: Mapped[str] = mapped_column(String(16)) + """share | bond | etf | currency | future""" + isin: Mapped[str | None] = mapped_column(String(12), index=True) + figi: Mapped[str | None] = mapped_column(String(12)) + ticker: Mapped[str | None] = mapped_column(String(32)) + payload: Mapped[dict[str, Any]] + fetched_at: Mapped[datetime] = mapped_column(server_default=func.now()) + + +class RawTinvestEvent(Base): + """Dividends, coupons, bond events and fundamentals, per instrument.""" + + __tablename__ = "raw_tinvest_event" + + instrument_uid: Mapped[str] = mapped_column(String(64), primary_key=True) + kind: Mapped[str] = mapped_column(String(24), primary_key=True) + """dividend | coupon | bond_event | fundamental""" + source_id: Mapped[str] = mapped_column(String(128), primary_key=True) + event_date: Mapped[date | None] + payload: Mapped[dict[str, Any]] + fetched_at: Mapped[datetime] = mapped_column(server_default=func.now()) diff --git a/backend/tests/ledger/test_lots.py b/backend/tests/ledger/test_lots.py new file mode 100644 index 0000000..a6ebb21 --- /dev/null +++ b/backend/tests/ledger/test_lots.py @@ -0,0 +1,243 @@ +"""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)