Files
fin-tracker/backend/tests/ledger/test_report_import_e2e.py
T
Dmitry ff3b76871d 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.
2026-09-19 10:40:04 +03:00

385 lines
16 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.
"""Сквозной импорт настоящего отчёта: файл → реестр → парсер → леджер → сверка.
Все остальные тесты фазы 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, "комиссия Сбера должна быть капитализирована в сделку"