Поток: 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.
242 lines
6.5 KiB
Python
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)
|