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