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:
Dmitry
2026-09-18 13:44:31 +03:00
parent 012a40981f
commit 1adb1c16df
6 changed files with 1370 additions and 0 deletions
@@ -0,0 +1,8 @@
"""The `tinvest` source: operations, snapshots and the instrument master from T-Invest."""
from fintracker.sources.registry import register
from fintracker.sources.tinvest.sync import TinvestSource
register(TinvestSource())
__all__ = ["TinvestSource"]
@@ -0,0 +1,457 @@
"""The only module allowed to import `t_tech.invest` (AGENTS.md).
Everything below returns plain dataclasses and `Decimal`s, so the rest of the codebase —
sync, mapper, tests — never depends on gRPC or on protobuf types. That also keeps the test
suite runnable without a token.
Rate limits (plan §T-Invest): Operations 200/min, Instruments 200/min, MarketData 600/min,
<= 50 rps per IP. The API answers a breach with RESOURCE_EXHAUSTED and an `x-ratelimit-reset`
header saying how many seconds to wait; `_call` honours exactly that instead of guessing a
backoff, and retries a bounded number of times.
Money arrives as `units` + `nano` (1e-9). `_money` converts it to an exact `Decimal` —
never a float, per the project-wide rule on money.
"""
from __future__ import annotations
import asyncio
import logging
from collections.abc import AsyncIterator, Callable, Coroutine
from dataclasses import dataclass, field, is_dataclass
from datetime import UTC, date, datetime
from decimal import Decimal
from enum import Enum
from typing import Any, TypeVar, cast
import sentry_sdk
from google.protobuf.json_format import MessageToDict
from grpc import StatusCode
from t_tech.invest import (
AsyncClient,
GetOperationsByCursorRequest,
InstrumentStatus,
OperationItem,
Quotation,
)
from t_tech.invest.exceptions import AioRequestError
log = logging.getLogger(__name__)
NANO = Decimal(10) ** 9
MAX_RETRIES = 5
DEFAULT_RESET_SECONDS = 5.0
PAGE_LIMIT = 1000
T = TypeVar("T")
def _money(value: Any) -> Decimal | None:
"""`units`+`nano` (MoneyValue or Quotation) as an exact Decimal."""
if value is None:
return None
units = getattr(value, "units", None)
nano = getattr(value, "nano", None)
if units is None and nano is None:
return None
return Decimal(units or 0) + Decimal(nano or 0) / NANO
def _currency(value: Any) -> str | None:
code = getattr(value, "currency", None)
return code.upper() if code else None
@dataclass(frozen=True)
class Operation:
"""One T-Invest operation, flattened to what the ledger needs."""
id: str
account_id: str
operation_type: str
"""The enum's NAME, e.g. OPERATION_TYPE_BUY — `mapper.py` keys on this."""
ts: datetime
instrument_uid: str | None
position_uid: str | None
"""Stable across the uid variants T-Invest uses for the same paper — see `sync.py`."""
figi: str | None
quantity: Decimal | None
price: Decimal | None
price_currency: str | None
payment: Decimal | None
"""Signed cash effect, as the broker reports it."""
payment_currency: str | None
commission: Decimal | None
accrued_int: Decimal | None
description: str | None
payload: dict[str, Any]
@dataclass(frozen=True)
class PositionLine:
instrument_uid: str
figi: str | None
quantity: Decimal
average_price: Decimal | None
current_price: Decimal | None
currency: str | None
instrument_type: str | None
@dataclass(frozen=True)
class CashLine:
currency: str
balance: Decimal
blocked: Decimal | None
@dataclass(frozen=True)
class PortfolioSnapshot:
account_id: str
captured_at: datetime
positions: list[PositionLine]
cash: list[CashLine]
payload: dict[str, Any] = field(default_factory=dict)
@dataclass(frozen=True)
class AccountInfo:
id: str
name: str
type: str
status: str
opened_date: datetime | None
@dataclass(frozen=True)
class InstrumentInfo:
uid: str
kind: str
"""share | bond | etf | currency"""
isin: str | None
figi: str | None
ticker: str | None
class_code: str | None
name: str
currency: str
lot: int
nominal: Decimal | None
nominal_currency: str | None
maturity_date: datetime | None
sector: str | None
country: str | None
exchange: str | None
payload: dict[str, Any]
def _as_dict(message: Any) -> dict[str, Any]:
"""A JSON-able dict for the `raw_*` tables.
The SDK hands back its own dataclasses, not protobuf messages, so `MessageToDict` is
only the fallback path. Storing a `repr()` string instead would defeat the point of the
raw tier: a mapping bug has to be fixable by re-deriving events from stored data, which
needs queryable JSON, not prose.
"""
if is_dataclass(message) and not isinstance(message, type):
return {k: _jsonable(v) for k, v in vars(message).items()}
try:
return MessageToDict(cast("Any", message), preserving_proto_field_name=True)
except Exception:
return {"repr": repr(message)}
def _jsonable(value: Any) -> Any:
"""Recursively turn SDK values into JSON primitives, keeping money exact as strings."""
if value is None or isinstance(value, (bool, int, str)):
return value
if isinstance(value, float):
return str(value)
if isinstance(value, Decimal):
return format(value, "f")
if isinstance(value, (datetime, date)):
return value.isoformat()
if isinstance(value, Enum):
return value.name
if isinstance(value, (list, tuple)):
return [_jsonable(v) for v in value]
if isinstance(value, dict):
return {str(k): _jsonable(v) for k, v in value.items()}
if is_dataclass(value) and not isinstance(value, type):
# MoneyValue/Quotation collapse to an exact decimal string; everything else recurses
money = _money(value)
if money is not None and set(vars(value)) <= {"currency", "units", "nano"}:
currency = getattr(value, "currency", None)
return (
{"value": format(money, "f"), "currency": currency}
if currency
else format(money, "f")
)
return {k: _jsonable(v) for k, v in vars(value).items()}
return str(value)
class TinvestClient:
"""Thin async wrapper: rate-limit aware, returns plain data."""
def __init__(self, token: str, *, app_name: str = "fin-tracker") -> None:
self._token = token
self._app_name = app_name
self._client: Any = None
self._ctx: Any = None
async def __aenter__(self) -> TinvestClient:
self._ctx = AsyncClient(self._token, app_name=self._app_name)
self._client = await self._ctx.__aenter__()
_silence_sdk_telemetry()
return self
async def __aexit__(self, *exc: object) -> None:
if self._ctx is not None:
await self._ctx.__aexit__(*exc)
self._ctx = self._client = None
async def _call(self, fn: Callable[[], Coroutine[Any, Any, T]]) -> T:
"""Run one RPC, waiting out RESOURCE_EXHAUSTED for as long as the API asks."""
for attempt in range(1, MAX_RETRIES + 1):
try:
return await fn()
except AioRequestError as err:
if err.code is not StatusCode.RESOURCE_EXHAUSTED or attempt == MAX_RETRIES:
raise
delay = _reset_seconds(err)
log.warning(
"tinvest: rate limited, waiting %.1fs (attempt %d/%d)",
delay,
attempt,
MAX_RETRIES,
)
await asyncio.sleep(delay)
raise AssertionError("unreachable") # pragma: no cover
async def accounts(self) -> list[AccountInfo]:
resp = await self._call(lambda: self._client.users.get_accounts())
return [
AccountInfo(
id=a.id,
name=a.name,
type=a.type.name,
status=a.status.name,
opened_date=a.opened_date,
)
for a in resp.accounts
]
async def operations(
self, account_id: str, *, since: datetime, until: datetime | None = None
) -> AsyncIterator[Operation]:
"""Every operation in [since, until], following the cursor to the end."""
cursor = ""
to = until or datetime.now(UTC)
while True:
resp = await self._call(
lambda c=cursor: self._client.operations.get_operations_by_cursor(
GetOperationsByCursorRequest(
account_id=account_id, from_=since, to=to, cursor=c, limit=PAGE_LIMIT
)
)
)
for item in resp.items:
yield _operation(account_id, item)
if not resp.has_next or not resp.next_cursor:
return
cursor = resp.next_cursor
async def portfolio(self, account_id: str) -> PortfolioSnapshot | None:
"""GetPortfolio + GetPositions: the broker's own view, used only for reconciliation.
Returns None when the account has no portfolio view at all — an Инвесткопилка or a
DFA account answers NOT_FOUND here. That costs only the reconciliation check for
that account; its operations are already in the ledger.
"""
try:
portfolio = await self._call(
lambda: self._client.operations.get_portfolio(account_id=account_id)
)
positions = await self._call(
lambda: self._client.operations.get_positions(account_id=account_id)
)
except AioRequestError as err:
if err.code is StatusCode.NOT_FOUND:
log.info("tinvest: account %s has no portfolio view, skipping snapshot", account_id)
return None
raise
lines = [
PositionLine(
instrument_uid=p.instrument_uid,
figi=p.figi or None,
quantity=_money(p.quantity) or Decimal(0),
average_price=_money(p.average_position_price),
current_price=_money(p.current_price),
currency=_currency(p.average_position_price) or _currency(p.current_price),
instrument_type=p.instrument_type or None,
)
for p in portfolio.positions
]
cash = [
CashLine(
currency=(m.currency or "").upper(),
balance=_money(m) or Decimal(0),
blocked=None,
)
for m in positions.money
]
blocked = {(m.currency or "").upper(): _money(m) for m in positions.blocked}
cash = [
CashLine(currency=c.currency, balance=c.balance, blocked=blocked.get(c.currency))
for c in cash
]
return PortfolioSnapshot(
account_id=account_id,
captured_at=datetime.now(UTC),
positions=lines,
cash=cash,
payload={"portfolio": _as_dict(portfolio), "positions": _as_dict(positions)},
)
async def instruments_by_uid(self, uids: set[str]) -> dict[str, InstrumentInfo]:
"""Resolve instruments one uid at a time (GetInstrumentBy), skipping what is gone.
Keyed by the uid we ASKED for: the API may answer with a different `uid` for the
same paper, and callers need to match on the id their own data carries.
"""
out: dict[str, InstrumentInfo] = {}
for uid in sorted(uids):
info = await self._instrument_by_uid(uid)
if info is not None:
out[uid] = info
return out
async def _instrument_by_uid(self, uid: str) -> InstrumentInfo | None:
from t_tech.invest import InstrumentIdType
try:
resp = await self._call(
lambda: self._client.instruments.get_instrument_by(
id_type=InstrumentIdType.INSTRUMENT_ID_TYPE_UID, id=uid
)
)
except AioRequestError as err:
if err.code is StatusCode.NOT_FOUND:
# delisted, or an instrument the token's account may no longer query: the
# operation keeps instrument_id = NULL instead of failing the whole sync.
log.info("tinvest: instrument %s not found", uid)
return None
raise
return _instrument(resp.instrument)
async def all_instruments(self, kind: str) -> list[InstrumentInfo]:
"""The full reference list for one asset kind: shares, bonds, etfs, currencies."""
method = {
"share": self._client.instruments.shares,
"bond": self._client.instruments.bonds,
"etf": self._client.instruments.etfs,
"currency": self._client.instruments.currencies,
}[kind]
resp = await self._call(
lambda: method(instrument_status=InstrumentStatus.INSTRUMENT_STATUS_ALL)
)
return [_instrument(i, kind=kind) for i in resp.instruments]
def _silence_sdk_telemetry() -> None:
"""Stop the SDK from reporting our errors to T-Bank's Sentry.
`t_tech.invest` calls `sentry_sdk.init(dsn=error-hub.tbank.ru)` from its client's
`__aenter__`, with no opt-out. Re-initialising with `dsn=None` right after replaces that
global client with one that sends nothing; their `capture_exception` then becomes a
no-op. This is a private finance tool — nothing about its failures should leave the host.
"""
sentry_sdk.init(dsn=None)
def _reset_seconds(err: AioRequestError) -> float:
"""How long the API told us to wait, defaulting to a small pause when it did not say."""
reset = getattr(err.metadata, "ratelimit_reset", None)
if reset is not None:
try:
return max(float(reset), 0.5)
except (TypeError, ValueError):
pass
return DEFAULT_RESET_SECONDS
def _operation(account_id: str, item: OperationItem) -> Operation:
price = _money(item.price)
payment = _money(item.payment)
# `quantity` is what the ORDER asked for; `quantity_done` is what actually executed. A
# partially filled order reports e.g. quantity=12, quantity_done=1 — taking the former
# would inflate the position by everything that never traded.
quantity = getattr(item, "quantity_done", None)
if quantity in (None, 0):
quantity = getattr(item, "quantity", None)
return Operation(
id=item.id,
account_id=account_id,
operation_type=item.type.name,
ts=item.date,
instrument_uid=item.instrument_uid or None,
position_uid=getattr(item, "position_uid", None) or None,
figi=item.figi or None,
quantity=Decimal(quantity) if quantity not in (None, 0) else None,
price=price,
price_currency=_currency(item.price),
payment=payment,
payment_currency=_currency(item.payment),
commission=_money(item.commission),
accrued_int=_money(getattr(item, "accrued_int", None)),
description=item.description or None,
payload=_as_dict(item),
)
def _instrument(raw: Any, *, kind: str | None = None) -> InstrumentInfo:
nominal = getattr(raw, "nominal", None)
return InstrumentInfo(
uid=raw.uid,
kind=kind or _kind_of(raw),
isin=getattr(raw, "isin", None) or None,
figi=getattr(raw, "figi", None) or None,
ticker=getattr(raw, "ticker", None) or None,
class_code=getattr(raw, "class_code", None) or None,
name=raw.name,
currency=(raw.currency or "").upper(),
lot=int(getattr(raw, "lot", 1) or 1),
nominal=_money(nominal),
nominal_currency=_currency(nominal),
maturity_date=getattr(raw, "maturity_date", None),
sector=getattr(raw, "sector", None) or None,
country=getattr(raw, "country_of_risk", None) or None,
exchange=getattr(raw, "exchange", None) or None,
payload=_as_dict(raw),
)
def _kind_of(raw: Any) -> str:
"""Asset kind, preferring what the API states over guessing from the record's shape.
`GetInstrumentBy` returns a generic record carrying `instrument_type` ("currency",
"share", "bond", "etf"); the per-kind listings (Shares/Bonds/…) do not, hence the
fallback. Guessing alone filed the rouble position as a share.
"""
stated = getattr(raw, "instrument_type", None)
if stated:
kind = str(stated).lower()
if kind in {"share", "bond", "etf", "currency"}:
return kind
if getattr(raw, "iso_currency_name", None):
return "currency"
if getattr(raw, "maturity_date", None):
return "bond"
if getattr(raw, "focus_type", None):
return "etf"
return "share"
def quotation_to_decimal(q: Quotation) -> Decimal | None:
"""Exposed for callers that get a bare Quotation (prices, coupon rates)."""
return _money(q)
@@ -0,0 +1,137 @@
"""`OperationType` -> `EventKind`, as an explicit table (plan §1.4).
Every one of the SDK's 67 operation types is listed here on purpose. `test_mapper.py`
asserts the table covers the enum exactly, so when T-Bank adds a type the test fails
loudly instead of the new operation sliding into `other` and silently skewing a metric.
The table is deliberately free of SDK imports: it is keyed by the enum's *name*, so the
mapping (and its test) stay readable and importable without gRPC. `client.py` is the only
module that touches `t_tech.invest`.
Three judgement calls worth stating:
* **Margin and delivery trades are ordinary trades.** BUY_MARGIN/SELL_MARGIN and
DELIVERY_BUY/DELIVERY_SELL move a position exactly like BUY/SELL; the margin *cost*
arrives separately as MARGIN_FEE.
* **Every tax variant maps to `tax`, every refund to `tax_refund`.** T-Bank splits tax by
instrument class and by progressive scale (13/15 %); for a ledger they are one thing. The
original type stays in `event.meta` for the tax screen of phase 4.
* **Securities transferred in or out are `transfer_in`/`transfer_out`, not buys.** They
change the position without cash, and they are external flows for XIRR.
"""
from __future__ import annotations
from fintracker.models.ledger import EventKind
#: T-Invest operation type name -> our ledger kind.
OPERATION_KINDS: dict[str, EventKind] = {
# --- trades -------------------------------------------------------------------------
"OPERATION_TYPE_BUY": EventKind.buy,
"OPERATION_TYPE_BUY_CARD": EventKind.buy,
"OPERATION_TYPE_BUY_MARGIN": EventKind.buy,
"OPERATION_TYPE_DELIVERY_BUY": EventKind.buy,
"OPERATION_TYPE_SELL": EventKind.sell,
"OPERATION_TYPE_SELL_CARD": EventKind.sell,
"OPERATION_TYPE_SELL_MARGIN": EventKind.sell,
"OPERATION_TYPE_DELIVERY_SELL": EventKind.sell,
"OPERATION_TYPE_PRIMARY_ORDER": EventKind.buy,
# --- cash in and out of the account (external flows for XIRR) -----------------------
"OPERATION_TYPE_INPUT": EventKind.deposit,
"OPERATION_TYPE_INPUT_SWIFT": EventKind.deposit,
"OPERATION_TYPE_INPUT_ACQUIRING": EventKind.deposit,
"OPERATION_TYPE_INP_MULTI": EventKind.deposit,
"OPERATION_TYPE_TRANS_IIS_BS": EventKind.deposit,
"OPERATION_TYPE_TRANS_BS_BS": EventKind.deposit,
"OPERATION_TYPE_OUTPUT": EventKind.withdrawal,
"OPERATION_TYPE_OUTPUT_SWIFT": EventKind.withdrawal,
"OPERATION_TYPE_OUTPUT_ACQUIRING": EventKind.withdrawal,
"OPERATION_TYPE_OUT_MULTI": EventKind.withdrawal,
"OPERATION_TYPE_OUTPUT_PENALTY": EventKind.withdrawal,
# --- securities moved in or out without cash ----------------------------------------
"OPERATION_TYPE_INPUT_SECURITIES": EventKind.transfer_in,
"OPERATION_TYPE_OUTPUT_SECURITIES": EventKind.transfer_out,
# --- income --------------------------------------------------------------------------
"OPERATION_TYPE_DIVIDEND": EventKind.dividend,
"OPERATION_TYPE_DIVIDEND_TRANSFER": EventKind.dividend,
"OPERATION_TYPE_DIV_EXT": EventKind.dividend,
"OPERATION_TYPE_COUPON": EventKind.coupon,
"OPERATION_TYPE_OVERNIGHT": EventKind.interest,
"OPERATION_TYPE_OVER_INCOME": EventKind.interest,
"OPERATION_TYPE_OVER_PLACEMENT": EventKind.interest,
"OPERATION_TYPE_ACCRUING_VARMARGIN": EventKind.interest,
# --- bond lifecycle ------------------------------------------------------------------
"OPERATION_TYPE_BOND_REPAYMENT": EventKind.amortization,
"OPERATION_TYPE_BOND_REPAYMENT_FULL": EventKind.repayment,
"OPERATION_TYPE_DFA_REDEMPTION": EventKind.repayment,
"OPERATION_TYPE_OPTION_EXPIRATION": EventKind.repayment,
"OPERATION_TYPE_FUTURE_EXPIRATION": EventKind.repayment,
# --- taxes ---------------------------------------------------------------------------
"OPERATION_TYPE_TAX": EventKind.tax,
"OPERATION_TYPE_TAX_PROGRESSIVE": EventKind.tax,
"OPERATION_TYPE_BOND_TAX": EventKind.tax,
"OPERATION_TYPE_BOND_TAX_PROGRESSIVE": EventKind.tax,
"OPERATION_TYPE_DIVIDEND_TAX": EventKind.tax,
"OPERATION_TYPE_DIVIDEND_TAX_PROGRESSIVE": EventKind.tax,
"OPERATION_TYPE_BENEFIT_TAX": EventKind.tax,
"OPERATION_TYPE_BENEFIT_TAX_PROGRESSIVE": EventKind.tax,
"OPERATION_TYPE_TAX_REPO": EventKind.tax,
"OPERATION_TYPE_TAX_REPO_PROGRESSIVE": EventKind.tax,
"OPERATION_TYPE_TAX_REPO_HOLD": EventKind.tax,
"OPERATION_TYPE_TAX_REPO_HOLD_PROGRESSIVE": EventKind.tax,
"OPERATION_TYPE_OUT_STAMP_DUTY": EventKind.tax,
# A correction can be a charge or a refund; the sign of `amount` decides, see
# `kind_for` — the table holds the charge case.
"OPERATION_TYPE_TAX_CORRECTION": EventKind.tax,
"OPERATION_TYPE_TAX_CORRECTION_PROGRESSIVE": EventKind.tax,
"OPERATION_TYPE_TAX_CORRECTION_COUPON": EventKind.tax,
"OPERATION_TYPE_TAX_REPO_REFUND": EventKind.tax_refund,
"OPERATION_TYPE_TAX_REPO_REFUND_PROGRESSIVE": EventKind.tax_refund,
# --- fees ------------------------------------------------------------------------------
"OPERATION_TYPE_BROKER_FEE": EventKind.commission,
"OPERATION_TYPE_SERVICE_FEE": EventKind.commission,
"OPERATION_TYPE_MARGIN_FEE": EventKind.commission,
"OPERATION_TYPE_SUCCESS_FEE": EventKind.commission,
"OPERATION_TYPE_TRACK_MFEE": EventKind.commission,
"OPERATION_TYPE_TRACK_PFEE": EventKind.commission,
"OPERATION_TYPE_CASH_FEE": EventKind.commission,
"OPERATION_TYPE_OUT_FEE": EventKind.commission,
"OPERATION_TYPE_ADVICE_FEE": EventKind.commission,
"OPERATION_TYPE_OTHER_FEE": EventKind.commission,
"OPERATION_TYPE_OVER_COM": EventKind.commission,
"OPERATION_TYPE_WRITING_OFF_VARMARGIN": EventKind.commission,
# --- genuinely unknown ------------------------------------------------------------------
"OPERATION_TYPE_OTHER": EventKind.other,
"OPERATION_TYPE_UNSPECIFIED": EventKind.other,
}
#: Types whose cash arrives from outside the brokerage account (a linked card), so the
#: purchase is also an external flow for return calculations.
CARD_FUNDED = frozenset({"OPERATION_TYPE_BUY_CARD", "OPERATION_TYPE_SELL_CARD"})
#: Corrections that can go either way; the sign of the amount tells charge from refund.
SIGN_DEPENDENT = frozenset(
{
"OPERATION_TYPE_TAX_CORRECTION",
"OPERATION_TYPE_TAX_CORRECTION_PROGRESSIVE",
"OPERATION_TYPE_TAX_CORRECTION_COUPON",
}
)
class UnknownOperationType(LookupError):
"""Raised for a type the table does not list — a new SDK value, not a data problem."""
def kind_for(operation_type: str, amount: object = None) -> EventKind:
"""Ledger kind for an operation type; `amount` only matters for tax corrections.
A positive tax correction is money coming back, which is a refund, not a tax.
"""
try:
kind = OPERATION_KINDS[operation_type]
except KeyError as exc:
raise UnknownOperationType(operation_type) from exc
if operation_type in SIGN_DEPENDENT and amount is not None and amount > 0: # type: ignore[operator]
return EventKind.tax_refund
return kind
@@ -0,0 +1,582 @@
"""The `tinvest` source: accounts, operations, instruments and reconciliation snapshots.
Flow of one run:
1. `GetAccounts` -> upsert `account` (kind=broker, broker=tinvest). Accounts are never
deleted here: a closed one is archived, so its history stays in the ledger.
2. Per account, operations since the cursor -> `raw_tinvest_operation` (idempotent on the
operation id), then mapped into `event`.
3. Instruments seen in those operations are resolved once and stored in `instrument`.
4. `GetPortfolio`/`GetPositions` -> snapshots, for the derived-vs-reported check.
**Cursor.** T-Invest's own cursor is a per-request token, not a durable watermark, so it
cannot be stored between runs. Instead the cursor is a JSON map `{account_id: iso_ts}` of
the newest operation seen per account, and the next run re-reads from `ts - OVERLAP` to
catch operations that settle late. Re-reading is free: the dedupe key makes it a no-op.
**Order matters.** Instruments are resolved before events are written, because `event`
references `instrument.id` — an operation on an instrument the API no longer serves keeps
`instrument_id = NULL` rather than blocking the whole sync.
"""
from __future__ import annotations
import json
import logging
from collections.abc import Iterable
from datetime import UTC, datetime, timedelta
from decimal import Decimal
from typing import Any
from zoneinfo import ZoneInfo
from sqlalchemy import select
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.ext.asyncio import AsyncSession
from fintracker.models import (
Account,
AccountKind,
AccountRole,
AssetClass,
Broker,
Event,
EventKind,
EventSource,
EventStatus,
Instrument,
InstrumentAlias,
PositionSnapshot,
RawTinvestInstrument,
RawTinvestOperation,
RawTinvestSnapshot,
)
from fintracker.models.pricing import CashSnapshot
from fintracker.sources.base import SyncContext, SyncResult
from fintracker.sources.tinvest.client import InstrumentInfo, Operation, TinvestClient
from fintracker.sources.tinvest.mapper import CARD_FUNDED, UnknownOperationType, kind_for
log = logging.getLogger(__name__)
SOURCE = "tinvest"
MSK = ZoneInfo("Europe/Moscow")
OVERLAP = timedelta(days=3)
"""How far before the last seen operation to re-read, for late settlement."""
HISTORY_START = datetime(2015, 1, 1, tzinfo=UTC)
"""Far enough back to cover any account; the API clamps to the account's own opening."""
#: Which of our asset classes a T-Invest instrument kind means.
ASSET_CLASSES = {
"share": AssetClass.share,
"bond": AssetClass.bond,
"etf": AssetClass.etf,
"currency": AssetClass.currency,
}
#: T-Invest account types that are not really brokerage accounts we want in the ledger.
SKIP_ACCOUNT_TYPES = frozenset({"ACCOUNT_TYPE_UNSPECIFIED"})
class TinvestSource:
name = SOURCE
async def sync(self, ctx: SyncContext) -> SyncResult:
token = ctx.settings.tinvest_token
if not token:
raise TinvestAuthError(
"TINVEST_TOKEN is not set — put a T-Invest token in .env "
"(t-bank.ru -> Инвестиции -> настройки -> токены)."
)
session = ctx.session
cursors = _parse_cursor(ctx.cursor_before)
counts = {"accounts": 0, "operations": 0, "events": 0, "instruments": 0, "snapshots": 0}
warnings: list[str] = []
new_cursors: dict[str, str] = dict(cursors)
async with TinvestClient(token) as client:
accounts = await client.accounts()
account_ids = await _upsert_accounts(session, accounts)
counts["accounts"] = len(account_ids)
for info in accounts:
if info.type in SKIP_ACCOUNT_TYPES or info.id not in account_ids:
continue
since = _since(cursors.get(info.id), info.opened_date)
operations = [op async for op in client.operations(info.id, since=since)]
if operations:
counts["operations"] += await _store_raw_operations(session, operations)
newest = max(op.ts for op in operations)
new_cursors[info.id] = newest.isoformat()
instruments = await _resolve_instruments(session, client, operations)
counts["instruments"] += instruments["created"]
written, unknown = await _write_events(
session, account_ids[info.id], operations, instruments["by_uid"]
)
counts["events"] += written
warnings += unknown
snapshot = await client.portfolio(info.id)
if snapshot is None:
warnings.append(
f"счёт «{info.name}» не отдаёт портфель — сверка по нему невозможна"
)
else:
counts["snapshots"] += await _store_snapshot(
session, account_ids[info.id], snapshot
)
await session.commit()
log.info(
"tinvest: %s accounts, %s operations, %s events, %s new instruments",
counts["accounts"],
counts["operations"],
counts["events"],
counts["instruments"],
)
return SyncResult(
cursor_after=json.dumps(new_cursors, sort_keys=True),
counts=counts,
warnings=warnings,
changed=counts["events"] > 0 or counts["operations"] > 0,
)
class TinvestAuthError(RuntimeError):
"""No usable token — actionable for the user, not a bug."""
def _parse_cursor(raw: str | None) -> dict[str, str]:
if not raw:
return {}
try:
value = json.loads(raw)
except json.JSONDecodeError:
log.warning("tinvest: unusable cursor %r, refetching the full history", raw)
return {}
return value if isinstance(value, dict) else {}
def _since(cursor: str | None, opened: datetime | None) -> datetime:
"""Where to start reading: just before the last seen operation, else from the opening."""
if cursor:
try:
return datetime.fromisoformat(cursor) - OVERLAP
except ValueError:
log.warning("tinvest: unusable per-account cursor %r", cursor)
if opened is not None:
return opened - timedelta(days=1)
return HISTORY_START
async def _upsert_accounts(session: AsyncSession, accounts: Iterable[Any]) -> dict[str, int]:
"""Create or refresh our `account` rows; returns T-Invest id -> our account id."""
out: dict[str, int] = {}
for info in accounts:
if info.type in SKIP_ACCOUNT_TYPES:
continue
existing = (
await session.execute(
select(Account).where(Account.source == SOURCE, Account.source_id == info.id)
)
).scalar_one_or_none()
archived = info.status != "ACCOUNT_STATUS_OPEN"
if existing is None:
account = Account(
kind=AccountKind.broker,
source=SOURCE,
source_id=info.id,
broker=Broker.tinvest,
name=info.name or f"T-Invest {info.id}",
currency="RUB",
include_in_net_worth=True,
role=AccountRole.investment,
primary_event_source=EventSource.tinvest_api,
opened_at=info.opened_date.date() if info.opened_date else None,
archived=archived,
)
session.add(account)
await session.flush()
out[info.id] = account.id
else:
existing.name = info.name or existing.name
existing.archived = archived
out[info.id] = existing.id
return out
async def _store_raw_operations(session: AsyncSession, operations: list[Operation]) -> int:
rows = [
{
"account_id": op.account_id,
"id": op.id,
"operation_type": op.operation_type,
"ts": op.ts,
"payload": op.payload,
"fetched_at": datetime.now(UTC),
}
for op in operations
]
stmt = pg_insert(RawTinvestOperation).values(rows)
stmt = stmt.on_conflict_do_update(
index_elements=["account_id", "id"],
set_={"payload": stmt.excluded.payload, "fetched_at": stmt.excluded.fetched_at},
)
await session.execute(stmt)
return len(rows)
async def _resolve_instruments(
session: AsyncSession, client: TinvestClient, operations: list[Operation]
) -> dict[str, Any]:
"""Make sure every instrument touched by these operations exists; map uid -> our id.
One paper reaches us under SEVERAL `instrument_uid`s — "Кредитный поток 1.0" arrives as
2adcb473… on a buy and 80212a5d… on its repayment — while `position_uid` and `figi` stay
the same. Resolving by uid alone therefore splits one bond into two half-positions that
never net out. So a uid the API will not resolve is matched by the operation's own figi
and position_uid first, and every alias seen is recorded for next time.
"""
uids = {op.instrument_uid for op in operations if op.instrument_uid}
if not uids:
return {"by_uid": {}, "created": 0}
known = await _instrument_ids_by_uid(session, uids)
missing = uids - set(known)
if missing:
# try the other identities of the same paper before spending an API call
by_identity = _identities(operations)
for uid in sorted(missing):
figi, position_uid = by_identity.get(uid, (None, None))
instrument_id = await _match_by_identity(session, figi, position_uid)
if instrument_id is not None:
known[uid] = instrument_id
await _remember_uid(session, instrument_id, uid)
missing -= set(known)
if not missing:
return {"by_uid": known, "created": 0}
fetched = await client.instruments_by_uid(missing)
await _store_raw_instruments(session, fetched)
created = 0
# `GetInstrumentBy(uid)` may answer with a DIFFERENT uid than the one asked for (the
# fund's own uid rather than the traded line's — TMOS comes back as 9654c2dd… when the
# operations say f509af83…). Remember which uid we asked about, or every later lookup
# by the id that actually appears in operations and snapshots misses.
identities = _identities(operations)
for asked_uid, info in fetched.items():
instrument = await _match_instrument(session, info)
if instrument is None:
instrument = Instrument(
asset_class=ASSET_CLASSES.get(info.kind, AssetClass.custom),
isin=info.isin,
figi=info.figi,
tinvest_uid=info.uid,
ticker=info.ticker,
board=info.class_code,
exchange=info.exchange,
name=info.name,
currency=info.currency or "RUB",
lot=info.lot,
nominal=info.nominal,
nominal_currency=info.nominal_currency,
maturity_date=info.maturity_date.date() if info.maturity_date else None,
sector=info.sector,
country=info.country,
)
session.add(instrument)
created += 1
else:
# an instrument already known from another source gains its T-Invest identity
instrument.tinvest_uid = instrument.tinvest_uid or info.uid
instrument.figi = instrument.figi or info.figi
await session.flush()
known[asked_uid] = instrument.id
known[info.uid] = instrument.id
for alias in {asked_uid, info.uid, identities.get(asked_uid, (None, None))[1]}:
if alias:
await _remember_uid(session, instrument.id, alias)
# Second pass for uids the API would not resolve at all (delisted papers, and the
# alternate uid of a paper whose own instrument only got created just now in the loop
# above). Matching them by figi/position_uid is what keeps a bought-and-repaid bond a
# single position instead of two halves that never net out.
for uid in sorted(uids - set(known)):
figi, position_uid = identities.get(uid, (None, None))
instrument_id = await _match_by_identity(session, figi, position_uid)
if instrument_id is not None:
known[uid] = instrument_id
await _remember_uid(session, instrument_id, uid)
return {"by_uid": known, "created": created}
def _identities(operations: list[Operation]) -> dict[str, tuple[str | None, str | None]]:
"""instrument_uid -> (figi, position_uid), as the operations themselves report it."""
out: dict[str, tuple[str | None, str | None]] = {}
for op in operations:
if op.instrument_uid and op.instrument_uid not in out:
out[op.instrument_uid] = (op.figi, op.position_uid)
return out
async def _match_by_identity(
session: AsyncSession, figi: str | None, position_uid: str | None
) -> int | None:
"""Find an instrument by the identities that survive T-Invest's uid churn."""
if figi:
found = (
await session.execute(select(Instrument.id).where(Instrument.figi == figi))
).scalar_one_or_none()
if found is not None:
return found
if position_uid:
return (
await session.execute(
select(InstrumentAlias.instrument_id).where(
InstrumentAlias.source == SOURCE,
InstrumentAlias.source_key == position_uid,
)
)
).scalar_one_or_none()
return None
async def _instrument_ids_by_uid(session: AsyncSession, uids: set[str]) -> dict[str, int]:
"""uid -> instrument id, looking at both the column and the aliases we recorded."""
found = {
uid: iid
for uid, iid in (
await session.execute(
select(Instrument.tinvest_uid, Instrument.id).where(
Instrument.tinvest_uid.in_(uids)
)
)
).all()
if uid
}
remaining = uids - set(found)
if remaining:
aliased = (
await session.execute(
select(InstrumentAlias.source_key, InstrumentAlias.instrument_id).where(
InstrumentAlias.source == SOURCE,
InstrumentAlias.source_key.in_(remaining),
)
)
).all()
found.update({key: iid for key, iid in aliased})
return found
async def _remember_uid(session: AsyncSession, instrument_id: int, uid: str) -> None:
"""Record the uid as an alias, so the next lookup by that id hits without an API call."""
await session.execute(
pg_insert(InstrumentAlias)
.values(instrument_id=instrument_id, source=SOURCE, source_key=uid)
.on_conflict_do_nothing(index_elements=["source", "source_key"])
)
async def _match_instrument(session: AsyncSession, info: InstrumentInfo) -> Instrument | None:
"""Identity order from the plan: ISIN -> FIGI -> uid -> (ticker, board)."""
for column, value in (
(Instrument.isin, info.isin),
(Instrument.figi, info.figi),
(Instrument.tinvest_uid, info.uid),
):
if not value:
continue
found = (
await session.execute(select(Instrument).where(column == value))
).scalar_one_or_none()
if found is not None:
return found
if info.ticker and info.class_code:
return (
await session.execute(
select(Instrument).where(
Instrument.ticker == info.ticker, Instrument.board == info.class_code
)
)
).scalar_one_or_none()
return None
async def _store_raw_instruments(
session: AsyncSession, instruments: dict[str, InstrumentInfo]
) -> None:
if not instruments:
return
rows = [
{
"uid": i.uid,
"kind": i.kind,
"isin": i.isin,
"figi": i.figi,
"ticker": i.ticker,
"payload": i.payload,
"fetched_at": datetime.now(UTC),
}
for i in instruments.values()
]
stmt = pg_insert(RawTinvestInstrument).values(rows)
stmt = stmt.on_conflict_do_update(
index_elements=["uid"],
set_={"payload": stmt.excluded.payload, "fetched_at": stmt.excluded.fetched_at},
)
await session.execute(stmt)
async def _write_events(
session: AsyncSession,
account_id: int,
operations: list[Operation],
instruments: dict[str, int],
) -> tuple[int, list[str]]:
"""Map operations into `event`, skipping what is already there (dedupe_key)."""
rows: list[dict[str, Any]] = []
warnings: list[str] = []
unknown_types: set[str] = set()
for op in operations:
try:
kind = kind_for(op.operation_type, op.payment)
except UnknownOperationType:
unknown_types.add(op.operation_type)
kind = EventKind.other
rows.append(_event_row(account_id, op, kind, instruments))
if unknown_types:
warnings.append(
"неизвестные типы операций T-Invest (записаны как other): "
+ ", ".join(sorted(unknown_types))
)
if not rows:
return 0, warnings
stmt = pg_insert(Event).values(rows)
# RETURNING counts what actually landed: a re-read of an already-imported operation
# conflicts on dedupe_key and is silently skipped, so `events` reports real news.
stmt = stmt.on_conflict_do_nothing(index_elements=["dedupe_key"]).returning(Event.id)
inserted = (await session.execute(stmt)).scalars().all()
return len(inserted), warnings
def _event_row(
account_id: int, op: Operation, kind: EventKind, instruments: dict[str, int]
) -> dict[str, Any]:
quantity = _signed_quantity(op, kind)
trade_date = op.ts.astimezone(MSK).date()
meta: dict[str, Any] = {"operation_type": op.operation_type}
if op.operation_type in CARD_FUNDED:
# cash came from a linked card, not from the account's own balance: the purchase is
# also an external flow, which phase-2 returns must not mistake for an internal move.
meta["card_funded"] = True
return {
"account_id": account_id,
"instrument_id": instruments.get(op.instrument_uid or ""),
"kind": kind,
"status": EventStatus.confirmed,
"ts": op.ts,
"trade_date": trade_date,
"quantity": quantity,
"price": op.price,
"price_currency": op.price_currency,
"amount": op.payment if op.payment is not None else Decimal(0),
"currency": op.payment_currency or op.price_currency or "RUB",
"fee": abs(op.commission) if op.commission else None,
"fee_currency": op.payment_currency if op.commission else None,
"accrued_interest": op.accrued_int,
"source": SOURCE,
"source_id": op.id,
"dedupe_key": f"{SOURCE}:{op.account_id}:{op.id}",
"description": op.description,
"meta": meta,
}
def _signed_quantity(op: Operation, kind: EventKind) -> Decimal | None:
"""T-Invest reports quantity unsigned; the ledger signs it by position effect."""
if op.quantity is None:
return None
magnitude = abs(op.quantity)
if kind in {EventKind.sell, EventKind.transfer_out, EventKind.repayment}:
return -magnitude
if kind in {EventKind.buy, EventKind.transfer_in}:
return magnitude
return None
async def _store_snapshot(session: AsyncSession, account_id: int, snapshot: Any) -> int:
"""Store the broker's own view; `metric_data_quality` compares it against the ledger."""
await session.execute(
pg_insert(RawTinvestSnapshot)
.values(
account_id=snapshot.account_id,
kind="portfolio",
captured_at=snapshot.captured_at,
payload=snapshot.payload,
)
.on_conflict_do_nothing(index_elements=["account_id", "kind", "captured_at"])
)
uids = {p.instrument_uid for p in snapshot.positions if p.instrument_uid}
by_uid = await _instrument_ids_by_uid(session, uids) if uids else {}
unresolved = uids - set(by_uid)
if unresolved:
# Dropping these silently would make the reconciliation look clean precisely where
# it is blind, so the gap is reported instead.
log.warning(
"tinvest: %d position(s) in the snapshot reference unknown instruments: %s",
len(unresolved),
", ".join(sorted(unresolved)),
)
stored = 0
position_rows = [
{
"account_id": account_id,
"instrument_id": by_uid[p.instrument_uid],
"as_of": snapshot.captured_at,
"source": SOURCE,
"qty": p.quantity,
"avg_price": p.average_price,
"market_value": (p.current_price * p.quantity) if p.current_price else None,
"currency": p.currency,
}
for p in snapshot.positions
if p.instrument_uid in by_uid
]
if position_rows:
await session.execute(
pg_insert(PositionSnapshot)
.values(position_rows)
.on_conflict_do_nothing(
index_elements=["account_id", "instrument_id", "as_of", "source"]
)
)
stored += len(position_rows)
cash_rows = [
{
"account_id": account_id,
"currency": c.currency,
"as_of": snapshot.captured_at,
"source": SOURCE,
"balance": c.balance,
"blocked": c.blocked,
}
for c in snapshot.cash
if c.currency
]
if cash_rows:
await session.execute(
pg_insert(CashSnapshot)
.values(cash_rows)
.on_conflict_do_nothing(index_elements=["account_id", "currency", "as_of", "source"])
)
stored += len(cash_rows)
return stored