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
+66
View File
@@ -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