"""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)}