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
+49
View File
@@ -0,0 +1,49 @@
"""`refresh_all` under concurrency: every step replaces its whole table, so two refreshes
must not interleave. The advisory lock makes the second one wait instead of racing (and
instead of being skipped — `POST /rules/apply` during a sync has to take effect)."""
from __future__ import annotations
import asyncio
from datetime import timedelta
from sqlalchemy import func, select
from factories import make_account, make_cbr_rate, make_txn
from fintracker.analytics import today_local
from fintracker.db import get_sessionmaker
from fintracker.metrics.refresh import refresh_all
from fintracker.models import MetricNetWorthDaily
async def _refresh(trigger: str):
async with get_sessionmaker()() as session:
return await refresh_all(session, trigger=trigger)
async def test_two_concurrent_refreshes_both_succeed(app):
t = today_local()
card = await make_account(name="Карта", balance="10000")
await make_account(name="Валютный", currency="USD", balance="100")
await make_cbr_rate(t - timedelta(days=1), "USD", "90")
for day in range(1, 6):
await make_txn(t - timedelta(days=day), outcome="100", outcome_account_id=card)
first, second = await asyncio.gather(_refresh("sync:a"), _refresh("rules"))
assert first.error is None, first.error
assert second.error is None, second.error
# serialised, not interleaved: one refresh finished before the other started
assert first.finished_at is not None and second.finished_at is not None
assert first.finished_at <= second.started_at or second.finished_at <= first.started_at
async with get_sessionmaker()() as session:
rows = (
await session.execute(select(func.count()).select_from(MetricNetWorthDaily))
).scalar_one()
days = (
await session.execute(select(func.count(func.distinct(MetricNetWorthDaily.d))))
).scalar_one()
# one row per day of the series, written exactly once
assert rows == days == 6