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.
This commit is contained in:
@@ -0,0 +1,159 @@
|
||||
"""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)}
|
||||
Reference in New Issue
Block a user