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