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,94 @@
|
||||
from datetime import date, timedelta
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from factories import make_account, make_cbr_rate, make_rule, make_txn, month_back, refresh
|
||||
from fintracker.analytics import today_local
|
||||
from fintracker.db import get_sessionmaker
|
||||
from fintracker.models import MetricCashFlowMonthly, RuleKind, RuleMatchType
|
||||
|
||||
|
||||
async def months() -> dict[date, MetricCashFlowMonthly]:
|
||||
async with get_sessionmaker()() as session:
|
||||
rows = (
|
||||
(
|
||||
await session.execute(
|
||||
select(MetricCashFlowMonthly).order_by(MetricCashFlowMonthly.month)
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
return {r.month: r for r in rows}
|
||||
|
||||
|
||||
async def test_month_totals_baseline_and_savings_rate(app):
|
||||
card = await make_account(balance="0")
|
||||
m = month_back(1)
|
||||
await make_txn(m + timedelta(days=5), income="100000", income_account_id=card)
|
||||
await make_txn(m + timedelta(days=6), outcome="30000", outcome_account_id=card, payee="Лента")
|
||||
await make_txn(m + timedelta(days=7), outcome="20000", outcome_account_id=card, payee="Отпуск")
|
||||
await make_txn(m + timedelta(days=8), outcome="10000", outcome_account_id=card, payee="Копилка")
|
||||
await make_rule(kind=RuleKind.one_off, match_type=RuleMatchType.payee, pattern="Отпуск")
|
||||
await make_rule(kind=RuleKind.savings, match_type=RuleMatchType.payee, pattern="Копилка")
|
||||
await refresh()
|
||||
|
||||
row = (await months())[m]
|
||||
assert row.income_rub == Decimal("100000")
|
||||
assert row.expense_rub == Decimal("50000")
|
||||
assert row.one_off_rub == Decimal("20000")
|
||||
assert row.baseline_rub == Decimal("30000")
|
||||
assert row.savings_transfer_rub == Decimal("10000")
|
||||
assert row.savings_rate == Decimal("0.5")
|
||||
assert row.txn_count == 4
|
||||
|
||||
|
||||
async def test_transfers_and_ignored_flows_do_not_count(app):
|
||||
card = await make_account(balance="0")
|
||||
other = await make_account(name="Вклад", balance="0")
|
||||
m = month_back(1)
|
||||
await make_txn(
|
||||
m + timedelta(days=2),
|
||||
income="50000",
|
||||
income_account_id=other,
|
||||
outcome="50000",
|
||||
outcome_account_id=card,
|
||||
)
|
||||
await refresh()
|
||||
|
||||
assert await months() == {}
|
||||
|
||||
|
||||
async def test_foreign_expense_uses_the_rate_of_its_own_date(app):
|
||||
card = await make_account(balance="0")
|
||||
m = month_back(1)
|
||||
d = m + timedelta(days=10)
|
||||
await make_txn(d, outcome="10", outcome_currency="USD", outcome_account_id=card)
|
||||
await make_cbr_rate(d, "USD", "90")
|
||||
await make_cbr_rate(today_local(), "USD", "100")
|
||||
await refresh()
|
||||
|
||||
assert (await months())[m].expense_rub == Decimal("900")
|
||||
|
||||
|
||||
async def test_income_zero_gives_null_savings_rate(app):
|
||||
card = await make_account(balance="0")
|
||||
m = month_back(1)
|
||||
await make_txn(m + timedelta(days=3), outcome="1000", outcome_account_id=card)
|
||||
await refresh()
|
||||
|
||||
assert (await months())[m].savings_rate is None
|
||||
|
||||
|
||||
async def test_unconvertible_expense_leaves_no_phantom_month(app):
|
||||
"""The month row is created by a successful conversion, not by the attempt: a single
|
||||
unquoted expense must not produce an all-zero month."""
|
||||
card = await make_account(balance="0")
|
||||
m = month_back(1)
|
||||
await make_txn(
|
||||
m + timedelta(days=4), outcome="1", outcome_currency="XBT", outcome_account_id=card
|
||||
)
|
||||
await refresh()
|
||||
|
||||
assert await months() == {}
|
||||
@@ -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
|
||||
@@ -0,0 +1,113 @@
|
||||
from datetime import date, timedelta
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from factories import make_cbr_rate, make_txn
|
||||
from fintracker.analytics import today_local
|
||||
from fintracker.db import get_sessionmaker
|
||||
from fintracker.models import FxRateDaily
|
||||
from fintracker.pricing.fx import FxTable, rebuild_fx_daily
|
||||
|
||||
|
||||
def last_friday(before_days: int = 7) -> date:
|
||||
d = today_local() - timedelta(days=before_days)
|
||||
return d - timedelta(days=(d.weekday() - 4) % 7)
|
||||
|
||||
|
||||
async def rebuild() -> None:
|
||||
async with get_sessionmaker()() as session:
|
||||
await rebuild_fx_daily(session)
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def rates_on(d: date) -> dict[str, tuple[Decimal, bool]]:
|
||||
async with get_sessionmaker()() as session:
|
||||
rows = (await session.execute(select(FxRateDaily).where(FxRateDaily.d == d))).scalars()
|
||||
return {r.ccy: (r.rate_rub, r.is_carried) for r in rows}
|
||||
|
||||
|
||||
async def test_nominal_is_divided_out(app):
|
||||
friday = last_friday()
|
||||
await make_cbr_rate(friday, "JPY", "65.0", nominal=100)
|
||||
await rebuild()
|
||||
|
||||
rate, is_carried = (await rates_on(friday))["JPY"]
|
||||
assert rate == Decimal("0.65")
|
||||
assert is_carried is False
|
||||
|
||||
|
||||
async def test_weekend_carries_friday_forward(app):
|
||||
friday = last_friday()
|
||||
await make_cbr_rate(friday, "USD", "90.5")
|
||||
await rebuild()
|
||||
|
||||
for offset in (1, 2): # Saturday, Sunday
|
||||
rate, is_carried = (await rates_on(friday + timedelta(days=offset)))["USD"]
|
||||
assert rate == Decimal("90.5")
|
||||
assert is_carried is True
|
||||
|
||||
|
||||
async def test_days_before_the_first_quote_are_back_filled(app):
|
||||
friday = last_friday()
|
||||
earlier = friday - timedelta(days=10)
|
||||
await make_txn(earlier, outcome="100", outcome_currency="USD")
|
||||
await make_cbr_rate(friday, "USD", "90.5")
|
||||
await rebuild()
|
||||
|
||||
rate, is_carried = (await rates_on(earlier))["USD"]
|
||||
assert rate == Decimal("90.5")
|
||||
assert is_carried is True
|
||||
|
||||
|
||||
async def test_rub_is_one_on_every_day_and_outside_the_spine(app):
|
||||
friday = last_friday()
|
||||
await make_cbr_rate(friday, "USD", "90.5")
|
||||
await rebuild()
|
||||
|
||||
assert (await rates_on(friday))["RUB"] == (Decimal(1), False)
|
||||
assert (await rates_on(today_local()))["RUB"] == (Decimal(1), False)
|
||||
|
||||
async with get_sessionmaker()() as session:
|
||||
fx = await FxTable.load(session)
|
||||
assert fx.rate(date(1999, 1, 1), "RUB") == Decimal(1)
|
||||
assert fx.rate(date(1999, 1, 1), "USD") is None
|
||||
assert fx.to_rub(Decimal("10"), "USD", friday) == Decimal("905.0")
|
||||
assert fx.to_rub(Decimal("10"), "XBT", friday) is None
|
||||
|
||||
|
||||
async def test_spine_covers_future_rates_and_future_transactions(app):
|
||||
"""The CBR publishes tomorrow's rate the evening before, and a transaction may be dated
|
||||
in the future — both days must be convertible."""
|
||||
t = today_local()
|
||||
await make_cbr_rate(t, "USD", "90")
|
||||
await make_cbr_rate(t + timedelta(days=1), "USD", "95")
|
||||
await make_txn(t + timedelta(days=3), outcome="10", outcome_currency="USD")
|
||||
await rebuild()
|
||||
|
||||
assert (await rates_on(t + timedelta(days=1)))["USD"] == (Decimal("95"), False)
|
||||
# the txn is dated past the last quote: the spine still reaches it, carried forward
|
||||
assert (await rates_on(t + timedelta(days=3)))["USD"] == (Decimal("95"), True)
|
||||
|
||||
async with get_sessionmaker()() as session:
|
||||
fx = await FxTable.load(session)
|
||||
assert fx.to_rub(Decimal("10"), "USD", t + timedelta(days=3)) == Decimal("950")
|
||||
|
||||
|
||||
async def test_deleted_txn_does_not_stretch_the_spine(app):
|
||||
"""ZenMoney hands out a zero date (1970-01-01) for some deleted rows.
|
||||
|
||||
Counting it would build the daily grid over five extra decades of carried-forward rates.
|
||||
"""
|
||||
friday = last_friday()
|
||||
await make_cbr_rate(friday, "USD", "90.5")
|
||||
await make_txn(friday, outcome=100, outcome_currency="RUB")
|
||||
await make_txn(date(1970, 1, 1), income=15000, income_currency="RUB", deleted=True)
|
||||
await rebuild()
|
||||
|
||||
async with get_sessionmaker()() as session:
|
||||
earliest = (
|
||||
(await session.execute(select(FxRateDaily.d).order_by(FxRateDaily.d))).scalars().first()
|
||||
)
|
||||
assert earliest is not None
|
||||
assert earliest >= friday - timedelta(days=1)
|
||||
@@ -0,0 +1,200 @@
|
||||
from datetime import timedelta
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from factories import make_account, make_cbr_rate, make_txn, refresh
|
||||
from fintracker.analytics import today_local
|
||||
from fintracker.db import get_sessionmaker
|
||||
from fintracker.models import AccountRole, MetricDataQuality, MetricNetWorthDaily
|
||||
|
||||
|
||||
async def series() -> dict:
|
||||
async with get_sessionmaker()() as session:
|
||||
rows = (
|
||||
(await session.execute(select(MetricNetWorthDaily).order_by(MetricNetWorthDaily.d)))
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
return {r.d: r for r in rows}
|
||||
|
||||
|
||||
async def test_series_is_reconstructed_backwards_from_the_current_balance(app):
|
||||
t = today_local()
|
||||
card = await make_account(name="Карта", balance="10000")
|
||||
await make_txn(t - timedelta(days=5), outcome="1000", outcome_account_id=card)
|
||||
await make_txn(t - timedelta(days=3), income="5000", income_account_id=card)
|
||||
await make_txn(t - timedelta(days=1), outcome="500", outcome_account_id=card)
|
||||
await refresh()
|
||||
|
||||
rows = await series()
|
||||
assert min(rows) == t - timedelta(days=5)
|
||||
assert max(rows) == t
|
||||
expected = {
|
||||
t: Decimal("10000"),
|
||||
t - timedelta(days=1): Decimal("10000"),
|
||||
t - timedelta(days=2): Decimal("10500"),
|
||||
t - timedelta(days=3): Decimal("10500"),
|
||||
t - timedelta(days=4): Decimal("5500"),
|
||||
t - timedelta(days=5): Decimal("5500"),
|
||||
}
|
||||
assert {d: r.total_rub for d, r in rows.items()} == expected
|
||||
assert rows[t].liquid_rub == Decimal("10000")
|
||||
assert rows[t].by_currency == {"RUB": "10000.0000000000"}
|
||||
|
||||
|
||||
async def test_foreign_account_converts_at_each_days_rate(app):
|
||||
t = today_local()
|
||||
await make_account(name="Валютный", currency="USD", balance="100")
|
||||
await make_txn(t - timedelta(days=2), outcome="10", outcome_currency="USD")
|
||||
await make_cbr_rate(t - timedelta(days=2), "USD", "90")
|
||||
await make_cbr_rate(t, "USD", "100")
|
||||
await refresh()
|
||||
|
||||
rows = await series()
|
||||
assert rows[t - timedelta(days=2)].total_rub == Decimal("9000")
|
||||
assert rows[t].total_rub == Decimal("10000")
|
||||
assert rows[t].by_currency == {"USD": "100.0000000000"}
|
||||
|
||||
|
||||
async def test_unquoted_currency_is_excluded_and_counted(app):
|
||||
t = today_local()
|
||||
await make_account(name="Рубли", balance="1000")
|
||||
await make_account(name="Биток", currency="XBT", balance="2")
|
||||
await make_txn(t - timedelta(days=1), outcome="100")
|
||||
await refresh()
|
||||
|
||||
rows = await series()
|
||||
assert rows[t].total_rub == Decimal("1000")
|
||||
assert rows[t].missing_fx_count == 1
|
||||
assert rows[t].by_currency == {"RUB": "1000.0000000000", "XBT": "2.0000000000"}
|
||||
|
||||
async with get_sessionmaker()() as session:
|
||||
checks = {
|
||||
r.check_name for r in (await session.execute(select(MetricDataQuality))).scalars().all()
|
||||
}
|
||||
assert "unquoted_currency" in checks
|
||||
assert "missing_fx" in checks
|
||||
|
||||
|
||||
async def test_debt_bucket_is_negative_and_lowers_the_total(app):
|
||||
t = today_local()
|
||||
await make_account(name="Карта", balance="10000")
|
||||
await make_account(name="Кредитка", balance="-3000", role=AccountRole.debt)
|
||||
await make_account(name="Вклад", balance="50000", role=AccountRole.savings)
|
||||
await make_txn(t - timedelta(days=1), outcome="100")
|
||||
await refresh()
|
||||
|
||||
row = (await series())[t]
|
||||
assert row.debt_rub == Decimal("-3000")
|
||||
assert row.savings_rub == Decimal("50000")
|
||||
assert row.total_rub == Decimal("57000")
|
||||
|
||||
|
||||
async def test_account_without_balance_is_skipped_and_reported(app):
|
||||
t = today_local()
|
||||
await make_account(name="Карта", balance="1000")
|
||||
await make_account(name="Без баланса", balance=None)
|
||||
await make_txn(t - timedelta(days=1), outcome="100")
|
||||
await refresh()
|
||||
|
||||
assert (await series())[t].total_rub == Decimal("1000")
|
||||
async with get_sessionmaker()() as session:
|
||||
rows = (
|
||||
(
|
||||
await session.execute(
|
||||
select(MetricDataQuality).where(
|
||||
MetricDataQuality.check_name == "account_without_balance"
|
||||
)
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
assert len(rows) == 1
|
||||
|
||||
|
||||
async def test_mirror_and_excluded_accounts_are_ignored(app):
|
||||
t = today_local()
|
||||
broker = await make_account(name="Брокер", balance="100000", role=AccountRole.investment)
|
||||
await make_account(name="Зеркало", balance="100000", mirror_of_account_id=broker)
|
||||
await make_account(name="Скрытый", balance="5000", include_in_net_worth=False)
|
||||
await make_txn(t - timedelta(days=1), outcome="100")
|
||||
await refresh()
|
||||
|
||||
row = (await series())[t]
|
||||
assert row.total_rub == Decimal("100000")
|
||||
assert row.investment_rub == Decimal("100000")
|
||||
|
||||
|
||||
async def findings(check_name: str) -> list[MetricDataQuality]:
|
||||
async with get_sessionmaker()() as session:
|
||||
return list(
|
||||
(
|
||||
await session.execute(
|
||||
select(MetricDataQuality).where(MetricDataQuality.check_name == check_name)
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
|
||||
|
||||
async def test_transfer_to_an_excluded_account_is_reported(app):
|
||||
"""One leg inside net worth, one leg on an excluded account: the value did not leave the
|
||||
household, but the series shows it leaving."""
|
||||
t = today_local()
|
||||
card = await make_account(name="Карта", balance="10000")
|
||||
hidden = await make_account(name="Скрытый", balance="5000", include_in_net_worth=False)
|
||||
await make_txn(
|
||||
t - timedelta(days=1),
|
||||
income="1000",
|
||||
income_account_id=hidden,
|
||||
outcome="1000",
|
||||
outcome_account_id=card,
|
||||
)
|
||||
await make_txn(t - timedelta(days=2), outcome="100", outcome_account_id=card)
|
||||
await refresh()
|
||||
|
||||
rows = await findings("transfer_out_of_net_worth")
|
||||
assert len(rows) == 1 # aggregated once per refresh, not per day
|
||||
assert rows[0].severity == "info"
|
||||
assert rows[0].count == 1
|
||||
assert rows[0].ref == {"account_ids": [hidden]}
|
||||
|
||||
|
||||
async def test_transfer_between_two_included_accounts_is_not_reported(app):
|
||||
t = today_local()
|
||||
card = await make_account(name="Карта", balance="10000")
|
||||
deposit = await make_account(name="Вклад", balance="5000", role=AccountRole.savings)
|
||||
await make_txn(
|
||||
t - timedelta(days=1),
|
||||
income="1000",
|
||||
income_account_id=deposit,
|
||||
outcome="1000",
|
||||
outcome_account_id=card,
|
||||
)
|
||||
await refresh()
|
||||
|
||||
assert await findings("transfer_out_of_net_worth") == []
|
||||
|
||||
|
||||
async def test_missing_fx_days_are_named_and_total_stays_computed(app):
|
||||
"""`total_rub` keeps the convertible buckets (a NULL would break the chart); the silent
|
||||
understatement is reported instead."""
|
||||
t = today_local()
|
||||
await make_account(name="Рубли", balance="1000")
|
||||
await make_account(name="Биток", currency="XBT", balance="2")
|
||||
await make_txn(t - timedelta(days=2), outcome="100")
|
||||
await refresh()
|
||||
|
||||
rows = await series()
|
||||
assert len(rows) == 3
|
||||
assert rows[t].total_rub == Decimal("1000")
|
||||
assert rows[t].missing_fx_count == 1
|
||||
|
||||
found = await findings("networth_missing_fx")
|
||||
assert len(found) == 1
|
||||
assert found[0].severity == "warn"
|
||||
assert found[0].count == 3 # every day of the series is affected
|
||||
assert found[0].ref == {"currencies": ["XBT"], "days": 3}
|
||||
@@ -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
|
||||
@@ -0,0 +1,71 @@
|
||||
from datetime import timedelta
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from factories import make_account, make_category, make_txn, month_back, refresh
|
||||
from fintracker.db import get_sessionmaker
|
||||
from fintracker.models import MetricSpendingByCategory
|
||||
|
||||
|
||||
async def rows() -> list[MetricSpendingByCategory]:
|
||||
async with get_sessionmaker()() as session:
|
||||
return list((await session.execute(select(MetricSpendingByCategory))).scalars().all())
|
||||
|
||||
|
||||
async def test_root_category_rollup_and_uncategorised_row(app):
|
||||
card = await make_account(balance="0")
|
||||
food = await make_category("Еда")
|
||||
groceries = await make_category("Продукты", parent_id=food)
|
||||
cafe = await make_category("Кафе", parent_id=food)
|
||||
m = month_back(1)
|
||||
await make_txn(
|
||||
m + timedelta(days=1), outcome="700", outcome_account_id=card, primary_category_id=groceries
|
||||
)
|
||||
await make_txn(
|
||||
m + timedelta(days=2), outcome="300", outcome_account_id=card, primary_category_id=cafe
|
||||
)
|
||||
await make_txn(m + timedelta(days=3), outcome="150", outcome_account_id=card)
|
||||
await refresh()
|
||||
|
||||
by_category = {r.category_id: r for r in await rows()}
|
||||
assert by_category[groceries].amount_rub == Decimal("700")
|
||||
assert by_category[groceries].root_category_id == food
|
||||
assert by_category[cafe].root_category_id == food
|
||||
assert by_category[None].amount_rub == Decimal("150")
|
||||
assert by_category[None].root_category_id is None
|
||||
assert sum(r.amount_rub for r in await rows()) == Decimal("1150")
|
||||
assert {r.month for r in await rows()} == {m}
|
||||
|
||||
|
||||
async def test_only_expenses_are_counted(app):
|
||||
card = await make_account(balance="0")
|
||||
savings = await make_account(name="Вклад", balance="0")
|
||||
m = month_back(1)
|
||||
await make_txn(m + timedelta(days=1), income="1000", income_account_id=card)
|
||||
await make_txn(
|
||||
m + timedelta(days=2),
|
||||
income="500",
|
||||
income_account_id=savings,
|
||||
outcome="500",
|
||||
outcome_account_id=card,
|
||||
)
|
||||
await refresh()
|
||||
|
||||
assert await rows() == []
|
||||
|
||||
|
||||
async def test_top_level_category_is_its_own_root(app):
|
||||
card = await make_account(balance="0")
|
||||
transport = await make_category("Транспорт")
|
||||
m = month_back(1)
|
||||
await make_txn(
|
||||
m + timedelta(days=4),
|
||||
outcome="90",
|
||||
outcome_account_id=card,
|
||||
primary_category_id=transport,
|
||||
)
|
||||
await refresh()
|
||||
|
||||
(row,) = await rows()
|
||||
assert (row.category_id, row.root_category_id) == (transport, transport)
|
||||
Reference in New Issue
Block a user