Files
fin-tracker/backend/tests/sources/test_tinvest_client.py
T
Dmitry 88979869f7 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 без поля не должен молча превращаться в отмену.

Перечитывание окна теперь ещё и удаляет отменённую заявку, импортированную до этой
проверки, — иначе плохая строка осталась бы в леджере навсегда.
2026-09-18 15:07:41 +03:00

150 lines
4.4 KiB
Python

"""Flattening of SDK records — the places where real data bit us.
These use light stand-ins rather than SDK objects: the point is the flattening rules, and
the suite must stay runnable without a token or a network.
"""
import enum
from dataclasses import dataclass
from datetime import UTC, datetime
from decimal import Decimal
from typing import Any, cast
from fintracker.sources.tinvest.client import _as_dict, _kind_of, _money, _operation
@dataclass
class Money:
currency: str
units: int
nano: int
class Kind(enum.Enum):
"""Stands in for the SDK's OperationType, which is a real Enum."""
OPERATION_TYPE_BUY = 15
@dataclass
class Item:
"""Only the fields `_operation` reads."""
id: str
type: Kind
date: datetime
instrument_uid: str
position_uid: str
figi: str
payment: Money
price: Money
commission: Money
accrued_int: Money
description: str
quantity: int
quantity_done: int
state: Any = None
def item(**over) -> Item:
base = {
"id": "42",
"type": Kind.OPERATION_TYPE_BUY,
"date": datetime(2026, 4, 16, 18, 11, tzinfo=UTC),
"instrument_uid": "uid-1",
"position_uid": "pos-1",
"figi": "TCS00A10B7T7",
"payment": Money("rub", -984, -780000000),
"price": Money("rub", 984, 780000000),
"commission": Money("rub", -2, -940000000),
"accrued_int": Money("", 0, 0),
"description": "Покупка 1 облигации",
"quantity": 12,
"quantity_done": 1,
}
return Item(**{**base, **over})
def test_money_is_exact_not_float():
"""units+nano must land as an exact Decimal — money never goes through float."""
assert _money(Money("rub", -984, -780000000)) == Decimal("-984.78")
assert _money(Money("rub", 0, 500000000)) == Decimal("0.5")
def op_of(**over):
"""`_operation` on the stand-in; the cast is for pyright, the fields are what matter."""
return _operation("acc", cast("Any", item(**over)))
def test_quantity_uses_what_executed_not_what_was_ordered():
"""A partially filled order reports quantity=12, quantity_done=1 — the position grew by 1."""
assert op_of().quantity == Decimal(1)
def test_quantity_falls_back_when_done_is_absent():
assert op_of(quantity_done=0, quantity=7).quantity == Decimal(7)
def test_operation_keeps_position_uid():
"""The identity that survives T-Invest handing one paper several instrument uids."""
assert op_of().position_uid == "pos-1"
def test_payload_is_queryable_json_not_a_repr_string():
"""The raw tier exists to be re-derived from; `repr()` prose would defeat that."""
payload = op_of().payload
assert payload["id"] == "42"
assert payload["type"] == "OPERATION_TYPE_BUY"
assert payload["quantity_done"] == 1
# MoneyValue collapses to an exact decimal string plus its currency
assert payload["payment"] == {"value": "-984.78", "currency": "rub"}
assert payload["date"] == "2026-04-16T18:11:00+00:00"
def test_as_dict_expands_the_top_level_record_by_field():
"""The record itself becomes a dict of its fields; money nested inside it collapses."""
assert _as_dict(Money("rub", 1, 500000000)) == {
"currency": "rub",
"units": 1,
"nano": 500000000,
}
def test_kind_prefers_what_the_api_states():
"""Guessing from the record's shape filed the rouble position as a share."""
@dataclass
class Rouble:
instrument_type: str
maturity_date: None = None
assert _kind_of(Rouble("currency")) == "currency"
def test_kind_falls_back_to_shape_when_unstated():
@dataclass
class Bond:
maturity_date: datetime
assert _kind_of(Bond(datetime(2030, 1, 1, tzinfo=UTC))) == "bond"
class State(enum.Enum):
"""Stands in for the SDK's OperationState."""
OPERATION_STATE_EXECUTED = 1
OPERATION_STATE_CANCELED = 2
def test_state_is_carried_so_the_ledger_can_refuse_a_cancelled_order():
"""A cancelled order arrives looking like a free acquisition — see `sync._is_executed`."""
cancelled = op_of(state=State.OPERATION_STATE_CANCELED, quantity_done=0, quantity=2)
assert cancelled.state == "OPERATION_STATE_CANCELED"
# quantity still reads as what the order ASKED for, which is exactly the trap
assert cancelled.quantity == Decimal(2)
def test_state_is_empty_when_the_record_does_not_state_one():
"""An older payload without the field must not be mistaken for a cancelled order."""
assert op_of().state == ""