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,88 @@
|
||||
from datetime import timedelta
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from factories import make_account, make_txn, month_back, refresh
|
||||
from fintracker.db import get_sessionmaker
|
||||
from fintracker.models import AccountRole, MetricRunway
|
||||
|
||||
|
||||
async def row() -> MetricRunway | None:
|
||||
async with get_sessionmaker()() as session:
|
||||
return (await session.execute(select(MetricRunway))).scalars().one_or_none()
|
||||
|
||||
|
||||
async def test_reserve_and_three_month_average(app):
|
||||
card = await make_account(name="Карта", balance="200000")
|
||||
await make_account(name="Вклад", balance="100000", role=AccountRole.savings)
|
||||
await make_account(name="Брокер", balance="900000", role=AccountRole.investment)
|
||||
for months_ago, amount in ((1, "10000"), (2, "20000"), (3, "30000"), (4, "999999")):
|
||||
await make_txn(
|
||||
month_back(months_ago) + timedelta(days=2), outcome=amount, outcome_account_id=card
|
||||
)
|
||||
await refresh()
|
||||
|
||||
r = await row()
|
||||
assert r is not None
|
||||
# reserve = liquid + savings only; investments are not runway
|
||||
assert r.liquid_reserve_rub == Decimal("300000")
|
||||
# last three COMPLETE months: 10000, 20000, 30000 -> 20000 (the 4th is out of window)
|
||||
assert r.avg_baseline_3m_rub == Decimal("20000")
|
||||
assert r.runway_months == Decimal("15")
|
||||
|
||||
|
||||
async def test_one_off_is_out_of_the_divisor(app):
|
||||
from factories import make_rule
|
||||
from fintracker.models import RuleKind, RuleMatchType
|
||||
|
||||
card = await make_account(name="Карта", balance="100000")
|
||||
m = month_back(1)
|
||||
await make_txn(m + timedelta(days=1), outcome="10000", outcome_account_id=card, payee="Лента")
|
||||
await make_txn(m + timedelta(days=2), outcome="90000", outcome_account_id=card, payee="Отпуск")
|
||||
await make_rule(kind=RuleKind.one_off, match_type=RuleMatchType.payee, pattern="Отпуск")
|
||||
await refresh()
|
||||
|
||||
r = await row()
|
||||
assert r is not None
|
||||
# 10000 baseline in the previous month, 0 in the two before it: (10000 + 0 + 0) / 3
|
||||
assert r.avg_baseline_3m_rub == Decimal("3333.3333333333")
|
||||
assert r.runway_months == Decimal("30")
|
||||
|
||||
|
||||
async def test_a_month_without_spending_counts_as_zero(app):
|
||||
"""The divisor is the three named complete months, not the last three rows that exist:
|
||||
a gap month spent nothing and must dilute the average."""
|
||||
card = await make_account(name="Карта", balance="90000")
|
||||
for months_ago, amount in ((1, "30000"), (3, "30000")): # month -2 has no transactions
|
||||
await make_txn(
|
||||
month_back(months_ago) + timedelta(days=3), outcome=amount, outcome_account_id=card
|
||||
)
|
||||
await refresh()
|
||||
|
||||
r = await row()
|
||||
assert r is not None
|
||||
# (30000 + 0 + 30000) / 3 = 20000, not the 30000 an average over existing rows would give
|
||||
assert r.avg_baseline_3m_rub == Decimal("20000")
|
||||
assert r.runway_months == Decimal("4.5")
|
||||
|
||||
|
||||
async def test_months_older_than_the_window_are_ignored(app):
|
||||
card = await make_account(name="Карта", balance="60000")
|
||||
await make_txn(month_back(4) + timedelta(days=3), outcome="90000", outcome_account_id=card)
|
||||
await refresh()
|
||||
|
||||
r = await row()
|
||||
assert r is not None
|
||||
assert r.avg_baseline_3m_rub == Decimal("0")
|
||||
assert r.runway_months is None
|
||||
|
||||
|
||||
async def test_no_history_gives_null_runway(app):
|
||||
await make_account(name="Карта", balance="50000")
|
||||
await refresh()
|
||||
|
||||
r = await row()
|
||||
assert r is not None
|
||||
assert r.avg_baseline_3m_rub == Decimal("0")
|
||||
assert r.runway_months is None
|
||||
Reference in New Issue
Block a user