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