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
+26
View File
@@ -25,19 +25,33 @@ from fintracker.models.ledger import (
)
from fintracker.models.metrics import (
AllocationDimension,
IncomeBasis,
MetricAllocation,
MetricBenchmarkReturns,
MetricCashFlowBroker,
MetricCashFlowMonthly,
MetricDataQuality,
MetricGoalProgress,
MetricHolding,
MetricIncomeCalendar,
MetricIncomeMonthly,
MetricNetWorthDaily,
MetricPortfolioValueDaily,
MetricRebalance,
MetricRefreshLog,
MetricReturns,
MetricRunway,
MetricSpendingByCategory,
MetricTaxYear,
)
from fintracker.models.planning import (
Benchmark,
BenchmarkKind,
Goal,
PortfolioTarget,
)
from fintracker.models.pricing import (
BondNominalSchedule,
CashSnapshot,
CorporateAction,
CorporateActionKind,
@@ -95,6 +109,9 @@ __all__ = [
"AllocationDimension",
"AppUser",
"AssetClass",
"Benchmark",
"BenchmarkKind",
"BondNominalSchedule",
"Broker",
"CashSnapshot",
"CashTxn",
@@ -111,6 +128,8 @@ __all__ = [
"FlowLinkKind",
"FlowType",
"FxRateDaily",
"Goal",
"IncomeBasis",
"Instrument",
"InstrumentAlias",
"JobStatus",
@@ -118,20 +137,27 @@ __all__ = [
"LotDisposal",
"Merchant",
"MetricAllocation",
"MetricBenchmarkReturns",
"MetricCashFlowBroker",
"MetricCashFlowMonthly",
"MetricDataQuality",
"MetricGoalProgress",
"MetricHolding",
"MetricIncomeCalendar",
"MetricIncomeMonthly",
"MetricNetWorthDaily",
"MetricPortfolioValueDaily",
"MetricRebalance",
"MetricRefreshLog",
"MetricReturns",
"MetricRunway",
"MetricSpendingByCategory",
"MetricTaxYear",
"PendingInstrument",
"PendingInstrumentStatus",
"Portfolio",
"PortfolioAccount",
"PortfolioTarget",
"PositionSnapshot",
"PriceCoverage",
"PriceDaily",
+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())
+106
View File
@@ -0,0 +1,106 @@
"""What the portfolio is *supposed* to look like: targets, benchmarks, goals (plan §4, фаза 4).
Everything else in `models/` records what happened. These three record intent, and that is
why they are separate: a target weight is not a measurement, it is a decision the user made,
and a refresh that rebuilds every metric from scratch must never touch it.
* `PortfolioTarget` — the desired split along one allocation dimension. Rebalancing compares
it against `metric_allocation` and proposes trades.
* `Benchmark` — an index the portfolio is measured against. Stored as a row rather than a
constant because the comparison list is a user's choice, and because an index quoted on
MOEX is an ordinary `instrument` whose history already lands in `price_daily`.
* `Goal` — an amount by a date. Progress is derived, never stored here.
"""
from __future__ import annotations
import enum
from datetime import date
from decimal import Decimal
from sqlalchemy import Boolean, ForeignKey, String, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from fintracker.db.base import Base, TimestampMixin, db_enum
from fintracker.models.metrics import AllocationDimension
class PortfolioTarget(TimestampMixin, Base):
"""Target weight of one bucket of one dimension, as a fraction (0.25 = 25 %).
Keyed by the same `(dimension, bucket)` pair `metric_allocation` uses, so the two join
directly and a bucket that exists in only one of them is visible rather than silently
dropped — an allocation that drifted to zero still owes an answer.
"""
__tablename__ = "portfolio_target"
__table_args__ = (UniqueConstraint("portfolio_id", "dimension", "bucket"),)
id: Mapped[int] = mapped_column(primary_key=True)
portfolio_id: Mapped[int] = mapped_column(
ForeignKey("portfolio.id", ondelete="CASCADE"), index=True
)
dimension: Mapped[AllocationDimension] = mapped_column(
db_enum(AllocationDimension, "allocation_dimension")
)
bucket: Mapped[str] = mapped_column(String(64))
target_weight: Mapped[Decimal]
"""Fraction of the portfolio. Weights of one dimension should add to 1; the API reports
the shortfall rather than normalising, because a total of 0.9 is a mistake, not a scale."""
band: Mapped[Decimal | None]
"""Tolerance around the target below which no rebalancing is suggested (0.05 = ±5 pp).
Without it every price tick produces a trade recommendation."""
note: Mapped[str | None] = mapped_column(Text)
class BenchmarkKind(enum.StrEnum):
price = "price"
"""Price index — no dividends (IMOEX). Understates what a holder actually earned."""
total_return = "total_return"
"""Gross total-return index — dividends reinvested (MCFTR). The honest comparison."""
class Benchmark(TimestampMixin, Base):
"""An index the portfolio's TWR is compared against."""
__tablename__ = "benchmark"
id: Mapped[int] = mapped_column(primary_key=True)
code: Mapped[str] = mapped_column(String(32), unique=True)
"""MOEX secid or another stable code: IMOEX, MCFTR, RGBITR."""
name: Mapped[str] = mapped_column(String(128))
kind: Mapped[BenchmarkKind] = mapped_column(db_enum(BenchmarkKind, "benchmark_kind"))
instrument_id: Mapped[int | None] = mapped_column(
ForeignKey("instrument.id", ondelete="SET NULL")
)
"""The `instrument` carrying the index's price history; NULL until it is first synced."""
source: Mapped[str] = mapped_column(String(16), default="moex")
"""moex | manual — the S&P 500 has no free official feed (plan §7, вопрос 2)."""
currency: Mapped[str] = mapped_column(String(3), default="RUB")
is_default: Mapped[bool] = mapped_column(Boolean, default=False)
"""Shown next to the portfolio's own return without being asked for."""
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
class Goal(TimestampMixin, Base):
"""An amount to reach, optionally by a date. Progress and the projected date are derived.
`scope` is the same string the analytics API uses (`all`, `account:<id>`,
`portfolio:<id>`) so a goal can track one account or the whole net worth without a
second way of naming a subset.
"""
__tablename__ = "goal"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(128), unique=True)
scope: Mapped[str] = mapped_column(String(32), default="all")
target_amount: Mapped[Decimal]
currency: Mapped[str] = mapped_column(String(3), default="RUB")
target_date: Mapped[date | None]
"""NULL means «no deadline» — progress is still tracked, only the date is projected."""
monthly_contribution: Mapped[Decimal | None]
"""Planned top-up. Used for the projection when the goal has too little history for a
trailing XIRR to mean anything."""
note: Mapped[str | None] = mapped_column(Text)
archived: Mapped[bool] = mapped_column(Boolean, default=False)
+23
View File
@@ -134,6 +134,29 @@ class CorporateAction(TimestampMixin, Base):
source_id: Mapped[str | None] = mapped_column(String(128))
class BondNominalSchedule(Base):
"""What a bond's nominal becomes after each amortisation (plan §1.3).
A bond quote is a percent of the *current* nominal, not the original one, so every
amortisation silently rewrites what a quote of «100» is worth. Without this schedule an
amortised bond keeps being valued at its issue par and the position drifts upward by
exactly the amount already repaid. The coupon forecast needs it for the same reason: a
coupon is a rate on the nominal that stands on the coupon date.
"""
__tablename__ = "bond_nominal_schedule"
instrument_id: Mapped[int] = mapped_column(
ForeignKey("instrument.id", ondelete="CASCADE"), primary_key=True
)
effective_date: Mapped[date] = mapped_column(primary_key=True)
"""First date this nominal applies to."""
nominal: Mapped[Decimal]
currency: Mapped[str] = mapped_column(String(3))
source: Mapped[str] = mapped_column(String(16))
"""moex | tinvest | ledger — `ledger` when derived from an amortisation payment."""
class PositionSnapshot(Base):
"""What the broker says a position is — kept for reconciliation, never for analytics."""