Files
fin-tracker/backend/tests/analytics/test_snapshot_reconcile.py
T
Dmitry 4236993106 feat(accounts): выключатель счёта, ручное создание брокерских счетов, брокеры в капитале
Отключённый счёт выпадает из всех скоупов, капитала и подсказок импорта, события остаются в леджере. Брокерский счёт без баланса попадает в net worth по оценке из леджера. POST /accounts заводит счёт под импорт отчёта, AccountOut отдаёт value_rub. Сверка со снапшотом не считает расхождением позиции счетов, у которых снапшота нет (отчёты Сбера и ВТБ).
2026-09-19 21:54:42 +03:00

122 lines
3.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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