feat(ledger): импорт отчётов в леджер — приём, дедупликация, pending_instrument
Поток: upload -> raw_report_file (sha256 UNIQUE) -> parse -> raw_report_line,
событий в леджере ещё нет -> preview -> POST /imports/{id}/commit ->
ledger/ingest.py резолвит инструмент, считает dedupe_key, пишет event
confirmed | shadow (plan §1.6 B) — сравнивая account.primary_event_source
с источником отчёта, а не гадая. Нерезолвленный инструмент ждёт в
pending_instrument, никогда не угадывается; POST /instruments/pending/{id}/resolve
привязывает и пересобирает лоты.
ledger/dedupe.py — shadow-матчинг случая B двумя проходами (точная дата, затем
±1 рабочий день, жадно 1:1, |price| ±0,5 %). Шаги shadow_dedupe и
report_reconcile зарегистрированы перед quality: оба говорят через FINDINGS.
/instruments/pending регистрируется в app.py ДО routers/instruments.py:
FastAPI сопоставляет маршруты по порядку, и /instruments/{id} с типом int
отвечает 422 на нечисловой сегмент, а не проваливается дальше.
Контракт — docs/ai/import-contract.md, общий для бэкенда и Flutter.
This commit is contained in:
@@ -0,0 +1,461 @@
|
||||
"""`/imports` and `/instruments/pending` against `docs/ai/import-contract.md`.
|
||||
|
||||
The parser is a stub registered for the duration of a test: the real ones are a separate
|
||||
contract, and these tests are about the HTTP surface the Flutter client was written against.
|
||||
`create_app` mounts both routers, and the order it mounts them in matters — that is what
|
||||
`test_pending_path_is_not_eaten_by_the_instrument_id_route` guards.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from factories import make_instrument
|
||||
from fintracker.db import get_sessionmaker
|
||||
from fintracker.models import (
|
||||
Account,
|
||||
AccountKind,
|
||||
AccountRole,
|
||||
Event,
|
||||
EventKind,
|
||||
EventSource,
|
||||
EventStatus,
|
||||
Instrument,
|
||||
PendingInstrument,
|
||||
RawReportFile,
|
||||
)
|
||||
from fintracker.sources.reports import registry
|
||||
from fintracker.sources.reports.base import (
|
||||
BrokerEvent,
|
||||
CashEnd,
|
||||
InstrumentRef,
|
||||
ParsedReport,
|
||||
ParseError,
|
||||
PositionEnd,
|
||||
)
|
||||
|
||||
ACCOUNT_NO = "S930W42"
|
||||
UNKNOWN_ISIN = "RU000A1035S8"
|
||||
PREFIX = "/api/v1"
|
||||
|
||||
GAZP = InstrumentRef(ticker="GAZP", board="TQBR", source_key="TICKER:GAZP/TQBR", name="Газпром")
|
||||
UNKNOWN = InstrumentRef(
|
||||
isin=UNKNOWN_ISIN,
|
||||
ticker="STME",
|
||||
name="Первая-ВечныйПортф БПИФ",
|
||||
currency="RUB",
|
||||
asset_class_hint="fund",
|
||||
source_key=f"ISIN:{UNKNOWN_ISIN}",
|
||||
)
|
||||
UNKNOWN2_ISIN = "RU000A105KR6"
|
||||
UNKNOWN2 = InstrumentRef(
|
||||
isin=UNKNOWN2_ISIN,
|
||||
ticker="TRUR",
|
||||
name="Тинькофф Вечный портфель",
|
||||
currency="RUB",
|
||||
asset_class_hint="fund",
|
||||
source_key=f"ISIN:{UNKNOWN2_ISIN}",
|
||||
)
|
||||
UNKNOWN_BY_TRADE = {"U1": UNKNOWN, "U2": UNKNOWN2}
|
||||
|
||||
|
||||
class StubParser:
|
||||
"""Reads `FAKE|<account>|<trade numbers>|<padding>` and yields one buy per trade number.
|
||||
|
||||
The padding exists only so two files with the same trades can have different bytes —
|
||||
which is how the overlapping-periods case is expressed without a real report.
|
||||
"""
|
||||
|
||||
broker = "sber"
|
||||
name = "report_sber"
|
||||
formats: tuple[str, ...] = ("txt",)
|
||||
version = "1"
|
||||
|
||||
def sniff(self, data: bytes, filename: str) -> bool:
|
||||
return data.startswith(b"FAKE|")
|
||||
|
||||
def parse(self, data: bytes, filename: str) -> ParsedReport:
|
||||
text = data.decode("utf-8")
|
||||
_, account_no, trades, *_ = text.split("|")
|
||||
if account_no == "BROKEN":
|
||||
raise ParseError("раздел «Сделки» не найден")
|
||||
events = []
|
||||
for line_no, trade_no in enumerate(t for t in trades.split(",") if t):
|
||||
ref = UNKNOWN_BY_TRADE.get(trade_no, GAZP)
|
||||
events.append(
|
||||
BrokerEvent(
|
||||
kind=EventKind.buy,
|
||||
trade_date=date(2026, 2, 24),
|
||||
settle_date=date(2026, 2, 25),
|
||||
amount=Decimal("-1000"),
|
||||
currency="RUB",
|
||||
instrument=ref,
|
||||
quantity=Decimal("10"),
|
||||
price=Decimal("100"),
|
||||
price_currency="RUB",
|
||||
fee=Decimal("0.39"),
|
||||
trade_no=trade_no,
|
||||
description="Покупка",
|
||||
raw_line_no=line_no + 1,
|
||||
)
|
||||
)
|
||||
return ParsedReport(
|
||||
broker="sber",
|
||||
account_external_id=account_no,
|
||||
period_from=date(2026, 2, 11),
|
||||
period_to=date(2026, 9, 17),
|
||||
parser_version="1",
|
||||
events=events,
|
||||
positions_end=[PositionEnd(instrument=GAZP, qty=Decimal("10"))],
|
||||
cash_end=[CashEnd(currency="RUB", balance=Decimal("-1000"))],
|
||||
warnings=["Раздел «Купонный доход» отсутствует в файле"],
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def parser():
|
||||
# first in the list, so `registry.pick` and `registry.get("report_sber")` both find the
|
||||
# stub rather than the real Sber parser, whose `sniff` is deliberately generous.
|
||||
stub = StubParser()
|
||||
registry.PARSERS.insert(0, stub)
|
||||
yield stub
|
||||
registry.PARSERS.remove(stub)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def http(app, client: AsyncClient) -> AsyncClient:
|
||||
"""The import routes come mounted by `create_app` — nothing to wire up here.
|
||||
|
||||
Kept as a named fixture so every test in this module reads the same way, and so the
|
||||
day the mounting changes there is one place to look.
|
||||
"""
|
||||
return client
|
||||
|
||||
|
||||
def content(trades: str = "T1", account_no: str = ACCOUNT_NO, pad: str = "") -> bytes:
|
||||
return f"FAKE|{account_no}|{trades}|{pad}".encode()
|
||||
|
||||
|
||||
async def make_account(external_id: str = ACCOUNT_NO) -> int:
|
||||
async with get_sessionmaker()() as session:
|
||||
account = Account(
|
||||
kind=AccountKind.broker,
|
||||
source="report_sber",
|
||||
source_id=external_id,
|
||||
name="Сбер ИИС",
|
||||
currency="RUB",
|
||||
role=AccountRole.investment,
|
||||
include_in_net_worth=False,
|
||||
primary_event_source=EventSource.report_sber,
|
||||
)
|
||||
session.add(account)
|
||||
await session.commit()
|
||||
return account.id
|
||||
|
||||
|
||||
async def upload(http: AsyncClient, headers, data: bytes, **form):
|
||||
return await http.post(
|
||||
f"{PREFIX}/imports",
|
||||
headers=headers,
|
||||
files={"file": ("report.txt", data, "text/plain")},
|
||||
data={k: str(v) for k, v in form.items()},
|
||||
)
|
||||
|
||||
|
||||
async def test_same_bytes_twice_return_the_first_import(app, http, auth_headers, parser):
|
||||
await make_account()
|
||||
await make_instrument(ticker="GAZP", name="Газпром")
|
||||
|
||||
first = await upload(http, auth_headers, content())
|
||||
second = await upload(http, auth_headers, content())
|
||||
|
||||
assert first.status_code == 200, first.text
|
||||
assert second.status_code == 200, second.text
|
||||
assert first.json()["duplicate_of_id"] is None
|
||||
assert second.json()["duplicate_of_id"] == first.json()["id"]
|
||||
assert second.json()["id"] == first.json()["id"]
|
||||
async with get_sessionmaker()() as session:
|
||||
assert await session.scalar(select(func.count()).select_from(RawReportFile)) == 1
|
||||
|
||||
|
||||
async def test_overlapping_reports_import_each_trade_once(app, http, auth_headers, parser):
|
||||
"""The second file repeats T1 and adds T2: committing it creates exactly one event."""
|
||||
await make_account()
|
||||
await make_instrument(ticker="GAZP", name="Газпром")
|
||||
|
||||
january = (await upload(http, auth_headers, content("T1"))).json()
|
||||
commit_one = await http.post(
|
||||
f"{PREFIX}/imports/{january['id']}/commit", headers=auth_headers, json={}
|
||||
)
|
||||
assert commit_one.status_code == 200, commit_one.text
|
||||
assert commit_one.json()["events_created"] == 1
|
||||
|
||||
april = (await upload(http, auth_headers, content("T1,T2", pad="april"))).json()
|
||||
assert april["counts"]["events_duplicate"] == 1
|
||||
assert april["counts"]["events_new"] == 1
|
||||
commit_two = await http.post(
|
||||
f"{PREFIX}/imports/{april['id']}/commit", headers=auth_headers, json={}
|
||||
)
|
||||
assert commit_two.status_code == 200, commit_two.text
|
||||
assert commit_two.json()["events_created"] == 1
|
||||
assert commit_two.json()["events_updated"] == 1
|
||||
|
||||
async with get_sessionmaker()() as session:
|
||||
assert await session.scalar(select(func.count()).select_from(Event)) == 2
|
||||
|
||||
|
||||
async def test_committing_a_committed_import_is_a_conflict(app, http, auth_headers, parser):
|
||||
await make_account()
|
||||
await make_instrument(ticker="GAZP", name="Газпром")
|
||||
created = (await upload(http, auth_headers, content())).json()
|
||||
|
||||
first = await http.post(
|
||||
f"{PREFIX}/imports/{created['id']}/commit", headers=auth_headers, json={}
|
||||
)
|
||||
second = await http.post(
|
||||
f"{PREFIX}/imports/{created['id']}/commit", headers=auth_headers, json={}
|
||||
)
|
||||
|
||||
assert first.status_code == 200, first.text
|
||||
assert second.status_code == 409, second.text
|
||||
assert second.json()["status"] == 409
|
||||
async with get_sessionmaker()() as session:
|
||||
assert await session.scalar(select(func.count()).select_from(Event)) == 1
|
||||
|
||||
|
||||
async def test_commit_without_an_account_is_refused(app, http, auth_headers, parser):
|
||||
await make_instrument(ticker="GAZP", name="Газгром")
|
||||
created = (await upload(http, auth_headers, content(account_no="NOSUCH"))).json()
|
||||
assert created["account_id"] is None
|
||||
assert created["account_suggestions"] == []
|
||||
|
||||
response = await http.post(
|
||||
f"{PREFIX}/imports/{created['id']}/commit", headers=auth_headers, json={}
|
||||
)
|
||||
|
||||
assert response.status_code == 422, response.text
|
||||
body = response.json()
|
||||
assert body["status"] == 422
|
||||
assert "account_id" in body["detail"]
|
||||
|
||||
|
||||
async def test_unreadable_file_is_kept_as_a_failed_import(app, http, auth_headers, parser):
|
||||
response = await upload(http, auth_headers, content(account_no="BROKEN"))
|
||||
|
||||
assert response.status_code == 422, response.text
|
||||
body = response.json()
|
||||
import_id = body["errors"][0]["import_id"]
|
||||
async with get_sessionmaker()() as session:
|
||||
row = await session.get(RawReportFile, import_id)
|
||||
assert row is not None
|
||||
assert row.parse_status.value == "failed"
|
||||
assert "Сделки" in (row.error or "")
|
||||
|
||||
|
||||
async def test_unknown_format_is_415(app, http, auth_headers, parser):
|
||||
response = await upload(http, auth_headers, b"not a report at all")
|
||||
assert response.status_code == 415, response.text
|
||||
|
||||
|
||||
async def test_pending_instrument_flows_through_all_three_modes(app, http, auth_headers, parser):
|
||||
account_id = await make_account()
|
||||
await make_instrument(ticker="GAZP", name="Газпром")
|
||||
created = (await upload(http, auth_headers, content("T1,U1"))).json()
|
||||
|
||||
assert created["counts"]["events_pending"] == 1
|
||||
assert [p["source_key"] for p in created["pending_instruments"]] == [f"ISIN:{UNKNOWN_ISIN}"]
|
||||
|
||||
commit = await http.post(
|
||||
f"{PREFIX}/imports/{created['id']}/commit", headers=auth_headers, json={}
|
||||
)
|
||||
assert commit.status_code == 200, commit.text
|
||||
assert commit.json()["events_skipped"] == 1
|
||||
assert commit.json()["pending_instruments"] == 1
|
||||
|
||||
listing = await http.get(f"{PREFIX}/instruments/pending", headers=auth_headers)
|
||||
assert listing.status_code == 200, listing.text
|
||||
row = listing.json()[0]
|
||||
assert row["status"] == "pending"
|
||||
assert row["asset_class_hint"] == "fund"
|
||||
assert isinstance(row["sample_price"], str)
|
||||
|
||||
# (a) ignore: the events stop waiting but gain no instrument
|
||||
ignored = await http.post(
|
||||
f"{PREFIX}/instruments/pending/{row['id']}/resolve",
|
||||
headers=auth_headers,
|
||||
json={"action": "ignore"},
|
||||
)
|
||||
assert ignored.status_code == 200, ignored.text
|
||||
assert ignored.json()["status"] == "ignored"
|
||||
assert ignored.json()["events_bound"] == 1
|
||||
assert ignored.json()["alias_created"] is False
|
||||
async with get_sessionmaker()() as session:
|
||||
statuses = set(
|
||||
(await session.execute(select(Event.status).where(Event.instrument_id.is_(None))))
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
assert EventStatus.pending not in statuses
|
||||
|
||||
# (b) create: a second unknown paper becomes a real instrument
|
||||
second = (await upload(http, auth_headers, content("U2", pad="second"))).json()
|
||||
pending_id = second["pending_instruments"][0]["id"]
|
||||
await http.post(f"{PREFIX}/imports/{second['id']}/commit", headers=auth_headers, json={})
|
||||
created_row = await http.post(
|
||||
f"{PREFIX}/instruments/pending/{pending_id}/resolve",
|
||||
headers=auth_headers,
|
||||
json={
|
||||
"action": "create",
|
||||
"instrument": {
|
||||
"asset_class": "fund",
|
||||
"isin": UNKNOWN2_ISIN,
|
||||
"ticker": "TRUR",
|
||||
"board": "TQTF",
|
||||
"name": "Тинькофф Вечный портфель",
|
||||
"currency": "RUB",
|
||||
"lot": 1,
|
||||
},
|
||||
},
|
||||
)
|
||||
assert created_row.status_code == 200, created_row.text
|
||||
assert created_row.json()["status"] == "resolved"
|
||||
assert created_row.json()["alias_created"] is True
|
||||
instrument_id = created_row.json()["instrument_id"]
|
||||
|
||||
# (c) link: resolving the same key again is a conflict, a fresh one links
|
||||
again = await http.post(
|
||||
f"{PREFIX}/instruments/pending/{pending_id}/resolve",
|
||||
headers=auth_headers,
|
||||
json={"action": "link", "instrument_id": instrument_id},
|
||||
)
|
||||
assert again.status_code == 409, again.text
|
||||
|
||||
async with get_sessionmaker()() as session:
|
||||
instrument = await session.get(Instrument, instrument_id)
|
||||
assert instrument is not None
|
||||
assert instrument.asset_class.value == "fund"
|
||||
bound = (
|
||||
(await session.execute(select(Event).where(Event.instrument_id == instrument_id)))
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
assert bound
|
||||
assert all(e.status == EventStatus.confirmed for e in bound)
|
||||
assert all(e.account_id == account_id for e in bound)
|
||||
|
||||
|
||||
async def test_link_mode_binds_the_waiting_events(app, http, auth_headers, parser):
|
||||
await make_account()
|
||||
target = await make_instrument(ticker="STME", name="БПИФ", board="TQTF")
|
||||
created = (await upload(http, auth_headers, content("U1"))).json()
|
||||
await http.post(f"{PREFIX}/imports/{created['id']}/commit", headers=auth_headers, json={})
|
||||
pending_id = created["pending_instruments"][0]["id"]
|
||||
|
||||
response = await http.post(
|
||||
f"{PREFIX}/instruments/pending/{pending_id}/resolve",
|
||||
headers=auth_headers,
|
||||
json={"action": "link", "instrument_id": target},
|
||||
)
|
||||
|
||||
assert response.status_code == 200, response.text
|
||||
assert response.json() == {
|
||||
"id": pending_id,
|
||||
"status": "resolved",
|
||||
"instrument_id": target,
|
||||
"events_bound": 1,
|
||||
"alias_created": True,
|
||||
"metrics_refreshed": True,
|
||||
}
|
||||
|
||||
|
||||
async def test_money_is_a_string_and_asset_class_is_not_an_enum(app, http, auth_headers, parser):
|
||||
await make_account()
|
||||
await make_instrument(ticker="GAZP", name="Газпром")
|
||||
|
||||
body = (await upload(http, auth_headers, content("T1,U1"))).json()
|
||||
|
||||
sample = body["sample_events"][0]
|
||||
assert sample["amount"] == "-1000"
|
||||
assert sample["quantity"] == "10"
|
||||
assert sample["price"] == "100"
|
||||
assert isinstance(sample["kind"], str)
|
||||
pending = body["pending_instruments"][0]
|
||||
assert isinstance(pending["asset_class_hint"], str)
|
||||
assert isinstance(pending["sample_quantity"], str)
|
||||
recon = body["reconciliation"]
|
||||
assert isinstance(recon["cash"][0]["balance_report"], str)
|
||||
assert body["warnings"]
|
||||
|
||||
|
||||
async def test_reconciliation_compares_the_report_against_the_ledger(
|
||||
app, http, auth_headers, parser
|
||||
):
|
||||
await make_account()
|
||||
await make_instrument(ticker="GAZP", name="Газпром")
|
||||
created = (await upload(http, auth_headers, content("T1"))).json()
|
||||
|
||||
result = await http.post(
|
||||
f"{PREFIX}/imports/{created['id']}/commit", headers=auth_headers, json={}
|
||||
)
|
||||
|
||||
recon = result.json()["reconciliation"]
|
||||
assert recon["as_of"] == "2026-09-17"
|
||||
assert recon["positions"][0]["qty_report"] == "10"
|
||||
assert recon["positions"][0]["qty_derived"] == "10.0000000000"
|
||||
assert recon["cash"][0]["currency"] == "RUB"
|
||||
assert recon["matches"] is True
|
||||
|
||||
|
||||
async def test_uncommitted_import_can_be_deleted_and_a_committed_one_cannot(
|
||||
app, http, auth_headers, parser
|
||||
):
|
||||
await make_account()
|
||||
await make_instrument(ticker="GAZP", name="Газпром")
|
||||
created = (await upload(http, auth_headers, content("T1"))).json()
|
||||
|
||||
dropped = await http.delete(f"{PREFIX}/imports/{created['id']}", headers=auth_headers)
|
||||
assert dropped.status_code == 204, dropped.text
|
||||
|
||||
again = (await upload(http, auth_headers, content("T1"))).json()
|
||||
await http.post(f"{PREFIX}/imports/{again['id']}/commit", headers=auth_headers, json={})
|
||||
refused = await http.delete(f"{PREFIX}/imports/{again['id']}", headers=auth_headers)
|
||||
assert refused.status_code == 409, refused.text
|
||||
|
||||
|
||||
async def test_listing_and_fetching_one_import(app, http, auth_headers, parser):
|
||||
await make_account()
|
||||
await make_instrument(ticker="GAZP", name="Газпром")
|
||||
created = (await upload(http, auth_headers, content("T1"))).json()
|
||||
|
||||
listing = await http.get(f"{PREFIX}/imports", headers=auth_headers)
|
||||
one = await http.get(f"{PREFIX}/imports/{created['id']}", headers=auth_headers)
|
||||
|
||||
assert [r["id"] for r in listing.json()] == [created["id"]]
|
||||
assert listing.json()[0]["account_name"] == "Сбер ИИС"
|
||||
assert one.json()["id"] == created["id"]
|
||||
assert one.json()["parse_status"] == "parsed"
|
||||
|
||||
|
||||
async def test_pending_path_is_not_eaten_by_the_instrument_id_route(app, http, auth_headers):
|
||||
"""`pending_router` is mounted first on purpose — otherwise this 422s on int('pending')."""
|
||||
response = await http.get(f"{PREFIX}/instruments/pending", headers=auth_headers)
|
||||
assert response.status_code == 200, response.text
|
||||
|
||||
|
||||
async def test_unknown_import_is_404(app, http, auth_headers):
|
||||
response = await http.get(f"{PREFIX}/imports/999", headers=auth_headers)
|
||||
assert response.status_code == 404, response.text
|
||||
|
||||
|
||||
async def test_pending_rows_are_created_by_the_preview_alone(app, http, auth_headers, parser):
|
||||
await make_account()
|
||||
await upload(http, auth_headers, content("U1"))
|
||||
|
||||
async with get_sessionmaker()() as session:
|
||||
assert await session.scalar(select(func.count()).select_from(PendingInstrument)) == 1
|
||||
assert await session.scalar(select(func.count()).select_from(Event)) == 0
|
||||
@@ -0,0 +1,241 @@
|
||||
"""Shadow matching: which report rows are the API's rows seen twice, and which are news."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from factories import make_account, make_event, make_instrument
|
||||
from fintracker.analytics import FINDINGS
|
||||
from fintracker.db import get_sessionmaker
|
||||
from fintracker.ledger.dedupe import match_shadow_events
|
||||
from fintracker.models import AccountKind, AccountRole, Event, EventKind, EventStatus
|
||||
|
||||
TRADE_DAY = date(2026, 3, 10)
|
||||
|
||||
|
||||
async def broker_account() -> int:
|
||||
return await make_account(
|
||||
name="Брокерский",
|
||||
kind=AccountKind.broker,
|
||||
role=AccountRole.investment,
|
||||
balance=None,
|
||||
include_in_net_worth=False,
|
||||
source="tinvest",
|
||||
)
|
||||
|
||||
|
||||
async def run() -> tuple[int, int]:
|
||||
FINDINGS.reset()
|
||||
async with get_sessionmaker()() as session:
|
||||
result = await match_shadow_events(session)
|
||||
await session.commit()
|
||||
return result.matched, result.unmatched
|
||||
|
||||
|
||||
async def matched_ids() -> dict[int, int | None]:
|
||||
async with get_sessionmaker()() as session:
|
||||
rows = (
|
||||
await session.execute(select(Event).where(Event.status == EventStatus.shadow))
|
||||
).scalars()
|
||||
return {e.id: (e.meta or {}).get("matched_event_id") for e in rows}
|
||||
|
||||
|
||||
async def test_price_within_half_a_percent_is_the_same_trade(app):
|
||||
account = await broker_account()
|
||||
instrument = await make_instrument(ticker="GAZP")
|
||||
api = await make_event(
|
||||
TRADE_DAY,
|
||||
account_id=account,
|
||||
kind=EventKind.buy,
|
||||
instrument_id=instrument,
|
||||
quantity="10",
|
||||
price="100",
|
||||
amount="-1000",
|
||||
)
|
||||
shadow = await make_event(
|
||||
TRADE_DAY,
|
||||
account_id=account,
|
||||
kind=EventKind.buy,
|
||||
instrument_id=instrument,
|
||||
quantity="10",
|
||||
price="100.4",
|
||||
amount="-1004",
|
||||
status=EventStatus.shadow,
|
||||
)
|
||||
|
||||
matched, unmatched = await run()
|
||||
|
||||
assert (matched, unmatched) == (1, 0)
|
||||
assert await matched_ids() == {shadow: api}
|
||||
assert FINDINGS.items == []
|
||||
|
||||
|
||||
async def test_price_off_by_two_percent_is_not_matched_and_is_reported(app):
|
||||
account = await broker_account()
|
||||
instrument = await make_instrument(ticker="GAZP")
|
||||
await make_event(
|
||||
TRADE_DAY,
|
||||
account_id=account,
|
||||
kind=EventKind.buy,
|
||||
instrument_id=instrument,
|
||||
quantity="10",
|
||||
price="100",
|
||||
amount="-1000",
|
||||
)
|
||||
shadow = await make_event(
|
||||
TRADE_DAY,
|
||||
account_id=account,
|
||||
kind=EventKind.buy,
|
||||
instrument_id=instrument,
|
||||
quantity="10",
|
||||
price="102",
|
||||
amount="-1020",
|
||||
status=EventStatus.shadow,
|
||||
)
|
||||
|
||||
matched, unmatched = await run()
|
||||
|
||||
assert (matched, unmatched) == (0, 1)
|
||||
assert await matched_ids() == {shadow: None}
|
||||
assert [f.check_name for f in FINDINGS.items] == ["report_only_event"]
|
||||
assert "нет в API" in FINDINGS.items[0].detail
|
||||
|
||||
|
||||
async def test_one_confirmed_event_closes_only_one_shadow(app):
|
||||
account = await broker_account()
|
||||
instrument = await make_instrument(ticker="GAZP")
|
||||
await make_event(
|
||||
TRADE_DAY,
|
||||
account_id=account,
|
||||
kind=EventKind.buy,
|
||||
instrument_id=instrument,
|
||||
quantity="10",
|
||||
price="100",
|
||||
amount="-1000",
|
||||
)
|
||||
first = await make_event(
|
||||
TRADE_DAY,
|
||||
account_id=account,
|
||||
kind=EventKind.buy,
|
||||
instrument_id=instrument,
|
||||
quantity="10",
|
||||
price="100",
|
||||
amount="-1000",
|
||||
status=EventStatus.shadow,
|
||||
)
|
||||
second = await make_event(
|
||||
TRADE_DAY,
|
||||
account_id=account,
|
||||
kind=EventKind.buy,
|
||||
instrument_id=instrument,
|
||||
quantity="10",
|
||||
price="100",
|
||||
amount="-1000",
|
||||
status=EventStatus.shadow,
|
||||
)
|
||||
|
||||
matched, unmatched = await run()
|
||||
|
||||
assert (matched, unmatched) == (1, 1)
|
||||
marks = await matched_ids()
|
||||
assert sorted(marks) == sorted([first, second])
|
||||
assert sum(1 for v in marks.values() if v is not None) == 1
|
||||
assert [f.check_name for f in FINDINGS.items] == ["report_only_event"]
|
||||
|
||||
|
||||
async def test_settlement_date_one_business_day_later_still_matches(app):
|
||||
"""The report prints T+1 where the API prints T — the relaxed level exists for this."""
|
||||
account = await broker_account()
|
||||
instrument = await make_instrument(ticker="GAZP")
|
||||
api = await make_event(
|
||||
date(2026, 3, 13), # Friday
|
||||
account_id=account,
|
||||
kind=EventKind.buy,
|
||||
instrument_id=instrument,
|
||||
quantity="10",
|
||||
price="100",
|
||||
amount="-1000",
|
||||
)
|
||||
shadow = await make_event(
|
||||
date(2026, 3, 16), # the next business day
|
||||
account_id=account,
|
||||
kind=EventKind.buy,
|
||||
instrument_id=instrument,
|
||||
quantity="10",
|
||||
price="100",
|
||||
amount="-1000",
|
||||
status=EventStatus.shadow,
|
||||
)
|
||||
|
||||
matched, unmatched = await run()
|
||||
|
||||
assert (matched, unmatched) == (1, 0)
|
||||
assert await matched_ids() == {shadow: api}
|
||||
|
||||
|
||||
async def test_an_exact_date_wins_over_a_next_day_candidate(app):
|
||||
account = await broker_account()
|
||||
instrument = await make_instrument(ticker="GAZP")
|
||||
same_day = await make_event(
|
||||
TRADE_DAY,
|
||||
account_id=account,
|
||||
kind=EventKind.buy,
|
||||
instrument_id=instrument,
|
||||
quantity="10",
|
||||
price="100",
|
||||
amount="-1000",
|
||||
)
|
||||
await make_event(
|
||||
date(2026, 3, 11),
|
||||
account_id=account,
|
||||
kind=EventKind.buy,
|
||||
instrument_id=instrument,
|
||||
quantity="10",
|
||||
price="100",
|
||||
amount="-1000",
|
||||
)
|
||||
shadow = await make_event(
|
||||
TRADE_DAY,
|
||||
account_id=account,
|
||||
kind=EventKind.buy,
|
||||
instrument_id=instrument,
|
||||
quantity="10",
|
||||
price="100",
|
||||
amount="-1000",
|
||||
status=EventStatus.shadow,
|
||||
)
|
||||
|
||||
await run()
|
||||
|
||||
assert await matched_ids() == {shadow: same_day}
|
||||
|
||||
|
||||
async def test_quantities_must_agree_exactly(app):
|
||||
account = await broker_account()
|
||||
instrument = await make_instrument(ticker="GAZP")
|
||||
await make_event(
|
||||
TRADE_DAY,
|
||||
account_id=account,
|
||||
kind=EventKind.buy,
|
||||
instrument_id=instrument,
|
||||
quantity="10",
|
||||
price="100",
|
||||
amount="-1000",
|
||||
)
|
||||
await make_event(
|
||||
TRADE_DAY,
|
||||
account_id=account,
|
||||
kind=EventKind.buy,
|
||||
instrument_id=instrument,
|
||||
quantity=Decimal("11"),
|
||||
price="100",
|
||||
amount="-1100",
|
||||
status=EventStatus.shadow,
|
||||
)
|
||||
|
||||
matched, unmatched = await run()
|
||||
|
||||
assert (matched, unmatched) == (0, 1)
|
||||
@@ -0,0 +1,338 @@
|
||||
"""`BrokerEvent` -> `event`: idempotency, pending instruments, shadow status, side effects.
|
||||
|
||||
Every `ParsedReport` here is built by hand: the parsers are a separate contract and a bug in
|
||||
one of them must not be able to fail these tests, which are about what the ledger does with
|
||||
what a parser produced.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from factories import make_instrument
|
||||
from fintracker.db import get_sessionmaker
|
||||
from fintracker.ledger.ingest import ingest
|
||||
from fintracker.ledger.report_import import InstrumentSpec, resolve_pending
|
||||
from fintracker.models import (
|
||||
Account,
|
||||
AccountKind,
|
||||
AccountRole,
|
||||
AssetClass,
|
||||
Event,
|
||||
EventKind,
|
||||
EventSource,
|
||||
EventStatus,
|
||||
Instrument,
|
||||
InstrumentAlias,
|
||||
Lot,
|
||||
PendingInstrument,
|
||||
PendingInstrumentStatus,
|
||||
PriceManual,
|
||||
RawReportFile,
|
||||
RawReportLine,
|
||||
ReportParseStatus,
|
||||
)
|
||||
from fintracker.sources.reports.base import (
|
||||
BrokerEvent,
|
||||
InstrumentRef,
|
||||
ParsedReport,
|
||||
)
|
||||
|
||||
SOURCE = "report_sber"
|
||||
ACCOUNT_NO = "S930W42"
|
||||
UNKNOWN_ISIN = "RU000A1035S8"
|
||||
|
||||
|
||||
async def make_broker_account(
|
||||
*, primary: EventSource | None = EventSource.report_sber, source: str = SOURCE
|
||||
) -> int:
|
||||
async with get_sessionmaker()() as session:
|
||||
account = Account(
|
||||
kind=AccountKind.broker,
|
||||
source=source,
|
||||
source_id=ACCOUNT_NO,
|
||||
name="Сбер ИИС",
|
||||
currency="RUB",
|
||||
role=AccountRole.investment,
|
||||
include_in_net_worth=False,
|
||||
primary_event_source=primary,
|
||||
)
|
||||
session.add(account)
|
||||
await session.commit()
|
||||
return account.id
|
||||
|
||||
|
||||
async def make_file(account_id: int | None = None) -> int:
|
||||
async with get_sessionmaker()() as session:
|
||||
row = RawReportFile(
|
||||
broker="sber",
|
||||
filename="report.html",
|
||||
sha256="0" * 64,
|
||||
size_bytes=10,
|
||||
parser_name=SOURCE,
|
||||
parser_version="1",
|
||||
parse_status=ReportParseStatus.parsed,
|
||||
account_id=account_id,
|
||||
)
|
||||
session.add(row)
|
||||
await session.commit()
|
||||
return row.id
|
||||
|
||||
|
||||
def unknown_ref() -> InstrumentRef:
|
||||
return InstrumentRef(
|
||||
isin=UNKNOWN_ISIN,
|
||||
ticker="STME",
|
||||
name="Первая-ВечныйПортф БПИФ",
|
||||
currency="RUB",
|
||||
asset_class_hint="fund",
|
||||
source_key=f"ISIN:{UNKNOWN_ISIN}",
|
||||
)
|
||||
|
||||
|
||||
def report(
|
||||
events: list[BrokerEvent],
|
||||
*,
|
||||
meta: dict | None = None,
|
||||
instruments: list[InstrumentRef] | None = None,
|
||||
) -> ParsedReport:
|
||||
return ParsedReport(
|
||||
broker="sber",
|
||||
account_external_id=ACCOUNT_NO,
|
||||
period_from=date(2026, 2, 11),
|
||||
period_to=date(2026, 9, 17),
|
||||
parser_version="1",
|
||||
events=events,
|
||||
instruments=instruments or [],
|
||||
meta=meta or {},
|
||||
)
|
||||
|
||||
|
||||
def buy(
|
||||
ref: InstrumentRef | None,
|
||||
*,
|
||||
trade_no: str | None = "15678045077",
|
||||
d: date = date(2026, 2, 24),
|
||||
qty: str = "10",
|
||||
price: str = "100",
|
||||
line_no: int = 1,
|
||||
) -> BrokerEvent:
|
||||
return BrokerEvent(
|
||||
kind=EventKind.buy,
|
||||
trade_date=d,
|
||||
settle_date=d,
|
||||
amount=Decimal(qty) * Decimal(price) * -1,
|
||||
currency="RUB",
|
||||
instrument=ref,
|
||||
quantity=Decimal(qty),
|
||||
price=Decimal(price),
|
||||
price_currency="RUB",
|
||||
fee=Decimal("0.39"),
|
||||
trade_no=trade_no,
|
||||
description="Покупка",
|
||||
raw_line_no=line_no,
|
||||
)
|
||||
|
||||
|
||||
async def run_ingest(account_id: int, parsed: ParsedReport, *, file_id: int | None = None):
|
||||
async with get_sessionmaker()() as session:
|
||||
account = await session.get(Account, account_id)
|
||||
assert account is not None
|
||||
result = await ingest(
|
||||
session, account=account, parsed=parsed, source=SOURCE, file_id=file_id
|
||||
)
|
||||
await session.commit()
|
||||
return result
|
||||
|
||||
|
||||
async def count(model, *where) -> int:
|
||||
async with get_sessionmaker()() as session:
|
||||
return await session.scalar(select(func.count()).select_from(model).where(*where)) or 0
|
||||
|
||||
|
||||
async def test_same_report_twice_creates_nothing_the_second_time(app):
|
||||
account_id = await make_broker_account()
|
||||
instrument_id = await make_instrument(ticker="GAZP", name="Газпром")
|
||||
ref = InstrumentRef(ticker="GAZP", board="TQBR", source_key="TICKER:GAZP/TQBR")
|
||||
parsed = report([buy(ref)])
|
||||
|
||||
first = await run_ingest(account_id, parsed)
|
||||
second = await run_ingest(account_id, parsed)
|
||||
|
||||
assert first.events_created == 1
|
||||
assert second.events_created == 0
|
||||
assert second.events_updated == 1
|
||||
assert second.events_duplicate == 1
|
||||
assert await count(Event) == 1
|
||||
async with get_sessionmaker()() as session:
|
||||
event = (await session.execute(select(Event))).scalar_one()
|
||||
assert event.instrument_id == instrument_id
|
||||
assert event.status == EventStatus.confirmed
|
||||
|
||||
|
||||
async def test_unknown_isin_parks_the_instrument_and_counts_repeats(app):
|
||||
account_id = await make_broker_account()
|
||||
parsed = report([buy(unknown_ref())])
|
||||
|
||||
first = await run_ingest(account_id, parsed)
|
||||
assert first.events_pending == 1
|
||||
|
||||
async with get_sessionmaker()() as session:
|
||||
event = (await session.execute(select(Event))).scalar_one()
|
||||
pending = (await session.execute(select(PendingInstrument))).scalar_one()
|
||||
assert event.status == EventStatus.pending
|
||||
assert event.instrument_id is None
|
||||
assert (event.meta or {})["pending_key"] == f"ISIN:{UNKNOWN_ISIN}"
|
||||
assert pending.source == SOURCE
|
||||
assert pending.isin == UNKNOWN_ISIN
|
||||
assert pending.occurrences == 1
|
||||
|
||||
await run_ingest(account_id, parsed)
|
||||
async with get_sessionmaker()() as session:
|
||||
rows = (await session.execute(select(PendingInstrument))).scalars().all()
|
||||
assert len(rows) == 1
|
||||
assert rows[0].occurrences == 2
|
||||
|
||||
|
||||
async def test_resolving_a_pending_instrument_binds_and_confirms_its_events(app):
|
||||
account_id = await make_broker_account()
|
||||
await run_ingest(account_id, report([buy(unknown_ref())]))
|
||||
instrument_id = await make_instrument(ticker="STME", name="БПИФ", board="TQTF")
|
||||
|
||||
async with get_sessionmaker()() as session:
|
||||
pending = (await session.execute(select(PendingInstrument))).scalar_one()
|
||||
outcome = await resolve_pending(
|
||||
session, pending, action="link", instrument_id=instrument_id
|
||||
)
|
||||
assert outcome.events_bound == 1
|
||||
assert outcome.alias_created is True
|
||||
|
||||
async with get_sessionmaker()() as session:
|
||||
event = (await session.execute(select(Event))).scalar_one()
|
||||
alias = (await session.execute(select(InstrumentAlias))).scalar_one()
|
||||
lots = (await session.execute(select(Lot))).scalars().all()
|
||||
pending = (await session.execute(select(PendingInstrument))).scalar_one()
|
||||
assert event.status == EventStatus.confirmed
|
||||
assert event.instrument_id == instrument_id
|
||||
assert "pending_key" not in (event.meta or {})
|
||||
assert (alias.source, alias.source_key) == (SOURCE, f"ISIN:{UNKNOWN_ISIN}")
|
||||
assert pending.status == PendingInstrumentStatus.resolved
|
||||
assert [(lot.instrument_id, lot.qty_open) for lot in lots] == [(instrument_id, Decimal(10))]
|
||||
|
||||
|
||||
async def test_report_on_an_api_primary_account_lands_as_shadow(app):
|
||||
account_id = await make_broker_account(primary=EventSource.tinvest_api, source="tinvest")
|
||||
await make_instrument(ticker="GAZP", name="Газпром")
|
||||
ref = InstrumentRef(ticker="GAZP", board="TQBR", source_key="TICKER:GAZP/TQBR")
|
||||
|
||||
result = await run_ingest(account_id, report([buy(ref)]))
|
||||
|
||||
assert result.events_shadow == 1
|
||||
async with get_sessionmaker()() as session:
|
||||
event = (await session.execute(select(Event))).scalar_one()
|
||||
assert event.status == EventStatus.shadow
|
||||
|
||||
|
||||
async def test_manual_prices_from_meta_become_price_manual_rows(app):
|
||||
account_id = await make_broker_account()
|
||||
instrument_id = await make_instrument(ticker="GAZP", name="Газпром")
|
||||
ref = InstrumentRef(ticker="GAZP", board="TQBR", source_key="TICKER:GAZP/TQBR")
|
||||
parsed = report(
|
||||
[buy(ref)],
|
||||
meta={
|
||||
"manual_prices": [
|
||||
{
|
||||
"instrument_key": "TICKER:GAZP/TQBR",
|
||||
"d": "2026-09-17",
|
||||
"price": "123.45",
|
||||
"currency": "RUB",
|
||||
}
|
||||
]
|
||||
},
|
||||
)
|
||||
|
||||
result = await run_ingest(account_id, parsed)
|
||||
|
||||
assert result.manual_prices == 1
|
||||
async with get_sessionmaker()() as session:
|
||||
row = (await session.execute(select(PriceManual))).scalar_one()
|
||||
assert row.instrument_id == instrument_id
|
||||
assert row.d == date(2026, 9, 17)
|
||||
assert row.price == Decimal("123.45")
|
||||
|
||||
|
||||
async def test_payout_hint_turns_a_dividend_on_a_bond_into_a_coupon(app):
|
||||
account_id = await make_broker_account()
|
||||
bond_id = await make_instrument(
|
||||
ticker="SU26238", name="ОФЗ 26238", asset_class=AssetClass.bond, board="TQOB"
|
||||
)
|
||||
ref = InstrumentRef(ticker="SU26238", board="TQOB", source_key="TICKER:SU26238/TQOB")
|
||||
payout = BrokerEvent(
|
||||
kind=EventKind.dividend,
|
||||
trade_date=date(2026, 5, 20),
|
||||
amount=Decimal("175.30"),
|
||||
currency="RUB",
|
||||
instrument=ref,
|
||||
trade_no="PAY-1",
|
||||
meta={"payout_hint": "coupon"},
|
||||
raw_line_no=7,
|
||||
)
|
||||
|
||||
await run_ingest(account_id, report([payout]))
|
||||
|
||||
async with get_sessionmaker()() as session:
|
||||
event = (await session.execute(select(Event))).scalar_one()
|
||||
assert event.kind == EventKind.coupon
|
||||
assert event.instrument_id == bond_id
|
||||
|
||||
|
||||
async def test_raw_report_lines_are_linked_and_carry_no_float(app):
|
||||
account_id = await make_broker_account()
|
||||
await make_instrument(ticker="GAZP", name="Газпром")
|
||||
ref = InstrumentRef(ticker="GAZP", board="TQBR", source_key="TICKER:GAZP/TQBR")
|
||||
file_id = await make_file(account_id)
|
||||
|
||||
await run_ingest(account_id, report([buy(ref, line_no=3)]), file_id=file_id)
|
||||
|
||||
async with get_sessionmaker()() as session:
|
||||
line = (await session.execute(select(RawReportLine))).scalar_one()
|
||||
event = (await session.execute(select(Event))).scalar_one()
|
||||
assert line.file_id == file_id
|
||||
assert line.line_no == 3
|
||||
assert line.event_id == event.id
|
||||
assert line.dedupe_key == event.dedupe_key
|
||||
assert line.payload["quantity"] == "10"
|
||||
assert _floats(line.payload) == []
|
||||
|
||||
|
||||
def _floats(value, path: str = "$") -> list[str]:
|
||||
"""Every float hiding in a JSONB payload, by path — money must never be one."""
|
||||
if isinstance(value, float):
|
||||
return [path]
|
||||
if isinstance(value, dict):
|
||||
return [p for k, v in value.items() for p in _floats(v, f"{path}.{k}")]
|
||||
if isinstance(value, list):
|
||||
return [p for i, v in enumerate(value) for p in _floats(v, f"{path}[{i}]")]
|
||||
return []
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("spec", "expected"),
|
||||
[(InstrumentSpec(asset_class="fund", name="X"), AssetClass.fund)],
|
||||
)
|
||||
async def test_create_mode_builds_the_instrument_from_the_report(app, spec, expected):
|
||||
account_id = await make_broker_account()
|
||||
await run_ingest(account_id, report([buy(unknown_ref())]))
|
||||
|
||||
async with get_sessionmaker()() as session:
|
||||
pending = (await session.execute(select(PendingInstrument))).scalar_one()
|
||||
outcome = await resolve_pending(session, pending, action="create", instrument=spec)
|
||||
|
||||
async with get_sessionmaker()() as session:
|
||||
instrument = await session.get(Instrument, outcome.instrument_id)
|
||||
assert instrument is not None
|
||||
assert instrument.asset_class == expected
|
||||
@@ -0,0 +1,384 @@
|
||||
"""Сквозной импорт настоящего отчёта: файл → реестр → парсер → леджер → сверка.
|
||||
|
||||
Все остальные тесты фазы 3 честно изолированы: парсеры проверяются на фикстурах без БД,
|
||||
`ingest` — на `ParsedReport`, собранном руками. Это правильно, но между ними остаётся щель,
|
||||
в которую проваливаются ровно те ошибки, ради которых фаза затевалась: парсер отдаёт
|
||||
безупречный `ParsedReport`, ingest безупречно его пишет, а вместе они дают задвоенный
|
||||
леджер, потому что ключи считаются от того, что различается между двумя выгрузками.
|
||||
|
||||
Поэтому здесь ни одного собранного вручную объекта — только байты обезличенных отчётов,
|
||||
`registry.pick` и публичный путь `upload → commit`. Проверки те же, что в плане §Фаза 3:
|
||||
тот же файл дважды даёт ноль новых событий; перекрывающиеся периоды дают каждую сделку по
|
||||
разу; закрывающие позиции и остаток денег из отчёта сходятся с derived; неизвестный ISIN
|
||||
уходит в `pending_instrument`, а после резолва лоты пересобираются.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from fintracker.db import get_sessionmaker
|
||||
from fintracker.ledger.report_import import (
|
||||
InstrumentSpec,
|
||||
build_preview,
|
||||
commit,
|
||||
resolve_pending,
|
||||
upload,
|
||||
)
|
||||
from fintracker.models import (
|
||||
Account,
|
||||
AccountKind,
|
||||
AccountRole,
|
||||
AssetClass,
|
||||
Broker,
|
||||
Event,
|
||||
EventKind,
|
||||
EventSource,
|
||||
EventStatus,
|
||||
Instrument,
|
||||
Lot,
|
||||
PendingInstrument,
|
||||
PendingInstrumentStatus,
|
||||
RawReportFile,
|
||||
)
|
||||
from fintracker.sources.reports import registry
|
||||
|
||||
FIXTURES = Path(__file__).resolve().parents[1] / "fixtures" / "reports" / "sber"
|
||||
FULL = FIXTURES / "S930W42_11022026_17092026.html"
|
||||
AUGUST = FIXTURES / "S930W42_01082026_31082026.html"
|
||||
|
||||
ACCOUNT_NO = "S930W42"
|
||||
|
||||
#: Справочник ценных бумаг полного отчёта: ISIN → (тикер, класс). Заводится заранее, чтобы
|
||||
#: сверка закрывающих позиций проверяла сам импорт, а не резолв инструментов.
|
||||
SECURITIES = {
|
||||
"RU0009062285": ("AFLT", AssetClass.share),
|
||||
"RU0009024277": ("LKOH", AssetClass.share),
|
||||
"RU000A0JR4A1": ("MOEX", AssetClass.share),
|
||||
"RU0008958863": ("MSNG", AssetClass.share),
|
||||
"RU0007775219": ("MTSS", AssetClass.share),
|
||||
"RU000A1035S8": ("STME", AssetClass.etf),
|
||||
"RU0009029540": ("SBER", AssetClass.share),
|
||||
"RU0009046510": ("CHMF", AssetClass.share),
|
||||
"RU0009033591": ("TATN", AssetClass.share),
|
||||
"RU000A100P44": ("SBRB", AssetClass.etf),
|
||||
"RU000A0JRKT8": ("PHOR", AssetClass.share),
|
||||
}
|
||||
|
||||
|
||||
async def make_sber_account(*, primary: EventSource | None = EventSource.report_sber) -> int:
|
||||
async with get_sessionmaker()() as session:
|
||||
account = Account(
|
||||
kind=AccountKind.broker,
|
||||
source="report_sber",
|
||||
source_id=ACCOUNT_NO,
|
||||
broker=Broker.sber,
|
||||
name="Сбер ИИС",
|
||||
currency="RUB",
|
||||
role=AccountRole.investment,
|
||||
primary_event_source=primary,
|
||||
)
|
||||
session.add(account)
|
||||
await session.commit()
|
||||
return account.id
|
||||
|
||||
|
||||
async def make_securities(skip: str | None = None) -> dict[str, int]:
|
||||
"""Инструменты из справочника отчёта. `skip` оставляет один ISIN неизвестным."""
|
||||
ids: dict[str, int] = {}
|
||||
async with get_sessionmaker()() as session:
|
||||
for isin, (ticker, asset_class) in SECURITIES.items():
|
||||
if isin == skip:
|
||||
continue
|
||||
instrument = Instrument(
|
||||
asset_class=asset_class,
|
||||
isin=isin,
|
||||
ticker=ticker,
|
||||
board="TQBR",
|
||||
name=ticker,
|
||||
currency="RUB",
|
||||
)
|
||||
session.add(instrument)
|
||||
await session.flush()
|
||||
ids[isin] = instrument.id
|
||||
await session.commit()
|
||||
return ids
|
||||
|
||||
|
||||
async def import_file(path: Path, account_id: int):
|
||||
"""Полный публичный путь: загрузка, парсинг, commit."""
|
||||
async with get_sessionmaker()() as session:
|
||||
outcome = await upload(
|
||||
session, data=path.read_bytes(), filename=path.name, account_id=account_id
|
||||
)
|
||||
await session.commit()
|
||||
file_id = outcome.file.id
|
||||
duplicate_of_id = outcome.duplicate_of_id
|
||||
|
||||
async with get_sessionmaker()() as session:
|
||||
row = await session.get(RawReportFile, file_id)
|
||||
assert row is not None
|
||||
result = await commit(session, row, account_id=account_id)
|
||||
await session.commit()
|
||||
return result, duplicate_of_id
|
||||
|
||||
|
||||
async def count_events(**where) -> int:
|
||||
async with get_sessionmaker()() as session:
|
||||
stmt = select(func.count()).select_from(Event)
|
||||
for column, value in where.items():
|
||||
stmt = stmt.where(getattr(Event, column) == value)
|
||||
return (await session.execute(stmt)).scalar_one()
|
||||
|
||||
|
||||
# --- 1. формат доезжает до парсера через реестр --------------------------------------------
|
||||
|
||||
|
||||
async def test_upload_routes_the_file_to_the_sber_parser(app) -> None:
|
||||
account_id = await make_sber_account()
|
||||
async with get_sessionmaker()() as session:
|
||||
outcome = await upload(
|
||||
session, data=FULL.read_bytes(), filename=FULL.name, account_id=account_id
|
||||
)
|
||||
await session.commit()
|
||||
row = outcome.file
|
||||
|
||||
assert row.parser_name == "report_sber"
|
||||
assert row.broker == "sber"
|
||||
assert row.account_external_id == ACCOUNT_NO
|
||||
assert row.period_from is not None and row.period_to is not None
|
||||
assert (row.period_from.isoformat(), row.period_to.isoformat()) == (
|
||||
"2026-02-11",
|
||||
"2026-09-17",
|
||||
)
|
||||
assert registry.pick(FULL.read_bytes(), FULL.name) is not None
|
||||
|
||||
|
||||
async def test_upload_writes_no_events(app) -> None:
|
||||
"""Загрузка — это диагностика, а не запись: леджер меняет только commit."""
|
||||
account_id = await make_sber_account()
|
||||
await make_securities()
|
||||
async with get_sessionmaker()() as session:
|
||||
await upload(session, data=FULL.read_bytes(), filename=FULL.name, account_id=account_id)
|
||||
await session.commit()
|
||||
assert await count_events() == 0
|
||||
|
||||
|
||||
# --- 2. тот же файл дважды → ноль новых событий --------------------------------------------
|
||||
|
||||
|
||||
async def test_the_same_file_twice_adds_nothing(app) -> None:
|
||||
account_id = await make_sber_account()
|
||||
await make_securities()
|
||||
|
||||
first, duplicate = await import_file(FULL, account_id)
|
||||
assert duplicate is None
|
||||
assert first.events_created == 35
|
||||
after_first = await count_events()
|
||||
|
||||
async with get_sessionmaker()() as session:
|
||||
again = await upload(
|
||||
session, data=FULL.read_bytes(), filename=FULL.name, account_id=account_id
|
||||
)
|
||||
await session.commit()
|
||||
assert again.duplicate_of_id is not None, "второй sha256 создал новый импорт"
|
||||
|
||||
assert await count_events() == after_first == 35
|
||||
|
||||
|
||||
# --- 3. перекрывающиеся периоды: каждая сделка ровно один раз -------------------------------
|
||||
|
||||
|
||||
async def test_overlapping_reports_record_each_trade_once(app) -> None:
|
||||
"""Август целиком входит в полный отчёт: второй импорт не должен ничего добавить.
|
||||
|
||||
Это и есть проверка §1.6 A на живых данных — ключи считаются от номера сделки и от
|
||||
экономического отпечатка операции, а не от того, каким файлом её принесли.
|
||||
"""
|
||||
account_id = await make_sber_account()
|
||||
await make_securities()
|
||||
|
||||
await import_file(FULL, account_id)
|
||||
total_after_full = await count_events()
|
||||
|
||||
august_result, _ = await import_file(AUGUST, account_id)
|
||||
|
||||
assert august_result.events_created == 0, "август задвоил операции полного отчёта"
|
||||
assert august_result.events_updated == 3
|
||||
assert await count_events() == total_after_full
|
||||
|
||||
async with get_sessionmaker()() as session:
|
||||
keys = (await session.execute(select(Event.dedupe_key))).scalars().all()
|
||||
assert len(keys) == len(set(keys))
|
||||
|
||||
|
||||
async def test_august_first_then_full_history(app) -> None:
|
||||
"""Обратный порядок: сначала месяц, потом вся история — итог тот же."""
|
||||
account_id = await make_sber_account()
|
||||
await make_securities()
|
||||
|
||||
august_result, _ = await import_file(AUGUST, account_id)
|
||||
assert august_result.events_created == 3
|
||||
|
||||
full_result, _ = await import_file(FULL, account_id)
|
||||
assert full_result.events_created == 32
|
||||
assert full_result.events_updated == 3
|
||||
assert await count_events() == 35
|
||||
|
||||
|
||||
# --- 4. закрывающие позиции и деньги отчёта = derived ---------------------------------------
|
||||
|
||||
|
||||
async def test_closing_positions_and_cash_match_the_ledger(app) -> None:
|
||||
"""Главная проверка фазы: то, что брокер напечатал, совпало с тем, что мы вывели."""
|
||||
account_id = await make_sber_account()
|
||||
await make_securities()
|
||||
await import_file(FULL, account_id)
|
||||
|
||||
async with get_sessionmaker()() as session:
|
||||
row = (
|
||||
(await session.execute(select(RawReportFile).order_by(RawReportFile.id.desc())))
|
||||
.scalars()
|
||||
.first()
|
||||
)
|
||||
assert row is not None
|
||||
preview = await build_preview(session, row)
|
||||
|
||||
mismatched = [p for p in preview.reconciliation.positions if not p.matches]
|
||||
assert not mismatched, [
|
||||
(p.ticker or p.instrument_name, str(p.qty_report), str(p.qty_derived)) for p in mismatched
|
||||
]
|
||||
|
||||
rub = next(c for c in preview.reconciliation.cash if c.currency == "RUB")
|
||||
assert rub.balance_report == Decimal("3171.34")
|
||||
assert rub.balance_derived == rub.balance_report
|
||||
assert preview.reconciliation.matches
|
||||
|
||||
|
||||
async def test_derived_position_equals_the_reports_own_quantity(app) -> None:
|
||||
"""Та же сверка, но из самого леджера: Σ `lot.qty_remaining` против отчёта."""
|
||||
account_id = await make_sber_account()
|
||||
ids = await make_securities()
|
||||
await import_file(FULL, account_id)
|
||||
|
||||
async with get_sessionmaker()() as session:
|
||||
rows = (
|
||||
await session.execute(
|
||||
select(Lot.instrument_id, func.sum(Lot.qty_remaining)).group_by(Lot.instrument_id)
|
||||
)
|
||||
).all()
|
||||
held: dict[int, Decimal] = {instrument_id: qty for instrument_id, qty in rows}
|
||||
|
||||
# «Портфель Ценных Бумаг» полного отчёта, колонка «Конец периода / Количество, шт»
|
||||
assert held[ids["RU0009062285"]] == Decimal("130") # Аэрофлот
|
||||
assert held[ids["RU0008958863"]] == Decimal("3000") # Мосэнерго
|
||||
assert held[ids["RU0009029540"]] == Decimal("20") # Сбербанк
|
||||
assert held.get(ids["RU000A1035S8"], Decimal(0)) == 0 # STME куплен и продан целиком
|
||||
assert held.get(ids["RU000A100P44"], Decimal(0)) == 0 # SBRB тоже закрыт
|
||||
|
||||
|
||||
# --- 5. неизвестный ISIN → pending → резолв → лоты ------------------------------------------
|
||||
|
||||
|
||||
async def test_unknown_isin_parks_and_resolves(app) -> None:
|
||||
"""Инструмент не угадывается: события ждут, пока его подтвердят, и только тогда считаются."""
|
||||
account_id = await make_sber_account()
|
||||
ids = await make_securities(skip="RU0009062285") # Аэрофлот остаётся неизвестным
|
||||
|
||||
result, _ = await import_file(FULL, account_id)
|
||||
assert result.pending_instruments >= 1
|
||||
|
||||
async with get_sessionmaker()() as session:
|
||||
pending = (
|
||||
(
|
||||
await session.execute(
|
||||
select(PendingInstrument).where(
|
||||
PendingInstrument.status == PendingInstrumentStatus.pending
|
||||
)
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
assert [p.isin for p in pending] == ["RU0009062285"]
|
||||
assert pending[0].occurrences >= 2 # Аэрофлот куплен двумя сделками
|
||||
pending_id = pending[0].id
|
||||
|
||||
assert await count_events(status=EventStatus.pending) >= 2
|
||||
|
||||
async with get_sessionmaker()() as session:
|
||||
row = await session.get(PendingInstrument, pending_id)
|
||||
assert row is not None
|
||||
outcome = await resolve_pending(
|
||||
session,
|
||||
row,
|
||||
action="create",
|
||||
instrument=InstrumentSpec(
|
||||
asset_class="share",
|
||||
isin="RU0009062285",
|
||||
ticker="AFLT",
|
||||
board="TQBR",
|
||||
name="Аэрофлот",
|
||||
currency="RUB",
|
||||
),
|
||||
)
|
||||
await session.commit()
|
||||
assert outcome.events_bound >= 2
|
||||
|
||||
assert await count_events(status=EventStatus.pending) == 0
|
||||
|
||||
async with get_sessionmaker()() as session:
|
||||
instrument = (
|
||||
await session.execute(select(Instrument).where(Instrument.isin == "RU0009062285"))
|
||||
).scalar_one()
|
||||
qty = (
|
||||
await session.execute(
|
||||
select(func.sum(Lot.qty_remaining)).where(Lot.instrument_id == instrument.id)
|
||||
)
|
||||
).scalar_one()
|
||||
assert qty == Decimal("130"), "после резолва лоты не пересобрались"
|
||||
|
||||
assert ids # остальные инструменты были известны заранее
|
||||
|
||||
|
||||
# --- 6. чужой источник пишется тенью -------------------------------------------------------
|
||||
|
||||
|
||||
async def test_report_on_an_api_driven_account_lands_as_shadow(app) -> None:
|
||||
"""У счёта один primary_event_source; отчёт поверх API — evidence, а не леджер."""
|
||||
account_id = await make_sber_account(primary=EventSource.tinvest_api)
|
||||
await make_securities()
|
||||
|
||||
result, _ = await import_file(FULL, account_id)
|
||||
|
||||
assert result.events_shadow == 35
|
||||
assert await count_events(status=EventStatus.confirmed) == 0
|
||||
assert await count_events(status=EventStatus.shadow) == 35
|
||||
|
||||
|
||||
# --- 7. трассируемость ---------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_every_event_points_back_at_its_raw_line(app) -> None:
|
||||
"""`raw_report_line` — то, по чему через полгода восстанавливают, откуда взялось число."""
|
||||
account_id = await make_sber_account()
|
||||
await make_securities()
|
||||
await import_file(FULL, account_id)
|
||||
|
||||
async with get_sessionmaker()() as session:
|
||||
linked = (
|
||||
await session.execute(
|
||||
select(func.count()).select_from(Event).where(Event.raw_ref.is_not(None))
|
||||
)
|
||||
).scalar_one()
|
||||
assert linked == 35
|
||||
|
||||
commission = (
|
||||
await session.execute(
|
||||
select(func.count()).select_from(Event).where(Event.kind == EventKind.commission)
|
||||
)
|
||||
).scalar_one()
|
||||
assert commission == 0, "комиссия Сбера должна быть капитализирована в сделку"
|
||||
Reference in New Issue
Block a user