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