feat(accounts): выключатель счёта, ручное создание брокерских счетов, брокеры в капитале
Отключённый счёт выпадает из всех скоупов, капитала и подсказок импорта, события остаются в леджере. Брокерский счёт без баланса попадает в net worth по оценке из леджера. POST /accounts заводит счёт под импорт отчёта, AccountOut отдаёт value_rub. Сверка со снапшотом не считает расхождением позиции счетов, у которых снапшота нет (отчёты Сбера и ВТБ).
This commit is contained in:
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user