feat(accounts): выключатель счёта, ручное создание брокерских счетов, брокеры в капитале
Отключённый счёт выпадает из всех скоупов, капитала и подсказок импорта, события остаются в леджере. Брокерский счёт без баланса попадает в net worth по оценке из леджера. POST /accounts заводит счёт под импорт отчёта, AccountOut отдаёт value_rub. Сверка со снапшотом не считает расхождением позиции счетов, у которых снапшота нет (отчёты Сбера и ВТБ).
This commit is contained in:
@@ -0,0 +1,29 @@
|
||||
"""account: user switch «disabled» — the account is left out of every calculation
|
||||
|
||||
Revision ID: d5a3c8e17f42
|
||||
Revises: c41e7a9d2b05
|
||||
Create Date: 2026-09-19 19:00:00.000000
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "d5a3c8e17f42"
|
||||
down_revision: str | None = "c41e7a9d2b05"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"account",
|
||||
sa.Column("disabled", sa.Boolean(), nullable=False, server_default=sa.false()),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("account", "disabled")
|
||||
@@ -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]
|
||||
|
||||
@@ -198,3 +198,69 @@ async def test_missing_fx_days_are_named_and_total_stays_computed(app):
|
||||
assert found[0].severity == "warn"
|
||||
assert found[0].count == 3 # every day of the series is affected
|
||||
assert found[0].ref == {"currencies": ["XBT"], "days": 3}
|
||||
|
||||
|
||||
async def _broker(name: str, amount: str = "100000", **kwargs) -> int:
|
||||
from factories import make_event
|
||||
from fintracker.models import AccountKind, EventKind
|
||||
|
||||
account = await make_account(
|
||||
name=name, kind=AccountKind.broker, role=AccountRole.investment, balance=None, **kwargs
|
||||
)
|
||||
await make_event(
|
||||
today_local() - timedelta(days=3), account_id=account, kind=EventKind.deposit, amount=amount
|
||||
)
|
||||
return account
|
||||
|
||||
|
||||
async def test_broker_accounts_count_through_their_ledger_valuation(app):
|
||||
t = today_local()
|
||||
await make_account(name="Карта", balance="5000")
|
||||
await make_txn(t - timedelta(days=5), outcome="100")
|
||||
await _broker("Брокер 1", "100000")
|
||||
await _broker("Брокер 2", "50000")
|
||||
await refresh()
|
||||
|
||||
rows = await series()
|
||||
assert rows[t].investment_rub == Decimal("150000")
|
||||
assert rows[t].liquid_rub == Decimal("5000")
|
||||
assert rows[t].total_rub == Decimal("155000")
|
||||
assert rows[t].by_currency["RUB"].startswith("155000")
|
||||
# before the deposits the brokers were worth nothing: only the card is left
|
||||
assert rows[t - timedelta(days=4)].investment_rub == Decimal("0")
|
||||
assert rows[t - timedelta(days=4)].total_rub == Decimal("5000")
|
||||
|
||||
|
||||
async def test_broker_switches_keep_an_account_out_of_net_worth(app):
|
||||
t = today_local()
|
||||
await make_account(name="Карта", balance="1000")
|
||||
await make_txn(t - timedelta(days=5), outcome="10")
|
||||
await _broker("Считается", "10000")
|
||||
await _broker("Не в капитале", "20000", include_in_net_worth=False)
|
||||
await _broker("Отключён", "40000", disabled=True)
|
||||
mirrored = await _broker("Оригинал", "80000")
|
||||
await make_account(name="Зеркало", balance="80000", mirror_of_account_id=mirrored)
|
||||
await refresh()
|
||||
|
||||
row = (await series())[t]
|
||||
# ledger side: «Считается» + «Оригинал»; the mirror and the two switched-off ones stay out
|
||||
assert row.investment_rub == Decimal("90000")
|
||||
assert row.total_rub == Decimal("91000")
|
||||
|
||||
|
||||
async def test_a_broker_account_without_balance_is_not_a_finding(app):
|
||||
await make_account(name="Карта", balance=None)
|
||||
await _broker("Брокер")
|
||||
await refresh()
|
||||
|
||||
named = [f.detail for f in await findings("account_without_balance")]
|
||||
assert len(named) == 1
|
||||
assert "Карта" in named[0]
|
||||
|
||||
|
||||
async def test_net_worth_of_brokers_alone_is_reported(app):
|
||||
await _broker("Единственный", "70000")
|
||||
await refresh()
|
||||
|
||||
rows = await series()
|
||||
assert rows[today_local()].total_rub == Decimal("70000")
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
"""Derived positions against the broker's snapshot — and only where a snapshot exists."""
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from factories import make_account, make_event, make_instrument, refresh
|
||||
from fintracker.analytics import today_local
|
||||
from fintracker.db import get_sessionmaker
|
||||
from fintracker.models import (
|
||||
AccountKind,
|
||||
AccountRole,
|
||||
CashSnapshot,
|
||||
EventKind,
|
||||
MetricDataQuality,
|
||||
PositionSnapshot,
|
||||
)
|
||||
|
||||
D = Decimal
|
||||
AS_OF = datetime(2026, 9, 19, 12, 0, tzinfo=UTC)
|
||||
|
||||
|
||||
async def broker(name: str, *, source: str = "tinvest") -> int:
|
||||
return await make_account(
|
||||
name=name,
|
||||
kind=AccountKind.broker,
|
||||
role=AccountRole.investment,
|
||||
balance=None,
|
||||
source=source,
|
||||
)
|
||||
|
||||
|
||||
async def buy(account: int, instrument: int, qty: int) -> None:
|
||||
await make_event(
|
||||
today_local() - timedelta(days=5),
|
||||
account_id=account,
|
||||
kind=EventKind.buy,
|
||||
instrument_id=instrument,
|
||||
quantity=qty,
|
||||
price=100,
|
||||
amount=-100 * qty,
|
||||
)
|
||||
|
||||
|
||||
async def snapshot(account: int, instrument: int | None, qty: str = "0", cash: str = "0") -> None:
|
||||
async with get_sessionmaker()() as session:
|
||||
if instrument is not None:
|
||||
session.add(
|
||||
PositionSnapshot(
|
||||
account_id=account, instrument_id=instrument, as_of=AS_OF, source="tinvest",
|
||||
qty=D(qty), currency="RUB",
|
||||
)
|
||||
) # fmt: skip
|
||||
session.add(
|
||||
CashSnapshot(
|
||||
account_id=account, currency="RUB", as_of=AS_OF, source="tinvest", balance=D(cash)
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def flagged() -> set[int] | None:
|
||||
async with get_sessionmaker()() as session:
|
||||
row = (
|
||||
await session.execute(
|
||||
select(MetricDataQuality).where(
|
||||
MetricDataQuality.check_name == "position_vs_snapshot"
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if row is None or row.ref is None:
|
||||
return None
|
||||
return set(row.ref["instruments"])
|
||||
|
||||
|
||||
async def test_matching_snapshot_is_no_finding(app):
|
||||
account = await broker("Т")
|
||||
sber = await make_instrument(ticker="SBER")
|
||||
await buy(account, sber, 10)
|
||||
await snapshot(account, sber, "10")
|
||||
await refresh()
|
||||
|
||||
assert await flagged() is None
|
||||
|
||||
|
||||
async def test_a_position_the_broker_does_not_list_is_flagged(app):
|
||||
account = await broker("Т")
|
||||
sber, gazp = await make_instrument(ticker="SBER"), await make_instrument(ticker="GAZP")
|
||||
await buy(account, sber, 10)
|
||||
await buy(account, gazp, 5)
|
||||
await snapshot(account, sber, "10") # the broker lists SBER only
|
||||
await refresh()
|
||||
|
||||
assert await flagged() == {gazp}
|
||||
|
||||
|
||||
async def test_a_snapshot_that_lists_nothing_still_catches_a_leftover_position(app):
|
||||
"""Everything sold at the broker leaves a cash snapshot and no positions: the ledger
|
||||
still holding a paper is exactly what the check is for."""
|
||||
account = await broker("Т")
|
||||
sber = await make_instrument(ticker="SBER")
|
||||
await buy(account, sber, 10)
|
||||
await snapshot(account, None, cash="500")
|
||||
await refresh()
|
||||
|
||||
assert await flagged() == {sber}
|
||||
|
||||
|
||||
async def test_an_account_fed_by_a_report_has_no_snapshot_and_is_not_flagged(app):
|
||||
"""Sber and VTB send no snapshot; their positions are reconciled against the report."""
|
||||
tinvest = await broker("Т")
|
||||
sber_account = await broker("Сбер", source="report_sber")
|
||||
sber, mtss = await make_instrument(ticker="SBER"), await make_instrument(ticker="MTSS")
|
||||
await buy(tinvest, sber, 10)
|
||||
await snapshot(tinvest, sber, "10")
|
||||
await buy(sber_account, sber, 20)
|
||||
await buy(sber_account, mtss, 3)
|
||||
await refresh()
|
||||
|
||||
assert await flagged() is None
|
||||
@@ -100,3 +100,144 @@ async def test_categories_are_flat_with_parent_ids(client, auth_headers):
|
||||
by_name = {c["name"]: c for c in r.json()}
|
||||
assert by_name["Еда"]["parent_id"] is None
|
||||
assert by_name["Продукты"]["parent_id"] == food
|
||||
|
||||
|
||||
async def test_create_broker_account_for_report_imports(client, auth_headers):
|
||||
r = await client.post(
|
||||
"/api/v1/accounts",
|
||||
json={"name": " ИИС-Сбер ", "broker": "sber", "source_id": " 1234567 "},
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert r.status_code == 201, r.text
|
||||
row = r.json()
|
||||
assert row["name"] == "ИИС-Сбер"
|
||||
assert row["kind"] == "broker"
|
||||
assert row["source"] == "report_sber"
|
||||
assert row["source_id"] == "1234567"
|
||||
assert row["broker"] == "sber"
|
||||
assert row["currency"] == "RUB"
|
||||
assert row["role"] == "investment"
|
||||
assert row["primary_event_source"] == "report_sber"
|
||||
assert row["include_in_net_worth"] is True
|
||||
|
||||
listed = (await client.get("/api/v1/accounts", headers=auth_headers)).json()
|
||||
assert [a["id"] for a in listed] == [row["id"]]
|
||||
|
||||
|
||||
async def test_create_account_for_another_broker_and_currency(client, auth_headers):
|
||||
r = await client.post(
|
||||
"/api/v1/accounts",
|
||||
json={"name": "ВТБ", "broker": "vtb", "source_id": "42", "currency": "usd"},
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert r.status_code == 201
|
||||
assert (r.json()["source"], r.json()["currency"]) == ("report_vtb", "USD")
|
||||
|
||||
r = await client.post(
|
||||
"/api/v1/accounts",
|
||||
json={"name": "Прочий", "broker": "other", "source_id": "7"},
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert r.json()["source"] == "csv"
|
||||
assert r.json()["primary_event_source"] is None
|
||||
|
||||
|
||||
async def test_create_account_rejects_tinvest_blanks_and_duplicates(client, auth_headers):
|
||||
url = "/api/v1/accounts"
|
||||
body = {"name": "ИИС", "broker": "sber", "source_id": "1"}
|
||||
|
||||
r = await client.post(url, json={**body, "broker": "tinvest"}, headers=auth_headers)
|
||||
assert r.status_code == 400
|
||||
|
||||
r = await client.post(url, json={**body, "name": " "}, headers=auth_headers)
|
||||
assert r.status_code == 400
|
||||
r = await client.post(url, json={**body, "source_id": " "}, headers=auth_headers)
|
||||
assert r.status_code == 400
|
||||
|
||||
assert (await client.post(url, json=body, headers=auth_headers)).status_code == 201
|
||||
r = await client.post(url, json={**body, "name": "Другое имя"}, headers=auth_headers)
|
||||
assert r.status_code == 409
|
||||
assert r.headers["content-type"].startswith("application/problem+json")
|
||||
assert "ИИС" in r.json()["detail"]
|
||||
|
||||
# the same agreement number at another broker is a different account
|
||||
r = await client.post(url, json={**body, "broker": "vtb"}, headers=auth_headers)
|
||||
assert r.status_code == 201
|
||||
|
||||
|
||||
async def test_disabled_flag_is_a_user_switch_that_defaults_off(client, auth_headers):
|
||||
a = await make_account(name="Карта")
|
||||
assert (await client.get("/api/v1/accounts", headers=auth_headers)).json()[0][
|
||||
"disabled"
|
||||
] is False
|
||||
|
||||
r = await client.patch(f"/api/v1/accounts/{a}", json={"disabled": True}, headers=auth_headers)
|
||||
assert r.status_code == 200
|
||||
assert r.json()["disabled"] is True
|
||||
|
||||
r = await client.patch(f"/api/v1/accounts/{a}", json={"disabled": None}, headers=auth_headers)
|
||||
assert r.status_code == 400
|
||||
|
||||
r = await client.patch(f"/api/v1/accounts/{a}", json={"disabled": False}, headers=auth_headers)
|
||||
assert r.json()["disabled"] is False
|
||||
|
||||
|
||||
async def test_a_disabled_account_leaves_scopes_and_returns_when_switched_on(client, auth_headers):
|
||||
from datetime import date
|
||||
|
||||
from factories import make_event, refresh
|
||||
from fintracker.models import AccountKind, AccountRole, EventKind
|
||||
|
||||
kept = await make_account(
|
||||
name="Оставить", kind=AccountKind.broker, role=AccountRole.investment, balance=None
|
||||
)
|
||||
dropped = await make_account(
|
||||
name="Отключить", kind=AccountKind.broker, role=AccountRole.investment, balance=None
|
||||
)
|
||||
for account in (kept, dropped):
|
||||
await make_event(date(2026, 1, 5), account_id=account, kind=EventKind.deposit, amount=1000)
|
||||
await refresh()
|
||||
|
||||
async def scopes() -> set[str]:
|
||||
r = await client.get("/api/v1/analytics/scopes", headers=auth_headers)
|
||||
return {s["scope"] for s in r.json()}
|
||||
|
||||
assert {f"account:{kept}", f"account:{dropped}"} <= await scopes()
|
||||
|
||||
await client.patch(f"/api/v1/accounts/{dropped}", json={"disabled": True}, headers=auth_headers)
|
||||
await refresh()
|
||||
now = await scopes()
|
||||
assert f"account:{kept}" in now
|
||||
assert f"account:{dropped}" not in now
|
||||
|
||||
(row,) = [
|
||||
a
|
||||
for a in (await client.get("/api/v1/accounts", headers=auth_headers)).json()
|
||||
if a["id"] == dropped
|
||||
]
|
||||
assert row["disabled"] is True # still listed, so it can be switched back on
|
||||
assert row["value_rub"] is None # no scope, so no valuation any more
|
||||
|
||||
await client.patch(
|
||||
f"/api/v1/accounts/{dropped}", json={"disabled": False}, headers=auth_headers
|
||||
)
|
||||
await refresh()
|
||||
assert f"account:{dropped}" in await scopes()
|
||||
|
||||
|
||||
async def test_broker_account_shows_its_ledger_valuation(client, auth_headers):
|
||||
from datetime import date
|
||||
|
||||
from factories import make_event, refresh
|
||||
from fintracker.models import AccountKind, AccountRole, EventKind
|
||||
|
||||
broker = await make_account(
|
||||
name="Брокер", kind=AccountKind.broker, role=AccountRole.investment, balance=None
|
||||
)
|
||||
card = await make_account(name="Карта", balance="500")
|
||||
await make_event(date(2026, 1, 5), account_id=broker, kind=EventKind.deposit, amount=1000)
|
||||
await refresh()
|
||||
|
||||
rows = {a["id"]: a for a in (await client.get("/api/v1/accounts", headers=auth_headers)).json()}
|
||||
assert float(rows[broker]["value_rub"]) == 1000.0
|
||||
assert rows[card]["value_rub"] is None
|
||||
|
||||
@@ -71,6 +71,7 @@ async def make_account(
|
||||
balance_as_of: datetime | None = None,
|
||||
include_in_net_worth: bool = True,
|
||||
archived: bool = False,
|
||||
disabled: bool = False,
|
||||
mirror_of_account_id: int | None = None,
|
||||
source: str = "zenmoney",
|
||||
source_id: str | None = None,
|
||||
@@ -87,6 +88,7 @@ async def make_account(
|
||||
balance_as_of=balance_as_of or datetime.now(UTC),
|
||||
include_in_net_worth=include_in_net_worth,
|
||||
archived=archived,
|
||||
disabled=disabled,
|
||||
mirror_of_account_id=mirror_of_account_id,
|
||||
)
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user