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