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
@@ -0,0 +1,137 @@
"""Phase-1 analytics: rebuild every `metric_*` table from core data (plan §3).
The steps are registered on `metrics.refresh.STEPS` in the order they must run:
fx -> classify -> networth -> cashflow -> spending -> runway -> quality
Registration happens lazily from `refresh_all` (see `register_steps`) so importing
`fintracker.metrics.refresh` stays free of the analytics import graph.
`FINDINGS` is the in-memory collector the steps use to report data-quality problems they
notice in passing (an unknown category in a rule, an account with no balance). It is reset
by the first step of a refresh and drained by the last one (`quality`).
"""
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import date, datetime
from typing import Any
from zoneinfo import ZoneInfo
from sqlalchemy.ext.asyncio import AsyncSession
from fintracker.config import get_settings
def today_local() -> date:
"""Today in the deployment timezone (MSK by default) — metrics end on this day."""
return datetime.now(ZoneInfo(get_settings().timezone)).date()
async def ledger_date_range(session: AsyncSession) -> tuple[date | None, date | None]:
"""First and last date the ledger actually covers, ignoring deleted transactions.
Deleted rows are tombstones every metric already filters out, but they still carry a
date — and ZenMoney hands out `1970-01-01` for one that was never really dated. Reading
the bounds without the filter stretches every date spine built from them (the FX grid,
the net-worth series) over five empty decades.
"""
from sqlalchemy import func, select
from fintracker.models import CashTxn
row = (
await session.execute(
select(func.min(CashTxn.date), func.max(CashTxn.date)).where(CashTxn.deleted.is_(False))
)
).one()
return row[0], row[1]
@dataclass(frozen=True)
class Finding:
"""One data-quality observation; identical findings are merged by `quality`."""
check_name: str
severity: str
"""info | warn | error"""
detail: str
count: int = 1
ref: dict[str, Any] | None = None
@dataclass
class FindingCollector:
items: list[Finding] = field(default_factory=list)
def reset(self) -> None:
self.items.clear()
def add(
self,
check_name: str,
severity: str,
detail: str,
*,
count: int = 1,
ref: dict[str, Any] | None = None,
) -> None:
self.items.append(Finding(check_name, severity, detail, count, ref))
FINDINGS = FindingCollector()
_registered = False
async def _step_fx(session: AsyncSession) -> None:
"""First step of every refresh: clear findings from the previous run, then rebuild FX."""
from fintracker.pricing.fx import rebuild_fx_daily
FINDINGS.reset()
await rebuild_fx_daily(session)
def register_steps() -> None:
"""Idempotently put the phase-1 steps on the refresh registry, in order."""
global _registered
if _registered:
return
_registered = True
from fintracker.analytics import (
cashflow,
classify,
networth,
quality,
returns,
runway,
spending,
valuation,
)
from fintracker.ledger.rebuild import rebuild_lots
from fintracker.metrics.refresh import register_step
register_step("fx", _step_fx)
register_step("classify", classify.rebuild_classification)
# lots need FX (cost in RUB at the open date) and feed every later valuation step
register_step("lots", rebuild_lots)
# valuation prices the positions the lots describe; returns reads the series it writes
register_step("valuation", valuation.rebuild_valuation)
register_step("returns", returns.rebuild_returns)
register_step("networth", networth.rebuild_net_worth_daily)
register_step("cashflow", cashflow.rebuild_cash_flow_monthly)
register_step("spending", spending.rebuild_spending_by_category)
register_step("runway", runway.rebuild_runway)
register_step("quality", quality.rebuild_data_quality)
__all__ = [
"FINDINGS",
"Finding",
"FindingCollector",
"ledger_date_range",
"register_steps",
"today_local",
]