feat(analytics): аллокация портфеля по классу, сектору, стране и валюте
Каждое измерение покрывает ОДИН И ТОТ ЖЕ итог — бумаги плюс кэш. Четыре диаграммы одного портфеля обязаны быть одного размера, иначе экраны противоречат друг другу, поэтому кэш это бакет в каждом разрезе, а не то, что выброшено из тех, куда он неочевидно ложится. Исключение — валютный разрез: деньги в рубле лежат вместе с рублёвыми бумагами, потому что вопрос к этой диаграмме именно такой. Бакет хранится ключом, а не подписью: класс актива как есть, сектор и страна как их пишет источник, плюс два литерала — cash и unknown. Язык живёт в клиенте; зашивать его в данные значит зашивать один язык навсегда. Позиция без цены исключается, а не считается нулём: ноль тихо ужал бы все остальные веса. Короткая позиция сохраняет свою величину, но не уменьшает знаменатель — иначе длинная сторона вылезла бы за 100 %, что на круговой диаграмме не значит ничего. Derived-кэш вынесен в valuation.cash_balances(): одно определение «нашего кэша» для сверки со снапшотом брокера и для аллокации, с одним и тем же исключением покупок с карты, деньги которых баланс счёта никогда не видел.
This commit is contained in:
@@ -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
|
||||
|
||||
from fintracker.analytics import (
|
||||
allocation,
|
||||
cashflow,
|
||||
classify,
|
||||
networth,
|
||||
@@ -120,6 +121,7 @@ def register_steps() -> None:
|
||||
# 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("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,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()}
|
||||
@@ -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
|
||||
|
||||
|
||||
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]]:
|
||||
"""Every set of accounts the metrics are reported for: all, each one, each portfolio."""
|
||||
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)
|
||||
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, cash_derived)
|
||||
await _reconcile(session, await cash_balances(session))
|
||||
if income_missing_fx:
|
||||
FINDINGS.add(
|
||||
"income_missing_fx",
|
||||
|
||||
@@ -22,6 +22,8 @@ from fintracker.models.ledger import (
|
||||
LotDisposal,
|
||||
)
|
||||
from fintracker.models.metrics import (
|
||||
AllocationDimension,
|
||||
MetricAllocation,
|
||||
MetricCashFlowMonthly,
|
||||
MetricDataQuality,
|
||||
MetricHolding,
|
||||
@@ -79,6 +81,7 @@ __all__ = [
|
||||
"AccountKind",
|
||||
"AccountLink",
|
||||
"AccountRole",
|
||||
"AllocationDimension",
|
||||
"AppUser",
|
||||
"AssetClass",
|
||||
"Broker",
|
||||
@@ -101,6 +104,7 @@ __all__ = [
|
||||
"Lot",
|
||||
"LotDisposal",
|
||||
"Merchant",
|
||||
"MetricAllocation",
|
||||
"MetricCashFlowMonthly",
|
||||
"MetricDataQuality",
|
||||
"MetricHolding",
|
||||
|
||||
@@ -3,6 +3,7 @@ wholesale inside one transaction by metrics/refresh.py; nothing else writes here
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import enum
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
@@ -10,7 +11,7 @@ from typing import Any
|
||||
from sqlalchemy import ForeignKey, Integer, String, Text, UniqueConstraint, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from fintracker.db.base import Base
|
||||
from fintracker.db.base import Base, db_enum
|
||||
|
||||
|
||||
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.
|
||||
Non-zero means `twr` covers only part of the period."""
|
||||
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([]) == []
|
||||
Reference in New Issue
Block a user