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
+38 -1
View File
@@ -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())