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