Compare commits

...

17 Commits

Author SHA1 Message Date
8c2a6c9e3c fix: trust tbank grpc root certificate
All checks were successful
CI / lint (pull_request) Successful in 2m11s
CI / test (pull_request) Successful in 1m58s
CI / build (pull_request) Successful in 2m3s
CI / lint (push) Successful in 1m57s
CI / test (push) Successful in 2m4s
CI / build (push) Successful in 2m8s
2026-06-17 07:39:37 +03:00
ea916dfec9 fix: restrict and expose tbank broker sync 2026-06-17 06:57:49 +03:00
b10a2cfb0d docs: document tbank invest integration 2026-06-17 06:49:09 +03:00
3fe4e7a4ec fix: harden tbank operation sync 2026-06-17 06:47:18 +03:00
6e30177294 feat: persist tbank broker operations 2026-06-17 06:44:45 +03:00
ead40bff6f feat: add broker portfolio UI 2026-06-16 23:05:52 +03:00
50bff3dbe7 feat: add broker frontend api hooks 2026-06-16 22:56:20 +03:00
b848d256d1 fix: load tbank proto contracts at runtime 2026-06-16 22:54:21 +03:00
d01d2b9f7f feat: expose tbank broker operations 2026-06-16 22:46:19 +03:00
bd6b2589c0 feat: expose tbank broker portfolio 2026-06-16 22:43:40 +03:00
ddeab01b9e feat: expose tbank broker accounts 2026-06-16 22:34:12 +03:00
4ef2f8d05e feat: add tbank grpc client service 2026-06-16 22:23:33 +03:00
825095106c feat: add tbank domain mappers 2026-06-16 22:20:01 +03:00
7a8bc7c90a feat: configure tbank integration 2026-06-16 22:15:31 +03:00
526e5dc5f3 feat: add tbank invest proto contracts 2026-06-16 22:10:54 +03:00
a971d0c8bf docs: require russian specs 2026-06-16 06:01:48 +03:00
a3817edfc7 docs: add tbank broker portfolios design 2026-06-16 05:40:53 +03:00
73 changed files with 7049 additions and 31 deletions

View File

