Files
fin-tracker/backend/src/fintracker/models/ledger.py
T
Dmitry e974ea9ffa feat(db): flow_link, metric_cash_flow_broker и price_coverage
Три таблицы одной миграцией, а не тремя: автогенерация из трёх параллельных веток
дала бы три ревизии с общим down_revision, то есть ручную разборку ветвления вместо
экономии.

price_coverage засевается прямо в миграции из price_daily: она отвечает на вопрос «с
какой даты мы УЖЕ спрашивали ISS», и без засева первый же прогон moex перекачал бы
историю всех 77 бумаг целиком.

flow_link_kind снимается на откате явно. DROP TABLE оставляет тип в базе, и следующий
upgrade упал бы на CREATE TYPE — то же, что уже сделано для остальных енумов домена.
2026-09-18 15:05:36 +03:00

223 lines
8.9 KiB
Python

"""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)."""
class FlowLinkKind(enum.StrEnum):
auto = "auto"
"""Produced by `ledger/matching.py`; rebuilt from scratch on every refresh."""
manual = "manual"
"""Confirmed by the user through the API; never touched by the matcher."""
class FlowLink(TimestampMixin, Base):
"""One ZenMoney transfer tied to the broker deposit/withdrawal it actually was (plan §1.6 C).
Without this pairing the same money is counted twice — once as the balance of the ZenMoney
account that mirrors the broker, once as the broker's own cash — and a top-up looks like an
expense in the cash flow. The unique constraints on both sides are what make the link a
1:1 statement: one transfer, one broker event, never a fan-out.
The scoring fields are kept because a link is a *guess*: `amount_delta` and `day_gap` are
what the reviewer needs in `GET /links/unmatched` to tell a good pairing from a lucky one.
"""
__tablename__ = "flow_link"
id: Mapped[int] = mapped_column(primary_key=True)
cash_txn_id: Mapped[int] = mapped_column(
ForeignKey("cash_txn.id", ondelete="CASCADE"), unique=True
)
event_id: Mapped[int] = mapped_column(ForeignKey("event.id", ondelete="CASCADE"), unique=True)
kind: Mapped[FlowLinkKind] = mapped_column(
db_enum(FlowLinkKind, "flow_link_kind"), default=FlowLinkKind.auto
)
confidence: Mapped[Decimal]
"""0..1; 1 means same day and the same kopeck."""
amount_delta: Mapped[Decimal]
"""|ZenMoney amount - broker amount|, in `currency` — both sides share it by construction."""
currency: Mapped[str] = mapped_column(String(3))
day_gap: Mapped[int] = mapped_column(Integer)
"""Business days between the two dates: money does not reach a broker over a weekend."""
note: Mapped[str | None] = mapped_column(Text)
"""Why the pair was accepted (which route identified the broker account), for debugging."""