Files
fin-tracker/backend/alembic/versions/b7424afbb5e2_метрики_оценки_холдингов_и_доходности.py
T
Dmitry c203ae65fc feat(analytics): оценка позиций, XIRR и TWR
pricing/prices.py — цена на дату: последний close тянется вперёд, после 10 дней
считается протухшей, но всё ещё используется. Назад не тянется никогда — цена из
будущего это выдумка, а не оценка.

valuation берёт два разных источника намеренно. Дневная серия — реплей event
(только он отвечает, сколько стоило в марте), текущие холдинги — из lot, где
есть себестоимость и учтены сплиты. Расхождение между ними на последний день
становится находкой, а не поводом выбрать одно из двух. Отчётная единица —
scope: all, account:<id>, portfolio:<id>.

returns читает только metric_portfolio_value_daily. XIRR — по внешним потокам и
терминальной стоимости; TWR — цепочкой V_t / (V_{t-1} + F_t). План пишет формулу
как (V_t - F_t) / V_{t-1}, то есть с потоком в конце дня; поток в начале даёт то
же число при нулевом потоке, не требует особого случая на первый день и относит
движение рынка к деньгам, которые в этот день уже работали.

Покупка бумаги без цены трактуется как вывод из оцениваемого портфеля
(unvalued_flow_rub): иначе деньги уходят из оценки, а бумага в неё не попадает,
и день читается как обвал — именно это фонд денежного рынка без фида MOEX
устроил серии 2024 года. Пропускается только день, в который меняется ЧИСЛО
неоценённых позиций, и их счётчик уходит в metric_data_quality.

Нет цены или курса — NULL и замечание, не ноль: SIBN6P4 в холдингах именно так и
выглядит. Валютные «позиции» из сверки исключены, их двойник — cash_snapshot, а
не лот; после этого расхождений с брокером ровно пять известных.

Проверка из плана закрыта тестами: взнос 100 и 110 через год дают XIRR 10,0 % и
TWR 10,0 %, второй взнос двигает XIRR и не трогает TWR подпериодов.
2026-09-18 13:44:50 +03:00

125 lines
6.2 KiB
Python

"""метрики оценки, холдингов и доходности
Revision ID: b7424afbb5e2
Revises: 3b1431df06af
Create Date: 2026-09-18 13:28:43.383174
"""
from __future__ import annotations
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "b7424afbb5e2"
down_revision: str | None = "3b1431df06af"
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(
"metric_portfolio_value_daily",
sa.Column("scope", sa.String(length=32), nullable=False),
sa.Column("d", sa.Date(), nullable=False),
sa.Column("market_value_rub", sa.Numeric(precision=24, scale=10), nullable=False),
sa.Column("accrued_interest_rub", sa.Numeric(precision=24, scale=10), nullable=False),
sa.Column("cash_rub", sa.Numeric(precision=24, scale=10), nullable=False),
sa.Column("total_rub", sa.Numeric(precision=24, scale=10), nullable=False),
sa.Column("external_flow_rub", sa.Numeric(precision=24, scale=10), nullable=False),
sa.Column("unvalued_flow_rub", sa.Numeric(precision=24, scale=10), nullable=False),
sa.Column("invested_net_rub", sa.Numeric(precision=24, scale=10), nullable=False),
sa.Column("pnl_total_rub", sa.Numeric(precision=24, scale=10), nullable=True),
sa.Column("stale_price_count", sa.Integer(), nullable=False),
sa.Column("missing_price_count", sa.Integer(), nullable=False),
sa.Column("missing_fx_count", sa.Integer(), nullable=False),
sa.Column(
"computed_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.PrimaryKeyConstraint("scope", "d", name=op.f("pk_metric_portfolio_value_daily")),
)
op.create_table(
"metric_returns",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("scope", sa.String(length=32), nullable=False),
sa.Column("period", sa.String(length=8), nullable=False),
sa.Column("date_from", sa.Date(), nullable=False),
sa.Column("date_to", sa.Date(), nullable=False),
sa.Column("value_start_rub", sa.Numeric(precision=24, scale=10), nullable=False),
sa.Column("value_end_rub", sa.Numeric(precision=24, scale=10), nullable=False),
sa.Column("external_flow_rub", sa.Numeric(precision=24, scale=10), nullable=False),
sa.Column("abs_pnl_rub", sa.Numeric(precision=24, scale=10), nullable=False),
sa.Column("xirr", sa.Numeric(precision=24, scale=10), nullable=True),
sa.Column("twr", sa.Numeric(precision=24, scale=10), nullable=True),
sa.Column("twr_annualized", sa.Numeric(precision=24, scale=10), nullable=True),
sa.Column("twr_days_skipped", sa.Integer(), nullable=False),
sa.Column(
"computed_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_metric_returns")),
sa.UniqueConstraint("scope", "period", name=op.f("uq_metric_returns_scope_period")),
)
op.create_index(op.f("ix_metric_returns_scope"), "metric_returns", ["scope"], unique=False)
op.create_table(
"metric_holding",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("scope", sa.String(length=32), nullable=False),
sa.Column("instrument_id", sa.Integer(), nullable=False),
sa.Column("qty", sa.Numeric(precision=24, scale=10), nullable=False),
sa.Column("avg_cost", sa.Numeric(precision=24, scale=10), nullable=True),
sa.Column("cost_currency", sa.String(length=3), nullable=True),
sa.Column("cost_total_rub", sa.Numeric(precision=24, scale=10), nullable=True),
sa.Column("market_price", sa.Numeric(precision=24, scale=10), nullable=True),
sa.Column("price_currency", sa.String(length=3), nullable=True),
sa.Column("price_date", sa.Date(), nullable=True),
sa.Column("price_status", sa.String(length=8), nullable=False),
sa.Column("value_native", sa.Numeric(precision=24, scale=10), nullable=True),
sa.Column("value_rub", sa.Numeric(precision=24, scale=10), nullable=True),
sa.Column("accrued_interest_rub", sa.Numeric(precision=24, scale=10), nullable=True),
sa.Column("unrealized_pnl_native", sa.Numeric(precision=24, scale=10), nullable=True),
sa.Column("unrealized_pnl_rub", sa.Numeric(precision=24, scale=10), nullable=True),
sa.Column("realized_pnl_rub", sa.Numeric(precision=24, scale=10), nullable=True),
sa.Column("income_rub", sa.Numeric(precision=24, scale=10), nullable=True),
sa.Column("weight", sa.Numeric(precision=24, scale=10), nullable=True),
sa.Column("xirr", sa.Numeric(precision=24, scale=10), nullable=True),
sa.Column("first_buy_date", sa.Date(), nullable=True),
sa.Column("days_held", sa.Integer(), nullable=True),
sa.Column("ldv_eligible_qty", sa.Numeric(precision=24, scale=10), nullable=False),
sa.Column(
"computed_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.ForeignKeyConstraint(
["instrument_id"],
["instrument.id"],
name=op.f("fk_metric_holding_instrument_id_instrument"),
ondelete="CASCADE",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_metric_holding")),
sa.UniqueConstraint(
"scope", "instrument_id", name=op.f("uq_metric_holding_scope_instrument_id")
),
)
op.create_index(op.f("ix_metric_holding_scope"), "metric_holding", ["scope"], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f("ix_metric_holding_scope"), table_name="metric_holding")
op.drop_table("metric_holding")
op.drop_index(op.f("ix_metric_returns_scope"), table_name="metric_returns")
op.drop_table("metric_returns")
op.drop_table("metric_portfolio_value_daily")
# ### end Alembic commands ###