@ -20,6 +20,7 @@ npm workspaces монорепозиторий: `apps/backend` (NestJS), `apps/fr
- `apps/docs` — единственная опубликованная человекочитаемая документация проекта (Docusaurus).
- Root `docs` хранит только согласованные SDD-спецификации в `docs/superpowers/specs/`.
- Все SDD spec-файлы в `docs/superpowers/specs/` пишутся на русском языке; англоязычные термины допустимы для API, кода, протоколов и официальных названий.
- ADR для опубликованной документации находятся в `apps/docs/docs/adr/`.
- OpenAPI source of truth — live Swagger JSON бэкенда на `/api/docs-json`; frontend generated types находятся в `apps/frontend/src/api/types.ts`.
- Superpowers plans и временные execution logs не коммитить по умолчанию. Если нужен план для ревью, держать его кратким и переносить устойчивые решения в spec/ADR/docs.
@ -53,6 +54,12 @@ Live MOEX integration tests opt-in: `npm run test:integration -w apps/backend`.
| `MOEX_RATE_LIMIT` | 10 | Запросов/с к MOEX |
| `MOEX_CIRCUIT_BREAKER_THRESHOLD` | 5 | Количество ошибок до открытия circuit breaker |
| `MOEX_CIRCUIT_BREAKER_RESET_SECONDS` | 30 | Время до попытки закрыть circuit breaker |
| `T_BANK_TOKEN` | `''` | Server-side токен T-Bank Invest |
| `T_BANK_BASE_URL` | `invest-public-api.tbank.ru:443` | gRPC endpoint T-Bank Invest |
| `T_BANK_CA_CERT_PATH` | `''` | Путь к PEM root CA для gRPC TLS, если локальная сеть подменяет сертификаты |
| `T_BANK_APP_NAME` | `ksv741.moex-vibe` | Metadata приложения для T-Bank |
| `T_BANK_RATE_LIMIT_PER_SECOND` | 5 | Локальный rate limiter для T-Bank |
| `T_BANK_REQUEST_TIMEOUT_MS` | 10000 | Deadline gRPC-запроса (мс) |
| `CACHE_MARKET_DATA_TTL` | 900 | TTL рыночных данных (с) |
| `CACHE_HISTORY_TTL` | 3600 | TTL истории (с) |
| `CACHE_CANDLES_TTL` | 3600 | TTL свечей (с) |

View File

@ -0,0 +1,33 @@
-----BEGIN CERTIFICATE-----
MIIFwjCCA6qgAwIBAgICEAAwDQYJKoZIhvcNAQELBQAwcDELMAkGA1UEBhMCUlUx
PzA9BgNVBAoMNlRoZSBNaW5pc3RyeSBvZiBEaWdpdGFsIERldmVsb3BtZW50IGFu
ZCBDb21tdW5pY2F0aW9uczEgMB4GA1UEAwwXUnVzc2lhbiBUcnVzdGVkIFJvb3Qg
Q0EwHhcNMjIwMzAxMjEwNDE1WhcNMzIwMjI3MjEwNDE1WjBwMQswCQYDVQQGEwJS
VTE/MD0GA1UECgw2VGhlIE1pbmlzdHJ5IG9mIERpZ2l0YWwgRGV2ZWxvcG1lbnQg
YW5kIENvbW11bmljYXRpb25zMSAwHgYDVQQDDBdSdXNzaWFuIFRydXN0ZWQgUm9v
dCBDQTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAMfFOZ8pUAL3+r2n
qqE0Zp52selXsKGFYoG0GM5bwz1bSFtCt+AZQMhkWQheI3poZAToYJu69pHLKS6Q
XBiwBC1cvzYmUYKMYZC7jE5YhEU2bSL0mX7NaMxMDmH2/NwuOVRj8OImVa5s1F4U
zn4Kv3PFlDBjjSjXKVY9kmjUBsXQrIHeaqmUIsPIlNWUnimXS0I0abExqkbdrXbX
YwCOXhOO2pDUx3ckmJlCMUGacUTnylyQW2VsJIyIGA8V0xzdaeUXg0VZ6ZmNUr5Y
Ber/EAOLPb8NYpsAhJe2mXjMB/J9HNsoFMBFJ0lLOT/+dQvjbdRZoOT8eqJpWnVD
U+QL/qEZnz57N88OWM3rabJkRNdU/Z7x5SFIM9FrqtN8xewsiBWBI0K6XFuOBOTD
4V08o4TzJ8+Ccq5XlCUW2L48pZNCYuBDfBh7FxkB7qDgGDiaftEkZZfApRg2E+M9
G8wkNKTPLDc4wH0FDTijhgxR3Y4PiS1HL2Zhw7bD3CbslmEGgfnnZojNkJtcLeBH
BLa52/dSwNU4WWLubaYSiAmA9IUMX1/RpfpxOxd4Ykmhz97oFbUaDJFipIggx5sX
ePAlkTdWnv+RWBxlJwMQ25oEHmRguNYf4Zr/Rxr9cS93Y+mdXIZaBEE0KS2iLRqa
OiWBki9IMQU4phqPOBAaG7A+eP8PAgMBAAGjZjBkMB0GA1UdDgQWBBTh0YHlzlpf
BKrS6badZrHF+qwshzAfBgNVHSMEGDAWgBTh0YHlzlpfBKrS6badZrHF+qwshzAS
BgNVHRMBAf8ECDAGAQH/AgEEMA4GA1UdDwEB/wQEAwIBhjANBgkqhkiG9w0BAQsF
AAOCAgEAALIY1wkilt/urfEVM5vKzr6utOeDWCUczmWX/RX4ljpRdgF+5fAIS4vH
tmXkqpSCOVeWUrJV9QvZn6L227ZwuE15cWi8DCDal3Ue90WgAJJZMfTshN4OI8cq
W9E4EG9wglbEtMnObHlms8F3CHmrw3k6KmUkWGoa+/ENmcVl68u/cMRl1JbW2bM+
/3A+SAg2c6iPDlehczKx2oa95QW0SkPPWGuNA/CE8CpyANIhu9XFrj3RQ3EqeRcS
AQQod1RNuHpfETLU/A2gMmvn/w/sx7TB3W5BPs6rprOA37tutPq9u6FTZOcG1Oqj
C/B7yTqgI7rbyvox7DEXoX7rIiEqyNNUguTk/u3SZ4VXE2kmxdmSh3TQvybfbnXV
4JbCZVaqiZraqc7oZMnRoWrXRG3ztbnbes/9qhRGI7PqXqeKJBztxRTEVj8ONs1d
WN5szTwaPIvhkhO3CO5ErU2rVdUr89wKpNXbBODFKRtgxUT70YpmJ46VVaqdAhOZ
D9EUUn4YaeLaS8AjSF/h7UkjOibNc4qVDiPP+rkehFWM66PVnP1Msh93tc+taIfC
EYVMxjh8zNbFuoc7fzvvrFILLe7ifvEIUqSVIC/AzplM/Jxw7buXFeGP1qVCBEHq
391d/9RAfaZ12zkwFsl+IKwE/OZxW8AHa9i1p4GO0YSNuczzEm4=
-----END CERTIFICATE-----

View File

@ -1,4 +1,13 @@
{
"collection": "@nestjs/schematics",
"sourceRoot": "src"
"sourceRoot": "src",
"compilerOptions": {
"assets": [
{
"include": "modules/tbank/proto/contracts/**/*",
"outDir": "dist"
}
],
"watchAssets": true
}
}

View File

@ -13,6 +13,8 @@
"test:integration": "MOEX_LIVE_TESTS=1 VITE_CJS_IGNORE_WARNING=1 vitest run \"src/**/*.integration.spec.ts\""
},
"dependencies": {
"@grpc/grpc-js": "^1.14.4",
"@grpc/proto-loader": "^0.8.1",
"@libsql/client": "^0.17.3",
"@nestjs/axios": "^3.0.0",
"@nestjs/cache-manager": "^2.0.0",
@ -30,7 +32,9 @@
"class-transformer": "^0.5.0",
"class-validator": "^0.14.0",
"cookie-parser": "^1.4.7",
"long": "^5.3.2",
"p-queue": "^7.3.0",
"protobufjs": "^8.6.4",
"reflect-metadata": "^0.1.13",
"rxjs": "^7.8.0",
"swagger-ui-express": "^5.0.0"

View File

@ -0,0 +1,50 @@
-- CreateTable
CREATE TABLE "BrokerOperation" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"accountId" TEXT NOT NULL,
"cursor" TEXT,
"operationId" TEXT,
"parentOperationId" TEXT,
"date" DATETIME,
"type" TEXT NOT NULL,
"category" TEXT NOT NULL,
"state" TEXT,
"instrumentUid" TEXT,
"figi" TEXT,
"ticker" TEXT,
"classCode" TEXT,
"payment" TEXT,
"price" TEXT,
"commission" TEXT,
"yield" TEXT,
"accruedInt" TEXT,
"quantity" INTEGER,
"quantityDone" INTEGER,
"raw" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
-- CreateTable
CREATE TABLE "BrokerOperationSyncState" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"accountId" TEXT NOT NULL,
"lastCursor" TEXT,
"lastSyncedFrom" DATETIME,
"lastSyncedTo" DATETIME,
"syncedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
-- CreateIndex
CREATE INDEX "BrokerOperation_accountId_date_idx" ON "BrokerOperation"("accountId", "date");
-- CreateIndex
CREATE INDEX "BrokerOperation_accountId_type_idx" ON "BrokerOperation"("accountId", "type");
-- CreateIndex
CREATE UNIQUE INDEX "BrokerOperation_accountId_cursor_key" ON "BrokerOperation"("accountId", "cursor");
-- CreateIndex
CREATE UNIQUE INDEX "BrokerOperationSyncState_accountId_key" ON "BrokerOperationSyncState"("accountId");

View File

@ -51,3 +51,44 @@ model User {
updatedAt DateTime @updatedAt
portfolios Portfolio[]
}
model BrokerOperation {
id Int @id @default(autoincrement())
accountId String
cursor String?
operationId String?
parentOperationId String?
date DateTime?
type String
category String
state String?
instrumentUid String?
figi String?
ticker String?
classCode String?
payment String?
price String?
commission String?
yield String?
accruedInt String?
quantity Int?
quantityDone Int?
raw String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([accountId, cursor])
@@index([accountId, date])
@@index([accountId, type])
}
model BrokerOperationSyncState {
id Int @id @default(autoincrement())
accountId String @unique
lastCursor String?
lastSyncedFrom DateTime?
lastSyncedTo DateTime?
syncedAt DateTime @default(now())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}

View File

@ -10,6 +10,7 @@ import { CandlesModule } from './modules/candles/candles.module';
import { PortfolioModule } from './modules/portfolio/portfolio.module';
import { PrismaModule } from './modules/prisma/prisma.module';
import { AuthModule } from './modules/auth/auth.module';
import { TBankModule } from './modules/tbank/tbank.module';
import configuration from './config/configuration';
@Module({
@ -25,6 +26,7 @@ import configuration from './config/configuration';
BondsModule,
CandlesModule,
PortfolioModule,
TBankModule,
],
})
export class AppModule {}

View File

@ -14,6 +14,14 @@ export default registerAs('app', () => ({
10,
),
},
tbank: {
token: process.env.T_BANK_TOKEN || '',
baseUrl: process.env.T_BANK_BASE_URL || 'invest-public-api.tbank.ru:443',
caCertPath: process.env.T_BANK_CA_CERT_PATH || '',
appName: process.env.T_BANK_APP_NAME || 'ksv741.moex-vibe',
rateLimitPerSecond: parseInt(process.env.T_BANK_RATE_LIMIT_PER_SECOND || '5', 10),
requestTimeoutMs: parseInt(process.env.T_BANK_REQUEST_TIMEOUT_MS || '10000', 10),
},
cache: {
marketDataTtl: parseInt(process.env.CACHE_MARKET_DATA_TTL || '900', 10),
historyTtl: parseInt(process.env.CACHE_HISTORY_TTL || '3600', 10),
@ -21,6 +29,10 @@ export default registerAs('app', () => ({
securityTtl: parseInt(process.env.CACHE_SECURITY_TTL || '86400', 10),
searchTtl: parseInt(process.env.CACHE_SEARCH_TTL || '3600', 10),
dividendsTtl: parseInt(process.env.CACHE_DIVIDENDS_TTL || '86400', 10),
tbankAccountsTtl: parseInt(process.env.CACHE_TBANK_ACCOUNTS_TTL || '3600', 10),
tbankPortfolioTtl: parseInt(process.env.CACHE_TBANK_PORTFOLIO_TTL || '60', 10),
tbankOperationsTtl: parseInt(process.env.CACHE_TBANK_OPERATIONS_TTL || '300', 10),
tbankInstrumentTtl: parseInt(process.env.CACHE_TBANK_INSTRUMENT_TTL || '86400', 10),
},
auth: {
jwtSecret: process.env.JWT_SECRET || 'dev-jwt-secret-change-in-production',

View File

@ -0,0 +1,21 @@
import { ApiProperty } from '@nestjs/swagger';
export class BrokerAccountResponseDto {
@ApiProperty()
id!: string;
@ApiProperty({ enum: ['brokerage', 'iis'] })
type!: 'brokerage' | 'iis';
@ApiProperty()
name!: string;
@ApiProperty()
status!: string;
@ApiProperty({ nullable: true })
openedAt!: string | null;
@ApiProperty({ nullable: true })
accessLevel!: string | null;
}

View File

@ -0,0 +1,45 @@
import { ApiProperty } from '@nestjs/swagger';
import { BrokerAccountResponseDto } from './broker-account-response.dto';
import { BrokerOperationSyncResponseDto } from './broker-operation-sync-query.dto';
import { BrokerOperationsPageResponseDto } from './broker-operation-response.dto';
import { BrokerPortfolioResponseDto } from './broker-portfolio-response.dto';
export class BrokerResponseMetaDto {
@ApiProperty({ nullable: true })
cachedAt!: string | null;
@ApiProperty()
fromCache!: boolean;
}
export class BrokerAccountsEnvelopeDto {
@ApiProperty({ type: [BrokerAccountResponseDto] })
data!: BrokerAccountResponseDto[];
@ApiProperty({ type: BrokerResponseMetaDto })
meta!: BrokerResponseMetaDto;
}
export class BrokerPortfolioEnvelopeDto {
@ApiProperty({ type: BrokerPortfolioResponseDto })
data!: BrokerPortfolioResponseDto;
@ApiProperty({ type: BrokerResponseMetaDto })
meta!: BrokerResponseMetaDto;
}
export class BrokerOperationsEnvelopeDto {
@ApiProperty({ type: BrokerOperationsPageResponseDto })
data!: BrokerOperationsPageResponseDto;
@ApiProperty({ type: BrokerResponseMetaDto })
meta!: BrokerResponseMetaDto;
}
export class BrokerOperationSyncEnvelopeDto {
@ApiProperty({ type: BrokerOperationSyncResponseDto })
data!: BrokerOperationSyncResponseDto;
@ApiProperty({ type: BrokerResponseMetaDto })
meta!: BrokerResponseMetaDto;
}

View File

@ -0,0 +1,15 @@
import { ApiProperty } from '@nestjs/swagger';
export class BrokerMoneyDto {
@ApiProperty()
currency!: string;
@ApiProperty()
units!: string;
@ApiProperty()
nano!: number;
@ApiProperty()
value!: number;
}

View File

@ -0,0 +1,43 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import { IsDateString, IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
export class BrokerOperationQueryDto {
@ApiPropertyOptional()
@IsOptional()
@IsDateString()
from?: string;
@ApiPropertyOptional()
@IsOptional()
@IsDateString()
to?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
cursor?: string;
@ApiPropertyOptional({ minimum: 1, maximum: 1000, default: 100 })
@IsOptional()
@Transform(({ value }) => (value === undefined ? undefined : Number(value)))
@IsInt()
@Min(1)
@Max(1000)
limit?: number;
@ApiPropertyOptional()
@IsOptional()
@IsString()
instrumentId?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
operationTypes?: string;
@ApiPropertyOptional({ default: 'OPERATION_STATE_EXECUTED' })
@IsOptional()
@IsString()
state?: string;
}

View File

@ -0,0 +1,86 @@
import { ApiProperty } from '@nestjs/swagger';
import { BrokerMoneyDto } from './broker-money.dto';
const operationCategories = ['trade', 'income', 'tax', 'fee', 'transfer', 'other'] as const;
export class BrokerOperationResponseDto {
@ApiProperty({ nullable: true })
cursor!: string | null;
@ApiProperty()
accountId!: string;
@ApiProperty({ nullable: true })
id!: string | null;
@ApiProperty({ nullable: true })
parentOperationId!: string | null;
@ApiProperty({ nullable: true })
date!: string | null;
@ApiProperty()
type!: string;
@ApiProperty({ enum: operationCategories })
category!: (typeof operationCategories)[number];
@ApiProperty({ nullable: true })
description!: string | null;
@ApiProperty({ nullable: true })
state!: string | null;
@ApiProperty({ nullable: true })
instrumentUid!: string | null;
@ApiProperty({ nullable: true })
figi!: string | null;
@ApiProperty({ nullable: true })
ticker!: string | null;
@ApiProperty({ nullable: true })
classCode!: string | null;
@ApiProperty({ nullable: true })
instrumentType!: string | null;
@ApiProperty({ type: BrokerMoneyDto, nullable: true })
payment!: BrokerMoneyDto | null;
@ApiProperty({ type: BrokerMoneyDto, nullable: true })
price!: BrokerMoneyDto | null;
@ApiProperty({ type: BrokerMoneyDto, nullable: true })
commission!: BrokerMoneyDto | null;
@ApiProperty({ type: BrokerMoneyDto, nullable: true })
yield!: BrokerMoneyDto | null;
@ApiProperty({ type: BrokerMoneyDto, nullable: true })
accruedInt!: BrokerMoneyDto | null;
@ApiProperty({ nullable: true })
quantity!: number | null;
@ApiProperty({ nullable: true })
quantityDone!: number | null;
}
export class BrokerOperationsPageResponseDto {
@ApiProperty()
accountId!: string;
@ApiProperty({ type: [BrokerOperationResponseDto] })
items!: BrokerOperationResponseDto[];
@ApiProperty({ nullable: true })
nextCursor!: string | null;
@ApiProperty()
hasNext!: boolean;
@ApiProperty()
asOf!: string;
}

View File

@ -0,0 +1,17 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsISO8601 } from 'class-validator';
export class BrokerOperationSyncQueryDto {
@ApiProperty({ example: '2026-06-01T00:00:00.000Z' })
@IsISO8601()
from!: string;
@ApiProperty({ example: '2026-06-17T00:00:00.000Z' })
@IsISO8601()
to!: string;
}
export class BrokerOperationSyncResponseDto {
@ApiProperty({ example: 42 })
upserted!: number;
}

View File

@ -0,0 +1,110 @@
import { ApiProperty } from '@nestjs/swagger';
import { BrokerAccountResponseDto } from './broker-account-response.dto';
import { BrokerMoneyDto } from './broker-money.dto';
export class BrokerPositionResponseDto {
@ApiProperty({ nullable: true })
figi!: string | null;
@ApiProperty({ nullable: true })
instrumentUid!: string | null;
@ApiProperty({ nullable: true })
positionUid!: string | null;
@ApiProperty({ nullable: true })
ticker!: string | null;
@ApiProperty({ nullable: true })
classCode!: string | null;
@ApiProperty({ nullable: true })
instrumentType!: string | null;
@ApiProperty({ nullable: true })
name!: string | null;
@ApiProperty({ nullable: true })
quantity!: number | null;
@ApiProperty({ nullable: true })
blockedLots!: number | null;
@ApiProperty({ type: BrokerMoneyDto, nullable: true })
currentPrice!: BrokerMoneyDto | null;
@ApiProperty({ type: BrokerMoneyDto, nullable: true })
currentValue!: BrokerMoneyDto | null;
@ApiProperty({ type: BrokerMoneyDto, nullable: true })
averagePositionPrice!: BrokerMoneyDto | null;
@ApiProperty({ nullable: true })
expectedYieldPercent!: number | null;
@ApiProperty({ type: BrokerMoneyDto, nullable: true })
dailyYield!: BrokerMoneyDto | null;
}
export class BrokerPortfolioTotalsDto {
@ApiProperty({ type: BrokerMoneyDto, nullable: true })
shares!: BrokerMoneyDto | null;
@ApiProperty({ type: BrokerMoneyDto, nullable: true })
bonds!: BrokerMoneyDto | null;
@ApiProperty({ type: BrokerMoneyDto, nullable: true })
etf!: BrokerMoneyDto | null;
@ApiProperty({ type: BrokerMoneyDto, nullable: true })
currencies!: BrokerMoneyDto | null;
@ApiProperty({ type: BrokerMoneyDto, nullable: true })
futures!: BrokerMoneyDto | null;
@ApiProperty({ type: BrokerMoneyDto, nullable: true })
options!: BrokerMoneyDto | null;
@ApiProperty({ type: BrokerMoneyDto, nullable: true })
structuredProducts!: BrokerMoneyDto | null;
@ApiProperty({ type: BrokerMoneyDto, nullable: true })
dfa!: BrokerMoneyDto | null;
@ApiProperty({ type: BrokerMoneyDto, nullable: true })
portfolio!: BrokerMoneyDto | null;
}
export class BrokerPortfolioYieldsDto {
@ApiProperty({ nullable: true })
expectedPercent!: number | null;
@ApiProperty({ type: BrokerMoneyDto, nullable: true })
daily!: BrokerMoneyDto | null;
@ApiProperty({ nullable: true })
dailyPercent!: number | null;
}
export class BrokerPortfolioResponseDto {
@ApiProperty({ type: BrokerAccountResponseDto })
account!: BrokerAccountResponseDto;
@ApiProperty({ type: BrokerPortfolioTotalsDto })
totals!: BrokerPortfolioTotalsDto;
@ApiProperty({ type: BrokerPortfolioYieldsDto })
yields!: BrokerPortfolioYieldsDto;
@ApiProperty({ type: [BrokerMoneyDto] })
cash!: BrokerMoneyDto[];
@ApiProperty({ type: [BrokerMoneyDto] })
blockedCash!: BrokerMoneyDto[];
@ApiProperty({ type: [BrokerPositionResponseDto] })
positions!: BrokerPositionResponseDto[];
@ApiProperty()
asOf!: string;
}

View File

@ -0,0 +1,43 @@
import { isSupportedBrokerAccount, mapAccount } from './account.mapper';
import type { TBankAccount } from '../types/tbank-proto.types';
describe('account.mapper', () => {
const baseAccount: TBankAccount = {
id: '2000000001',
type: 'ACCOUNT_TYPE_TINKOFF',
name: 'Broker',
status: 'ACCOUNT_STATUS_OPEN',
openedDate: { seconds: '1781577000' },
accessLevel: 'ACCOUNT_ACCESS_LEVEL_FULL_ACCESS',
};
it('accepts open brokerage and IIS accounts', () => {
expect(isSupportedBrokerAccount(baseAccount)).toBe(true);
expect(isSupportedBrokerAccount({ ...baseAccount, type: 'ACCOUNT_TYPE_TINKOFF_IIS' })).toBe(
true,
);
});
it('rejects invest box, closed, and unspecified accounts', () => {
expect(isSupportedBrokerAccount({ ...baseAccount, type: 'ACCOUNT_TYPE_INVEST_BOX' })).toBe(
false,
);
expect(isSupportedBrokerAccount({ ...baseAccount, status: 'ACCOUNT_STATUS_CLOSED' })).toBe(
false,
);
expect(isSupportedBrokerAccount({ ...baseAccount, type: 'ACCOUNT_TYPE_UNSPECIFIED' })).toBe(
false,
);
});
it('maps T-Bank account to broker account DTO', () => {
expect(mapAccount(baseAccount)).toEqual({
id: '2000000001',
type: 'brokerage',
name: 'Broker',
status: 'ACCOUNT_STATUS_OPEN',
openedAt: '2026-06-16T02:30:00.000Z',
accessLevel: 'ACCOUNT_ACCESS_LEVEL_FULL_ACCESS',
});
});
});

View File

@ -0,0 +1,22 @@
import { TBANK_ACCOUNT_TYPES, TBANK_OPEN_ACCOUNT_STATUS } from '../tbank.config';
import type { BrokerAccount } from '../types/broker.types';
import type { TBankAccount } from '../types/tbank-proto.types';
import { mapTimestampToIso } from './money.mapper';
export function isSupportedBrokerAccount(account: TBankAccount): boolean {
return (
account.status === TBANK_OPEN_ACCOUNT_STATUS &&
(account.type === TBANK_ACCOUNT_TYPES.brokerage || account.type === TBANK_ACCOUNT_TYPES.iis)
);
}
export function mapAccount(account: TBankAccount): BrokerAccount {
return {
id: account.id,
type: account.type === TBANK_ACCOUNT_TYPES.iis ? 'iis' : 'brokerage',
name: account.name || account.id,
status: account.status,
openedAt: mapTimestampToIso(account.openedDate),
accessLevel: account.accessLevel ?? null,
};
}

View File

@ -0,0 +1,33 @@
import { mapMoneyValue, mapQuotationToNumber, mapTimestampToIso } from './money.mapper';
describe('money.mapper', () => {
it('maps positive MoneyValue with nano precision', () => {
expect(mapMoneyValue({ currency: 'rub', units: '123', nano: 450000000 })).toEqual({
currency: 'RUB',
units: '123',
nano: 450000000,
value: 123.45,
});
});
it('maps negative MoneyValue with negative nano', () => {
expect(mapMoneyValue({ currency: 'rub', units: '-5', nano: -250000000 })).toEqual({
currency: 'RUB',
units: '-5',
nano: -250000000,
value: -5.25,
});
});
it('returns null for absent MoneyValue', () => {
expect(mapMoneyValue(undefined)).toBeNull();
});
it('maps quotation to number', () => {
expect(mapQuotationToNumber({ units: '12', nano: 345000000 })).toBe(12.345);
});
it('maps unix timestamp seconds to ISO string', () => {
expect(mapTimestampToIso({ seconds: '1781577000', nanos: 0 })).toBe('2026-06-16T02:30:00.000Z');
});
});

View File

@ -0,0 +1,45 @@
import type { BrokerMoney } from '../types/broker.types';
import type { TBankMoneyValue, TBankQuotation, TBankTimestamp } from '../types/tbank-proto.types';
const NANO_FACTOR = 1_000_000_000;
export function mapMoneyValue(value: TBankMoneyValue | null | undefined): BrokerMoney | null {
if (!value) return null;
const units = String(value.units ?? '0');
const nano = value.nano ?? 0;
const numericUnits = Number(units);
const decimal = numericUnits + nano / NANO_FACTOR;
return {
currency: (value.currency || '').toUpperCase(),
units,
nano,
value: Number(decimal.toFixed(9)),
};
}
export function mapQuotationToNumber(value: TBankQuotation | null | undefined): number | null {
if (!value) return null;
const units = Number(value.units ?? 0);
const nano = value.nano ?? 0;
return Number((units + nano / NANO_FACTOR).toFixed(9));
}
export function mapTimestampToIso(value: TBankTimestamp | null | undefined): string | null {
if (!value?.seconds) return null;
const millis = Number(value.seconds) * 1000 + Math.floor((value.nanos ?? 0) / 1_000_000);
return new Date(millis).toISOString();
}
export function mapInteger(value: string | number | null | undefined): number | null {
if (value === null || value === undefined || value === '') return null;
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
}

View File

@ -0,0 +1,64 @@
import { categorizeOperationType, mapOperation, mapOperationsPage } from './operation.mapper';
describe('operation.mapper', () => {
it.each([
['OPERATION_TYPE_BUY', 'trade'],
['OPERATION_TYPE_SELL', 'trade'],
['OPERATION_TYPE_DIVIDEND', 'income'],
['OPERATION_TYPE_COUPON', 'income'],
['OPERATION_TYPE_TAX', 'tax'],
['OPERATION_TYPE_DIVIDEND_TAX', 'tax'],
['OPERATION_TYPE_BROKER_FEE', 'fee'],
['OPERATION_TYPE_SERVICE_FEE', 'fee'],
['OPERATION_TYPE_INPUT', 'transfer'],
['OPERATION_TYPE_OUTPUT', 'transfer'],
['OPERATION_TYPE_UNRECOGNIZED_NEW_VALUE', 'other'],
])('maps %s to %s', (type, category) => {
expect(categorizeOperationType(type)).toBe(category);
});
it('maps operation item with money and quantities', () => {
const result = mapOperation(
{
cursor: 'cursor-1',
brokerAccountId: 'acc-1',
id: 'op-1',
date: { seconds: '1781577000' },
type: 'OPERATION_TYPE_COUPON',
description: 'Coupon',
state: 'OPERATION_STATE_EXECUTED',
ticker: 'SU26238RMFS5',
classCode: 'TQOB',
payment: { currency: 'rub', units: '100', nano: 0 },
commission: { currency: 'rub', units: '0', nano: 0 },
quantity: '5',
quantityDone: '5',
},
'acc-1',
);
expect(result).toMatchObject({
cursor: 'cursor-1',
accountId: 'acc-1',
id: 'op-1',
category: 'income',
ticker: 'SU26238RMFS5',
quantity: 5,
quantityDone: 5,
payment: { currency: 'RUB', value: 100 },
});
});
it('maps operations page cursor metadata', () => {
const page = mapOperationsPage('acc-1', {
hasNext: true,
nextCursor: 'next',
items: [{ cursor: 'cursor-1', type: 'OPERATION_TYPE_BUY' }],
});
expect(page.accountId).toBe('acc-1');
expect(page.hasNext).toBe(true);
expect(page.nextCursor).toBe('next');
expect(page.items).toHaveLength(1);
});
});

View File

@ -0,0 +1,138 @@
import type {
BrokerOperation,
BrokerOperationCategory,
BrokerOperationsPage,
} from '../types/broker.types';
import type {
TBankOperationItem,
TBankOperationsByCursorResponse,
} from '../types/tbank-proto.types';
import { mapInteger, mapMoneyValue, mapTimestampToIso } from './money.mapper';
const TRADE_TYPES = new Set([
'OPERATION_TYPE_BUY',
'OPERATION_TYPE_BUY_CARD',
'OPERATION_TYPE_SELL',
'OPERATION_TYPE_SELL_CARD',
'OPERATION_TYPE_BUY_MARGIN',
'OPERATION_TYPE_SELL_MARGIN',
'OPERATION_TYPE_DELIVERY_BUY',
'OPERATION_TYPE_DELIVERY_SELL',
]);
const INCOME_TYPES = new Set([
'OPERATION_TYPE_DIVIDEND',
'OPERATION_TYPE_COUPON',
'OPERATION_TYPE_BOND_REPAYMENT',
'OPERATION_TYPE_BOND_REPAYMENT_FULL',
'OPERATION_TYPE_OVERNIGHT',
'OPERATION_TYPE_OVER_INCOME',
'OPERATION_TYPE_ACCRUING_VARMARGIN',
'OPERATION_TYPE_TAX_REPO_REFUND',
'OPERATION_TYPE_TAX_REPO_REFUND_PROGRESSIVE',
'OPERATION_TYPE_DIV_EXT',
'OPERATION_TYPE_DFA_REDEMPTION',
]);
const TAX_TYPES = new Set([
'OPERATION_TYPE_TAX',
'OPERATION_TYPE_BOND_TAX',
'OPERATION_TYPE_DIVIDEND_TAX',
'OPERATION_TYPE_TAX_CORRECTION',
'OPERATION_TYPE_BENEFIT_TAX',
'OPERATION_TYPE_TAX_PROGRESSIVE',
'OPERATION_TYPE_BOND_TAX_PROGRESSIVE',
'OPERATION_TYPE_DIVIDEND_TAX_PROGRESSIVE',
'OPERATION_TYPE_BENEFIT_TAX_PROGRESSIVE',
'OPERATION_TYPE_TAX_CORRECTION_PROGRESSIVE',
'OPERATION_TYPE_TAX_REPO',
'OPERATION_TYPE_TAX_REPO_PROGRESSIVE',
'OPERATION_TYPE_TAX_REPO_HOLD',
'OPERATION_TYPE_TAX_REPO_HOLD_PROGRESSIVE',
'OPERATION_TYPE_TAX_CORRECTION_COUPON',
]);
const FEE_TYPES = new Set([
'OPERATION_TYPE_SERVICE_FEE',
'OPERATION_TYPE_MARGIN_FEE',
'OPERATION_TYPE_BROKER_FEE',
'OPERATION_TYPE_SUCCESS_FEE',
'OPERATION_TYPE_TRACK_MFEE',
'OPERATION_TYPE_TRACK_PFEE',
'OPERATION_TYPE_CASH_FEE',
'OPERATION_TYPE_OUT_FEE',
'OPERATION_TYPE_OUT_STAMP_DUTY',
'OPERATION_TYPE_OUTPUT_PENALTY',
'OPERATION_TYPE_ADVICE_FEE',
'OPERATION_TYPE_OVER_COM',
'OPERATION_TYPE_OTHER_FEE',
'OPERATION_TYPE_FUNDING',
]);
const TRANSFER_TYPES = new Set([
'OPERATION_TYPE_INPUT',
'OPERATION_TYPE_OUTPUT',
'OPERATION_TYPE_INPUT_SECURITIES',
'OPERATION_TYPE_OUTPUT_SECURITIES',
'OPERATION_TYPE_OUTPUT_SWIFT',
'OPERATION_TYPE_INPUT_SWIFT',
'OPERATION_TYPE_OUTPUT_ACQUIRING',
'OPERATION_TYPE_INPUT_ACQUIRING',
'OPERATION_TYPE_TRANS_IIS_BS',
'OPERATION_TYPE_TRANS_BS_BS',
'OPERATION_TYPE_OUT_MULTI',
'OPERATION_TYPE_INP_MULTI',
'OPERATION_TYPE_OVER_PLACEMENT',
]);
export function categorizeOperationType(type: string | null | undefined): BrokerOperationCategory {
if (!type) return 'other';
if (TRADE_TYPES.has(type)) return 'trade';
if (INCOME_TYPES.has(type)) return 'income';
if (TAX_TYPES.has(type)) return 'tax';
if (FEE_TYPES.has(type)) return 'fee';
if (TRANSFER_TYPES.has(type)) return 'transfer';
return 'other';
}
export function mapOperation(item: TBankOperationItem, accountId: string): BrokerOperation {
const type = item.type || 'OPERATION_TYPE_UNSPECIFIED';
return {
cursor: item.cursor ?? null,
accountId: item.brokerAccountId || accountId,
id: item.id ?? null,
parentOperationId: item.parentOperationId ?? null,
date: mapTimestampToIso(item.date),
type,
category: categorizeOperationType(type),
description: item.description || item.name || null,
state: item.state ?? null,
instrumentUid: item.instrumentUid ?? null,
figi: item.figi ?? null,
ticker: item.ticker ?? null,
classCode: item.classCode ?? null,
instrumentType: item.instrumentType ?? null,
payment: mapMoneyValue(item.payment),
price: mapMoneyValue(item.price),
commission: mapMoneyValue(item.commission),
yield: mapMoneyValue(item.yield),
accruedInt: mapMoneyValue(item.accruedInt),
quantity: mapInteger(item.quantity),
quantityDone: mapInteger(item.quantityDone),
};
}
export function mapOperationsPage(
accountId: string,
response: TBankOperationsByCursorResponse,
): BrokerOperationsPage {
return {
accountId,
items: (response.items ?? []).map((item) => mapOperation(item, accountId)),
nextCursor: response.nextCursor || null,
hasNext: response.hasNext ?? false,
asOf: new Date().toISOString(),
};
}

View File

@ -0,0 +1,54 @@
import { mapBrokerPortfolio } from './portfolio.mapper';
import type { BrokerAccount } from '../types/broker.types';
describe('portfolio.mapper', () => {
const account: BrokerAccount = {
id: 'acc-1',
type: 'brokerage',
name: 'Broker',
status: 'ACCOUNT_STATUS_OPEN',
openedAt: null,
accessLevel: 'ACCOUNT_ACCESS_LEVEL_FULL_ACCESS',
};
it('combines portfolio totals, cash, and enriched positions', () => {
const result = mapBrokerPortfolio({
account,
portfolio: {
accountId: 'acc-1',
totalAmountShares: { currency: 'rub', units: '1000', nano: 0 },
totalAmountPortfolio: { currency: 'rub', units: '1500', nano: 0 },
expectedYield: { units: '10', nano: 500000000 },
positions: [
{
figi: 'BBG004730N88',
instrumentUid: 'uid-1',
ticker: 'SBER',
classCode: 'TQBR',
instrumentType: 'share',
quantity: { units: '10', nano: 0 },
currentPrice: { currency: 'rub', units: '250', nano: 0 },
averagePositionPrice: { currency: 'rub', units: '200', nano: 0 },
},
],
},
positions: {
money: [{ currency: 'rub', units: '500', nano: 0 }],
blocked: [{ currency: 'rub', units: '10', nano: 0 }],
securities: [],
},
instruments: new Map([['uid-1', { name: 'Sberbank', ticker: 'SBER' }]]),
});
expect(result.account.id).toBe('acc-1');
expect(result.totals.shares?.value).toBe(1000);
expect(result.cash[0].value).toBe(500);
expect(result.blockedCash[0].value).toBe(10);
expect(result.positions[0]).toMatchObject({
ticker: 'SBER',
name: 'Sberbank',
quantity: 10,
currentValue: { value: 2500 },
});
});
});

View File

@ -0,0 +1,84 @@
import type {
BrokerAccount,
BrokerMoney,
BrokerPortfolio,
BrokerPosition,
} from '../types/broker.types';
import type {
TBankInstrument,
TBankPortfolioResponse,
TBankPositionsResponse,
} from '../types/tbank-proto.types';
import { mapMoneyValue, mapQuotationToNumber } from './money.mapper';
type MapBrokerPortfolioInput = {
account: BrokerAccount;
portfolio: TBankPortfolioResponse;
positions: TBankPositionsResponse;
instruments: Map<string, Partial<TBankInstrument>>;
};
function isBrokerMoney(value: BrokerMoney | null): value is BrokerMoney {
return value !== null;
}
export function mapBrokerPortfolio(input: MapBrokerPortfolioInput): BrokerPortfolio {
const mappedPositions = (input.portfolio.positions ?? []).map<BrokerPosition>((position) => {
const quantity = mapQuotationToNumber(position.quantity);
const currentPrice = mapMoneyValue(position.currentPrice);
const currentValue =
currentPrice && quantity !== null
? {
...currentPrice,
units: String(Math.trunc(currentPrice.value * quantity)),
nano: 0,
value: Number((currentPrice.value * quantity).toFixed(9)),
}
: null;
const instrument =
(position.instrumentUid && input.instruments.get(position.instrumentUid)) ||
(position.positionUid && input.instruments.get(position.positionUid)) ||
undefined;
return {
figi: position.figi ?? null,
instrumentUid: position.instrumentUid ?? null,
positionUid: position.positionUid ?? null,
ticker: position.ticker || instrument?.ticker || null,
classCode: position.classCode || instrument?.classCode || null,
instrumentType: position.instrumentType || instrument?.instrumentType || null,
name: instrument?.name ?? null,
quantity,
blockedLots: mapQuotationToNumber(position.blockedLots),
currentPrice,
currentValue,
averagePositionPrice: mapMoneyValue(position.averagePositionPrice),
expectedYieldPercent: mapQuotationToNumber(position.expectedYield),
dailyYield: mapMoneyValue(position.dailyYield),
};
});
return {
account: input.account,
totals: {
shares: mapMoneyValue(input.portfolio.totalAmountShares),
bonds: mapMoneyValue(input.portfolio.totalAmountBonds),
etf: mapMoneyValue(input.portfolio.totalAmountEtf),
currencies: mapMoneyValue(input.portfolio.totalAmountCurrencies),
futures: mapMoneyValue(input.portfolio.totalAmountFutures),
options: mapMoneyValue(input.portfolio.totalAmountOptions),
structuredProducts: mapMoneyValue(input.portfolio.totalAmountSp),
dfa: mapMoneyValue(input.portfolio.totalAmountDfa),
portfolio: mapMoneyValue(input.portfolio.totalAmountPortfolio),
},
yields: {
expectedPercent: mapQuotationToNumber(input.portfolio.expectedYield),
daily: mapMoneyValue(input.portfolio.dailyYield),
dailyPercent: mapQuotationToNumber(input.portfolio.dailyYieldRelative),
},
cash: (input.positions.money ?? []).map(mapMoneyValue).filter(isBrokerMoney),
blockedCash: (input.positions.blocked ?? []).map(mapMoneyValue).filter(isBrokerMoney),
positions: mappedPositions,
asOf: new Date().toISOString(),
};
}

View File

@ -0,0 +1,144 @@
syntax = "proto3";
package tinkoff.public.invest.api.contract.v1;
option go_package = "./;investapi";
option java_package = "ru.tinkoff.piapi.contract.v1";
option java_multiple_files = true;
option csharp_namespace = "Tinkoff.InvestApi.V1";
option objc_class_prefix = "TIAPI";
option php_namespace = "Tinkoff\\Invest\\V1";
import "google/protobuf/timestamp.proto";
//Тип инструмента.
enum InstrumentType {
INSTRUMENT_TYPE_UNSPECIFIED = 0;
INSTRUMENT_TYPE_BOND = 1; //Облигация.
INSTRUMENT_TYPE_SHARE = 2; //Акция.
INSTRUMENT_TYPE_CURRENCY = 3; //Валюта.
INSTRUMENT_TYPE_ETF = 4; //Exchange-traded fund. Фонд.
INSTRUMENT_TYPE_FUTURES = 5; //Фьючерс.
INSTRUMENT_TYPE_SP = 6; //Структурная нота.
INSTRUMENT_TYPE_OPTION = 7; //Опцион.
INSTRUMENT_TYPE_CLEARING_CERTIFICATE = 8; //Clearing certificate.
INSTRUMENT_TYPE_INDEX = 9; //Индекс.
INSTRUMENT_TYPE_COMMODITY = 10; //Товар.
INSTRUMENT_TYPE_DFA = 11; //Цифровой актив.
}
//Статус запрашиваемых инструментов.
enum InstrumentStatus {
INSTRUMENT_STATUS_UNSPECIFIED = 0; //Значение не определено.
INSTRUMENT_STATUS_BASE = 1; //По умолчанию базовый список инструментов, которыми можно торговать через T-Invest API. Сейчас списки доступных бумаг в API и других интерфейсах совпадают кроме внебиржевых бумаг, но в будущем списки могут различаться.
INSTRUMENT_STATUS_ALL = 2; //Список всех инструментов.
}
//Денежная сумма в определенной валюте.
message MoneyValue {
// Строковый ISO-код валюты.
string currency = 1;
// Целая часть суммы, может быть отрицательным числом.
int64 units = 2;
// Дробная часть суммы, может быть отрицательным числом.
int32 nano = 3;
}
//Котировка денежная сумма без указания валюты.
message Quotation {
// Целая часть суммы, может быть отрицательным числом.
int64 units = 1;
// Дробная часть суммы, может быть отрицательным числом.
int32 nano = 2;
}
//Режим торгов инструмента
enum SecurityTradingStatus {
SECURITY_TRADING_STATUS_UNSPECIFIED = 0; //Торговый статус не определен.
SECURITY_TRADING_STATUS_NOT_AVAILABLE_FOR_TRADING = 1; //Недоступен для торгов.
SECURITY_TRADING_STATUS_OPENING_PERIOD = 2; //Период открытия торгов.
SECURITY_TRADING_STATUS_CLOSING_PERIOD = 3; //Период закрытия торгов.
SECURITY_TRADING_STATUS_BREAK_IN_TRADING = 4; //Перерыв в торговле.
SECURITY_TRADING_STATUS_NORMAL_TRADING = 5; //Нормальная торговля.
SECURITY_TRADING_STATUS_CLOSING_AUCTION = 6; //Аукцион закрытия.
SECURITY_TRADING_STATUS_DARK_POOL_AUCTION = 7; //Аукцион крупных пакетов.
SECURITY_TRADING_STATUS_DISCRETE_AUCTION = 8; //Дискретный аукцион.
SECURITY_TRADING_STATUS_OPENING_AUCTION_PERIOD = 9; //Аукцион открытия.
SECURITY_TRADING_STATUS_TRADING_AT_CLOSING_AUCTION_PRICE = 10; //Период торгов по цене аукциона закрытия.
SECURITY_TRADING_STATUS_SESSION_ASSIGNED = 11; //Сессия назначена.
SECURITY_TRADING_STATUS_SESSION_CLOSE = 12; //Сессия закрыта.
SECURITY_TRADING_STATUS_SESSION_OPEN = 13; //Сессия открыта.
SECURITY_TRADING_STATUS_DEALER_NORMAL_TRADING = 14; //Доступна торговля в режиме внутренней ликвидности брокера.
SECURITY_TRADING_STATUS_DEALER_BREAK_IN_TRADING = 15; //Перерыв торговли в режиме внутренней ликвидности брокера.
SECURITY_TRADING_STATUS_DEALER_NOT_AVAILABLE_FOR_TRADING = 16; //Недоступна торговля в режиме внутренней ликвидности брокера.
SECURITY_TRADING_STATUS_STABILIZATION_AUCTION = 17; //Аукцион обновления цен.
}
message PingRequest {
optional google.protobuf.Timestamp time = 1; //Время формирования запроса.
}
message PingDelaySettings {
optional int32 ping_delay_ms = 15; //Задержка (пинг) сообщений: 5000180 000 миллисекунд. Значение по умолчанию 120 000.
}
//Проверка активности стрима.
message Ping {
google.protobuf.Timestamp time = 1; //Время проверки.
string stream_id = 2; //Идентификатор соединения.
optional google.protobuf.Timestamp ping_request_time = 4; //Время формирования запроса.
}
//Тип цены.
enum PriceType {
PRICE_TYPE_UNSPECIFIED = 0; //Значение не определено.
PRICE_TYPE_POINT = 1; //Цена в пунктах (только для фьючерсов и облигаций).
PRICE_TYPE_CURRENCY = 2; //Цена в валюте расчетов по инструменту.
}
message Page {
int32 limit = 1; //Максимальное число возвращаемых записей.
int32 page_number = 2; //Порядковый номер страницы, начиная с 0.
}
message PageResponse {
int32 limit = 1; //Максимальное число возвращаемых записей.
int32 page_number = 2; //Порядковый номер страницы, начиная с 0.
int32 total_count = 3; //Общее количество записей.
}
message ResponseMetadata {
string tracking_id = 42; //Идентификатор трекинга.
google.protobuf.Timestamp server_time = 43; //Серверное время.
}
message BrandData {
string logo_name = 1; // Логотип инструмента. Имя файла для получения логотипа.
string logo_base_color = 2; // Цвет бренда.
string text_color = 3; // Цвет текста для цвета логотипа бренда.
}
enum ResultSubscriptionStatus {
RESULT_SUBSCRIPTION_STATUS_UNSPECIFIED = 0; //Статус подписки не определен.
RESULT_SUBSCRIPTION_STATUS_OK = 1; //Подписка успешно установлена.
RESULT_SUBSCRIPTION_STATUS_ERROR = 13; //Ошибка подписки
}
message ErrorDetail {
string code = 1; //Код ошибки.
string message = 3; //Описание ошибки.
}
//Реальная площадка исполнения расчетов.
enum RealExchange {
REAL_EXCHANGE_UNSPECIFIED = 0; //Тип не определен.
REAL_EXCHANGE_MOEX = 1; //Московская биржа.
REAL_EXCHANGE_RTS = 2; //Санкт-Петербургская биржа.
REAL_EXCHANGE_OTC = 3; //Внебиржевой инструмент.
REAL_EXCHANGE_DEALER = 4; //Инструмент, торгуемый на площадке брокера.
}

View File

@ -0,0 +1,104 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
syntax = "proto3";
package google.api;
import "google/protobuf/descriptor.proto";
option go_package = "google.golang.org/genproto/googleapis/api/annotations;annotations";
option java_multiple_files = true;
option java_outer_classname = "FieldBehaviorProto";
option java_package = "com.google.api";
option objc_class_prefix = "GAPI";
extend google.protobuf.FieldOptions {
// A designation of a specific field behavior (required, output only, etc.)
// in protobuf messages.
//
// Examples:
//
// string name = 1 [(google.api.field_behavior) = REQUIRED];
// State state = 1 [(google.api.field_behavior) = OUTPUT_ONLY];
// google.protobuf.Duration ttl = 1
// [(google.api.field_behavior) = INPUT_ONLY];
// google.protobuf.Timestamp expire_time = 1
// [(google.api.field_behavior) = OUTPUT_ONLY,
// (google.api.field_behavior) = IMMUTABLE];
repeated google.api.FieldBehavior field_behavior = 1052 [packed = false];
}
// An indicator of the behavior of a given field (for example, that a field
// is required in requests, or given as output but ignored as input).
// This **does not** change the behavior in protocol buffers itself; it only
// denotes the behavior and may affect how API tooling handles the field.
//
// Note: This enum **may** receive new values in the future.
enum FieldBehavior {
// Conventional default for enums. Do not use this.
FIELD_BEHAVIOR_UNSPECIFIED = 0;
// Specifically denotes a field as optional.
// While all fields in protocol buffers are optional, this may be specified
// for emphasis if appropriate.
OPTIONAL = 1;
// Denotes a field as required.
// This indicates that the field **must** be provided as part of the request,
// and failure to do so will cause an error (usually `INVALID_ARGUMENT`).
REQUIRED = 2;
// Denotes a field as output only.
// This indicates that the field is provided in responses, but including the
// field in a request does nothing (the server *must* ignore it and
// *must not* throw an error as a result of the field's presence).
OUTPUT_ONLY = 3;
// Denotes a field as input only.
// This indicates that the field is provided in requests, and the
// corresponding field is not included in output.
INPUT_ONLY = 4;
// Denotes a field as immutable.
// This indicates that the field may be set once in a request to create a
// resource, but may not be changed thereafter.
IMMUTABLE = 5;
// Denotes that a (repeated) field is an unordered list.
// This indicates that the service may provide the elements of the list
// in any arbitrary order, rather than the order the user originally
// provided. Additionally, the list's order may or may not be stable.
UNORDERED_LIST = 6;
// Denotes that this field returns a non-empty default value if not set.
// This indicates that if the user provides the empty value in a request,
// a non-empty value will be returned. The user will not be aware of what
// non-empty value to expect.
NON_EMPTY_DEFAULT = 7;
// Denotes that the field in a resource (a message annotated with
// google.api.resource) is used in the resource name to uniquely identify the
// resource. For AIP-compliant APIs, this should only be applied to the
// `name` field on the resource.
//
// This behavior should not be applied to references to other resources within
// the message.
//
// The identifier field of resources often have different field behavior
// depending on the request it is embedded in (e.g. for Create methods name
// is optional and unused, while for Update methods it is required). Instead
// of method-specific annotations, only `IDENTIFIER` is required.
IDENTIFIER = 8;
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,648 @@
syntax = "proto3";
package tinkoff.public.invest.api.contract.v1;
option go_package = "./;investapi";
option java_package = "ru.tinkoff.piapi.contract.v1";
option java_multiple_files = true;
option csharp_namespace = "Tinkoff.InvestApi.V1";
option objc_class_prefix = "TIAPI";
option php_namespace = "Tinkoff\\Invest\\V1";
import "google/protobuf/timestamp.proto";
import "common.proto";
import "google/api/field_behavior.proto";
service OperationsService {/*С помощью методов сервиса можно получить:<br/><br/> **1**. Список операций по счету.<br/> **2**.
Портфель по счету.<br/> **3**. Позиции ценных бумаг на счете.<br/> **4**.
Доступный остаток для вывода средств.<br/> **5**. Различные отчеты.*/
//Deprecated GetOperations список операций по счету
//При работе с методом учитывайте [особенности взаимодействия](/invest/services/operations/operations_problems).
rpc GetOperations(OperationsRequest) returns (OperationsResponse);
//GetPortfolio портфель по счету
rpc GetPortfolio(PortfolioRequest) returns (PortfolioResponse);
//GetPositions список позиций по счету
rpc GetPositions(PositionsRequest) returns (PositionsResponse);
//GetWithdrawLimits доступный остаток для вывода средств
rpc GetWithdrawLimits(WithdrawLimitsRequest) returns (WithdrawLimitsResponse);
//GetBrokerReport брокерский отчет.
rpc GetBrokerReport(BrokerReportRequest) returns (BrokerReportResponse);
//GetDividendsForeignIssuer отчет «Справка о доходах за пределами РФ»
rpc GetDividendsForeignIssuer(GetDividendsForeignIssuerRequest) returns (GetDividendsForeignIssuerResponse);
//GetOperationsByCursor список операций по счету с пагинацией
//При работе с методом учитывайте [особенности взаимодействия](/invest/services/operations/operations_problems).
rpc GetOperationsByCursor(GetOperationsByCursorRequest) returns (GetOperationsByCursorResponse);
}
service OperationsStreamService {
//PortfolioStream стрим обновлений портфеля
rpc PortfolioStream(PortfolioStreamRequest) returns (stream PortfolioStreamResponse);
//PositionsStream стрим обновлений информации по изменению позиций портфеля
rpc PositionsStream(PositionsStreamRequest) returns (stream PositionsStreamResponse);
//OperationsStream стрим обновлений операций
rpc OperationsStream(OperationsStreamRequest) returns (stream OperationsStreamResponse);
}
//Запрос получения списка операций по счету.
message OperationsRequest {
string account_id = 1 [(google.api.field_behavior) = REQUIRED]; //Идентификатор счета клиента.
optional google.protobuf.Timestamp from = 2; //Начало периода по UTC.
optional google.protobuf.Timestamp to = 3; //Окончание периода по UTC.
optional OperationState state = 4; //Статус запрашиваемых операций.
optional string figi = 5; //FIGI-идентификатор инструмента для фильтрации.
}
//Список операций.
message OperationsResponse {
repeated Operation operations = 1; //Массив операций.
}
//Данные по операции.
message Operation {
string id = 1; //Идентификатор операции.
string parent_operation_id = 2; //Идентификатор родительской операции.
string currency = 3; //Валюта операции.
MoneyValue payment = 4; //Сумма операции.
MoneyValue price = 5; //Цена операции за 1 инструмент. Чтобы получить стоимость лота, нужно умножить на лотность инструмента.
OperationState state = 6; //Статус операции.
int64 quantity = 7; //Количество единиц инструмента.
int64 quantity_rest = 8; //Неисполненный остаток по сделке.
string figi = 9; //FIGI-идентификатор инструмента, связанного с операцией.
string instrument_type = 10; //Тип инструмента. Возможные значения: <br/><br/>`bond` облигация; <br/>`share` акция; <br/>`currency` валюта; <br/>`etf` фонд; <br/>`futures` фьючерс.
google.protobuf.Timestamp date = 11; //Дата и время операции в формате часовом поясе UTC.
string type = 12; //Текстовое описание типа операции.
OperationType operation_type = 13; //Тип операции.
repeated OperationTrade trades = 14; //Массив сделок.
string asset_uid = 16; //Идентификатор актива
string position_uid = 17; //Уникальный идентификатор позиции.
string instrument_uid = 18; //Уникальный идентификатор инструмента.
repeated ChildOperationItem child_operations = 19; //Массив дочерних операций.
}
//Сделка по операции.
message OperationTrade {
string trade_id = 1; //Идентификатор сделки.
google.protobuf.Timestamp date_time = 2; //Дата и время сделки по UTC.
int64 quantity = 3; //Количество инструментов.
MoneyValue price = 4; //Цена за 1 инструмент. Чтобы получить стоимость лота, нужно умножить на лотность инструмента.
}
//Запрос получения текущего портфеля по счету.
message PortfolioRequest {
string account_id = 1 [(google.api.field_behavior) = REQUIRED]; //Идентификатор счета пользователя.
optional CurrencyRequest currency = 2; //Валюта, в которой нужно рассчитать портфель.
enum CurrencyRequest {
RUB = 0; //Рубли
USD = 1; //Доллары
EUR = 2; //Евро
}
}
//Текущий портфель по счету.
message PortfolioResponse {
MoneyValue total_amount_shares = 1; //Общая стоимость акций в портфеле.
MoneyValue total_amount_bonds = 2; //Общая стоимость облигаций в портфеле.
MoneyValue total_amount_etf = 3; //Общая стоимость фондов в портфеле.
MoneyValue total_amount_currencies = 4; //Общая стоимость валют в портфеле.
MoneyValue total_amount_futures = 5; //Общая стоимость фьючерсов в портфеле.
Quotation expected_yield = 6; //Текущая относительная доходность портфеля в %.
repeated PortfolioPosition positions = 7; //Список позиций портфеля.
string account_id = 8; //Идентификатор счета пользователя.
MoneyValue total_amount_options = 9; //Общая стоимость опционов в портфеле.
MoneyValue total_amount_sp = 10; //Общая стоимость структурных нот в портфеле.
MoneyValue total_amount_portfolio = 11; //Общая стоимость портфеля.
repeated VirtualPortfolioPosition virtual_positions = 12; //Массив виртуальных позиций портфеля.
MoneyValue daily_yield = 15; // Рассчитанная доходность портфеля за день в рублях.
Quotation daily_yield_relative = 16; //Относительная доходность в день в %.
MoneyValue total_amount_dfa = 17; //Общая стоимость смарт-активов в портфеле в рублях.
}
//Запрос позиций портфеля по счету.
message PositionsRequest {
string account_id = 1 [(google.api.field_behavior) = REQUIRED]; //Идентификатор счета пользователя.
}
//Список позиций по счету.
message PositionsResponse {
repeated MoneyValue money = 1; //Массив валютных позиций портфеля.
repeated MoneyValue blocked = 2; //Массив заблокированных валютных позиций портфеля.
repeated PositionsSecurities securities = 3; //Список ценно-бумажных позиций портфеля.
bool limits_loading_in_progress = 4; //Признак идущей выгрузки лимитов в данный момент.
repeated PositionsFutures futures = 5; //Список фьючерсов портфеля.
repeated PositionsOptions options = 6; //Список опционов портфеля.
string account_id = 15; //Идентификатор счёта пользователя.
}
//Запрос доступного остатка для вывода.
message WithdrawLimitsRequest {
string account_id = 1 [(google.api.field_behavior) = REQUIRED]; //Идентификатор счета пользователя.
}
//Доступный остаток для вывода.
message WithdrawLimitsResponse {
repeated MoneyValue money = 1; //Массив валютных позиций портфеля.
repeated MoneyValue blocked = 2; //Массив заблокированных валютных позиций портфеля.
repeated MoneyValue blocked_guarantee = 3; //Заблокировано под гарантийное обеспечение фьючерсов.
}
//Позиции портфеля.
message PortfolioPosition {
string figi = 1; //FIGI-идентификатор инструмента.
string instrument_type = 2; //Тип инструмента.
Quotation quantity = 3; //Количество инструмента в портфеле в штуках.
MoneyValue average_position_price = 4; //Средневзвешенная цена позиции. Для пересчета возможна задержка до одной секунды.
Quotation expected_yield = 5; //Текущая рассчитанная доходность позиции.
MoneyValue current_nkd = 6; // Текущий НКД.
Quotation average_position_price_pt = 7 [ deprecated = true ]; // Deprecated Средняя цена позиции в пунктах (для фьючерсов). Для пересчета возможна задержка до одной секунды.
MoneyValue current_price = 8; //Текущая цена за 1 инструмент. Чтобы получить стоимость лота, нужно умножить на лотность инструмента.
MoneyValue average_position_price_fifo = 9; //Средняя цена позиции по методу FIFO. Для пересчета возможна задержка до одной секунды.
Quotation quantity_lots = 10 [ deprecated = true ]; //Deprecated Количество лотов в портфеле.
bool blocked = 21; //Заблокировано на бирже.
Quotation blocked_lots = 22; //Количество бумаг, заблокированных выставленными заявками.
string position_uid = 24; //Уникальный идентификатор позиции.
string instrument_uid = 25; //Уникальный идентификатор инструмента.
MoneyValue var_margin = 26; //Вариационная маржа.
Quotation expected_yield_fifo = 27; //Текущая рассчитанная доходность позиции.
MoneyValue daily_yield = 31; // Рассчитанная доходность портфеля за день.
string ticker = 32; //Тикер инструмента.
string class_code = 33; //Класс-код (секция торгов).
optional MoneyValue var_margin_settled = 34; //Вариационная маржа (расчетная).
}
message VirtualPortfolioPosition {
string position_uid = 1; //Уникальный идентификатор позиции.
string instrument_uid = 2; //Уникальный идентификатор инструмента.
string figi = 3; //FIGI-идентификатор инструмента.
string instrument_type = 4; //Тип инструмента.
Quotation quantity = 5; //Количество инструмента в портфеле в штуках.
MoneyValue average_position_price = 6; //Средневзвешенная цена позиции. Для пересчета возможна задержка до одной секунды.
Quotation expected_yield = 7; //Текущая рассчитанная доходность позиции.
Quotation expected_yield_fifo = 8; //Текущая рассчитанная доходность позиции.
google.protobuf.Timestamp expire_date = 9; //Дата, до которой нужно продать виртуальные бумаги. После этой даты виртуальная позиция «сгораетт».
MoneyValue current_price = 10; //Текущая цена за 1 инструмент. Чтобы получить стоимость лота, нужно умножить на лотность инструмента.
MoneyValue average_position_price_fifo = 11; //Средняя цена позиции по методу FIFO. Для пересчета возможна задержка до одной секунды.
MoneyValue daily_yield = 31; // Рассчитанная доходность портфеля за день.
string ticker = 32; //Тикер инструмента.
string class_code = 33; //Класс-код (секция торгов).
}
//Баланс позиции ценной бумаги.
message PositionsSecurities {
string figi = 1; //FIGI-идентификатор бумаги.
int64 blocked = 2; //Количество бумаг, заблокированных выставленными заявками.
int64 balance = 3; //Текущий незаблокированный баланс.
string position_uid = 4; //Уникальный идентификатор позиции.
string instrument_uid = 5; //Уникальный идентификатор инструмента.
string ticker = 6; //Тикер инструмента.
string class_code = 7; //Класс-код (секция торгов).
bool exchange_blocked = 11; //Заблокировано на бирже.
string instrument_type = 16; //Тип инструмента.
}
//Баланс фьючерса.
message PositionsFutures {
string figi = 1; //FIGI-идентификатор фьючерса.
int64 blocked = 2; //Количество бумаг, заблокированных выставленными заявками.
int64 balance = 3; //Текущий незаблокированный баланс.
string position_uid = 4; //Уникальный идентификатор позиции.
string instrument_uid = 5; //Уникальный идентификатор инструмента.
string ticker = 6; //Тикер инструмента.
string class_code = 7; //Класс-код (секция торгов).
}
//Баланс опциона.
message PositionsOptions {
string position_uid = 1; //Уникальный идентификатор позиции опциона.
string instrument_uid = 2; //Уникальный идентификатор инструмента.
string ticker = 3; //Тикер инструмента.
string class_code = 4; //Класс-код (секция торгов).
int64 blocked = 11; //Количество бумаг, заблокированных выставленными заявками.
int64 balance = 21; //Текущий незаблокированный баланс.
}
message BrokerReportRequest {
oneof payload {
GenerateBrokerReportRequest generate_broker_report_request = 1;
GetBrokerReportRequest get_broker_report_request = 2;
}
}
message BrokerReportResponse {
oneof payload {
GenerateBrokerReportResponse generate_broker_report_response = 1;
GetBrokerReportResponse get_broker_report_response = 2;
}
}
message GenerateBrokerReportRequest {
string account_id = 1 [(google.api.field_behavior) = REQUIRED]; //Идентификатор счета клиента.
google.protobuf.Timestamp from = 2 [(google.api.field_behavior) = REQUIRED]; //Начало периода по UTC.
google.protobuf.Timestamp to = 3 [(google.api.field_behavior) = REQUIRED]; //Окончание периода по UTC.
}
message GenerateBrokerReportResponse {
string task_id = 1; //Идентификатор задачи формирования брокерского отчета.
}
message GetBrokerReportRequest {
string task_id = 1 [(google.api.field_behavior) = REQUIRED]; //Идентификатор задачи формирования брокерского отчета.
optional int32 page = 2; //Номер страницы отчета, начинается с 1. Значение по умолчанию 0.
}
message GetBrokerReportResponse {
repeated BrokerReport broker_report = 1;
int32 itemsCount = 2; //Количество записей в отчете.
int32 pagesCount = 3; //Количество страниц с данными отчета, начинается с 0.
int32 page = 4; //Текущая страница, начинается с 0.
string task_id = 5; //Идентификатор задачи формирования брокерского отчета.
}
message BrokerReport {
string trade_id = 1;//Номер сделки.
string order_id = 2; //Номер поручения.
string figi = 3; //FIGI-идентификаторинструмента.
string execute_sign = 4; //Признак исполнения.
google.protobuf.Timestamp trade_datetime = 5; //Дата и время заключения по UTC.
string exchange = 6; //Торговая площадка.
string class_code = 7; //Режим торгов.
string direction = 8; //Вид сделки.
string name = 9; //Сокращенное наименование актива.
string ticker = 10; //Код актива.
MoneyValue price = 11; //Цена за единицу.
int64 quantity = 12; //Количество.
MoneyValue order_amount = 13; //Сумма без НКД.
Quotation aci_value = 14; //НКД.
MoneyValue total_order_amount = 15; //Сумма сделки.
MoneyValue broker_commission = 16; //Комиссия брокера.
MoneyValue exchange_commission = 17; //Комиссия биржи.
MoneyValue exchange_clearing_commission = 18; //Комиссия клирингового центра.
Quotation repo_rate = 19; //Ставка РЕПО, %.
string party = 20; //Контрагент или брокерарокер.
google.protobuf.Timestamp clear_value_date = 21; //Дата расчетов по UTC.
google.protobuf.Timestamp sec_value_date = 22; //Дата поставки по UTC.
string broker_status = 23; //Статус брокера.
string separate_agreement_type = 24; //Тип договора.
string separate_agreement_number = 25; //Номер договора.
string separate_agreement_date = 26; //Дата договора.
string delivery_type = 27; //Тип расчета по сделке.
}
//Статус запрашиваемых операций.
enum OperationState {
OPERATION_STATE_UNSPECIFIED = 0; //Статус операции не определен.
OPERATION_STATE_EXECUTED = 1; //Исполнена частично или полностью.
OPERATION_STATE_CANCELED = 2; //Отменена.
OPERATION_STATE_PROGRESS = 3; //Исполняется.
}
//Тип операции.
enum OperationType {
OPERATION_TYPE_UNSPECIFIED = 0; //Тип операции не определен.
OPERATION_TYPE_INPUT = 1; //Пополнение брокерского счета.
OPERATION_TYPE_BOND_TAX = 2; //Удержание НДФЛ по купонам.
OPERATION_TYPE_OUTPUT_SECURITIES = 3; //Вывод ЦБ.
OPERATION_TYPE_OVERNIGHT = 4; //Доход по сделке РЕПО овернайт.
OPERATION_TYPE_TAX = 5; //Удержание налога.
OPERATION_TYPE_BOND_REPAYMENT_FULL = 6; //Полное погашение облигаций.
OPERATION_TYPE_SELL_CARD = 7; //Продажа ЦБ с карты.
OPERATION_TYPE_DIVIDEND_TAX = 8; //Удержание налога по дивидендам.
OPERATION_TYPE_OUTPUT = 9; //Вывод денежных средств.
OPERATION_TYPE_BOND_REPAYMENT = 10; //Частичное погашение облигаций.
OPERATION_TYPE_TAX_CORRECTION = 11; //Корректировка налога.
OPERATION_TYPE_SERVICE_FEE = 12; //Удержание комиссии за обслуживание брокерского счета.
OPERATION_TYPE_BENEFIT_TAX = 13; //Удержание налога за материальную выгоду.
OPERATION_TYPE_MARGIN_FEE = 14; //Удержание комиссии за непокрытую позицию.
OPERATION_TYPE_BUY = 15; //Покупка ЦБ.
OPERATION_TYPE_BUY_CARD = 16; //Покупка ЦБ с карты.
OPERATION_TYPE_INPUT_SECURITIES = 17; //Перевод ценных бумаг из другого депозитария.
OPERATION_TYPE_SELL_MARGIN = 18; //Продажа в результате Margin-call.
OPERATION_TYPE_BROKER_FEE = 19; //Удержание комиссии за операцию.
OPERATION_TYPE_BUY_MARGIN = 20; //Покупка в результате Margin-call.
OPERATION_TYPE_DIVIDEND = 21; //Выплата дивидендов.
OPERATION_TYPE_SELL = 22; //Продажа ЦБ.
OPERATION_TYPE_COUPON = 23; //Выплата купонов.
OPERATION_TYPE_SUCCESS_FEE = 24; //Удержание комиссии SuccessFee.
OPERATION_TYPE_DIVIDEND_TRANSFER = 25; //Передача дивидендного дохода.
OPERATION_TYPE_ACCRUING_VARMARGIN = 26; //Зачисление вариационной маржи.
OPERATION_TYPE_WRITING_OFF_VARMARGIN = 27; //Списание вариационной маржи.
OPERATION_TYPE_DELIVERY_BUY = 28; //Покупка в рамках экспирации фьючерсного контракта.
OPERATION_TYPE_DELIVERY_SELL = 29; //Продажа в рамках экспирации фьючерсного контракта.
OPERATION_TYPE_TRACK_MFEE = 30; //Комиссия за управление по счету автоследования.
OPERATION_TYPE_TRACK_PFEE = 31; //Комиссия за результат по счету автоследования.
OPERATION_TYPE_TAX_PROGRESSIVE = 32; //Удержание налога по ставке 15%.
OPERATION_TYPE_BOND_TAX_PROGRESSIVE = 33; //Удержание налога по купонам по ставке 15%.
OPERATION_TYPE_DIVIDEND_TAX_PROGRESSIVE = 34; //Удержание налога по дивидендам по ставке 15%.
OPERATION_TYPE_BENEFIT_TAX_PROGRESSIVE = 35; //Удержание налога за материальную выгоду по ставке 15%.
OPERATION_TYPE_TAX_CORRECTION_PROGRESSIVE = 36; //Корректировка налога по ставке 15%.
OPERATION_TYPE_TAX_REPO_PROGRESSIVE = 37; //Удержание налога за возмещение по сделкам РЕПО по ставке 15%.
OPERATION_TYPE_TAX_REPO = 38; //Удержание налога за возмещение по сделкам РЕПО.
OPERATION_TYPE_TAX_REPO_HOLD = 39; //Удержание налога по сделкам РЕПО.
OPERATION_TYPE_TAX_REPO_REFUND = 40; //Возврат налога по сделкам РЕПО.
OPERATION_TYPE_TAX_REPO_HOLD_PROGRESSIVE = 41; //Удержание налога по сделкам РЕПО по ставке 15%.
OPERATION_TYPE_TAX_REPO_REFUND_PROGRESSIVE = 42; //Возврат налога по сделкам РЕПО по ставке 15%.
OPERATION_TYPE_DIV_EXT = 43; //Выплата дивидендов на карту.
OPERATION_TYPE_TAX_CORRECTION_COUPON = 44; //Корректировка налога по купонам.
OPERATION_TYPE_CASH_FEE = 45; //Комиссия за валютный остаток.
OPERATION_TYPE_OUT_FEE = 46; //Комиссия за вывод валюты с брокерского счета.
OPERATION_TYPE_OUT_STAMP_DUTY = 47; //Гербовый сбор.
OPERATION_TYPE_OUTPUT_SWIFT = 50; // SWIFT-перевод.
OPERATION_TYPE_INPUT_SWIFT = 51; // SWIFT-перевод.
OPERATION_TYPE_OUTPUT_ACQUIRING = 53; // Перевод на карту.
OPERATION_TYPE_INPUT_ACQUIRING = 54; // Перевод с карты.
OPERATION_TYPE_OUTPUT_PENALTY = 55; // Комиссия за вывод средств.
OPERATION_TYPE_ADVICE_FEE = 56; // Списание оплаты за сервис Советов.
OPERATION_TYPE_TRANS_IIS_BS = 57; // Перевод ценных бумаг с ИИС на брокерский счет.
OPERATION_TYPE_TRANS_BS_BS = 58; // Перевод ценных бумаг с одного брокерского счета на другой.
OPERATION_TYPE_OUT_MULTI = 59; // Вывод денежных средств со счета.
OPERATION_TYPE_INP_MULTI = 60; // Пополнение денежных средств со счета.
OPERATION_TYPE_OVER_PLACEMENT = 61; // Размещение биржевого овернайта.
OPERATION_TYPE_OVER_COM = 62; // Списание комиссии.
OPERATION_TYPE_OVER_INCOME = 63; // Доход от оверанайта.
OPERATION_TYPE_OPTION_EXPIRATION = 64; // Экспирация опциона.
OPERATION_TYPE_FUTURE_EXPIRATION = 65; // Экспирация фьючерса.
OPERATION_TYPE_OTHER_FEE = 66; // Прочие комиссии;
OPERATION_TYPE_OTHER = 67; // Операция по счету;
OPERATION_TYPE_DFA_REDEMPTION = 68; // погашение ЦФА-токена;
OPERATION_TYPE_PRIMARY_ORDER =69; // отмена заявки на первичное размещение по ЦФА;
OPERATION_TYPE_FUNDING = 70; //Списание фандинга.
}
message GetDividendsForeignIssuerRequest {
oneof payload {
GenerateDividendsForeignIssuerReportRequest generate_div_foreign_issuer_report = 1; //Объект запроса формирования отчета.
GetDividendsForeignIssuerReportRequest get_div_foreign_issuer_report = 2; //Объект запроса сформированного отчета.
}
}
message GetDividendsForeignIssuerResponse {
oneof payload {
GenerateDividendsForeignIssuerReportResponse generate_div_foreign_issuer_report_response = 1; //Объект результата задачи запуска формирования отчета.
GetDividendsForeignIssuerReportResponse div_foreign_issuer_report = 2; //Отчет «Справка о доходах за пределами РФ».
}
}
//Объект запроса формирования отчета «Справка о доходах за пределами РФ».
message GenerateDividendsForeignIssuerReportRequest {
string account_id = 1 [(google.api.field_behavior) = REQUIRED]; //Идентификатор счета клиента.
google.protobuf.Timestamp from = 2 [(google.api.field_behavior) = REQUIRED]; //Начало периода по UTC.
google.protobuf.Timestamp to = 3 [(google.api.field_behavior) = REQUIRED]; //Окончание периода по UTC. Как правило, можно сформировать отчет по дату на несколько дней меньше текущей. Начало и окончание периода должны быть в рамках одного календарного года.
}
// Объект запроса сформированного отчета «Справка о доходах за пределами РФ».
message GetDividendsForeignIssuerReportRequest {
string task_id = 1 [(google.api.field_behavior) = REQUIRED]; //Идентификатор задачи формирования отчета.
optional int32 page = 2; //Номер страницы отчета (начинается с 0), значение по умолчанию: 0.
}
// Объект результата задачи запуска формирования отчета «Справка о доходах за пределами РФ».
message GenerateDividendsForeignIssuerReportResponse {
string task_id = 1; //Идентификатор задачи формирования отчета.
}
message GetDividendsForeignIssuerReportResponse {
repeated DividendsForeignIssuerReport dividends_foreign_issuer_report = 1;
int32 itemsCount = 2; //Количество записей в отчете.
int32 pagesCount = 3; //Количество страниц с данными отчета, начинается с 0.
int32 page = 4; //Текущая страница, начинается с 0.
}
// Отчет «Справка о доходах за пределами РФ».
message DividendsForeignIssuerReport {
google.protobuf.Timestamp record_date = 1; //Дата фиксации реестра.
google.protobuf.Timestamp payment_date = 2; //Дата выплаты.
string security_name = 3; //Наименование ценной бумаги.
string isin = 4; //ISIN-идентификатор ценной бумаги.
string issuer_country = 5; //Страна эмитента. Для депозитарных расписок указывается страна эмитента базового актива.
int64 quantity = 6; //Количество ценных бумаг.
Quotation dividend = 7; //Выплаты на одну бумагу
Quotation external_commission = 8; //Комиссия внешних платежных агентов.
Quotation dividend_gross = 9; //Сумма до удержания налога.
Quotation tax = 10; //Сумма налога, удержанного агентом.
Quotation dividend_amount = 11; //Итоговая сумма выплаты.
string currency = 12; //Валюта.
}
//Запрос установки stream-соединения.
message PortfolioStreamRequest {
repeated string accounts = 1; //Массив идентификаторов счетов пользователя.
PingDelaySettings ping_settings = 15; //Запрос настройки пинга.
}
//Информация по позициям и доходностям портфелей.
message PortfolioStreamResponse {
oneof payload {
PortfolioSubscriptionResult subscriptions = 1; //Объект результата подписки.
PortfolioResponse portfolio = 2; //Объект стриминга портфеля.
Ping ping = 3; //Проверка активности стрима.
}
}
//Объект результата подписки.
message PortfolioSubscriptionResult {
repeated AccountSubscriptionStatus accounts = 1; //Массив счетов клиента.
string tracking_id = 7; //Уникальный идентификатор запроса, подробнее: [tracking_id](/invest/intro/developer/protocols/grpc#tracking-id).
string stream_id = 8; //Идентификатор открытого соединения
}
//Счет клиента.
message AccountSubscriptionStatus {
string account_id = 1; //Идентификатор счета.
PortfolioSubscriptionStatus subscription_status = 6; //Результат подписки.
}
//Результат подписки.
enum PortfolioSubscriptionStatus {
PORTFOLIO_SUBSCRIPTION_STATUS_UNSPECIFIED = 0; //Тип не определен.
PORTFOLIO_SUBSCRIPTION_STATUS_SUCCESS = 1; //Успешно.
PORTFOLIO_SUBSCRIPTION_STATUS_ACCOUNT_NOT_FOUND = 2; //Счет не найден или недостаточно прав.
PORTFOLIO_SUBSCRIPTION_STATUS_INTERNAL_ERROR = 3; //Произошла ошибка.
}
//Запрос списка операций по счету с пагинацией.
message GetOperationsByCursorRequest {
string account_id = 1 [(google.api.field_behavior) = REQUIRED]; //Идентификатор счета клиента, обязательный параметр. Остальные параметры опциональны.
optional string instrument_id = 2; //Идентификатор инструмента. Принимает значение `figi`, `instrument_uid` или `ticker + '_' + class_code`.
optional google.protobuf.Timestamp from = 6; //Начало периода по UTC.
optional google.protobuf.Timestamp to = 7; //Окончание периода по UTC.
optional string cursor = 11; //Идентификатор элемента, с которого начать формировать ответ.
optional int32 limit = 12; //Лимит количества операций. По умолчанию `100`, максимальное значение `1000`.
repeated OperationType operation_types = 13; //Тип операции. Принимает значение из списка `OperationType`.
optional OperationState state = 14; //Статус запрашиваемых операций. Возможные значения указаны в `OperationState`.
optional bool without_commissions = 15; //Флаг возврата комиссии. По умолчанию `false`.
optional bool without_trades = 16; //Флаг получения ответа без массива сделок.
optional bool without_overnights = 17; //Флаг показа overnight операций.
}
//Список операций по счету с пагинацией.
message GetOperationsByCursorResponse {
bool has_next = 1; //Признак, есть ли следующий элемент.
string next_cursor = 2; //Следующий курсор.
repeated OperationItem items = 6; //Список операций.
}
//Данные об операции.
message OperationItem {
string cursor = 1; //Курсор.
string broker_account_id = 6; //Номер счета клиента.
string id = 16; //Идентификатор операции, может меняться с течением времени.
string parent_operation_id = 17; //Идентификатор родительской операции. Может измениться, если изменился ID родительской операции.
string name = 18; //Название операции.
google.protobuf.Timestamp date = 21; //Дата поручения.
OperationType type = 22; //Тип операции.
string description = 23; //Описание операции.
OperationState state = 24; //Статус поручения.
string instrument_uid = 31; //Уникальный идентификатор инструмента.
string figi = 32; //FIGI.
string instrument_type = 33; //Тип инструмента.
InstrumentType instrument_kind = 34; //Тип инструмента.
string position_uid = 35; //Уникальный идентификатор позиции.
string ticker = 36; //Тикер инструмента.
string class_code = 37; //Класс-код (секция торгов).
MoneyValue payment = 41; //Сумма операции.
MoneyValue price = 42; //Цена операции за 1 инструмент.
MoneyValue commission = 43; //Комиссия.
MoneyValue yield = 44; //Доходность.
Quotation yield_relative = 45; //Относительная доходность.
MoneyValue accrued_int = 46; //Накопленный купонный доход.
int64 quantity = 51; //Количество единиц инструмента.
int64 quantity_rest = 52; //Неисполненный остаток по сделке.
int64 quantity_done = 53; //Исполненный остаток.
google.protobuf.Timestamp cancel_date_time = 56; //Дата и время снятия заявки.
string cancel_reason = 57; //Причина отмены операции.
OperationItemTrades trades_info = 61; //Массив сделок.
string asset_uid = 64; //Идентификатор актива.
repeated ChildOperationItem child_operations = 65; //Массив дочерних операций.
}
//Массив с информацией о сделках.
message OperationItemTrades {
repeated OperationItemTrade trades = 6;
}
//Сделка по операции.
message OperationItemTrade {
string num = 1; //Номер сделки.
google.protobuf.Timestamp date = 6; //Дата сделки.
int64 quantity = 11; //Количество в единицах.
MoneyValue price = 16; //Цена.
MoneyValue yield = 21; //Доходность.
Quotation yield_relative = 22; //Относительная доходность.
}
//Запрос установки stream-соединения позиций.
message PositionsStreamRequest {
repeated string accounts = 1; //Массив идентификаторов счетов пользователя.
bool with_initial_positions = 3; //Получение состояния позиций на момент подключения.
PingDelaySettings ping_settings = 15; //Запрос настройки пинга.
}
//Информация по изменению позиций портфеля.
message PositionsStreamResponse {
oneof payload {
PositionsSubscriptionResult subscriptions = 1; //Объект результата подписки.
PositionData position = 2; //Объект стриминга позиций.
Ping ping = 3; //Проверка активности стрима.
PositionsResponse initial_positions = 5; //Текущие позиции.
}
}
//Объект результата подписки.
message PositionsSubscriptionResult {
repeated PositionsSubscriptionStatus accounts = 1; //Массив счетов клиента.
string tracking_id = 7; //Уникальный идентификатор запроса, подробнее: [tracking_id](/invest/intro/developer/protocols/grpc#tracking-id).
string stream_id = 8; //Идентификатор открытого соединения
}
//Счет клиента.
message PositionsSubscriptionStatus {
string account_id = 1; //Идентификатор счета.
PositionsAccountSubscriptionStatus subscription_status = 6; //Результат подписки.
}
//Результат подписки.
enum PositionsAccountSubscriptionStatus {
POSITIONS_SUBSCRIPTION_STATUS_UNSPECIFIED = 0; //Тип не определен.
POSITIONS_SUBSCRIPTION_STATUS_SUCCESS = 1; //Успешно.
POSITIONS_SUBSCRIPTION_STATUS_ACCOUNT_NOT_FOUND = 2; //Счет не найден или недостаточно прав.
POSITIONS_SUBSCRIPTION_STATUS_INTERNAL_ERROR = 3; //Произошла ошибка.
}
//Данные о позиции портфеля.
message PositionData {
string account_id = 1; //Идентификатор счета.
repeated PositionsMoney money = 2; //Массив валютных позиций портфеля.
repeated PositionsSecurities securities = 3; //Список ценно-бумажных позиций портфеля.
repeated PositionsFutures futures = 4; //Список фьючерсов портфеля.
repeated PositionsOptions options = 5; //Список опционов портфеля.
google.protobuf.Timestamp date = 6; //Дата и время операции в формате UTC.
}
//Валютная позиция портфеля.
message PositionsMoney {
MoneyValue available_value = 1; //Доступное количество валютный позиций.
MoneyValue blocked_value = 2; //Заблокированное количество валютных позиций.
}
message ChildOperationItem {
string instrument_uid = 1; //Уникальный идентификатор инструмента.
MoneyValue payment = 2; //Сумма операции.
}
//Запрос установки stream-соединения операций.
message OperationsStreamRequest {
repeated string accounts = 1; //Массив идентификаторов счетов пользователя.
PingDelaySettings ping_settings = 15; //Запрос настройки пинга.
}
//Информация по операциям.
message OperationsStreamResponse {
oneof payload {
OperationsSubscriptionResult subscriptions = 1; //Объект результата подписки.
OperationData operation = 2; //Объект стриминга операций.
Ping ping = 3; //Проверка активности стрима.
}
}
//Объект результата подписки.
message OperationsSubscriptionResult {
repeated string accounts = 1; //Массив счетов клиента.
OperationsAccountSubscriptionStatus subscription_status = 2; //Результат подписки.
string tracking_id = 7; //Уникальный идентификатор запроса, подробнее: [tracking_id](/invest/intro/developer/protocols/grpc#tracking-id).
string stream_id = 8; //Идентификатор открытого соединения
}
//Результат подписки.
enum OperationsAccountSubscriptionStatus {
OPERATIONS_SUBSCRIPTION_STATUS_UNSPECIFIED = 0; //Тип не определен.
OPERATIONS_SUBSCRIPTION_STATUS_SUCCESS = 1; //Успешно.
OPERATIONS_SUBSCRIPTION_STATUS_ACCOUNT_NOT_FOUND = 2; //Счет не найден или недостаточно прав.
OPERATIONS_SUBSCRIPTION_STATUS_INTERNAL_ERROR = 3; //Произошла ошибка.
}
//Данные об операции.
message OperationData {
string broker_account_id = 1; //Идентификатор счета.
string id = 2; //Номер поручения.
string parent_operation_id = 3; //Номер родительского поручения.
string name = 4; //Название инструмента.
google.protobuf.Timestamp date = 5; //Дата.
OperationType type = 6; //Тип операции.
OperationState state = 7; //Статус поручения.
string instrument_uid = 8; //Уникальный идентификатор инструмента.
string figi = 9; //FIGI-идентификатор инструмента.
string instrument_type = 10; //Тип инструмента.
InstrumentType instrument_kind = 11; //Тип инструмента.
string position_uid = 12; //Идентификатор позиции.
string ticker = 13; //Тикер инструмента.
string class_code = 14; //Класс-код (секция торгов).
MoneyValue payment = 15; //Сумма операции.
}

View File

@ -0,0 +1,249 @@
syntax = "proto3";
package tinkoff.public.invest.api.contract.v1;
option go_package = "./;investapi";
option java_package = "ru.tinkoff.piapi.contract.v1";
option java_multiple_files = true;
option csharp_namespace = "Tinkoff.InvestApi.V1";
option objc_class_prefix = "TIAPI";
option php_namespace = "Tinkoff\\Invest\\V1";
import "google/protobuf/timestamp.proto";
import "google/api/field_behavior.proto";
import "common.proto";
service UsersService { /*С помощью сервиса можно получить: <br/> 1.
список счетов пользователя; <br/> 2. маржинальные показатели по счу.*/
//GetAccounts счета пользователя
//Получить список счетов.
rpc GetAccounts (GetAccountsRequest) returns (GetAccountsResponse);
//GetMarginAttributes маржинальные показатели по счу
//Метод позволяет получить маржинальные показатели и ликвидность по заданному счу.
rpc GetMarginAttributes (GetMarginAttributesRequest) returns (GetMarginAttributesResponse);
//GetUserTariff тариф пользователя
//Получить информацию о текущих лимитах на подклчение, согласно текущему тарифу пользователя.
rpc GetUserTariff (GetUserTariffRequest) returns (GetUserTariffResponse);
//GetInfo информация о пользователе
//Получить информацию о пользователе: тариф, признак квалификации, пройденные тесты и др.
rpc GetInfo (GetInfoRequest) returns (GetInfoResponse);
//GetBankAccounts банковские счета пользователя
//Получить список счетов пользователя, в том числе и банковских.
rpc GetBankAccounts(GetBankAccountsRequest) returns (GetBankAccountsResponse);
//CurrencyTransfer перевод денежных средств между счетами
//Перевести денежные средства между брокерскими счетами
rpc CurrencyTransfer(CurrencyTransferRequest) returns (CurrencyTransferResponse);
//PayIn пополнение брокерского счета
//Пополнить брокерский счёт с банковского
rpc PayIn(PayInRequest) returns (PayInResponse);
//GetAccountValues дополнительные показатели счетов
//Метод предназначен для получения дополнительных показателей счетов
rpc GetAccountValues(GetAccountValuesRequest) returns (GetAccountValuesResponse);
}
//Запрос получения счетов пользователя.
message GetAccountsRequest {
optional AccountStatus status = 1; //Статус счета.
}
//Список счетов пользователя.
message GetAccountsResponse {
// Массив счетов клиента.
repeated Account accounts = 1;
}
//Информация о сче.
message Account {
// Идентификатор сча.
string id = 1;
// Тип сча.
AccountType type = 2;
// Название сча.
string name = 3;
// Статус сча.
AccountStatus status = 4;
// Дата открытия сча в часовом поясе UTC.
google.protobuf.Timestamp opened_date = 5;
// Дата закрытия сча в часовом поясе UTC.
google.protobuf.Timestamp closed_date = 6;
// Уровень доступа к текущему счу (определяется токеном).
AccessLevel access_level = 7;
}
//Тип сча.
enum AccountType {
ACCOUNT_TYPE_UNSPECIFIED = 0; //Тип аккаунта не определeн.
ACCOUNT_TYPE_TINKOFF = 1; //Брокерский сч Т-Инвестиций.
ACCOUNT_TYPE_TINKOFF_IIS = 2; //ИИС.
ACCOUNT_TYPE_INVEST_BOX = 3; //Инвесткопилка.
ACCOUNT_TYPE_INVEST_FUND = 4; //Фонд денежного рынка.
ACCOUNT_TYPE_DEBIT = 5; //Дебетовый карточный счeт.
ACCOUNT_TYPE_SAVING = 6; //Накопительный счeт.
ACCOUNT_TYPE_DFA = 7; //Смарт-счет.
}
//Статус сча.
enum AccountStatus {
ACCOUNT_STATUS_UNSPECIFIED = 0; //Статус сча не определeн.
ACCOUNT_STATUS_NEW = 1; //Новый, в процессе открытия.
ACCOUNT_STATUS_OPEN = 2; //Открытый и активный счeт.
ACCOUNT_STATUS_CLOSED = 3; //Закрытый счeт.
ACCOUNT_STATUS_ALL = 4; //Все счета.
}
//Запрос маржинальных показателей по счу.
message GetMarginAttributesRequest {
// Идентификатор сча пользователя.
string account_id = 1 [(google.api.field_behavior) = REQUIRED];
}
//Маржинальные показатели по счу.
message GetMarginAttributesResponse {
// Ликвидная стоимость портфеля. [Подробнее про ликвидный портфель](https://www.tbank.ru/invest/help/brokerage/account/margin/about/#q4).
MoneyValue liquid_portfolio = 1;
// Начальная маржа начальное обеспечение для совершения новой сделки. [Подробнее про начальную и минимальную маржу](https://www.tbank.ru/invest/help/brokerage/account/margin/about/#q6).
MoneyValue starting_margin = 2;
// Минимальная маржа это минимальное обеспечение для поддержания позиции, которую вы уже открыли. [Подробнее про начальную и минимальную маржу](https://www.tbank.ru/invest/help/brokerage/account/margin/about/#q6).
MoneyValue minimal_margin = 3;
// Уровень достаточности средств. Соотношение стоимости ликвидного портфеля к начальной марже.
Quotation funds_sufficiency_level = 4;
// Объем недостающих средств. Разница между стартовой маржой и ликвидной стоимости портфеля.
MoneyValue amount_of_missing_funds = 5;
// Скорректированная маржа. Начальная маржа, в которой плановые позиции рассчитываются с учом активных заявок на покупку позиций лонг или продажу позиций шорт.
MoneyValue corrected_margin = 6;
// Размер гарантийного обеспечения, заблокированного под фьючерсы.
MoneyValue guarantee_for_futures = 7;
}
//Запрос текущих лимитов пользователя.
message GetUserTariffRequest {
}
//Текущие лимиты пользователя.
message GetUserTariffResponse {
repeated UnaryLimit unary_limits = 1; //Массив лимитов пользователя по unary-запросам.
repeated StreamLimit stream_limits = 2; //Массив лимитов пользователей для stream-соединений.
}
//Лимит unary-методов.
message UnaryLimit {
int32 limit_per_minute = 1; //Количество unary-запросов в минуту.
repeated string methods = 2; //Названия методов.
optional int32 limit_per_second = 3; //Количество unary-запросов в секунду.
}
//Лимит stream-соединений.
message StreamLimit {
int32 limit = 1; //Максимальное количество stream-соединений.
repeated string streams = 2; //Названия stream-методов.
int32 open = 3; //Текущее количество открытых stream-соединений.
}
//Запрос информации о пользователе.
message GetInfoRequest {
}
//Информация о пользователе.
message GetInfoResponse {
bool prem_status = 1; //Признак премиум клиента.
bool qual_status = 2; //Признак квалифицированного инвестора.
repeated string qualified_for_work_with = 3; //Набор требующих тестирования инструментов и возможностей, с которыми может работать пользователь. [Подробнее](/invest/services/accounts/faq_users).
string tariff = 4; //Наименование тарифа пользователя.
string user_id = 9; //Идентификатор пользователя.
string risk_level_code = 12; // Категория риска.
}
//Уровень доступа к счу.
enum AccessLevel {
ACCOUNT_ACCESS_LEVEL_UNSPECIFIED = 0; //Уровень доступа не определeн.
ACCOUNT_ACCESS_LEVEL_FULL_ACCESS = 1; //Полный доступ к счу.
ACCOUNT_ACCESS_LEVEL_READ_ONLY = 2; //Доступ с уровнем прав «только чтение».
ACCOUNT_ACCESS_LEVEL_NO_ACCESS = 3; //Доступа нет.
}
//Запрос списка банковских счетов пользователя.
message GetBankAccountsRequest {
}
//Список банковских счетов пользователя.
message GetBankAccountsResponse {
repeated BankAccount bank_accounts = 1; //Массив банковских счетов.
}
//Банковский счeт.
message BankAccount {
string id = 1; //Идентификатор сча.
string name = 2; //Название сча.
repeated MoneyValue money = 3; //Список валютных позиций на сче.
google.protobuf.Timestamp opened_date = 4; //Дата открытия сча в часовом поясе UTC.
AccountType type = 5; //Тип сча.
}
message CurrencyTransferRequest {
string from_account_id = 1 [(google.api.field_behavior) = REQUIRED]; // Номер счета списания.
string to_account_id = 2 [(google.api.field_behavior) = REQUIRED]; // Номер счета зачисления.
MoneyValue amount = 3 [(google.api.field_behavior) = REQUIRED]; // Сумма перевода с указанием валюты.
string transaction_id = 4; // Идентификатор запроса выставления поручения для целей идемпотентности в формате UUID.
}
message CurrencyTransferResponse {
}
message PayInRequest {
string from_account_id = 1 [(google.api.field_behavior) = REQUIRED]; // Номер счета списания.
string to_account_id = 2 [(google.api.field_behavior) = REQUIRED]; // Номер брокерского счета зачисления.
MoneyValue amount = 3 [(google.api.field_behavior) = REQUIRED]; // Сумма перевода с указанием валюты.
}
message PayInResponse {
}
enum AccountValue {
ACCOUNT_VALUE_UNSPECIFIED = 0; // Не определён.
ACCOUNT_VALUE_MARGIN_FEE = 1; // Размер комиссии за маржинальное кредитование.
ACCOUNT_VALUE_AMOUNT_WITHOUT_EXTRA_FEE = 2; // Остаток доступного лимита с текущей комиссией.
}
message GetAccountValuesRequest {
repeated string accounts = 1; // Массив счетов пользователя.
repeated AccountValue values = 2; // Массив запрашиваемых параметров.
}
message GetAccountValuesResponse {
repeated AccountValuesWithParameters accounts = 1; // Массив счетов с параметрами.
}
message AccountValuesWithParameters {
string account_id = 1; // Номер счета.
repeated InstrumentParameter values = 2; // Массив параметров инструмента.
}
message InstrumentParameter {
AccountValue name = 1; // Тип запрашиваемого параметра.
MoneyValue value = 2; // Значение запрашиваемого параметра.
}

View File

@ -0,0 +1,49 @@
import { BrokerAccountsService } from './broker-accounts.service';
import { TBankClientService } from './tbank-client.service';
import { CacheService } from '../../cache/cache.service';
describe('BrokerAccountsService', () => {
const client = {
getServiceClient: vi.fn(),
callUnary: vi.fn(),
} as unknown as TBankClientService;
const cache = {
getOrFetch: vi.fn(),
} as unknown as CacheService;
beforeEach(() => {
vi.clearAllMocks();
});
it('returns only open brokerage and IIS accounts from cache wrapper', async () => {
vi.mocked(cache.getOrFetch).mockImplementation(
async (_prefix: string, _parts: string[], fetchFn: () => Promise<unknown>) => ({
data: await fetchFn(),
fromCache: false,
cachedAt: '2026-06-16T02:30:00.000Z',
}),
);
vi.mocked(client.getServiceClient).mockReturnValue({ getAccounts: vi.fn() } as any);
vi.mocked(client.callUnary).mockResolvedValue({
accounts: [
{ id: '1', type: 'ACCOUNT_TYPE_TINKOFF', name: 'Broker', status: 'ACCOUNT_STATUS_OPEN' },
{ id: '2', type: 'ACCOUNT_TYPE_TINKOFF_IIS', name: 'IIS', status: 'ACCOUNT_STATUS_OPEN' },
{ id: '3', type: 'ACCOUNT_TYPE_INVEST_BOX', name: 'Box', status: 'ACCOUNT_STATUS_OPEN' },
{ id: '4', type: 'ACCOUNT_TYPE_TINKOFF', name: 'Closed', status: 'ACCOUNT_STATUS_CLOSED' },
],
});
const service = new BrokerAccountsService(client, cache);
const result = await service.findAll();
expect(result.data).toHaveLength(2);
expect(result.data.map((account) => account.type)).toEqual(['brokerage', 'iis']);
expect(result.meta.fromCache).toBe(false);
expect(cache.getOrFetch).toHaveBeenCalledWith(
'tbank:accounts',
['open-brokerage-iis'],
expect.any(Function),
'tbankAccountsTtl',
);
});
});

View File

@ -0,0 +1,50 @@
import { Injectable } from '@nestjs/common';
import { CacheService } from '../../cache/cache.service';
import { isSupportedBrokerAccount, mapAccount } from '../mappers/account.mapper';
import { TBANK_CACHE_KEYS } from '../tbank.config';
import type { BrokerAccount } from '../types/broker.types';
import type { TBankAccountsResponse } from '../types/tbank-proto.types';
import { TBankClientService } from './tbank-client.service';
@Injectable()
export class BrokerAccountsService {
constructor(
private readonly tbankClient: TBankClientService,
private readonly cacheService: CacheService,
) {}
async findAll(): Promise<{
data: BrokerAccount[];
meta: { fromCache: boolean; cachedAt: string | null };
}> {
const result = await this.cacheService.getOrFetch(
TBANK_CACHE_KEYS.accounts,
['open-brokerage-iis'],
() => this.fetchAccounts(),
'tbankAccountsTtl',
);
return {
data: result.data,
meta: { fromCache: result.fromCache, cachedAt: result.cachedAt },
};
}
async findById(accountId: string): Promise<BrokerAccount | null> {
const accounts = await this.findAll();
return accounts.data.find((account) => account.id === accountId) ?? null;
}
private async fetchAccounts(): Promise<BrokerAccount[]> {
const usersClient = this.tbankClient.getServiceClient('UsersService') as any;
const response = await this.tbankClient.callUnary<
Record<string, string>,
TBankAccountsResponse
>('UsersService/GetAccounts', usersClient.getAccounts.bind(usersClient), {
status: 'ACCOUNT_STATUS_OPEN',
});
return (response.accounts ?? []).filter(isSupportedBrokerAccount).map(mapAccount);
}
}

View File

@ -0,0 +1,38 @@
import { Injectable } from '@nestjs/common';
import { CacheService } from '../../cache/cache.service';
import { TBANK_CACHE_KEYS } from '../tbank.config';
import type { TBankInstrument, TBankInstrumentResponse } from '../types/tbank-proto.types';
import { TBankClientService } from './tbank-client.service';
@Injectable()
export class BrokerInstrumentsService {
constructor(
private readonly tbankClient: TBankClientService,
private readonly cacheService: CacheService,
) {}
async findByInstrumentUid(instrumentUid: string): Promise<TBankInstrument | null> {
const result = await this.cacheService.getOrFetch(
TBANK_CACHE_KEYS.instrument,
[instrumentUid],
() => this.fetchByUid(instrumentUid),
'tbankInstrumentTtl',
);
return result.data;
}
private async fetchByUid(instrumentUid: string): Promise<TBankInstrument | null> {
const instrumentsClient = this.tbankClient.getServiceClient('InstrumentsService') as any;
const response = await this.tbankClient.callUnary<
{ idType: string; id: string },
TBankInstrumentResponse
>(
'InstrumentsService/GetInstrumentBy',
instrumentsClient.getInstrumentBy.bind(instrumentsClient),
{ idType: 'INSTRUMENT_ID_TYPE_UID', id: instrumentUid },
);
return response.instrument ?? null;
}
}

View File

@ -0,0 +1,155 @@
import { PrismaService } from '../../prisma/prisma.service';
import { BrokerOperationSyncService } from './broker-operation-sync.service';
import { BrokerOperationsService } from './broker-operations.service';
describe('BrokerOperationSyncService', () => {
const operations = { getOperations: vi.fn() } as unknown as BrokerOperationsService;
const prisma = {
brokerOperation: { upsert: vi.fn() },
brokerOperationSyncState: { upsert: vi.fn() },
} as unknown as PrismaService;
beforeEach(() => {
vi.clearAllMocks();
});
it('syncs operation pages and stores raw payload', async () => {
vi.mocked(operations.getOperations)
.mockResolvedValueOnce({
data: {
accountId: 'acc-1',
hasNext: true,
nextCursor: 'next',
asOf: '2026-06-16T00:00:00.000Z',
items: [
{
cursor: 'c1',
accountId: 'acc-1',
id: 'op-1',
parentOperationId: null,
date: '2026-06-16T00:00:00.000Z',
type: 'OPERATION_TYPE_BUY',
category: 'trade',
description: null,
state: 'OPERATION_STATE_EXECUTED',
instrumentUid: 'uid-1',
figi: null,
ticker: 'SBER',
classCode: 'TQBR',
instrumentType: 'share',
payment: { currency: 'RUB', units: '-1000', nano: 0, value: -1000 },
price: null,
commission: null,
yield: null,
accruedInt: null,
quantity: 10,
quantityDone: 10,
},
],
},
meta: { fromCache: false, cachedAt: null },
})
.mockResolvedValueOnce({
data: { accountId: 'acc-1', hasNext: false, nextCursor: null, asOf: 'now', items: [] },
meta: { fromCache: false, cachedAt: null },
});
const service = new BrokerOperationSyncService(operations, prisma);
const result = await service.syncAccount('acc-1', {
from: '2026-06-01T00:00:00.000Z',
to: '2026-06-16T00:00:00.000Z',
});
expect(result.upserted).toBe(1);
expect(operations.getOperations).toHaveBeenNthCalledWith(2, 'acc-1', {
from: '2026-06-01T00:00:00.000Z',
to: '2026-06-16T00:00:00.000Z',
cursor: 'next',
limit: 1000,
state: 'OPERATION_STATE_EXECUTED',
});
expect(prisma.brokerOperation.upsert).toHaveBeenCalledWith(
expect.objectContaining({
where: { accountId_cursor: { accountId: 'acc-1', cursor: 'c1' } },
}),
);
expect(prisma.brokerOperationSyncState.upsert).toHaveBeenCalledWith(
expect.objectContaining({
update: expect.objectContaining({ lastCursor: 'next' }),
}),
);
});
it('stores operations under the synced account id', async () => {
vi.mocked(operations.getOperations).mockResolvedValueOnce({
data: {
accountId: 'acc-1',
hasNext: false,
nextCursor: null,
asOf: '2026-06-16T00:00:00.000Z',
items: [
{
cursor: 'c1',
accountId: 'unexpected-account',
id: 'op-1',
parentOperationId: null,
date: null,
type: 'OPERATION_TYPE_BUY',
category: 'trade',
description: null,
state: null,
instrumentUid: null,
figi: null,
ticker: null,
classCode: null,
instrumentType: null,
payment: null,
price: null,
commission: null,
yield: null,
accruedInt: null,
quantity: null,
quantityDone: null,
},
],
},
meta: { fromCache: false, cachedAt: null },
});
const service = new BrokerOperationSyncService(operations, prisma);
await service.syncAccount('acc-1', {
from: '2026-06-01T00:00:00.000Z',
to: '2026-06-16T00:00:00.000Z',
});
expect(prisma.brokerOperation.upsert).toHaveBeenCalledWith(
expect.objectContaining({
where: { accountId_cursor: { accountId: 'acc-1', cursor: 'c1' } },
create: expect.objectContaining({ accountId: 'acc-1' }),
update: expect.objectContaining({ accountId: 'acc-1' }),
}),
);
});
it('fails when a page claims more data without a next cursor', async () => {
vi.mocked(operations.getOperations).mockResolvedValueOnce({
data: {
accountId: 'acc-1',
hasNext: true,
nextCursor: null,
asOf: '2026-06-16T00:00:00.000Z',
items: [],
},
meta: { fromCache: false, cachedAt: null },
});
const service = new BrokerOperationSyncService(operations, prisma);
await expect(
service.syncAccount('acc-1', {
from: '2026-06-01T00:00:00.000Z',
to: '2026-06-16T00:00:00.000Z',
}),
).rejects.toThrow('T-Bank returned hasNext without nextCursor');
});
});

View File

@ -0,0 +1,106 @@
import { Injectable, InternalServerErrorException } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import type { BrokerOperation } from '../types/broker.types';
import { BrokerOperationsService } from './broker-operations.service';
type BrokerOperationSyncRange = {
from: string;
to: string;
};
@Injectable()
export class BrokerOperationSyncService {
constructor(
private readonly operationsService: BrokerOperationsService,
private readonly prisma: PrismaService,
) {}
async syncAccount(
accountId: string,
range: BrokerOperationSyncRange,
): Promise<{ upserted: number }> {
let cursor: string | undefined;
let lastCursor: string | null = null;
let upserted = 0;
do {
const page = await this.operationsService.getOperations(accountId, {
from: range.from,
to: range.to,
cursor,
limit: 1000,
state: 'OPERATION_STATE_EXECUTED',
});
for (const operation of page.data.items) {
await this.upsertOperation(accountId, operation);
upserted++;
}
const nextCursor = page.data.nextCursor ?? undefined;
if (page.data.hasNext && !nextCursor) {
throw new InternalServerErrorException('T-Bank returned hasNext without nextCursor');
}
if (nextCursor) {
lastCursor = nextCursor;
}
cursor = nextCursor;
if (!page.data.hasNext) {
break;
}
} while (cursor);
await this.prisma.brokerOperationSyncState.upsert({
where: { accountId },
create: {
accountId,
lastCursor,
lastSyncedFrom: new Date(range.from),
lastSyncedTo: new Date(range.to),
},
update: {
lastCursor,
lastSyncedFrom: new Date(range.from),
lastSyncedTo: new Date(range.to),
syncedAt: new Date(),
},
});
return { upserted };
}
private async upsertOperation(accountId: string, operation: BrokerOperation): Promise<void> {
const cursor =
operation.cursor || `${operation.id || 'operation'}:${operation.date || 'no-date'}`;
const data = {
accountId,
cursor,
operationId: operation.id,
parentOperationId: operation.parentOperationId,
date: operation.date ? new Date(operation.date) : null,
type: operation.type,
category: operation.category,
state: operation.state,
instrumentUid: operation.instrumentUid,
figi: operation.figi,
ticker: operation.ticker,
classCode: operation.classCode,
payment: operation.payment ? JSON.stringify(operation.payment) : null,
price: operation.price ? JSON.stringify(operation.price) : null,
commission: operation.commission ? JSON.stringify(operation.commission) : null,
yield: operation.yield ? JSON.stringify(operation.yield) : null,
accruedInt: operation.accruedInt ? JSON.stringify(operation.accruedInt) : null,
quantity: operation.quantity,
quantityDone: operation.quantityDone,
raw: JSON.stringify(operation),
};
await this.prisma.brokerOperation.upsert({
where: { accountId_cursor: { accountId, cursor } },
create: data,
update: data,
});
}
}

View File

@ -0,0 +1,64 @@
import { NotFoundException } from '@nestjs/common';
import { CacheService } from '../../cache/cache.service';
import { BrokerAccountsService } from './broker-accounts.service';
import { BrokerOperationsService } from './broker-operations.service';
import { TBankClientService } from './tbank-client.service';
describe('BrokerOperationsService', () => {
const accounts = { findById: vi.fn() } as unknown as BrokerAccountsService;
const client = { getServiceClient: vi.fn(), callUnary: vi.fn() } as unknown as TBankClientService;
const cache = { getOrFetch: vi.fn() } as unknown as CacheService;
beforeEach(() => {
vi.clearAllMocks();
});
it('throws 404 for excluded or missing account', async () => {
vi.mocked(accounts.findById).mockResolvedValue(null);
const service = new BrokerOperationsService(accounts, client, cache);
await expect(service.getOperations('missing', {})).rejects.toThrow(NotFoundException);
});
it('builds cursor request and maps operation page', async () => {
vi.mocked(accounts.findById).mockResolvedValue({
id: 'acc-1',
type: 'brokerage',
name: 'Broker',
status: 'ACCOUNT_STATUS_OPEN',
openedAt: null,
accessLevel: null,
});
vi.mocked(cache.getOrFetch).mockImplementation(
async (_prefix: string, _parts: string[], fetchFn: () => Promise<unknown>) => ({
data: await fetchFn(),
fromCache: false,
cachedAt: null,
}),
);
vi.mocked(client.getServiceClient).mockReturnValue({ getOperationsByCursor: vi.fn() } as any);
vi.mocked(client.callUnary).mockResolvedValue({
hasNext: false,
items: [{ cursor: 'c1', brokerAccountId: 'acc-1', type: 'OPERATION_TYPE_BUY' }],
});
const service = new BrokerOperationsService(accounts, client, cache);
const result = await service.getOperations('acc-1', {
from: '2026-01-01T00:00:00.000Z',
to: '2026-06-16T00:00:00.000Z',
limit: 1000,
state: 'OPERATION_STATE_EXECUTED',
});
expect(result.data.items[0].category).toBe('trade');
expect(client.callUnary).toHaveBeenCalledWith(
'OperationsService/GetOperationsByCursor',
expect.any(Function),
expect.objectContaining({
accountId: 'acc-1',
limit: 1000,
state: 'OPERATION_STATE_EXECUTED',
}),
);
});
});

View File

@ -0,0 +1,88 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { CacheService } from '../../cache/cache.service';
import type { BrokerOperationQueryDto } from '../dto/broker-operation-query.dto';
import { mapOperationsPage } from '../mappers/operation.mapper';
import { TBANK_CACHE_KEYS } from '../tbank.config';
import type { BrokerOperationsPage } from '../types/broker.types';
import type { TBankOperationsByCursorResponse } from '../types/tbank-proto.types';
import { BrokerAccountsService } from './broker-accounts.service';
import { TBankClientService } from './tbank-client.service';
@Injectable()
export class BrokerOperationsService {
constructor(
private readonly accountsService: BrokerAccountsService,
private readonly tbankClient: TBankClientService,
private readonly cacheService: CacheService,
) {}
async getOperations(
accountId: string,
query: BrokerOperationQueryDto,
): Promise<{
data: BrokerOperationsPage;
meta: { fromCache: boolean; cachedAt: string | null };
}> {
const account = await this.accountsService.findById(accountId);
if (!account) throw new NotFoundException('Broker account not found');
const request = this.buildRequest(accountId, query);
const result = await this.cacheService.getOrFetch(
TBANK_CACHE_KEYS.operations,
[accountId, JSON.stringify(request)],
() => this.fetchOperations(accountId, request),
'tbankOperationsTtl',
);
return {
data: result.data,
meta: { fromCache: result.fromCache, cachedAt: result.cachedAt },
};
}
private buildRequest(accountId: string, query: BrokerOperationQueryDto): Record<string, unknown> {
const now = new Date();
const startOfYear = new Date(Date.UTC(now.getUTCFullYear(), 0, 1));
const operationTypes = query.operationTypes
? query.operationTypes
.split(',')
.map((value) => value.trim())
.filter(Boolean)
: undefined;
return {
accountId,
instrumentId: query.instrumentId,
from: {
seconds: Math.floor(new Date(query.from ?? startOfYear.toISOString()).getTime() / 1000),
},
to: {
seconds: Math.floor(new Date(query.to ?? now.toISOString()).getTime() / 1000),
},
cursor: query.cursor,
limit: query.limit ?? 100,
operationTypes,
state: query.state ?? 'OPERATION_STATE_EXECUTED',
withoutCommissions: false,
withoutTrades: false,
withoutOvernights: false,
};
}
private async fetchOperations(
accountId: string,
request: Record<string, unknown>,
): Promise<BrokerOperationsPage> {
const operationsClient = this.tbankClient.getServiceClient('OperationsService') as any;
const response = await this.tbankClient.callUnary<
Record<string, unknown>,
TBankOperationsByCursorResponse
>(
'OperationsService/GetOperationsByCursor',
operationsClient.getOperationsByCursor.bind(operationsClient),
request,
);
return mapOperationsPage(accountId, response);
}
}

View File

@ -0,0 +1,70 @@
import { NotFoundException } from '@nestjs/common';
import { CacheService } from '../../cache/cache.service';
import { BrokerAccountsService } from './broker-accounts.service';
import { BrokerInstrumentsService } from './broker-instruments.service';
import { BrokerPortfolioService } from './broker-portfolio.service';
import { TBankClientService } from './tbank-client.service';
describe('BrokerPortfolioService', () => {
const accounts = { findById: vi.fn() } as unknown as BrokerAccountsService;
const instruments = { findByInstrumentUid: vi.fn() } as unknown as BrokerInstrumentsService;
const client = { getServiceClient: vi.fn(), callUnary: vi.fn() } as unknown as TBankClientService;
const cache = { getOrFetch: vi.fn() } as unknown as CacheService;
beforeEach(() => {
vi.clearAllMocks();
});
it('throws 404 for excluded or missing account', async () => {
vi.mocked(accounts.findById).mockResolvedValue(null);
const service = new BrokerPortfolioService(accounts, instruments, client, cache);
await expect(service.getPortfolio('missing')).rejects.toThrow(NotFoundException);
});
it('fetches portfolio and positions through cache', async () => {
vi.mocked(accounts.findById).mockResolvedValue({
id: 'acc-1',
type: 'brokerage',
name: 'Broker',
status: 'ACCOUNT_STATUS_OPEN',
openedAt: null,
accessLevel: null,
});
vi.mocked(cache.getOrFetch).mockImplementation(
async (_prefix: string, _parts: string[], fetchFn: () => Promise<unknown>) => ({
data: await fetchFn(),
fromCache: false,
cachedAt: null,
}),
);
vi.mocked(client.getServiceClient).mockReturnValue({
getPortfolio: vi.fn(),
getPositions: vi.fn(),
} as any);
vi.mocked(client.callUnary)
.mockResolvedValueOnce({
accountId: 'acc-1',
totalAmountPortfolio: { currency: 'rub', units: '1000', nano: 0 },
positions: [],
})
.mockResolvedValueOnce({
accountId: 'acc-1',
money: [{ currency: 'rub', units: '1000', nano: 0 }],
blocked: [],
securities: [],
});
const service = new BrokerPortfolioService(accounts, instruments, client, cache);
const result = await service.getPortfolio('acc-1');
expect(result.data.account.id).toBe('acc-1');
expect(result.data.cash[0].value).toBe(1000);
expect(cache.getOrFetch).toHaveBeenCalledWith(
'tbank:portfolio',
['acc-1'],
expect.any(Function),
'tbankPortfolioTtl',
);
});
});

View File

@ -0,0 +1,81 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { CacheService } from '../../cache/cache.service';
import { mapBrokerPortfolio } from '../mappers/portfolio.mapper';
import { TBANK_CACHE_KEYS } from '../tbank.config';
import type { BrokerPortfolio } from '../types/broker.types';
import type {
TBankInstrument,
TBankPortfolioResponse,
TBankPositionsResponse,
} from '../types/tbank-proto.types';
import { BrokerAccountsService } from './broker-accounts.service';
import { BrokerInstrumentsService } from './broker-instruments.service';
import { TBankClientService } from './tbank-client.service';
@Injectable()
export class BrokerPortfolioService {
constructor(
private readonly accountsService: BrokerAccountsService,
private readonly instrumentsService: BrokerInstrumentsService,
private readonly tbankClient: TBankClientService,
private readonly cacheService: CacheService,
) {}
async getPortfolio(accountId: string): Promise<{
data: BrokerPortfolio;
meta: { fromCache: boolean; cachedAt: string | null };
}> {
const account = await this.accountsService.findById(accountId);
if (!account) throw new NotFoundException('Broker account not found');
const result = await this.cacheService.getOrFetch(
TBANK_CACHE_KEYS.portfolio,
[accountId],
async () => {
const operationsClient = this.tbankClient.getServiceClient('OperationsService') as any;
const [portfolio, positions] = await Promise.all([
this.tbankClient.callUnary<
{ accountId: string; currency: string },
TBankPortfolioResponse
>(
'OperationsService/GetPortfolio',
operationsClient.getPortfolio.bind(operationsClient),
{ accountId, currency: 'RUB' },
),
this.tbankClient.callUnary<{ accountId: string }, TBankPositionsResponse>(
'OperationsService/GetPositions',
operationsClient.getPositions.bind(operationsClient),
{ accountId },
),
]);
const instrumentMap = await this.buildInstrumentMap(portfolio);
return mapBrokerPortfolio({ account, portfolio, positions, instruments: instrumentMap });
},
'tbankPortfolioTtl',
);
return {
data: result.data,
meta: { fromCache: result.fromCache, cachedAt: result.cachedAt },
};
}
private async buildInstrumentMap(
portfolio: TBankPortfolioResponse,
): Promise<Map<string, Partial<TBankInstrument>>> {
const ids = Array.from(
new Set(
(portfolio.positions ?? []).map((position) => position.instrumentUid).filter(Boolean),
),
) as string[];
const entries = await Promise.all(
ids.map(async (id) => [id, await this.instrumentsService.findByInstrumentUid(id)] as const),
);
return new Map(
entries.filter((entry): entry is readonly [string, TBankInstrument] => entry[1] !== null),
);
}
}

View File

@ -0,0 +1,115 @@
import { ServiceUnavailableException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { ChannelCredentials, ClientUnaryCall, Metadata, ServiceError, status } from '@grpc/grpc-js';
import { mkdtempSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { TBankClientService } from './tbank-client.service';
describe('TBankClientService', () => {
const unaryCall = {
cancel: vi.fn(),
getPeer: vi.fn(),
getAuthContext: vi.fn(),
} as unknown as ClientUnaryCall;
const config = {
get: vi.fn((key: string, fallback?: unknown) => {
const values: Record<string, unknown> = {
'app.tbank.token': 'token-1',
'app.tbank.appName': 'ksv741.moex-vibe',
'app.tbank.rateLimitPerSecond': 5,
'app.tbank.requestTimeoutMs': 10000,
};
return values[key] ?? fallback;
}),
} as unknown as ConfigService;
it('builds redacted authorization metadata', () => {
const service = new TBankClientService(config);
const metadata = service.createMetadata();
expect(metadata.get('Authorization')).toEqual(['Bearer token-1']);
expect(metadata.get('x-app-name')).toEqual(['ksv741.moex-vibe']);
expect(service.redactMetadata(metadata)).toEqual({
Authorization: '<redacted>',
'x-app-name': 'ksv741.moex-vibe',
});
});
it('creates service clients from vendored proto contracts', () => {
const service = new TBankClientService(config);
expect(() => service.getServiceClient('UsersService')).not.toThrow();
});
it('creates grpc SSL credentials with configured custom CA certificate', () => {
const caPath = join(mkdtempSync(join(tmpdir(), 'tbank-ca-')), 'root.pem');
writeFileSync(caPath, '-----BEGIN CERTIFICATE-----\ntest-ca\n-----END CERTIFICATE-----\n');
const createSslSpy = vi.spyOn(ChannelCredentials, 'createSsl');
const caConfig = {
get: vi.fn((key: string, fallback?: unknown) => {
const values: Record<string, unknown> = {
'app.tbank.token': 'token-1',
'app.tbank.caCertPath': caPath,
};
return values[key] ?? fallback;
}),
} as unknown as ConfigService;
const service = new TBankClientService(caConfig);
service.getServiceClient('UsersService');
const [rootCerts] = createSslSpy.mock.calls.at(-1) ?? [];
expect(Buffer.isBuffer(rootCerts)).toBe(true);
expect((rootCerts as Buffer).toString('utf8')).toContain('test-ca');
createSslSpy.mockRestore();
});
it('throws integration unavailable when token is missing', async () => {
const missingConfig = {
get: vi.fn((key: string, fallback?: unknown) =>
key === 'app.tbank.token' ? '' : (fallback as unknown),
),
} as unknown as ConfigService;
const service = new TBankClientService(missingConfig);
await expect(
service.callUnary(
'UsersService/GetAccounts',
(_request, _metadata, _options, callback) => {
callback(null, {});
return unaryCall;
},
{},
),
).rejects.toThrow(ServiceUnavailableException);
});
it('wraps grpc errors with status code and tracking id', async () => {
const service = new TBankClientService(config);
const error = Object.assign(new Error('Too many requests'), {
code: status.RESOURCE_EXHAUSTED,
details: 'Too many requests',
metadata: new Metadata(),
}) as ServiceError;
error.metadata.set('x-tracking-id', 'tracking-1');
await expect(
service.callUnary(
'OperationsService/GetPortfolio',
(_request, _metadata, _options, callback) => {
callback(error, null);
return unaryCall;
},
{},
),
).rejects.toMatchObject({
response: expect.objectContaining({
message: expect.stringContaining('T-Bank upstream error'),
}),
});
});
});

View File

@ -0,0 +1,191 @@
import {
BadGatewayException,
Injectable,
Logger,
ServiceUnavailableException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import {
CallOptions,
ChannelCredentials,
Client,
ClientUnaryCall,
loadPackageDefinition,
Metadata,
ServiceError,
status,
} from '@grpc/grpc-js';
import { loadSync } from '@grpc/proto-loader';
import PQueue from 'p-queue';
import { existsSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { TBANK_PROTO_FILES, TBANK_PROTO_PACKAGE } from '../tbank.config';
type GrpcUnary<TRequest, TResponse> = (
request: TRequest,
metadata: Metadata,
options: CallOptions,
callback: (error: ServiceError | null, response: TResponse | null) => void,
) => ClientUnaryCall;
type GrpcServiceConstructor = new (address: string, credentials: ChannelCredentials) => Client;
@Injectable()
export class TBankClientService {
private readonly logger = new Logger(TBankClientService.name);
private readonly queue: PQueue;
private readonly requestTimeoutMs: number;
private readonly packageDefinition: ReturnType<typeof loadPackageDefinition>;
private readonly clientCache = new Map<string, Client>();
constructor(private readonly configService: ConfigService) {
this.requestTimeoutMs = this.configService.get<number>('app.tbank.requestTimeoutMs', 10000);
this.queue = new PQueue({
interval: 1000,
intervalCap: this.configService.get<number>('app.tbank.rateLimitPerSecond', 5),
});
const protoRoot = this.resolveProtoRoot();
const definition = loadSync(
Object.values(TBANK_PROTO_FILES).map((fileName) => join(protoRoot, fileName)),
{
includeDirs: [protoRoot],
keepCase: false,
longs: String,
enums: String,
defaults: true,
oneofs: true,
},
);
this.packageDefinition = loadPackageDefinition(definition);
}
private resolveProtoRoot(): string {
const distProtoRoot = join(__dirname, '..', 'proto', 'contracts');
if (existsSync(distProtoRoot)) return distProtoRoot;
return join(process.cwd(), 'src', 'modules', 'tbank', 'proto', 'contracts');
}
createMetadata(): Metadata {
const token = this.configService.get<string>('app.tbank.token', '');
if (!token) {
throw new ServiceUnavailableException('T-Bank integration is not configured');
}
const metadata = new Metadata();
metadata.set('Authorization', `Bearer ${token}`);
const appName = this.configService.get<string>('app.tbank.appName', '');
if (appName) metadata.set('x-app-name', appName);
return metadata;
}
redactMetadata(metadata: Metadata): Record<string, string> {
const result: Record<string, string> = {};
for (const key of Object.keys(metadata.getMap())) {
if (key.toLowerCase() === 'authorization') {
result.Authorization = '<redacted>';
continue;
}
result[key] = String(metadata.get(key)[0]);
}
return result;
}
getServiceClient(
serviceName: 'UsersService' | 'OperationsService' | 'InstrumentsService',
): Client {
const cached = this.clientCache.get(serviceName);
if (cached) return cached;
const namespace = this.resolveProtoNamespace();
const ServiceCtor = namespace[serviceName] as GrpcServiceConstructor;
const client = new ServiceCtor(
this.configService.get<string>('app.tbank.baseUrl', 'invest-public-api.tbank.ru:443'),
this.createChannelCredentials(),
);
this.clientCache.set(serviceName, client);
return client;
}
async callUnary<TRequest, TResponse>(
label: string,
method: GrpcUnary<TRequest, TResponse>,
request: TRequest,
): Promise<TResponse> {
const metadata = this.createMetadata();
const deadline = new Date(Date.now() + this.requestTimeoutMs);
return this.queue.add(
() =>
new Promise<TResponse>((resolve, reject) => {
method(request, metadata, { deadline }, (error, response) => {
if (error) {
reject(this.mapGrpcError(label, error));
return;
}
resolve(response as TResponse);
});
}),
) as Promise<TResponse>;
}
private resolveProtoNamespace(): Record<string, unknown> {
return TBANK_PROTO_PACKAGE.split('.').reduce<Record<string, unknown>>(
(current, part) => {
return current[part] as Record<string, unknown>;
},
this.packageDefinition as Record<string, unknown>,
);
}
private createChannelCredentials(): ChannelCredentials {
const caCertPath = this.configService.get<string>('app.tbank.caCertPath', '');
if (!caCertPath) return ChannelCredentials.createSsl();
try {
return ChannelCredentials.createSsl(readFileSync(caCertPath));
} catch (error) {
this.logger.error(
`Failed to read T-Bank CA certificate from ${caCertPath}: ${
error instanceof Error ? error.message : String(error)
}`,
);
throw new ServiceUnavailableException('T-Bank CA certificate is not readable');
}
}
private mapGrpcError(label: string, error: ServiceError): Error {
const trackingId = error.metadata?.get('x-tracking-id')?.[0];
const retryAfter = error.metadata?.get('x-ratelimit-reset')?.[0];
const publicMessage =
error.code === status.RESOURCE_EXHAUSTED
? 'T-Bank upstream error: rate limit exceeded'
: `T-Bank upstream error while calling ${label}`;
this.logger.warn(
JSON.stringify({
label,
code: error.code,
trackingId,
retryAfter,
message: error.message,
}),
);
return new BadGatewayException({
message: publicMessage,
trackingId: trackingId ? String(trackingId) : null,
retryAfter: retryAfter ? String(retryAfter) : null,
});
}
}

View File

@ -0,0 +1,41 @@
import configuration from '../../config/configuration';
describe('T-Bank configuration', () => {
const originalEnv = process.env;
beforeEach(() => {
process.env = { ...originalEnv };
});
afterAll(() => {
process.env = originalEnv;
});
it('uses conservative defaults for T-Bank integration', () => {
delete process.env.T_BANK_BASE_URL;
delete process.env.T_BANK_RATE_LIMIT_PER_SECOND;
delete process.env.CACHE_TBANK_PORTFOLIO_TTL;
const config = configuration();
expect(config.tbank.baseUrl).toBe('invest-public-api.tbank.ru:443');
expect(config.tbank.rateLimitPerSecond).toBe(5);
expect(config.cache.tbankPortfolioTtl).toBe(60);
});
it('reads T-Bank token and TTL overrides from environment', () => {
process.env.T_BANK_TOKEN = 'secret-token';
process.env.T_BANK_BASE_URL = 'sandbox-invest-public-api.tbank.ru:443';
process.env.T_BANK_CA_CERT_PATH = '/tmp/tbank-root-ca.pem';
process.env.T_BANK_RATE_LIMIT_PER_SECOND = '2';
process.env.CACHE_TBANK_ACCOUNTS_TTL = '120';
const config = configuration();
expect(config.tbank.token).toBe('secret-token');
expect(config.tbank.baseUrl).toBe('sandbox-invest-public-api.tbank.ru:443');
expect(config.tbank.caCertPath).toBe('/tmp/tbank-root-ca.pem');
expect(config.tbank.rateLimitPerSecond).toBe(2);
expect(config.cache.tbankAccountsTtl).toBe(120);
});
});

View File

@ -0,0 +1,22 @@
export const TBANK_PROTO_PACKAGE = 'tinkoff.public.invest.api.contract.v1';
export const TBANK_PROTO_FILES = {
users: 'users.proto',
operations: 'operations.proto',
instruments: 'instruments.proto',
} as const;
export const TBANK_ACCOUNT_TYPES = {
brokerage: 'ACCOUNT_TYPE_TINKOFF',
iis: 'ACCOUNT_TYPE_TINKOFF_IIS',
} as const;
export const TBANK_OPEN_ACCOUNT_STATUS = 'ACCOUNT_STATUS_OPEN';
export const TBANK_CACHE_KEYS = {
accounts: 'tbank:accounts',
portfolio: 'tbank:portfolio',
positions: 'tbank:positions',
operations: 'tbank:operations',
instrument: 'tbank:instrument',
} as const;

View File

@ -0,0 +1,61 @@
import { ROLES_KEY } from '../auth/decorators/roles.decorator';
import { ApiResponse } from '../../common/dto/api-response.dto';
import { TBankController } from './tbank.controller';
import { BrokerAccountsService } from './services/broker-accounts.service';
import { BrokerOperationSyncService } from './services/broker-operation-sync.service';
import { BrokerOperationsService } from './services/broker-operations.service';
import { BrokerPortfolioService } from './services/broker-portfolio.service';
describe('TBankController', () => {
const accounts = { findAll: vi.fn() } as unknown as BrokerAccountsService;
const portfolio = { getPortfolio: vi.fn() } as unknown as BrokerPortfolioService;
const operations = { getOperations: vi.fn() } as unknown as BrokerOperationsService;
const sync = { syncAccount: vi.fn() } as unknown as BrokerOperationSyncService;
beforeEach(() => {
vi.clearAllMocks();
});
it('allows authenticated users to access broker endpoints', () => {
expect(Reflect.getMetadata(ROLES_KEY, TBankController)).toEqual(['user']);
});
it('returns accounts in a single API envelope', async () => {
vi.mocked(accounts.findAll).mockResolvedValueOnce({
data: [
{
id: 'acc-1',
type: 'brokerage',
name: 'Broker',
status: 'ACCOUNT_STATUS_OPEN',
openedAt: null,
accessLevel: null,
},
],
meta: { fromCache: true, cachedAt: '2026-06-17T00:00:00.000Z' },
});
const controller = new TBankController(accounts, portfolio, operations, sync);
const response = await controller.getAccounts();
expect(response).toBeInstanceOf(ApiResponse);
expect(response.data).toHaveLength(1);
expect(response.meta).toEqual({ fromCache: true, cachedAt: '2026-06-17T00:00:00.000Z' });
});
it('exposes a sync trigger for durable operation history', async () => {
vi.mocked(sync.syncAccount).mockResolvedValueOnce({ upserted: 2 });
const controller = new TBankController(accounts, portfolio, operations, sync);
const response = await controller.syncOperations('acc-1', {
from: '2026-06-01T00:00:00.000Z',
to: '2026-06-17T00:00:00.000Z',
});
expect(sync.syncAccount).toHaveBeenCalledWith('acc-1', {
from: '2026-06-01T00:00:00.000Z',
to: '2026-06-17T00:00:00.000Z',
});
expect(response.data).toEqual({ upserted: 2 });
});
});

View File

@ -0,0 +1,67 @@
import { Controller, Get, Param, Post, Query } from '@nestjs/common';
import { ApiBearerAuth, ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger';
import { ApiResponse } from '../../common/dto/api-response.dto';
import { Roles } from '../auth/decorators/roles.decorator';
import {
BrokerAccountsEnvelopeDto,
BrokerOperationSyncEnvelopeDto,
BrokerOperationsEnvelopeDto,
BrokerPortfolioEnvelopeDto,
} from './dto/broker-envelope.dto';
import { BrokerOperationQueryDto } from './dto/broker-operation-query.dto';
import { BrokerOperationSyncQueryDto } from './dto/broker-operation-sync-query.dto';
import { BrokerAccountsService } from './services/broker-accounts.service';
import { BrokerOperationSyncService } from './services/broker-operation-sync.service';
import { BrokerOperationsService } from './services/broker-operations.service';
import { BrokerPortfolioService } from './services/broker-portfolio.service';
@ApiTags('Broker')
@ApiBearerAuth()
@Roles('user')
@Controller('broker')
export class TBankController {
constructor(
private readonly brokerAccountsService: BrokerAccountsService,
private readonly brokerPortfolioService: BrokerPortfolioService,
private readonly brokerOperationsService: BrokerOperationsService,
private readonly brokerOperationSyncService: BrokerOperationSyncService,
) {}
@Get('accounts')
@ApiOperation({ summary: 'Get open T-Bank brokerage and IIS accounts' })
@ApiOkResponse({ type: BrokerAccountsEnvelopeDto })
async getAccounts() {
const result = await this.brokerAccountsService.findAll();
return new ApiResponse(result.data, result.meta.fromCache, result.meta.cachedAt);
}
@Get('accounts/:accountId/portfolio')
@ApiOperation({ summary: 'Get T-Bank broker account portfolio with cash and positions' })
@ApiOkResponse({ type: BrokerPortfolioEnvelopeDto })
async getPortfolio(@Param('accountId') accountId: string) {
const result = await this.brokerPortfolioService.getPortfolio(accountId);
return new ApiResponse(result.data, result.meta.fromCache, result.meta.cachedAt);
}
@Get('accounts/:accountId/operations')
@ApiOperation({ summary: 'Get paginated T-Bank broker account operations' })
@ApiOkResponse({ type: BrokerOperationsEnvelopeDto })
async getOperations(
@Param('accountId') accountId: string,
@Query() query: BrokerOperationQueryDto,
) {
const result = await this.brokerOperationsService.getOperations(accountId, query);
return new ApiResponse(result.data, result.meta.fromCache, result.meta.cachedAt);
}
@Post('accounts/:accountId/operations/sync')
@ApiOperation({ summary: 'Synchronize T-Bank broker account operations into local history' })
@ApiOkResponse({ type: BrokerOperationSyncEnvelopeDto })
async syncOperations(
@Param('accountId') accountId: string,
@Query() query: BrokerOperationSyncQueryDto,
) {
const result = await this.brokerOperationSyncService.syncAccount(accountId, query);
return new ApiResponse(result);
}
}

View File

@ -0,0 +1,29 @@
import { Module } from '@nestjs/common';
import { TBankController } from './tbank.controller';
import { BrokerAccountsService } from './services/broker-accounts.service';
import { BrokerInstrumentsService } from './services/broker-instruments.service';
import { BrokerOperationSyncService } from './services/broker-operation-sync.service';
import { BrokerOperationsService } from './services/broker-operations.service';
import { BrokerPortfolioService } from './services/broker-portfolio.service';
import { TBankClientService } from './services/tbank-client.service';
@Module({
controllers: [TBankController],
providers: [
TBankClientService,
BrokerAccountsService,
BrokerInstrumentsService,
BrokerPortfolioService,
BrokerOperationsService,
BrokerOperationSyncService,
],
exports: [
TBankClientService,
BrokerAccountsService,
BrokerInstrumentsService,
BrokerPortfolioService,
BrokerOperationsService,
BrokerOperationSyncService,
],
})
export class TBankModule {}

View File

@ -0,0 +1,90 @@
export type BrokerMoney = {
currency: string;
units: string;
nano: number;
value: number;
};
export type BrokerAccount = {
id: string;
type: 'brokerage' | 'iis';
name: string;
status: string;
openedAt: string | null;
accessLevel: string | null;
};
export type BrokerPosition = {
figi: string | null;
instrumentUid: string | null;
positionUid: string | null;
ticker: string | null;
classCode: string | null;
instrumentType: string | null;
name: string | null;
quantity: number | null;
blockedLots: number | null;
currentPrice: BrokerMoney | null;
currentValue: BrokerMoney | null;
averagePositionPrice: BrokerMoney | null;
expectedYieldPercent: number | null;
dailyYield: BrokerMoney | null;
};
export 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;
};
export type BrokerOperationCategory = 'trade' | 'income' | 'tax' | 'fee' | 'transfer' | 'other';
export type BrokerOperation = {
cursor: string | null;
accountId: string;
id: string | null;
parentOperationId: string | null;
date: string | null;
type: string;
category: BrokerOperationCategory;
description: string | null;
state: string | null;
instrumentUid: string | null;
figi: string | null;
ticker: string | null;
classCode: string | null;
instrumentType: string | null;
payment: BrokerMoney | null;
price: BrokerMoney | null;
commission: BrokerMoney | null;
yield: BrokerMoney | null;
accruedInt: BrokerMoney | null;
quantity: number | null;
quantityDone: number | null;
};
export type BrokerOperationsPage = {
accountId: string;
items: BrokerOperation[];
nextCursor: string | null;
hasNext: boolean;
asOf: string;
};

View File

@ -0,0 +1,148 @@
export type TBankTimestamp = {
seconds?: number | string;
nanos?: number;
};
export type TBankMoneyValue = {
currency?: string;
units?: number | string;
nano?: number;
};
export type TBankQuotation = {
units?: number | string;
nano?: number;
};
export type TBankAccount = {
id: string;
type: string;
name?: string;
status: string;
openedDate?: TBankTimestamp;
closedDate?: TBankTimestamp;
accessLevel?: string;
};
export type TBankAccountsResponse = {
accounts?: TBankAccount[];
};
export type TBankPortfolioPosition = {
figi?: string;
instrumentType?: string;
quantity?: TBankQuotation;
averagePositionPrice?: TBankMoneyValue;
expectedYield?: TBankQuotation;
currentNkd?: TBankMoneyValue;
currentPrice?: TBankMoneyValue;
averagePositionPriceFifo?: TBankMoneyValue;
blocked?: boolean;
blockedLots?: TBankQuotation;
positionUid?: string;
instrumentUid?: string;
expectedYieldFifo?: TBankQuotation;
dailyYield?: TBankMoneyValue;
ticker?: string;
classCode?: string;
};
export type TBankPortfolioResponse = {
accountId?: string;
totalAmountShares?: TBankMoneyValue;
totalAmountBonds?: TBankMoneyValue;
totalAmountEtf?: TBankMoneyValue;
totalAmountCurrencies?: TBankMoneyValue;
totalAmountFutures?: TBankMoneyValue;
expectedYield?: TBankQuotation;
positions?: TBankPortfolioPosition[];
totalAmountOptions?: TBankMoneyValue;
totalAmountSp?: TBankMoneyValue;
totalAmountPortfolio?: TBankMoneyValue;
dailyYield?: TBankMoneyValue;
dailyYieldRelative?: TBankQuotation;
totalAmountDfa?: TBankMoneyValue;
};
export type TBankPositionsSecurity = {
figi?: string;
blocked?: string | number;
balance?: string | number;
positionUid?: string;
instrumentUid?: string;
ticker?: string;
classCode?: string;
exchangeBlocked?: boolean;
instrumentType?: string;
};
export type TBankPositionsResponse = {
accountId?: string;
money?: TBankMoneyValue[];
blocked?: TBankMoneyValue[];
securities?: TBankPositionsSecurity[];
};
export type TBankOperationTrade = {
num?: string;
date?: TBankTimestamp;
quantity?: string | number;
price?: TBankMoneyValue;
yield?: TBankMoneyValue;
yieldRelative?: TBankQuotation;
};
export type TBankOperationItem = {
cursor?: string;
brokerAccountId?: string;
id?: string;
parentOperationId?: string;
name?: string;
date?: TBankTimestamp;
type?: string;
description?: string;
state?: string;
instrumentUid?: string;
figi?: string;
instrumentType?: string;
instrumentKind?: string;
positionUid?: string;
ticker?: string;
classCode?: string;
payment?: TBankMoneyValue;
price?: TBankMoneyValue;
commission?: TBankMoneyValue;
yield?: TBankMoneyValue;
yieldRelative?: TBankQuotation;
accruedInt?: TBankMoneyValue;
quantity?: string | number;
quantityRest?: string | number;
quantityDone?: string | number;
tradesInfo?: { trades?: TBankOperationTrade[] };
};
export type TBankOperationsByCursorResponse = {
hasNext?: boolean;
nextCursor?: string;
items?: TBankOperationItem[];
};
export type TBankInstrument = {
figi?: string;
ticker?: string;
classCode?: string;
isin?: string;
lot?: number;
currency?: string;
name?: string;
exchange?: string;
instrumentType?: string;
uid?: string;
positionUid?: string;
assetUid?: string;
instrumentKind?: string;
};
export type TBankInstrumentResponse = {
instrument?: TBankInstrument;
};

View File

@ -0,0 +1,30 @@
# ADR-011: Интеграция с T-Bank Invest использует gRPC
**Статус:** Accepted
**Дата:** 2026-06-16
## Контекст
MoexVibe нужна read-only интеграция с T-Bank Invest для брокерских счетов и ИИС: текущие позиции,
деньги на счёте и история операций. T-Bank предоставляет gRPC, REST-прокси, WebSocket и официальный
JS SDK.
## Решение
Использовать тонкую backend gRPC-интеграцию на основе официальных proto-контрактов. REST оставить
для ручной диагностики, а официальный JS SDK не делать прямой зависимостью первого варианта.
## Обоснование
- gRPC — основной протокол T-Bank Invest API.
- Unary methods покрывают счета, портфель, позиции, операции и инструменты.
- Stream methods можно добавить позже без изменения публичного MoexVibe API.
- Собственный транспортный слой позволяет контролировать rate limiting, cache TTL, redaction
metadata, test doubles и будущий переход от одного server token к per-user token storage.
## Последствия
- Backend хранит vendored proto-контракты T-Bank Invest.
- Backend владеет T-Bank-specific rate limits и cache TTL.
- Интеграция остаётся read-only, пока отдельный ADR не разрешит торговые операции и заявки.

View File

@ -12,5 +12,6 @@
| [ADR-008](ADR-008-auth-system) | Accepted | Authentication и Authorization |
| [ADR-009](ADR-009-portfolio-domain) | Accepted | Доменная модель портфеля |
| [ADR-010](ADR-010-backend-price-computation) | Accepted | Расчёт цен на backend |
| [ADR-011](ADR-011-tbank-invest-grpc) | Accepted | Интеграция с T-Bank Invest через gRPC |
Все опубликованные ADR находятся в `apps/docs/docs/adr/` и отображаются в этом Docusaurus-разделе.

View File

@ -37,7 +37,7 @@ flowchart LR
```typescript
getOrFetch<T>(
keyPrefix: string, // 'marketdata' | 'history' | 'candles' | 'search' | 'bond' | 'dividends'
keyPrefix: string, // 'marketdata' | 'history' | 'candles' | 'search' | 'bond' | 'dividends' | 'tbank:*'
keyParts: string[], // ['shares', 'SBER'] | ['SBER', '2026-06-13', '2026-06-14']
fetchFn: () => Promise<T>,
ttlConfigKey: string, // 'marketDataTtl' | 'historyTtl' | etc.
@ -78,3 +78,14 @@ export class CacheModule {}
| Спецификация инструмента | `securityTtl` | 86400s (24 ч) | `CACHE_SECURITY_TTL` |
| Поиск | `searchTtl` | 3600s (1 ч) | `CACHE_SEARCH_TTL` |
| Дивиденды | `dividendsTtl` | 86400s (24 ч) | `CACHE_DIVIDENDS_TTL` |
| Счета T-Bank | `tbankAccountsTtl` | 3600s (1 ч) | `CACHE_TBANK_ACCOUNTS_TTL` |
| Портфель T-Bank | `tbankPortfolioTtl` | 60s (1 мин) | `CACHE_TBANK_PORTFOLIO_TTL` |
| Операции T-Bank | `tbankOperationsTtl` | 300s (5 мин) | `CACHE_TBANK_OPERATIONS_TTL` |
| Инструменты T-Bank | `tbankInstrumentTtl` | 86400s (24 ч) | `CACHE_TBANK_INSTRUMENT_TTL` |
## T-Bank cache
`TBankModule` использует те же механики `CacheService`, но с отдельными key prefixes
`tbank:accounts`, `tbank:portfolio`, `tbank:positions`, `tbank:operations` и `tbank:instrument`.
Это позволяет держать агрессивно короткий TTL для текущего портфеля и более длинный TTL для
справочных данных инструментов.

View File

@ -11,12 +11,22 @@
| `MOEX_RATE_LIMIT` | `10` | Максимум запросов в секунду к MOEX |
| `MOEX_CIRCUIT_BREAKER_THRESHOLD` | `5` | Количество ошибок до открытия circuit breaker |
| `MOEX_CIRCUIT_BREAKER_RESET_SECONDS` | `30` | Время в секундах до сброса circuit breaker |
| `T_BANK_TOKEN` | `''` | Server-side токен T-Bank Invest |
| `T_BANK_BASE_URL` | `invest-public-api.tbank.ru:443` | gRPC endpoint T-Bank Invest |
| `T_BANK_CA_CERT_PATH` | `''` | Путь к PEM root CA для gRPC TLS, если локальная сеть подменяет сертификаты |
| `T_BANK_APP_NAME` | `ksv741.moex-vibe` | Metadata приложения для T-Bank |
| `T_BANK_RATE_LIMIT_PER_SECOND` | `5` | Локальный rate limiter для T-Bank |
| `T_BANK_REQUEST_TIMEOUT_MS` | `10000` | Deadline gRPC-запроса (мс) |
| `CACHE_MARKET_DATA_TTL` | `900` | TTL рыночных данных (секунды) |
| `CACHE_HISTORY_TTL` | `3600` | TTL истории торгов (секунды) |
| `CACHE_CANDLES_TTL` | `3600` | TTL свечей (секунды) |
| `CACHE_SECURITY_TTL` | `86400` | TTL спецификации инструмента (секунды) |
| `CACHE_SEARCH_TTL` | `3600` | TTL результатов поиска (секунды) |
| `CACHE_DIVIDENDS_TTL` | `86400` | TTL дивидендов (секунды) |
| `CACHE_TBANK_ACCOUNTS_TTL` | `3600` | TTL списка брокерских счетов T-Bank (секунды) |
| `CACHE_TBANK_PORTFOLIO_TTL` | `60` | TTL брокерского портфеля T-Bank (секунды) |
| `CACHE_TBANK_OPERATIONS_TTL` | `300` | TTL страницы операций T-Bank (секунды) |
| `CACHE_TBANK_INSTRUMENT_TTL` | `86400` | TTL метаданных инструментов T-Bank (секунды) |
## Файл конфигурации
@ -33,6 +43,14 @@ registerAs('app', () => ({
process.env.MOEX_CIRCUIT_BREAKER_RESET_SECONDS || '30', 10,
),
},
tbank: {
token: process.env.T_BANK_TOKEN || '',
baseUrl: process.env.T_BANK_BASE_URL || 'invest-public-api.tbank.ru:443',
caCertPath: process.env.T_BANK_CA_CERT_PATH || '',
appName: process.env.T_BANK_APP_NAME || 'ksv741.moex-vibe',
rateLimitPerSecond: parseInt(process.env.T_BANK_RATE_LIMIT_PER_SECOND || '5', 10),
requestTimeoutMs: parseInt(process.env.T_BANK_REQUEST_TIMEOUT_MS || '10000', 10),
},
cache: {
marketDataTtl: parseInt(process.env.CACHE_MARKET_DATA_TTL || '900', 10),
historyTtl: parseInt(process.env.CACHE_HISTORY_TTL || '3600', 10),
@ -40,6 +58,10 @@ registerAs('app', () => ({
securityTtl: parseInt(process.env.CACHE_SECURITY_TTL || '86400', 10),
searchTtl: parseInt(process.env.CACHE_SEARCH_TTL || '3600', 10),
dividendsTtl: parseInt(process.env.CACHE_DIVIDENDS_TTL || '86400', 10),
tbankAccountsTtl: parseInt(process.env.CACHE_TBANK_ACCOUNTS_TTL || '3600', 10),
tbankPortfolioTtl: parseInt(process.env.CACHE_TBANK_PORTFOLIO_TTL || '60', 10),
tbankOperationsTtl: parseInt(process.env.CACHE_TBANK_OPERATIONS_TTL || '300', 10),
tbankInstrumentTtl: parseInt(process.env.CACHE_TBANK_INSTRUMENT_TTL || '86400', 10),
},
}));
```

View File

@ -8,7 +8,7 @@
flowchart TB
AppModule["AppModule"]
GlobalModules["Глобальные модули<br/>ConfigModule<br/>PrismaModule<br/>CacheModule<br/>MoexClientModule"]
FeatureModules["Feature-модули<br/>HealthModule<br/>AuthModule<br/>PortfolioModule<br/>SecuritiesModule<br/>SharesModule<br/>BondsModule<br/>CandlesModule"]
FeatureModules["Feature-модули<br/>HealthModule<br/>AuthModule<br/>PortfolioModule<br/>SecuritiesModule<br/>SharesModule<br/>BondsModule<br/>CandlesModule<br/>TBankModule"]
AppModule --> GlobalModules
AppModule --> FeatureModules
@ -21,10 +21,12 @@ flowchart TB
AuthModule["AuthModule"]
PortfolioModule["PortfolioModule"]
MarketModules["SecuritiesModule<br/>SharesModule<br/>BondsModule<br/>CandlesModule"]
TBankModule["TBankModule"]
PrismaService["PrismaService"]
CacheService["CacheService"]
MoexClientService["MoexClientService"]
TBankClientService["TBankClientService"]
PrismaModule --> PrismaService
CacheModule --> CacheService
@ -37,6 +39,10 @@ flowchart TB
MarketModules --> CacheService
MarketModules --> MoexClientService
TBankModule --> CacheService
TBankModule --> PrismaService
TBankModule --> TBankClientService
```
## Список модулей
@ -53,6 +59,7 @@ flowchart TB
| `BondsModule` | Нет | `modules/bonds/` | Облигации |
| `CandlesModule` | Нет | `modules/candles/` | Свечи OHLCV |
| `PortfolioModule` | Нет | `modules/portfolio/` | Пользовательские портфели и аналитика |
| `TBankModule` | Нет | `modules/tbank/` | Read-only брокерские портфели T-Bank Invest |
### PrismaModule
@ -131,3 +138,18 @@ flowchart TB
- Обогащение позиций текущими ценами из MOEX
- Расчёт summary, PnL и долей портфеля
- Все endpoints защищены JWT
### TBankModule
Read-only интеграция с T-Bank Invest для брокерских счетов и ИИС.
- `TBankClientService` создаёт gRPC clients по vendored proto-контрактам и добавляет metadata
`Authorization` + `x-app-name`
- `BrokerAccountsService` фильтрует только открытые брокерские счета и ИИС
- `BrokerPortfolioService` объединяет портфель, позиции, cash и метаданные инструментов
- `BrokerOperationsService` отдаёт cursor-paginated историю операций
- `BrokerOperationSyncService` сохраняет историю операций в отдельные Prisma-таблицы
- Broker endpoints требуют роль `user`; текущая single-user версия использует один server-side
`T_BANK_TOKEN`
- Direct-read endpoints используют `CacheService` с T-Bank TTL и не записывают данные в ручной
`PortfolioModule`

View File

@ -3,6 +3,12 @@
`PortfolioModule` позволяет пользователям создавать и вести виртуальные инвестиционные портфели для
аналитики и отслеживания позиций.
## Ручные портфели и брокерские портфели
`PortfolioModule` остаётся доменом ручных виртуальных портфелей. Брокерские счета T-Bank Invest
экспортируются отдельным `TBankModule` под `/api/v1/broker/*` и не сохраняются как записи
`Portfolio`.
## Обзор
- **Backend:** `PortfolioModule` (`apps/backend/src/modules/portfolio/`)

View File

@ -0,0 +1,81 @@
# T-Bank Invest
`TBankModule` — read-only интеграция backend с T-Bank Invest API. Frontend не обращается к
T-Bank напрямую: он использует endpoints MoexVibe под `/api/v1/broker`.
## Область поддержки
Первая версия показывает только открытые брокерские счета и ИИС:
- `ACCOUNT_TYPE_TINKOFF`
- `ACCOUNT_TYPE_TINKOFF_IIS`
Инвесткопилка, счета ЦФА, дебетовые счета, накопительные счета и счета фондов денежного рынка
игнорируются.
## Протокол
MoexVibe использует gRPC endpoint `invest-public-api.tbank.ru:443`. REST-прокси T-Bank считается
инструментом для ручной диагностики, а не основным протоколом интеграции.
Backend отправляет metadata:
```text
Authorization: Bearer <T_BANK_TOKEN>
x-app-name: ksv741.moex-vibe
```
Токен читается из env-переменных backend и не возвращается во frontend, Swagger responses или логи.
Если gRPC-запросы падают с `self-signed certificate in certificate chain`, значит Node.js не доверяет
root CA, которым локальная сеть или proxy подписывает TLS-цепочку. В этом случае нужно экспортировать
доверенный root certificate в PEM-файл и указать путь в `T_BANK_CA_CERT_PATH`. Проверку TLS отключать
не нужно.
## Backend endpoints
Все endpoints защищены JWT, требуют роль `user` и возвращают стандартную оболочку `{ data, meta }`.
| Endpoint | Описание |
|---|---|
| `GET /api/v1/broker/accounts` | Открытые брокерские счета и ИИС |
| `GET /api/v1/broker/accounts/:accountId/portfolio` | Итоги портфеля, позиции, деньги и заблокированные деньги |
| `GET /api/v1/broker/accounts/:accountId/operations` | История операций с cursor pagination |
| `POST /api/v1/broker/accounts/:accountId/operations/sync` | Синхронизация истории операций в локальные Prisma-таблицы |
## Методы T-Bank
| Задача | Метод T-Bank |
|---|---|
| Счета | `UsersService/GetAccounts` |
| Итоги портфеля | `OperationsService/GetPortfolio` |
| Деньги и settled-позиции | `OperationsService/GetPositions` |
| История операций | `OperationsService/GetOperationsByCursor` |
| Метаданные инструментов | `InstrumentsService/GetInstrumentBy` |
## Кеширование и синхронизация
Direct-read endpoints используют короткий in-memory cache, чтобы не спамить T-Bank API при
переключении страниц и повторных запросах:
- счета: `CACHE_TBANK_ACCOUNTS_TTL`
- портфель: `CACHE_TBANK_PORTFOLIO_TTL`
- страницы операций: `CACHE_TBANK_OPERATIONS_TTL`
- метаданные инструментов: `CACHE_TBANK_INSTRUMENT_TTL`
Для долговременной истории операций есть отдельные таблицы Prisma:
- `BrokerOperation` — нормализованная операция и сырой JSON payload;
- `BrokerOperationSyncState` — состояние последней синхронизации по брокерскому счёту.
Синхронизация запускается явно через endpoint `POST .../operations/sync` с query параметрами `from`
и `to` в ISO-8601 формате.
Эти таблицы не связаны с ручными портфелями `Portfolio` и `Position`.
## Безопасность
Текущая версия рассчитана на single-user сценарий: используется один server-side `T_BANK_TOKEN`, а
broker endpoints доступны аутентифицированным пользователям с ролью `user`. Перед multi-user режимом
нужно добавить зашифрованное хранение пользовательских T-Bank токенов и привязку каждого брокерского
счёта к владельцу.

View File

@ -17,6 +17,7 @@ const sidebars: SidebarsConfig = {
'backend/configuration',
'backend/caching',
'backend/moex-client',
'backend/tbank-invest',
'backend/securities',
'backend/portfolio',
],
@ -63,6 +64,7 @@ const sidebars: SidebarsConfig = {
'adr/ADR-008-auth-system',
'adr/ADR-009-portfolio-domain',
'adr/ADR-010-backend-price-computation',
'adr/ADR-011-tbank-invest-grpc',
],
},
],

