feat(accounts): выключатель счёта, ручное создание брокерских счетов, брокеры в капитале

Отключённый счёт выпадает из всех скоупов, капитала и подсказок импорта, события остаются в леджере. Брокерский счёт без баланса попадает в net worth по оценке из леджера. POST /accounts заводит счёт под импорт отчёта, AccountOut отдаёт value_rub. Сверка со снапшотом не считает расхождением позиции счетов, у которых снапшота нет (отчёты Сбера и ВТБ).
This commit is contained in:
Dmitry
2026-09-19 21:54:42 +03:00
parent 1b521be35e
commit 4236993106
12 changed files with 559 additions and 13 deletions
+64 -5
View File
@@ -13,7 +13,8 @@ even on days with no transactions, which is the whole point of the daily series.
Accounts are bucketed by `role`. Loan and credit accounts already carry a negative balance in
ZenMoney, so `debt_rub` is negative without flipping any signs. Mirror accounts
(`mirror_of_account_id`) and accounts with `include_in_net_worth = false` are excluded — their
value comes from the broker ledger in phase 2, and counting both would double it.
value comes from the broker ledger, and counting both would double it. Broker accounts have no
balance: their ledger valuation is added to `investment_rub` (see `_ledger_value_series`).
Two ways value can leave the picture unnoticed, both reported to data quality instead of being
swallowed:
@@ -27,16 +28,24 @@ swallowed:
from __future__ import annotations
from bisect import bisect_right
from collections import defaultdict
from collections.abc import Sequence
from datetime import date, timedelta
from decimal import Decimal
from sqlalchemy import Row, delete, insert, select
from sqlalchemy import Row, delete, func, insert, select
from sqlalchemy.ext.asyncio import AsyncSession
from fintracker.analytics import FINDINGS, ledger_date_range, today_local
from fintracker.models import Account, AccountRole, CashTxn, MetricNetWorthDaily
from fintracker.models import (
Account,
AccountKind,
AccountRole,
CashTxn,
MetricNetWorthDaily,
MetricPortfolioValueDaily,
)
from fintracker.pricing.fx import FxTable
ZERO = Decimal(0)
@@ -58,6 +67,7 @@ async def rebuild_net_worth_daily(session: AsyncSession) -> None:
select(Account).where(
Account.include_in_net_worth.is_(True),
Account.archived.is_(False),
Account.disabled.is_(False),
Account.mirror_of_account_id.is_(None),
)
)
@@ -67,6 +77,9 @@ async def rebuild_net_worth_daily(session: AsyncSession) -> None:
)
usable: list[Account] = []
for acc in accounts:
if acc.kind == AccountKind.broker:
# no balance by design: what it is worth comes from the ledger (added below)
continue
if acc.balance is None:
FINDINGS.add(
"account_without_balance",
@@ -110,15 +123,20 @@ async def rebuild_net_worth_daily(session: AsyncSession) -> None:
balances[acc.id] = Decimal(acc.balance) - after_today
fx = await FxTable.load(session)
ledger_days, ledger_values = await _ledger_value_series(session)
out: list[dict[str, object]] = []
missing_ccys: set[str] = set()
missing_days = 0
# nothing to reconstruct: no account has a balance, so there is no net worth to report
d = end if usable else start - timedelta(days=1)
# nothing to reconstruct: no account has a balance and no broker has a valuation
d = end if (usable or ledger_days) else start - timedelta(days=1)
while d >= start:
buckets = dict.fromkeys(_BUCKETS.values(), ZERO)
by_currency: dict[str, Decimal] = defaultdict(Decimal)
missing = 0
ledger_rub = _value_on(ledger_days, ledger_values, d)
if ledger_rub:
buckets[_BUCKETS[AccountRole.investment]] += ledger_rub
by_currency["RUB"] += ledger_rub
for acc in usable:
native = balances[acc.id]
by_currency[acc.currency.upper()] += native
@@ -164,6 +182,47 @@ async def rebuild_net_worth_daily(session: AsyncSession) -> None:
await session.execute(insert(MetricNetWorthDaily), out)
async def _ledger_value_series(session: AsyncSession) -> tuple[list[date], list[Decimal]]:
"""What the broker accounts that count towards net worth were worth, per day.
A broker account has no `balance`: its value is the ledger valuation (positions at market
plus cash) that the `valuation` step already wrote per account scope. Summing those scopes
rather than reading `all` is what lets the account's own switches apply — «В капитал»
off, disabled, or a mirror all keep it out, exactly as for a ZenMoney account.
"""
ids = (
(
await session.execute(
select(Account.id).where(
Account.kind == AccountKind.broker,
Account.include_in_net_worth.is_(True),
Account.disabled.is_(False),
Account.mirror_of_account_id.is_(None),
)
)
)
.scalars()
.all()
)
if not ids:
return [], []
rows = (
await session.execute(
select(MetricPortfolioValueDaily.d, func.sum(MetricPortfolioValueDaily.total_rub))
.where(MetricPortfolioValueDaily.scope.in_([f"account:{i}" for i in ids]))
.group_by(MetricPortfolioValueDaily.d)
.order_by(MetricPortfolioValueDaily.d)
)
).all()
return [r[0] for r in rows], [Decimal(r[1] or 0) for r in rows]
def _value_on(days: Sequence[date], values: Sequence[Decimal], d: date) -> Decimal:
"""The last known value on or before `d`; nothing before the series starts."""
i = bisect_right(days, d)
return values[i - 1] if i else ZERO
async def _report_leaking_transfers(
session: AsyncSession,
rows: Sequence[Row[tuple[date, Decimal, int | None, Decimal, int | None]]],
@@ -71,6 +71,7 @@ async def _missing_fx(session: AsyncSession, fx: FxTable, as_of: date) -> list[F
select(Account.currency).where(
Account.include_in_net_worth.is_(True),
Account.archived.is_(False),
Account.disabled.is_(False),
Account.mirror_of_account_id.is_(None),
Account.balance.is_not(None),
)
+18 -2
View File
@@ -483,7 +483,15 @@ async def cash_balances(session: AsyncSession) -> dict[tuple[int, str], Decimal]
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.
A disabled account (the user's own switch) belongs to none of them, so it is in no total,
no portfolio and no per-account view; its events stay in the ledger untouched.
"""
switched_off = set(
(await session.execute(select(Account.id).where(Account.disabled.is_(True)))).scalars()
)
ledger_accounts = ledger_accounts - switched_off
scopes: dict[str, set[int]] = {"all": set(ledger_accounts)}
for account_id in sorted(ledger_accounts):
scopes[f"account:{account_id}"] = {account_id}
@@ -691,6 +699,14 @@ async def _reconcile(
).all()
}
# A derived position the snapshot does not list is only a discrepancy where there IS a
# snapshot to disagree with. Accounts fed by a broker report (Sber, VTB) have none at all:
# their positions are checked against the report's own closing balances by
# `ledger/report_import.reconcile_reports`, and would otherwise all read as «not at the broker».
covered = set(
(await session.execute(select(PositionSnapshot.account_id).distinct())).scalars()
) | set((await session.execute(select(CashSnapshot.account_id).distinct())).scalars())
mismatched: list[int] = []
seen: set[tuple[int, int]] = set()
for r in snapshots:
@@ -699,7 +715,7 @@ async def _reconcile(
if abs(derived_pos.get(key, ZERO) - Decimal(r.qty)) > QTY_TOLERANCE:
mismatched.append(r.instrument_id)
for key, qty in derived_pos.items():
if key not in seen and abs(qty) > QTY_TOLERANCE:
if key not in seen and key[0] in covered and abs(qty) > QTY_TOLERANCE:
mismatched.append(key[1])
if mismatched:
+87 -4
View File
@@ -5,19 +5,100 @@ from sqlalchemy import select
from fintracker.api.deps import CurrentUser, SessionDep
from fintracker.api.errors import Problem
from fintracker.api.schemas.accounts import AccountOut, AccountPatch
from fintracker.models import Account
from fintracker.api.schemas.accounts import AccountCreate, AccountOut, AccountPatch
from fintracker.models import (
Account,
AccountKind,
AccountRole,
Broker,
EventSource,
MetricPortfolioValueDaily,
)
router = APIRouter(prefix="/accounts", tags=["accounts"])
async def _out(session: SessionDep, accounts: list[Account]) -> list[AccountOut]:
"""Rows as the API shows them, each with its latest ledger valuation (null when none)."""
latest = (
await session.execute(
select(MetricPortfolioValueDaily.scope, MetricPortfolioValueDaily.total_rub)
.where(MetricPortfolioValueDaily.scope.like("account:%"))
.distinct(MetricPortfolioValueDaily.scope)
.order_by(MetricPortfolioValueDaily.scope, MetricPortfolioValueDaily.d.desc())
)
).all()
values = {row.scope: row.total_rub for row in latest}
out: list[AccountOut] = []
for a in accounts:
row = AccountOut.model_validate(a, from_attributes=True)
row.value_rub = values.get(f"account:{a.id}")
out.append(row)
return out
@router.get("", name="list")
async def list_accounts(session: SessionDep, _: CurrentUser) -> list[AccountOut]:
"""Every account, archived ones included — the client decides what to show."""
rows = (
await session.execute(select(Account).order_by(Account.archived, Account.name, Account.id))
).scalars()
return [AccountOut.model_validate(a, from_attributes=True) for a in rows]
return await _out(session, list(rows))
_REPORT_SOURCES = {
Broker.sber: ("report_sber", EventSource.report_sber),
Broker.vtb: ("report_vtb", EventSource.report_vtb),
Broker.other: ("csv", None),
}
"""`account.source` and `primary_event_source` for a broker account fed by report imports."""
@router.post("", name="create", status_code=status.HTTP_201_CREATED)
async def create_account(body: AccountCreate, session: SessionDep, _: CurrentUser) -> AccountOut:
"""Create the broker account a report import will write into.
`source_id` is the agreement number the report prints: it is how the import finds the
account on its own the next time.
"""
if body.broker not in _REPORT_SOURCES:
raise Problem(
status.HTTP_400_BAD_REQUEST,
"Bad request",
"Счета T-Invest создаёт синхронизация, вручную их не заводят",
)
name, source_id = body.name.strip(), body.source_id.strip()
if not name or not source_id:
raise Problem(
status.HTTP_400_BAD_REQUEST, "Bad request", "name and source_id must not be empty"
)
source, primary = _REPORT_SOURCES[body.broker]
taken = (
await session.execute(
select(Account.name).where(Account.source == source, Account.source_id == source_id)
)
).scalar_one_or_none()
if taken is not None:
raise Problem(
status.HTTP_409_CONFLICT,
"Conflict",
f"Счёт с номером договора {source_id} уже есть: «{taken}»",
)
account = Account(
kind=AccountKind.broker,
source=source,
source_id=source_id,
broker=body.broker,
name=name,
currency=body.currency.upper(),
role=AccountRole.investment,
primary_event_source=primary,
include_in_net_worth=True,
)
session.add(account)
await session.commit()
await session.refresh(account)
return (await _out(session, [account]))[0]
@router.patch("/{account_id}", name="patch")
@@ -38,6 +119,8 @@ async def patch_account(
)
if "role" in changes and changes["role"] is None:
raise Problem(status.HTTP_400_BAD_REQUEST, "Bad request", "role must not be null")
if "disabled" in changes and changes["disabled"] is None:
raise Problem(status.HTTP_400_BAD_REQUEST, "Bad request", "disabled must not be null")
mirror = changes.get("mirror_of_account_id")
if mirror is not None:
if mirror == account_id:
@@ -51,4 +134,4 @@ async def patch_account(
setattr(account, field, value)
await session.commit()
await session.refresh(account)
return AccountOut.model_validate(account, from_attributes=True)
return (await _out(session, [account]))[0]
+22 -1
View File
@@ -2,7 +2,7 @@ from __future__ import annotations
from datetime import date, datetime
from pydantic import BaseModel, ConfigDict
from pydantic import BaseModel, ConfigDict, Field
from fintracker.api.schemas.common import MoneyOpt
from fintracker.models import AccountKind, AccountRole, Broker, EventSource
@@ -21,12 +21,17 @@ class AccountOut(BaseModel):
mirror_of_account_id: int | None
primary_event_source: EventSource | None
archived: bool
disabled: bool
"""User switch: the account is left out of net worth, portfolios and metrics."""
opened_at: date | None
balance: MoneyOpt
"""Native currency, as the source reported it; JSON string."""
balance_as_of: datetime | None
start_balance: MoneyOpt
credit_limit: MoneyOpt
value_rub: MoneyOpt = None
"""Latest ledger valuation in RUB (`metric_portfolio_value_daily`): what a broker account is
worth, since it has no `balance`. Null when the account has no valuation."""
class AccountPatch(BaseModel):
@@ -42,3 +47,19 @@ class AccountPatch(BaseModel):
role: AccountRole | None = None
mirror_of_account_id: int | None = None
primary_event_source: EventSource | None = None
disabled: bool | None = None
class AccountCreate(BaseModel):
"""A broker account for report imports. T-Invest accounts come from the sync, not from here."""
model_config = ConfigDict(extra="forbid")
name: str = Field(min_length=1, max_length=256)
broker: Broker
source_id: str = Field(
min_length=1,
max_length=128,
description="Agreement number exactly as the broker's report prints it",
)
currency: str = Field(default="RUB", min_length=3, max_length=3)
@@ -236,7 +236,11 @@ async def account_suggestions(session: AsyncSession) -> list[Account]:
(
await session.execute(
select(Account)
.where(Account.kind == AccountKind.broker, Account.archived.is_(False))
.where(
Account.kind == AccountKind.broker,
Account.archived.is_(False),
Account.disabled.is_(False),
)
.order_by(Account.name)
)
)
@@ -74,6 +74,9 @@ class Account(TimestampMixin, Base):
)
opened_at: Mapped[date | None]
archived: Mapped[bool] = mapped_column(Boolean, default=False)
disabled: Mapped[bool] = mapped_column(Boolean, default=False)
"""The user switched the account off: no sync or source ever writes this. It leaves the
lists, net worth, portfolios and every metric scope; its history stays in the ledger."""
deposit_terms: Mapped[dict[str, Any] | None]
"""ZenMoney deposit/loan terms: percent, startDate, endDateOffset, capitalization…"""
balance: Mapped[Decimal | None]