feat(tinvest): источник T-Invest — операции, снапшоты и справочник
SDK t_tech.invest импортируется ровно в client.py: остальной код видит обычные Decimal и dataclass'ы. Операции читаются по курсору GetOperationsByCursor, снапшоты GetPortfolio/GetPositions складываются в position_snapshot и cash_snapshot — только для сверки, аналитика их не читает. mapper.py — явный словарь OperationType -> EventKind на все значения enum SDK, покрытый тестом: новый тип должен ломать тест, а не молча уезжать в other. Покупка с привязанной карты помечается meta.card_funded: деньги пришли снаружи, баланс счёта их не видел, и для доходности это внешний поток, а не внутреннее движение.
This commit is contained in:
@@ -0,0 +1,128 @@
|
||||
"""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
|
||||
|
||||
|
||||
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"
|
||||
Reference in New Issue
Block a user