View File

@ -0,0 +1,27 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { getBrokerOperations } from './broker';
describe('broker api', () => {
afterEach(() => {
vi.restoreAllMocks();
});
it('serializes operations query parameters', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValue({
ok: true,
json: async () => ({
data: {
data: { accountId: 'acc-1', items: [], nextCursor: null, hasNext: false, asOf: 'now' },
meta: { fromCache: false, cachedAt: null },
},
}),
} as Response);
await getBrokerOperations('acc-1', { cursor: 'c1', limit: 50 });
expect(fetch).toHaveBeenCalledWith(
expect.stringContaining('/api/v1/broker/accounts/acc-1/operations?cursor=c1&limit=50'),
expect.any(Object),
);
});
});

View File

@ -0,0 +1,51 @@
import { request } from './client';
import type {
ApiResponseMeta,
BrokerAccount,
BrokerOperationsPage,
BrokerPortfolio,
} from './responses';
export type BrokerOperationQuery = {
from?: string;
to?: string;
cursor?: string;
limit?: number;
instrumentId?: string;
operationTypes?: string;
state?: string;
};
export function getBrokerAccounts(): Promise<{
data: BrokerAccount[];
meta: ApiResponseMeta;
}> {
return request<BrokerAccount[]>('/api/v1/broker/accounts');
}
export function getBrokerPortfolio(accountId: string): Promise<{
data: BrokerPortfolio;
meta: ApiResponseMeta;
}> {
return request<BrokerPortfolio>(
`/api/v1/broker/accounts/${encodeURIComponent(accountId)}/portfolio`,
);
}
export function getBrokerOperations(
accountId: string,
query: BrokerOperationQuery = {},
): Promise<{ data: BrokerOperationsPage; meta: ApiResponseMeta }> {
return request<BrokerOperationsPage>(
`/api/v1/broker/accounts/${encodeURIComponent(accountId)}/operations`,
{
from: query.from,
to: query.to,
cursor: query.cursor,
limit: query.limit ? String(query.limit) : undefined,
instrumentId: query.instrumentId,
operationTypes: query.operationTypes,
state: query.state,
},
);
}

