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,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",
|
||||
]
|
||||
Reference in New Issue
Block a user