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
+192 -1
View File
@@ -8,7 +8,7 @@ from datetime import date, datetime
from decimal import Decimal
from typing import Any
from sqlalchemy import ForeignKey, Integer, String, Text, UniqueConstraint, func
from sqlalchemy import Boolean, ForeignKey, Integer, String, Text, UniqueConstraint, func
from sqlalchemy.orm import Mapped, mapped_column
from fintracker.db.base import Base, db_enum
@@ -284,4 +284,195 @@ class MetricAllocation(Base):
"""Share of the scope's valued total; the weights of one dimension add up to 1."""
holding_count: Mapped[int] = mapped_column(Integer, default=0)
"""Instruments in this bucket; 0 for the cash bucket."""
target_weight: Mapped[Decimal | None]
"""From `portfolio_target`; NULL when the user set no target for this bucket."""
drift: Mapped[Decimal | None]
"""`weight - target_weight` in percentage points of the whole, NULL without a target."""
computed_at: Mapped[datetime] = mapped_column(server_default=func.now())
class IncomeBasis(enum.StrEnum):
"""Where a calendar entry's number comes from — the honesty column.
A bond's future coupon is arithmetic on a published schedule; an announced dividend is a
fact the issuer stated; a projected dividend is an extrapolation from history and can be
wrong by any amount. Mixing the three into one «expected income» figure is the fastest
way to make a forecast that nobody can audit.
"""
schedule = "schedule"
"""Bond coupon from the payment schedule and the nominal in force on that date."""
announced = "announced"
"""Declared by the issuer: a `corporate_action` with a future record date."""
history = "history"
"""Extrapolated from the last 24 months — a guess, labelled as one."""
paid = "paid"
"""Already received; the entry is history, not a forecast."""
class MetricIncomeCalendar(Base):
"""Dividends and coupons, past and expected, per scope and instrument (plan §3).
Rows are per payment, not per month, because the screen the user actually wants answers
«what lands, and when» — and because an amount that turns out wrong has to be traceable
to the single assumption that produced it (`basis`, `per_unit`, `qty`).
"""
__tablename__ = "metric_income_calendar"
__table_args__ = (UniqueConstraint("scope", "instrument_id", "kind", "expected_date", "basis"),)
id: Mapped[int] = mapped_column(primary_key=True)
scope: Mapped[str] = mapped_column(String(32), index=True)
instrument_id: Mapped[int] = mapped_column(ForeignKey("instrument.id", ondelete="CASCADE"))
kind: Mapped[str] = mapped_column(String(16))
"""dividend | coupon | amortization | repayment — the `corporate_action` kinds that pay."""
expected_date: Mapped[date] = mapped_column(index=True)
record_date: Mapped[date | None]
qty: Mapped[Decimal]
"""Position held when the payment is expected; 0 once the position is gone."""
per_unit: Mapped[Decimal | None]
amount: Mapped[Decimal]
currency: Mapped[str] = mapped_column(String(3))
amount_rub: Mapped[Decimal | None]
"""NULL when the date has no FX rate — never a substituted number."""
basis: Mapped[IncomeBasis] = mapped_column(db_enum(IncomeBasis, "income_basis"))
tax_withheld: Mapped[Decimal | None]
"""What the broker already held back, for payments that happened."""
computed_at: Mapped[datetime] = mapped_column(server_default=func.now())
class MetricIncomeMonthly(Base):
"""Income actually received, per scope, month and kind — the history half of the screen."""
__tablename__ = "metric_income_monthly"
__table_args__ = (UniqueConstraint("scope", "month", "kind", "currency"),)
id: Mapped[int] = mapped_column(primary_key=True)
scope: Mapped[str] = mapped_column(String(32), index=True)
month: Mapped[date]
"""First day of the month."""
kind: Mapped[str] = mapped_column(String(16))
currency: Mapped[str] = mapped_column(String(3))
amount: Mapped[Decimal]
amount_rub: Mapped[Decimal | None]
tax_withheld: Mapped[Decimal]
payment_count: Mapped[int] = mapped_column(Integer, default=0)
computed_at: Mapped[datetime] = mapped_column(server_default=func.now())
class MetricBenchmarkReturns(Base):
"""A benchmark's TWR over the same period and date grid as `metric_returns`.
Kept beside the portfolio's own returns rather than inside them: the set of benchmarks is
a user's choice, and a column per index would mean a migration per index. Comparing
requires the same grid — a benchmark measured over a different set of days is not a
comparison, so `date_from`/`date_to` are stored and checked.
"""
__tablename__ = "metric_benchmark_returns"
__table_args__ = (UniqueConstraint("scope", "period", "benchmark_id"),)
id: Mapped[int] = mapped_column(primary_key=True)
scope: Mapped[str] = mapped_column(String(32), index=True)
period: Mapped[str] = mapped_column(String(8))
benchmark_id: Mapped[int] = mapped_column(ForeignKey("benchmark.id", ondelete="CASCADE"))
date_from: Mapped[date]
date_to: Mapped[date]
twr: Mapped[Decimal | None]
twr_annualized: Mapped[Decimal | None]
days_skipped: Mapped[int] = mapped_column(Integer, default=0)
"""Days the index had no quote for. Non-zero means the comparison is not like-for-like."""
computed_at: Mapped[datetime] = mapped_column(server_default=func.now())
class MetricRebalance(Base):
"""What to buy or sell to reach the target weights (plan §3).
`suggested_qty` respects the lot size and the cash on hand, because a recommendation that
cannot be executed is not a recommendation. It is whole lots, never a fractional share.
"""
__tablename__ = "metric_rebalance"
__table_args__ = (UniqueConstraint("portfolio_id", "dimension", "bucket", "instrument_id"),)
id: Mapped[int] = mapped_column(primary_key=True)
portfolio_id: Mapped[int] = mapped_column(ForeignKey("portfolio.id", ondelete="CASCADE"))
dimension: Mapped[AllocationDimension] = mapped_column(
db_enum(AllocationDimension, "allocation_dimension")
)
bucket: Mapped[str] = mapped_column(String(64))
instrument_id: Mapped[int | None] = mapped_column(
ForeignKey("instrument.id", ondelete="CASCADE")
)
"""NULL on the bucket's own summary row; set on each proposed trade inside it."""
current_value_rub: Mapped[Decimal]
current_weight: Mapped[Decimal]
target_weight: Mapped[Decimal | None]
delta_value_rub: Mapped[Decimal]
"""Positive: buy this much. Negative: sell."""
suggested_qty: Mapped[Decimal | None]
"""Whole lots; NULL when the instrument has no usable price."""
lot: Mapped[int | None] = mapped_column(Integer)
price: Mapped[Decimal | None]
price_currency: Mapped[str | None] = mapped_column(String(3))
within_band: Mapped[bool] = mapped_column(Boolean, default=False)
"""True when the drift is inside `portfolio_target.band` — shown, but not acted on."""
blocked_by_cash: Mapped[bool] = mapped_column(Boolean, default=False)
"""A buy the available cash does not cover; the quantity is what the cash does allow."""
computed_at: Mapped[datetime] = mapped_column(server_default=func.now())
class MetricTaxYear(Base):
"""The tax picture per calendar year and account (plan §3, ст. 214.1 / 219.1 НК).
Estimated, and labelled estimated everywhere it surfaces: the broker is the tax agent and
the authoritative number is its own certificate. This exists to make that certificate
checkable and to show the cost of selling a position before the three-year mark.
"""
__tablename__ = "metric_tax_year"
__table_args__ = (UniqueConstraint("year", "account_id"),)
id: Mapped[int] = mapped_column(primary_key=True)
year: Mapped[int] = mapped_column(Integer, index=True)
account_id: Mapped[int] = mapped_column(ForeignKey("account.id", ondelete="CASCADE"))
dividends_gross_rub: Mapped[Decimal]
coupons_gross_rub: Mapped[Decimal]
tax_withheld_rub: Mapped[Decimal]
"""What the broker already took — the difference between gross and what arrived."""
realized_gain_rub: Mapped[Decimal]
"""FIFO result in roubles, each leg at the CBR rate of its own date (currency revaluation
is part of the base, plan §7 вопрос 4)."""
realized_loss_rub: Mapped[Decimal]
ldv_exempt_rub: Mapped[Decimal]
"""Gain on lots held 3+ years, exempt under art. 219.1 — an estimate, see the docstring."""
taxable_base_rub: Mapped[Decimal]
estimated_tax_rub: Mapped[Decimal]
tax_rate: Mapped[Decimal]
"""The rate applied, so a changed rate is visible rather than baked into the number."""
computed_at: Mapped[datetime] = mapped_column(server_default=func.now())
class MetricGoalProgress(Base):
"""Where a goal stands and when it is projected to be met."""
__tablename__ = "metric_goal_progress"
goal_id: Mapped[int] = mapped_column(
ForeignKey("goal.id", ondelete="CASCADE"), primary_key=True
)
as_of: Mapped[date]
current_value_rub: Mapped[Decimal]
target_amount_rub: Mapped[Decimal]
progress: Mapped[Decimal]
"""current / target, clamped to [0, …]; can exceed 1 for a goal already met."""
projected_date: Mapped[date | None]
"""NULL when the trend does not reach the target (flat or falling), never a far-future
date pretending to be an answer."""
basis: Mapped[str] = mapped_column(String(16))
"""xirr | contribution | none — which assumption produced `projected_date`."""
assumed_rate: Mapped[Decimal | None]
monthly_needed_rub: Mapped[Decimal | None]
"""Contribution required to hit `target_date`; NULL when the goal has no deadline."""
on_track: Mapped[bool | None]
computed_at: Mapped[datetime] = mapped_column(server_default=func.now())