View File

@ -17,6 +17,24 @@ describe('request', () => {
expect(result.data.status).toBe('ok');
});
it('supports the single API envelope shape documented by Swagger', async () => {
server.use(
http.get(`${API}/test-single-envelope`, () =>
HttpResponse.json({
data: { ok: true },
meta: { fromCache: true, cachedAt: '2026-06-17T00:00:00.000Z' },
}),
),
);
const result = await request<{ ok: boolean }>('/api/v1/test-single-envelope');
expect(result).toEqual({
data: { ok: true },
meta: { fromCache: true, cachedAt: '2026-06-17T00:00:00.000Z' },
});
});
it('includes Authorization header when token is set', async () => {
setAccessToken('test-token');
let capturedAuth: string | null = null;

View File

@ -40,14 +40,31 @@ async function refreshTokens(): Promise<boolean> {
credentials: 'include',
});
if (!res.ok) return false;
const json: ApiEnvelope<{ data: AuthResponse; meta: ApiResponseMeta }> = await res.json();
accessToken = json.data.data.accessToken;
const json = await res.json();
accessToken = normalizeEnvelope<AuthResponse>(json).data.accessToken;
return true;
} catch {
return false;
}
}
function normalizeEnvelope<T>(json: unknown): { data: T; meta: ApiResponseMeta } {
const envelope = json as ApiEnvelope<T | { data: T; meta: ApiResponseMeta }>;
if (
envelope.data &&
typeof envelope.data === 'object' &&
'data' in envelope.data &&
'meta' in envelope.data
) {
return envelope.data as { data: T; meta: ApiResponseMeta };
}
return {
data: envelope.data as T,
meta: envelope.meta,
};
}
async function handleUnauthorized(): Promise<boolean> {
if (isRefreshing && refreshPromise) {
return refreshPromise;
@ -114,8 +131,8 @@ export async function request<T>(
throw new Error(`Ошибка API: ${res.status} ${res.statusText}${text ? ` - ${text}` : ''}`);
}
const json: ApiEnvelope<{ data: T; meta: ApiResponseMeta }> = await res.json();
return json.data;
const json = await res.json();
return normalizeEnvelope<T>(json);
}
export function getHealth(): Promise<{ data: HealthResponse; meta: ApiResponseMeta }> {

View File

@ -245,3 +245,94 @@ export interface ScreenerResult {
pageSize: number;
totalPages: number;
}
export interface BrokerMoney {
currency: string;
units: string;
nano: number;
value: number;
}
export interface BrokerAccount {
id: string;
type: 'brokerage' | 'iis';
name: string;
status: string;
openedAt: string | null;
accessLevel: string | null;
}
export interface BrokerPosition {
figi: string | null;
instrumentUid: string | null;
positionUid: string | null;
ticker: string | null;
classCode: string | null;
instrumentType: string | null;
name: string | null;
quantity: number | null;
blockedLots: number | null;
currentPrice: BrokerMoney | null;
currentValue: BrokerMoney | null;
averagePositionPrice: BrokerMoney | null;
expectedYieldPercent: number | null;
dailyYield: BrokerMoney | null;
}
export interface 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;
}
export type BrokerOperationCategory = 'trade' | 'income' | 'tax' | 'fee' | 'transfer' | 'other';
export interface BrokerOperation {
cursor: string | null;
accountId: string;
id: string | null;
parentOperationId: string | null;
date: string | null;
type: string;
category: BrokerOperationCategory;
description: string | null;
state: string | null;
instrumentUid: string | null;
figi: string | null;
ticker: string | null;
classCode: string | null;
instrumentType: string | null;
payment: BrokerMoney | null;
price: BrokerMoney | null;
commission: BrokerMoney | null;
yield: BrokerMoney | null;
accruedInt: BrokerMoney | null;
quantity: number | null;
quantityDone: number | null;
}
export interface BrokerOperationsPage {
accountId: string;
items: BrokerOperation[];
nextCursor: string | null;
hasNext: boolean;
asOf: string;
}

View File

@ -383,6 +383,74 @@ export interface paths {
patch?: never;
trace?: never;
};
'/api/v1/broker/accounts': {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Get open T-Bank brokerage and IIS accounts */
get: operations['TBankController_getAccounts'];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
'/api/v1/broker/accounts/{accountId}/portfolio': {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Get T-Bank broker account portfolio with cash and positions */
get: operations['TBankController_getPortfolio'];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
'/api/v1/broker/accounts/{accountId}/operations': {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Get paginated T-Bank broker account operations */
get: operations['TBankController_getOperations'];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
'/api/v1/broker/accounts/{accountId}/operations/sync': {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Synchronize T-Bank broker account operations into local history */
post: operations['TBankController_syncOperations'];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
}
export type webhooks = Record<string, never>;
export interface components {
@ -689,6 +757,117 @@ export interface components {
data: components['schemas']['AnalyticsResponseDto'];
meta: components['schemas']['PortfolioResponseMetaDto'];
};
BrokerAccountResponseDto: {
id: string;
/** @enum {string} */
type: 'brokerage' | 'iis';
name: string;
status: string;
openedAt: Record<string, never> | null;
accessLevel: Record<string, never> | null;
};
BrokerResponseMetaDto: {
cachedAt: Record<string, never> | null;
fromCache: boolean;
};
BrokerAccountsEnvelopeDto: {
data: components['schemas']['BrokerAccountResponseDto'][];
meta: components['schemas']['BrokerResponseMetaDto'];
};
BrokerMoneyDto: {
currency: string;
units: string;
nano: number;
value: number;
};
BrokerPortfolioTotalsDto: {
shares: components['schemas']['BrokerMoneyDto'] | null;
bonds: components['schemas']['BrokerMoneyDto'] | null;
etf: components['schemas']['BrokerMoneyDto'] | null;
currencies: components['schemas']['BrokerMoneyDto'] | null;
futures: components['schemas']['BrokerMoneyDto'] | null;
options: components['schemas']['BrokerMoneyDto'] | null;
structuredProducts: components['schemas']['BrokerMoneyDto'] | null;
dfa: components['schemas']['BrokerMoneyDto'] | null;
portfolio: components['schemas']['BrokerMoneyDto'] | null;
};
BrokerPortfolioYieldsDto: {
expectedPercent: Record<string, never> | null;
daily: components['schemas']['BrokerMoneyDto'] | null;
dailyPercent: Record<string, never> | null;
};
BrokerPositionResponseDto: {
figi: Record<string, never> | null;
instrumentUid: Record<string, never> | null;
positionUid: Record<string, never> | null;
ticker: Record<string, never> | null;
classCode: Record<string, never> | null;
instrumentType: Record<string, never> | null;
name: Record<string, never> | null;
quantity: Record<string, never> | null;
blockedLots: Record<string, never> | null;
currentPrice: components['schemas']['BrokerMoneyDto'] | null;
currentValue: components['schemas']['BrokerMoneyDto'] | null;
averagePositionPrice: components['schemas']['BrokerMoneyDto'] | null;
expectedYieldPercent: Record<string, never> | null;
dailyYield: components['schemas']['BrokerMoneyDto'] | null;
};
BrokerPortfolioResponseDto: {
account: components['schemas']['BrokerAccountResponseDto'];
totals: components['schemas']['BrokerPortfolioTotalsDto'];
yields: components['schemas']['BrokerPortfolioYieldsDto'];
cash: components['schemas']['BrokerMoneyDto'][];
blockedCash: components['schemas']['BrokerMoneyDto'][];
positions: components['schemas']['BrokerPositionResponseDto'][];
asOf: string;
};
BrokerPortfolioEnvelopeDto: {
data: components['schemas']['BrokerPortfolioResponseDto'];
meta: components['schemas']['BrokerResponseMetaDto'];
};
BrokerOperationResponseDto: {
cursor: Record<string, never> | null;
accountId: string;
id: Record<string, never> | null;
parentOperationId: Record<string, never> | null;
date: Record<string, never> | null;
type: string;
/** @enum {string} */
category: 'trade' | 'income' | 'tax' | 'fee' | 'transfer' | 'other';
description: Record<string, never> | null;
state: Record<string, never> | null;
instrumentUid: Record<string, never> | null;
figi: Record<string, never> | null;
ticker: Record<string, never> | null;
classCode: Record<string, never> | null;
instrumentType: Record<string, never> | null;
payment: components['schemas']['BrokerMoneyDto'] | null;
price: components['schemas']['BrokerMoneyDto'] | null;
commission: components['schemas']['BrokerMoneyDto'] | null;
yield: components['schemas']['BrokerMoneyDto'] | null;
accruedInt: components['schemas']['BrokerMoneyDto'] | null;
quantity: Record<string, never> | null;
quantityDone: Record<string, never> | null;
};
BrokerOperationsPageResponseDto: {
accountId: string;
items: components['schemas']['BrokerOperationResponseDto'][];
nextCursor: Record<string, never> | null;
hasNext: boolean;
asOf: string;
};
BrokerOperationsEnvelopeDto: {
data: components['schemas']['BrokerOperationsPageResponseDto'];
meta: components['schemas']['BrokerResponseMetaDto'];
};
BrokerOperationSyncResponseDto: {
/** @example 42 */
upserted: number;
};
BrokerOperationSyncEnvelopeDto: {
data: components['schemas']['BrokerOperationSyncResponseDto'];
meta: components['schemas']['BrokerResponseMetaDto'];
};
};
responses: never;
parameters: never;
@ -1300,4 +1479,97 @@ export interface operations {
};
};
};
TBankController_getAccounts: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content: {
'application/json': components['schemas']['BrokerAccountsEnvelopeDto'];
};
};
};
};
TBankController_getPortfolio: {
parameters: {
query?: never;
header?: never;
path: {
accountId: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content: {
'application/json': components['schemas']['BrokerPortfolioEnvelopeDto'];
};
};
};
};
TBankController_getOperations: {
parameters: {
query?: {
from?: string;
to?: string;
cursor?: string;
limit?: number;
instrumentId?: string;
operationTypes?: string;
state?: string;
};
header?: never;
path: {
accountId: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content: {
'application/json': components['schemas']['BrokerOperationsEnvelopeDto'];
};
};
};
};
TBankController_syncOperations: {
parameters: {
query: {
from: string;
to: string;
};
header?: never;
path: {
accountId: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content: {
'application/json': components['schemas']['BrokerOperationSyncEnvelopeDto'];
};
};
};
};
}

