Files
fin-tracker/backend/tests/analytics/test_runway.py
T
Dmitry b9c12fa1a1 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.
2026-09-18 13:44:09 +03:00

89 lines
3.4 KiB
Python

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