Files
fin-tracker/backend/tests/analytics/test_cashflow_broker.py
T
Dmitry 1ceeba2c48 feat(analytics): брокерские потоки по месяцам и порядок шагов пересчёта
cashflow_broker читает event заново по EXTERNAL_FLOW_KINDS, а не агрегирует готовый
external_flow_rub. Дневная серия неттит потоки по (счёт, валюта, день) ДО конвертации,
поэтому пополнение и вывод одного дня схлопываются, и разбивку из неё не восстановить:
на живых данных так спрятано 512 550 ₽ выводов, и все 40 месяцев выглядели бы как
«только пополнения». Правила чтения скопированы из valuation._load_deltas один в один,
поэтому net сходится с external_flow_rub по всем 40 месяцам до последнего знака.

Месяц без потоков строки не порождает: разрежённый ряд позволяет клиенту отличить
«ничего не было» от «вышло в ноль», а дорисовать нули он может сам.

Порядок шагов: fx → classify → matching → corpactions → lots → … → cashflow_broker →
networth → …. matching строго ПОСЛЕ classify, потому что classify пересчитывает
flow_type всех транзакций с нуля из правил и затёр бы internal_transfer, проставленный
линковкой; и строго ДО networth и cashflow, которые этот flow_type читают. corpactions
строго ДО lots: rebuild._split_ratios берёт коэффициенты из corporate_action.
2026-09-18 15:06:19 +03:00

160 lines
5.4 KiB
Python

"""Monthly brokerage flows: the pure fold on synthetic flows, then the rebuild end to end."""
from datetime import date, timedelta
from decimal import Decimal
from sqlalchemy import select
from factories import make_account, make_cbr_rate, make_event, month_back, refresh
from fintracker.analytics.cashflow_broker import Flow, aggregate, rebuild_cash_flow_broker
from fintracker.db import get_sessionmaker
from fintracker.models import (
AccountKind,
AccountRole,
EventKind,
MetricCashFlowBroker,
MetricPortfolioValueDaily,
)
D = Decimal
def flow(day: int, amount: str, *, account_id: int = 1, month: date = date(2026, 3, 1)) -> Flow:
return Flow(account_id=account_id, d=month.replace(day=day), amount_rub=D(amount))
def test_a_deposit_and_a_withdrawal_in_one_month_keep_their_own_columns():
months = aggregate([flow(3, "200000"), flow(20, "-150000")], [1])
row = months[date(2026, 3, 1)]
assert row.deposits_rub == D("200000")
assert row.withdrawals_rub == D("150000")
assert row.net_rub == D("50000")
assert row.event_count == 2
def test_a_month_that_nets_to_zero_is_not_the_same_as_a_month_with_nothing():
months = aggregate([flow(3, "100000"), flow(9, "-100000")], [1])
row = months[date(2026, 3, 1)]
assert (row.deposits_rub, row.withdrawals_rub, row.net_rub) == (D("100000"), D("100000"), D(0))
# ...and a month nothing landed in gets no row at all, rather than a computed zero
assert date(2026, 4, 1) not in months
assert aggregate([], [1]) == {}
def test_flows_are_split_by_month_and_filtered_by_scope():
flows = [
flow(28, "1000", month=date(2026, 3, 1)),
flow(1, "2000", month=date(2026, 4, 1)),
flow(2, "9999", account_id=2, month=date(2026, 4, 1)),
]
months = aggregate(flows, [1])
assert sorted(months) == [date(2026, 3, 1), date(2026, 4, 1)]
assert months[date(2026, 4, 1)].deposits_rub == D("2000")
assert aggregate(flows, [2])[date(2026, 4, 1)].deposits_rub == D("9999")
async def broker_account() -> int:
return await make_account(
name="Брокерский",
kind=AccountKind.broker,
role=AccountRole.investment,
balance=None,
include_in_net_worth=False,
source="tinvest",
)
async def broker_months(scope: str = "all") -> dict[date, MetricCashFlowBroker]:
async with get_sessionmaker()() as session:
rows = (
(
await session.execute(
select(MetricCashFlowBroker)
.where(MetricCashFlowBroker.scope == scope)
.order_by(MetricCashFlowBroker.month)
)
)
.scalars()
.all()
)
return {r.month: r for r in rows}
async def rebuild() -> None:
async with get_sessionmaker()() as session:
await rebuild_cash_flow_broker(session)
await session.commit()
async def test_a_foreign_deposit_converts_at_the_rate_of_its_own_day(app):
account = await broker_account()
m = month_back(1)
deposit_day = m + timedelta(days=4)
withdrawal_day = m + timedelta(days=18)
for d in (deposit_day, withdrawal_day):
await make_cbr_rate(d, "USD", "90" if d == deposit_day else "100")
await make_event(
deposit_day, account_id=account, kind=EventKind.deposit, amount="1000", currency="USD"
)
await make_event(
withdrawal_day, account_id=account, kind=EventKind.withdrawal, amount="-100", currency="USD"
)
await refresh()
await rebuild()
row = (await broker_months())[m]
# the deposit at its own day's 90, the withdrawal at the 100 of eleven days later
assert row.deposits_rub == D("90000")
assert row.withdrawals_rub == D("10000")
assert row.net_rub == D("80000")
assert row.event_count == 2
assert (await broker_months(f"account:{account}"))[m].net_rub == D("80000")
async def test_net_matches_the_daily_external_flow_of_the_value_series(app):
account = await broker_account()
m = month_back(1)
await make_event(
m + timedelta(days=2), account_id=account, kind=EventKind.deposit, amount="300000"
)
await make_event(
m + timedelta(days=2), account_id=account, kind=EventKind.withdrawal, amount="-120000"
)
await refresh()
await rebuild()
async with get_sessionmaker()() as session:
daily = (
await session.execute(
select(MetricPortfolioValueDaily.external_flow_rub).where(
MetricPortfolioValueDaily.scope == "all"
)
)
).scalars()
total = sum((D(v) for v in daily), start=D(0))
row = (await broker_months())[m]
# the same two events collapse into one daily number; only the gross split is new here
assert row.deposits_rub == D("300000")
assert row.withdrawals_rub == D("120000")
assert row.net_rub == total
async def test_rebuild_is_idempotent(app):
account = await broker_account()
m = month_back(2)
await make_event(
m + timedelta(days=1), account_id=account, kind=EventKind.deposit, amount="50000"
)
await refresh()
await rebuild()
first = {
month: (r.deposits_rub, r.net_rub, r.event_count)
for month, r in (await broker_months()).items()
}
await rebuild()
second = {
month: (r.deposits_rub, r.net_rub, r.event_count)
for month, r in (await broker_months()).items()
}
assert first == second == {m: (D("50000"), D("50000"), 1)}