feat(analytics): аллокация портфеля по классу, сектору, стране и валюте

Каждое измерение покрывает ОДИН И ТОТ ЖЕ итог — бумаги плюс кэш. Четыре диаграммы
одного портфеля обязаны быть одного размера, иначе экраны противоречат друг другу,
поэтому кэш это бакет в каждом разрезе, а не то, что выброшено из тех, куда он
неочевидно ложится. Исключение — валютный разрез: деньги в рубле лежат вместе с
рублёвыми бумагами, потому что вопрос к этой диаграмме именно такой.

Бакет хранится ключом, а не подписью: класс актива как есть, сектор и страна как их
пишет источник, плюс два литерала — cash и unknown. Язык живёт в клиенте; зашивать
его в данные значит зашивать один язык навсегда.

Позиция без цены исключается, а не считается нулём: ноль тихо ужал бы все остальные
веса. Короткая позиция сохраняет свою величину, но не уменьшает знаменатель — иначе
длинная сторона вылезла бы за 100 %, что на круговой диаграмме не значит ничего.

Derived-кэш вынесен в valuation.cash_balances(): одно определение «нашего кэша» для
сверки со снапшотом брокера и для аллокации, с одним и тем же исключением покупок с
карты, деньги которых баланс счёта никогда не видел.
This commit is contained in:
Dmitry
2026-09-18 14:20:37 +03:00
parent c203ae65fc
commit b58ffb3aac
7 changed files with 426 additions and 3 deletions
@@ -0,0 +1,57 @@
"""аллокация портфеля
Revision ID: a99f438e0010
Revises: b7424afbb5e2
Create Date: 2026-09-18 13:48:30.529174
"""
from __future__ import annotations
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "a99f438e0010"
down_revision: str | None = "b7424afbb5e2"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table(
"metric_allocation",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("scope", sa.String(length=32), nullable=False),
sa.Column(
"dimension",
sa.Enum("asset_class", "sector", "country", "currency", name="allocation_dimension"),
nullable=False,
),
sa.Column("bucket", sa.String(length=64), nullable=False),
sa.Column("value_rub", sa.Numeric(precision=24, scale=10), nullable=False),
sa.Column("weight", sa.Numeric(precision=24, scale=10), nullable=False),
sa.Column("holding_count", sa.Integer(), nullable=False),
sa.Column(
"computed_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_metric_allocation")),
sa.UniqueConstraint(
"scope", "dimension", "bucket", name=op.f("uq_metric_allocation_scope_dimension_bucket")
),
)
op.create_index(
op.f("ix_metric_allocation_scope"), "metric_allocation", ["scope"], unique=False
)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f("ix_metric_allocation_scope"), table_name="metric_allocation")
op.drop_table("metric_allocation")
# ### end Alembic commands ###
@@ -101,6 +101,7 @@ def register_steps() -> None:
_registered = True _registered = True
from fintracker.analytics import ( from fintracker.analytics import (
allocation,
cashflow, cashflow,
classify, classify,
networth, networth,
@@ -120,6 +121,7 @@ def register_steps() -> None:
# valuation prices the positions the lots describe; returns reads the series it writes # valuation prices the positions the lots describe; returns reads the series it writes
register_step("valuation", valuation.rebuild_valuation) register_step("valuation", valuation.rebuild_valuation)
register_step("returns", returns.rebuild_returns) register_step("returns", returns.rebuild_returns)
register_step("allocation", allocation.rebuild_allocation)
register_step("networth", networth.rebuild_net_worth_daily) register_step("networth", networth.rebuild_net_worth_daily)
register_step("cashflow", cashflow.rebuild_cash_flow_monthly) register_step("cashflow", cashflow.rebuild_cash_flow_monthly)
register_step("spending", spending.rebuild_spending_by_category) register_step("spending", spending.rebuild_spending_by_category)
@@ -0,0 +1,215 @@
"""How the portfolio is split — by asset class, sector, country and currency (plan §3).
Every dimension covers the same total: the securities that could be valued, plus cash. That
is the whole point of slicing — four pies of the same portfolio must be the same size, or
the screens contradict each other. So cash is a bucket in each of them, not something left
out of the ones where it does not obviously belong.
A position nobody quotes is excluded rather than counted as zero, for the reason it always
is here: a zero would silently shrink every other weight. `holding_without_price` in
`metric_data_quality` already names those instruments, so this step adds no second remark.
The input is `metric_holding`, which `valuation.py` has just built — instrument attributes
are joined on top of it, nothing is re-derived from the ledger. Cash is the exception: it
has no holding row, so it comes from the same derived balances the reconciliation uses.
"""
from __future__ import annotations
import logging
from collections import defaultdict
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from decimal import Decimal
from sqlalchemy import delete, insert, select
from sqlalchemy.ext.asyncio import AsyncSession
from fintracker.analytics import FINDINGS, today_local
from fintracker.analytics.valuation import account_scopes, cash_balances
from fintracker.models import (
AllocationDimension,
Instrument,
MetricAllocation,
MetricHolding,
)
from fintracker.pricing.fx import FxTable
log = logging.getLogger(__name__)
ZERO = Decimal(0)
CASH = "cash"
"""Bucket for money. A literal, so the client can label it without parsing anything."""
UNKNOWN = "unknown"
"""Bucket for an instrument whose sector or country nobody filled in — not a guess."""
@dataclass(frozen=True)
class Holding:
"""What allocation needs from a valued position."""
instrument_id: int
value_rub: Decimal
asset_class: str
sector: str | None
country: str | None
currency: str
def bucket_of(holding: Holding, dimension: AllocationDimension) -> str:
match dimension:
case AllocationDimension.asset_class:
return holding.asset_class
case AllocationDimension.sector:
return holding.sector or UNKNOWN
case AllocationDimension.country:
return holding.country or UNKNOWN
case AllocationDimension.currency:
return holding.currency.upper()
def split(
holdings: Sequence[Holding], cash_rub: Mapping[str, Decimal], dimension: AllocationDimension
) -> list[tuple[str, Decimal, int]]:
"""One dimension's buckets as (bucket, value, holding count), largest first.
Cash lands in its own bucket everywhere except the currency dimension, where money in a
currency belongs with the papers denominated in it — that is the question the currency
chart is actually asked: how much of me is exposed to the dollar.
"""
values: dict[str, Decimal] = defaultdict(lambda: ZERO)
counts: dict[str, int] = defaultdict(int)
for holding in holdings:
key = bucket_of(holding, dimension)
values[key] += holding.value_rub
counts[key] += 1
for ccy, amount in cash_rub.items():
if amount == ZERO:
continue
key = ccy.upper() if dimension is AllocationDimension.currency else CASH
values[key] += amount
return sorted(
((k, v, counts[k]) for k, v in values.items()),
key=lambda row: (-row[1], row[0]),
)
def weigh(buckets: Sequence[tuple[str, Decimal, int]]) -> list[tuple[str, Decimal, int, Decimal]]:
"""Add each bucket's share of the total.
The base is the sum of the POSITIVE buckets. A short position or a negative cash balance
is real and keeps its own value, but letting it shrink the denominator would push the
long side above 100 %, which means nothing on a pie chart.
"""
base = sum((value for _, value, _ in buckets if value > ZERO), start=ZERO)
return [
(bucket, value, count, value / base if base > ZERO else ZERO)
for bucket, value, count in buckets
]
async def rebuild_allocation(session: AsyncSession) -> None:
"""Replace `metric_allocation` for every scope and dimension."""
await session.execute(delete(MetricAllocation))
rows = (
await session.execute(
select(
MetricHolding.scope,
MetricHolding.instrument_id,
MetricHolding.value_rub,
Instrument.asset_class,
Instrument.sector,
Instrument.country,
Instrument.currency,
)
.join(Instrument, Instrument.id == MetricHolding.instrument_id)
.where(MetricHolding.value_rub.is_not(None))
)
).all()
per_scope: dict[str, list[Holding]] = defaultdict(list)
for r in rows:
per_scope[r.scope].append(
Holding(
instrument_id=r.instrument_id,
value_rub=Decimal(r.value_rub),
asset_class=str(r.asset_class),
sector=r.sector,
country=r.country,
currency=r.currency,
)
)
cash = await _cash_by_scope(session)
scopes = sorted(set(per_scope) | set(cash))
if not scopes:
return
out: list[dict[str, object]] = []
for scope in scopes:
for dimension in AllocationDimension:
for bucket, value, count, weight in weigh(
split(per_scope.get(scope, []), cash.get(scope, {}), dimension)
):
out.append(
{
"scope": scope,
"dimension": dimension,
"bucket": bucket,
"value_rub": value,
"weight": weight,
"holding_count": count,
}
)
if out:
await session.execute(insert(MetricAllocation), out)
_report_unknown(per_scope.get("all", []))
log.info("allocation: %s scopes, %s rows", len(scopes), len(out))
def _report_unknown(holdings: Sequence[Holding]) -> None:
"""A dimension that is mostly `unknown` is a missing attribute, not a portfolio shape.
Sector is the live case: the instrument sync reads `GetInstrumentBy`, whose response has
no sector field at all — it would take the per-type Shares/Bonds calls. Until then the
chart is honestly empty, and says so here instead of looking broken.
"""
if not holdings:
return
for dimension in (AllocationDimension.sector, AllocationDimension.country):
missing = [h for h in holdings if bucket_of(h, dimension) == UNKNOWN]
if not missing:
continue
name = "сектор" if dimension is AllocationDimension.sector else "страна"
FINDINGS.add(
f"instrument_without_{dimension.value}",
"info",
f"У {len(missing)} из {len(holdings)} инструментов не заполнен {name}"
f"аллокация по этому измерению неполная",
count=len(missing),
ref={"instruments": sorted(h.instrument_id for h in missing)},
)
async def _cash_by_scope(session: AsyncSession) -> dict[str, dict[str, Decimal]]:
"""Derived cash per scope and currency, in RUB at today's rate."""
balances = await cash_balances(session)
if not balances:
return {}
fx = await FxTable.load(session)
as_of = today_local()
scopes = await account_scopes(session, {account_id for account_id, _ in balances})
out: dict[str, dict[str, Decimal]] = defaultdict(lambda: defaultdict(Decimal))
for scope, account_ids in scopes.items():
for (account_id, ccy), amount in balances.items():
if account_id not in account_ids or amount == ZERO:
continue
rub = fx.to_rub(amount, ccy, as_of)
# no rate today: the money is real but unconvertible, and a substituted number
# would be worse than a bucket that quietly omits it (see `missing_fx`)
if rub is not None:
out[scope][ccy.upper()] += rub
return {scope: dict(by_ccy) for scope, by_ccy in out.items()}
+22 -2
View File
@@ -461,6 +461,27 @@ async def _load_deltas(session: AsyncSession, prices: PriceTable) -> tuple[Delta
return Deltas(positions, cash, flows, instrument_cash), rows[0].trade_date return Deltas(positions, cash, flows, instrument_cash), rows[0].trade_date
async def cash_balances(session: AsyncSession) -> dict[tuple[int, str], Decimal]:
"""Derived cash per (account, currency): the sum of every confirmed event's amount.
Card-funded trades are left out — the money came from a linked card and the account's
balance never saw it, so counting the payment would show an overdraft the broker does not
report. This is the one definition of "our cash", used by the reconciliation here and by
`analytics/allocation.py`.
"""
rows = (
await session.execute(
select(Event.account_id, Event.currency, func.sum(Event.amount))
.where(
Event.status == EventStatus.confirmed,
Event.meta["card_funded"].as_string().is_distinct_from("true"),
)
.group_by(Event.account_id, Event.currency)
)
).all()
return {(r[0], (r[1] or RUB).upper()): Decimal(r[2] or 0) for r in rows}
async def account_scopes(session: AsyncSession, ledger_accounts: set[int]) -> dict[str, set[int]]: async def account_scopes(session: AsyncSession, ledger_accounts: set[int]) -> dict[str, set[int]]:
"""Every set of accounts the metrics are reported for: all, each one, each portfolio.""" """Every set of accounts the metrics are reported for: all, each one, each portfolio."""
scopes: dict[str, set[int]] = {"all": set(ledger_accounts)} scopes: dict[str, set[int]] = {"all": set(ledger_accounts)}
@@ -767,8 +788,7 @@ async def rebuild_valuation(session: AsyncSession) -> None:
income, income_missing_fx = await _income_by_instrument(session, fx) income, income_missing_fx = await _income_by_instrument(session, fx)
await _write_holdings(session, scopes, positions, realized, income, prices, fx, as_of) await _write_holdings(session, scopes, positions, realized, income, prices, fx, as_of)
cash_derived = {key: sum(by_day.values(), start=ZERO) for key, by_day in deltas.cash.items()} await _reconcile(session, await cash_balances(session))
await _reconcile(session, cash_derived)
if income_missing_fx: if income_missing_fx:
FINDINGS.add( FINDINGS.add(
"income_missing_fx", "income_missing_fx",
@@ -22,6 +22,8 @@ from fintracker.models.ledger import (
LotDisposal, LotDisposal,
) )
from fintracker.models.metrics import ( from fintracker.models.metrics import (
AllocationDimension,
MetricAllocation,
MetricCashFlowMonthly, MetricCashFlowMonthly,
MetricDataQuality, MetricDataQuality,
MetricHolding, MetricHolding,
@@ -79,6 +81,7 @@ __all__ = [
"AccountKind", "AccountKind",
"AccountLink", "AccountLink",
"AccountRole", "AccountRole",
"AllocationDimension",
"AppUser", "AppUser",
"AssetClass", "AssetClass",
"Broker", "Broker",
@@ -101,6 +104,7 @@ __all__ = [
"Lot", "Lot",
"LotDisposal", "LotDisposal",
"Merchant", "Merchant",
"MetricAllocation",
"MetricCashFlowMonthly", "MetricCashFlowMonthly",
"MetricDataQuality", "MetricDataQuality",
"MetricHolding", "MetricHolding",
+38 -1
View File
@@ -3,6 +3,7 @@ wholesale inside one transaction by metrics/refresh.py; nothing else writes here
from __future__ import annotations from __future__ import annotations
import enum
from datetime import date, datetime from datetime import date, datetime
from decimal import Decimal from decimal import Decimal
from typing import Any from typing import Any
@@ -10,7 +11,7 @@ from typing import Any
from sqlalchemy import ForeignKey, Integer, String, Text, UniqueConstraint, func from sqlalchemy import ForeignKey, Integer, String, Text, UniqueConstraint, func
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from fintracker.db.base import Base from fintracker.db.base import Base, db_enum
class MetricNetWorthDaily(Base): class MetricNetWorthDaily(Base):
@@ -214,3 +215,39 @@ class MetricReturns(Base):
"""Days left out of the chain because the portfolio could not be valued in full on them. """Days left out of the chain because the portfolio could not be valued in full on them.
Non-zero means `twr` covers only part of the period.""" Non-zero means `twr` covers only part of the period."""
computed_at: Mapped[datetime] = mapped_column(server_default=func.now()) computed_at: Mapped[datetime] = mapped_column(server_default=func.now())
class AllocationDimension(enum.StrEnum):
"""How a portfolio can be sliced. Every dimension covers the SAME total — securities
plus cash — so the weights of any one of them add up to 1 and the charts agree."""
asset_class = "asset_class"
sector = "sector"
country = "country"
currency = "currency"
class MetricAllocation(Base):
"""Portfolio split per (scope, dimension, bucket). Target weights arrive in phase 4.
`bucket` is a stable key, not a label: an asset class as stored, a sector or country as
the source spells it, a currency code, plus two literals — `cash` for money and `unknown`
for an instrument whose attribute nobody filled in. The client decides how to say those
in Russian; inventing a label here would bake one language into the data.
"""
__tablename__ = "metric_allocation"
__table_args__ = (UniqueConstraint("scope", "dimension", "bucket"),)
id: Mapped[int] = mapped_column(primary_key=True)
scope: Mapped[str] = mapped_column(String(32), index=True)
dimension: Mapped[AllocationDimension] = mapped_column(
db_enum(AllocationDimension, "allocation_dimension")
)
bucket: Mapped[str] = mapped_column(String(64))
value_rub: Mapped[Decimal]
weight: Mapped[Decimal]
"""Share of the scope's valued total; the weights of one dimension add up to 1."""
holding_count: Mapped[int] = mapped_column(Integer, default=0)
"""Instruments in this bucket; 0 for the cash bucket."""
computed_at: Mapped[datetime] = mapped_column(server_default=func.now())
@@ -0,0 +1,88 @@
"""Allocation rules on synthetic holdings — no database."""
from decimal import Decimal
from fintracker.analytics.allocation import CASH, UNKNOWN, Holding, bucket_of, split, weigh
from fintracker.models import AllocationDimension
D = Decimal
DIM = AllocationDimension
def holding(
instrument_id: int,
value: str,
*,
asset_class: str = "share",
sector: str | None = "energy",
country: str | None = "RU",
currency: str = "RUB",
) -> Holding:
return Holding(
instrument_id=instrument_id,
value_rub=D(value),
asset_class=asset_class,
sector=sector,
country=country,
currency=currency,
)
def test_buckets_are_summed_per_dimension_and_sorted_by_value():
rows = split(
[
holding(1, "100", asset_class="share"),
holding(2, "300", asset_class="bond"),
holding(3, "50", asset_class="share"),
],
{},
DIM.asset_class,
)
assert rows == [("bond", D(300), 1), ("share", D(150), 2)]
def test_cash_is_its_own_bucket_everywhere_but_the_currency_chart():
holdings = [holding(1, "900", currency="RUB")]
for dimension in (DIM.asset_class, DIM.sector, DIM.country):
assert (CASH, D(100), 0) in split(holdings, {"RUB": D(100)}, dimension)
# the currency question is "how exposed am I to this money", and cash is exposure too
assert split(holdings, {"RUB": D(100)}, DIM.currency) == [("RUB", D(1000), 1)]
def test_an_unfilled_attribute_becomes_its_own_bucket_not_a_guess():
rows = split([holding(1, "100", sector=None, country=None)], {}, DIM.sector)
assert rows == [(UNKNOWN, D(100), 1)]
assert bucket_of(holding(1, "1", country=None), DIM.country) == UNKNOWN
def test_every_dimension_covers_the_same_total():
holdings = [
holding(1, "600", asset_class="bond", sector="gov", country="RU", currency="RUB"),
holding(2, "400", asset_class="etf", sector=None, country="US", currency="USD"),
]
cash = {"RUB": D(200)}
totals = {
dimension: sum(value for _, value, _ in split(holdings, cash, dimension))
for dimension in AllocationDimension
}
assert set(totals.values()) == {D(1200)}
def test_weights_add_up_to_one():
weighted = weigh(
split([holding(1, "300"), holding(2, "100")], {"RUB": D(100)}, DIM.asset_class)
)
assert sum(w for _, _, _, w in weighted) == D(1)
def test_a_short_keeps_its_value_but_does_not_inflate_the_longs():
weighted = weigh([("share", D(100), 1), ("bond", D(-50), 1)])
shares = next(row for row in weighted if row[0] == "share")
shorts = next(row for row in weighted if row[0] == "bond")
assert shares[3] == D(1)
assert shorts[1] == D(-50)
def test_an_empty_portfolio_has_no_buckets():
assert split([], {}, DIM.asset_class) == []
assert weigh([]) == []