395 lines
26 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Проектирование брокерских портфелей T-Bank
**Статус:** одобрено для планирования реализации
**Дата:** 2026-06-16
**Владелец:** MoexVibe
## Контекст
MoexVibe уже поддерживает вручную управляемые виртуальные портфели на основе рыночных данных MOEX.
Следующий шаг — интеграция T-Bank Invest только для чтения, чтобы приложение показывало реальные
брокерские счета, позиции, денежные остатки и историю операций.
Первая версия интеграции персональная и серверная. Бэкенд читает один `T_BANK_TOKEN` из
`apps/backend/.env`. В будущих версиях это можно заменить хранением токенов на уровне пользователя,
но первая реализация не должна требовать более крупной модели секретов.
## Факты о внешнем API
T-Bank Invest API — это gRPC API. REST доступен как прокси, а WebSocket существует для клиентов,
которым он нужен, но основным протоколом является gRPC. Production endpoint:
`invest-public-api.tbank.ru:443`; sandbox endpoint: `sandbox-invest-public-api.tbank.ru:443`.
Авторизация передается как gRPC-метаданные:
```text
Authorization: Bearer <T_BANK_TOKEN>
```
Нужные официальные сервисы и методы:
| Потребность | Метод T-Bank | Примечания |
| --- | --- | --- |
| Счета | `UsersService/GetAccounts` | Фильтровать только открытые брокерские счета и ИИС. |
| Текущая оценка портфеля | `OperationsService/GetPortfolio` | Возвращает итоги, позиции, дневную доходность, ожидаемую доходность. |
| Деньги и рассчитанные позиции | `OperationsService/GetPositions` | Возвращает деньги, заблокированные деньги, бумаги, фьючерсы, опционы. |
| История операций | `OperationsService/GetOperationsByCursor` | Курсорная пагинация, лимит до 1000, фильтры по типам операций. |
| Метаданные инструмента | `InstrumentsService/GetInstrumentBy` | Получение `instrument_uid`, тикера, class code, лота, названия, ISIN. |
Официальная документация рекомендует оставаться ниже 50 запросов в секунду суммарно по счетам и
токенам. Документированные лимиты unary-запросов включают 100 запросов в минуту для сервиса счетов и
200 запросов в минуту для сервиса операций. T-Bank также возвращает метаданные по лимитам запросов,
например `x-ratelimit-limit`, `x-ratelimit-remaining` и `x-ratelimit-reset`.
Stream-сервисы существуют для обновлений портфеля, позиций и операций. Они пригодятся позже, но
первая реализация использует unary-чтение плюс локальные кеширование и синхронизацию: это проще, тестируемее и не
держит лишние долгоживущие соединения.
Ссылки:
- [T-Invest API intro](https://developer.tbank.ru/invest/intro/intro)
- [gRPC protocol](https://developer.tbank.ru/invest/intro/developer/protocols/grpc/)
- [Limits](https://developer.tbank.ru/invest/intro/intro/limits)
- [UsersService](https://developer.tbank.ru/invest/api/users-service)
- [OperationsService](https://developer.tbank.ru/invest/api/operations-service)
- [JS SDK](https://developer.tbank.ru/invest/sdk/faq_js)
- [Official proto contracts](https://opensource.tbank.ru/invest/invest-contracts)
## Цели
- Показать реальные брокерские счета T-Bank и ИИС в отдельном разделе брокерских портфелей.
- Показать текущие позиции по каждому счету: акции, облигации, другие бумаги, если они вернулись из
API, и денежные остатки.
- Показать историю операций по каждому счету: покупки, продажи, комиссии, налоги, купоны, дивиденды,
пополнения, выводы, погашения, корректировки и другие типы операций, которые возвращает T-Bank.
- Не создавать лишнюю нагрузку на T-Bank: использовать ограничение частоты запросов на бэкенде,
короткоживущий кеш чтения и устойчивую локальную синхронизацию операций.
- Изолировать фронтенд от T-Bank. Бэкенд остается единственным клиентом внешнего API.
- Опубликовать человекочитаемую архитектурную и пользовательскую документацию в `apps/docs`.
## Не цели
- В этой фазе нет торговли, выставления заявок, переводов или пополнения счетов.
- В этой фазе нет Инвесткопилки, ЦФА-счета, дебетового счета, накопительного счета или счета фонда
денежного рынка.
- В этой фазе нет управления T-Bank токенами на уровне пользователя.
- В первой версии нет постоянно запущенных stream workers.
- Нет собственного движка расчета налогов сверх отображения налоговых операций, которые предоставляет
брокер.
## Область счетов
Используем `UsersService/GetAccounts` и оставляем только:
- `ACCOUNT_TYPE_TINKOFF`
- `ACCOUNT_TYPE_TINKOFF_IIS`
Также оставляем только открытые счета:
- `ACCOUNT_STATUS_OPEN`
Исключенные типы счетов:
- `ACCOUNT_TYPE_INVEST_BOX`
- `ACCOUNT_TYPE_INVEST_FUND`
- `ACCOUNT_TYPE_DEBIT`
- `ACCOUNT_TYPE_SAVING`
- `ACCOUNT_TYPE_DFA`
- `ACCOUNT_TYPE_UNSPECIFIED`
## Решение по протоколу
Используем тонкую gRPC-интеграцию на стороне бэкенда вместо REST-прокси или жесткой зависимости от
официального JS SDK.
### Почему gRPC
- Это основной протокол T-Bank Invest API.
- Он дает прямой доступ к unary API и будущим stream API.
- Он оставляет метаданные запроса, `x-tracking-id` и метаданные лимитов видимыми на границе
транспорта.
- Он соответствует правилу MoexVibe: бэкенд является единственным клиентом внешних данных.
### Почему не REST первым
REST полезен для ручной отладки и Swagger-примеров, но это прокси поверх той же поверхности сервисов.
Если строиться на REST, появится лишний слой перевода, а будущая поддержка stream API потребует
отдельного дизайна.
### Почему не SDK первым
Официальный JS SDK существует и может быть полезен как справочник. Текущие npm-метаданные
`@tinkoff/invest-js` показывают, что SDK уже зависит от gRPC-библиотек, включая `@grpc/grpc-js`,
`@grpc/proto-loader`, `nice-grpc` и `protobufjs`. Прямое использование SDK скроет транспортные
детали, которыми MoexVibe должен владеть сам: ограничение частоты запросов, сбор метаданных,
тестовые двойники, обновление сгенерированных типов и будущий источник токенов. Граница модуля при
этом должна позволять заменить внутренний транспортный адаптер на вызовы SDK, если позже это станет очевидно
дешевле.
## Архитектура бэкенда
Создать новый feature-модуль:
```text
apps/backend/src/modules/tbank/
├── tbank.module.ts
├── tbank.config.ts
├── tbank.controller.ts
├── services/
│ ├── tbank-client.service.ts
│ ├── broker-accounts.service.ts
│ ├── broker-portfolio.service.ts
│ ├── broker-operations.service.ts
│ └── broker-instruments.service.ts
├── dto/
│ ├── broker-account-response.dto.ts
│ ├── broker-portfolio-response.dto.ts
│ ├── broker-operation-query.dto.ts
│ └── broker-operation-response.dto.ts
└── mappers/
├── money.mapper.ts
├── account.mapper.ts
├── portfolio.mapper.ts
└── operation.mapper.ts
```
`TBankClientService` отвечает за:
- создание gRPC channel;
- метаданные `Authorization`;
- опциональные метаданные `x-app-name`, например `ksv741.moex-vibe`;
- таймаут запроса;
- повторные попытки для временных gRPC-сбоев;
- ограничение частоты запросов на уровне сервисов;
- сбор `x-tracking-id` и метаданных лимитов для логов и ответов об ошибках.
Доменные сервисы зависят от `TBankClientService`, а не напрямую от сгенерированных gRPC-клиентов.
Это оставляет будущий переход от одного серверного токена к providers токенов на уровне пользователя
локальным для клиентского слоя.
## Публичный API бэкенда
Все endpoints требуют существующую JWT-аутентификацию. В первой версии любой аутентифицированный
пользователь MoexVibe технически сможет прочитать одни и те же брокерские данные серверного токена.
До реализации хранилища токенов на уровне пользователя deployment должен считаться
single-user/admin-only.
| Endpoint | Метод | Описание |
| --- | --- | --- |
| `/api/v1/broker/accounts` | GET | Список открытых брокерских счетов T-Bank и ИИС. |
| `/api/v1/broker/accounts/:accountId/portfolio` | GET | Текущий портфель счета, позиции, итоги, деньги. |
| `/api/v1/broker/accounts/:accountId/operations` | GET | История операций с пагинацией и фильтрами. |
Параметры query для операций:
| Параметр | Тип | По умолчанию | Примечания |
| --- | --- | --- | --- |
| `from` | ISO datetime | начало текущего года | UTC в запросе к T-Bank. |
| `to` | ISO datetime | сейчас | UTC в запросе к T-Bank. |
| `cursor` | string | отсутствует | Передается в `GetOperationsByCursor`. |
| `limit` | integer | 100 | Ограничить до `1..1000`. |
| `instrumentId` | string | отсутствует | Поддерживает figi, instrument UID или `ticker_classCode`. |
| `operationTypes` | string list | отсутствует | Опциональные имена enum типов операций T-Bank. |
| `state` | enum | `OPERATION_STATE_EXECUTED` | По умолчанию исполненные операции для учетных представлений портфеля. |
## Модель ответа
Используем JSON DTO, удобные для фронтенда, а не сырые proto-объекты.
Денежные значения нормализуются из T-Bank `MoneyValue { currency, units, nano }` в:
```typescript
type BrokerMoney = {
currency: string;
units: string;
nano: number;
value: number;
};
```
`units` остается строкой, чтобы сохранить исходное int64-значение. `value` — удобное decimal-число
для отображения и графиков. Финансовые расчеты, которым нужна точная арифметика, должны использовать
decimal helpers, а не арифметику с плавающей точкой.
Форма ответа портфеля:
```typescript
type BrokerPortfolio = {
account: BrokerAccount;
totals: {
shares: BrokerMoney | null;
bonds: BrokerMoney | null;
etf: BrokerMoney | null;
currencies: BrokerMoney | null;
futures: BrokerMoney | null;
options: BrokerMoney | null;
structuredProducts: BrokerMoney | null;
dfa: BrokerMoney | null;
portfolio: BrokerMoney | null;
};
yields: {
expectedPercent: number | null;
daily: BrokerMoney | null;
dailyPercent: number | null;
};
cash: BrokerMoney[];
blockedCash: BrokerMoney[];
positions: BrokerPosition[];
asOf: string;
};
```
Форма ответа операций:
```typescript
type BrokerOperationsPage = {
accountId: string;
items: BrokerOperation[];
nextCursor: string | null;
hasNext: boolean;
asOf: string;
};
```
Каждая операция хранит исходный enum T-Bank в поле `type`, а также отображаемую категорию MoexVibe:
| Категория | Примеры операций T-Bank |
| --- | --- |
| `trade` | `OPERATION_TYPE_BUY`, `OPERATION_TYPE_SELL`, маржинальные/поставочные варианты |
| `income` | `OPERATION_TYPE_DIVIDEND`, `OPERATION_TYPE_COUPON`, погашения, overnight income |
| `tax` | `OPERATION_TYPE_TAX`, налог на дивиденды, налог на облигации, прогрессивные налоги |
| `fee` | брокерская комиссия, сервисная комиссия, маржинальная комиссия, success fee, прочие комиссии |
| `transfer` | пополнение, вывод, перевод бумаг, SWIFT/acquiring/multi transfers |
| `other` | unspecified и еще не категоризованные типы операций T-Bank |
Нельзя отбрасывать неизвестные типы операций. Их нужно хранить и показывать как `other` с исходным
enum-значением.
## Стратегия синхронизации операций
Фаза 1 может читать операции напрямую из T-Bank с кешированием. Фаза 2 должна сохранять
нормализованные операции локально, потому что история операций — это audit trail, и она не должна
зависеть от повторного обхода одних и тех же удаленных страниц.
Дизайн устойчивой синхронизации:
- Хранить одну строку на каждый элемент операции T-Bank с ключом `(accountId, cursor)`, если cursor
присутствует.
- Также хранить `operationId`, `parentOperationId`, `date`, `type`, `state`, `instrumentUid`,
`figi`, `ticker`, `classCode`, `payment`, `price`, `commission`, `yield`, `accruedInt`,
`quantity`, `quantityDone`, `raw`.
- Хранить состояние синхронизации по каждому счету: последний успешный диапазон, последний cursor, timestamp
последней синхронизации.
- Делать backfill исторических данных окнами по датам, например по одному календарному году за запуск.
- Обновлять скользящее недавнее окно, например последние 3 дня, потому что operation IDs и parent
IDs могут меняться согласно комментариям в proto.
- Сохранять сырой JSON payload операции для аудита/отладки и отдавать наружу нормализованные DTO.
## Стратегия кеширования
Использовать существующий backend `CacheService` для короткоживущих чтений и добавить key prefixes
для T-Bank. Frontend TanStack Query должен использовать совпадающие или более короткие stale times.
| Данные | TTL бэкенда | Причина |
| --- | --- | --- |
| Счета | 1 час | Список счетов меняется редко. |
| Итоги портфеля и позиции | 30-60 секунд | Текущий пользовательский вид должен ощущаться свежим, но не спамить T-Bank. |
| Деньги/лимиты вывода | 30-60 секунд | Такая же свежесть, как у позиций. |
| Метаданные инструмента | 24 часа | Название инструмента, лот, ISIN, UID стабильны. |
| Страница операций, недавнее окно | 60-300 секунд | Полезно до появления устойчивой синхронизации. |
| Исторические страницы операций | 24 часа или только БД | История в основном неизменна вне недавнего окна корректировок. |
Добавить переменные окружения:
| Переменная | Значение по умолчанию | Описание |
| --- | --- | --- |
| `T_BANK_TOKEN` | нет | Серверный T-Bank Invest token. Обязателен, когда интеграция включена. |
| `T_BANK_BASE_URL` | `invest-public-api.tbank.ru:443` | gRPC endpoint. |
| `T_BANK_APP_NAME` | `ksv741.moex-vibe` | Опциональная gRPC metadata. |
| `T_BANK_RATE_LIMIT_PER_SECOND` | `5` | Консервативный локальный limiter для всех вызовов T-Bank. |
| `CACHE_TBANK_ACCOUNTS_TTL` | `3600` | TTL кеша счетов. |
| `CACHE_TBANK_PORTFOLIO_TTL` | `60` | TTL кеша портфеля и позиций. |
| `CACHE_TBANK_OPERATIONS_TTL` | `300` | TTL кеша страницы недавних операций. |
| `CACHE_TBANK_INSTRUMENT_TTL` | `86400` | TTL кеша метаданных инструмента. |
Rate limiter должен оставаться ниже документированных публичных лимитов. При 429 или исчерпанных
метаданных лимита нужно отступать до reset, если метаданные доступны.
## Обработка ошибок
- Отсутствует `T_BANK_TOKEN`: вернуть понятную application error в стиле 503 и пометить интеграцию
недоступной; в development не ронять весь бэкенд.
- Ошибка авторизации от T-Bank: вернуть 502/503 с общим сообщением; никогда не отдавать token или
полные метаданные наружу.
- Счет не найден или исключен по type/status: вернуть 404 из endpoints MoexVibe.
- T-Bank 429: вернуть 429 или 503 с retry metadata, если она доступна.
- Временные gRPC-ошибки T-Bank: повторные попытки с небольшим exponential backoff, затем типизированная
upstream error с `trackingId`, если он присутствует.
- Неизвестные enum values при mapping: сохранить исходное значение и категоризовать как `other`.
## Безопасность
- `T_BANK_TOKEN` нельзя логировать, возвращать в API responses или коммитить.
- Логирование gRPC metadata должно редактировать `Authorization`.
- Первый deployment по дизайну single-user. Перед включением доступа для нескольких пользователей
MoexVibe нужно добавить encrypted token storage на уровне пользователя и authorization rules, которые связывают
каждый брокерский счет с владельцем.
- Интеграция только для чтения. В первый модуль нельзя включать clients для orders, stop-orders, transfers
или pay-in.
## Вид продукта на фронтенде
Добавить раздел брокерских портфелей отдельно от вручную управляемых виртуальных портфелей:
- Страница списка: карточки счетов для брокерских счетов и ИИС, с полной стоимостью, деньгами,
дневным изменением и временем последнего обновления.
- Страница счета: вкладки `Позиции`, `Операции` и позже `Аналитика`.
- Таблица позиций: название инструмента, тикер, тип, количество, текущая цена, текущая стоимость,
ожидаемая доходность, дневная доходность, заблокированное количество.
- Секция денег: доступные и заблокированные деньги по валютам.
- Таблица операций: дата, type/category, инструмент, количество, платеж, комиссия,
признаки налога/дохода, статус, раскрываемые детали сделки.
Не смешивать реальные брокерские счета с существующей ручной моделью `Portfolio`. Держать их отдельно
в UI и backend API. Позже MoexVibe сможет добавить представления сравнения или import flows из брокерских
операций в виртуальные портфели.
## Документация
Во время реализации опубликовать устойчивую документацию в `apps/docs`:
- `apps/docs/docs/backend/tbank-invest.md`: архитектура модуля, методы API, переменные окружения,
обработка ошибок и область только для чтения.
- Обновить `apps/docs/docs/backend/modules.md`: добавить `TBankInvestModule`.
- Обновить `apps/docs/docs/backend/configuration.md`: задокументировать `T_BANK_*` и переменные
TTL кеша.
- Обновить `apps/docs/docs/backend/caching.md`: добавить стратегию кеширования для T-Bank.
- Обновить `apps/docs/docs/backend/portfolio.md`: разделить ручные портфели и брокерские портфели.
- Добавить ADR `apps/docs/docs/adr/ADR-011-tbank-invest-grpc.md`: зафиксировать решение gRPC over
REST/SDK.
- Обновить `apps/docs/sidebars.ts`, чтобы включить новую страницу бэкенда и ADR.
## Критерии приемки
- Бэкенд показывает только открытые счета `ACCOUNT_TYPE_TINKOFF` и `ACCOUNT_TYPE_TINKOFF_IIS`.
- Бэкенд отдает текущий портфель счета с позициями и деньгами, не раскрывая сырые данные токена.
- Бэкенд отдает историю операций с пагинацией и включает категории buy, sell, tax, fee, coupon,
dividend, deposit, withdrawal и unknown operations.
- Вызовы T-Bank используют консервативный rate limiter и стратегию кеширования.
- Отсутствующий или некорректный token дает понятную ошибку integration-unavailable.
- Опубликованная документация в `apps/docs` описывает архитектуру, конфигурацию, кеширование и
решение по gRPC.
- Тесты покрывают фильтрацию счетов, mapping денег, категоризацию операций, валидацию query,
cache keys и mapping upstream-ошибок.
## Этапы реализации
1. Базовая gRPC-инфраструктура бэкенда и DTO mappers.
2. Endpoints только для чтения для счетов и портфеля.
3. Endpoint истории операций с курсорной пагинацией.
4. Список брокерских счетов и detail pages счета на фронтенде.
5. Устойчивая синхронизация операций в базе данных.
6. Опциональные stream workers для near-real-time refresh после стабилизации unary sync.