feat(analytics): аллокация портфеля по классу, сектору, стране и валюте
Каждое измерение покрывает ОДИН И ТОТ ЖЕ итог — бумаги плюс кэш. Четыре диаграммы одного портфеля обязаны быть одного размера, иначе экраны противоречат друг другу, поэтому кэш это бакет в каждом разрезе, а не то, что выброшено из тех, куда он неочевидно ложится. Исключение — валютный разрез: деньги в рубле лежат вместе с рублёвыми бумагами, потому что вопрос к этой диаграмме именно такой. Бакет хранится ключом, а не подписью: класс актива как есть, сектор и страна как их пишет источник, плюс два литерала — cash и unknown. Язык живёт в клиенте; зашивать его в данные значит зашивать один язык навсегда. Позиция без цены исключается, а не считается нулём: ноль тихо ужал бы все остальные веса. Короткая позиция сохраняет свою величину, но не уменьшает знаменатель — иначе длинная сторона вылезла бы за 100 %, что на круговой диаграмме не значит ничего. Derived-кэш вынесен в valuation.cash_balances(): одно определение «нашего кэша» для сверки со снапшотом брокера и для аллокации, с одним и тем же исключением покупок с карты, деньги которых баланс счёта никогда не видел.
This commit is contained in:
@@ -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())
|
||||
|
||||
Reference in New Issue
Block a user