fix(tinvest): не принимать отменённую заявку за сделку
GetOperationsByCursor отдаёт отменённые заявки наравне с исполненными, а отменённая — это не сделка, которая сорвалась, а сделка, которой не было. Приходит она с quantity_done = 0, количеством, которое заявка только ПРОСИЛА, в quantity, и без платежа вообще, — а _operation при нулевом quantity_done откатывался на quantity. Покупка читалась как бесплатное приобретение: позиция росла, деньги не убывали. Это и есть четыре из пяти известных расхождений derived с брокерским снапшотом, а не корпоративные действия, как записано в AGENTS.md: SIBN6P4 +5, HEAD +2, FIVE +2, MGNT −1. В сырых операциях ровно десять строк с state = OPERATION_STATE_CANCELED, и разбивка по бумагам сходится с расхождением ровно. Пятое, FIVE→X5, действительно редомициляция: зачисления X5 нет ни в одной операции ленты, лот невыводим из данных. Фильтрует только event. Сырые остаются как есть — это журнал того, что отдал источник, и их отсутствие было бы собственной загадкой. Отсутствие state вообще считается исполнением: старый payload без поля не должен молча превращаться в отмену. Перечитывание окна теперь ещё и удаляет отменённую заявку, импортированную до этой проверки, — иначе плохая строка осталась бы в леджере навсегда.
This commit is contained in:
@@ -77,6 +77,10 @@ class Operation:
|
||||
account_id: str
|
||||
operation_type: str
|
||||
"""The enum's NAME, e.g. OPERATION_TYPE_BUY — `mapper.py` keys on this."""
|
||||
state: str
|
||||
"""The enum's NAME, e.g. OPERATION_STATE_EXECUTED. Only executed ones reach the ledger:
|
||||
a cancelled order still arrives in the feed, with the quantity it ASKED for and no
|
||||
payment, and taking it for a trade inflates the position (see `sync._is_executed`)."""
|
||||
ts: datetime
|
||||
instrument_uid: str | None
|
||||
position_uid: str | None
|
||||
@@ -468,6 +472,7 @@ def _operation(account_id: str, item: OperationItem) -> Operation:
|
||||
id=item.id,
|
||||
account_id=account_id,
|
||||
operation_type=item.type.name,
|
||||
state=getattr(getattr(item, "state", None), "name", "") or "",
|
||||
ts=item.date,
|
||||
instrument_uid=item.instrument_uid or None,
|
||||
position_uid=getattr(item, "position_uid", None) or None,
|
||||
|
||||
@@ -29,7 +29,7 @@ from decimal import Decimal
|
||||
from typing import Any
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import delete, select
|
||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
@@ -491,6 +491,21 @@ async def _store_raw_instruments(
|
||||
await session.execute(stmt)
|
||||
|
||||
|
||||
def _is_executed(op: Operation) -> bool:
|
||||
"""Whether this operation actually happened, i.e. whether the ledger may believe it.
|
||||
|
||||
`GetOperationsByCursor` returns cancelled orders too, and a cancelled one is not a trade
|
||||
that fell through — it is a trade that never was. It arrives with `quantity_done = 0`,
|
||||
the quantity the order ASKED for in `quantity`, and no payment at all, so a buy reads as
|
||||
a free acquisition: the position grows and the cash does not shrink. Five of the papers
|
||||
whose derived quantity disagreed with the broker's snapshot were exactly this.
|
||||
|
||||
The raw table keeps them — it is the audit trail, and their absence would be its own
|
||||
puzzle later. Only `event` filters, because only `event` is read as truth.
|
||||
"""
|
||||
return op.state in ("", "OPERATION_STATE_EXECUTED")
|
||||
|
||||
|
||||
async def _write_events(
|
||||
session: AsyncSession,
|
||||
account_id: int,
|
||||
@@ -502,7 +517,15 @@ async def _write_events(
|
||||
warnings: list[str] = []
|
||||
unknown_types: set[str] = set()
|
||||
|
||||
stale = [f"{SOURCE}:{op.account_id}:{op.id}" for op in operations if not _is_executed(op)]
|
||||
if stale:
|
||||
# A cancelled order may already sit in `event` from an import made before this filter
|
||||
# existed, and re-reading it would otherwise leave the bad row there forever.
|
||||
await session.execute(delete(Event).where(Event.dedupe_key.in_(stale)))
|
||||
|
||||
for op in operations:
|
||||
if not _is_executed(op):
|
||||
continue
|
||||
try:
|
||||
kind = kind_for(op.operation_type, op.payment)
|
||||
except UnknownOperationType:
|
||||
|
||||
Reference in New Issue
Block a user