Отменённые и незавершённые операции удаляются из event по raw_tinvest_operation, а не только из текущего окна синка. Бумага, которую MOEX не котирует (структурные ноты, внебиржевые облигации), получает цену из портфеля брокера. Логотип и цвет бренда копируются в instrument (миграция e8b21f6a90c3).
171 lines
5.7 KiB
Python
171 lines
5.7 KiB
Python
"""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() == []
|
|
|
|
|
|
async def _raw(state: object, op_id: str) -> None:
|
|
from fintracker.models import RawTinvestOperation
|
|
|
|
async with get_sessionmaker()() as session:
|
|
session.add(
|
|
RawTinvestOperation(
|
|
account_id="tinv-1",
|
|
id=op_id,
|
|
operation_type="OPERATION_TYPE_BUY",
|
|
ts=datetime(2026, 4, 2, 10, 0, tzinfo=UTC),
|
|
payload={"state": state},
|
|
)
|
|
)
|
|
await session.commit()
|
|
|
|
|
|
async def _event(account: int, op_id: str) -> None:
|
|
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=op_id,
|
|
dedupe_key=f"tinvest:tinv-1:{op_id}",
|
|
)
|
|
)
|
|
await session.commit()
|
|
|
|
|
|
async def test_a_cancelled_order_that_is_no_longer_re_read_is_still_removed(app):
|
|
"""The sync re-reads only a recent window, so an old cancelled order never comes back in
|
|
the batch. It is found through the raw table instead."""
|
|
account = await broker_account()
|
|
await _raw(2, "old-cancel") # the payload keeps the enum as its number
|
|
await _raw("OPERATION_STATE_PROGRESS", "old-progress")
|
|
await _raw(1, "old-exec")
|
|
for op_id in ("old-cancel", "old-progress", "old-exec"):
|
|
await _event(account, op_id)
|
|
|
|
await write(account, op("fresh-1", "OPERATION_STATE_EXECUTED", quantity="1"))
|
|
|
|
assert sorted(await dedupe_keys()) == ["tinvest:tinv-1:fresh-1", "tinvest:tinv-1:old-exec"]
|
|
|
|
|
|
async def test_the_purge_is_a_no_op_on_a_clean_ledger(app):
|
|
account = await broker_account()
|
|
await _raw(1, "old-exec")
|
|
await _event(account, "old-exec")
|
|
|
|
await write(account, op("fresh-1", "OPERATION_STATE_EXECUTED", quantity="1"))
|
|
await write(account, op("fresh-1", "OPERATION_STATE_EXECUTED", quantity="1"))
|
|
|
|
assert sorted(await dedupe_keys()) == ["tinvest:tinv-1:fresh-1", "tinvest:tinv-1:old-exec"]
|