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
|
_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()}
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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([]) == []
|
||||||
Reference in New Issue
Block a user