Files
fin-tracker/backend/tests/ledger/test_dedupe.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

242 lines
6.5 KiB
Python

"""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)