diff --git a/backend/alembic/versions/a99f438e0010_аллокация_портфеля.py b/backend/alembic/versions/a99f438e0010_аллокация_портфеля.py new file mode 100644 index 0000000..fbb81de --- /dev/null +++ b/backend/alembic/versions/a99f438e0010_аллокация_портфеля.py @@ -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 ### diff --git a/backend/src/fintracker/analytics/__init__.py b/backend/src/fintracker/analytics/__init__.py index 2a2c131..eee6350 100644 --- a/backend/src/fintracker/analytics/__init__.py +++ b/backend/src/fintracker/analytics/__init__.py @@ -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) diff --git a/backend/src/fintracker/analytics/allocation.py b/backend/src/fintracker/analytics/allocation.py new file mode 100644 index 0000000..a28d19d --- /dev/null +++ b/backend/src/fintracker/analytics/allocation.py @@ -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()} diff --git a/backend/src/fintracker/analytics/valuation.py b/backend/src/fintracker/analytics/valuation.py index ce57a8a..facf31c 100644 --- a/backend/src/fintracker/analytics/valuation.py +++ b/backend/src/fintracker/analytics/valuation.py @@ -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", diff --git a/backend/src/fintracker/models/__init__.py b/backend/src/fintracker/models/__init__.py index 31f45ce..cbce08d 100644 --- a/backend/src/fintracker/models/__init__.py +++ b/backend/src/fintracker/models/__init__.py @@ -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", diff --git a/backend/src/fintracker/models/metrics.py b/backend/src/fintracker/models/metrics.py index 8faf2c4..3d0486a 100644 --- a/backend/src/fintracker/models/metrics.py +++ b/backend/src/fintracker/models/metrics.py @@ -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()) diff --git a/backend/tests/analytics/test_allocation.py b/backend/tests/analytics/test_allocation.py new file mode 100644 index 0000000..729a2a4 --- /dev/null +++ b/backend/tests/analytics/test_allocation.py @@ -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([]) == []