docs: add tbank broker portfolios design

This commit is contained in:
Sergey Krylov 2026-06-16 05:40:53 +03:00
parent 106467e5c4
commit a3817edfc7

View File

@ -0,0 +1,384 @@
# T-Bank Broker Portfolios Design
**Status:** Approved for implementation planning
**Date:** 2026-06-16
**Owner:** MoexVibe
## Context
MoexVibe already supports manually managed virtual portfolios based on MOEX market data. The next
step is a read-only integration with T-Bank Invest so the application can show real brokerage
accounts, positions, cash balances, and operation history.
The initial integration is personal and server-side. The backend reads one `T_BANK_TOKEN` from
`apps/backend/.env`. Later versions may replace this with per-user token storage, but the first
implementation must not require that larger secrets model.
## External API Facts
T-Bank Invest API is a gRPC API. REST is available as a proxy and WebSocket exists for clients that
need it, but gRPC is the primary protocol. Production endpoint is `invest-public-api.tbank.ru:443`;
sandbox endpoint is `sandbox-invest-public-api.tbank.ru:443`.
Authorization is passed as gRPC metadata:
```text
Authorization: Bearer <T_BANK_TOKEN>
```
The relevant official services and methods are:
| Need | T-Bank method | Notes |
| --- | --- | --- |
| Accounts | `UsersService/GetAccounts` | Filter by open brokerage accounts and IIS only. |
| Current portfolio valuation | `OperationsService/GetPortfolio` | Returns totals, positions, daily yield, expected yield. |
| Cash and settled positions | `OperationsService/GetPositions` | Returns money, blocked money, securities, futures, options. |
| Operation history | `OperationsService/GetOperationsByCursor` | Cursor pagination, limit up to 1000, operation type filters. |
| Instrument metadata | `InstrumentsService/GetInstrumentBy` | Resolve `instrument_uid`, ticker, class code, lot, name, ISIN. |
The official docs recommend staying below 50 requests per second across accounts and tokens. The
documented unary limits include 100 requests per minute for the accounts service and 200 requests
per minute for the operations service. T-Bank also exposes rate limit response metadata such as
`x-ratelimit-limit`, `x-ratelimit-remaining`, and `x-ratelimit-reset`.
Stream services exist for portfolio, positions, and operations updates. They are useful later, but
the first implementation will use unary reads plus local cache/sync because it is simpler, testable,
and less likely to hold unnecessary long-lived connections.
References:
- [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)
## Goals
- Show real T-Bank brokerage accounts and IIS accounts in a separate broker portfolios area.
- Show current positions for each account: shares, bonds, other securities if returned, and cash.
- Show operation history for each account: buys, sells, commissions, taxes, coupons, dividends,
deposits, withdrawals, repayments, corrections, and other operation types returned by T-Bank.
- Avoid excessive calls to T-Bank through backend rate limiting, short-lived read cache, and durable
local operation sync.
- Keep the frontend isolated from T-Bank. The backend remains the only external API client.
- Publish human-readable architecture and usage documentation in `apps/docs`.
## Non-Goals
- No trading, order placement, transfers, or account funding in this phase.
- No Invest Box, DFA smart account, debit account, savings account, or money market fund account in
this phase.
- No per-user T-Bank token management in this phase.
- No always-on stream workers in the first version.
- No tax calculation engine beyond showing broker-provided tax operations.
## Account Scope
Use `UsersService/GetAccounts` and keep only:
- `ACCOUNT_TYPE_TINKOFF`
- `ACCOUNT_TYPE_TINKOFF_IIS`
Also keep only open accounts:
- `ACCOUNT_STATUS_OPEN`
Excluded account types:
- `ACCOUNT_TYPE_INVEST_BOX`
- `ACCOUNT_TYPE_INVEST_FUND`
- `ACCOUNT_TYPE_DEBIT`
- `ACCOUNT_TYPE_SAVING`
- `ACCOUNT_TYPE_DFA`
- `ACCOUNT_TYPE_UNSPECIFIED`
## Protocol Decision
Use a thin backend gRPC integration instead of the REST proxy or a hard dependency on the official
JS SDK.
### Why gRPC
- It is the primary protocol of T-Bank Invest API.
- It gives direct access to unary and future stream APIs.
- It keeps request metadata, `x-tracking-id`, and rate-limit metadata visible at the transport
boundary.
- It fits the existing MoexVibe rule that backend is the only external data client.
### Why Not REST First
REST is useful for manual debugging and Swagger examples, but it is a proxy over the same service
surface. Building on REST would add translation overhead and make future stream support a separate
design.
### Why Not SDK First
The official JS SDK exists and can be useful as a reference. Current npm metadata for
`@tinkoff/invest-js` shows it already depends on gRPC libraries such as `@grpc/grpc-js`,
`@grpc/proto-loader`, `nice-grpc`, and `protobufjs`. Using it directly would hide transport details
that MoexVibe needs to own: rate limiting, metadata capture, test doubles, generated type updates,
and later token sourcing. The module boundary should still allow replacing the internal transport
adapter with SDK calls if that becomes clearly cheaper.
## Backend Architecture
Create a new feature module:
```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` owns:
- gRPC channel creation.
- `Authorization` metadata.
- optional `x-app-name` metadata, for example `ksv741.moex-vibe`.
- request timeout.
- retry for transient gRPC failures.
- service-level rate limiting.
- capturing `x-tracking-id` and rate-limit metadata for logs and error responses.
Domain services depend on `TBankClientService`, not directly on generated gRPC clients. This keeps
the later switch from one server token to per-user token providers local to the client layer.
## Public Backend API
All endpoints require the existing JWT authentication. In the first version, any authenticated
MoexVibe user can technically read the same server-token broker data. Deployment must treat this as
single-user/admin-only until per-user token storage is implemented.
| Endpoint | Method | Description |
| --- | --- | --- |
| `/api/v1/broker/accounts` | GET | List open T-Bank brokerage and IIS accounts. |
| `/api/v1/broker/accounts/:accountId/portfolio` | GET | Current account portfolio, positions, totals, cash. |
| `/api/v1/broker/accounts/:accountId/operations` | GET | Operation history with pagination and filters. |
Operations query parameters:
| Query | Type | Default | Notes |
| --- | --- | --- | --- |
| `from` | ISO datetime | start of current year | UTC in T-Bank request. |
| `to` | ISO datetime | now | UTC in T-Bank request. |
| `cursor` | string | absent | Passed to `GetOperationsByCursor`. |
| `limit` | integer | 100 | Clamp to `1..1000`. |
| `instrumentId` | string | absent | Supports figi, instrument UID, or `ticker_classCode`. |
| `operationTypes` | string list | absent | Optional T-Bank operation type enum names. |
| `state` | enum | `OPERATION_STATE_EXECUTED` | Default to executed operations for portfolio accounting views. |
## Response Model
Use JSON DTOs shaped for the frontend, not raw proto objects.
Money values are normalized from T-Bank `MoneyValue { currency, units, nano }` into:
```typescript
type BrokerMoney = {
currency: string;
units: string;
nano: number;
value: number;
};
```
`units` remains a string to preserve the original int64 value. `value` is a convenience decimal for
display and charting. Financial calculations that require exact precision should use decimal helpers,
not floating-point arithmetic.
Portfolio response shape:
```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;
};
```
Operation response shape:
```typescript
type BrokerOperationsPage = {
accountId: string;
items: BrokerOperation[];
nextCursor: string | null;
hasNext: boolean;
asOf: string;
};
```
Each operation keeps the original T-Bank enum in `type`, plus a MoexVibe display category:
| Category | T-Bank operation examples |
| --- | --- |
| `trade` | `OPERATION_TYPE_BUY`, `OPERATION_TYPE_SELL`, margin/delivery variants |
| `income` | `OPERATION_TYPE_DIVIDEND`, `OPERATION_TYPE_COUPON`, repayments, overnight income |
| `tax` | `OPERATION_TYPE_TAX`, dividend tax, bond tax, progressive tax variants |
| `fee` | broker fee, service fee, margin fee, success fee, cash/out/advice/other fees |
| `transfer` | input, output, securities transfer, SWIFT/acquiring/multi transfers |
| `other` | unspecified and T-Bank operation types not yet categorized |
Never drop unknown operation types. Store and display them as `other` with the original enum.
## Operation Sync Strategy
Phase 1 can read operations directly from T-Bank with a cache. Phase 2 should persist normalized
operations locally because operation history is the audit trail and should not depend on repeatedly
walking the same remote pages.
Durable sync design:
- Store one row per T-Bank operation item keyed by `(accountId, cursor)` when cursor is present.
- Also keep `operationId`, `parentOperationId`, `date`, `type`, `state`, `instrumentUid`, `figi`,
`ticker`, `classCode`, `payment`, `price`, `commission`, `yield`, `accruedInt`, `quantity`,
`quantityDone`, `raw`.
- Store sync state per account: last successful range, last cursor, last synced timestamp.
- Backfill historical data by date windows, for example one calendar year per run.
- Refresh a moving recent window, for example last 3 days, because broker operation IDs and parent
IDs can change according to the proto comments.
- Keep raw operation payload JSON for audit/debug while exposing normalized DTOs.
## Cache Strategy
Use existing backend `CacheService` for short-lived reads and add T-Bank-specific key prefixes.
Frontend TanStack Query should use matching or shorter stale times.
| Data | Backend TTL | Reason |
| --- | --- | --- |
| Accounts | 1 hour | Account list changes rarely. |
| Portfolio totals and positions | 30-60 seconds | User-facing current view, should feel fresh but not spam T-Bank. |
| Cash/withdraw limits | 30-60 seconds | Similar freshness to positions. |
| Instrument metadata | 24 hours | Instrument name, lot, ISIN, UID are stable. |
| Operation page, recent window | 60-300 seconds | Useful before durable sync exists. |
| Operation historical pages | 24 hours or DB only | History is mostly immutable outside recent correction window. |
Add environment variables:
| Variable | Default | Description |
| --- | --- | --- |
| `T_BANK_TOKEN` | none | Server-side T-Bank Invest token. Required when integration is enabled. |
| `T_BANK_BASE_URL` | `invest-public-api.tbank.ru:443` | gRPC endpoint. |
| `T_BANK_APP_NAME` | `ksv741.moex-vibe` | Optional gRPC metadata. |
| `T_BANK_RATE_LIMIT_PER_SECOND` | `5` | Conservative local limiter across T-Bank calls. |
| `CACHE_TBANK_ACCOUNTS_TTL` | `3600` | Accounts cache TTL. |
| `CACHE_TBANK_PORTFOLIO_TTL` | `60` | Portfolio and positions cache TTL. |
| `CACHE_TBANK_OPERATIONS_TTL` | `300` | Recent operation page cache TTL. |
| `CACHE_TBANK_INSTRUMENT_TTL` | `86400` | Instrument metadata cache TTL. |
The rate limiter must stay below documented public limits. On 429 or exhausted rate-limit metadata,
back off until reset when metadata is available.
## Error Handling
- Missing `T_BANK_TOKEN`: return a clear 503-style application error and mark integration
unavailable; do not crash the whole backend in development.
- Auth error from T-Bank: return 502/503 with a generic message; never echo token or full metadata.
- Account not found or excluded by type/status: return 404 from MoexVibe endpoints.
- T-Bank 429: return 429 or 503 with retry metadata when available.
- T-Bank transient gRPC errors: retry with small exponential backoff, then surface a typed upstream
error with `trackingId` if present.
- Mapping unknown enum values: preserve the original value and categorize as `other`.
## Security
- `T_BANK_TOKEN` must never be logged, returned in API responses, or committed.
- gRPC metadata logging must redact `Authorization`.
- The initial deployment is single-user by design. Before enabling access for multiple MoexVibe
users, add per-user encrypted token storage and authorization rules that bind each broker account
to its owner.
- The integration is read-only. Do not include order, stop-order, transfer, or pay-in clients in the
first module.
## Frontend Product Shape
Add a broker portfolios area separate from manually managed virtual portfolios:
- List page: account cards for brokerage and IIS accounts, with total value, cash, daily change, and
last refresh time.
- Account detail page: tabs for `Позиции`, `Операции`, and later `Аналитика`.
- Positions table: instrument name, ticker, type, quantity, current price, current value, expected
yield, daily yield, blocked quantity.
- Cash section: available and blocked money by currency.
- Operations table: date, type/category, instrument, quantity, payment, commission, tax/income
indicators, status, expandable trade details.
Do not merge real broker accounts into the existing manual `Portfolio` model. Keep them separate in
UI and backend API. Later, MoexVibe can add comparison views or import flows from broker operations
into virtual portfolios.
## Documentation Deliverables
Publish durable documentation in `apps/docs` during implementation:
- `apps/docs/docs/backend/tbank-invest.md`: module architecture, API methods, env vars, error
handling, and read-only scope.
- Update `apps/docs/docs/backend/modules.md`: add `TBankInvestModule`.
- Update `apps/docs/docs/backend/configuration.md`: document `T_BANK_*` and cache TTL variables.
- Update `apps/docs/docs/backend/caching.md`: add T-Bank cache strategy.
- Update `apps/docs/docs/backend/portfolio.md`: distinguish manual portfolios from broker
portfolios.
- Add ADR `apps/docs/docs/adr/ADR-011-tbank-invest-grpc.md`: record gRPC over REST/SDK decision.
- Update `apps/docs/sidebars.ts` to include the new backend page and ADR.
## Acceptance Criteria
- Backend lists only open `ACCOUNT_TYPE_TINKOFF` and `ACCOUNT_TYPE_TINKOFF_IIS` accounts.
- Backend exposes current account portfolio with positions and cash without exposing raw token data.
- Backend exposes paginated operation history and includes buy, sell, tax, fee, coupon, dividend,
deposit, withdrawal, and unknown operation categories.
- T-Bank calls use a conservative rate limiter and cache strategy.
- Missing or invalid token produces a clear integration-unavailable error.
- Published docs in `apps/docs` describe architecture, configuration, caching, and the gRPC decision.
- Tests cover account filtering, money mapping, operation categorization, query validation, cache
keys, and upstream error mapping.
## Implementation Phases
1. Backend gRPC foundation and DTO mappers.
2. Read-only account and portfolio endpoints.
3. Operation history endpoint with cursor pagination.
4. Frontend broker account list and account detail pages.
5. Durable operation sync in database.
6. Optional stream workers for near-real-time refresh after unary sync is stable.