feat(analytics): метрики фазы 1 — классификация, net worth, потоки, расходы, runway

fx_rate_daily получает строку на каждый календарный день: котировки ЦБ тянутся
вперёд (и назад до первой), is_carried это помечает, RUB = 1.0 всегда. Дальше
любая сумма конвертируется по курсу СВОЕЙ даты, а не сегодняшнему.

Net worth восстанавливается назад от текущего account.balance по транзакциям —
ZenMoney отдаёт остаток, а не историю; поэтому сегодняшняя строка совпадает с
тем, что показывает ZenMoney, а каждая прошлая с ней согласована.

Нет курса — не подстановка, а NULL и строка в metric_data_quality. Туда же
попадает то, что шаги заметили по дороге: правило без совпадений, счёт без
баланса, перевод через границу net worth.
This commit is contained in:
Dmitry
2026-09-18 13:44:09 +03:00
parent c55fe19e48
commit b9c12fa1a1
18 changed files with 2232 additions and 0 deletions
+216
View File
@@ -0,0 +1,216 @@
"""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
from datetime import date, datetime
from decimal import Decimal
from typing import Any
from sqlalchemy import ForeignKey, Integer, String, Text, UniqueConstraint, func
from sqlalchemy.orm import Mapped, mapped_column
from fintracker.db.base import Base
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 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)
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())