feat(analytics): доходы, ребалансировка, налоги, бенчмарки и цели — фаза 4

Второй источник выплат: sources/tinvest/sync_events.py (GetDividends,
GetBondCoupons, GetBondEvents) и sources/moex/payouts.py (ISS bondization +
dividends). Приоритет между ними — pricing/payouts.resolve_payouts, решается
на чтении, а не на записи: corporate_action уникален по (instrument_id, kind,
source, source_id), обе версии сосуществуют, и правило можно поменять без
ресинка истории. Амортизация от MOEX идёт в bond_nominal_schedule, а не
в corporate_action — этим типом безраздельно владеет
ledger/corporate_actions.py.

analytics/income.py — metric_income_monthly (факт) и metric_income_calendar
(прошлое и прогноз) с basis paid/announced/history на каждой строке, три
источника числа не смешиваются. analytics/rebalance.py — сделки по
portfolio_target пропорционально внутри бакета, лоты только вниз, покупки не
занимают у ещё не свершившихся продаж. analytics/tax.py — оценка, не замена
справки брокера: дивиденды/купоны gross, реализованный результат из
lot_disposal с переоценкой каждой ноги на свою дату. analytics/benchmarks.py —
TWR индекса на сетке портфеля, kind (price/total_return) не скрывается.
analytics/goals.py — прогресс цели и нужный взнос по trailing XIRR.

