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:
@@ -2,7 +2,8 @@
|
||||
|
||||
The steps are registered on `metrics.refresh.STEPS` in the order they must run:
|
||||
|
||||
fx -> classify -> networth -> cashflow -> spending -> runway -> quality
|
||||
fx -> classify -> matching -> corpactions -> lots -> valuation -> returns -> allocation
|
||||
-> cashflow_broker -> networth -> cashflow -> spending -> runway -> quality
|
||||
|
||||
Registration happens lazily from `refresh_all` (see `register_steps`) so importing
|
||||
`fintracker.metrics.refresh` stays free of the analytics import graph.
|
||||
@@ -103,6 +104,7 @@ def register_steps() -> None:
|
||||
from fintracker.analytics import (
|
||||
allocation,
|
||||
cashflow,
|
||||
cashflow_broker,
|
||||
classify,
|
||||
networth,
|
||||
quality,
|
||||
@@ -111,17 +113,25 @@ def register_steps() -> None:
|
||||
spending,
|
||||
valuation,
|
||||
)
|
||||
from fintracker.ledger.corporate_actions import rebuild_corporate_actions
|
||||
from fintracker.ledger.matching import rebuild_flow_links
|
||||
from fintracker.ledger.rebuild import rebuild_lots
|
||||
from fintracker.metrics.refresh import register_step
|
||||
|
||||
register_step("fx", _step_fx)
|
||||
register_step("classify", classify.rebuild_classification)
|
||||
# matching must follow classify, never precede it: classify recomputes every flow_type
|
||||
# from the rules, so a link's `internal_transfer` written earlier would be overwritten
|
||||
register_step("matching", rebuild_flow_links)
|
||||
# splits and amortisations are what lots consume, so they have to exist first
|
||||
register_step("corpactions", rebuild_corporate_actions)
|
||||
# lots need FX (cost in RUB at the open date) and feed every later valuation step
|
||||
register_step("lots", rebuild_lots)
|
||||
# valuation prices the positions the lots describe; returns reads the series it writes
|
||||
register_step("valuation", valuation.rebuild_valuation)
|
||||
register_step("returns", returns.rebuild_returns)
|
||||
register_step("allocation", allocation.rebuild_allocation)
|
||||
register_step("cashflow_broker", cashflow_broker.rebuild_cash_flow_broker)
|
||||
register_step("networth", networth.rebuild_net_worth_daily)
|
||||
register_step("cashflow", cashflow.rebuild_cash_flow_monthly)
|
||||
register_step("spending", spending.rebuild_spending_by_category)
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
"""Money in and out of the brokerage accounts, by month (plan §3).
|
||||
|
||||
`metric_portfolio_value_daily.external_flow_rub` already carries the net flow of every day,
|
||||
and summing it per month would be both cheap and trivially consistent with the value chart.
|
||||
It is not enough: that column is netted per (account, currency, day) before conversion, so a
|
||||
month that took 200 000 ₽ in and 200 000 ₽ out reads as a month where nothing happened. The
|
||||
screen needs both bars, so the events are read again here — the same `EXTERNAL_FLOW_KINDS`,
|
||||
under the same rules — and only the sign of each one decides which column it lands in. The
|
||||
net of the two columns still reconciles with `external_flow_rub` exactly, because the rate
|
||||
used is the same one: the rate of the event's own day.
|
||||
|
||||
The rules copied from `valuation.py` deliberately, because a flow this step counted and that
|
||||
one did not would show up as a gap between the bar chart and the value chart:
|
||||
|
||||
* a **card-funded** trade is a flow of the opposite sign — money arrived from a linked card
|
||||
and went straight into the paper, so a buy is a deposit and a sell a withdrawal;
|
||||
* a **securities transfer** with no cash amount is valued at the market price of its trade
|
||||
date, and skipped (never counted as zero) when nobody quotes the paper;
|
||||
* an event whose currency has no rate that day is skipped too, and reported.
|
||||
|
||||
Scopes are the ones `valuation.py` builds: `all`, every `account:<id>` that the ledger
|
||||
touches, every `portfolio:<id>`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from collections.abc import Iterable, Mapping, Sequence
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy import delete, insert, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from fintracker.analytics import FINDINGS
|
||||
from fintracker.analytics.cashflow import month_start
|
||||
from fintracker.analytics.valuation import account_scopes
|
||||
from fintracker.models import Event, EventStatus, MetricCashFlowBroker
|
||||
from fintracker.models.ledger import EXTERNAL_FLOW_KINDS
|
||||
from fintracker.pricing.fx import FxTable
|
||||
from fintracker.pricing.prices import PriceTable
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
ZERO = Decimal(0)
|
||||
RUB = "RUB"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Flow:
|
||||
"""One external flow, already converted at the rate of its own day."""
|
||||
|
||||
account_id: int
|
||||
d: date
|
||||
amount_rub: Decimal
|
||||
"""Signed: + money entering the portfolio, - money leaving it."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class MonthTotals:
|
||||
"""One month of one scope. Both columns are magnitudes, so both are non-negative."""
|
||||
|
||||
deposits_rub: Decimal = field(default=ZERO)
|
||||
withdrawals_rub: Decimal = field(default=ZERO)
|
||||
event_count: int = 0
|
||||
|
||||
@property
|
||||
def net_rub(self) -> Decimal:
|
||||
return self.deposits_rub - self.withdrawals_rub
|
||||
|
||||
|
||||
def aggregate(flows: Iterable[Flow], account_ids: Iterable[int]) -> dict[date, MonthTotals]:
|
||||
"""Fold one scope's flows into months, keeping the two directions apart.
|
||||
|
||||
A month appears only once a flow actually landed in it: the series is sparse, and the
|
||||
client reads a gap as "nothing moved" rather than as a zero someone computed.
|
||||
"""
|
||||
wanted = set(account_ids)
|
||||
months: dict[date, MonthTotals] = {}
|
||||
for flow in flows:
|
||||
if flow.account_id not in wanted or flow.amount_rub == ZERO:
|
||||
continue
|
||||
totals = months.setdefault(month_start(flow.d), MonthTotals())
|
||||
if flow.amount_rub > ZERO:
|
||||
totals.deposits_rub += flow.amount_rub
|
||||
else:
|
||||
totals.withdrawals_rub += -flow.amount_rub
|
||||
totals.event_count += 1
|
||||
return months
|
||||
|
||||
|
||||
def _card_funded(meta: object) -> bool:
|
||||
return bool(meta.get("card_funded")) if isinstance(meta, dict) else False
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LoadedFlows:
|
||||
flows: list[Flow]
|
||||
missing_fx: int
|
||||
"""Events whose currency had no rate on their day — left out, never substituted."""
|
||||
unpriced_transfers: int
|
||||
"""Securities moved in or out with no quote on their date — left out as well."""
|
||||
|
||||
|
||||
async def load_flows(session: AsyncSession, prices: PriceTable, fx: FxTable) -> LoadedFlows:
|
||||
"""Read every confirmed external flow and convert it at the rate of its own day."""
|
||||
rows = (
|
||||
await session.execute(
|
||||
select(
|
||||
Event.account_id,
|
||||
Event.instrument_id,
|
||||
Event.kind,
|
||||
Event.trade_date,
|
||||
Event.quantity,
|
||||
Event.amount,
|
||||
Event.currency,
|
||||
Event.meta,
|
||||
)
|
||||
.where(Event.status == EventStatus.confirmed)
|
||||
.order_by(Event.trade_date)
|
||||
)
|
||||
).all()
|
||||
|
||||
flows: list[Flow] = []
|
||||
missing_fx = 0
|
||||
unpriced = 0
|
||||
|
||||
for r in rows:
|
||||
card = _card_funded(r.meta)
|
||||
if not card and r.kind not in EXTERNAL_FLOW_KINDS:
|
||||
continue
|
||||
|
||||
amount = Decimal(r.amount or 0)
|
||||
ccy = (r.currency or RUB).upper()
|
||||
if card:
|
||||
# the card supplied (or absorbed) the cash: a contribution of the opposite sign
|
||||
native = -amount
|
||||
elif amount:
|
||||
native = amount
|
||||
elif r.instrument_id is not None and r.quantity:
|
||||
quote = prices.at(r.instrument_id, r.trade_date)
|
||||
if quote is None:
|
||||
unpriced += 1
|
||||
continue
|
||||
native, ccy = Decimal(r.quantity) * quote.total, quote.currency
|
||||
else:
|
||||
continue
|
||||
|
||||
if native == ZERO:
|
||||
continue
|
||||
rub = fx.to_rub(native, ccy, r.trade_date)
|
||||
if rub is None:
|
||||
missing_fx += 1
|
||||
continue
|
||||
flows.append(Flow(account_id=r.account_id, d=r.trade_date, amount_rub=rub))
|
||||
|
||||
return LoadedFlows(flows, missing_fx, unpriced)
|
||||
|
||||
|
||||
async def rebuild_cash_flow_broker(session: AsyncSession) -> None:
|
||||
"""Replace `metric_cash_flow_broker` for every scope."""
|
||||
await session.execute(delete(MetricCashFlowBroker))
|
||||
|
||||
prices = await PriceTable.load(session)
|
||||
fx = await FxTable.load(session)
|
||||
loaded = await load_flows(session, prices, fx)
|
||||
if not loaded.flows:
|
||||
_report(loaded)
|
||||
return
|
||||
|
||||
scopes = await account_scopes(session, {flow.account_id for flow in loaded.flows})
|
||||
out = _rows(loaded.flows, scopes)
|
||||
if out:
|
||||
await session.execute(insert(MetricCashFlowBroker), out)
|
||||
_report(loaded)
|
||||
log.info("cashflow_broker: %s scopes, %s rows", len(scopes), len(out))
|
||||
|
||||
|
||||
def _rows(flows: Sequence[Flow], scopes: Mapping[str, set[int]]) -> list[dict[str, object]]:
|
||||
out: list[dict[str, object]] = []
|
||||
for scope, account_ids in sorted(scopes.items()):
|
||||
for month, totals in sorted(aggregate(flows, account_ids).items()):
|
||||
out.append(
|
||||
{
|
||||
"scope": scope,
|
||||
"month": month,
|
||||
"deposits_rub": totals.deposits_rub,
|
||||
"withdrawals_rub": totals.withdrawals_rub,
|
||||
"net_rub": totals.net_rub,
|
||||
"event_count": totals.event_count,
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def _report(loaded: LoadedFlows) -> None:
|
||||
"""Flows that could not be converted: the bars are short by exactly this much."""
|
||||
if loaded.missing_fx:
|
||||
FINDINGS.add(
|
||||
"broker_flow_missing_fx",
|
||||
"warn",
|
||||
f"Внешних потоков без курса на дату: {loaded.missing_fx} — "
|
||||
"они не вошли ни в пополнения, ни в выводы",
|
||||
count=loaded.missing_fx,
|
||||
)
|
||||
if loaded.unpriced_transfers:
|
||||
FINDINGS.add(
|
||||
"broker_flow_unpriced_transfer",
|
||||
"warn",
|
||||
f"Переводов бумагами без цены на дату: {loaded.unpriced_transfers} — "
|
||||
"они не попали в помесячные потоки по брокеру",
|
||||
count=loaded.unpriced_transfers,
|
||||
)
|
||||
Reference in New Issue
Block a user