Отключённый счёт выпадает из всех скоупов, капитала и подсказок импорта, события остаются в леджере. Брокерский счёт без баланса попадает в net worth по оценке из леджера. POST /accounts заводит счёт под импорт отчёта, AccountOut отдаёт value_rub. Сверка со снапшотом не считает расхождением позиции счетов, у которых снапшота нет (отчёты Сбера и ВТБ).
267 lines
9.8 KiB
Python
267 lines
9.8 KiB
Python
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}
|
|
|
|
|
|
async def _broker(name: str, amount: str = "100000", **kwargs) -> int:
|
|
from factories import make_event
|
|
from fintracker.models import AccountKind, EventKind
|
|
|
|
account = await make_account(
|
|
name=name, kind=AccountKind.broker, role=AccountRole.investment, balance=None, **kwargs
|
|
)
|
|
await make_event(
|
|
today_local() - timedelta(days=3), account_id=account, kind=EventKind.deposit, amount=amount
|
|
)
|
|
return account
|
|
|
|
|
|
async def test_broker_accounts_count_through_their_ledger_valuation(app):
|
|
t = today_local()
|
|
await make_account(name="Карта", balance="5000")
|
|
await make_txn(t - timedelta(days=5), outcome="100")
|
|
await _broker("Брокер 1", "100000")
|
|
await _broker("Брокер 2", "50000")
|
|
await refresh()
|
|
|
|
rows = await series()
|
|
assert rows[t].investment_rub == Decimal("150000")
|
|
assert rows[t].liquid_rub == Decimal("5000")
|
|
assert rows[t].total_rub == Decimal("155000")
|
|
assert rows[t].by_currency["RUB"].startswith("155000")
|
|
# before the deposits the brokers were worth nothing: only the card is left
|
|
assert rows[t - timedelta(days=4)].investment_rub == Decimal("0")
|
|
assert rows[t - timedelta(days=4)].total_rub == Decimal("5000")
|
|
|
|
|
|
async def test_broker_switches_keep_an_account_out_of_net_worth(app):
|
|
t = today_local()
|
|
await make_account(name="Карта", balance="1000")
|
|
await make_txn(t - timedelta(days=5), outcome="10")
|
|
await _broker("Считается", "10000")
|
|
await _broker("Не в капитале", "20000", include_in_net_worth=False)
|
|
await _broker("Отключён", "40000", disabled=True)
|
|
mirrored = await _broker("Оригинал", "80000")
|
|
await make_account(name="Зеркало", balance="80000", mirror_of_account_id=mirrored)
|
|
await refresh()
|
|
|
|
row = (await series())[t]
|
|
# ledger side: «Считается» + «Оригинал»; the mirror and the two switched-off ones stay out
|
|
assert row.investment_rub == Decimal("90000")
|
|
assert row.total_rub == Decimal("91000")
|
|
|
|
|
|
async def test_a_broker_account_without_balance_is_not_a_finding(app):
|
|
await make_account(name="Карта", balance=None)
|
|
await _broker("Брокер")
|
|
await refresh()
|
|
|
|
named = [f.detail for f in await findings("account_without_balance")]
|
|
assert len(named) == 1
|
|
assert "Карта" in named[0]
|
|
|
|
|
|
async def test_net_worth_of_brokers_alone_is_reported(app):
|
|
await _broker("Единственный", "70000")
|
|
await refresh()
|
|
|
|
rows = await series()
|
|
assert rows[today_local()].total_rub == Decimal("70000")
|