Четыре шага зарегистрированы в register_steps: benchmarks после returns
(общая сетка дат), rebalance после allocation (её веса, не пересчитывает),
income и tax после lots (нужен lot_disposal).
This commit is contained in:
Dmitry
2026-09-19 10:42:50 +03:00
parent ff3b76871d
commit 15f5812ea4
42 changed files with 10607 additions and 3 deletions
@@ -0,0 +1,417 @@
"""фаза 4: доходы, ребалансировка, бенчмарки, цели, налоги
Revision ID: 2bf84b07fd5e
Revises: ee170b3e7872
Create Date: 2026-09-18 18:41:12.207755
"""
from __future__ import annotations
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
# `allocation_dimension` already exists (фаза 2, metric_allocation). Declaring it with
# `create_type=False` reuses it instead of re-issuing CREATE TYPE, which would fail on any
# re-upgrade — the type outlives a downgrade because `metric_allocation` still uses it.
revision: str = "2bf84b07fd5e"
down_revision: str | None = "ee170b3e7872"
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(
"goal",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("name", sa.String(length=128), nullable=False),
sa.Column("scope", sa.String(length=32), nullable=False),
sa.Column("target_amount", sa.Numeric(precision=24, scale=10), nullable=False),
sa.Column("currency", sa.String(length=3), nullable=False),
sa.Column("target_date", sa.Date(), nullable=True),
sa.Column("monthly_contribution", sa.Numeric(precision=24, scale=10), nullable=True),
sa.Column("note", sa.Text(), nullable=True),
sa.Column("archived", sa.Boolean(), nullable=False),
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.PrimaryKeyConstraint("id", name=op.f("pk_goal")),
sa.UniqueConstraint("name", name=op.f("uq_goal_name")),
)
op.create_table(
"metric_income_monthly",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("scope", sa.String(length=32), nullable=False),
sa.Column("month", sa.Date(), nullable=False),
sa.Column("kind", sa.String(length=16), nullable=False),
sa.Column("currency", sa.String(length=3), nullable=False),
sa.Column("amount", sa.Numeric(precision=24, scale=10), nullable=False),
sa.Column("amount_rub", sa.Numeric(precision=24, scale=10), nullable=True),
sa.Column("tax_withheld", sa.Numeric(precision=24, scale=10), nullable=False),
sa.Column("payment_count", 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_income_monthly")),
sa.UniqueConstraint(
"scope",
"month",
"kind",
"currency",
name=op.f("uq_metric_income_monthly_scope_month_kind_currency"),
),
)
op.create_index(
op.f("ix_metric_income_monthly_scope"), "metric_income_monthly", ["scope"], unique=False
)
op.create_table(
"benchmark",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("code", sa.String(length=32), nullable=False),
sa.Column("name", sa.String(length=128), nullable=False),
sa.Column("kind", sa.Enum("price", "total_return", name="benchmark_kind"), nullable=False),
sa.Column("instrument_id", sa.Integer(), nullable=True),
sa.Column("source", sa.String(length=16), nullable=False),
sa.Column("currency", sa.String(length=3), nullable=False),
sa.Column("is_default", sa.Boolean(), nullable=False),
sa.Column("is_active", sa.Boolean(), nullable=False),
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_benchmark_instrument_id_instrument"),
ondelete="SET NULL",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_benchmark")),
sa.UniqueConstraint("code", name=op.f("uq_benchmark_code")),
)
op.create_table(
"bond_nominal_schedule",
sa.Column("instrument_id", sa.Integer(), nullable=False),
sa.Column("effective_date", sa.Date(), nullable=False),
sa.Column("nominal", 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_bond_nominal_schedule_instrument_id_instrument"),
ondelete="CASCADE",
),
sa.PrimaryKeyConstraint(
"instrument_id", "effective_date", name=op.f("pk_bond_nominal_schedule")
),
)
op.create_table(
"metric_goal_progress",
sa.Column("goal_id", sa.Integer(), nullable=False),
sa.Column("as_of", sa.Date(), nullable=False),
sa.Column("current_value_rub", sa.Numeric(precision=24, scale=10), nullable=False),
sa.Column("target_amount_rub", sa.Numeric(precision=24, scale=10), nullable=False),
sa.Column("progress", sa.Numeric(precision=24, scale=10), nullable=False),
sa.Column("projected_date", sa.Date(), nullable=True),
sa.Column("basis", sa.String(length=16), nullable=False),
sa.Column("assumed_rate", sa.Numeric(precision=24, scale=10), nullable=True),
sa.Column("monthly_needed_rub", sa.Numeric(precision=24, scale=10), nullable=True),
sa.Column("on_track", sa.Boolean(), nullable=True),
sa.Column(
"computed_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.ForeignKeyConstraint(
["goal_id"],
["goal.id"],
name=op.f("fk_metric_goal_progress_goal_id_goal"),
ondelete="CASCADE",
),
sa.PrimaryKeyConstraint("goal_id", name=op.f("pk_metric_goal_progress")),
)
op.create_table(
"metric_income_calendar",
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("kind", sa.String(length=16), nullable=False),
sa.Column("expected_date", sa.Date(), nullable=False),
sa.Column("record_date", sa.Date(), nullable=True),
sa.Column("qty", sa.Numeric(precision=24, scale=10), nullable=False),
sa.Column("per_unit", sa.Numeric(precision=24, scale=10), nullable=True),
sa.Column("amount", sa.Numeric(precision=24, scale=10), nullable=False),
sa.Column("currency", sa.String(length=3), nullable=False),
sa.Column("amount_rub", sa.Numeric(precision=24, scale=10), nullable=True),
sa.Column(
"basis",
sa.Enum("schedule", "announced", "history", "paid", name="income_basis"),
nullable=False,
),
sa.Column("tax_withheld", sa.Numeric(precision=24, scale=10), nullable=True),
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_income_calendar_instrument_id_instrument"),
ondelete="CASCADE",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_metric_income_calendar")),
sa.UniqueConstraint(
"scope",
"instrument_id",
"kind",
"expected_date",
"basis",
name=op.f("uq_metric_income_calendar_scope_instrument_id_kind_expected_date_basis"),
),
)
op.create_index(
op.f("ix_metric_income_calendar_expected_date"),
"metric_income_calendar",
["expected_date"],
unique=False,
)
op.create_index(
op.f("ix_metric_income_calendar_scope"), "metric_income_calendar", ["scope"], unique=False
)
op.create_table(
"metric_rebalance",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("portfolio_id", sa.Integer(), nullable=False),
sa.Column(
"dimension",
postgresql.ENUM(
"asset_class",
"sector",
"country",
"currency",
name="allocation_dimension",
create_type=False,
),
nullable=False,
),
sa.Column("bucket", sa.String(length=64), nullable=False),
sa.Column("instrument_id", sa.Integer(), nullable=True),
sa.Column("current_value_rub", sa.Numeric(precision=24, scale=10), nullable=False),
sa.Column("current_weight", sa.Numeric(precision=24, scale=10), nullable=False),
sa.Column("target_weight", sa.Numeric(precision=24, scale=10), nullable=True),
sa.Column("delta_value_rub", sa.Numeric(precision=24, scale=10), nullable=False),
sa.Column("suggested_qty", sa.Numeric(precision=24, scale=10), nullable=True),
sa.Column("lot", sa.Integer(), 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("within_band", sa.Boolean(), nullable=False),
sa.Column("blocked_by_cash", sa.Boolean(), 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_rebalance_instrument_id_instrument"),
ondelete="CASCADE",
),
sa.ForeignKeyConstraint(
["portfolio_id"],
["portfolio.id"],
name=op.f("fk_metric_rebalance_portfolio_id_portfolio"),
ondelete="CASCADE",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_metric_rebalance")),
sa.UniqueConstraint(
"portfolio_id",
"dimension",
"bucket",
"instrument_id",
name=op.f("uq_metric_rebalance_portfolio_id_dimension_bucket_instrument_id"),
),
)
op.create_table(
"metric_tax_year",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("year", sa.Integer(), nullable=False),
sa.Column("account_id", sa.Integer(), nullable=False),
sa.Column("dividends_gross_rub", sa.Numeric(precision=24, scale=10), nullable=False),
sa.Column("coupons_gross_rub", sa.Numeric(precision=24, scale=10), nullable=False),
sa.Column("tax_withheld_rub", sa.Numeric(precision=24, scale=10), nullable=False),
sa.Column("realized_gain_rub", sa.Numeric(precision=24, scale=10), nullable=False),
sa.Column("realized_loss_rub", sa.Numeric(precision=24, scale=10), nullable=False),
sa.Column("ldv_exempt_rub", sa.Numeric(precision=24, scale=10), nullable=False),
sa.Column("taxable_base_rub", sa.Numeric(precision=24, scale=10), nullable=False),
sa.Column("estimated_tax_rub", sa.Numeric(precision=24, scale=10), nullable=False),
sa.Column("tax_rate", 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(
["account_id"],
["account.id"],
name=op.f("fk_metric_tax_year_account_id_account"),
ondelete="CASCADE",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_metric_tax_year")),
sa.UniqueConstraint("year", "account_id", name=op.f("uq_metric_tax_year_year_account_id")),
)
op.create_index(op.f("ix_metric_tax_year_year"), "metric_tax_year", ["year"], unique=False)
op.create_table(
"portfolio_target",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("portfolio_id", sa.Integer(), nullable=False),
sa.Column(
"dimension",
postgresql.ENUM(
"asset_class",
"sector",
"country",
"currency",
name="allocation_dimension",
create_type=False,
),
nullable=False,
),
sa.Column("bucket", sa.String(length=64), nullable=False),
sa.Column("target_weight", sa.Numeric(precision=24, scale=10), nullable=False),
sa.Column("band", sa.Numeric(precision=24, scale=10), nullable=True),
sa.Column("note", 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(
["portfolio_id"],
["portfolio.id"],
name=op.f("fk_portfolio_target_portfolio_id_portfolio"),
ondelete="CASCADE",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_portfolio_target")),
sa.UniqueConstraint(
"portfolio_id",
"dimension",
"bucket",
name=op.f("uq_portfolio_target_portfolio_id_dimension_bucket"),
),
)
op.create_index(
op.f("ix_portfolio_target_portfolio_id"), "portfolio_target", ["portfolio_id"], unique=False
)
op.create_table(
"metric_benchmark_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("benchmark_id", sa.Integer(), nullable=False),
sa.Column("date_from", sa.Date(), nullable=False),
sa.Column("date_to", sa.Date(), nullable=False),
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("days_skipped", sa.Integer(), nullable=False),
sa.Column(
"computed_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.ForeignKeyConstraint(
["benchmark_id"],
["benchmark.id"],
name=op.f("fk_metric_benchmark_returns_benchmark_id_benchmark"),
ondelete="CASCADE",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_metric_benchmark_returns")),
sa.UniqueConstraint(
"scope",
"period",
"benchmark_id",
name=op.f("uq_metric_benchmark_returns_scope_period_benchmark_id"),
),
)
op.create_index(
op.f("ix_metric_benchmark_returns_scope"),
"metric_benchmark_returns",
["scope"],
unique=False,
)
op.add_column(
"metric_allocation",
sa.Column("target_weight", sa.Numeric(precision=24, scale=10), nullable=True),
)
op.add_column(
"metric_allocation", sa.Column("drift", sa.Numeric(precision=24, scale=10), nullable=True)
)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column("metric_allocation", "drift")
op.drop_column("metric_allocation", "target_weight")
op.drop_index(op.f("ix_metric_benchmark_returns_scope"), table_name="metric_benchmark_returns")
op.drop_table("metric_benchmark_returns")
op.drop_index(op.f("ix_portfolio_target_portfolio_id"), table_name="portfolio_target")
op.drop_table("portfolio_target")
op.drop_index(op.f("ix_metric_tax_year_year"), table_name="metric_tax_year")
op.drop_table("metric_tax_year")
op.drop_table("metric_rebalance")
op.drop_index(op.f("ix_metric_income_calendar_scope"), table_name="metric_income_calendar")
op.drop_index(
op.f("ix_metric_income_calendar_expected_date"), table_name="metric_income_calendar"
)
op.drop_table("metric_income_calendar")
op.drop_table("metric_goal_progress")
op.drop_table("bond_nominal_schedule")
op.drop_table("benchmark")
op.drop_index(op.f("ix_metric_income_monthly_scope"), table_name="metric_income_monthly")
op.drop_table("metric_income_monthly")
op.drop_table("goal")
# ### end Alembic commands ###
# Dropping a table leaves its enum type behind, and a re-upgrade would then fail.
# `allocation_dimension` stays: `metric_allocation` still uses it.
sa.Enum(name="income_basis").drop(op.get_bind(), checkfirst=True)
sa.Enum(name="benchmark_kind").drop(op.get_bind(), checkfirst=True)