Files
fin-tracker/backend/src/fintracker/models/metrics.py
T
Dmitry 3b3ee4d682 feat(worker): ручной пересчёт метрик через очередь, диагностика шагов и heartbeat
POST /metrics/refresh ставит задачу в sync_job (source=METRICS_JOB) и отвечает 202, пересчёт делает worker; GET /metrics/status отдаёт refreshing и consistent. metric_refresh_log хранит failed_step и step_timings. Источники с needs="tinvest_token" не попадают в расписание без токена, tinvest/moex добавлены в default_schedule. Воркер трогает heartbeat-файл для healthcheck.
2026-09-19 21:54:30 +03:00

484 lines
22 KiB
Python

"""Precomputed metric tables served verbatim by the API (plan §3). Every table is rebuilt
wholesale inside one transaction by metrics/refresh.py; nothing else writes here."""
from __future__ import annotations
import enum
from datetime import date, datetime
from decimal import Decimal
from typing import Any
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
class MetricNetWorthDaily(Base):
__tablename__ = "metric_net_worth_daily"
d: Mapped[date] = mapped_column(primary_key=True)
total_rub: Mapped[Decimal]
liquid_rub: Mapped[Decimal]
savings_rub: Mapped[Decimal]
investment_rub: Mapped[Decimal]
debt_rub: Mapped[Decimal]
"""Negative or zero: loans and credit-card debt."""
by_currency: Mapped[dict[str, Any] | None]
"""{ccy: native amount} across all accounts, before conversion."""
missing_fx_count: Mapped[int] = mapped_column(Integer, default=0)
computed_at: Mapped[datetime] = mapped_column(server_default=func.now())
class MetricCashFlowMonthly(Base):
__tablename__ = "metric_cash_flow_monthly"
month: Mapped[date] = mapped_column(primary_key=True)
"""First day of the month."""
income_rub: Mapped[Decimal]
expense_rub: Mapped[Decimal]
"""All consumption incl. one-offs; excludes transfers and savings."""
baseline_rub: Mapped[Decimal]
"""expense minus one-offs — what runway divides by."""
one_off_rub: Mapped[Decimal]
savings_transfer_rub: Mapped[Decimal]
savings_rate: Mapped[Decimal | None]
"""(income - expense) / income, NULL when income == 0."""
txn_count: Mapped[int] = mapped_column(Integer, default=0)
computed_at: Mapped[datetime] = mapped_column(server_default=func.now())
class MetricCashFlowBroker(Base):
"""Money put into and taken out of the brokerage accounts, per scope and month.
The sibling of `metric_cash_flow_monthly`, for the other half of the picture: that one
answers what the household earned and spent, this one what it moved across the portfolio
boundary. The scope key is the same string as everywhere else in the investment metrics
(`all`, `account:<id>`, `portfolio:<id>`).
Deposits and withdrawals are kept apart, both as positive magnitudes, because the screen
shows both bars: a month that took 200 000 ₽ in and 200 000 ₽ out is not the same month
as one that saw no money at all, and a single signed sum cannot tell them apart.
`net_rub = deposits_rub - withdrawals_rub` and equals the month's sum of
`metric_portfolio_value_daily.external_flow_rub` for the same scope.
A month with no flow gets no row at all — the series is sparse on purpose, so the client
can tell "nothing happened" from "zero on balance".
"""
__tablename__ = "metric_cash_flow_broker"
scope: Mapped[str] = mapped_column(String(32), primary_key=True)
month: Mapped[date] = mapped_column(primary_key=True)
"""First day of the month."""
deposits_rub: Mapped[Decimal]
"""Everything that came in, positive: cash deposits and securities transferred in."""
withdrawals_rub: Mapped[Decimal]
"""Everything that went out, also positive."""
net_rub: Mapped[Decimal]
"""deposits - withdrawals; negative in a month that took more out than it put in."""
event_count: Mapped[int] = mapped_column(Integer, default=0)
"""Flow events behind the row — the ones that converted; see `metric_data_quality`."""
computed_at: Mapped[datetime] = mapped_column(server_default=func.now())
class MetricSpendingByCategory(Base):
__tablename__ = "metric_spending_by_category"
__table_args__ = (UniqueConstraint("month", "category_id", postgresql_nulls_not_distinct=True),)
id: Mapped[int] = mapped_column(primary_key=True)
month: Mapped[date] = mapped_column(index=True)
category_id: Mapped[int | None] = mapped_column(ForeignKey("category.id", ondelete="CASCADE"))
"""NULL = uncategorised."""
root_category_id: Mapped[int | None] = mapped_column(
ForeignKey("category.id", ondelete="CASCADE")
)
amount_rub: Mapped[Decimal]
txn_count: Mapped[int] = mapped_column(Integer, default=0)
computed_at: Mapped[datetime] = mapped_column(server_default=func.now())
class MetricRunway(Base):
__tablename__ = "metric_runway"
as_of: Mapped[date] = mapped_column(primary_key=True)
liquid_reserve_rub: Mapped[Decimal]
avg_baseline_3m_rub: Mapped[Decimal]
runway_months: Mapped[Decimal | None]
computed_at: Mapped[datetime] = mapped_column(server_default=func.now())
class MetricDataQuality(Base):
"""One row per finding; the whole table is replaced on refresh."""
__tablename__ = "metric_data_quality"
id: Mapped[int] = mapped_column(primary_key=True)
check_name: Mapped[str] = mapped_column(String(64), index=True)
severity: Mapped[str] = mapped_column(String(8))
"""info | warn | error"""
detail: Mapped[str] = mapped_column(Text)
count: Mapped[int] = mapped_column(Integer, default=1)
ref: Mapped[dict[str, Any] | None]
"""Pointers for the UI: {"cash_txn_id": …} / {"rule_id": …} / {"ccy": …}."""
computed_at: Mapped[datetime] = mapped_column(server_default=func.now())
class MetricRefreshLog(Base):
"""When metrics were last rebuilt and why; the API exposes it as `as_of`."""
__tablename__ = "metric_refresh_log"
id: Mapped[int] = mapped_column(primary_key=True)
started_at: Mapped[datetime] = mapped_column(server_default=func.now())
finished_at: Mapped[datetime | None]
trigger: Mapped[str] = mapped_column(String(32))
"""sync:<source> | manual | cli"""
error: Mapped[str | None] = mapped_column(Text)
failed_step: Mapped[str | None] = mapped_column(String(64))
"""Name of the step that raised. Steps before it committed and the ones after it did not
run, so the metric tables then come from two different runs."""
step_timings: Mapped[dict[str, Any] | None]
"""Seconds per step that completed, in run order."""
class MetricPortfolioValueDaily(Base):
"""Daily value of a scope: what the position was worth, in RUB, on every calendar day.
A scope is a set of accounts, named by a string so the table serves all of them at once:
`all`, `account:<id>`, `portfolio:<id>`. Sums are RUB at the rate of THAT day, so a
foreign-currency holding moves with the rate even on a day it did not trade.
Instruments whose price or rate is missing are left out of the sums and counted in
`missing_price_count` / `missing_fx_count` instead of being valued at zero: the chart
needs a number, the counters say how much of one it is. A price older than
`valuation.STALE_AFTER_DAYS` is still used, but counted as stale.
"""
__tablename__ = "metric_portfolio_value_daily"
scope: Mapped[str] = mapped_column(String(32), primary_key=True)
d: Mapped[date] = mapped_column(primary_key=True)
market_value_rub: Mapped[Decimal]
"""Securities at close; bonds include their accrued interest."""
accrued_interest_rub: Mapped[Decimal]
"""The НКД part of `market_value_rub`, broken out."""
cash_rub: Mapped[Decimal]
total_rub: Mapped[Decimal]
external_flow_rub: Mapped[Decimal]
"""Net contribution on this day: + into the portfolio, - out of it."""
unvalued_flow_rub: Mapped[Decimal]
"""Cash that crossed into (negative) or out of (positive) a position with no price.
It is not an external flow — it never left the portfolio — but for a time-weighted
return it behaves like one, because the paper it bought is absent from `market_value_rub`."""
invested_net_rub: Mapped[Decimal]
"""Cumulative external flow up to and including this day."""
pnl_total_rub: Mapped[Decimal | None]
"""total - invested_net: everything made so far (realised, unrealised and income).
NULL on a day where a price or a rate was missing, since the total is then incomplete."""
stale_price_count: Mapped[int] = mapped_column(Integer, default=0)
missing_price_count: Mapped[int] = mapped_column(Integer, default=0)
missing_fx_count: Mapped[int] = mapped_column(Integer, default=0)
computed_at: Mapped[datetime] = mapped_column(server_default=func.now())
class MetricHolding(Base):
"""Current position per (scope, instrument): what it is worth and what it cost.
Everything RUB-denominated is NULL — never zero — when the price or the rate for it is
missing, which is what `price_status` names. `qty` is signed: a short position is
negative, exactly as `lot.qty_remaining` stores it.
"""
__tablename__ = "metric_holding"
__table_args__ = (UniqueConstraint("scope", "instrument_id"),)
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"))
qty: Mapped[Decimal]
avg_cost: Mapped[Decimal | None]
"""Weighted cost per unit across the open lots, in `cost_currency`."""
cost_currency: Mapped[str | None] = mapped_column(String(3))
cost_total_rub: Mapped[Decimal | None]
market_price: Mapped[Decimal | None]
price_currency: Mapped[str | None] = mapped_column(String(3))
price_date: Mapped[date | None]
price_status: Mapped[str] = mapped_column(String(8), default="ok")
"""ok | stale | missing — `missing` is why the value columns are NULL."""
value_native: Mapped[Decimal | None]
value_rub: Mapped[Decimal | None]
accrued_interest_rub: Mapped[Decimal | None]
unrealized_pnl_native: Mapped[Decimal | None]
unrealized_pnl_rub: Mapped[Decimal | None]
realized_pnl_rub: Mapped[Decimal | None]
"""Cumulative over all disposals of this instrument in the scope."""
income_rub: Mapped[Decimal | None]
"""Cumulative dividends, coupons and amortisation received, net of tax."""
weight: Mapped[Decimal | None]
"""Share of the scope's valued market value; NULL when this holding has no value."""
xirr: Mapped[Decimal | None]
"""Money-weighted return of this instrument alone, filled by analytics/returns.py."""
first_buy_date: Mapped[date | None]
days_held: Mapped[int | None]
ldv_eligible_qty: Mapped[Decimal]
"""Quantity held 3+ years on an exchange-traded instrument (art. 219.1 NK)."""
computed_at: Mapped[datetime] = mapped_column(server_default=func.now())
class MetricReturns(Base):
"""XIRR and TWR per (scope, period). Rates are fractions: 0.1 means 10 %."""
__tablename__ = "metric_returns"
__table_args__ = (UniqueConstraint("scope", "period"),)
id: Mapped[int] = mapped_column(primary_key=True)
scope: Mapped[str] = mapped_column(String(32), index=True)
period: Mapped[str] = mapped_column(String(8))
"""1m | 3m | 6m | ytd | 1y | 3y | all"""
date_from: Mapped[date]
date_to: Mapped[date]
value_start_rub: Mapped[Decimal]
value_end_rub: Mapped[Decimal]
external_flow_rub: Mapped[Decimal]
"""Net contribution over the period."""
abs_pnl_rub: Mapped[Decimal]
"""end - start - net contribution: the money actually made."""
xirr: Mapped[Decimal | None]
"""Annualised money-weighted return; NULL when the flows admit no solution."""
twr: Mapped[Decimal | None]
"""Cumulative time-weighted return over the period, not annualised."""
twr_annualized: Mapped[Decimal | None]
"""TWR scaled to a year; NULL for periods shorter than one."""
twr_days_skipped: Mapped[int] = mapped_column(Integer, default=0)
"""Days left out of the chain because the portfolio could not be valued in full on them.
Non-zero means `twr` covers only part of the period."""
computed_at: Mapped[datetime] = mapped_column(server_default=func.now())
class AllocationDimension(enum.StrEnum):
"""How a portfolio can be sliced. Every dimension covers the SAME total — securities
plus cash — so the weights of any one of them add up to 1 and the charts agree."""
asset_class = "asset_class"
sector = "sector"
country = "country"
currency = "currency"
class MetricAllocation(Base):
"""Portfolio split per (scope, dimension, bucket). Target weights arrive in phase 4.
`bucket` is a stable key, not a label: an asset class as stored, a sector or country as
the source spells it, a currency code, plus two literals — `cash` for money and `unknown`
for an instrument whose attribute nobody filled in. The client decides how to say those
in Russian; inventing a label here would bake one language into the data.
"""
__tablename__ = "metric_allocation"
__table_args__ = (UniqueConstraint("scope", "dimension", "bucket"),)
id: Mapped[int] = mapped_column(primary_key=True)
scope: Mapped[str] = mapped_column(String(32), index=True)
dimension: Mapped[AllocationDimension] = mapped_column(
db_enum(AllocationDimension, "allocation_dimension")
)
bucket: Mapped[str] = mapped_column(String(64))
value_rub: Mapped[Decimal]
weight: Mapped[Decimal]
"""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())