feat(ledger): единый леджер событий и FIFO-лоты

Одна таблица event, куда маппится каждый брокерский источник: quantity знаковый
по эффекту на позицию, amount — по эффекту на кэш, dedupe_key UNIQUE делает
повторный импорт пустой операцией. Аналитика читает только confirmed.

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

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

Всё пересобирается с нуля на каждом refresh: объёмы личные, это секунды, зато
исчезает целый класс багов расхождения инкремента с леджером.
This commit is contained in:
Dmitry
2026-09-18 13:44:31 +03:00
parent 90ba198c2a
commit 012a40981f
7 changed files with 1433 additions and 0 deletions
@@ -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)