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:
@@ -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
|
||||
Reference in New Issue
Block a user