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:
Dmitry
2026-09-19 10:40:04 +03:00
parent 2e742a093b
commit ff3b76871d
16 changed files with 4484 additions and 0 deletions
@@ -114,8 +114,10 @@ def register_steps() -> None:
valuation,
)
from fintracker.ledger.corporate_actions import rebuild_corporate_actions
from fintracker.ledger.dedupe import rebuild_shadow_matches
from fintracker.ledger.matching import rebuild_flow_links
from fintracker.ledger.rebuild import rebuild_lots
from fintracker.ledger.report_import import reconcile_reports
from fintracker.metrics.refresh import register_step
register_step("fx", _step_fx)
@@ -136,6 +138,12 @@ def register_steps() -> None:
register_step("cashflow", cashflow.rebuild_cash_flow_monthly)
register_step("spending", spending.rebuild_spending_by_category)
register_step("runway", runway.rebuild_runway)
# Both report steps speak through FINDINGS, which `quality` drains — so they have to run
# before it, and inside a refresh at all. `shadow_dedupe` pairs a report's events with
# the API's before `report_reconcile` compares the closing balances, or every shadowed
# position would read as a discrepancy.
register_step("shadow_dedupe", rebuild_shadow_matches)
register_step("report_reconcile", reconcile_reports)
register_step("quality", quality.rebuild_data_quality)
+6
View File
@@ -22,6 +22,7 @@ from fintracker.api.routers import (
categories,
events,
health,
imports,
instruments,
links,
metrics,
@@ -86,6 +87,11 @@ def create_app() -> FastAPI:
app.include_router(cashflow.router, prefix=API_PREFIX)
app.include_router(analytics.router, prefix=API_PREFIX)
app.include_router(events.router, prefix=API_PREFIX)
app.include_router(imports.router, prefix=API_PREFIX)
# Before `instruments`: FastAPI matches in registration order, and `/instruments/{id}`
# typed `int` does not fall through on a non-numeric segment — it answers 422. So
# `/instruments/pending` has to be declared first or it becomes unreachable.
app.include_router(imports.pending_router, prefix=API_PREFIX)
app.include_router(instruments.router, prefix=API_PREFIX)
app.include_router(links.router, prefix=API_PREFIX)
app.include_router(metrics.router, prefix=API_PREFIX)
@@ -0,0 +1,380 @@
"""Broker report imports and the resolution of what they named (plan §4).
Implements `docs/ai/import-contract.md` literally — paths, field names and error codes are
what the Flutter screens were written against, so this module's job is translation, not
judgement: everything that decides anything lives in `ledger/report_import.py`.
Two routers, one module, on purpose. The pending-instrument endpoints belong to the import
feature but sit under `/instruments` because that is where the user looks for an instrument,
and `api/routers/instruments.py` has nothing to do with importing.
**Mounting order matters.** `pending_router` must be included BEFORE `instruments.router`:
FastAPI matches in registration order, and `/instruments/{instrument_id}` would otherwise
swallow `/instruments/pending` and fail to read "pending" as an int.
"""
from __future__ import annotations
from typing import Annotated
from fastapi import APIRouter, File, Form, Query, UploadFile
from sqlalchemy import select
from fintracker.api.deps import CurrentUser, SessionDep
from fintracker.api.errors import Problem
from fintracker.api.schemas.imports import (
AccountSuggestion,
CommitRequest,
ImportCounts,
ImportPreview,
ImportResult,
ImportSummary,
PendingInstrumentOut,
PendingResolveRequest,
PendingResolveResult,
ReconciliationCash,
ReconciliationOut,
ReconciliationPosition,
SampleEventOut,
)
from fintracker.ledger import report_import
from fintracker.ledger.report_import import (
CommitOutcome,
ImportProblem,
InstrumentSpec,
Preview,
Reconciliation,
)
from fintracker.models import Account, PendingInstrument, PendingInstrumentStatus, RawReportFile
router = APIRouter(prefix="/imports", tags=["imports"])
pending_router = APIRouter(prefix="/instruments", tags=["instruments"])
#: `pending_router` must be included BEFORE `routers/instruments.py` in `api/app.py`:
#: FastAPI matches in registration order, and `/instruments/{instrument_id}` typed `int`
#: answers 422 on a non-numeric segment instead of falling through to the next route.
#: `tests/api/test_imports_api.py` guards the ordering.
def _problem(exc: ImportProblem) -> Problem:
return Problem(exc.status_code, exc.title, exc.detail, extra=exc.extra)
@router.post("", name="create")
async def create_import(
session: SessionDep,
_: CurrentUser,
file: Annotated[UploadFile, File(description="the report itself")],
account_id: Annotated[int | None, Form(description="target account, if known")] = None,
parser: Annotated[str | None, Form(description="force a parser from registry.names()")] = None,
) -> ImportPreview:
"""Upload a report, parse it and show what committing it would do. Writes no event."""
data = await file.read()
try:
outcome = await report_import.upload(
session,
data=data,
filename=file.filename or "report",
account_id=account_id,
parser_name=parser,
)
preview = await report_import.build_preview(
session, outcome.file, duplicate_of_id=outcome.duplicate_of_id
)
except ImportProblem as exc:
raise _problem(exc) from exc
return _preview_out(preview)
@router.get("", name="list")
async def list_imports(
session: SessionDep,
_: CurrentUser,
status: Annotated[
str | None, Query(description="uploaded | parsed | committed | failed")
] = None,
limit: Annotated[int, Query(ge=1, le=200)] = 50,
offset: Annotated[int, Query(ge=0)] = 0,
) -> list[ImportSummary]:
"""Newest imports first, with the counters each was committed with."""
stmt = select(RawReportFile).order_by(RawReportFile.id.desc()).limit(limit).offset(offset)
if status is not None:
stmt = stmt.where(RawReportFile.parse_status == status)
rows = (await session.execute(stmt)).scalars().all()
names = await _account_names(session, [r.account_id for r in rows])
return [
_summary_out(r, names.get(r.account_id) if r.account_id is not None else None) for r in rows
]
@router.get("/{import_id}", name="get")
async def get_import(session: SessionDep, _: CurrentUser, import_id: int) -> ImportPreview:
"""The preview again — recomputed for an uncommitted import, replayed for a committed one."""
row = await _load(session, import_id)
try:
preview = await report_import.build_preview(session, row)
except ImportProblem as exc:
raise _problem(exc) from exc
return _preview_out(preview)
@router.post("/{import_id}/commit", name="commit")
async def commit_import(
session: SessionDep, _: CurrentUser, import_id: int, body: CommitRequest
) -> ImportResult:
"""Write the file's events into the ledger and rebuild every metric."""
row = await _load(session, import_id)
try:
outcome = await report_import.commit(
session,
row,
account_id=body.account_id,
confirm_duplicates=body.confirm_duplicates,
dry_run=body.dry_run,
)
except ImportProblem as exc:
raise _problem(exc) from exc
return _result_out(import_id, outcome)
@router.delete("/{import_id}", name="delete", status_code=204)
async def delete_import(session: SessionDep, _: CurrentUser, import_id: int) -> None:
"""Drop an uncommitted import. A committed one stays: `raw_*` is append-only."""
row = await _load(session, import_id)
if row.parse_status == "committed":
raise Problem(
409,
"Conflict",
f"Импорт #{import_id} закоммичен — его события уже в леджере, след не удаляется",
)
await session.delete(row)
await session.commit()
@pending_router.get("/pending", name="pending")
async def list_pending(
session: SessionDep,
_: CurrentUser,
status: Annotated[str, Query(description="pending | resolved | ignored | all")] = "pending",
limit: Annotated[int, Query(ge=1, le=500)] = 100,
offset: Annotated[int, Query(ge=0)] = 0,
) -> list[PendingInstrumentOut]:
"""Instruments a report named that nothing in the master resolves to."""
stmt = (
select(PendingInstrument)
.order_by(PendingInstrument.occurrences.desc(), PendingInstrument.id)
.limit(limit)
.offset(offset)
)
if status != "all":
if status not in {s.value for s in PendingInstrumentStatus}:
raise Problem(
400, "Bad Request", f"Неизвестный статус {status!r}: pending | resolved | ignored"
)
stmt = stmt.where(PendingInstrument.status == status)
rows = (await session.execute(stmt)).scalars().all()
return [_pending_out(r) for r in rows]
@pending_router.post("/pending/{pending_id}/resolve", name="pending_resolve")
async def resolve_pending(
session: SessionDep, _: CurrentUser, pending_id: int, body: PendingResolveRequest
) -> PendingResolveResult:
"""Link, create or ignore — then bind the events that were waiting and rebuild the lots."""
row = await session.get(PendingInstrument, pending_id)
if row is None:
raise Problem(404, "Not Found", f"Нет строки pending_instrument #{pending_id}")
spec = None
if body.instrument is not None:
spec = InstrumentSpec(
asset_class=body.instrument.asset_class,
name=body.instrument.name,
currency=body.instrument.currency,
isin=body.instrument.isin,
ticker=body.instrument.ticker,
board=body.instrument.board,
lot=body.instrument.lot,
)
try:
outcome = await report_import.resolve_pending(
session, row, action=body.action, instrument_id=body.instrument_id, instrument=spec
)
except ImportProblem as exc:
raise _problem(exc) from exc
return PendingResolveResult(
id=outcome.pending.id,
status=outcome.pending.status.value,
instrument_id=outcome.instrument_id,
events_bound=outcome.events_bound,
alias_created=outcome.alias_created,
metrics_refreshed=outcome.metrics_refreshed,
)
async def _load(session: SessionDep, import_id: int) -> RawReportFile:
row = await session.get(RawReportFile, import_id)
if row is None:
raise Problem(404, "Not Found", f"Нет импорта #{import_id}")
return row
async def _account_names(session: SessionDep, ids: list[int | None]) -> dict[int, str]:
wanted = {i for i in ids if i is not None}
if not wanted:
return {}
rows = (
await session.execute(select(Account.id, Account.name).where(Account.id.in_(wanted)))
).all()
return {account_id: name for account_id, name in rows}
def _counts_out(counts: dict) -> ImportCounts:
return ImportCounts.model_validate({**_ZERO_COUNTS, **(counts or {})})
_ZERO_COUNTS = {
"lines": 0,
"events_total": 0,
"events_new": 0,
"events_duplicate": 0,
"events_shadow": 0,
"events_pending": 0,
"by_kind": {},
}
def _summary_out(
row: RawReportFile, account_name: str | None, duplicate_of_id: int | None = None
) -> ImportSummary:
return ImportSummary(
id=row.id,
broker=row.broker,
filename=row.filename,
sha256=row.sha256,
size_bytes=row.size_bytes,
parser_name=row.parser_name,
parser_version=row.parser_version,
parse_status=row.parse_status.value,
error=row.error,
duplicate_of_id=duplicate_of_id,
account_external_id=row.account_external_id,
account_id=row.account_id,
account_name=account_name,
period_from=row.period_from,
period_to=row.period_to,
uploaded_at=row.uploaded_at,
committed_at=row.committed_at,
counts=_counts_out(row.counts or {}),
)
def _preview_out(preview: Preview) -> ImportPreview:
row = preview.file
base = _summary_out(
row,
preview.account.name if preview.account else None,
duplicate_of_id=preview.duplicate_of_id,
)
return ImportPreview(
**base.model_dump(exclude={"counts"}),
counts=_counts_out(preview.counts),
account_suggestions=[
AccountSuggestion(
id=a.id,
name=a.name,
broker=a.broker.value if a.broker else None,
source_id=a.source_id,
)
for a in preview.suggestions
],
pending_instruments=[_pending_out(p) for p in preview.pending],
reconciliation=_recon_out(preview.reconciliation),
warnings=preview.warnings,
sample_events=[
SampleEventOut(
line_no=s.line_no,
kind=s.kind,
trade_date=s.trade_date,
settle_date=s.settle_date,
instrument_key=s.instrument_key,
instrument_name=s.instrument_name,
instrument_id=s.instrument_id,
quantity=s.quantity,
price=s.price,
amount=s.amount,
currency=s.currency,
fee=s.fee,
trade_no=s.trade_no,
dedupe_key=s.dedupe_key,
is_duplicate=s.is_duplicate,
description=s.description,
)
for s in preview.sample_events
],
)
def _recon_out(recon: Reconciliation) -> ReconciliationOut:
return ReconciliationOut(
as_of=recon.as_of,
positions=[
ReconciliationPosition(
instrument_id=p.instrument_id,
instrument_name=p.instrument_name,
ticker=p.ticker,
isin=p.isin,
qty_report=p.qty_report,
qty_derived=p.qty_derived,
qty_delta=p.qty_delta,
matches=p.matches,
)
for p in recon.positions
],
cash=[
ReconciliationCash(
currency=c.currency,
balance_report=c.balance_report,
balance_derived=c.balance_derived,
delta=c.delta,
matches=c.matches,
)
for c in recon.cash
],
matches=recon.matches,
)
def _pending_out(row: PendingInstrument) -> PendingInstrumentOut:
return PendingInstrumentOut(
id=row.id,
source=row.source,
source_key=row.source_key,
isin=row.isin,
ticker=row.ticker,
board=row.board,
name=row.name,
currency=row.currency,
asset_class_hint=row.asset_class_hint,
occurrences=row.occurrences,
sample_quantity=row.sample_quantity,
sample_price=row.sample_price,
status=row.status.value,
instrument_id=row.instrument_id,
first_seen_file_id=row.first_seen_file_id,
created_at=row.created_at,
)
def _result_out(import_id: int, outcome: CommitOutcome) -> ImportResult:
return ImportResult(
import_id=import_id,
committed=outcome.committed,
events_created=outcome.events_created,
events_updated=outcome.events_updated,
events_skipped=outcome.events_skipped,
events_shadow=outcome.events_shadow,
pending_instruments=outcome.pending_instruments,
reconciliation=_recon_out(outcome.reconciliation),
metrics_refreshed=outcome.metrics_refreshed,
)
@@ -0,0 +1,200 @@
"""Schemas for `/imports` and `/instruments/pending`, per `docs/ai/import-contract.md`.
Two conventions the Flutter client depends on, both repeated from AGENTS.md because breaking
either one breaks the generated Dart rather than a test:
* money and quantities are `Money` / `MoneyOpt` — `Decimal` in Python, a string on the wire;
* every stable key (`broker`, `parse_status`, `kind`, `status`, `asset_class`) is a plain
`str`, never an enum. `AssetClass.index` cannot exist as a Dart enum member (it collides
with `Enum.index`) and one enum leaking out stops the whole client from compiling.
"""
from __future__ import annotations
from datetime import date, datetime
from typing import Literal
from pydantic import BaseModel, Field
from fintracker.api.schemas.common import Money, MoneyOpt
class AccountSuggestion(BaseModel):
"""A broker account the user may attach an import to when the report's number matched none."""
id: int
name: str
broker: str | None
"""tinvest | sber | vtb | other"""
source_id: str
class ImportCounts(BaseModel):
lines: int
events_total: int
events_new: int
"""No `event` carries this row's `dedupe_key` yet."""
events_duplicate: int
"""The key is already in the ledger: committing upserts, it does not insert."""
events_shadow: int
"""Rows that will land as `shadow` — this feed is not the account's primary source."""
events_pending: int
"""Rows whose instrument nobody recognises yet."""
by_kind: dict[str, int]
class PendingInstrumentOut(BaseModel):
id: int
source: str
source_key: str
isin: str | None = None
ticker: str | None = None
board: str | None = None
name: str | None = None
currency: str | None = None
asset_class_hint: str | None = None
"""share | bond | etf | fund | currency — the report's own wording, never applied alone."""
occurrences: int
sample_quantity: MoneyOpt = None
sample_price: MoneyOpt = None
status: str
"""pending | resolved | ignored"""
instrument_id: int | None = None
first_seen_file_id: int | None = None
created_at: datetime | None = None
class ReconciliationPosition(BaseModel):
instrument_id: int | None
instrument_name: str
ticker: str | None
isin: str | None
qty_report: Money
qty_derived: MoneyOpt
"""Null when the account is unknown or its lots were never rebuilt — never a zero."""
qty_delta: MoneyOpt
matches: bool
class ReconciliationCash(BaseModel):
currency: str
balance_report: Money
balance_derived: MoneyOpt
delta: MoneyOpt
matches: bool
class ReconciliationOut(BaseModel):
as_of: date | None
positions: list[ReconciliationPosition] = Field(default_factory=list)
cash: list[ReconciliationCash] = Field(default_factory=list)
matches: bool = True
class SampleEventOut(BaseModel):
line_no: int
kind: str
"""buy | sell | dividend | coupon | … — the `EventKind` value as a string."""
trade_date: date
settle_date: date | None = None
instrument_key: str | None = None
instrument_name: str | None = None
instrument_id: int | None = None
quantity: MoneyOpt = None
price: MoneyOpt = None
amount: Money
currency: str
fee: MoneyOpt = None
trade_no: str | None = None
dedupe_key: str
is_duplicate: bool
description: str | None = None
class ImportSummary(BaseModel):
"""One import without the heavy parts — what `GET /imports` lists."""
id: int
broker: str
"""sber | vtb | csv"""
filename: str
sha256: str
size_bytes: int
parser_name: str | None = None
parser_version: str | None = None
parse_status: str
"""uploaded | parsed | committed | failed"""
error: str | None = None
duplicate_of_id: int | None = None
"""Not null ⇒ these bytes were already uploaded, and this is that import."""
account_external_id: str | None = None
account_id: int | None = None
"""Null ⇒ the account is unknown and commit is refused."""
account_name: str | None = None
period_from: date | None = None
period_to: date | None = None
uploaded_at: datetime | None = None
committed_at: datetime | None = None
counts: ImportCounts
class ImportPreview(ImportSummary):
"""The full preview: `POST /imports` and `GET /imports/{id}` both return this."""
account_suggestions: list[AccountSuggestion] = Field(default_factory=list)
pending_instruments: list[PendingInstrumentOut] = Field(default_factory=list)
reconciliation: ReconciliationOut
warnings: list[str] = Field(default_factory=list)
sample_events: list[SampleEventOut] = Field(default_factory=list)
class CommitRequest(BaseModel):
account_id: int | None = None
"""Needed only when the preview could not work the account out on its own."""
confirm_duplicates: bool = False
dry_run: bool = False
class ImportResult(BaseModel):
import_id: int
committed: bool
events_created: int
events_updated: int
events_skipped: int
"""Rows still waiting on a `pending_instrument`."""
events_shadow: int
pending_instruments: int
reconciliation: ReconciliationOut
metrics_refreshed: bool
class NewInstrument(BaseModel):
"""The instrument to create from a report's own data (`action = "create"`)."""
asset_class: str
"""share | bond | etf | fund | currency | index | deposit | real_estate | crypto | custom"""
name: str
currency: str = "RUB"
isin: str | None = None
ticker: str | None = None
board: str | None = None
lot: int = 1
class PendingResolveRequest(BaseModel):
action: Literal["link", "create", "ignore"]
instrument_id: int | None = None
"""Required for `link`."""
instrument: NewInstrument | None = None
"""Required for `create`."""
class PendingResolveResult(BaseModel):
id: int
status: str
"""resolved | ignored"""
instrument_id: int | None = None
events_bound: int
"""How many events left `pending` for `confirmed`/`shadow`."""
alias_created: bool
metrics_refreshed: bool
+257
View File
@@ -0,0 +1,257 @@
"""Shadow deduplication: the same trade seen by two feeds (plan §1.6 B).
An account has one `primary_event_source`. Everything another feed says about it is written
as `status = shadow` — kept, never counted — and this module's job is to say which shadow
rows are the primary feed's rows seen twice, and which are things the primary feed never
reported at all. The second group is the valuable one: «есть в отчёте, нет в API» is exactly
the shape of a mapping bug or a missing operation, and it goes to `metric_data_quality`.
**What counts as the same trade.** `(account, instrument, kind, quantity)` must agree
exactly, and `|price|` within 0,5 % — the plan's tolerance, wide enough for a report that
rounds a fill price to kopecks and narrow enough that two different fills of one paper on
one day do not swap.
**Dates.** A report and an API do not always mean the same day by "the date of the trade":
the broker's back office prints T+1 settlement where `GetOperationsByCursor` gives T. So the
match runs in two passes: first every pair whose dates agree exactly, then — over what is
left — pairs within **one business day**. Two passes rather than one wide window, because a
same-day pair must always win over a next-day one; a weekend is crossed by counting business
days, so Friday/Monday is a gap of 1. The relaxed level is deliberately weaker and second:
it is the level that can be wrong, so it never gets to take a candidate away from an exact
match.
**Greedy 1:1.** One confirmed event closes at most one shadow. Two report rows that both
look like one API row mean the report has a row the API does not — which is the finding we
are here to produce, not something to hide by matching the same event twice.
The matched shadow keeps `status = shadow` (analytics still must not read it) and gains
`meta["matched_event_id"]`, so the reconciliation screen can show what it was matched to.
Registered as a refresh step it belongs right after `matching` and before `quality`, so its
findings are drained into `metric_data_quality` by the same run that produced them; called
directly (as `report_import.commit` does) it still does the ledger half of the work.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from datetime import date
from decimal import Decimal
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
from fintracker.analytics import FINDINGS, Finding
from fintracker.ledger.matching import business_days_between
from fintracker.models import Event, EventStatus, Instrument
log = logging.getLogger(__name__)
PRICE_TOLERANCE = Decimal("0.005")
"""Relative tolerance on |price| — plan §1.6 B."""
MAX_DAY_GAP = 1
"""Business days a settlement date may sit away from a trade date at the relaxed level."""
MATCHED_KEY = "matched_event_id"
@dataclass
class DedupeResult:
matched: int = 0
matched_exact: int = 0
matched_relaxed: int = 0
unmatched: int = 0
findings: list[Finding] = field(default_factory=list)
def _price_close(a: Decimal | None, b: Decimal | None) -> bool:
"""Whether two prices are the same price, by magnitude and within 0,5 %."""
if a is None and b is None:
return True
if a is None or b is None:
return False
left, right = abs(a), abs(b)
if right == 0:
return left == 0
return abs(left - right) <= right * PRICE_TOLERANCE
def _quantity_equal(a: Decimal | None, b: Decimal | None) -> bool:
if a is None and b is None:
return True
if a is None or b is None:
return False
return a == b
def _bucket(event: Event) -> tuple[int, int | None, str]:
return (event.account_id, event.instrument_id, event.kind.value)
async def match_shadow_events(session: AsyncSession) -> DedupeResult:
"""Pair every `shadow` event with the `confirmed` one it duplicates, and report the rest.
Rebuilt from scratch on each call — previous `matched_event_id` marks are cleared first —
so a run after new events arrive cannot leave a stale pairing behind.
"""
shadows = list(
(
await session.execute(
select(Event)
.where(Event.status == EventStatus.shadow)
.order_by(Event.trade_date, Event.id)
)
)
.scalars()
.all()
)
result = DedupeResult()
if not shadows:
return result
account_ids = {s.account_id for s in shadows}
confirmed = list(
(
await session.execute(
select(Event)
.where(
Event.status == EventStatus.confirmed,
Event.account_id.in_(account_ids),
)
.order_by(Event.trade_date, Event.id)
)
)
.scalars()
.all()
)
buckets: dict[tuple[int, int | None, str], list[Event]] = {}
for event in confirmed:
buckets.setdefault(_bucket(event), []).append(event)
used: set[int] = set()
pairs: dict[int, int] = {}
for max_gap in (0, MAX_DAY_GAP):
for shadow in shadows:
if shadow.id in pairs:
continue
candidate = _pick(shadow, buckets.get(_bucket(shadow), []), used, max_gap)
if candidate is None:
continue
pairs[shadow.id] = candidate.id
used.add(candidate.id)
result.matched += 1
if max_gap == 0:
result.matched_exact += 1
else:
result.matched_relaxed += 1
# written for every shadow, matched or not: a run after new data must also CLEAR a mark
# that no longer holds, or a pairing invalidated by a re-import would outlive its match.
for shadow in shadows:
meta = dict(shadow.meta or {})
had = meta.pop(MATCHED_KEY, None)
matched_id = pairs.get(shadow.id)
if matched_id is not None:
meta[MATCHED_KEY] = matched_id
if had != matched_id:
await session.execute(update(Event).where(Event.id == shadow.id).values(meta=meta))
unmatched = [s for s in shadows if s.id not in pairs]
result.unmatched = len(unmatched)
result.findings = await _report_unmatched(session, unmatched)
for finding in result.findings:
FINDINGS.add(
finding.check_name,
finding.severity,
finding.detail,
count=finding.count,
ref=finding.ref,
)
log.info(
"shadow dedupe: %s matched (%s exact, %s ±1 business day), %s unmatched",
result.matched,
result.matched_exact,
result.matched_relaxed,
result.unmatched,
)
return result
def _pick(shadow: Event, candidates: list[Event], used: set[int], max_gap: int) -> Event | None:
"""The closest unused confirmed event that can be this shadow, or None."""
best: tuple[int, int, Event] | None = None
for candidate in candidates:
if candidate.id in used:
continue
if not _quantity_equal(shadow.quantity, candidate.quantity):
continue
if not _price_close(shadow.price, candidate.price):
continue
gap = _gap(shadow.trade_date, candidate)
if gap > max_gap:
continue
rank = (gap, candidate.id)
if best is None or rank < (best[0], best[1]):
best = (gap, candidate.id, candidate)
return best[2] if best else None
def _gap(report_date: date, candidate: Event) -> int:
"""Business days between the report's date and the API event's closest own date.
The settlement date is checked too: when a report prints T+1 and the API event carries
both dates, the pair is exact rather than merely close.
"""
gaps = [business_days_between(report_date, candidate.trade_date)]
if candidate.settle_date is not None:
gaps.append(business_days_between(report_date, candidate.settle_date))
return min(gaps)
async def _report_unmatched(session: AsyncSession, unmatched: list[Event]) -> list[Finding]:
"""One finding per shadow event the primary feed never reported."""
if not unmatched:
return []
instrument_ids = {e.instrument_id for e in unmatched if e.instrument_id is not None}
names: dict[int, str] = {}
if instrument_ids:
rows = (
await session.execute(
select(Instrument.id, Instrument.ticker, Instrument.name).where(
Instrument.id.in_(instrument_ids)
)
)
).all()
names = {iid: (ticker or name) for iid, ticker, name in rows}
findings: list[Finding] = []
for event in unmatched:
what = names.get(event.instrument_id or -1, "без инструмента")
qty = "" if event.quantity is None else f" {format(event.quantity, 'f')} шт"
findings.append(
Finding(
"report_only_event",
"warn",
f"Есть в отчёте, нет в API: {event.kind.value} {what}{qty} "
f"от {event.trade_date.isoformat()} (счёт #{event.account_id}, "
f"источник {event.source})",
1,
{
"event_id": event.id,
"account_id": event.account_id,
"instrument_id": event.instrument_id,
"trade_date": event.trade_date.isoformat(),
"source": event.source,
},
)
)
return findings
async def rebuild_shadow_matches(session: AsyncSession) -> None:
"""Refresh-step shape: the same work, discarding the result object."""
await match_shadow_events(session)
+650
View File
@@ -0,0 +1,650 @@
"""`BrokerEvent` -> `event`: the only place a parsed report becomes ledger truth (plan §1.4).
Three rules hold this module together.
**Nothing is guessed.** The instrument behind a report row is resolved by identity only —
ISIN, FIGI, T-Invest uid, (ticker, board), then an `instrument_alias` recorded by an earlier
resolution (plan §1.3). A name is never compared fuzzily: attaching a trade to the wrong
paper produces a wrong lot, and a wrong lot is far more expensive to notice than a row that
openly says "I do not know this". What does not resolve becomes a `pending_instrument` and
its events are written with `status = pending` and `instrument_id = NULL`, carrying
`meta["pending_key"]` so `report_import.resolve_pending` can find them again.
**Re-import is a no-op.** Every row gets a `dedupe_key` (plan §1.6 A) and the unique index on
it turns the second import of the same file — or the overlapping half of the next month's
report — into an upsert of the row that already exists. Created and updated are counted
apart, so "0 new events" is an observable fact rather than a hope.
**A report is evidence, not authority.** An account names one `primary_event_source`; rows
from any other feed land as `shadow` (plan §1.6 B) and are matched against the primary feed
by `ledger/dedupe.py`. Only `confirmed` is read by analytics.
Two smaller conventions live here because nothing else can apply them:
* `meta["payout_hint"] == "coupon"` — a CSV export cannot tell a dividend from a coupon
(both are "payout"), but the instrument master can: once the row resolves to a bond, a
`dividend` is reclassified as `coupon`. Before resolution the hint is just a hint.
* `ParsedReport.meta["manual_prices"]` — closing prices a report prints for papers no
exchange quotes. They become `price_manual` rows after the instrument resolves.
`meta["balance_adjustments"]` is deliberately NOT turned into events: a broker's own
correction of a balance is not an economic event, only a warning worth showing.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from datetime import date, datetime, time
from decimal import Decimal
from typing import Any
from zoneinfo import ZoneInfo
from sqlalchemy import func, literal_column, select
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.ext.asyncio import AsyncSession
from fintracker.models import (
Account,
AssetClass,
Event,
EventKind,
EventSource,
EventStatus,
Instrument,
InstrumentAlias,
PendingInstrument,
PendingInstrumentStatus,
PriceManual,
RawReportLine,
)
from fintracker.sources.reports.base import (
BrokerEvent,
InstrumentRef,
ParsedReport,
fingerprint_key,
trade_dedupe_key,
)
log = logging.getLogger(__name__)
MSK = ZoneInfo("Europe/Moscow")
SOURCE_ALIASES: dict[str, EventSource] = {
"csv_universal": EventSource.csv,
"report_csv": EventSource.csv,
}
"""Parser names that are not literally an `EventSource` value."""
PENDING_KEY = "pending_key"
"""`event.meta` field naming the `pending_instrument.source_key` an event waits on."""
def event_source(source: str) -> EventSource | None:
"""The `EventSource` a parser name stands for, or None when it names no feed."""
if source in SOURCE_ALIASES:
return SOURCE_ALIASES[source]
try:
return EventSource(source)
except ValueError:
return None
def status_for(account: Account, source: str) -> EventStatus:
"""`confirmed` when this feed is the account's primary one, else `shadow` (plan §1.6 B).
An account that names no primary source at all has nothing to be shadowed by, so the
first feed to write into it is believed — otherwise a freshly created account would
accumulate events no metric ever reads.
"""
primary = account.primary_event_source
if primary is None:
return EventStatus.confirmed
return EventStatus.confirmed if event_source(source) == primary else EventStatus.shadow
@dataclass
class IngestResult:
"""What one `ingest` call did, in the shape the preview and the commit response need."""
events_total: int = 0
events_created: int = 0
events_updated: int = 0
events_new: int = 0
"""Rows whose `dedupe_key` was not in `event` when the run started."""
events_duplicate: int = 0
events_shadow: int = 0
events_pending: int = 0
by_kind: dict[str, int] = field(default_factory=dict)
pending_keys: list[str] = field(default_factory=list)
manual_prices: int = 0
lines: int = 0
warnings: list[str] = field(default_factory=list)
@dataclass(frozen=True, slots=True)
class Resolution:
"""The outcome of looking one `InstrumentRef` up in the master."""
instrument_id: int | None
state: str
"""resolved | pending | ignored | none — `none` is a row with no instrument at all
(a deposit, a fee), `ignored` a key the user said is not an instrument."""
asset_class: AssetClass | None = None
NO_INSTRUMENT = Resolution(None, "none")
class InstrumentResolver:
"""Identity-only resolution, cached per key for the length of one import.
The order is the plan's (§1.3) and the fallbacks are ordered by how much they can lie:
ISIN identifies a paper globally, an alias only says "a human confirmed this once".
"""
def __init__(self, session: AsyncSession, source: str) -> None:
self._session = session
self._source = source
self._cache: dict[str, Resolution] = {}
async def resolve(self, ref: InstrumentRef | None) -> Resolution:
if ref is None:
return NO_INSTRUMENT
key = ref.key()
cached = self._cache.get(key)
if cached is not None:
return cached
found = await self._lookup(ref, key)
self._cache[key] = found
return found
async def _lookup(self, ref: InstrumentRef, key: str) -> Resolution:
session = self._session
for column, value in (
(Instrument.isin, ref.isin),
(Instrument.figi, ref.meta.get("figi")),
(Instrument.tinvest_uid, ref.meta.get("tinvest_uid")),
):
if not value:
continue
row = (
await session.execute(
select(Instrument.id, Instrument.asset_class).where(column == value)
)
).first()
if row is not None:
return Resolution(row[0], "resolved", row[1])
if ref.ticker and ref.board:
row = (
await session.execute(
select(Instrument.id, Instrument.asset_class).where(
Instrument.ticker == ref.ticker, Instrument.board == ref.board
)
)
).first()
if row is not None:
return Resolution(row[0], "resolved", row[1])
row = (
await session.execute(
select(Instrument.id, Instrument.asset_class)
.join(InstrumentAlias, InstrumentAlias.instrument_id == Instrument.id)
.where(InstrumentAlias.source == self._source, InstrumentAlias.source_key == key)
)
).first()
if row is not None:
return Resolution(row[0], "resolved", row[1])
# a key the user already answered for: resolved (the alias above usually catches it,
# but the alias is written at resolve time and a hand-linked row may predate it) or
# explicitly declared not-an-instrument.
parked = (
await session.execute(
select(PendingInstrument).where(
PendingInstrument.source == self._source,
PendingInstrument.source_key == key,
)
)
).scalar_one_or_none()
if (
parked is not None
and parked.status == PendingInstrumentStatus.resolved
and parked.instrument_id is not None
):
asset_class = await self._session.scalar(
select(Instrument.asset_class).where(Instrument.id == parked.instrument_id)
)
return Resolution(parked.instrument_id, "resolved", asset_class)
if parked is not None and parked.status == PendingInstrumentStatus.ignored:
return Resolution(None, "ignored")
return Resolution(None, "pending")
def dedupe_key_for(parsed: ParsedReport, be: BrokerEvent) -> str:
"""The plan's §1.6 A key: the broker's deal number when there is one, else a fingerprint."""
if be.trade_no:
return trade_dedupe_key(parsed.broker, parsed.account_external_id, be.trade_no)
return fingerprint_key(
parsed.broker,
parsed.account_external_id,
be.kind,
be.instrument.key() if be.instrument else "",
be.trade_date,
be.quantity,
be.price,
be.currency,
amount=be.amount,
seq=be.seq,
)
def dedupe_keys(parsed: ParsedReport) -> list[str]:
"""Keys for every row of one report, with in-file collisions broken deterministically.
A deal number is supposed to be unique, but a report that prints the trade and its own
commission under one number would otherwise collapse two rows into one. When a key
repeats inside a single file the later rows fall back to the fingerprint — which carries
the kind and the amount — and, if that still repeats, to the fingerprint's `seq`.
"""
seen: dict[str, int] = {}
out: list[str] = []
for be in parsed.events:
key = dedupe_key_for(parsed, be)
if key in seen:
bump = seen[key]
while True:
key = fingerprint_key(
parsed.broker,
parsed.account_external_id,
be.kind,
be.instrument.key() if be.instrument else "",
be.trade_date,
be.quantity,
be.price,
be.currency,
amount=be.amount,
seq=be.seq + bump,
)
if key not in seen:
break
bump += 1
seen[key] = seen.get(key, 0) + 1
out.append(key)
return out
def jsonable(value: Any) -> Any:
"""JSONB-safe snapshot: Decimal and date become strings, float never survives.
`raw_report_line.payload` is the diagnostic record of what the parser saw, and a float
in it would silently round the money it is supposed to preserve.
"""
if isinstance(value, Decimal):
return format(value, "f")
if isinstance(value, float):
return format(Decimal(str(value)), "f")
if isinstance(value, datetime):
return value.isoformat()
if isinstance(value, date):
return value.isoformat()
if isinstance(value, dict):
return {str(k): jsonable(v) for k, v in value.items()}
if isinstance(value, list | tuple | set):
return [jsonable(v) for v in value]
if isinstance(value, InstrumentRef):
return {
"key": value.key(),
"isin": value.isin,
"ticker": value.ticker,
"board": value.board,
"name": value.name,
"currency": value.currency,
"asset_class_hint": value.asset_class_hint,
"source_key": value.source_key,
"meta": jsonable(value.meta),
}
return value
def line_payload(be: BrokerEvent) -> dict[str, Any]:
"""The JSONB snapshot of one parsed row, in the parser's own terms."""
return {
"kind": be.kind.value,
"trade_date": be.trade_date.isoformat(),
"settle_date": be.settle_date.isoformat() if be.settle_date else None,
"amount": jsonable(be.amount),
"currency": be.currency,
"instrument": jsonable(be.instrument) if be.instrument else None,
"quantity": jsonable(be.quantity),
"price": jsonable(be.price),
"price_currency": be.price_currency,
"fee": jsonable(be.fee),
"fee_currency": be.fee_currency,
"tax": jsonable(be.tax),
"tax_currency": be.tax_currency,
"accrued_interest": jsonable(be.accrued_interest),
"trade_no": be.trade_no,
"description": be.description,
"raw_line_no": be.raw_line_no,
"seq": be.seq,
"meta": jsonable(be.meta),
}
def section_of(be: BrokerEvent) -> str:
"""Which part of the report a row came from — stored on `raw_report_line`."""
section = be.meta.get("section")
if isinstance(section, str) and section:
return section
if be.instrument is not None:
return "trades"
return "cash"
def final_kind(be: BrokerEvent, resolution: Resolution) -> EventKind:
"""`dividend` from a payout-hinting parser becomes `coupon` once the paper is a bond."""
if (
be.kind == EventKind.dividend
and be.meta.get("payout_hint") == "coupon"
and resolution.asset_class == AssetClass.bond
):
return EventKind.coupon
return be.kind
async def ingest(
session: AsyncSession,
*,
account: Account,
parsed: ParsedReport,
source: str,
dry_run: bool = False,
file_id: int | None = None,
) -> IngestResult:
"""Write one parsed report into `event`, idempotently.
`dry_run` is what the import preview runs: it resolves instruments, counts everything
and — deliberately — still parks unknown instruments in `pending_instrument`, because
the preview screen is exactly where the user is asked to resolve them. It writes no
`event` and no `raw_report_line`.
"""
resolver = InstrumentResolver(session, source)
keys = dedupe_keys(parsed)
existing = set(
(await session.execute(select(Event.dedupe_key).where(Event.dedupe_key.in_(keys))))
.scalars()
.all()
)
result = IngestResult(events_total=len(parsed.events), lines=len(parsed.events))
result.warnings.extend(parsed.warnings)
for adjustment in parsed.meta.get("balance_adjustments") or []:
result.warnings.append(f"Корректировка остатка в отчёте (не событие): {adjustment}")
default_status = status_for(account, source)
rows: list[dict[str, Any]] = []
lines: list[dict[str, Any]] = []
pending_seen: dict[str, tuple[InstrumentRef, BrokerEvent, int]] = {}
for be, key in zip(parsed.events, keys, strict=True):
resolution = await resolver.resolve(be.instrument)
kind = final_kind(be, resolution)
if resolution.state == "pending" and be.instrument is not None:
status = EventStatus.pending
pending_key = be.instrument.key()
ref, sample, count = pending_seen.get(pending_key, (be.instrument, be, 0))
pending_seen[pending_key] = (ref, sample, count + 1)
result.events_pending += 1
else:
status = default_status
pending_key = None
if status == EventStatus.shadow:
result.events_shadow += 1
result.by_kind[kind.value] = result.by_kind.get(kind.value, 0) + 1
if key in existing:
result.events_duplicate += 1
else:
result.events_new += 1
rows.append(
_event_row(account, be, kind, status, key, source, pending_key, file_id, resolution)
)
lines.append(
{
"file_id": file_id,
"line_no": be.raw_line_no,
"section": section_of(be),
"payload": line_payload(be),
"dedupe_key": key,
}
)
for ref, sample, count in pending_seen.values():
await upsert_pending(session, source, ref, sample, file_id, occurrences=count)
result.pending_keys = sorted(pending_seen)
if dry_run:
return result
if rows:
stmt = pg_insert(Event).values(rows)
stmt = stmt.on_conflict_do_update(
index_elements=["dedupe_key"],
set_={
c: stmt.excluded[c]
for c in (
"account_id",
"instrument_id",
"kind",
"status",
"ts",
"trade_date",
"settle_date",
"quantity",
"price",
"price_currency",
"amount",
"currency",
"fee",
"fee_currency",
"tax",
"tax_currency",
"accrued_interest",
"source",
"source_id",
"raw_ref",
"description",
"meta",
)
},
).returning(Event.id, Event.dedupe_key, literal_column("xmax") == 0)
written = (await session.execute(stmt)).all()
ids = {key: event_id for event_id, key, _ in written}
result.events_created = sum(1 for _, _, inserted in written if inserted)
result.events_updated = len(written) - result.events_created
if file_id is not None:
for line in lines:
line["event_id"] = ids.get(line["dedupe_key"])
await _write_lines(session, lines)
result.manual_prices = await _write_manual_prices(session, parsed, resolver)
return result
def _event_row(
account: Account,
be: BrokerEvent,
kind: EventKind,
status: EventStatus,
key: str,
source: str,
pending_key: str | None,
file_id: int | None,
resolution: Resolution,
) -> dict[str, Any]:
meta: dict[str, Any] = jsonable(dict(be.meta))
if pending_key is not None:
meta[PENDING_KEY] = pending_key
if file_id is not None:
meta["import_file_id"] = file_id
meta["report_line_no"] = be.raw_line_no
return {
"account_id": account.id,
"instrument_id": resolution.instrument_id,
"kind": kind,
"status": status,
"ts": datetime.combine(be.trade_date, time(0, 0), tzinfo=MSK),
"trade_date": be.trade_date,
"settle_date": be.settle_date,
"quantity": be.quantity,
"price": be.price,
"price_currency": be.price_currency or (be.currency if be.price is not None else None),
"amount": be.amount,
"currency": be.currency,
"fee": abs(be.fee) if be.fee is not None else None,
"fee_currency": be.fee_currency or (be.currency if be.fee is not None else None),
"tax": abs(be.tax) if be.tax is not None else None,
"tax_currency": be.tax_currency or (be.currency if be.tax is not None else None),
"accrued_interest": be.accrued_interest,
"source": source,
"source_id": be.trade_no,
"raw_ref": f"{file_id}:{be.raw_line_no}" if file_id is not None else None,
"dedupe_key": key,
"description": be.description,
"meta": meta,
}
async def upsert_pending(
session: AsyncSession,
source: str,
ref: InstrumentRef,
be: BrokerEvent,
file_id: int | None,
*,
occurrences: int,
) -> None:
"""Park an unresolved instrument, counting how often it has been seen (plan §1.3).
The conflict target is `(source, source_key)`, so ten files naming the same unknown paper
are one question to the user with a count, not ten identical rows. A row the user already
answered is never reopened: the update only fires while the row is still `pending`.
"""
stmt = pg_insert(PendingInstrument).values(
source=source,
source_key=ref.key(),
isin=ref.isin,
ticker=ref.ticker,
board=ref.board,
name=ref.name,
currency=ref.currency,
asset_class_hint=ref.asset_class_hint,
first_seen_file_id=file_id,
occurrences=occurrences,
sample_quantity=be.quantity,
sample_price=be.price,
status=PendingInstrumentStatus.pending,
)
await session.execute(
stmt.on_conflict_do_update(
index_elements=["source", "source_key"],
set_={
"occurrences": PendingInstrument.occurrences + occurrences,
"isin": func.coalesce(PendingInstrument.isin, stmt.excluded.isin),
"ticker": func.coalesce(PendingInstrument.ticker, stmt.excluded.ticker),
"board": func.coalesce(PendingInstrument.board, stmt.excluded.board),
"name": func.coalesce(PendingInstrument.name, stmt.excluded.name),
"currency": func.coalesce(PendingInstrument.currency, stmt.excluded.currency),
"asset_class_hint": func.coalesce(
PendingInstrument.asset_class_hint, stmt.excluded.asset_class_hint
),
"sample_quantity": func.coalesce(
PendingInstrument.sample_quantity, stmt.excluded.sample_quantity
),
"sample_price": func.coalesce(
PendingInstrument.sample_price, stmt.excluded.sample_price
),
},
where=PendingInstrument.status == PendingInstrumentStatus.pending,
)
)
async def _write_lines(session: AsyncSession, lines: list[dict[str, Any]]) -> None:
"""Append-only raw tier: a re-commit refreshes the snapshot and the event it produced."""
if not lines:
return
stmt = pg_insert(RawReportLine).values(lines)
await session.execute(
stmt.on_conflict_do_update(
index_elements=["file_id", "line_no"],
set_={
"section": stmt.excluded.section,
"payload": stmt.excluded.payload,
"dedupe_key": stmt.excluded.dedupe_key,
"event_id": stmt.excluded.event_id,
},
)
)
async def _write_manual_prices(
session: AsyncSession, parsed: ParsedReport, resolver: InstrumentResolver
) -> int:
"""`meta["manual_prices"]` -> `price_manual`, for papers no exchange quotes.
The key names an instrument the same way the events do, so resolution reuses the cache
the events already filled; a price for something still pending is simply skipped — it
will come back with the next import, after the user resolves the paper.
"""
entries = parsed.meta.get("manual_prices") or []
if not entries:
return 0
by_key = {ref.key(): ref for ref in instrument_refs(parsed)}
written = 0
for entry in entries:
key = entry.get("instrument_key")
ref = by_key.get(key)
if ref is None:
continue
resolution = await resolver.resolve(ref)
if resolution.instrument_id is None:
continue
d = entry["d"]
if isinstance(d, str):
d = date.fromisoformat(d)
price = Decimal(str(entry["price"]))
currency = entry.get("currency") or ref.currency or parsed.meta.get("currency") or "RUB"
exists = await session.scalar(
select(PriceManual.id).where(
PriceManual.instrument_id == resolution.instrument_id, PriceManual.d == d
)
)
if exists is not None:
continue
session.add(
PriceManual(
instrument_id=resolution.instrument_id,
d=d,
price=price,
currency=currency,
note=f"{parsed.broker} report {parsed.period_from}..{parsed.period_to}",
)
)
written += 1
return written
def instrument_refs(parsed: ParsedReport) -> list[InstrumentRef]:
"""Every instrument the report named, from any section."""
refs: dict[str, InstrumentRef] = {}
for ref in parsed.instruments:
refs.setdefault(ref.key(), ref)
for be in parsed.events:
if be.instrument is not None:
refs.setdefault(be.instrument.key(), be.instrument)
for pos in parsed.positions_end:
refs.setdefault(pos.instrument.key(), pos.instrument)
return list(refs.values())
@@ -0,0 +1,961 @@
"""The import service behind `/api/v1/imports`: upload -> preview -> commit (plan §2, §4).
The flow is two-phase because a broker report is not trustworthy until a human has looked at
it. Uploading stores the bytes and parses them; nothing reaches the ledger. The preview is
the screen where the numbers are checked — how many rows are new, which instruments nobody
recognises, whether the report's own closing balances agree with what the ledger derives.
Only `commit` writes events.
**Idempotency has two layers.** The outer one is the file: `raw_report_file.sha256` is
unique, so re-uploading the same bytes returns the import that already exists
(`duplicate_of_id`) without parsing again — the same file twice is 0 new events before any
parser runs. The inner one is the row: `event.dedupe_key` makes overlapping report periods
upsert into the rows they already produced, which is what makes "JanuaryJune" and
"AprilSeptember" add up to each trade once.
**Reconciliation is the point of storing `positions_end` / `cash_end`.** The report states
what the broker thinks the account held; the ledger derives the same numbers from events
(positions as Σ `lot.qty_remaining`, cash as Σ `event.amount` per currency over confirmed
events). A disagreement is the single most useful signal this project has about a mapping
bug, so each one becomes a data-quality finding naming the instrument and the report.
Findings raised here (and by `ledger/dedupe.py`) only survive into `metric_data_quality` if
they are produced *during* a refresh — the first step resets the collector and the last one
drains it. So `shadow_dedupe` and `report_reconcile` are registered as ordinary refresh
steps just before `quality` (see `analytics/__init__.register_steps`), and commit simply
calls `refresh_all` the way every other writer does.
"""
from __future__ import annotations
import hashlib
import logging
from dataclasses import dataclass, field
from datetime import UTC, date, datetime
from decimal import Decimal
from typing import Any
from sqlalchemy import func, select
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.ext.asyncio import AsyncSession
from fintracker.analytics import FINDINGS
from fintracker.ledger.dedupe import match_shadow_events
from fintracker.ledger.ingest import (
PENDING_KEY,
InstrumentResolver,
dedupe_keys,
ingest,
line_payload,
section_of,
status_for,
)
from fintracker.models import (
Account,
AccountKind,
AssetClass,
Event,
EventStatus,
Instrument,
InstrumentAlias,
Lot,
PendingInstrument,
PendingInstrumentStatus,
RawReportFile,
RawReportLine,
ReportParseStatus,
)
from fintracker.sources.reports import registry
from fintracker.sources.reports.base import ParsedReport, ParseError
log = logging.getLogger(__name__)
MAX_UPLOAD_BYTES = 16 * 1024 * 1024
SAMPLE_SIZE = 20
BROKER_SOURCES = ("report_sber", "report_vtb", "csv")
"""`account.source` values a broker report can belong to."""
class ImportProblem(Exception):
"""A refusal the API turns into a `Problem`; the service stays free of FastAPI."""
def __init__(
self, status_code: int, title: str, detail: str, *, extra: dict[str, Any] | None = None
) -> None:
super().__init__(detail)
self.status_code = status_code
self.title = title
self.detail = detail
self.extra = extra or {}
# --------------------------------------------------------------------------- upload
@dataclass
class UploadOutcome:
file: RawReportFile
duplicate_of_id: int | None = None
async def upload(
session: AsyncSession,
*,
data: bytes,
filename: str,
account_id: int | None = None,
parser_name: str | None = None,
) -> UploadOutcome:
"""Store one uploaded report and parse it. Never writes an event.
A file already known by its sha256 short-circuits: its import row is returned as-is, so a
user who uploads the same export twice sees the first import rather than a second one.
"""
if len(data) > MAX_UPLOAD_BYTES:
raise ImportProblem(
413,
"Payload Too Large",
f"Файл больше {MAX_UPLOAD_BYTES // (1024 * 1024)} МБ",
)
sha256 = hashlib.sha256(data).hexdigest()
existing = (
await session.execute(select(RawReportFile).where(RawReportFile.sha256 == sha256))
).scalar_one_or_none()
if existing is not None:
return UploadOutcome(existing, duplicate_of_id=existing.id)
parser = _pick_parser(data, filename, parser_name)
row = RawReportFile(
broker=parser.broker,
filename=filename,
sha256=sha256,
size_bytes=len(data),
content=data,
parser_name=parser.name,
parser_version=parser.version,
parse_status=ReportParseStatus.uploaded,
uploaded_at=datetime.now(UTC),
account_id=account_id,
)
session.add(row)
await session.flush()
try:
parsed = parser.parse(data, filename)
except ParseError as exc:
# the row stays: a format regression that leaves no trace is a bug report nobody can
# reproduce. `parse_status = failed` is the trace, and its id goes back to the client.
row.parse_status = ReportParseStatus.failed
row.error = str(exc)
await session.commit()
raise ImportProblem(
422,
"Unprocessable Entity",
f"Парсер {parser.name} не смог прочитать файл: {exc}",
extra={"errors": [{"import_id": row.id}]},
) from exc
row.account_external_id = parsed.account_external_id
row.period_from = parsed.period_from
row.period_to = parsed.period_to
row.warnings = list(parsed.warnings)
row.parse_status = ReportParseStatus.parsed
if row.account_id is None:
row.account_id = await _find_account(session, parsed)
await _store_lines(session, row.id, parsed)
await session.commit()
return UploadOutcome(row)
def _pick_parser(data: bytes, filename: str, parser_name: str | None):
if parser_name:
try:
return registry.get(parser_name)
except KeyError:
known = ", ".join(registry.names()) or ""
raise ImportProblem(
415,
"Unsupported Media Type",
f"Нет парсера {parser_name!r}; известные парсеры: {known}",
) from None
parser = registry.pick(data, filename)
if parser is None:
known = ", ".join(registry.names()) or ""
raise ImportProblem(
415,
"Unsupported Media Type",
f"Формат файла не распознан; известные парсеры: {known}",
)
return parser
async def _store_lines(session: AsyncSession, file_id: int, parsed: ParsedReport) -> None:
"""The raw tier: one row per parsed event, keyed by the file and its line number."""
keys = dedupe_keys(parsed)
rows = [
{
"file_id": file_id,
"line_no": be.raw_line_no,
"section": section_of(be),
"payload": line_payload(be),
"dedupe_key": key,
}
for be, key in zip(parsed.events, keys, strict=True)
]
if not rows:
return
stmt = pg_insert(RawReportLine).values(rows)
await session.execute(
stmt.on_conflict_do_update(
index_elements=["file_id", "line_no"],
set_={
"section": stmt.excluded.section,
"payload": stmt.excluded.payload,
"dedupe_key": stmt.excluded.dedupe_key,
},
)
)
async def _find_account(session: AsyncSession, parsed: ParsedReport) -> int | None:
"""The account whose `source_id` is the agreement number the report prints."""
if not parsed.account_external_id:
return None
return await session.scalar(
select(Account.id).where(
Account.source.in_(BROKER_SOURCES),
Account.source_id == parsed.account_external_id,
)
)
async def account_suggestions(session: AsyncSession) -> list[Account]:
"""Broker accounts the user may pick when the report's number matches none of them."""
return list(
(
await session.execute(
select(Account)
.where(Account.kind == AccountKind.broker, Account.archived.is_(False))
.order_by(Account.name)
)
)
.scalars()
.all()
)
def reparse(row: RawReportFile) -> ParsedReport:
"""Re-read the stored bytes with the parser that claimed them.
Kept instead of a second normalised copy of the report: the bytes are the evidence, and
a parser fix must be able to change what a stored file means without asking for it again.
"""
if row.content is None:
raise ImportProblem(422, "Unprocessable Entity", "Байты файла не сохранены")
if row.parser_name:
try:
parser = registry.get(row.parser_name)
except KeyError:
parser = None
else:
parser = None
if parser is None:
parser = registry.pick(row.content, row.filename)
if parser is None:
raise ImportProblem(
415, "Unsupported Media Type", f"Нет парсера {row.parser_name!r} для этого файла"
)
return parser.parse(row.content, row.filename)
# --------------------------------------------------------------------------- preview
@dataclass
class ReconPosition:
instrument_id: int | None
instrument_name: str
ticker: str | None
isin: str | None
qty_report: Decimal
qty_derived: Decimal | None
qty_delta: Decimal | None
matches: bool
@dataclass
class ReconCash:
currency: str
balance_report: Decimal
balance_derived: Decimal | None
delta: Decimal | None
matches: bool
@dataclass
class Reconciliation:
as_of: date | None
positions: list[ReconPosition] = field(default_factory=list)
cash: list[ReconCash] = field(default_factory=list)
matches: bool = True
@dataclass
class SampleEvent:
line_no: int
kind: str
trade_date: date
settle_date: date | None
instrument_key: str | None
instrument_name: str | None
instrument_id: int | None
quantity: Decimal | None
price: Decimal | None
amount: Decimal
currency: str
fee: Decimal | None
trade_no: str | None
dedupe_key: str
is_duplicate: bool
description: str | None
@dataclass
class Preview:
file: RawReportFile
account: Account | None
counts: dict[str, Any]
pending: list[PendingInstrument]
sample_events: list[SampleEvent]
reconciliation: Reconciliation
warnings: list[str]
suggestions: list[Account]
duplicate_of_id: int | None = None
async def build_preview(
session: AsyncSession, row: RawReportFile, *, duplicate_of_id: int | None = None
) -> Preview:
"""Everything `ImportPreview` needs, computed without writing a single event.
A committed import reports the counters it was committed with (`raw_report_file.counts`):
re-deriving them later would show today's ledger, not what this file did to it. Its
reconciliation is recomputed, because that one IS a question about today.
"""
account = await session.get(Account, row.account_id) if row.account_id else None
warnings = list(row.warnings or [])
if row.parse_status == ReportParseStatus.failed:
return Preview(
file=row,
account=account,
counts=_empty_counts(),
pending=[],
sample_events=[],
reconciliation=Reconciliation(as_of=row.period_to),
warnings=warnings,
suggestions=await account_suggestions(session),
duplicate_of_id=duplicate_of_id,
)
parsed = reparse(row)
source = row.parser_name or parsed.broker
resolver = InstrumentResolver(session, source)
if row.parse_status == ReportParseStatus.committed and row.counts:
counts = dict(row.counts)
elif account is not None:
result = await ingest(
session, account=account, parsed=parsed, source=source, dry_run=True, file_id=row.id
)
counts = _counts_of(result)
warnings = list(dict.fromkeys(warnings + result.warnings))
else:
# no account yet: the dry run would have nowhere to hang a status, so only the parts
# that do not depend on one are reported — and the unknown instruments are still
# parked, because the user can resolve them before picking the account.
counts = await _accountless_counts(session, parsed, source, row.id)
keys = dedupe_keys(parsed)
known = set(
(await session.execute(select(Event.dedupe_key).where(Event.dedupe_key.in_(keys))))
.scalars()
.all()
)
samples: list[SampleEvent] = []
for be, key in list(zip(parsed.events, keys, strict=True))[:SAMPLE_SIZE]:
resolution = await resolver.resolve(be.instrument)
samples.append(
SampleEvent(
line_no=be.raw_line_no,
kind=be.kind.value,
trade_date=be.trade_date,
settle_date=be.settle_date,
instrument_key=be.instrument.key() if be.instrument else None,
instrument_name=be.instrument.name if be.instrument else None,
instrument_id=resolution.instrument_id,
quantity=be.quantity,
price=be.price,
amount=be.amount,
currency=be.currency,
fee=be.fee,
trade_no=be.trade_no,
dedupe_key=key,
is_duplicate=key in known,
description=be.description,
)
)
pending_keys = {ref.key() for ref in _pending_refs(parsed)}
pending = []
if pending_keys:
pending = list(
(
await session.execute(
select(PendingInstrument).where(
PendingInstrument.source == source,
PendingInstrument.source_key.in_(pending_keys),
PendingInstrument.status == PendingInstrumentStatus.pending,
)
)
)
.scalars()
.all()
)
recon = await reconcile(session, parsed, account, resolver)
await session.commit()
return Preview(
file=row,
account=account,
counts=counts,
pending=pending,
sample_events=samples,
reconciliation=recon,
warnings=warnings,
suggestions=[] if account is not None else await account_suggestions(session),
duplicate_of_id=duplicate_of_id,
)
def _pending_refs(parsed: ParsedReport):
from fintracker.ledger.ingest import instrument_refs
return instrument_refs(parsed)
def _empty_counts() -> dict[str, Any]:
return {
"lines": 0,
"events_total": 0,
"events_new": 0,
"events_duplicate": 0,
"events_shadow": 0,
"events_pending": 0,
"by_kind": {},
}
def _counts_of(result) -> dict[str, Any]:
return {
"lines": result.lines,
"events_total": result.events_total,
"events_new": result.events_new,
"events_duplicate": result.events_duplicate,
"events_shadow": result.events_shadow,
"events_pending": result.events_pending,
"by_kind": dict(result.by_kind),
}
async def _accountless_counts(
session: AsyncSession, parsed: ParsedReport, source: str, file_id: int
) -> dict[str, Any]:
"""Counters for a file whose account is not known yet, plus the pending parking."""
resolver = InstrumentResolver(session, source)
keys = dedupe_keys(parsed)
known = set(
(await session.execute(select(Event.dedupe_key).where(Event.dedupe_key.in_(keys))))
.scalars()
.all()
)
counts = _empty_counts()
counts["lines"] = len(parsed.events)
counts["events_total"] = len(parsed.events)
by_kind: dict[str, int] = {}
for be, key in zip(parsed.events, keys, strict=True):
by_kind[be.kind.value] = by_kind.get(be.kind.value, 0) + 1
if key in known:
counts["events_duplicate"] += 1
else:
counts["events_new"] += 1
resolution = await resolver.resolve(be.instrument)
if resolution.state == "pending":
counts["events_pending"] += 1
counts["by_kind"] = by_kind
from fintracker.ledger.ingest import upsert_pending
seen: dict[str, Any] = {}
for be in parsed.events:
if be.instrument is None:
continue
resolution = await resolver.resolve(be.instrument)
if resolution.state != "pending":
continue
key = be.instrument.key()
ref, sample, count = seen.get(key, (be.instrument, be, 0))
seen[key] = (ref, sample, count + 1)
for ref, sample, count in seen.values():
await upsert_pending(session, source, ref, sample, file_id, occurrences=count)
return counts
# --------------------------------------------------------------------------- reconciliation
async def reconcile(
session: AsyncSession,
parsed: ParsedReport,
account: Account | None,
resolver: InstrumentResolver,
*,
report_findings: bool = False,
) -> Reconciliation:
"""The report's closing balances against what the ledger derives for the same account.
Positions come from Σ `lot.qty_remaining` (the FIFO replay's own answer), cash from
Σ `event.amount` per currency over confirmed events — the two VIEWs the plan describes in
§1.6, computed inline. `qty_derived` is null when the account is unknown or its lots have
never been rebuilt, and a null is shown as "не сверено", never as a zero.
"""
recon = Reconciliation(as_of=parsed.period_to)
if not parsed.positions_end and not parsed.cash_end:
return recon
derived_qty: dict[int, Decimal] = {}
derived_cash: dict[str, Decimal] = {}
if account is not None:
derived_qty = {
iid: qty
for iid, qty in (
await session.execute(
select(Lot.instrument_id, func.sum(Lot.qty_remaining))
.where(Lot.account_id == account.id)
.group_by(Lot.instrument_id)
)
).all()
}
derived_cash = {
ccy: total
for ccy, total in (
await session.execute(
select(Event.currency, func.sum(Event.amount))
.where(
Event.account_id == account.id,
Event.status == EventStatus.confirmed,
)
.group_by(Event.currency)
)
).all()
}
for position in parsed.positions_end:
resolution = await resolver.resolve(position.instrument)
iid = resolution.instrument_id
qty_derived = derived_qty.get(iid) if (iid is not None and account is not None) else None
delta = None if qty_derived is None else qty_derived - position.qty
matches = delta is not None and delta == 0
recon.positions.append(
ReconPosition(
instrument_id=iid,
instrument_name=position.instrument.name or position.instrument.key(),
ticker=position.instrument.ticker,
isin=position.instrument.isin,
qty_report=position.qty,
qty_derived=qty_derived,
qty_delta=delta,
matches=matches,
)
)
if report_findings and delta is not None and delta != 0:
FINDINGS.add(
"report_position_mismatch",
"warn",
f"Отчёт {parsed.broker} на {parsed.period_to}: "
f"{position.instrument.name or position.instrument.key()} "
f"({position.instrument.isin or position.instrument.ticker or ''}) — "
f"в отчёте {format(position.qty, 'f')}, в леджере {format(qty_derived, 'f')}",
ref={
"account_id": account.id if account else None,
"instrument_id": iid,
"isin": position.instrument.isin,
"as_of": parsed.period_to.isoformat(),
},
)
for cash in parsed.cash_end:
# A currency with no confirmed event is a derived balance of ZERO, not an unknown:
# the derived balance is a sum over events, and a sum over none of them is 0. Sber
# prints an EUR and a USD row on every report whether or not the account ever held
# either, and reading those as «неизвестно» made a clean reconciliation impossible —
# every report would have declared two permanent discrepancies. With no account at
# all (preview before the user picked one) there is genuinely nothing to compare.
balance_derived = (
derived_cash.get(cash.currency, Decimal(0)) if account is not None else None
)
delta = None if balance_derived is None else balance_derived - cash.balance
matches = delta is not None and delta == 0
recon.cash.append(
ReconCash(
currency=cash.currency,
balance_report=cash.balance,
balance_derived=balance_derived,
delta=delta,
matches=matches,
)
)
if report_findings and delta is not None and delta != 0:
FINDINGS.add(
"report_cash_mismatch",
"warn",
f"Отчёт {parsed.broker} на {parsed.period_to}: остаток {cash.currency}"
f"в отчёте {format(cash.balance, 'f')}, в леджере {format(balance_derived, 'f')}",
ref={
"account_id": account.id if account else None,
"currency": cash.currency,
"as_of": parsed.period_to.isoformat(),
},
)
recon.matches = all(p.matches for p in recon.positions) and all(c.matches for c in recon.cash)
return recon
# --------------------------------------------------------------------------- commit
@dataclass
class CommitOutcome:
file: RawReportFile
events_created: int
events_updated: int
events_skipped: int
events_shadow: int
pending_instruments: int
reconciliation: Reconciliation
metrics_refreshed: bool
committed: bool
async def commit(
session: AsyncSession,
row: RawReportFile,
*,
account_id: int | None = None,
confirm_duplicates: bool = False,
dry_run: bool = False,
) -> CommitOutcome:
"""Write the file's events, match the shadows, rebuild the metrics.
Idempotent by `event.dedupe_key`: a second commit of the same file updates the rows it
already produced and reports `events_created = 0`. `confirm_duplicates` is accepted for
the contract's sake but changes nothing about safety — the upsert is the safe operation
either way; without it duplicates are merely reported, with it they are refreshed.
"""
if row.parse_status == ReportParseStatus.committed:
raise ImportProblem(409, "Conflict", f"Импорт #{row.id} уже закоммичен")
if row.parse_status == ReportParseStatus.failed:
raise ImportProblem(
422, "Unprocessable Entity", f"Импорт #{row.id} не распарсен: {row.error}"
)
if account_id is not None:
row.account_id = account_id
if row.account_id is None:
raise ImportProblem(
422,
"Unprocessable Entity",
"Счёт не определён: в отчёте номер "
f"{row.account_external_id!r}, подходящего account нет — передайте account_id",
)
account = await session.get(Account, row.account_id)
if account is None:
raise ImportProblem(422, "Unprocessable Entity", f"Нет счёта #{row.account_id}")
parsed = reparse(row)
source = row.parser_name or parsed.broker
result = await ingest(
session, account=account, parsed=parsed, source=source, dry_run=dry_run, file_id=row.id
)
if dry_run:
await session.rollback()
fresh = await session.get(RawReportFile, row.id)
assert fresh is not None
return CommitOutcome(
file=fresh,
events_created=0,
events_updated=0,
events_skipped=result.events_pending,
events_shadow=result.events_shadow,
pending_instruments=len(result.pending_keys),
reconciliation=Reconciliation(as_of=parsed.period_to),
metrics_refreshed=False,
committed=False,
)
await match_shadow_events(session)
row.parse_status = ReportParseStatus.committed
row.committed_at = datetime.now(UTC)
row.counts = _counts_of(result)
row.warnings = list(dict.fromkeys(list(row.warnings or []) + result.warnings))
await session.commit()
await refresh_metrics(session)
resolver = InstrumentResolver(session, source)
recon = await reconcile(session, parsed, account, resolver)
await session.commit()
return CommitOutcome(
file=row,
events_created=result.events_created,
events_updated=result.events_updated,
events_skipped=result.events_pending,
events_shadow=result.events_shadow,
pending_instruments=len(result.pending_keys),
reconciliation=recon,
metrics_refreshed=True,
committed=True,
)
async def refresh_metrics(session: AsyncSession) -> bool:
"""Rebuild every metric the way the worker and `POST /metrics/refresh` do."""
from fintracker.metrics.refresh import refresh_all
entry = await refresh_all(session, trigger="import")
if entry.error:
log.warning("import: metric refresh failed: %s", entry.error)
return entry.error is None
async def reconcile_reports(session: AsyncSession) -> None:
"""Re-check every committed report's closing balances against the current ledger."""
rows = list(
(
await session.execute(
select(RawReportFile)
.where(
RawReportFile.parse_status == ReportParseStatus.committed,
RawReportFile.account_id.is_not(None),
)
.order_by(RawReportFile.period_to.desc())
)
)
.scalars()
.all()
)
seen: set[int] = set()
for row in rows:
# only the newest report per account states the balance that should hold TODAY;
# an older period's closing position is history, not a disagreement.
if row.account_id is None or row.account_id in seen:
continue
seen.add(row.account_id)
try:
parsed = reparse(row)
except (ImportProblem, ParseError) as exc:
log.warning("import: cannot re-read file #%s for reconciliation: %s", row.id, exc)
continue
account = await session.get(Account, row.account_id)
resolver = InstrumentResolver(session, row.parser_name or parsed.broker)
await reconcile(session, parsed, account, resolver, report_findings=True)
# --------------------------------------------------------------------------- pending
@dataclass
class ResolveOutcome:
pending: PendingInstrument
instrument_id: int | None
events_bound: int
alias_created: bool
metrics_refreshed: bool
@dataclass
class InstrumentSpec:
asset_class: str
name: str
currency: str = "RUB"
isin: str | None = None
ticker: str | None = None
board: str | None = None
lot: int = 1
async def resolve_pending(
session: AsyncSession,
pending: PendingInstrument,
*,
action: str,
instrument_id: int | None = None,
instrument: InstrumentSpec | None = None,
) -> ResolveOutcome:
"""Answer one parked instrument: link it, create it, or declare it not an instrument.
Whatever the answer, the events that were waiting on this key stop waiting: they get the
instrument (or stay without one), leave `status = pending` for `confirmed`/`shadow` by the
account's own primary-source rule, and the lots are rebuilt so the position appears.
"""
if pending.status == PendingInstrumentStatus.resolved:
raise ImportProblem(409, "Conflict", f"Строка #{pending.id} уже разрешена")
alias_created = False
if action == "ignore":
pending.status = PendingInstrumentStatus.ignored
pending.resolved_at = datetime.now(UTC)
target_id = None
elif action == "link":
if instrument_id is None:
raise ImportProblem(422, "Unprocessable Entity", "Для action=link нужен instrument_id")
target = await session.get(Instrument, instrument_id)
if target is None:
raise ImportProblem(404, "Not Found", f"Нет инструмента #{instrument_id}")
target_id = target.id
elif action == "create":
if instrument is None:
raise ImportProblem(
422, "Unprocessable Entity", "Для action=create нужен блок instrument"
)
target_id = await _create_instrument(session, instrument)
else:
raise ImportProblem(
422, "Unprocessable Entity", f"Неизвестный action {action!r}: link | create | ignore"
)
if target_id is not None:
pending.status = PendingInstrumentStatus.resolved
pending.instrument_id = target_id
pending.resolved_at = datetime.now(UTC)
alias_created = await _ensure_alias(session, pending.source, pending.source_key, target_id)
bound = await _bind_waiting_events(session, pending, target_id)
await session.commit()
refreshed = await refresh_metrics(session)
return ResolveOutcome(
pending=pending,
instrument_id=target_id,
events_bound=bound,
alias_created=alias_created,
metrics_refreshed=refreshed,
)
async def _create_instrument(session: AsyncSession, spec: InstrumentSpec) -> int:
try:
asset_class = AssetClass(spec.asset_class)
except ValueError:
known = ", ".join(a.value for a in AssetClass)
raise ImportProblem(
422,
"Unprocessable Entity",
f"Неизвестный класс актива {spec.asset_class!r}; есть: {known}",
) from None
if spec.isin:
clash = await session.scalar(select(Instrument.id).where(Instrument.isin == spec.isin))
if clash is not None:
raise ImportProblem(
422,
"Unprocessable Entity",
f"ISIN {spec.isin} уже принадлежит инструменту #{clash}",
extra={"conflicting_instrument_id": clash},
)
row = Instrument(
asset_class=asset_class,
isin=spec.isin,
ticker=spec.ticker,
board=spec.board,
name=spec.name,
currency=spec.currency,
lot=spec.lot,
)
session.add(row)
await session.flush()
return row.id
async def _ensure_alias(
session: AsyncSession, source: str, source_key: str, instrument_id: int
) -> bool:
"""Record the report's own key, so the next import resolves it without asking again."""
stmt = pg_insert(InstrumentAlias).values(
instrument_id=instrument_id, source=source, source_key=source_key
)
result = await session.execute(
stmt.on_conflict_do_nothing(index_elements=["source", "source_key"]).returning(
InstrumentAlias.id
)
)
return result.scalar_one_or_none() is not None
async def _bind_waiting_events(
session: AsyncSession, pending: PendingInstrument, instrument_id: int | None
) -> int:
"""Attach and re-status the events that were parked on this key."""
events = list(
(
await session.execute(
select(Event).where(
Event.source == pending.source,
Event.status == EventStatus.pending,
Event.meta[PENDING_KEY].as_string() == pending.source_key,
)
)
)
.scalars()
.all()
)
if not events:
return 0
accounts = {
a.id: a
for a in (
await session.execute(
select(Account).where(Account.id.in_({e.account_id for e in events}))
)
)
.scalars()
.all()
}
for event in events:
account = accounts[event.account_id]
event.instrument_id = instrument_id
event.status = status_for(account, pending.source)
meta = dict(event.meta or {})
meta.pop(PENDING_KEY, None)
meta["resolved_pending_key"] = pending.source_key
event.meta = meta
await session.flush()
return len(events)
__all__ = [
"MAX_UPLOAD_BYTES",
"CommitOutcome",
"ImportProblem",
"InstrumentSpec",
"Preview",
"Reconciliation",
"ResolveOutcome",
"UploadOutcome",
"account_suggestions",
"build_preview",
"commit",
"reconcile",
"reconcile_reports",
"reparse",
"resolve_pending",
"upload",
]
+12
View File
@@ -50,6 +50,13 @@ from fintracker.models.pricing import (
PriceManual,
RawCbrRate,
)
from fintracker.models.reports import (
PendingInstrument,
PendingInstrumentStatus,
RawReportFile,
RawReportLine,
ReportParseStatus,
)
from fintracker.models.sync import (
JobStatus,
RunStatus,
@@ -121,6 +128,8 @@ __all__ = [
"MetricReturns",
"MetricRunway",
"MetricSpendingByCategory",
"PendingInstrument",
"PendingInstrumentStatus",
"Portfolio",
"PortfolioAccount",
"PositionSnapshot",
@@ -129,6 +138,8 @@ __all__ = [
"PriceLast",
"PriceManual",
"RawCbrRate",
"RawReportFile",
"RawReportLine",
"RawTinvestEvent",
"RawTinvestInstrument",
"RawTinvestOperation",
@@ -136,6 +147,7 @@ __all__ = [
"RawZenmoneyDeletion",
"RawZenmoneyEntity",
"RefreshToken",
"ReportParseStatus",
"Rule",
"RuleKind",
"RuleMatchType",
@@ -45,6 +45,9 @@ class EventSource(enum.StrEnum):
tinvest_api = "tinvest_api"
report_sber = "report_sber"
report_vtb = "report_vtb"
csv = "csv"
"""A universal CSV export (Snowball and the like). Rarely primary: such an export spans
every account at once, so it usually lands as `shadow` next to the account's own feed."""
manual = "manual"
+139
View File
@@ -0,0 +1,139 @@
"""Uploaded broker reports: the raw tier and what they could not resolve (plan §1.1, §1.3).
An import is two-phase on purpose. `raw_report_file` records the bytes' sha256 the moment
they arrive, so re-uploading the same file is a no-op rather than a doubled ledger — the
unique index is the whole mechanism, and it works even when the file is renamed (VTB names
its exports by GUID). `raw_report_line` keeps every parsed row as JSONB before normalisation,
which is what makes a mapping bug diagnosable months later: the ledger can be rebuilt from
these rows without asking the user for the file again.
`pending_instrument` exists because guessing is worse than waiting. A report prints
«Первая-ВечныйПортф БПИФ / STME» and nothing else; matching that to an instrument by name
similarity would silently attach trades to the wrong paper, and a wrong lot is far more
expensive to notice than an unresolved one. The row parks the unknown, the events that
reference it stay `pending`, and the user confirms the instrument in the UI.
"""
from __future__ import annotations
import enum
from datetime import date, datetime
from decimal import Decimal
from typing import Any
from sqlalchemy import BigInteger, ForeignKey, Index, Integer, String, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from fintracker.db.base import Base, TimestampMixin, db_enum
class ReportParseStatus(enum.StrEnum):
uploaded = "uploaded"
"""Bytes stored, not parsed yet."""
parsed = "parsed"
"""Parsed into `raw_report_line`; the preview is available, nothing is in the ledger."""
committed = "committed"
"""Events written. A committed file is never re-parsed on re-upload."""
failed = "failed"
"""The parser raised; `error` says what. Kept so a format regression leaves a trace."""
class RawReportFile(TimestampMixin, Base):
"""One uploaded file. `sha256` is the idempotency key for the whole import flow."""
__tablename__ = "raw_report_file"
__table_args__ = (
Index("uq_raw_report_file_sha256", "sha256", unique=True),
Index("ix_raw_report_file_account", "account_id"),
)
id: Mapped[int] = mapped_column(primary_key=True)
broker: Mapped[str] = mapped_column(String(32))
"""sber | vtb | csv — as the parser reported it, not as the user guessed."""
filename: Mapped[str] = mapped_column(String(512))
sha256: Mapped[str] = mapped_column(String(64))
size_bytes: Mapped[int] = mapped_column(Integer)
content: Mapped[bytes | None] = mapped_column()
"""The original bytes. Personal reports are small (tens of KB) and keeping them turns a
parser fix into a re-parse instead of a request to the user for a file they deleted."""
account_id: Mapped[int | None] = mapped_column(ForeignKey("account.id", ondelete="SET NULL"))
"""Resolved at preview from `account_external_id`; NULL until the user picks an account."""
account_external_id: Mapped[str | None] = mapped_column(String(128))
period_from: Mapped[date | None]
period_to: Mapped[date | None]
parser_name: Mapped[str | None] = mapped_column(String(32))
parser_version: Mapped[str | None] = mapped_column(String(32))
parse_status: Mapped[ReportParseStatus] = mapped_column(
db_enum(ReportParseStatus, "report_parse_status"), default=ReportParseStatus.uploaded
)
error: Mapped[str | None] = mapped_column(Text)
warnings: Mapped[list[Any] | None]
counts: Mapped[dict[str, Any] | None]
"""Preview counters kept after commit: events by kind, new/duplicate/pending totals."""
uploaded_at: Mapped[datetime | None]
committed_at: Mapped[datetime | None]
class RawReportLine(Base):
"""A parsed but not yet normalised row, append-only, addressable as (file, line)."""
__tablename__ = "raw_report_line"
file_id: Mapped[int] = mapped_column(
ForeignKey("raw_report_file.id", ondelete="CASCADE"), primary_key=True
)
line_no: Mapped[int] = mapped_column(Integer, primary_key=True)
section: Mapped[str | None] = mapped_column(String(64))
"""Which part of the report the row came from — 'trades', 'cash', 'positions'."""
payload: Mapped[dict[str, Any]]
dedupe_key: Mapped[str | None] = mapped_column(String(256), index=True)
"""The key the event will carry; kept here so the preview can spot duplicates before
anything is written, and so a committed line can be traced back from an event."""
event_id: Mapped[int | None] = mapped_column(ForeignKey("event.id", ondelete="SET NULL"))
class PendingInstrumentStatus(enum.StrEnum):
pending = "pending"
resolved = "resolved"
ignored = "ignored"
"""The user decided this key is not a tradable instrument (a fee line misread as one)."""
class PendingInstrument(TimestampMixin, Base):
"""An instrument a report named that nothing in the master resolves to.
Unique on (source, source_key) so the same unknown paper appearing in ten files is one
row with a count, not ten identical questions to the user.
"""
__tablename__ = "pending_instrument"
__table_args__ = (UniqueConstraint("source", "source_key"),)
id: Mapped[int] = mapped_column(primary_key=True)
source: Mapped[str] = mapped_column(String(32))
"""report_sber | report_vtb | csv"""
source_key: Mapped[str] = mapped_column(String(256))
"""`InstrumentRef.key()` — 'ISIN:RU000A101EJ5', 'TICKER:STME', 'NAME:…'."""
isin: Mapped[str | None] = mapped_column(String(12))
ticker: Mapped[str | None] = mapped_column(String(32))
board: Mapped[str | None] = mapped_column(String(16))
name: Mapped[str | None] = mapped_column(String(256))
currency: Mapped[str | None] = mapped_column(String(3))
asset_class_hint: Mapped[str | None] = mapped_column(String(16))
"""What the report's wording suggested. A hint for the UI, never applied on its own."""
first_seen_file_id: Mapped[int | None] = mapped_column(
ForeignKey("raw_report_file.id", ondelete="SET NULL")
)
occurrences: Mapped[int] = mapped_column(BigInteger, default=1)
sample_quantity: Mapped[Decimal | None]
sample_price: Mapped[Decimal | None]
status: Mapped[PendingInstrumentStatus] = mapped_column(
db_enum(PendingInstrumentStatus, "pending_instrument_status"),
default=PendingInstrumentStatus.pending,
)
instrument_id: Mapped[int | None] = mapped_column(
ForeignKey("instrument.id", ondelete="SET NULL")
)
"""Set when resolved; the events waiting on this key are then bound and confirmed."""
resolved_at: Mapped[datetime | None]
meta: Mapped[dict[str, Any] | None]