View File

@ -20,6 +20,7 @@ export function Layout() {
padding: '12px 24px',
display: 'flex',
alignItems: 'center',
flexWrap: 'wrap',
gap: 24,
}}
>
@ -34,7 +35,9 @@ export function Layout() {
>
MoexVibe
</Link>
<div style={{ flex: '1 1 280px', minWidth: 220, maxWidth: 420 }}>
<SearchBar />
</div>
<Link
to="/portfolios"
style={{
@ -46,6 +49,17 @@ export function Layout() {
>
Портфели
</Link>
<Link
to="/broker"
style={{
fontSize: 14,
color: 'var(--color-text)',
textDecoration: 'none',
fontWeight: 500,
}}
>
Брокер
</Link>
<Link
to="/screener"
style={{
@ -58,7 +72,15 @@ export function Layout() {
Скринер
</Link>
<div style={{ marginLeft: 'auto', display: 'flex', alignItems: 'center', gap: 12 }}>
<div
style={{
marginLeft: 'auto',
display: 'flex',
alignItems: 'center',
gap: 12,
flexWrap: 'wrap',
}}
>
{isAuthenticated ? (
<>
<Link
@ -104,7 +126,15 @@ export function Layout() {
)}
</div>
</header>
<main style={{ flex: 1, padding: 24, maxWidth: 1200, width: '100%', margin: '0 auto' }}>
<main
style={{
flex: 1,
padding: 'clamp(16px, 4vw, 24px)',
maxWidth: 1200,
width: '100%',
margin: '0 auto',
}}
>
<Outlet />
</main>
</div>

View File

@ -38,7 +38,7 @@ export function PortfolioCard({ portfolio }: { portfolio: Portfolio }) {
<h3 style={{ margin: 0, fontSize: 16, fontWeight: 600, flex: 1 }}>{portfolio.name}</h3>
<div style={{ textAlign: 'right' }}>
<div style={{ fontSize: 20, fontWeight: 700, lineHeight: 1.2 }}>
{portfolio.totalValue.toLocaleString('ru-RU', {
{portfolio.totalValue?.toLocaleString('ru-RU', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}

View File

@ -19,7 +19,7 @@ export function PortfolioSummary({ portfolio }: { portfolio: PortfolioDetail })
Общая стоимость
</div>
<div style={{ fontSize: 24, fontWeight: 700 }}>
{portfolio.totalValue.toLocaleString('ru-RU', {
{portfolio.totalValue?.toLocaleString('ru-RU', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}

View File

@ -0,0 +1,41 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { renderHook, waitFor } from '@testing-library/react';
import { type ReactNode } from 'react';
import { describe, expect, it, vi } from 'vitest';
import { getBrokerAccounts } from '../api/broker';
import { useBrokerAccounts } from './useBrokerAccounts';
vi.mock('../api/broker', () => ({
getBrokerAccounts: vi.fn(),
}));
function createWrapper() {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return function Wrapper({ children }: { children: ReactNode }) {
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
};
}
describe('useBrokerAccounts', () => {
it('returns broker accounts from API', async () => {
vi.mocked(getBrokerAccounts).mockResolvedValue({
data: [
{
id: 'acc-1',
type: 'brokerage',
name: 'Broker',
status: 'ACCOUNT_STATUS_OPEN',
openedAt: null,
accessLevel: null,
},
],
meta: { fromCache: false, cachedAt: null },
});
const { result } = renderHook(() => useBrokerAccounts(), { wrapper: createWrapper() });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data?.[0].name).toBe('Broker');
});
});

View File

@ -0,0 +1,13 @@
import { useQuery } from '@tanstack/react-query';
import { getBrokerAccounts } from '../api/broker';
import type { BrokerAccount } from '../api/responses';
export function useBrokerAccounts() {
return useQuery<BrokerAccount[]>({
queryKey: ['broker', 'accounts'],
queryFn: async () => (await getBrokerAccounts()).data,
staleTime: 3_600_000,
retry: 2,
refetchOnWindowFocus: false,
});
}

View File

@ -0,0 +1,17 @@
import { useQuery } from '@tanstack/react-query';
import { getBrokerOperations, type BrokerOperationQuery } from '../api/broker';
import type { BrokerOperationsPage } from '../api/responses';
export function useBrokerOperations(
accountId: string | undefined,
query: BrokerOperationQuery = {},
) {
return useQuery<BrokerOperationsPage>({
queryKey: ['broker', 'operations', accountId, query],
enabled: Boolean(accountId),
queryFn: async () => (await getBrokerOperations(accountId!, query)).data,
staleTime: 300_000,
retry: 2,
refetchOnWindowFocus: false,
});
}

View File

@ -0,0 +1,14 @@
import { useQuery } from '@tanstack/react-query';
import { getBrokerPortfolio } from '../api/broker';
import type { BrokerPortfolio } from '../api/responses';
export function useBrokerPortfolio(accountId: string | undefined) {
return useQuery<BrokerPortfolio>({
queryKey: ['broker', 'portfolio', accountId],
enabled: Boolean(accountId),
queryFn: async () => (await getBrokerPortfolio(accountId!)).data,
staleTime: 60_000,
retry: 2,
refetchOnWindowFocus: false,
});
}

View File

@ -0,0 +1,179 @@
import { useParams } from 'react-router-dom';
import type { BrokerMoney } from '../../api/responses';
import { useBrokerOperations } from '../../hooks/useBrokerOperations';
import { useBrokerPortfolio } from '../../hooks/useBrokerPortfolio';
function formatMoney(value: BrokerMoney | null | undefined) {
if (!value) return '-';
return new Intl.NumberFormat('ru-RU', {
style: 'currency',
currency: value.currency || 'RUB',
maximumFractionDigits: 2,
}).format(value.value);
}
function formatDate(value: string | null) {
if (!value) return '-';
return new Date(value).toLocaleString('ru-RU');
}
const tableStyle = {
width: '100%',
borderCollapse: 'collapse',
fontSize: 14,
} satisfies React.CSSProperties;
const thStyle = {
borderBottom: '1px solid #e0e0e0',
color: 'var(--color-text-secondary)',
fontWeight: 600,
padding: '10px 8px',
} satisfies React.CSSProperties;
const tdStyle = {
borderBottom: '1px solid #eeeeee',
padding: '10px 8px',
verticalAlign: 'top',
} satisfies React.CSSProperties;
export function BrokerAccountDetailPage() {
const { accountId } = useParams();
const portfolio = useBrokerPortfolio(accountId);
const operations = useBrokerOperations(accountId, { limit: 100 });
if (portfolio.isLoading) return <p>Загрузка портфеля...</p>;
if (portfolio.error || !portfolio.data) {
return <p style={{ color: 'var(--color-negative)' }}>Не удалось загрузить портфель</p>;
}
return (
<div style={{ display: 'grid', gap: 24 }}>
<header>
<h1 style={{ fontSize: 28, lineHeight: 1.2, marginBottom: 12 }}>
{portfolio.data.account.name}
</h1>
<div style={{ display: 'flex', gap: 16, flexWrap: 'wrap', alignItems: 'baseline' }}>
<strong style={{ fontSize: 24 }}>{formatMoney(portfolio.data.totals.portfolio)}</strong>
<span style={{ color: 'var(--color-text-secondary)' }}>
День: {formatMoney(portfolio.data.yields.daily)}
</span>
<span style={{ color: 'var(--color-text-secondary)' }}>
Ожидаемая: {portfolio.data.yields.expectedPercent ?? '-'}%
</span>
</div>
</header>
<section
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))',
gap: 12,
}}
>
{portfolio.data.cash.map((money) => (
<div
key={money.currency}
style={{
background: 'var(--color-surface)',
border: '1px solid #e0e0e0',
borderRadius: 8,
padding: 16,
}}
>
<div style={{ color: 'var(--color-text-secondary)', fontSize: 13 }}>
{money.currency}
</div>
<strong>{formatMoney(money)}</strong>
</div>
))}
</section>
<section>
<h2 style={{ fontSize: 20, marginBottom: 12 }}>Позиции</h2>
<div style={{ overflowX: 'auto', background: 'var(--color-surface)' }}>
<table style={tableStyle}>
<thead>
<tr>
<th align="left" style={thStyle}>
Инструмент
</th>
<th align="right" style={thStyle}>
Количество
</th>
<th align="right" style={thStyle}>
Стоимость
</th>
<th align="right" style={thStyle}>
Доходность
</th>
</tr>
</thead>
<tbody>
{portfolio.data.positions.map((position) => (
<tr key={position.positionUid || position.instrumentUid || position.ticker}>
<td style={tdStyle}>
<strong>{position.ticker || position.name || position.figi}</strong>
{position.name && (
<div style={{ color: 'var(--color-text-secondary)' }}>{position.name}</div>
)}
</td>
<td align="right" style={tdStyle}>
{position.quantity ?? '-'}
</td>
<td align="right" style={tdStyle}>
{formatMoney(position.currentValue)}
</td>
<td align="right" style={tdStyle}>
{position.expectedYieldPercent ?? '-'}%
</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
<section>
<h2 style={{ fontSize: 20, marginBottom: 12 }}>Операции</h2>
{operations.isLoading ? (
<p>Загрузка операций...</p>
) : (
<div style={{ overflowX: 'auto', background: 'var(--color-surface)' }}>
<table style={tableStyle}>
<thead>
<tr>
<th align="left" style={thStyle}>
Дата
</th>
<th align="left" style={thStyle}>
Тип
</th>
<th align="left" style={thStyle}>
Инструмент
</th>
<th align="right" style={thStyle}>
Сумма
</th>
</tr>
</thead>
<tbody>
{(operations.data?.items ?? []).map((operation) => (
<tr key={operation.cursor || operation.id}>
<td style={tdStyle}>{formatDate(operation.date)}</td>
<td style={tdStyle}>{operation.type}</td>
<td style={tdStyle}>{operation.ticker || operation.description || '-'}</td>
<td align="right" style={tdStyle}>
{formatMoney(operation.payment)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
</div>
);
}

View File

@ -0,0 +1,50 @@
import { Link } from 'react-router-dom';
import { useBrokerAccounts } from '../../hooks/useBrokerAccounts';
const cardStyle = {
display: 'block',
padding: 20,
background: 'var(--color-surface)',
border: '1px solid #e0e0e0',
borderRadius: 8,
color: 'var(--color-text)',
textDecoration: 'none',
boxShadow: 'var(--shadow)',
} satisfies React.CSSProperties;
export function BrokerAccountsPage() {
const { data: accounts, isLoading, error } = useBrokerAccounts();
if (isLoading) return <p>Загрузка брокерских счетов...</p>;
if (error) return <p style={{ color: 'var(--color-negative)' }}>Не удалось загрузить счета</p>;
return (
<div>
<div style={{ display: 'flex', alignItems: 'baseline', gap: 12, marginBottom: 20 }}>
<h1 style={{ fontSize: 28, lineHeight: 1.2 }}>Брокерские счета</h1>
<span style={{ color: 'var(--color-text-secondary)', fontSize: 14 }}>
{(accounts ?? []).length}
</span>
</div>
<div
style={{
display: 'grid',
gap: 16,
gridTemplateColumns: 'repeat(auto-fit, minmax(260px, 1fr))',
}}
>
{(accounts ?? []).map((account) => (
<Link key={account.id} to={`/broker/${encodeURIComponent(account.id)}`} style={cardStyle}>
<div style={{ fontSize: 18, fontWeight: 700, marginBottom: 10 }}>{account.name}</div>
<div style={{ display: 'grid', gap: 6, color: 'var(--color-text-secondary)' }}>
<span>{account.type === 'iis' ? 'ИИС' : 'Брокерский счет'}</span>
<span>{account.status}</span>
<span>{account.id}</span>
</div>
</Link>
))}
</div>
</div>
);
}

View File

@ -0,0 +1,137 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { render, screen } from '@testing-library/react';
import { type ReactElement } from 'react';
import { MemoryRouter, Route, Routes } from 'react-router-dom';
import { describe, expect, it, vi } from 'vitest';
import * as accountHook from '../../hooks/useBrokerAccounts';
import * as operationsHook from '../../hooks/useBrokerOperations';
import * as portfolioHook from '../../hooks/useBrokerPortfolio';
import { BrokerAccountDetailPage } from './BrokerAccountDetailPage';
import { BrokerAccountsPage } from './BrokerAccountsPage';
function renderWithClient(ui: ReactElement, initialEntries = ['/broker']) {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={client}>
<MemoryRouter initialEntries={initialEntries}>{ui}</MemoryRouter>
</QueryClientProvider>,
);
}
describe('Broker pages', () => {
it('renders broker and IIS accounts', () => {
vi.spyOn(accountHook, 'useBrokerAccounts').mockReturnValue({
data: [
{
id: 'acc-1',
type: 'brokerage',
name: 'Broker',
status: 'ACCOUNT_STATUS_OPEN',
openedAt: null,
accessLevel: null,
},
{
id: 'acc-2',
type: 'iis',
name: 'IIS',
status: 'ACCOUNT_STATUS_OPEN',
openedAt: null,
accessLevel: null,
},
],
isLoading: false,
error: null,
} as any);
renderWithClient(<BrokerAccountsPage />);
expect(screen.getByText('Broker')).toBeInTheDocument();
expect(screen.getByText('IIS')).toBeInTheDocument();
});
it('renders positions and operations for account detail', () => {
vi.spyOn(portfolioHook, 'useBrokerPortfolio').mockReturnValue({
data: {
account: {
id: 'acc-1',
type: 'brokerage',
name: 'Broker',
status: 'ACCOUNT_STATUS_OPEN',
openedAt: null,
accessLevel: null,
},
totals: { portfolio: { currency: 'RUB', units: '1000', nano: 0, value: 1000 } },
yields: { expectedPercent: 5, daily: null, dailyPercent: null },
cash: [{ currency: 'RUB', units: '100', nano: 0, value: 100 }],
blockedCash: [],
positions: [
{
figi: null,
instrumentUid: 'uid-1',
positionUid: null,
ticker: 'SBER',
classCode: 'TQBR',
instrumentType: 'share',
name: 'Sberbank',
quantity: 10,
blockedLots: null,
currentPrice: null,
currentValue: { currency: 'RUB', units: '1000', nano: 0, value: 1000 },
averagePositionPrice: null,
expectedYieldPercent: null,
dailyYield: null,
},
],
asOf: '2026-06-16T00:00:00.000Z',
},
isLoading: false,
error: null,
} as any);
vi.spyOn(operationsHook, 'useBrokerOperations').mockReturnValue({
data: {
accountId: 'acc-1',
items: [
{
cursor: 'cursor-1',
accountId: 'acc-1',
id: 'op-1',
parentOperationId: null,
date: '2026-06-16T00:00:00.000Z',
category: 'trade',
type: 'OPERATION_TYPE_BUY',
description: 'Buy',
state: 'OPERATION_STATE_EXECUTED',
instrumentUid: 'uid-1',
figi: null,
ticker: 'SBER',
classCode: 'TQBR',
instrumentType: 'share',
payment: { currency: 'RUB', units: '-1000', nano: 0, value: -1000 },
price: null,
commission: null,
yield: null,
accruedInt: null,
quantity: 10,
quantityDone: 10,
},
],
nextCursor: null,
hasNext: false,
asOf: '2026-06-16T00:00:00.000Z',
},
isLoading: false,
error: null,
} as any);
renderWithClient(
<Routes>
<Route path="/broker/:accountId" element={<BrokerAccountDetailPage />} />
</Routes>,
['/broker/acc-1'],
);
expect(screen.getAllByText('SBER').length).toBeGreaterThan(0);
expect(screen.getByText('OPERATION_TYPE_BUY')).toBeInTheDocument();
});
});

View File

@ -10,6 +10,8 @@ import { ProtectedRoute } from './components/ProtectedRoute';
import { PortfoliosListPage } from './pages/portfolios/PortfoliosListPage';
import { PortfolioDetailPage } from './pages/portfolios/PortfolioDetailPage';
import { ScreenerPage } from './pages/screener/ScreenerPage';
import { BrokerAccountsPage } from './pages/broker/BrokerAccountsPage';
import { BrokerAccountDetailPage } from './pages/broker/BrokerAccountDetailPage';
export function AppRoutes() {
return (
@ -45,6 +47,22 @@ export function AppRoutes() {
</ProtectedRoute>
}
/>
<Route
path="/broker"
element={
<ProtectedRoute>
<BrokerAccountsPage />
</ProtectedRoute>
}
/>
<Route
path="/broker/:accountId"
element={
<ProtectedRoute>
<BrokerAccountDetailPage />
</ProtectedRoute>
}
/>
</Route>
</Routes>
);

View File

@ -0,0 +1,394 @@
# Проектирование брокерских портфелей 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.

151
package-lock.json generated
View File

@ -21,6 +21,8 @@
"version": "0.0.1",
"hasInstallScript": true,
"dependencies": {
"@grpc/grpc-js": "^1.14.4",
"@grpc/proto-loader": "^0.8.1",
"@libsql/client": "^0.17.3",
"@nestjs/axios": "^3.0.0",
"@nestjs/cache-manager": "^2.0.0",
@ -38,7 +40,9 @@
"class-transformer": "^0.5.0",
"class-validator": "^0.14.0",
"cookie-parser": "^1.4.7",
"long": "^5.3.2",
"p-queue": "^7.3.0",
"protobufjs": "^8.6.4",
"reflect-metadata": "^0.1.13",
"rxjs": "^7.8.0",
"swagger-ui-express": "^5.0.0"
@ -6361,6 +6365,60 @@
}
}
},
"node_modules/@grpc/grpc-js": {
"version": "1.14.4",
"resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.4.tgz",
"integrity": "sha512-k9Dj3DV/itK9D06Y8f190Qgop7/Ui+D0njFV3LHMPwPT75DpXLQohE9Wmz0QElrJnzsjB7KPWiKJbOl7IPDArQ==",
"license": "Apache-2.0",
"dependencies": {
"@grpc/proto-loader": "^0.8.0",
"@js-sdsl/ordered-map": "^4.4.2"
},
"engines": {
"node": ">=12.10.0"
}
},
"node_modules/@grpc/proto-loader": {
"version": "0.8.1",
"resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.8.1.tgz",
"integrity": "sha512-wtF6h+DY6M3YaDBPAmvuuA6jV8Sif9MjtOI5euKFWRgCDl5PeDpPsHR9u2l6St5ceY8AZgoNDww5+HvEsXFsGg==",
"license": "Apache-2.0",
"dependencies": {
"lodash.camelcase": "^4.3.0",
"long": "^5.0.0",
"protobufjs": "^7.5.5",
"yargs": "^17.7.2"
},
"bin": {
"proto-loader-gen-types": "build/bin/proto-loader-gen-types.js"
},
"engines": {
"node": ">=6"
}
},
"node_modules/@grpc/proto-loader/node_modules/protobufjs": {
"version": "7.6.4",
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.4.tgz",
"integrity": "sha512-RJJPTTpvFfHcWLkIa2JFWK4XvtSzS0yEWDmunqHXli1h3JlkbcQZXDZdcWxv+JK3Xsl5/UFDPZ0iGm7DAengYw==",
"hasInstallScript": true,
"license": "BSD-3-Clause",
"dependencies": {
"@protobufjs/aspromise": "^1.1.2",
"@protobufjs/base64": "^1.1.2",
"@protobufjs/codegen": "^2.0.5",
"@protobufjs/eventemitter": "^1.1.1",
"@protobufjs/fetch": "^1.1.1",
"@protobufjs/float": "^1.0.2",
"@protobufjs/path": "^1.1.2",
"@protobufjs/pool": "^1.1.0",
"@protobufjs/utf8": "^1.1.1",
"@types/node": ">=13.7.0",
"long": "^5.3.2"
},
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/@hapi/hoek": {
"version": "9.3.0",
"resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz",
@ -6762,6 +6820,16 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@js-sdsl/ordered-map": {
"version": "4.4.2",
"resolved": "https://registry.npmjs.org/@js-sdsl/ordered-map/-/ordered-map-4.4.2.tgz",
"integrity": "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/js-sdsl"
}
},
"node_modules/@kurkle/color": {
"version": "0.3.4",
"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
@ -7978,6 +8046,63 @@
"react-dom": "^18.0.0 || ^19.0.0"
}
},
"node_modules/@protobufjs/aspromise": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz",
"integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/base64": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz",
"integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/codegen": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz",
"integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/eventemitter": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz",
"integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/fetch": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz",
"integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==",
"license": "BSD-3-Clause",
"dependencies": {
"@protobufjs/aspromise": "^1.1.1"
}
},
"node_modules/@protobufjs/float": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz",
"integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/path": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz",
"integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/pool": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz",
"integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/utf8": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.1.tgz",
"integrity": "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==",
"license": "BSD-3-Clause"
},
"node_modules/@radix-ui/primitive": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz",
@ -12728,7 +12853,6 @@
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
"integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==",
"dev": true,
"license": "ISC",
"dependencies": {
"string-width": "^4.2.0",
@ -12743,7 +12867,6 @@
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
@ -16914,7 +17037,6 @@
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
"dev": true,
"license": "ISC",
"engines": {
"node": "6.* || 8.* || >= 10.*"
@ -20172,6 +20294,12 @@
"integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==",
"license": "MIT"
},
"node_modules/lodash.camelcase": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz",
"integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==",
"license": "MIT"
},
"node_modules/lodash.clonedeep": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz",
@ -20469,7 +20597,6 @@
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz",
"integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==",
"devOptional": true,
"license": "Apache-2.0"
},
"node_modules/longest-streak": {
@ -26222,6 +26349,18 @@
"integrity": "sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==",
"license": "ISC"
},
"node_modules/protobufjs": {
"version": "8.6.4",
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-8.6.4.tgz",
"integrity": "sha512-/+XMv9JalknuncEJSwsyEVlwcxVLKx2iaoSUXFZA86MJkdqyOdfrlB1sB7S6aKyUk9tl20YY+SgQe5J2sJHTcg==",
"license": "BSD-3-Clause",
"dependencies": {
"long": "^5.3.2"
},
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/proxy-addr": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
@ -27399,7 +27538,6 @@
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@ -31824,7 +31962,6 @@
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
"integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=10"
@ -31856,7 +31993,6 @@
"version": "17.7.2",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
"integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==",
"dev": true,
"license": "MIT",
"dependencies": {
"cliui": "^8.0.1",
@ -31875,7 +32011,6 @@
"version": "21.1.1",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
"integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=12"