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,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)
|
||||
Reference in New Issue
Block a user