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:
@@ -0,0 +1,109 @@
|
||||
"""Cancelled orders must never reach `event` (plan §1.4).
|
||||
|
||||
`GetOperationsByCursor` returns cancelled orders alongside executed ones. A cancelled one
|
||||
carries `quantity_done = 0`, the quantity the order only ASKED for in `quantity`, and no
|
||||
payment — so taken at face value it reads as a free acquisition, and the derived position
|
||||
drifts above the broker's snapshot. The raw tier keeps them; only `event` filters.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from factories import make_account
|
||||
from fintracker.db import get_sessionmaker
|
||||
from fintracker.models import AccountKind, AccountRole, Event, EventKind
|
||||
from fintracker.sources.tinvest.client import Operation
|
||||
from fintracker.sources.tinvest.sync import _is_executed, _write_events
|
||||
|
||||
D = Decimal
|
||||
|
||||
|
||||
def op(op_id: str, state: str, *, quantity: str = "2") -> Operation:
|
||||
return Operation(
|
||||
id=op_id,
|
||||
account_id="tinv-1",
|
||||
operation_type="OPERATION_TYPE_BUY",
|
||||
state=state,
|
||||
ts=datetime(2026, 4, 2, 10, 0, tzinfo=UTC),
|
||||
instrument_uid=None,
|
||||
position_uid=None,
|
||||
figi=None,
|
||||
quantity=D(quantity),
|
||||
price=D("11325"),
|
||||
price_currency="RUB",
|
||||
payment=D("-22650.95"),
|
||||
payment_currency="RUB",
|
||||
commission=None,
|
||||
accrued_int=None,
|
||||
description="Покупка",
|
||||
payload={},
|
||||
)
|
||||
|
||||
|
||||
def test_is_executed_reads_the_state_not_the_quantity():
|
||||
assert _is_executed(op("1", "OPERATION_STATE_EXECUTED"))
|
||||
assert not _is_executed(op("2", "OPERATION_STATE_CANCELED"))
|
||||
# an older payload that states no state at all is trusted, not silently dropped
|
||||
assert _is_executed(op("3", ""))
|
||||
|
||||
|
||||
async def broker_account() -> int:
|
||||
return await make_account(
|
||||
name="Брокерский",
|
||||
kind=AccountKind.broker,
|
||||
role=AccountRole.investment,
|
||||
balance=None,
|
||||
include_in_net_worth=False,
|
||||
source="tinvest",
|
||||
source_id="tinv-1",
|
||||
)
|
||||
|
||||
|
||||
async def dedupe_keys() -> list[str]:
|
||||
async with get_sessionmaker()() as session:
|
||||
return list((await session.execute(select(Event.dedupe_key))).scalars().all())
|
||||
|
||||
|
||||
async def write(account_id: int, *operations: Operation) -> int:
|
||||
async with get_sessionmaker()() as session:
|
||||
written, _ = await _write_events(session, account_id, list(operations), {})
|
||||
await session.commit()
|
||||
return written
|
||||
|
||||
|
||||
async def test_a_cancelled_order_never_becomes_an_event(app):
|
||||
account = await broker_account()
|
||||
written = await write(
|
||||
account,
|
||||
op("exec-1", "OPERATION_STATE_EXECUTED", quantity="1"),
|
||||
op("cancel-1", "OPERATION_STATE_CANCELED"),
|
||||
)
|
||||
assert written == 1
|
||||
assert await dedupe_keys() == ["tinvest:tinv-1:exec-1"]
|
||||
|
||||
|
||||
async def test_re_reading_removes_a_cancelled_order_imported_before_this_filter(app):
|
||||
account = await broker_account()
|
||||
async with get_sessionmaker()() as session:
|
||||
session.add(
|
||||
Event(
|
||||
account_id=account,
|
||||
kind=EventKind.buy,
|
||||
ts=datetime(2026, 4, 2, 10, 0, tzinfo=UTC),
|
||||
trade_date=datetime(2026, 4, 2, tzinfo=UTC).date(),
|
||||
quantity=D(2),
|
||||
amount=D("22650.95"),
|
||||
currency="RUB",
|
||||
source="tinvest",
|
||||
source_id="cancel-1",
|
||||
dedupe_key="tinvest:tinv-1:cancel-1",
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
await write(account, op("cancel-1", "OPERATION_STATE_CANCELED"))
|
||||
assert await dedupe_keys() == []
|
||||
Reference in New Issue
Block a user