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
+219
View File
@@ -0,0 +1,219 @@
from datetime import timedelta
from sqlalchemy import select
from factories import make_account, make_category, make_rule, make_trip, make_txn, refresh
from fintracker.analytics import today_local
from fintracker.db import get_sessionmaker
from fintracker.models import CashTxn, FlowType, MetricDataQuality, Rule, RuleKind, RuleMatchType
async def txn(source_id: str) -> CashTxn:
async with get_sessionmaker()() as session:
return (
await session.execute(select(CashTxn).where(CashTxn.source_id == source_id))
).scalar_one()
async def test_transfer_stays_internal_transfer(app):
card = await make_account(name="Карта")
deposit = await make_account(name="Вклад")
d = today_local() - timedelta(days=3)
await make_txn(
d,
income="10000",
income_account_id=deposit,
outcome="10000",
outcome_account_id=card,
source_id="transfer",
)
await refresh()
assert (await txn("transfer")).flow_type == FlowType.internal_transfer
async def test_savings_rule_moves_expense_to_savings_transfer(app):
card = await make_account()
d = today_local() - timedelta(days=3)
await make_txn(d, outcome="5000", outcome_account_id=card, payee="Копилка", source_id="save")
await make_txn(d, outcome="700", outcome_account_id=card, payee="Пятёрочка", source_id="food")
rule_id = await make_rule(
kind=RuleKind.savings, match_type=RuleMatchType.payee, pattern="копилка"
)
await refresh()
assert (await txn("save")).flow_type == FlowType.savings_transfer
assert (await txn("food")).flow_type == FlowType.expense
async with get_sessionmaker()() as session:
rule = await session.get(Rule, rule_id)
assert rule is not None
assert rule.match_count == 1
assert rule.last_matched_at is not None
async def test_one_off_and_payee_and_trip(app):
card = await make_account()
d = today_local() - timedelta(days=2)
trip_id = await make_trip("Тбилиси", d - timedelta(days=1), d + timedelta(days=1))
await make_txn(
d, outcome="42000", outcome_account_id=card, payee="AIRLINE TICKETS 123", source_id="fly"
)
await make_rule(kind=RuleKind.one_off, match_type=RuleMatchType.payee, pattern="AIRLINE%")
await make_rule(
kind=RuleKind.payee, match_type=RuleMatchType.payee, pattern="airline%", value="Авиабилеты"
)
await refresh()
row = await txn("fly")
assert row.is_one_off is True
assert row.payee_canonical == "Авиабилеты"
assert row.trip_id == trip_id
async def test_category_rule_and_unknown_category_reported(app):
card = await make_account()
food = await make_category("Еда")
groceries = await make_category("Продукты", parent_id=food)
d = today_local() - timedelta(days=1)
await make_txn(
d,
outcome="800",
outcome_account_id=card,
payee="Ozon",
primary_category_id=food,
source_id="ozon",
)
await make_txn(d, outcome="100", outcome_account_id=card, payee="Wildberries", source_id="wb")
await make_rule(
kind=RuleKind.category,
match_type=RuleMatchType.payee,
pattern="ozon",
value="продукты",
)
await make_rule(
kind=RuleKind.category,
match_type=RuleMatchType.payee,
pattern="wildberries",
value="Нет такой категории",
)
await refresh()
assert (await txn("ozon")).category_id == groceries
assert (await txn("wb")).category_id is None
async with get_sessionmaker()() as session:
rows = (
(
await session.execute(
select(MetricDataQuality).where(
MetricDataQuality.check_name == "rule_unknown_category"
)
)
)
.scalars()
.all()
)
assert len(rows) == 1
assert rows[0].severity == "warn"
async def test_category_match_type_sees_root_parent(app):
card = await make_account()
food = await make_category("Еда")
groceries = await make_category("Продукты", parent_id=food)
d = today_local() - timedelta(days=1)
await make_txn(
d,
outcome="800",
outcome_account_id=card,
primary_category_id=groceries,
source_id="root",
)
await make_rule(kind=RuleKind.one_off, match_type=RuleMatchType.category, pattern="Еда")
await refresh()
assert (await txn("root")).is_one_off is True
async def test_classification_is_idempotent(app):
card = await make_account()
d = today_local() - timedelta(days=4)
await make_txn(d, outcome="5000", outcome_account_id=card, payee="Копилка", source_id="save")
rule_id = await make_rule(
kind=RuleKind.savings, match_type=RuleMatchType.payee, pattern="Копилка"
)
await refresh()
first = await txn("save")
await refresh()
second = await txn("save")
assert (first.flow_type, first.category_id, first.payee_canonical) == (
second.flow_type,
second.category_id,
second.payee_canonical,
)
async with get_sessionmaker()() as session:
rule = await session.get(Rule, rule_id)
assert rule is not None and rule.match_count == 1 # set, not accumulated
async def test_deleted_transactions_are_marked_deleted(app):
card = await make_account()
d = today_local() - timedelta(days=5)
await make_txn(d, outcome="100", outcome_account_id=card, deleted=True, source_id="gone")
await refresh()
assert (await txn("gone")).flow_type == FlowType.deleted
async def test_ignore_and_account_and_mcc_rules(app):
card = await make_account()
d = today_local() - timedelta(days=1)
await make_txn(d, outcome="100", outcome_account_id=card, mcc=6011, source_id="atm")
await make_rule(kind=RuleKind.ignore, match_type=RuleMatchType.mcc, pattern="6011")
await refresh()
assert (await txn("atm")).flow_type == FlowType.other
async def test_ignore_is_terminal_for_later_rules(app):
"""An ignore match stops rule application: a later broker_target on the same payee must
not pull the transaction back into a counted flow."""
card = await make_account()
d = today_local() - timedelta(days=1)
await make_txn(d, outcome="100", outcome_account_id=card, payee="Мимо кассы", source_id="skip")
await make_rule(
kind=RuleKind.ignore, match_type=RuleMatchType.payee, pattern="Мимо кассы", priority=10
)
await make_rule(
kind=RuleKind.broker_target,
match_type=RuleMatchType.payee,
pattern="Мимо кассы",
value="1",
priority=20,
)
await refresh()
assert (await txn("skip")).flow_type == FlowType.other
async def test_disabled_rule_match_count_is_reset(app):
card = await make_account()
d = today_local() - timedelta(days=1)
await make_txn(d, outcome="5000", outcome_account_id=card, payee="Копилка", source_id="save")
rule_id = await make_rule(
kind=RuleKind.savings, match_type=RuleMatchType.payee, pattern="Копилка"
)
await refresh()
async with get_sessionmaker()() as session:
rule = await session.get(Rule, rule_id)
assert rule is not None and rule.match_count == 1
rule.enabled = False
await session.commit()
await refresh()
async with get_sessionmaker()() as session:
rule = await session.get(Rule, rule_id)
assert rule is not None and rule.match_count == 0