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}