diff --git a/AGENTS.md b/AGENTS.md index eb821eb..89ad694 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 свечей (с) | diff --git a/apps/backend/certs/russian-trusted-root-ca.pem b/apps/backend/certs/russian-trusted-root-ca.pem new file mode 100644 index 0000000..fe7ad30 --- /dev/null +++ b/apps/backend/certs/russian-trusted-root-ca.pem @@ -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----- diff --git a/apps/backend/nest-cli.json b/apps/backend/nest-cli.json index 56167b3..82f7afe 100644 --- a/apps/backend/nest-cli.json +++ b/apps/backend/nest-cli.json @@ -1,4 +1,13 @@ { "collection": "@nestjs/schematics", - "sourceRoot": "src" + "sourceRoot": "src", + "compilerOptions": { + "assets": [ + { + "include": "modules/tbank/proto/contracts/**/*", + "outDir": "dist" + } + ], + "watchAssets": true + } } diff --git a/apps/backend/package.json b/apps/backend/package.json index c194228..561d622 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -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" diff --git a/apps/backend/prisma/migrations/20260616201023_add_broker_operations/migration.sql b/apps/backend/prisma/migrations/20260616201023_add_broker_operations/migration.sql new file mode 100644 index 0000000..ffc9ad1 --- /dev/null +++ b/apps/backend/prisma/migrations/20260616201023_add_broker_operations/migration.sql @@ -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"); diff --git a/apps/backend/prisma/schema.prisma b/apps/backend/prisma/schema.prisma index 59f70ab..8645590 100644 --- a/apps/backend/prisma/schema.prisma +++ b/apps/backend/prisma/schema.prisma @@ -7,35 +7,35 @@ datasource db { } model Portfolio { - id Int @id @default(autoincrement()) + id Int @id @default(autoincrement()) userId Int name String description String? - currency String @default("RUB") + currency String @default("RUB") targets String? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - positions Position[] + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + positions Position[] @@unique([userId, name]) } model Position { - id Int @id @default(autoincrement()) + id Int @id @default(autoincrement()) portfolioId Int secid String - type String @default("share") + type String @default("share") quantity Int buyPrice Float? buyDate DateTime? notes String? tags String? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt - portfolio Portfolio @relation(fields: [portfolioId], references: [id], onDelete: Cascade) + portfolio Portfolio @relation(fields: [portfolioId], references: [id], onDelete: Cascade) @@unique([portfolioId, secid]) } @@ -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 +} diff --git a/apps/backend/src/app.module.ts b/apps/backend/src/app.module.ts index 9329973..06db044 100644 --- a/apps/backend/src/app.module.ts +++ b/apps/backend/src/app.module.ts @@ -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 {} diff --git a/apps/backend/src/config/configuration.ts b/apps/backend/src/config/configuration.ts index 028b210..cb3472a 100644 --- a/apps/backend/src/config/configuration.ts +++ b/apps/backend/src/config/configuration.ts @@ -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', diff --git a/apps/backend/src/modules/tbank/dto/broker-account-response.dto.ts b/apps/backend/src/modules/tbank/dto/broker-account-response.dto.ts new file mode 100644 index 0000000..f5ce7dc --- /dev/null +++ b/apps/backend/src/modules/tbank/dto/broker-account-response.dto.ts @@ -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; +} diff --git a/apps/backend/src/modules/tbank/dto/broker-envelope.dto.ts b/apps/backend/src/modules/tbank/dto/broker-envelope.dto.ts new file mode 100644 index 0000000..d98eb0a --- /dev/null +++ b/apps/backend/src/modules/tbank/dto/broker-envelope.dto.ts @@ -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; +} diff --git a/apps/backend/src/modules/tbank/dto/broker-money.dto.ts b/apps/backend/src/modules/tbank/dto/broker-money.dto.ts new file mode 100644 index 0000000..6e68bb4 --- /dev/null +++ b/apps/backend/src/modules/tbank/dto/broker-money.dto.ts @@ -0,0 +1,15 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class BrokerMoneyDto { + @ApiProperty() + currency!: string; + + @ApiProperty() + units!: string; + + @ApiProperty() + nano!: number; + + @ApiProperty() + value!: number; +} diff --git a/apps/backend/src/modules/tbank/dto/broker-operation-query.dto.ts b/apps/backend/src/modules/tbank/dto/broker-operation-query.dto.ts new file mode 100644 index 0000000..a56cc8f --- /dev/null +++ b/apps/backend/src/modules/tbank/dto/broker-operation-query.dto.ts @@ -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; +} diff --git a/apps/backend/src/modules/tbank/dto/broker-operation-response.dto.ts b/apps/backend/src/modules/tbank/dto/broker-operation-response.dto.ts new file mode 100644 index 0000000..d6b365d --- /dev/null +++ b/apps/backend/src/modules/tbank/dto/broker-operation-response.dto.ts @@ -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; +} diff --git a/apps/backend/src/modules/tbank/dto/broker-operation-sync-query.dto.ts b/apps/backend/src/modules/tbank/dto/broker-operation-sync-query.dto.ts new file mode 100644 index 0000000..a99b85d --- /dev/null +++ b/apps/backend/src/modules/tbank/dto/broker-operation-sync-query.dto.ts @@ -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; +} diff --git a/apps/backend/src/modules/tbank/dto/broker-portfolio-response.dto.ts b/apps/backend/src/modules/tbank/dto/broker-portfolio-response.dto.ts new file mode 100644 index 0000000..e86f56c --- /dev/null +++ b/apps/backend/src/modules/tbank/dto/broker-portfolio-response.dto.ts @@ -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; +} diff --git a/apps/backend/src/modules/tbank/mappers/account.mapper.spec.ts b/apps/backend/src/modules/tbank/mappers/account.mapper.spec.ts new file mode 100644 index 0000000..a623894 --- /dev/null +++ b/apps/backend/src/modules/tbank/mappers/account.mapper.spec.ts @@ -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', + }); + }); +}); diff --git a/apps/backend/src/modules/tbank/mappers/account.mapper.ts b/apps/backend/src/modules/tbank/mappers/account.mapper.ts new file mode 100644 index 0000000..e8c26e8 --- /dev/null +++ b/apps/backend/src/modules/tbank/mappers/account.mapper.ts @@ -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, + }; +} diff --git a/apps/backend/src/modules/tbank/mappers/money.mapper.spec.ts b/apps/backend/src/modules/tbank/mappers/money.mapper.spec.ts new file mode 100644 index 0000000..d50b096 --- /dev/null +++ b/apps/backend/src/modules/tbank/mappers/money.mapper.spec.ts @@ -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'); + }); +}); diff --git a/apps/backend/src/modules/tbank/mappers/money.mapper.ts b/apps/backend/src/modules/tbank/mappers/money.mapper.ts new file mode 100644 index 0000000..e5400b1 --- /dev/null +++ b/apps/backend/src/modules/tbank/mappers/money.mapper.ts @@ -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; +} diff --git a/apps/backend/src/modules/tbank/mappers/operation.mapper.spec.ts b/apps/backend/src/modules/tbank/mappers/operation.mapper.spec.ts new file mode 100644 index 0000000..2384dbc --- /dev/null +++ b/apps/backend/src/modules/tbank/mappers/operation.mapper.spec.ts @@ -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); + }); +}); diff --git a/apps/backend/src/modules/tbank/mappers/operation.mapper.ts b/apps/backend/src/modules/tbank/mappers/operation.mapper.ts new file mode 100644 index 0000000..b7f3e0e --- /dev/null +++ b/apps/backend/src/modules/tbank/mappers/operation.mapper.ts @@ -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(), + }; +} diff --git a/apps/backend/src/modules/tbank/mappers/portfolio.mapper.spec.ts b/apps/backend/src/modules/tbank/mappers/portfolio.mapper.spec.ts new file mode 100644 index 0000000..945b08e --- /dev/null +++ b/apps/backend/src/modules/tbank/mappers/portfolio.mapper.spec.ts @@ -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 }, + }); + }); +}); diff --git a/apps/backend/src/modules/tbank/mappers/portfolio.mapper.ts b/apps/backend/src/modules/tbank/mappers/portfolio.mapper.ts new file mode 100644 index 0000000..cce8974 --- /dev/null +++ b/apps/backend/src/modules/tbank/mappers/portfolio.mapper.ts @@ -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>; +}; + +function isBrokerMoney(value: BrokerMoney | null): value is BrokerMoney { + return value !== null; +} + +export function mapBrokerPortfolio(input: MapBrokerPortfolioInput): BrokerPortfolio { + const mappedPositions = (input.portfolio.positions ?? []).map((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(), + }; +} diff --git a/apps/backend/src/modules/tbank/proto/contracts/common.proto b/apps/backend/src/modules/tbank/proto/contracts/common.proto new file mode 100644 index 0000000..5162419 --- /dev/null +++ b/apps/backend/src/modules/tbank/proto/contracts/common.proto @@ -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; //Задержка (пинг) сообщений: 5000–180 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; //Инструмент, торгуемый на площадке брокера. +} diff --git a/apps/backend/src/modules/tbank/proto/contracts/google/api/field_behavior.proto b/apps/backend/src/modules/tbank/proto/contracts/google/api/field_behavior.proto new file mode 100644 index 0000000..861f825 --- /dev/null +++ b/apps/backend/src/modules/tbank/proto/contracts/google/api/field_behavior.proto @@ -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; +} diff --git a/apps/backend/src/modules/tbank/proto/contracts/instruments.proto b/apps/backend/src/modules/tbank/proto/contracts/instruments.proto new file mode 100644 index 0000000..d5a5e44 --- /dev/null +++ b/apps/backend/src/modules/tbank/proto/contracts/instruments.proto @@ -0,0 +1,1751 @@ +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 InstrumentsService {/*Методы сервиса предназначены для получения:
1. Информации об инструментах.
2. + Расписания торговых сессий.
3. Календаря выплат купонов по облигациям.
4. + Размера гарантийного обеспечения по фьючерсам.
5. Дивидендов по ценной бумаге.*/ + + //TradingSchedules — расписания торговых площадок + rpc TradingSchedules (TradingSchedulesRequest) returns (TradingSchedulesResponse); + + //BondBy — получить облигацию по ее идентификатору + rpc BondBy (InstrumentRequest) returns (BondResponse); + + //Bonds — список облигаций + rpc Bonds (InstrumentsRequest) returns (BondsResponse); + + //GetBondCoupons — график выплат купонов по облигации + rpc GetBondCoupons (GetBondCouponsRequest) returns (GetBondCouponsResponse); + + //GetBondEvents — события по облигации + rpc GetBondEvents (GetBondEventsRequest) returns (GetBondEventsResponse); + + //CurrencyBy — получить валюту по ее идентификатору + rpc CurrencyBy (InstrumentRequest) returns (CurrencyResponse); + + //Currencies — список валют + rpc Currencies (InstrumentsRequest) returns (CurrenciesResponse); + + //EtfBy — получить инвестиционный фонд по его идентификатору + rpc EtfBy (InstrumentRequest) returns (EtfResponse); + + //Etfs — список инвестиционных фондов + rpc Etfs (InstrumentsRequest) returns (EtfsResponse); + + //FutureBy — получить фьючерс по его идентификатору + rpc FutureBy (InstrumentRequest) returns (FutureResponse); + + //Futures — список фьючерсов + rpc Futures (InstrumentsRequest) returns (FuturesResponse); + + //OptionBy — получить опцион по его идентификатору + rpc OptionBy (InstrumentRequest) returns (OptionResponse); + + //Deprecated Options — список опционов + rpc Options (InstrumentsRequest) returns (OptionsResponse) { + option deprecated = true; + }; + + //OptionsBy — список опционов + rpc OptionsBy (FilterOptionsRequest) returns (OptionsResponse); + + //ShareBy — получить акцию по ее идентификатору + rpc ShareBy (InstrumentRequest) returns (ShareResponse); + + //Shares — список акций + rpc Shares (InstrumentsRequest) returns (SharesResponse); + + //DfaBy — получить цифровой актив по его идентификатору + rpc DfaBy (InstrumentRequest) returns (DfaResponse); + + //Dfas — список цифровых активов + rpc Dfas (DfasRequest) returns (DfasResponse); + + //Indicatives — индикативные инструменты — индексы, товары и другие + rpc Indicatives(IndicativesRequest) returns (IndicativesResponse); + + //GetAccruedInterests — накопленный купонный доход по облигации + rpc GetAccruedInterests (GetAccruedInterestsRequest) returns (GetAccruedInterestsResponse); + + //GetFuturesMargin — размера гарантийного обеспечения по фьючерсам + rpc GetFuturesMargin (GetFuturesMarginRequest) returns (GetFuturesMarginResponse); + + //GetInstrumentBy — основная информация об инструменте + rpc GetInstrumentBy (InstrumentRequest) returns (InstrumentResponse); + + //GetDividends — события выплаты дивидендов по инструменту + rpc GetDividends (GetDividendsRequest) returns (GetDividendsResponse); + + //GetAssetBy — получить актив по его идентификатору + rpc GetAssetBy (AssetRequest) returns (AssetResponse); + + //GetAssets — список активов + //Метод работает для всех инструментов, кроме срочных — фьючерсов и опционов + rpc GetAssets (AssetsRequest) returns (AssetsResponse); + + //GetFavorites — получить список избранных инструментов + rpc GetFavorites (GetFavoritesRequest) returns (GetFavoritesResponse); + + //EditFavorites — отредактировать список избранных инструментов + rpc EditFavorites (EditFavoritesRequest) returns (EditFavoritesResponse); + + //CreateFavoriteGroup — создать новую группу избранных инструментов + rpc CreateFavoriteGroup (CreateFavoriteGroupRequest) returns (CreateFavoriteGroupResponse); + + //DeleteFavoriteGroup — удалить группу избранных инструментов + rpc DeleteFavoriteGroup (DeleteFavoriteGroupRequest) returns (DeleteFavoriteGroupResponse); + + //GetFavoriteGroups — список групп избранных инструментов + rpc GetFavoriteGroups (GetFavoriteGroupsRequest) returns (GetFavoriteGroupsResponse); + + //GetCountries — список стран + rpc GetCountries (GetCountriesRequest) returns (GetCountriesResponse); + + //FindInstrument — найти инструмент + rpc FindInstrument (FindInstrumentRequest) returns (FindInstrumentResponse); + + //GetBrands — список брендов + rpc GetBrands(GetBrandsRequest) returns (GetBrandsResponse); + + //GetBrandBy — получить бренд по его идентификатору + rpc GetBrandBy(GetBrandRequest) returns (Brand); + + //GetAssetFundamentals — фундаментальные показатели по активу + rpc GetAssetFundamentals(GetAssetFundamentalsRequest) returns (GetAssetFundamentalsResponse); + + //GetAssetReports — расписания выхода отчетностей эмитентов + rpc GetAssetReports(GetAssetReportsRequest) returns (GetAssetReportsResponse); + + //GetConsensusForecasts — мнения аналитиков по инструменту + rpc GetConsensusForecasts(GetConsensusForecastsRequest) returns (GetConsensusForecastsResponse); + + //GetForecastBy — прогнозы инвестдомов по инструменту + rpc GetForecastBy(GetForecastRequest) returns (GetForecastResponse); + + //GetRiskRates — ставки риска по инструменту + rpc GetRiskRates(RiskRatesRequest) returns (RiskRatesResponse); + + //GetInsiderDeals — сделки инсайдеров по инструментам + rpc GetInsiderDeals(GetInsiderDealsRequest) returns (GetInsiderDealsResponse); + + //StructuredNoteBy — получить структурную ноту по ее идентификатору + rpc StructuredNoteBy (InstrumentRequest) returns (StructuredNoteResponse); + + //StructuredNotes — список структурных нот + rpc StructuredNotes(InstrumentsRequest) returns (StructuredNotesResponse); + + //News — получение актуальных новостей + rpc News(NewsRequest) returns (NewsResponse); +} + +//Запрос расписания торгов. +message TradingSchedulesRequest { + optional string exchange = 1; //Наименование биржи или расчетного календаря.
Если не передается, возвращается информация по всем доступным торговым площадкам. + optional google.protobuf.Timestamp from = 2; //Начало периода по UTC. + optional google.protobuf.Timestamp to = 3; //Окончание периода по UTC. +} + +//Список торговых площадок. +message TradingSchedulesResponse { + repeated TradingSchedule exchanges = 1; // Список торговых площадок и режимов торгов. +} + +//Данные по торговой площадке. +message TradingSchedule { + string exchange = 1; // Наименование торговой площадки. + repeated TradingDay days = 2; // Массив с торговыми и неторговыми днями. +} + +//Информация о времени торгов. +message TradingDay { + reserved 5, 6; + google.protobuf.Timestamp date = 1; // Дата. + bool is_trading_day = 2; // Признак торгового дня на бирже. + google.protobuf.Timestamp start_time = 3; // Время начала торгов по UTC. + google.protobuf.Timestamp end_time = 4; // Время окончания торгов по UTC. + google.protobuf.Timestamp opening_auction_start_time = 7; // Время начала аукциона открытия по UTC. + google.protobuf.Timestamp closing_auction_end_time = 8; // Время окончания аукциона закрытия по UTC. + google.protobuf.Timestamp evening_opening_auction_start_time = 9; // Время начала аукциона открытия вечерней сессии по UTC. + google.protobuf.Timestamp evening_start_time = 10; // Время начала вечерней сессии по UTC. + google.protobuf.Timestamp evening_end_time = 11; // Время окончания вечерней сессии по UTC. + google.protobuf.Timestamp clearing_start_time = 12; // Время начала основного клиринга по UTC. + google.protobuf.Timestamp clearing_end_time = 13; // Время окончания основного клиринга по UTC. + google.protobuf.Timestamp premarket_start_time = 14; // Время начала премаркета по UTC. + google.protobuf.Timestamp premarket_end_time = 15; // Время окончания премаркета по UTC. + google.protobuf.Timestamp closing_auction_start_time = 16; // Время начала аукциона закрытия по UTC. + google.protobuf.Timestamp opening_auction_end_time = 17; // Время окончания аукциона открытия по UTC. + repeated TradingInterval intervals = 18; // Торговые интервалы. +} + +//Запрос получения инструмента по идентификатору. +message InstrumentRequest { + InstrumentIdType id_type = 1 [(google.api.field_behavior) = REQUIRED]; // Тип идентификатора инструмента. Возможные значения — `figi`, `ticker`. [Подробнее об идентификации инструментов](/invest/intro/intro/faq_identification). + optional string class_code = 2; // Идентификатор `class_code`. Обязательный, если `id_type = ticker`. + string id = 3 [(google.api.field_behavior) = REQUIRED]; // Идентификатор запрашиваемого инструмента. +} + +//Запрос получения инструментов. +message InstrumentsRequest { + optional InstrumentStatus instrument_status = 1; //Статус запрашиваемых инструментов. [Возможные значения](#instrumentstatus). + optional InstrumentExchangeType instrument_exchange = 2; // Тип площадки торговли. [Возможные значения](#instrumentexchangetype). +} +//Параметры фильтрации опционов. +message FilterOptionsRequest { + optional string basic_asset_uid = 1; //Идентификатор базового актива опциона. Обязательный параметр. + optional string basic_asset_position_uid = 2; //Идентификатор позиции базового актива опциона. + optional string basic_instrument_id = 3; //Идентификатор базового инструмента, принимает значение принимает значения figi, instrument_uid или ticker+"_"+classCode. +} + +//Запрос получения актуальных новостей +message NewsRequest { + optional int64 cursor = 1; //Идентификатор элемента, с которого начать формировать ответ. + optional int32 limit = 2; //Лимит количества новостей в ответе. По умолчанию 1000. +} + +//Информация об облигации. +message BondResponse { + Bond instrument = 1; // Информация об облигации. +} + +//Список облигаций. +message BondsResponse { + repeated Bond instruments = 1; //Массив облигаций. +} + +//Запрос купонов по облигации. +message GetBondCouponsRequest { + string figi = 1 [deprecated = true]; //FIGI-идентификатор инструмента. + optional google.protobuf.Timestamp from = 2; //Начало запрашиваемого периода по UTC. Фильтрация по `coupon_date` — дата выплаты купона. + optional google.protobuf.Timestamp to = 3; //Окончание запрашиваемого периода по UTC. Фильтрация по `coupon_date` — дата выплаты купона. + string instrument_id = 4 [(google.api.field_behavior) = REQUIRED]; //Идентификатор инструмента. Принимает значение `figi`, `instrument_uid` или `ticker + '_' + class_code`. +} + +//Купоны по облигации. +message GetBondCouponsResponse { + repeated Coupon events = 1; +} + +//События по облигации. +message GetBondEventsRequest { + optional google.protobuf.Timestamp from = 2; //Начало запрашиваемого периода по UTC. + optional google.protobuf.Timestamp to = 3; //Окончание запрашиваемого периода по UTC. + string instrument_id = 4 [(google.api.field_behavior) = REQUIRED]; //Идентификатор инструмента — `figi` или `instrument_uid`. + EventType type = 5; //Тип события + + enum EventType { + EVENT_TYPE_UNSPECIFIED = 0; //Неопределенное значение. + EVENT_TYPE_CPN = 1; // Купон. + EVENT_TYPE_CALL = 2; // Опцион (оферта). + EVENT_TYPE_MTY = 3;// Погашение. + EVENT_TYPE_CONV = 4; // Конвертация. + } +} + +//Объект передачи информации о событии облигации. +message GetBondEventsResponse { + repeated BondEvent events = 1; + message BondEvent { + string instrument_id = 2; // Идентификатор инструмента. Принимает значение `figi`, `instrument_uid` или `ticker + '_' + class_code`. + int32 event_number = 3; // Номер события для данного типа события. + google.protobuf.Timestamp event_date = 4; // Дата события. + GetBondEventsRequest.EventType event_type = 5; // Тип события. + Quotation event_total_vol = 6; // Полное количество бумаг, задействованных в событии. + google.protobuf.Timestamp fix_date = 7; // Дата фиксации владельцев для участия в событии. + google.protobuf.Timestamp rate_date = 8; // Дата определения даты или факта события. + google.protobuf.Timestamp default_date = 9; // Дата дефолта, если применимо. + google.protobuf.Timestamp real_pay_date = 10; // Дата реального исполнения обязательства. + google.protobuf.Timestamp pay_date = 11; // Дата выплаты. + MoneyValue pay_one_bond = 12; // Выплата на одну облигацию. + MoneyValue money_flow_val = 13; // Выплаты на все бумаги, задействованные в событии. + string execution = 14; // Признак исполнения. + string operation_type = 15; // Тип операции. + Quotation value = 16; // Стоимость операции — ставка купона, доля номинала, цена выкупа или коэффициент конвертации. + string note = 17; // Примечание. + string convert_to_fin_tool_id = 18; // ID выпуска бумаг, в который произведена конвертация (для конвертаций). + google.protobuf.Timestamp coupon_start_date = 19; // Начало купонного периода. + google.protobuf.Timestamp coupon_end_date = 20; // Окончание купонного периода. + int32 coupon_period = 21; // Купонный период. + Quotation coupon_interest_rate = 22; // Ставка купона, процентов годовых. + } +} + + + +//Объект передачи информации о купоне облигации. +message Coupon { + string figi = 1; //FIGI-идентификатор инструмента. + google.protobuf.Timestamp coupon_date = 2; //Дата выплаты купона. + int64 coupon_number = 3; //Номер купона. + google.protobuf.Timestamp fix_date = 4; //Дата фиксации реестра для выплаты купона — опционально. + MoneyValue pay_one_bond = 5; //Выплата на одну облигацию. + CouponType coupon_type = 6; //Тип купона. + google.protobuf.Timestamp coupon_start_date = 7; //Начало купонного периода. + google.protobuf.Timestamp coupon_end_date = 8; //Окончание купонного периода. + int32 coupon_period = 9; //Купонный период в днях. +} + +//Тип купонов. +enum CouponType { + COUPON_TYPE_UNSPECIFIED = 0; //Неопределенное значение. + COUPON_TYPE_CONSTANT = 1; //Постоянный. + COUPON_TYPE_FLOATING = 2; //Плавающий. + COUPON_TYPE_DISCOUNT = 3; //Дисконт. + COUPON_TYPE_MORTGAGE = 4; //Ипотечный. + COUPON_TYPE_FIX = 5; //Фиксированный. + COUPON_TYPE_VARIABLE = 6; //Переменный. + COUPON_TYPE_OTHER = 7; //Прочее. +} + +//Данные по валюте. +message CurrencyResponse { + Currency instrument = 1; // Информация о валюте. +} + +//Данные по валютам. +message CurrenciesResponse { + repeated Currency instruments = 1; //Массив валют. +} + +//Данные по фонду. +message EtfResponse { + Etf instrument = 1; // Информация о фонде. +} + +//Данные по фондам. +message EtfsResponse { + repeated Etf instruments = 1; //Массив фондов. +} + +//Данные по фьючерсу. +message FutureResponse { + Future instrument = 1; // Информация о фьючерсу. +} + +//Данные по фьючерсам. +message FuturesResponse { + repeated Future instruments = 1; //Массив фьючерсов. +} + +//Данные по опциону. +message OptionResponse { + Option instrument = 1; // Информация по опциону. +} + +//Данные по опционам. +message OptionsResponse { + repeated Option instruments = 1; //Массив данных по опциону. +} + +//Опцион. +message Option { + string uid = 1; //Уникальный идентификатор инструмента. + string position_uid = 2; //Уникальный идентификатор позиции. + string ticker = 3; //Тикер инструмента. + string class_code = 4; //Класс-код. + string basic_asset_position_uid = 5; //Уникальный идентификатор позиции основного инструмента. + + SecurityTradingStatus trading_status = 21; //Текущий режим торгов инструмента. + RealExchange real_exchange = 31; //Реальная площадка исполнения расчетов (биржа). + OptionDirection direction = 41; //Направление опциона. + OptionPaymentType payment_type = 42; //Тип расчетов по опциону. + OptionStyle style = 43; //Стиль опциона. + OptionSettlementType settlement_type = 44; //Способ исполнения опциона. + + string name = 101; //Название инструмента. + string currency = 111; //Валюта. + string settlement_currency = 112; //Валюта, в которой оценивается контракт. + string asset_type = 131; //Тип актива. + string basic_asset = 132; //Основной актив. + string exchange = 141; // Tорговая площадка (секция биржи). + string country_of_risk = 151; //Код страны рисков. + string country_of_risk_name = 152; //Наименование страны рисков. + string sector = 161; //Сектор экономики. + BrandData brand = 162; // Информация о бренде. + + int32 lot = 201; //Количество бумаг в лоте. + Quotation basic_asset_size = 211; //Размер основного актива. + Quotation klong = 221 [deprecated = true]; //Коэффициент ставки риска длинной позиции по клиенту. 2 – клиент со стандартным уровнем риска (КСУР); 1 – клиент с повышенным уровнем риска (КПУР). + Quotation kshort = 222 [deprecated = true]; //Коэффициент ставки риска короткой позиции по клиенту. 2 – клиент со стандартным уровнем риска (КСУР); 1 – клиент с повышенным уровнем риска (КПУР). + Quotation dlong = 223; //Ставка риска начальной маржи для КСУР лонг. [Подробнее про ставки риска](https://www.tbank.ru/invest/help/brokerage/account/margin/about/#q5). + Quotation dshort = 224; //Ставка риска начальной маржи для КСУР шорт. [Подробнее про ставки риска](https://www.tbank.ru/invest/help/brokerage/account/margin/about/#q5). + Quotation dlong_min = 225; //Ставка риска начальной маржи для КПУР лонг. [Подробнее про ставки риска](https://www.tbank.ru/invest/help/brokerage/account/margin/about/#q5). + Quotation dshort_min = 226; //Ставка риска начальной маржи для КПУР шорт. [Подробнее про ставки риска](https://www.tbank.ru/invest/help/brokerage/account/margin/about/#q5). + Quotation min_price_increment = 231; //Минимальный шаг цены. + MoneyValue strike_price = 241; //Цена страйка. + + Quotation dlong_client = 290; //Ставка риска в лонг с учетом текущего уровня риска портфеля клиента. [Подробнее про ставки риска](https://www.tbank.ru/invest/help/brokerage/account/margin/about/#q5). + Quotation dshort_client = 291; //Ставка риска в шорт с учетом текущего уровня риска портфеля клиента. [Подробнее про ставки риска](https://www.tbank.ru/invest/help/brokerage/account/margin/about/#q5). + + google.protobuf.Timestamp expiration_date = 301; //Дата истечения срока в формате UTC. + google.protobuf.Timestamp first_trade_date = 311; //Дата начала обращения контракта в формате UTC. + google.protobuf.Timestamp last_trade_date = 312; //Дата исполнения в формате UTC. + google.protobuf.Timestamp first_1min_candle_date = 321; //Дата первой минутной свечи в формате UTC. + google.protobuf.Timestamp first_1day_candle_date = 322; //Дата первой дневной свечи в формате UTC. + + bool short_enabled_flag = 401; //Признак доступности для операций шорт. + bool for_iis_flag = 402; //Возможность покупки или продажи на ИИС. + bool otc_flag = 403; //Флаг, используемый ранее для определения внебиржевых инструментов. На данный момент не используется для торгуемых через API инструментов. Может использоваться как фильтр для операций, совершавшихся некоторое время назад на ОТС площадке. + bool buy_available_flag = 404; //Признак доступности для покупки. + bool sell_available_flag = 405; //Признак доступности для продажи. + bool for_qual_investor_flag = 406; //Флаг, отображающий доступность торговли инструментом только для квалифицированных инвесторов. + bool weekend_flag = 407; //Флаг, отображающий доступность торговли инструментом по выходным. + bool blocked_tca_flag = 408; //Флаг заблокированного ТКС. + bool api_trade_available_flag = 409; //Возможность торговать инструментом через API. + repeated string required_tests = 410; //Тесты, которые необходимо пройти клиенту, чтобы совершать сделки по инструменту. +} + +//Тип опциона по направлению сделки. +enum OptionDirection { + OPTION_DIRECTION_UNSPECIFIED = 0; //Тип не определен. + OPTION_DIRECTION_PUT = 1; //Опцион на продажу. + OPTION_DIRECTION_CALL = 2; //Опцион на покупку. +} + +//Тип расчетов по опциону. +enum OptionPaymentType { + OPTION_PAYMENT_TYPE_UNSPECIFIED = 0; //Тип не определен. + OPTION_PAYMENT_TYPE_PREMIUM = 1; //Опционы с использованием премии в расчетах. + OPTION_PAYMENT_TYPE_MARGINAL = 2; //Маржируемые опционы. +} + +//Тип опциона по стилю. +enum OptionStyle { + OPTION_STYLE_UNSPECIFIED = 0; //Тип не определен. + OPTION_STYLE_AMERICAN = 1; //Американский опцион. + OPTION_STYLE_EUROPEAN = 2; //Европейский опцион. +} + +//Тип опциона по способу исполнения. +enum OptionSettlementType { + OPTION_EXECUTION_TYPE_UNSPECIFIED = 0; //Тип не определен. + OPTION_EXECUTION_TYPE_PHYSICAL_DELIVERY = 1; // Поставочный тип опциона. + OPTION_EXECUTION_TYPE_CASH_SETTLEMENT = 2; //Расчетный тип опциона. +} + +//Данные по акции. +message ShareResponse { + Share instrument = 1; // Информация об акции. +} + +//Данные по акциям. +message SharesResponse { + repeated Share instruments = 1; //Массив акций. +} + +//Данные по структурной ноте. +message StructuredNoteResponse { + StructuredNote instrument = 1; // Информация о структурной ноте. +} + +//Данные по структурным нотам. +message StructuredNotesResponse { + repeated StructuredNote instruments = 1; //Массив структурных нот. +} + +//Данные по актуальным новостям. +message NewsResponse { + bool has_next = 1; //Признак, есть ли еще новости. + optional int64 next_cursor = 2; //Следующий курсор. + repeated NewsItem items = 3; //Массив новостей. +} + +//Объект новости. +message NewsItem { + int64 id = 1; //Уникальный идентификатор новости. + string source = 2; //Источник новости. + string title = 3; //Заголовок новости. + string content = 4; //Содержание новости. + optional string summary = 5; //Обобщенная информация. + repeated Table tables = 6; //Табличные данные. + repeated NewsInstrument instrument_id = 7; //Инструменты из новости. + bool priority = 8; //Флаг, указывающий, важная ли новость. + google.protobuf.Timestamp ts = 9;//Время новости. +} + +//Объект табличных данных. +message Table { + string table = 1; //Таблица. +} + +//Объект инструмента из новости. +message NewsInstrument { + NewsInstrumentInfo instrument = 1; //Информация по инструменту. +} + +//Объект информации по инструменту из новости. +message NewsInstrumentInfo { + string instrument_uid = 1; //Уникальный идентификатор инструмента. + string ticker = 2; //Тикер инструмента. + string class_code = 3; //Класс-код (секция торгов). +} + +//Объект передачи информации об облигации. +message Bond { + string figi = 1; //FIGI-идентификатор инструмента. + string ticker = 2; //Тикер инструмента. + string class_code = 3; //Класс-код (секция торгов). + string isin = 4; //ISIN-идентификатор инструмента. + int32 lot = 5; //Лотность инструмента. Возможно совершение операций только на количества ценной бумаги, кратные параметру `lot`. [Подробнее](./glossary#lot). + string currency = 6; //Валюта расчетов. + + Quotation klong = 7 [deprecated = true]; //Коэффициент ставки риска длинной позиции по клиенту. 2 – клиент со стандартным уровнем риска (КСУР); 1 – клиент с повышенным уровнем риска (КПУР). + Quotation kshort = 8 [deprecated = true]; //Коэффициент ставки риска короткой позиции по клиенту. 2 – клиент со стандартным уровнем риска (КСУР); 1 – клиент с повышенным уровнем риска (КПУР). + Quotation dlong = 9; //Ставка риска начальной маржи для КСУР лонг. [Подробнее про ставки риска](https://www.tbank.ru/invest/help/brokerage/account/margin/about/#q5). + Quotation dshort = 10; //Ставка риска начальной маржи для КСУР шорт. [Подробнее про ставки риска](https://www.tbank.ru/invest/help/brokerage/account/margin/about/#q5). + Quotation dlong_min = 11; // Ставка риска начальной маржи для КПУР лонг. [Подробнее про ставки риска](https://www.tbank.ru/invest/help/brokerage/account/margin/about/#q5). + Quotation dshort_min = 12; //Ставка риска начальной маржи для КПУР шорт. [Подробнее про ставки риска](https://www.tbank.ru/invest/help/brokerage/account/margin/about/#q5). + bool short_enabled_flag = 13; //Признак доступности для операций в шорт. + string name = 15; //Название инструмента. + string exchange = 16; //Tорговая площадка (секция биржи). + + int32 coupon_quantity_per_year = 17; //Количество выплат по купонам в год. + google.protobuf.Timestamp maturity_date = 18; //Дата погашения облигации по UTC. + MoneyValue nominal = 19; //Номинал облигации. + MoneyValue initial_nominal = 20; //Первоначальный номинал облигации. + + google.protobuf.Timestamp state_reg_date = 21; //Дата выпуска облигации по UTC. + google.protobuf.Timestamp placement_date = 22; //Дата размещения по UTC. + MoneyValue placement_price = 23; //Цена размещения. + MoneyValue aci_value = 24; //Значение НКД (накопленного купонного дохода) на дату. + + string country_of_risk = 25; //Код страны риска — то есть страны, в которой компания ведет основной бизнес. + string country_of_risk_name = 26; //Наименование страны риска — то есть страны, в которой компания ведет основной бизнес. + string sector = 27; //Сектор экономики. + string issue_kind = 28; //Форма выпуска. Возможные значения:
**documentary** — документарная;
**non_documentary** — бездокументарная. + int64 issue_size = 29; //Размер выпуска. + int64 issue_size_plan = 30; //Плановый размер выпуска. + + SecurityTradingStatus trading_status = 31; //Текущий режим торгов инструмента. + bool otc_flag = 32; //Флаг, используемый ранее для определения внебиржевых инструментов. На данный момент не используется для торгуемых через API инструментов. Может использоваться как фильтр для операций, совершавшихся некоторое время назад на ОТС площадке. + bool buy_available_flag = 33; //Признак доступности для покупки. + bool sell_available_flag = 34; //Признак доступности для продажи. + bool floating_coupon_flag = 35; //Признак облигации с плавающим купоном. + bool perpetual_flag = 36; //Признак бессрочной облигации. + bool amortization_flag = 37; //Признак облигации с амортизацией долга. + Quotation min_price_increment = 38; //Шаг цены. + bool api_trade_available_flag = 39; //Параметр указывает на возможность торговать инструментом через API. + + string uid = 40; //Уникальный идентификатор инструмента. + RealExchange real_exchange = 41; //Реальная площадка исполнения расчетов. (биржа) + string position_uid = 42; //Уникальный идентификатор позиции инструмента. + string asset_uid = 43; //Уникальный идентификатор актива. + + repeated string required_tests = 44; //Тесты, которые необходимо пройти клиенту, чтобы совершать сделки по инструменту. + + bool for_iis_flag = 51; //Признак доступности для ИИС. + bool for_qual_investor_flag = 52; //Флаг, отображающий доступность торговли инструментом только для квалифицированных инвесторов. + bool weekend_flag = 53; //Флаг, отображающий доступность торговли инструментом по выходным. + bool blocked_tca_flag = 54; //Флаг заблокированного ТКС. + bool subordinated_flag = 55; //Признак субординированной облигации. + bool liquidity_flag = 56; //Флаг достаточной ликвидности. + google.protobuf.Timestamp first_1min_candle_date = 61; //Дата первой минутной свечи. + google.protobuf.Timestamp first_1day_candle_date = 62; //Дата первой дневной свечи. + RiskLevel risk_level = 63; //Уровень риска. + BrandData brand = 64; // Информация о бренде. + BondType bond_type = 65; // Тип облигации. + google.protobuf.Timestamp call_date = 69; // Дата оферты. + + Quotation dlong_client = 90; //Ставка риска в лонг с учетом текущего уровня риска портфеля клиента. [Подробнее про ставки риска](https://www.tbank.ru/invest/help/brokerage/account/margin/about/#q5). + Quotation dshort_client = 91; //Ставка риска в шорт с учетом текущего уровня риска портфеля клиента. [Подробнее про ставки риска](https://www.tbank.ru/invest/help/brokerage/account/margin/about/#q5). + +} + +//Объект передачи информации о валюте. +message Currency { + string figi = 1; //FIGI-идентификатор инструмента. + string ticker = 2; //Тикер инструмента. + string class_code = 3; //Класс-код (секция торгов). + string isin = 4; //ISIN-идентификатор инструмента. + int32 lot = 5; //Лотность инструмента. Возможно совершение операций только на количества ценной бумаги, кратные параметру `lot`. [Подробнее](./glossary#lot). + string currency = 6; //Валюта расчетов. + + Quotation klong = 7 [deprecated = true]; //Коэффициент ставки риска длинной позиции по клиенту. 2 – клиент со стандартным уровнем риска (КСУР); 1 – клиент с повышенным уровнем риска (КПУР). + Quotation kshort = 8 [deprecated = true]; //Коэффициент ставки риска короткой позиции по клиенту. 2 – клиент со стандартным уровнем риска (КСУР); 1 – клиент с повышенным уровнем риска (КПУР). + Quotation dlong = 9; //Ставка риска начальной маржи для КСУР лонг. [Подробнее про ставки риска](https://www.tbank.ru/invest/help/brokerage/account/margin/about/#q5). + Quotation dshort = 10; //Ставка риска начальной маржи для КСУР шорт. [Подробнее про ставки риска](https://www.tbank.ru/invest/help/brokerage/account/margin/about/#q5). + Quotation dlong_min = 11; //Ставка риска начальной маржи для КПУР лонг. [Подробнее про ставки риска](https://www.tbank.ru/invest/help/brokerage/account/margin/about/#q5). + Quotation dshort_min = 12; //Ставка риска начальной маржи для КПУР шорт. [Подробнее про ставки риска](https://www.tbank.ru/invest/help/brokerage/account/margin/about/#q5). + bool short_enabled_flag = 13; //Признак доступности для операций в шорт. + string name = 15; //Название инструмента. + string exchange = 16; //Tорговая площадка (секция биржи). + + MoneyValue nominal = 17; //Номинал. + + string country_of_risk = 18; //Код страны риска — то есть страны, в которой компания ведет основной бизнес. + string country_of_risk_name = 19; //Наименование страны риска — то есть страны, в которой компания ведет основной бизнес. + + SecurityTradingStatus trading_status = 20; //Текущий режим торгов инструмента. + bool otc_flag = 21; //Флаг, используемый ранее для определения внебиржевых инструментов. На данный момент не используется для торгуемых через API инструментов. Может использоваться как фильтр для операций, совершавшихся некоторое время назад на ОТС площадке. + bool buy_available_flag = 22; //Признак доступности для покупки. + bool sell_available_flag = 23; //Признак доступности для продажи. + string iso_currency_name = 24; //Строковый ISO-код валюты. + Quotation min_price_increment = 25; //Шаг цены. + bool api_trade_available_flag = 26; //Параметр указывает на возможность торговать инструментом через API. + + string uid = 27; //Уникальный идентификатор инструмента. + RealExchange real_exchange = 28; //Реальная площадка исполнения расчетов (биржа). + string position_uid = 29; //Уникальный идентификатор позиции инструмента. + + repeated string required_tests = 30; //Тесты, которые необходимо пройти клиенту, чтобы совершать сделки по инструменту. + + string asset_uid = 31; //Уникальный идентификатор актива. + + bool for_iis_flag = 41; //Признак доступности для ИИС. + bool for_qual_investor_flag = 52; //Флаг, отображающий доступность торговли инструментом только для квалифицированных инвесторов. + bool weekend_flag = 53; //Флаг, отображающий доступность торговли инструментом по выходным. + bool blocked_tca_flag = 54; //Флаг заблокированного ТКС. + google.protobuf.Timestamp first_1min_candle_date = 56; //Дата первой минутной свечи. + google.protobuf.Timestamp first_1day_candle_date = 57; //Дата первой дневной свечи. + BrandData brand = 60; // Информация о бренде. + + Quotation dlong_client = 90; //Ставка риска в лонг с учетом текущего уровня риска портфеля клиента. [Подробнее про ставки риска](https://www.tbank.ru/invest/help/brokerage/account/margin/about/#q5). + Quotation dshort_client = 91; //Ставка риска в шорт с учетом текущего уровня риска портфеля клиента. [Подробнее про ставки риска](https://www.tbank.ru/invest/help/brokerage/account/margin/about/#q5). + +} + +//Объект передачи информации об инвестиционном фонде. +message Etf { + string figi = 1; //FIGI-идентификатор инструмента. + string ticker = 2; //Тикер инструмента. + string class_code = 3; //Класс-код (секция торгов). + string isin = 4; //ISIN-идентификатор инструмента. + int32 lot = 5; //Лотность инструмента. Возможно совершение операций только на количества ценной бумаги, кратные параметру `lot`. [Подробнее](./glossary#lot). + string currency = 6; //Валюта расчетов. + + Quotation klong = 7 [deprecated = true]; //Коэффициент ставки риска длинной позиции по клиенту. 2 – клиент со стандартным уровнем риска (КСУР); 1 – клиент с повышенным уровнем риска (КПУР). + Quotation kshort = 8 [deprecated = true]; //Коэффициент ставки риска короткой позиции по клиенту. 2 – клиент со стандартным уровнем риска (КСУР); 1 – клиент с повышенным уровнем риска (КПУР). + Quotation dlong = 9; //Ставка риска начальной маржи для КСУР лонг. [Подробнее про ставки риска](https://www.tbank.ru/invest/help/brokerage/account/margin/about/#q5). + Quotation dshort = 10; //Ставка риска начальной маржи для КСУР шорт. [Подробнее про ставки риска](https://www.tbank.ru/invest/help/brokerage/account/margin/about/#q5). + Quotation dlong_min = 11; //Ставка риска начальной маржи для КПУР лонг. [Подробнее про ставки риска](https://www.tbank.ru/invest/help/brokerage/account/margin/about/#q5). + Quotation dshort_min = 12; //Ставка риска начальной маржи для КПУР шорт. [Подробнее про ставки риска](https://www.tbank.ru/invest/help/brokerage/account/margin/about/#q5). + bool short_enabled_flag = 13; //Признак доступности для операций в шорт. + string name = 15; //Название инструмента. + string exchange = 16; //Tорговая площадка (секция биржи). + + Quotation fixed_commission = 17; //Размер фиксированной комиссии фонда. + string focus_type = 18; //Возможные значения:
**equity** — акции;
**fixed_income** — облигации;
**mixed_allocation** — смешанный;
**money_market** — денежный рынок;
**real_estate** — недвижимость;
**commodity** — товары;
**specialty** — специальный;
**private_equity** — private equity;
**alternative_investment** — альтернативные инвестиции. + google.protobuf.Timestamp released_date = 19; //Дата выпуска по UTC. + Quotation num_shares = 20; //Количество паев фонда в обращении. + + string country_of_risk = 21; //Код страны риска — то есть страны, в которой компания ведет основной бизнес. + string country_of_risk_name = 22; //Наименование страны риска — то есть страны, в которой компания ведет основной бизнес. + string sector = 23; //Сектор экономики. + string rebalancing_freq = 24; //Частота ребалансировки. + + SecurityTradingStatus trading_status = 25; //Текущий режим торгов инструмента. + bool otc_flag = 26; //Флаг, используемый ранее для определения внебиржевых инструментов. На данный момент не используется для торгуемых через API инструментов. Может использоваться как фильтр для операций, совершавшихся некоторое время назад на ОТС площадке. + bool buy_available_flag = 27; //Признак доступности для покупки. + bool sell_available_flag = 28; //Признак доступности для продажи. + Quotation min_price_increment = 29; //Шаг цены. + bool api_trade_available_flag = 30; //Параметр указывает на возможность торговать инструментом через API. + + string uid = 31; //Уникальный идентификатор инструмента. + RealExchange real_exchange = 32; //Реальная площадка исполнения расчетов (биржа). + string position_uid = 33; //Уникальный идентификатор позиции инструмента. + string asset_uid = 34; //Уникальный идентификатор актива. + InstrumentExchangeType instrument_exchange = 35; //Тип площадки торговли. + + repeated string required_tests = 36; //Тесты, которые необходимо пройти клиенту, чтобы совершать сделки по инструменту. + + bool for_iis_flag = 41; //Признак доступности для ИИС. + bool for_qual_investor_flag = 42; //Флаг, отображающий доступность торговли инструментом только для квалифицированных инвесторов. + bool weekend_flag = 43; //ФлагФлаг, отображающий доступность торговли инструментом по выходным. + bool blocked_tca_flag = 44; //Флаг заблокированного ТКС. + bool liquidity_flag = 45; //Флаг достаточной ликвидности. + google.protobuf.Timestamp first_1min_candle_date = 56; //Дата первой минутной свечи. + google.protobuf.Timestamp first_1day_candle_date = 57; //Дата первой дневной свечи. + BrandData brand = 60; // Информация о бренде. + + Quotation dlong_client = 90; //Ставка риска в лонг с учетом текущего уровня риска портфеля клиента. [Подробнее про ставки риска](https://www.tbank.ru/invest/help/brokerage/account/margin/about/#q5). + Quotation dshort_client = 91; //Ставка риска в шорт с учетом текущего уровня риска портфеля клиента. [Подробнее про ставки риска](https://www.tbank.ru/invest/help/brokerage/account/margin/about/#q5). + +} + +//Объект передачи информации о фьючерсе. +message Future { + string figi = 1; //FIGI-идентификатор инструмента. + string ticker = 2; //Тикер инструмента. + string class_code = 3; //Класс-код (секция торгов). + int32 lot = 4; //Лотность инструмента. Возможно совершение операций только на количества ценной бумаги, кратные параметру `lot`. [Подробнее](./glossary#lot). + string currency = 5; //Валюта расчетов. + + Quotation klong = 6 [deprecated = true]; //Коэффициент ставки риска длинной позиции по клиенту. 2 – клиент со стандартным уровнем риска (КСУР); 1 – клиент с повышенным уровнем риска (КПУР). + Quotation kshort = 7 [deprecated = true]; //Коэффициент ставки риска короткой позиции по клиенту. 2 – клиент со стандартным уровнем риска (КСУР); 1 – клиент с повышенным уровнем риска (КПУР). + Quotation dlong = 8; //Ставка риска начальной маржи для КСУР лонг. [Подробнее про ставки риска](https://www.tbank.ru/invest/help/brokerage/account/margin/about/#q5). + Quotation dshort = 9; //Ставка риска начальной маржи для КСУР шорт. [Подробнее про ставки риска](https://www.tbank.ru/invest/help/brokerage/account/margin/about/#q5). + Quotation dlong_min = 10; //Ставка риска начальной маржи для КПУР лонг. [Подробнее про ставки риска](https://www.tbank.ru/invest/help/brokerage/account/margin/about/#q5). + Quotation dshort_min = 11; //Ставка риска начальной маржи для КПУР шорт. [Подробнее про ставки риска](https://www.tbank.ru/invest/help/brokerage/account/margin/about/#q5). + bool short_enabled_flag = 12; //Признак доступности для операций шорт. + string name = 13; //Название инструмента. + string exchange = 14; //Tорговая площадка (секция биржи). + + google.protobuf.Timestamp first_trade_date = 15; //Дата начала обращения контракта по UTC. + google.protobuf.Timestamp last_trade_date = 16; //Дата по UTC, до которой возможно проведение операций с фьючерсом. + string futures_type = 17; //Тип фьючерса. Возможные значения:
**physical_delivery** — физические поставки;
**cash_settlement** — денежный эквивалент. + string asset_type = 18; //Тип актива. Возможные значения:
**commodity** — товар;
**currency** — валюта;
**security** — ценная бумага;
**index** — индекс. + string basic_asset = 19; //Основной актив. + Quotation basic_asset_size = 20; //Размер основного актива. + + string country_of_risk = 21; //Код страны риска — то есть страны, в которой компания ведет основной бизнес. + string country_of_risk_name = 22; //Наименование страны риска — то есть страны, в которой компания ведет основной бизнес. + string sector = 23; //Сектор экономики. + google.protobuf.Timestamp expiration_date = 24; //Дата истечения срока в часов поясе UTC. + + SecurityTradingStatus trading_status = 25; //Текущий режим торгов инструмента. + bool otc_flag = 26; //Флаг, используемый ранее для определения внебиржевых инструментов. На данный момент не используется для торгуемых через API инструментов. Может использоваться как фильтр для операций, совершавшихся некоторое время назад на ОТС площадке. + bool buy_available_flag = 27; //Признак доступности для покупки. + bool sell_available_flag = 28; //Признак доступности для продажи. + Quotation min_price_increment = 29; //Шаг цены. + bool api_trade_available_flag = 30; //Параметр указывает на возможность торговать инструментом через API. + + string uid = 31; //Уникальный идентификатор инструмента. + RealExchange real_exchange = 32; //Реальная площадка исполнения расчетов (биржа). + string position_uid = 33; //Уникальный идентификатор позиции инструмента. + string basic_asset_position_uid = 34; //Уникальный идентификатор позиции основного инструмента. + + repeated string required_tests = 35; //Тесты, которые необходимо пройти клиенту, чтобы совершать сделки по инструменту. + + bool for_iis_flag = 41; //Признак доступности для ИИС. + bool for_qual_investor_flag = 42; //Флаг, отображающий доступность торговли инструментом только для квалифицированных инвесторов. + bool weekend_flag = 43; //Флаг, отображающий доступность торговли инструментом по выходным. + bool blocked_tca_flag = 44; //Флаг заблокированного ТКС. + google.protobuf.Timestamp first_1min_candle_date = 56; //Дата первой минутной свечи. + google.protobuf.Timestamp first_1day_candle_date = 57; //Дата первой дневной свечи. + + MoneyValue initial_margin_on_buy = 61; //Гарантийное обеспечение при покупке. + MoneyValue initial_margin_on_sell = 62; //Гарантийное обеспечение при продаже. + Quotation min_price_increment_amount = 63; //Стоимость шага цены. + BrandData brand = 64; // Информация о бренде. + + Quotation dlong_client = 90; //Ставка риска в лонг с учетом текущего уровня риска портфеля клиента. [Подробнее про ставки риска](https://www.tbank.ru/invest/help/brokerage/account/margin/about/#q5). + Quotation dshort_client = 91; //Ставка риска в шорт с учетом текущего уровня риска портфеля клиента. [Подробнее про ставки риска](https://www.tbank.ru/invest/help/brokerage/account/margin/about/#q5). +} + +//Объект передачи информации об акции. +message Share { + string figi = 1; //FIGI-идентификатор инструмента. + string ticker = 2; //Тикер инструмента. + string class_code = 3; //Класс-код (секция торгов). + string isin = 4; //ISIN-идентификатор инструмента. + int32 lot = 5; //Лотность инструмента. Возможно совершение операций только на количества ценной бумаги, кратные параметру `lot`. [Подробнее](./glossary#lot) + string currency = 6; //Валюта расчетов. + + Quotation klong = 7 [deprecated = true]; //Коэффициент ставки риска длинной позиции по клиенту. 2 – клиент со стандартным уровнем риска (КСУР); 1 – клиент с повышенным уровнем риска (КПУР). + Quotation kshort = 8 [deprecated = true]; //Коэффициент ставки риска короткой позиции по клиенту. 2 – клиент со стандартным уровнем риска (КСУР); 1 – клиент с повышенным уровнем риска (КПУР). + Quotation dlong = 9; //Ставка риска начальной маржи для КСУР лонг. [Подробнее про ставки риска](https://www.tbank.ru/invest/help/brokerage/account/margin/about/#q5). + Quotation dshort = 10; //Ставка риска начальной маржи для КСУР шорт. [Подробнее про ставки риска](https://www.tbank.ru/invest/help/brokerage/account/margin/about/#q5). + Quotation dlong_min = 11; //Ставка риска начальной маржи для КПУР лонг. [Подробнее про ставки риска](https://www.tbank.ru/invest/help/brokerage/account/margin/about/#q5). + Quotation dshort_min = 12; //Ставка риска начальной маржи для КПУР шорт. [Подробнее про ставки риска](https://www.tbank.ru/invest/help/brokerage/account/margin/about/#q5). + bool short_enabled_flag = 13; //Признак доступности для операций в шорт. + string name = 15; //Название инструмента. + string exchange = 16; //Tорговая площадка (секция биржи). + + google.protobuf.Timestamp ipo_date = 17; //Дата IPO акции по UTC. + int64 issue_size = 18; //Размер выпуска. + + string country_of_risk = 19; //Код страны риска — то есть страны, в которой компания ведет основной бизнес. + string country_of_risk_name = 20; //Наименование страны риска — то есть страны, в которой компания ведет основной бизнес. + string sector = 21; //Сектор экономики. + int64 issue_size_plan = 22; //Плановый размер выпуска. + MoneyValue nominal = 23; //Номинал. + + SecurityTradingStatus trading_status = 25; //Текущий режим торгов инструмента. + bool otc_flag = 26; //Флаг, используемый ранее для определения внебиржевых инструментов. На данный момент не используется для торгуемых через API инструментов. Может использоваться как фильтр для операций, совершавшихся некоторое время назад на ОТС площадке. + bool buy_available_flag = 27; //Признак доступности для покупки. + bool sell_available_flag = 28; //Признак доступности для продажи. + bool div_yield_flag = 29; //Признак наличия дивидендной доходности. + ShareType share_type = 30; //Тип акции. Возможные значения — `[ShareType](./instruments#sharetype)`. + Quotation min_price_increment = 31; //Шаг цены. + bool api_trade_available_flag = 32; //Возможность торговать инструментом через API. + + string uid = 33; //Уникальный идентификатор инструмента. + RealExchange real_exchange = 34; //Реальная площадка исполнения расчетов (биржа). + string position_uid = 35; //Уникальный идентификатор позиции инструмента. + string asset_uid = 36; //Уникальный идентификатор актива. + InstrumentExchangeType instrument_exchange = 37; //Тип площадки торговли. + + repeated string required_tests = 38; //Тесты, которые необходимо пройти клиенту, чтобы совершать сделки по инструменту. + + bool for_iis_flag = 46; //Признак доступности для ИИС. + bool for_qual_investor_flag = 47; //Флаг, отображающий доступность торговли инструментом только для квалифицированных инвесторов. + bool weekend_flag = 48; //Флаг, отображающий доступность торговли инструментом по выходным. + bool blocked_tca_flag = 49; //Флаг заблокированного ТКС. + bool liquidity_flag = 50; //Флаг достаточной ликвидности. + google.protobuf.Timestamp first_1min_candle_date = 56; //Дата первой минутной свечи. + google.protobuf.Timestamp first_1day_candle_date = 57; //Дата первой дневной свечи. + BrandData brand = 60; // Информация о бренде. + + Quotation dlong_client = 90; //Ставка риска в лонг с учетом текущего уровня риска портфеля клиента. [Подробнее про ставки риска](https://www.tbank.ru/invest/help/brokerage/account/margin/about/#q5). + Quotation dshort_client = 91; //Ставка риска в шорт с учетом текущего уровня риска портфеля клиента. [Подробнее про ставки риска](https://www.tbank.ru/invest/help/brokerage/account/margin/about/#q5). +} + +//Объект передачи информации о структурной ноте. +message StructuredNote { + string uid = 1; //Уникальный идентификатор инструмента. + string figi = 2; //FIGI-идентификатор инструмента. + string ticker = 3; //Тикер инструмента. + string class_code = 4; //Класс-код (секция торгов). + string isin = 5; //ISIN-идентификатор инструмента. + string name = 6; //Название инструмента. + string asset_uid = 7; //Уникальный идентификатор актива. + string position_uid = 8; //Уникальный идентификатор позиции. + Quotation min_price_increment = 9; //Шаг цены. + int32 lot = 10; //Лотность инструмента. + MoneyValue nominal = 11; //Номинал. + string currency = 12; //Валюта расчетов. + google.protobuf.Timestamp maturity_date = 13; //Дата погашения облигации в формате UTC. + google.protobuf.Timestamp placement_date = 14; //Дата размещения в формате UTC. + string issue_kind = 15; //Форма выпуска. + int32 issue_size = 16; //Размер выпуска. + int32 issue_size_plan = 17; //Плановый размер выпуска. + Quotation dlong_client = 18; //Ставка риска клиента по инструменту лонг. + Quotation dshort_client = 19; //Ставка риска клиента по инструменту шорт. + bool short_enabled_flag = 20; //Признак доступности для операций в шорт. + string exchange = 21; //Торговая площадка (секция биржи). + SecurityTradingStatus trading_status = 22; //Текущий режим торгов инструмента. + bool api_trade_available_flag = 23; //Признак доступности торгов по бумаге через API. + bool buy_available_flag = 24; //Признак доступности для покупки. + bool sell_available_flag = 25; //Признак доступности для продажи. + bool limit_order_available_flag = 26; //Признак доступности выставления лимитной заявки по инструменту. + bool market_order_available_flag = 27; //Признак доступности выставления рыночной заявки по инструменту. + bool bestprice_order_available_flag = 28; //Признак доступности выставления bestprice заявки по инструменту. + bool weekend_flag = 29; //Флаг отображающий доступность торговли инструментом по выходным. + bool liquidity_flag = 30; //Флаг достаточной ликвидности. + bool for_iis_flag = 31; //Возможность покупки/продажи на ИИС. + bool for_qual_investor_flag = 32; //Флаг отображающий доступность торговли инструментом только для квалифицированных инвесторов. + bool pawnshop_list_flag = 33; //Признак ФИ, включенного в ломбардный список. + RealExchange real_exchange = 34; //Реальная площадка исполнения расчётов. + google.protobuf.Timestamp first_1min_candle_date = 35; //Дата первой минутной свечи. + google.protobuf.Timestamp first_1day_candle_date = 36; //Дата первой дневной свечи. + string borrow_name = 37; //Название заемщика. + string type = 38; //Тип структурной ноты. + LogicPortfolio logic_portfolio = 39; //Стратегия портфеля. + AssetType asset_type = 40; //Тип базового актива. + repeated BasicAsset basic_assets = 41; //Базовые активы, входящие в ноту. + Quotation safety_barrier = 42; //Барьер сохранности (в процентах). + string coupon_period_base = 43; //Базис расчета НКД. + ObservationPrinciple observation_principle = 44; //Принцип наблюдений. + string observation_frequency = 45; //Частота наблюдений. + google.protobuf.Timestamp initial_price_fixing_date = 46; //Дата фиксации цен базовых активов. + repeated Yield yield = 47; //Доходность по ноте в годовом выражении. + bool coupon_saving_flag = 48; //Признак сохранения купонов. + string sector = 49; //Сектор экономики. + string country_of_risk = 50; //Код страны рисков. + string country_of_risk_name = 51; //Наименование страны рисков. + string logo_name = 52; //Имя файла логотипа эмитента. + repeated string required_tests = 53; //Тесты, которые необходимо пройти клиенту, чтобы совершать покупки по бумаге. + + //Базовый актив. + message BasicAsset { + string uid = 1; //Уникальный идентификатор базового актива. + AssetType type = 2; //Тип базового актива. + Quotation initial_price = 3; //Начальная цена базового актива. + } + + //Доходность. + message Yield { + YieldType type = 1; //Тип доходности. + Quotation value = 2; //Значение доходности. + } + + //Стратегия портфеля. + enum LogicPortfolio { + LOGIC_PORTFOLIO_UNSPECIFIED = 0; //Стратегия портфеля не определена. + LOGIC_PORTFOLIO_VOLATILITY = 1; //Волатильность. + LOGIC_PORTFOLIO_CORRELATION = 2; //Корреляция. + } + + //Принцип наблюдений. + enum ObservationPrinciple { + OBSERVATION_PRINCIPLE_UNSPECIFIED = 0; //Принцип наблюдений не определен. + OBSERVATION_PRINCIPLE_WORST_BASIC_ASSET = 1; //По худшему базовому активу. + OBSERVATION_PRINCIPLE_BEST_BASIC_ASSET = 2; //По лучшему базовому активу. + OBSERVATION_PRINCIPLE_AVERAGE_OF_BASIC_ASSETS = 3; //Среднее значение по базовым активам. + OBSERVATION_PRINCIPLE_SINGLE_BASIC_ASSET_PERFORMANCE = 4; //Динамика актива (только если у ноты один базовый актив). + } + + //Тип доходности. + enum YieldType { + YIELD_TYPE_UNSPECIFIED = 0; //Тип доходности не определен. + YIELD_TYPE_GUARANTED_COUPON = 1; //Гарантированный купон. + YIELD_TYPE_CONDITIONAL_COUPON = 2; //Условный купон. + YIELD_TYPE_PARTICIPATION = 3; //Участие в росте. + } +} + +//Запрос НКД по облигации. +message GetAccruedInterestsRequest { + string figi = 1 [deprecated = true]; //FIGI-идентификатор инструмента. + google.protobuf.Timestamp from = 2 [(google.api.field_behavior) = REQUIRED]; //Начало запрашиваемого периода по UTC. + google.protobuf.Timestamp to = 3 [(google.api.field_behavior) = REQUIRED]; //Окончание запрашиваемого периода по UTC. + string instrument_id = 4 [(google.api.field_behavior) = REQUIRED]; //Идентификатор инструмента. Принимает значение `figi`, `instrument_uid` или `ticker + '_' + class_code`. +} + +//НКД облигации. +message GetAccruedInterestsResponse { + repeated AccruedInterest accrued_interests = 1; //Массив операций начисления купонов. +} + +//Операция начисления купонов. +message AccruedInterest { + google.protobuf.Timestamp date = 1; //Дата и время выплаты по UTC. + Quotation value = 2; //Величина выплаты. + Quotation value_percent = 3; //Величина выплаты в процентах от номинала. + Quotation nominal = 4; //Номинал облигации. +} + +//Запрос информации о фьючерсе +message GetFuturesMarginRequest { + string figi = 1 [deprecated = true]; // Идентификатор инструмента. + string instrument_id = 4 [(google.api.field_behavior) = REQUIRED]; //Идентификатор инструмента. Принимает значение `figi`, `instrument_uid` или `ticker + '_' + class_code`. +} + +//Данные по фьючерсу +message GetFuturesMarginResponse { + MoneyValue initial_margin_on_buy = 1; //Гарантийное обеспечение при покупке. + MoneyValue initial_margin_on_sell = 2; //Гарантийное обеспечение при продаже. + Quotation min_price_increment = 3; //Шаг цены. + Quotation min_price_increment_amount = 4; //Стоимость шага цены. +} + +//Тип идентификатора инструмента. [Подробнее об идентификации инструментов](/invest/intro/intro/faq_identification). +enum InstrumentIdType { + INSTRUMENT_ID_UNSPECIFIED = 0; //Значение не определено. + INSTRUMENT_ID_TYPE_FIGI = 1; //FIGI. + INSTRUMENT_ID_TYPE_TICKER = 2; //Ticker. + INSTRUMENT_ID_TYPE_UID = 3; //Уникальный идентификатор. + INSTRUMENT_ID_TYPE_POSITION_UID = 4; //Идентификатор позиции. + INSTRUMENT_ID_TYPE_ID = 5; //Универсальный тип идентификатора инструмента. +} + +//Данные по инструменту. +message InstrumentResponse { + Instrument instrument = 1; // Основная информация об инструменте. +} + +//Объект передачи основной информации об инструменте. +message Instrument { + string figi = 1; //FIGI-идентификатор инструмента. + string ticker = 2; //Тикер инструмента. + string class_code = 3; //Класс-код инструмента. + string isin = 4; //ISIN-идентификатор инструмента. + int32 lot = 5; //Лотность инструмента. Возможно совершение операций только на количества ценной бумаги, кратные параметру `lot`. [Подробнее](./glossary#lot). + string currency = 6; //Валюта расчетов. + + Quotation klong = 7 [deprecated = true]; //Коэффициент ставки риска длинной позиции по клиенту. 2 – клиент со стандартным уровнем риска (КСУР); 1 – клиент с повышенным уровнем риска (КПУР). + Quotation kshort = 8 [deprecated = true]; //Коэффициент ставки риска короткой позиции по клиенту. 2 – клиент со стандартным уровнем риска (КСУР); 1 – клиент с повышенным уровнем риска (КПУР). + Quotation dlong = 9; //Ставка риска начальной маржи для КСУР лонг. [Подробнее про ставки риска](https://www.tbank.ru/invest/help/brokerage/account/margin/about/#q5). + Quotation dshort = 10; //Ставка риска начальной маржи для КСУР шорт. [Подробнее про ставки риска](https://www.tbank.ru/invest/help/brokerage/account/margin/about/#q5). + Quotation dlong_min = 11; //Ставка риска начальной маржи для КПУР лонг. [Подробнее про ставки риска](https://www.tbank.ru/invest/help/brokerage/account/margin/about/#q5). + Quotation dshort_min = 12; //Ставка риска начальной маржи для КПУР шорт. [Подробнее про ставки риска](https://www.tbank.ru/invest/help/brokerage/account/margin/about/#q5). + bool short_enabled_flag = 13; //Признак доступности для операций в шорт. + string name = 14; //Название инструмента. + string exchange = 15; //Tорговая площадка (секция биржи). + + string country_of_risk = 16; //Код страны риска — то есть страны, в которой компания ведет основной бизнес. + string country_of_risk_name = 17; //Наименование страны риска — то есть страны, в которой компания ведет основной бизнес. + string instrument_type = 18; //Тип инструмента. + + SecurityTradingStatus trading_status = 19; //Текущий режим торгов инструмента. + bool otc_flag = 20; //Флаг, используемый ранее для определения внебиржевых инструментов. На данный момент не используется для торгуемых через API инструментов. Может использоваться как фильтр для операций, совершавшихся некоторое время назад на ОТС площадке. + bool buy_available_flag = 21; //Признак доступности для покупки. + bool sell_available_flag = 22; //Признак доступности для продажи. + Quotation min_price_increment = 23; //Шаг цены. + bool api_trade_available_flag = 24; //Параметр указывает на возможность торговать инструментом через API. + + string uid = 25; //Уникальный идентификатор инструмента. + RealExchange real_exchange = 26; //Реальная площадка исполнения расчетов (биржа). + string position_uid = 27; //Уникальный идентификатор позиции инструмента. + string asset_uid = 28; //Уникальный идентификатор актива. + + repeated string required_tests = 29; //Тесты, которые необходимо пройти клиенту, чтобы совершать сделки по инструменту. + + bool for_iis_flag = 36; //Признак доступности для ИИС. + bool for_qual_investor_flag = 37; //Флаг, отображающий доступность торговли инструментом только для квалифицированных инвесторов. + bool weekend_flag = 38; //Флаг, отображающий доступность торговли инструментом по выходным. + bool blocked_tca_flag = 39; //Флаг заблокированного ТКС. + InstrumentType instrument_kind = 40; //Тип инструмента. + google.protobuf.Timestamp first_1min_candle_date = 56; //Дата первой минутной свечи. + google.protobuf.Timestamp first_1day_candle_date = 57; //Дата первой дневной свечи. + BrandData brand = 60; // Информация о бренде. + + Quotation dlong_client = 490; //Ставка риска в лонг с учетом текущего уровня риска портфеля клиента. [Подробнее про ставки риска](https://www.tbank.ru/invest/help/brokerage/account/margin/about/#q5). + Quotation dshort_client = 491; //Ставка риска в шорт с учетом текущего уровня риска портфеля клиента. [Подробнее про ставки риска](https://www.tbank.ru/invest/help/brokerage/account/margin/about/#q5). + +} + +//Запрос дивидендов. +message GetDividendsRequest { + string figi = 1 [deprecated = true]; //FIGI-идентификатор инструмента. + optional google.protobuf.Timestamp from = 2; //Начало запрашиваемого периода по UTC. Фильтрация происходит по параметру `record_date` — дата фиксации реестра. + optional google.protobuf.Timestamp to = 3; //Окончание запрашиваемого периода по UTC. Фильтрация происходит по параметру `record_date` — дата фиксации реестра. + string instrument_id = 4 [(google.api.field_behavior) = REQUIRED]; //Идентификатор инструмента. Принимает значение `figi`, `instrument_uid` или `ticker + '_' + class_code`. +} + +//Дивиденды. +message GetDividendsResponse { + repeated Dividend dividends = 1; +} + +//Информация о выплате. +message Dividend { + MoneyValue dividend_net = 1; //Величина дивиденда на 1 ценную бумагу (включая валюту). + google.protobuf.Timestamp payment_date = 2; //Дата фактических выплат по UTC. + google.protobuf.Timestamp declared_date = 3; //Дата объявления дивидендов по UTC. + google.protobuf.Timestamp last_buy_date = 4; //Последний день (включительно) покупки для получения выплаты по UTC. + string dividend_type = 5; //Тип выплаты. Возможные значения: `Regular Cash` – регулярные выплаты, `Cancelled` – выплата отменена, `Daily Accrual` – ежедневное начисление, `Return of Capital` – возврат капитала, прочие типы выплат. + google.protobuf.Timestamp record_date = 6; //Дата фиксации реестра по UTC. + string regularity = 7; //Регулярность выплаты. Возможные значения: `Annual` – ежегодная, `Semi-Anl` – каждые полгода, прочие типы выплат. + MoneyValue close_price = 8; //Цена закрытия инструмента на момент `ex_dividend_date`. + Quotation yield_value = 9; //Величина доходности. + google.protobuf.Timestamp created_at = 10; //Дата и время создания записи по UTC. +} + +//Тип акций. +enum ShareType { + SHARE_TYPE_UNSPECIFIED = 0; //Значение не определено. + SHARE_TYPE_COMMON = 1; //Обыкновенная. + SHARE_TYPE_PREFERRED = 2; //Привилегированная. + SHARE_TYPE_ADR = 3; //Американские депозитарные расписки. + SHARE_TYPE_GDR = 4; //Глобальные депозитарные расписки. + SHARE_TYPE_MLP = 5; //Товарищество с ограниченной ответственностью. + SHARE_TYPE_NY_REG_SHRS = 6; //Акции из реестра Нью-Йорка. + SHARE_TYPE_CLOSED_END_FUND = 7; //Закрытый инвестиционный фонд. + SHARE_TYPE_REIT = 8; //Траст недвижимости. +} + +//Запрос актива по идентификатору. +message AssetRequest { + string id = 1 [(google.api.field_behavior) = REQUIRED]; //UID-идентификатор актива. +} + +//Данные по активу. +message AssetResponse { + AssetFull asset = 1; //Актив. +} + +//Запрос списка активов. +message AssetsRequest { + optional InstrumentType instrument_type = 1; + optional InstrumentStatus instrument_status = 2; //Статус запрашиваемых инструментов. [Возможные значения](#instrumentstatus). + +} + +//Список активов. +message AssetsResponse { + repeated Asset assets = 1; //Активы. +} + +message AssetFull { + string uid = 1; //Уникальный идентификатор актива. + AssetType type = 2; //Тип актива. + string name = 3; //Наименование актива. + string name_brief = 4; //Короткое наименование актива. + string description = 5; //Описание актива. + google.protobuf.Timestamp deleted_at = 6; //Дата и время удаления актива. + repeated string required_tests = 7; //Тестирование клиентов. + oneof ext { + AssetCurrency currency = 8; //Валюта. Обязательно и заполняется только для `type = ASSET_TYPE_CURRENCY`. + AssetSecurity security = 9; //Ценная бумага. Обязательно и заполняется только для `type = ASSET_TYPE_SECURITY`. + } + string gos_reg_code = 10; //Номер государственной регистрации. + string cfi = 11; //Код CFI. + string code_nsd = 12; //Код НРД инструмента. + string status = 13; //Статус актива. + Brand brand = 14; //Бренд. + google.protobuf.Timestamp updated_at = 15; //Дата и время последнего обновления записи. + string br_code = 16; //Код типа ц.б. по классификации Банка России. + string br_code_name = 17; //Наименование кода типа ц.б. по классификации Банка России. + repeated AssetInstrument instruments = 18; //Массив идентификаторов инструментов. +} + +//Информация об активе. +message Asset { + string uid = 1; //Уникальный идентификатор актива. + AssetType type = 2; //Тип актива. + string name = 3; //Наименование актива. + repeated AssetInstrument instruments = 4; //Массив идентификаторов инструментов. +} + +//Тип актива. +enum AssetType { + ASSET_TYPE_UNSPECIFIED = 0; //Тип не определен. + ASSET_TYPE_CURRENCY = 1; //Валюта. + ASSET_TYPE_COMMODITY = 2; //Товар. + ASSET_TYPE_INDEX = 3; //Индекс. + ASSET_TYPE_SECURITY = 4; //Ценная бумага. +} + +//Валюта. +message AssetCurrency { + string base_currency = 1; //ISO-код валюты. +} + +//Ценная бумага. +message AssetSecurity { + string isin = 1; //ISIN-идентификатор ценной бумаги. + string type = 2; //Тип ценной бумаги. + InstrumentType instrument_kind = 10; //Тип инструмента. + oneof ext { + AssetShare share = 3; //Акция. Заполняется только для акций — тип актива `asset.type = ASSET_TYPE_SECURITY` и `security.type = share`. + AssetBond bond = 4; //Облигация. Заполняется только для облигаций — тип актива `asset.type = ASSET_TYPE_SECURITY` и `security.type = bond`. + AssetStructuredProduct sp = 5; //Структурная нота. Заполняется только для структурных продуктов — тип актива `asset.type = ASSET_TYPE_SECURITY` и `security.type = sp`. + AssetEtf etf = 6; // Фонд. Заполняется только для фондов — тип актива `asset.type = ASSET_TYPE_SECURITY` и `security.type = etf`. + AssetClearingCertificate clearing_certificate = 7; // Клиринговый сертификат участия. Заполняется только для клиринговых сертификатов — тип актива `asset.type = ASSET_TYPE_SECURITY` и security.type = `clearing_certificate`. + } +} + +//Акция. +message AssetShare { + ShareType type = 1; //Тип акции. + Quotation issue_size = 2; //Объем выпуска (шт.). + Quotation nominal = 3; //Номинал. + string nominal_currency = 4; //Валюта номинала. + string primary_index = 5; //Индекс (Bloomberg). + Quotation dividend_rate = 6; //Ставка дивиденда (для привилегированных акций). + string preferred_share_type = 7; //Тип привилегированных акций. + google.protobuf.Timestamp ipo_date = 8; //Дата IPO. + google.protobuf.Timestamp registry_date = 9; //Дата регистрации. + bool div_yield_flag = 10; //Признак наличия дивидендной доходности. + string issue_kind = 11; //Форма выпуска ФИ. + google.protobuf.Timestamp placement_date = 12; //Дата размещения акции. + string repres_isin = 13; //ISIN базового актива. + Quotation issue_size_plan = 14; //Объявленное количество, шт. + Quotation total_float = 15; //Количество акций в свободном обращении. +} + +//Облигация. +message AssetBond { + Quotation current_nominal = 1; //Текущий номинал. + string borrow_name = 2; //Наименование заемщика. + Quotation issue_size = 3; //Объем эмиссии облигации (стоимость). + Quotation nominal = 4 ; //Номинал облигации. + string nominal_currency = 5; //Валюта номинала. + string issue_kind = 6; //Форма выпуска облигации. + string interest_kind = 7; //Форма дохода облигации. + int32 coupon_quantity_per_year = 8; //Количество выплат в год. + bool indexed_nominal_flag = 9; //Признак облигации с индексируемым номиналом. + bool subordinated_flag = 10; //Признак субординированной облигации. + bool collateral_flag = 11; //Признак обеспеченной облигации. + bool tax_free_flag = 12; //Признак показывает, что купоны облигации не облагаются налогом — для mass market. + bool amortization_flag = 13; //Признак облигации с амортизацией долга. + bool floating_coupon_flag = 14; //Признак облигации с плавающим купоном. + bool perpetual_flag = 15; //Признак бессрочной облигации. + google.protobuf.Timestamp maturity_date = 16; //Дата погашения облигации. + string return_condition = 17; //Описание и условия получения дополнительного дохода. + google.protobuf.Timestamp state_reg_date = 18; //Дата выпуска облигации. + google.protobuf.Timestamp placement_date = 19; //Дата размещения облигации. + Quotation placement_price = 20; //Цена размещения облигации. + Quotation issue_size_plan = 21; //Объявленное количество, шт. +} + +//Структурная нота. +message AssetStructuredProduct { + string borrow_name = 1; //Наименование заемщика. + Quotation nominal = 2; //Номинал. + string nominal_currency = 3; //Валюта номинала. + StructuredProductType type = 4; //Тип структурной ноты. + string logic_portfolio = 5; //Стратегия портфеля. + AssetType asset_type = 6; //Тип базового актива. + string basic_asset = 7; //Вид базового актива в зависимости от типа базового актива. + Quotation safety_barrier = 8; //Барьер сохранности в процентах. + google.protobuf.Timestamp maturity_date = 9; //Дата погашения. + Quotation issue_size_plan = 10; //Объявленное количество, шт. + Quotation issue_size = 11; //Объем размещения. + google.protobuf.Timestamp placement_date = 12; //Дата размещения ноты. + string issue_kind = 13; //Форма выпуска. +} + +//Тип структурной ноты. +enum StructuredProductType { + SP_TYPE_UNSPECIFIED = 0; //Тип не определен. + SP_TYPE_DELIVERABLE = 1; //Поставочный. + SP_TYPE_NON_DELIVERABLE = 2; //Беспоставочный. +} + +//Фонд. +message AssetEtf { + Quotation total_expense = 1; //Суммарные расходы фонда в процентах. + Quotation hurdle_rate = 2; //Барьерная ставка доходности, после которой фонд имеет право на perfomance fee — в процентах. + Quotation performance_fee = 3; //Комиссия за успешные результаты фонда в процентах. + Quotation fixed_commission = 4; //Фиксированная комиссия за управление в процентах. + string payment_type = 5; //Тип распределения доходов от выплат по бумагам. + bool watermark_flag = 6; //Признак необходимости выхода фонда в плюс для получения комиссии. + Quotation buy_premium = 7; //Премия (надбавка к цене) при покупке доли в фонде — в процентах. + Quotation sell_discount = 8; //Ставка дисконта (вычет из цены) при продаже доли в фонде — в процентах. + bool rebalancing_flag = 9; //Признак ребалансируемости портфеля фонда. + string rebalancing_freq = 10; //Периодичность ребалансировки. + string management_type = 11; //Тип управления. + string primary_index = 12; //Индекс, который реплицирует (старается копировать) фонд. + string focus_type = 13; //База ETF. + bool leveraged_flag = 14; //Признак использования заемных активов (плечо). + Quotation num_share = 15; //Количество акций в обращении. + bool ucits_flag = 16; //Признак обязательства по отчетности перед регулятором. + google.protobuf.Timestamp released_date = 17; //Дата выпуска. + string description = 18; //Описание фонда. + string primary_index_description = 19; //Описание индекса, за которым следует фонд. + string primary_index_company = 20; //Основные компании, в которые вкладывается фонд. + Quotation index_recovery_period = 21; //Срок восстановления индекса после просадки. + string inav_code = 22; //IVAV-код. + bool div_yield_flag = 23; //Признак наличия дивидендной доходности. + Quotation expense_commission = 24; //Комиссия на покрытие расходов фонда в процентах. + Quotation primary_index_tracking_error = 25; //Ошибка следования за индексом в процентах. + string rebalancing_plan = 26; //Плановая ребалансировка портфеля. + string tax_rate = 27; //Ставки налогообложения дивидендов и купонов. + repeated google.protobuf.Timestamp rebalancing_dates = 28; //Даты ребалансировок. + string issue_kind = 29; //Форма выпуска. + Quotation nominal = 30; //Номинал. + string nominal_currency = 31; //Валюта номинала. +} + +//Клиринговый сертификат участия. +message AssetClearingCertificate { + Quotation nominal = 1; //Номинал. + string nominal_currency = 2; //Валюта номинала. +} + +//Бренд. +message Brand { + string uid = 1; //UID-идентификатор бренда. + string name = 2; //Наименование бренда. + string description = 3; //Описание. + string info = 4; //Информация о бренде. + string company = 5; //Компания. + string sector = 6; //Сектор. + string country_of_risk = 7; //Код страны риска. + string country_of_risk_name = 8; //Наименование страны риска. +} + +//Идентификаторы инструмента. +message AssetInstrument { + string uid = 1; //UID-идентификатор инструмента. + string figi = 2; //FIGI-идентификатор инструмента. + string instrument_type = 3; //Тип инструмента. + string ticker = 4; //Тикер инструмента. + string class_code = 5; //Класс-код (секция торгов). + repeated InstrumentLink links = 6; //Массив связанных инструментов. + InstrumentType instrument_kind = 10; //Тип инструмента. + string position_uid = 11; //ID позиции. +} + +//Связь с другим инструментом. +message InstrumentLink { + string type = 1; //Тип связи. + string instrument_uid = 2; //UID-идентификатор связанного инструмента. +} + +//Запрос списка избранных инструментов, входные параметры не требуются. +message GetFavoritesRequest { + optional string group_id = 1; //Уникальный идентификатор группы. +} + +//В ответ передается список избранных инструментов в качестве массива. +message GetFavoritesResponse { + repeated FavoriteInstrument favorite_instruments = 1; //Массив инструментов. + optional string group_id = 2; //Уникальный идентификатор группы. +} + +//Массив избранных инструментов. +message FavoriteInstrument { + string figi = 1; //FIGI-идентификатор инструмента. + string ticker = 2; //Тикер инструмента. + string class_code = 3; //Класс-код инструмента. + string isin = 4; //ISIN-идентификатор инструмента. + string instrument_type = 11; //Тип инструмента. + string name = 12; //Название инструмента. + string uid = 13; //Уникальный идентификатор инструмента. + bool otc_flag = 16; //Флаг, используемый ранее для определения внебиржевых инструментов. На данный момент не используется для торгуемых через API инструментов. Может использоваться как фильтр для операций, совершавшихся некоторое время назад на ОТС площадке. + bool api_trade_available_flag = 17; //Возможность торговать инструментом через API. + InstrumentType instrument_kind = 18; //Тип инструмента. +} + +//Запрос редактирования списка избранных инструментов. +message EditFavoritesRequest { + repeated EditFavoritesRequestInstrument instruments = 1 [(google.api.field_behavior) = REQUIRED]; //Массив инструментов. + EditFavoritesActionType action_type = 6 [(google.api.field_behavior) = REQUIRED]; //Тип действия со списком. + optional string group_id = 7; //Уникальный идентификатор группы. +} + +//Массив инструментов для редактирования списка избранных инструментов. +message EditFavoritesRequestInstrument { + optional string figi = 1 [deprecated = true]; //FIGI-идентификатор инструмента. + string instrument_id = 2 [(google.api.field_behavior) = REQUIRED]; //Идентификатор инструмента — `figi` или `instrument_uid`. +} + +//Тип действия со списком избранных инструментов. +enum EditFavoritesActionType { + EDIT_FAVORITES_ACTION_TYPE_UNSPECIFIED = 0; //Тип не определен. + EDIT_FAVORITES_ACTION_TYPE_ADD = 1; //Добавить в список. + EDIT_FAVORITES_ACTION_TYPE_DEL = 2; //Удалить из списка. +} + +//Результат редактирования списка избранных инструментов. +message EditFavoritesResponse { + repeated FavoriteInstrument favorite_instruments = 1; //Массив инструментов. + optional string group_id = 2; //Уникальный идентификатор группы. +} + +//Запрос создания новой группы избранных инструментов. +message CreateFavoriteGroupRequest { + string group_name = 1 [(google.api.field_behavior) = REQUIRED]; //Название группы, не более 255 символов. + string group_color = 2 [(google.api.field_behavior) = REQUIRED]; //Цвет группы. Принимает значения в HEX-формате, от "000000" до "FFFFFF" + optional string note = 3; //Описание +} + +message CreateFavoriteGroupResponse { + string group_id = 1; //Уникальный идентификатор группы. + string group_name = 2; //Название группы. +} + +//Запрос удаления избранной группы +message DeleteFavoriteGroupRequest { + string group_id = 1 [(google.api.field_behavior) = REQUIRED]; //Уникальный идентификатор группы. +} + +message DeleteFavoriteGroupResponse { +} + +//Запрос получения списка избранных групп +message GetFavoriteGroupsRequest { + repeated string instrument_id = 1; //Массив идентификаторов инструментов. Принимает значение `figi` или `instrument_uid`. Если в группе будет хотя бы один из инструментов массива, то в ответе у группы вернется признак `containsInstrument = true`. + repeated string excluded_group_id = 2; //Массив идентификаторов групп, которые необходимо исключить из ответа. +} + +//Избранные группы +message GetFavoriteGroupsResponse { + repeated FavoriteGroup groups = 1; //Массив групп избранных списков инструментов. + + //Избранная группа + message FavoriteGroup { + string group_id = 1 [(google.api.field_behavior) = REQUIRED]; //Уникальный идентификатор группы. + string group_name = 2 [(google.api.field_behavior) = REQUIRED]; //Название группы. + string color = 3 [(google.api.field_behavior) = REQUIRED]; //Цвет группы в HEX-формате. + int32 size = 4 [(google.api.field_behavior) = REQUIRED]; //Количество инструментов в группе. + optional bool contains_instrument = 5; //Признак наличия в группе хотя бы одного инструмента из запроса. + } +} + +//Запрос справочника стран. +message GetCountriesRequest {} + +//Справочник стран. +message GetCountriesResponse { + repeated CountryResponse countries = 1; //Массив стран. +} + +//Запрос справочника индексов и товаров +message IndicativesRequest {} + +//Справочник индексов и товаров +message IndicativesResponse { + repeated IndicativeResponse instruments = 1; // Массив инструментов. +}; + +//Индикатив +message IndicativeResponse { + string figi = 1; // FIGI-идентификатор инструмента. + string ticker = 2; // Тикер инструмента. + string class_code = 3; // Класс-код инструмента. + string currency = 4; // Валюта расчетов. + InstrumentType instrument_kind = 10; //Тип инструмента. + string name = 12; // Название инструмента. + string exchange = 13; // Tорговая площадка (секция биржи). + string uid = 14; // Уникальный идентификатор инструмента. + bool buy_available_flag = 404; // Признак доступности для покупки. + bool sell_available_flag = 405; // Признак доступности для продажи. + repeated IndexInstrument index_composition = 406; // Состав индекса. +} + +//Инструмент в составе индекса +message IndexInstrument { + string uid = 1; // Идентификатор инструмента. + Quotation weight = 2; // Вес инструмента в составе индекса в %. +} + +//Данные о стране. +message CountryResponse { + string alfa_two = 1; //Двухбуквенный код страны. + string alfa_three = 2; //Трехбуквенный код страны. + string name = 3; //Наименование страны. + string name_brief = 4; //Краткое наименование страны. +} + +//Запрос на поиск инструментов. +message FindInstrumentRequest { + string query = 1 [(google.api.field_behavior) = REQUIRED]; //Строка поиска. + optional InstrumentType instrument_kind = 2; //Фильтр по типу инструмента. + optional bool api_trade_available_flag = 3; //Фильтр для отображения только торговых инструментов. +} + +//Результат поиска инструментов. +message FindInstrumentResponse { + repeated InstrumentShort instruments = 1; //Массив инструментов, удовлетворяющих условиям поиска. +} + +//Краткая информация об инструменте. +message InstrumentShort { + string isin = 1; //ISIN инструмента. + string figi = 2; //FIGI инструмента. + string ticker = 3; //Ticker инструмента. + string class_code = 4; //ClassCode инструмента. + string instrument_type = 5; //Тип инструмента. + string name = 6; //Название инструмента. + string uid = 7; //Уникальный идентификатор инструмента. + string position_uid = 8; //Уникальный идентификатор позиции инструмента. + InstrumentType instrument_kind = 10; //Тип инструмента. + bool api_trade_available_flag = 11; //Возможность торговать инструментом через API. + bool for_iis_flag = 12; //Признак доступности для ИИС. + google.protobuf.Timestamp first_1min_candle_date = 26; //Дата первой минутной свечи. + google.protobuf.Timestamp first_1day_candle_date = 27; //Дата первой дневной свечи. + bool for_qual_investor_flag = 28; //Флаг, отображающий доступность торговли инструментом только для квалифицированных инвесторов. + bool weekend_flag = 29; //Флаг, отображающий доступность торговли инструментом по выходным. + bool blocked_tca_flag = 30; //Флаг заблокированного ТКС. + int32 lot = 31; //Количество бумаг в лоте. +} + +//Запрос списка брендов. +message GetBrandsRequest { + Page paging = 1; //Настройки пагинации. +} + +//Запрос бренда. +message GetBrandRequest { + string id = 1 [(google.api.field_behavior) = REQUIRED]; //UID-идентификатор бренда. +} + +//Список брендов. +message GetBrandsResponse { + repeated Brand brands = 1; //Массив брендов. + PageResponse paging = 2; //Данные по пагинации. +} + +//Запрос фундаментальных показателей +message GetAssetFundamentalsRequest { + repeated string assets = 1 [(google.api.field_behavior) = REQUIRED]; //Массив идентификаторов активов, не более 100 шт. +} + +//Фундаментальные показатели +message GetAssetFundamentalsResponse { + repeated StatisticResponse fundamentals = 1; + + //Фундаментальные показатели по активу + message StatisticResponse { + string asset_uid = 1; //Идентификатор актива. + string currency = 2; //Валюта. + double market_capitalization = 3; //Рыночная капитализация. + double high_price_last_52_weeks = 4; //Максимум за год. + double low_price_last_52_weeks = 5; //Минимум за год. + double average_daily_volume_last_10_days = 6; //Средний объем торгов за 10 дней. + double average_daily_volume_last_4_weeks = 7; //Средний объем торгов за месяц. + double beta = 8; + double free_float = 9; //Доля акций в свободном обращении. + double forward_annual_dividend_yield = 10; //Процент форвардной дивидендной доходности по отношению к цене акций. + double shares_outstanding = 11; //Количество акций в обращении. + double revenue_ttm = 12; //Выручка. + double ebitda_ttm = 13; //EBITDA — прибыль до вычета процентов, налогов, износа и амортизации. + double net_income_ttm = 14; //Чистая прибыль. + double eps_ttm = 15; //EPS — величина чистой прибыли компании, которая приходится на каждую обыкновенную акцию. + double diluted_eps_ttm = 16; //EPS компании с допущением, что все конвертируемые ценные бумаги компании были сконвертированы в обыкновенные акции. + double free_cash_flow_ttm = 17; //Свободный денежный поток. + double five_year_annual_revenue_growth_rate = 18; //Среднегодовой рocт выручки за 5 лет. + double three_year_annual_revenue_growth_rate = 19; //Среднегодовой рocт выручки за 3 года. + double pe_ratio_ttm = 20; //Соотношение рыночной капитализации компании к ее чистой прибыли. + double price_to_sales_ttm = 21; //Соотношение рыночной капитализации компании к ее выручке. + double price_to_book_ttm = 22; //Соотношение рыночной капитализации компании к ее балансовой стоимости. + double price_to_free_cash_flow_ttm = 23; //Соотношение рыночной капитализации компании к ее свободному денежному потоку. + double total_enterprise_value_mrq = 24; //Рыночная стоимость компании. + double ev_to_ebitda_mrq = 25; //Соотношение EV и EBITDA. + double net_margin_mrq = 26; //Маржа чистой прибыли. + double net_interest_margin_mrq = 27; //Рентабельность чистой прибыли. + double roe = 28; //Рентабельность собственного капитала. + double roa = 29; //Рентабельность активов. + double roic = 30; //Рентабельность активов. + double total_debt_mrq = 31; //Сумма краткосрочных и долгосрочных обязательств компании. + double total_debt_to_equity_mrq = 32; //Соотношение долга к собственному капиталу. + double total_debt_to_ebitda_mrq = 33; //Total Debt/EBITDA. + double free_cash_flow_to_price = 34; //Отношение свободногоо кэша к стоимости. + double net_debt_to_ebitda = 35; //Отношение чистого долга к EBITDA. + double current_ratio_mrq = 36; //Коэффициент текущей ликвидности. + double fixed_charge_coverage_ratio_fy = 37; //Коэффициент покрытия фиксированных платежей — FCCR. + double dividend_yield_daily_ttm = 38; //Дивидендная доходность за 12 месяцев. + double dividend_rate_ttm = 39; //Выплаченные дивиденды за 12 месяцев. + double dividends_per_share = 40; //Значение дивидендов на акцию. + double five_years_average_dividend_yield = 41; //Средняя дивидендная доходность за 5 лет. + double five_year_annual_dividend_growth_rate = 42; //Среднегодовой рост дивидендов за 5 лет. + double dividend_payout_ratio_fy = 43; //Процент чистой прибыли, уходящий на выплату дивидендов. + double buy_back_ttm = 44; //Деньги, потраченные на обратный выкуп акций. + double one_year_annual_revenue_growth_rate = 45; //Рост выручки за 1 год. + string domicile_indicator_code = 46; //Код страны. + double adr_to_common_share_ratio = 47; //Соотношение депозитарной расписки к акциям. + double number_of_employees = 48; //Количество сотрудников. + google.protobuf.Timestamp ex_dividend_date = 49; + google.protobuf.Timestamp fiscal_period_start_date = 50; //Начало фискального периода. + google.protobuf.Timestamp fiscal_period_end_date = 51; //Окончание фискального периода. + double revenue_change_five_years = 53; //Изменение общего дохода за 5 лет. + double eps_change_five_years = 54; //Изменение EPS за 5 лет. + double ebitda_change_five_years = 55; //Изменение EBIDTA за 5 лет. + double total_debt_change_five_years = 56; //Изменение общей задолжности за 5 лет. + double ev_to_sales = 57; //Отношение EV к выручке. + } + +} + +//Запрос отчетов эмитентов +message GetAssetReportsRequest { + string instrument_id = 1 [(google.api.field_behavior) = REQUIRED]; //Идентификатор инструмента. Принимает значение `figi`, `instrument_uid` или `ticker + '_' + class_code`. + optional google.protobuf.Timestamp from = 2; //Начало запрашиваемого периода по UTC. + optional google.protobuf.Timestamp to = 3; //Окончание запрашиваемого периода по UTC. +} + +//Отчеты эмитентов +message GetAssetReportsResponse { + repeated GetAssetReportsEvent events = 1; // Массив событий по облигации. + + //Отчет + message GetAssetReportsEvent { + string instrument_id = 1; // Идентификатор инструмента. + google.protobuf.Timestamp report_date = 2; // Дата публикации отчета. + int32 period_year = 3; // Год периода отчета. + int32 period_num = 4; // Номер периода. + AssetReportPeriodType period_type = 5; // Тип отчета. + google.protobuf.Timestamp created_at = 6; // Дата создания записи. + } + + enum AssetReportPeriodType { + PERIOD_TYPE_UNSPECIFIED = 0; // Не указан. + PERIOD_TYPE_QUARTER = 1; // Квартальный. + PERIOD_TYPE_SEMIANNUAL = 2; // Полугодовой. + PERIOD_TYPE_ANNUAL = 3; // Годовой. + } +} + +//Запрос консенсус-прогнозов +message GetConsensusForecastsRequest { + optional Page paging = 1; //Настройки пагинации. +} + +//Консенсус-прогнозы +message GetConsensusForecastsResponse { + repeated ConsensusForecastsItem items = 1; // Массив прогнозов. + PageResponse page = 2; //Данные по пагинации. + + //Прогноз + message ConsensusForecastsItem { + string uid = 1; // UID-идентификатор. + string asset_uid = 2; // UID-идентификатор актива. + google.protobuf.Timestamp created_at = 3; // Дата и время создания записи. + Quotation best_target_price = 4; // Целевая цена на 12 месяцев. + Quotation best_target_low = 5; // Минимальная прогнозная цена. + Quotation best_target_high = 6; // Максимальная прогнозная цена. + int32 total_buy_recommend = 7; // Количество аналитиков рекомендующих покупать. + int32 total_hold_recommend = 8; // Количество аналитиков рекомендующих держать. + int32 total_sell_recommend = 9; // Количество аналитиков рекомендующих продавать. + string currency = 10; // Валюта прогнозов инструмента. + Recommendation consensus = 11; // Консенсус-прогноз. + google.protobuf.Timestamp prognosis_date = 12; // Дата прогноза. + } + +} + +enum Recommendation { + RECOMMENDATION_UNSPECIFIED = 0; // Не определено. + RECOMMENDATION_BUY = 1; // Покупать. + RECOMMENDATION_HOLD = 2; // Держать. + RECOMMENDATION_SELL = 3; // Продавать. +} + +//Запрос прогнозов инвестдомов. +message GetForecastRequest { + string instrument_id = 1; // Идентификатор инструмента. Принимает значение `figi`, `instrument_uid` или `ticker + '_' + class_code`. +} + +//Прогнозы инвестдомов по инструменту. +message GetForecastResponse { + repeated TargetItem targets = 1; //Массив прогнозов. + ConsensusItem consensus = 2; // Согласованный прогноз. + + //Прогноз + message TargetItem { + string uid = 1; // Уникальный идентификатор инструмента. + string ticker = 2; // Тикер инструмента. + string company = 3; // Название компании, давшей прогноз. + Recommendation recommendation = 4; // Прогноз. + google.protobuf.Timestamp recommendation_date = 5; //Дата прогноза. + string currency = 6; // Валюта. + Quotation current_price = 7; // Текущая цена. + Quotation target_price = 8; // Прогнозируемая цена. + Quotation price_change = 9; // Изменение цены. + Quotation price_change_rel = 10; // Относительное изменение цены. + string show_name = 11; // Наименование инструмента. + } + + //Консенсус-прогноз. + message ConsensusItem { + string uid = 1; // Уникальный идентификатор инструмента. + string ticker = 2; // Тикер инструмента. + Recommendation recommendation = 3; // Прогноз. + string currency = 4; // Валюта. + Quotation current_price = 5; // Текущая цена. + Quotation consensus = 6; // Прогнозируемая цена. + Quotation min_target = 7; // Минимальная цена прогноза. + Quotation max_target = 8; // Максимальная цена прогноза. + Quotation price_change = 9; // Изменение цены. + Quotation price_change_rel = 10; // Относительное изменение цены. + } +} + + +//Запрос ставок риска +message RiskRatesRequest { + repeated string instrument_id = 1; // Идентификатор инструмента. Принимает значение `figi`, `instrument_uid` или `ticker + '_' + class_code`. +} + + +//Ставки риска +message RiskRatesResponse { + repeated RiskRateResult instrument_risk_rates = 1; + + message RiskRateResult { + string instrument_uid = 1; + optional RiskRate short_risk_rate = 2; // Ставка риска пользователя в шорт + optional RiskRate long_risk_rate = 3; // Ставка риска пользователя в лонг + repeated RiskRate short_risk_rates = 5; //Доступные ставки риска в шорт + repeated RiskRate long_risk_rates = 6; //Доступные ставки риска в лонг + optional string error = 9; // Ошибка. + } + message RiskRate { + string risk_level_code = 2; // Категория риска. + Quotation value = 5; // Значение ставки риска. + } +} + +message TradingInterval { + string type = 1; //Название интервала. + TimeInterval interval = 2; //Интервал. + + message TimeInterval { + google.protobuf.Timestamp start_ts = 1; // Время начала интервала. + google.protobuf.Timestamp end_ts = 2; // Время окончания интервала. + } +} + + +//Запрос сделок по инсайдерам +message GetInsiderDealsRequest { + string instrument_id = 1; // Идентификатор инструмента. Принимает значение `figi`, `instrument_uid` или `ticker + '_' + class_code`. + int32 limit = 2; // Количество выводимых записей в ответе, не больше 100. + optional string next_cursor = 3; // Курсор. +} + +//сделки инсайдеров +message GetInsiderDealsResponse { + + repeated InsiderDeal insider_deals = 1; // Массив сделок. + optional string next_cursor = 2; // Курсор для получения следующей страницы. + + message InsiderDeal { + int64 trade_id = 1; // Уникальный идентификатор сделки. + TradeDirection direction = 2; // Направление сделки. + string currency = 3; // Валюта сделки. + google.protobuf.Timestamp date = 4; // Дата сделки. + int64 quantity = 5; // Количество. + Quotation price = 6; // Цена. + string instrument_uid = 7; // Уникальный идентификатор инструмента. + string ticker = 8; // Тикер инструмента. + string investor_name = 9; // Имя инвестора. + string investor_position = 10; // Какое отношение покупатель/продавец имеет к эмитенту + float percentage = 11; // Купленный/проданный объём от общего количества ценных бумаг на рынке + bool is_option_execution = 12; //Признак является ли сделка реализацией опциона + google.protobuf.Timestamp disclosure_date = 13; // Дата раскрытия сделки. + } + + enum TradeDirection { + TRADE_DIRECTION_UNSPECIFIED = 0; // Не определено. + TRADE_DIRECTION_BUY = 1; // Покупка. + TRADE_DIRECTION_SELL = 2; // Продажа. + TRADE_DIRECTION_INCREASE = 3; // Увеличение доли. + TRADE_DIRECTION_DECREASE = 4; // Уменьшение доли. + } +} + +//Запрос цифровых активов +message DfasRequest { + +} + +//Цифровой актив +message DfaResponse { + string uid = 1; // Уникальный идентификатор инструмента. + string ticker = 2; // Тикер инструмента. + string name = 3; // Название инструмента. + string position_uid = 4; // Уникальный идентификатор позиции. + Quotation min_price_increment = 5; // Шаг цены. + int32 lot = 6; // Количество лотов. + MoneyValue nominal = 7; // Номинал. + string currency = 8; // Валюта. + google.protobuf.Timestamp maturity_date = 9; // Дата погашения ЦФА в формате UTC. + bool short_enabled_flag = 10; // Признак доступности для операций шорт. + bool api_trade_available_flag = 11; // Признак доступности торгов по бумаге через API. + bool buy_available_flag = 12; // Признак доступности для покупки. + bool sell_available_flag = 13; // Признак доступности для продажи. + bool limit_order_available_flag = 14; // Признак доступности выставления лимитной заявки по инструменту. + bool market_order_available_flag = 15; // Признак доступности выставления рыночной заявки по инструменту. + bool bestprice_order_available_flag = 16; // Признак доступности выставления bestprice заявки по инструменту. + bool for_iis_flag = 17; // Возможность покупки/продажи на ИИС. + bool for_qual_investor_flag = 18; // Флаг отображающий доступность торговли инструментом только для квалифицированных инвесторов. + string type = 19; // Тип актива. Возможные значения: credit_portfolio_dfa, debt_dfa. + repeated BasicAsset basic_assets = 20; // Базовые активы, входящие в ЦФА. + ForecastYield forecast_yield = 21; // Прогнозная доходность смарт-портфелей, в виде интервала в %. + Quotation yield_to_maturity = 22; // Доходность к погашению в %. + Quotation coupon_value = 23; // Величина купона. + int32 coupon_payment_frequency = 24; // Количество выплат в год. + google.protobuf.Timestamp coupon_payment_date = 25; // Дата выплаты купона. + Quotation aci_value = 26; // Значение НКД (накопленного купонного дохода) на дату. + + // Базовый актив. + message BasicAsset { + string uid = 1; // UID базового актива + } + + // Прогнозная доходность смарт-портфелей. + message ForecastYield { + Quotation min_value = 1; // Минимальное значение прогнозной доходности в % + Quotation max_value = 2; // Максимальное значение прогнозной доходности в % + } +} + + // Цифровые активы +message DfasResponse { + repeated DfaResponse instruments = 1; // Массив инструментов. +} + +//Уровень риска облигации. +enum RiskLevel { + RISK_LEVEL_UNSPECIFIED = 0; //Не указан. + RISK_LEVEL_LOW = 1; //Низкий уровень риска. + RISK_LEVEL_MODERATE = 2; //Средний уровень риска. + RISK_LEVEL_HIGH = 3; //Высокий уровень риска. +} + +enum BondType { + BOND_TYPE_UNSPECIFIED = 0; // Тип облигации не определен. + BOND_TYPE_REPLACED = 1; // Замещающая облигация. +} + +// Площадка торговли. +enum InstrumentExchangeType { + INSTRUMENT_EXCHANGE_UNSPECIFIED = 0; // Площадка торговли не определена. + INSTRUMENT_EXCHANGE_DEALER = 1; // Бумага, торгуемая у дилера. +} diff --git a/apps/backend/src/modules/tbank/proto/contracts/operations.proto b/apps/backend/src/modules/tbank/proto/contracts/operations.proto new file mode 100644 index 0000000..9d5f072 --- /dev/null +++ b/apps/backend/src/modules/tbank/proto/contracts/operations.proto @@ -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 {/*С помощью методов сервиса можно получить:

**1**. Список операций по счету.
**2**. + Портфель по счету.
**3**. Позиции ценных бумаг на счете.
**4**. + Доступный остаток для вывода средств.
**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; //Тип инструмента. Возможные значения:

`bond` — облигация;
`share` — акция;
`currency` — валюта;
`etf` — фонд;
`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; //Сумма операции. +} diff --git a/apps/backend/src/modules/tbank/proto/contracts/users.proto b/apps/backend/src/modules/tbank/proto/contracts/users.proto new file mode 100644 index 0000000..cc17e38 --- /dev/null +++ b/apps/backend/src/modules/tbank/proto/contracts/users.proto @@ -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 { /*С помощью сервиса можно получить:
1. + список счетов пользователя;
2. маржинальные показатели по счeту.*/ + + //GetAccounts — счета пользователя + //Получить список счетов. + rpc GetAccounts (GetAccountsRequest) returns (GetAccountsResponse); + + //GetMarginAttributes — маржинальные показатели по счeту + //Метод позволяет получить маржинальные показатели и ликвидность по заданному счeту. + 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; +} + +//Информация о счeте. +message Account { + + // Идентификатор счeта. + string id = 1; + + // Тип счeта. + AccountType type = 2; + + // Название счeта. + string name = 3; + + // Статус счeта. + AccountStatus status = 4; + + // Дата открытия счeта в часовом поясе UTC. + google.protobuf.Timestamp opened_date = 5; + + // Дата закрытия счeта в часовом поясе UTC. + google.protobuf.Timestamp closed_date = 6; + + // Уровень доступа к текущему счeту (определяется токеном). + AccessLevel access_level = 7; +} + +//Тип счeта. +enum AccountType { + ACCOUNT_TYPE_UNSPECIFIED = 0; //Тип аккаунта не определeн. + ACCOUNT_TYPE_TINKOFF = 1; //Брокерский счeт Т-Инвестиций. + 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; //Смарт-счет. +} + +//Статус счeта. +enum AccountStatus { + ACCOUNT_STATUS_UNSPECIFIED = 0; //Статус счeта не определeн. + ACCOUNT_STATUS_NEW = 1; //Новый, в процессе открытия. + ACCOUNT_STATUS_OPEN = 2; //Открытый и активный счeт. + ACCOUNT_STATUS_CLOSED = 3; //Закрытый счeт. + ACCOUNT_STATUS_ALL = 4; //Все счета. +} + +//Запрос маржинальных показателей по счeту. +message GetMarginAttributesRequest { + + // Идентификатор счeта пользователя. + string account_id = 1 [(google.api.field_behavior) = REQUIRED]; +} + +//Маржинальные показатели по счeту. +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; + + // Скорректированная маржа. Начальная маржа, в которой плановые позиции рассчитываются с учeтом активных заявок на покупку позиций лонг или продажу позиций шорт. + 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; // Категория риска. +} + +//Уровень доступа к счeту. +enum AccessLevel { + ACCOUNT_ACCESS_LEVEL_UNSPECIFIED = 0; //Уровень доступа не определeн. + ACCOUNT_ACCESS_LEVEL_FULL_ACCESS = 1; //Полный доступ к счeту. + 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; //Идентификатор счeта. + string name = 2; //Название счeта. + repeated MoneyValue money = 3; //Список валютных позиций на счeте. + google.protobuf.Timestamp opened_date = 4; //Дата открытия счeта в часовом поясе UTC. + AccountType type = 5; //Тип счeта. +} + +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; // Значение запрашиваемого параметра. +} \ No newline at end of file diff --git a/apps/backend/src/modules/tbank/services/broker-accounts.service.spec.ts b/apps/backend/src/modules/tbank/services/broker-accounts.service.spec.ts new file mode 100644 index 0000000..e37584e --- /dev/null +++ b/apps/backend/src/modules/tbank/services/broker-accounts.service.spec.ts @@ -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) => ({ + 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', + ); + }); +}); diff --git a/apps/backend/src/modules/tbank/services/broker-accounts.service.ts b/apps/backend/src/modules/tbank/services/broker-accounts.service.ts new file mode 100644 index 0000000..5d51948 --- /dev/null +++ b/apps/backend/src/modules/tbank/services/broker-accounts.service.ts @@ -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 { + const accounts = await this.findAll(); + + return accounts.data.find((account) => account.id === accountId) ?? null; + } + + private async fetchAccounts(): Promise { + const usersClient = this.tbankClient.getServiceClient('UsersService') as any; + const response = await this.tbankClient.callUnary< + Record, + TBankAccountsResponse + >('UsersService/GetAccounts', usersClient.getAccounts.bind(usersClient), { + status: 'ACCOUNT_STATUS_OPEN', + }); + + return (response.accounts ?? []).filter(isSupportedBrokerAccount).map(mapAccount); + } +} diff --git a/apps/backend/src/modules/tbank/services/broker-instruments.service.ts b/apps/backend/src/modules/tbank/services/broker-instruments.service.ts new file mode 100644 index 0000000..8762c2b --- /dev/null +++ b/apps/backend/src/modules/tbank/services/broker-instruments.service.ts @@ -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 { + const result = await this.cacheService.getOrFetch( + TBANK_CACHE_KEYS.instrument, + [instrumentUid], + () => this.fetchByUid(instrumentUid), + 'tbankInstrumentTtl', + ); + + return result.data; + } + + private async fetchByUid(instrumentUid: string): Promise { + 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; + } +} diff --git a/apps/backend/src/modules/tbank/services/broker-operation-sync.service.spec.ts b/apps/backend/src/modules/tbank/services/broker-operation-sync.service.spec.ts new file mode 100644 index 0000000..14bc828 --- /dev/null +++ b/apps/backend/src/modules/tbank/services/broker-operation-sync.service.spec.ts @@ -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'); + }); +}); diff --git a/apps/backend/src/modules/tbank/services/broker-operation-sync.service.ts b/apps/backend/src/modules/tbank/services/broker-operation-sync.service.ts new file mode 100644 index 0000000..2d54fb1 --- /dev/null +++ b/apps/backend/src/modules/tbank/services/broker-operation-sync.service.ts @@ -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 { + 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, + }); + } +} diff --git a/apps/backend/src/modules/tbank/services/broker-operations.service.spec.ts b/apps/backend/src/modules/tbank/services/broker-operations.service.spec.ts new file mode 100644 index 0000000..384e810 --- /dev/null +++ b/apps/backend/src/modules/tbank/services/broker-operations.service.spec.ts @@ -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) => ({ + 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', + }), + ); + }); +}); diff --git a/apps/backend/src/modules/tbank/services/broker-operations.service.ts b/apps/backend/src/modules/tbank/services/broker-operations.service.ts new file mode 100644 index 0000000..eee1792 --- /dev/null +++ b/apps/backend/src/modules/tbank/services/broker-operations.service.ts @@ -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 { + 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, + ): Promise { + const operationsClient = this.tbankClient.getServiceClient('OperationsService') as any; + const response = await this.tbankClient.callUnary< + Record, + TBankOperationsByCursorResponse + >( + 'OperationsService/GetOperationsByCursor', + operationsClient.getOperationsByCursor.bind(operationsClient), + request, + ); + + return mapOperationsPage(accountId, response); + } +} diff --git a/apps/backend/src/modules/tbank/services/broker-portfolio.service.spec.ts b/apps/backend/src/modules/tbank/services/broker-portfolio.service.spec.ts new file mode 100644 index 0000000..dc3b2d5 --- /dev/null +++ b/apps/backend/src/modules/tbank/services/broker-portfolio.service.spec.ts @@ -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) => ({ + 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', + ); + }); +}); diff --git a/apps/backend/src/modules/tbank/services/broker-portfolio.service.ts b/apps/backend/src/modules/tbank/services/broker-portfolio.service.ts new file mode 100644 index 0000000..a4ded6f --- /dev/null +++ b/apps/backend/src/modules/tbank/services/broker-portfolio.service.ts @@ -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>> { + 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), + ); + } +} diff --git a/apps/backend/src/modules/tbank/services/tbank-client.service.spec.ts b/apps/backend/src/modules/tbank/services/tbank-client.service.spec.ts new file mode 100644 index 0000000..4aa8cb1 --- /dev/null +++ b/apps/backend/src/modules/tbank/services/tbank-client.service.spec.ts @@ -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 = { + '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: '', + '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 = { + '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'), + }), + }); + }); +}); diff --git a/apps/backend/src/modules/tbank/services/tbank-client.service.ts b/apps/backend/src/modules/tbank/services/tbank-client.service.ts new file mode 100644 index 0000000..52be652 --- /dev/null +++ b/apps/backend/src/modules/tbank/services/tbank-client.service.ts @@ -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 = ( + 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; + private readonly clientCache = new Map(); + + constructor(private readonly configService: ConfigService) { + this.requestTimeoutMs = this.configService.get('app.tbank.requestTimeoutMs', 10000); + this.queue = new PQueue({ + interval: 1000, + intervalCap: this.configService.get('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('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('app.tbank.appName', ''); + if (appName) metadata.set('x-app-name', appName); + + return metadata; + } + + redactMetadata(metadata: Metadata): Record { + const result: Record = {}; + + for (const key of Object.keys(metadata.getMap())) { + if (key.toLowerCase() === 'authorization') { + result.Authorization = ''; + 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('app.tbank.baseUrl', 'invest-public-api.tbank.ru:443'), + this.createChannelCredentials(), + ); + + this.clientCache.set(serviceName, client); + + return client; + } + + async callUnary( + label: string, + method: GrpcUnary, + request: TRequest, + ): Promise { + const metadata = this.createMetadata(); + const deadline = new Date(Date.now() + this.requestTimeoutMs); + + return this.queue.add( + () => + new Promise((resolve, reject) => { + method(request, metadata, { deadline }, (error, response) => { + if (error) { + reject(this.mapGrpcError(label, error)); + return; + } + + resolve(response as TResponse); + }); + }), + ) as Promise; + } + + private resolveProtoNamespace(): Record { + return TBANK_PROTO_PACKAGE.split('.').reduce>( + (current, part) => { + return current[part] as Record; + }, + this.packageDefinition as Record, + ); + } + + private createChannelCredentials(): ChannelCredentials { + const caCertPath = this.configService.get('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, + }); + } +} diff --git a/apps/backend/src/modules/tbank/tbank.config.spec.ts b/apps/backend/src/modules/tbank/tbank.config.spec.ts new file mode 100644 index 0000000..e3f9a34 --- /dev/null +++ b/apps/backend/src/modules/tbank/tbank.config.spec.ts @@ -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); + }); +}); diff --git a/apps/backend/src/modules/tbank/tbank.config.ts b/apps/backend/src/modules/tbank/tbank.config.ts new file mode 100644 index 0000000..803376f --- /dev/null +++ b/apps/backend/src/modules/tbank/tbank.config.ts @@ -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; diff --git a/apps/backend/src/modules/tbank/tbank.controller.spec.ts b/apps/backend/src/modules/tbank/tbank.controller.spec.ts new file mode 100644 index 0000000..e5340a5 --- /dev/null +++ b/apps/backend/src/modules/tbank/tbank.controller.spec.ts @@ -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 }); + }); +}); diff --git a/apps/backend/src/modules/tbank/tbank.controller.ts b/apps/backend/src/modules/tbank/tbank.controller.ts new file mode 100644 index 0000000..5b3f316 --- /dev/null +++ b/apps/backend/src/modules/tbank/tbank.controller.ts @@ -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); + } +} diff --git a/apps/backend/src/modules/tbank/tbank.module.ts b/apps/backend/src/modules/tbank/tbank.module.ts new file mode 100644 index 0000000..964158b --- /dev/null +++ b/apps/backend/src/modules/tbank/tbank.module.ts @@ -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 {} diff --git a/apps/backend/src/modules/tbank/types/broker.types.ts b/apps/backend/src/modules/tbank/types/broker.types.ts new file mode 100644 index 0000000..113a77e --- /dev/null +++ b/apps/backend/src/modules/tbank/types/broker.types.ts @@ -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; +}; diff --git a/apps/backend/src/modules/tbank/types/tbank-proto.types.ts b/apps/backend/src/modules/tbank/types/tbank-proto.types.ts new file mode 100644 index 0000000..c6abb88 --- /dev/null +++ b/apps/backend/src/modules/tbank/types/tbank-proto.types.ts @@ -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; +}; diff --git a/apps/docs/docs/adr/ADR-011-tbank-invest-grpc.md b/apps/docs/docs/adr/ADR-011-tbank-invest-grpc.md new file mode 100644 index 0000000..f202a87 --- /dev/null +++ b/apps/docs/docs/adr/ADR-011-tbank-invest-grpc.md @@ -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 не разрешит торговые операции и заявки. diff --git a/apps/docs/docs/adr/index.md b/apps/docs/docs/adr/index.md index 7fea488..58d9185 100644 --- a/apps/docs/docs/adr/index.md +++ b/apps/docs/docs/adr/index.md @@ -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-разделе. diff --git a/apps/docs/docs/backend/caching.md b/apps/docs/docs/backend/caching.md index db9337b..0072435 100644 --- a/apps/docs/docs/backend/caching.md +++ b/apps/docs/docs/backend/caching.md @@ -37,7 +37,7 @@ flowchart LR ```typescript getOrFetch( - 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, 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 для +справочных данных инструментов. diff --git a/apps/docs/docs/backend/configuration.md b/apps/docs/docs/backend/configuration.md index d5a517c..d6e5941 100644 --- a/apps/docs/docs/backend/configuration.md +++ b/apps/docs/docs/backend/configuration.md @@ -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), }, })); ``` diff --git a/apps/docs/docs/backend/modules.md b/apps/docs/docs/backend/modules.md index 4a76b31..bcbed9e 100644 --- a/apps/docs/docs/backend/modules.md +++ b/apps/docs/docs/backend/modules.md @@ -8,7 +8,7 @@ flowchart TB AppModule["AppModule"] GlobalModules["Глобальные модули
ConfigModule
PrismaModule
CacheModule
MoexClientModule"] - FeatureModules["Feature-модули
HealthModule
AuthModule
PortfolioModule
SecuritiesModule
SharesModule
BondsModule
CandlesModule"] + FeatureModules["Feature-модули
HealthModule
AuthModule
PortfolioModule
SecuritiesModule
SharesModule
BondsModule
CandlesModule
TBankModule"] AppModule --> GlobalModules AppModule --> FeatureModules @@ -21,10 +21,12 @@ flowchart TB AuthModule["AuthModule"] PortfolioModule["PortfolioModule"] MarketModules["SecuritiesModule
SharesModule
BondsModule
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` diff --git a/apps/docs/docs/backend/portfolio.md b/apps/docs/docs/backend/portfolio.md index 8953965..a84803c 100644 --- a/apps/docs/docs/backend/portfolio.md +++ b/apps/docs/docs/backend/portfolio.md @@ -3,6 +3,12 @@ `PortfolioModule` позволяет пользователям создавать и вести виртуальные инвестиционные портфели для аналитики и отслеживания позиций. +## Ручные портфели и брокерские портфели + +`PortfolioModule` остаётся доменом ручных виртуальных портфелей. Брокерские счета T-Bank Invest +экспортируются отдельным `TBankModule` под `/api/v1/broker/*` и не сохраняются как записи +`Portfolio`. + ## Обзор - **Backend:** `PortfolioModule` (`apps/backend/src/modules/portfolio/`) diff --git a/apps/docs/docs/backend/tbank-invest.md b/apps/docs/docs/backend/tbank-invest.md new file mode 100644 index 0000000..058a5fe --- /dev/null +++ b/apps/docs/docs/backend/tbank-invest.md @@ -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 +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 токенов и привязку каждого брокерского +счёта к владельцу. diff --git a/apps/docs/sidebars.ts b/apps/docs/sidebars.ts index 2d11a82..41c75ae 100644 --- a/apps/docs/sidebars.ts +++ b/apps/docs/sidebars.ts @@ -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', ], }, ], diff --git a/apps/frontend/src/api/broker.test.ts b/apps/frontend/src/api/broker.test.ts new file mode 100644 index 0000000..68eca40 --- /dev/null +++ b/apps/frontend/src/api/broker.test.ts @@ -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), + ); + }); +}); diff --git a/apps/frontend/src/api/broker.ts b/apps/frontend/src/api/broker.ts new file mode 100644 index 0000000..4fe91f3 --- /dev/null +++ b/apps/frontend/src/api/broker.ts @@ -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('/api/v1/broker/accounts'); +} + +export function getBrokerPortfolio(accountId: string): Promise<{ + data: BrokerPortfolio; + meta: ApiResponseMeta; +}> { + return request( + `/api/v1/broker/accounts/${encodeURIComponent(accountId)}/portfolio`, + ); +} + +export function getBrokerOperations( + accountId: string, + query: BrokerOperationQuery = {}, +): Promise<{ data: BrokerOperationsPage; meta: ApiResponseMeta }> { + return request( + `/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, + }, + ); +} diff --git a/apps/frontend/src/api/client.test.ts b/apps/frontend/src/api/client.test.ts index 095f62f..cd4dd9d 100644 --- a/apps/frontend/src/api/client.test.ts +++ b/apps/frontend/src/api/client.test.ts @@ -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; diff --git a/apps/frontend/src/api/client.ts b/apps/frontend/src/api/client.ts index 320a94d..c6f0873 100644 --- a/apps/frontend/src/api/client.ts +++ b/apps/frontend/src/api/client.ts @@ -40,14 +40,31 @@ async function refreshTokens(): Promise { 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(json).data.accessToken; return true; } catch { return false; } } +function normalizeEnvelope(json: unknown): { data: T; meta: ApiResponseMeta } { + const envelope = json as ApiEnvelope; + 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 { if (isRefreshing && refreshPromise) { return refreshPromise; @@ -114,8 +131,8 @@ export async function request( 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(json); } export function getHealth(): Promise<{ data: HealthResponse; meta: ApiResponseMeta }> { diff --git a/apps/frontend/src/api/responses.ts b/apps/frontend/src/api/responses.ts index 868e78e..3227eec 100644 --- a/apps/frontend/src/api/responses.ts +++ b/apps/frontend/src/api/responses.ts @@ -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; +} diff --git a/apps/frontend/src/api/types.ts b/apps/frontend/src/api/types.ts index 0046607..b7bbec6 100644 --- a/apps/frontend/src/api/types.ts +++ b/apps/frontend/src/api/types.ts @@ -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; 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 | null; + accessLevel: Record | null; + }; + BrokerResponseMetaDto: { + cachedAt: Record | 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 | null; + daily: components['schemas']['BrokerMoneyDto'] | null; + dailyPercent: Record | null; + }; + BrokerPositionResponseDto: { + figi: Record | null; + instrumentUid: Record | null; + positionUid: Record | null; + ticker: Record | null; + classCode: Record | null; + instrumentType: Record | null; + name: Record | null; + quantity: Record | null; + blockedLots: Record | null; + currentPrice: components['schemas']['BrokerMoneyDto'] | null; + currentValue: components['schemas']['BrokerMoneyDto'] | null; + averagePositionPrice: components['schemas']['BrokerMoneyDto'] | null; + expectedYieldPercent: Record | 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 | null; + accountId: string; + id: Record | null; + parentOperationId: Record | null; + date: Record | null; + type: string; + /** @enum {string} */ + category: 'trade' | 'income' | 'tax' | 'fee' | 'transfer' | 'other'; + description: Record | null; + state: Record | null; + instrumentUid: Record | null; + figi: Record | null; + ticker: Record | null; + classCode: Record | null; + instrumentType: Record | 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 | null; + quantityDone: Record | null; + }; + BrokerOperationsPageResponseDto: { + accountId: string; + items: components['schemas']['BrokerOperationResponseDto'][]; + nextCursor: Record | 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']; + }; + }; + }; + }; } diff --git a/apps/frontend/src/components/Layout.tsx b/apps/frontend/src/components/Layout.tsx index 29ac2b5..20ed33a 100644 --- a/apps/frontend/src/components/Layout.tsx +++ b/apps/frontend/src/components/Layout.tsx @@ -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 - +
+ +
Портфели + + Брокер + -
+
{isAuthenticated ? ( <> -
+
diff --git a/apps/frontend/src/components/portfolios/PortfolioCard.tsx b/apps/frontend/src/components/portfolios/PortfolioCard.tsx index 82312a2..5d3e094 100644 --- a/apps/frontend/src/components/portfolios/PortfolioCard.tsx +++ b/apps/frontend/src/components/portfolios/PortfolioCard.tsx @@ -38,7 +38,7 @@ export function PortfolioCard({ portfolio }: { portfolio: Portfolio }) {

{portfolio.name}

- {portfolio.totalValue.toLocaleString('ru-RU', { + {portfolio.totalValue?.toLocaleString('ru-RU', { minimumFractionDigits: 2, maximumFractionDigits: 2, })} diff --git a/apps/frontend/src/components/portfolios/PortfolioSummary.tsx b/apps/frontend/src/components/portfolios/PortfolioSummary.tsx index 0e376b7..b126e3d 100644 --- a/apps/frontend/src/components/portfolios/PortfolioSummary.tsx +++ b/apps/frontend/src/components/portfolios/PortfolioSummary.tsx @@ -19,7 +19,7 @@ export function PortfolioSummary({ portfolio }: { portfolio: PortfolioDetail }) Общая стоимость
- {portfolio.totalValue.toLocaleString('ru-RU', { + {portfolio.totalValue?.toLocaleString('ru-RU', { minimumFractionDigits: 2, maximumFractionDigits: 2, })} diff --git a/apps/frontend/src/hooks/useBrokerAccounts.test.tsx b/apps/frontend/src/hooks/useBrokerAccounts.test.tsx new file mode 100644 index 0000000..e3fe99c --- /dev/null +++ b/apps/frontend/src/hooks/useBrokerAccounts.test.tsx @@ -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 {children}; + }; +} + +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'); + }); +}); diff --git a/apps/frontend/src/hooks/useBrokerAccounts.ts b/apps/frontend/src/hooks/useBrokerAccounts.ts new file mode 100644 index 0000000..70f880a --- /dev/null +++ b/apps/frontend/src/hooks/useBrokerAccounts.ts @@ -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({ + queryKey: ['broker', 'accounts'], + queryFn: async () => (await getBrokerAccounts()).data, + staleTime: 3_600_000, + retry: 2, + refetchOnWindowFocus: false, + }); +} diff --git a/apps/frontend/src/hooks/useBrokerOperations.ts b/apps/frontend/src/hooks/useBrokerOperations.ts new file mode 100644 index 0000000..40945f4 --- /dev/null +++ b/apps/frontend/src/hooks/useBrokerOperations.ts @@ -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({ + queryKey: ['broker', 'operations', accountId, query], + enabled: Boolean(accountId), + queryFn: async () => (await getBrokerOperations(accountId!, query)).data, + staleTime: 300_000, + retry: 2, + refetchOnWindowFocus: false, + }); +} diff --git a/apps/frontend/src/hooks/useBrokerPortfolio.ts b/apps/frontend/src/hooks/useBrokerPortfolio.ts new file mode 100644 index 0000000..4b7db02 --- /dev/null +++ b/apps/frontend/src/hooks/useBrokerPortfolio.ts @@ -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({ + queryKey: ['broker', 'portfolio', accountId], + enabled: Boolean(accountId), + queryFn: async () => (await getBrokerPortfolio(accountId!)).data, + staleTime: 60_000, + retry: 2, + refetchOnWindowFocus: false, + }); +} diff --git a/apps/frontend/src/pages/broker/BrokerAccountDetailPage.tsx b/apps/frontend/src/pages/broker/BrokerAccountDetailPage.tsx new file mode 100644 index 0000000..f1759a6 --- /dev/null +++ b/apps/frontend/src/pages/broker/BrokerAccountDetailPage.tsx @@ -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

Загрузка портфеля...

; + if (portfolio.error || !portfolio.data) { + return

Не удалось загрузить портфель

; + } + + return ( +
+
+

+ {portfolio.data.account.name} +

+
+ {formatMoney(portfolio.data.totals.portfolio)} + + День: {formatMoney(portfolio.data.yields.daily)} + + + Ожидаемая: {portfolio.data.yields.expectedPercent ?? '-'}% + +
+
+ +
+ {portfolio.data.cash.map((money) => ( +
+
+ {money.currency} +
+ {formatMoney(money)} +
+ ))} +
+ +
+

Позиции

+
+ + + + + + + + + + + {portfolio.data.positions.map((position) => ( + + + + + + + ))} + +
+ Инструмент + + Количество + + Стоимость + + Доходность +
+ {position.ticker || position.name || position.figi} + {position.name && ( +
{position.name}
+ )} +
+ {position.quantity ?? '-'} + + {formatMoney(position.currentValue)} + + {position.expectedYieldPercent ?? '-'}% +
+
+
+ +
+

Операции

+ {operations.isLoading ? ( +

Загрузка операций...

+ ) : ( +
+ + + + + + + + + + + {(operations.data?.items ?? []).map((operation) => ( + + + + + + + ))} + +
+ Дата + + Тип + + Инструмент + + Сумма +
{formatDate(operation.date)}{operation.type}{operation.ticker || operation.description || '-'} + {formatMoney(operation.payment)} +
+
+ )} +
+
+ ); +} diff --git a/apps/frontend/src/pages/broker/BrokerAccountsPage.tsx b/apps/frontend/src/pages/broker/BrokerAccountsPage.tsx new file mode 100644 index 0000000..0089983 --- /dev/null +++ b/apps/frontend/src/pages/broker/BrokerAccountsPage.tsx @@ -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

Загрузка брокерских счетов...

; + if (error) return

Не удалось загрузить счета

; + + return ( +
+
+

Брокерские счета

+ + {(accounts ?? []).length} + +
+ +
+ {(accounts ?? []).map((account) => ( + +
{account.name}
+
+ {account.type === 'iis' ? 'ИИС' : 'Брокерский счет'} + {account.status} + {account.id} +
+ + ))} +
+
+ ); +} diff --git a/apps/frontend/src/pages/broker/BrokerPages.test.tsx b/apps/frontend/src/pages/broker/BrokerPages.test.tsx new file mode 100644 index 0000000..c4f19f5 --- /dev/null +++ b/apps/frontend/src/pages/broker/BrokerPages.test.tsx @@ -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( + + {ui} + , + ); +} + +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(); + + 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( + + } /> + , + ['/broker/acc-1'], + ); + + expect(screen.getAllByText('SBER').length).toBeGreaterThan(0); + expect(screen.getByText('OPERATION_TYPE_BUY')).toBeInTheDocument(); + }); +}); diff --git a/apps/frontend/src/routes.tsx b/apps/frontend/src/routes.tsx index c285c4a..fd5ad03 100644 --- a/apps/frontend/src/routes.tsx +++ b/apps/frontend/src/routes.tsx @@ -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() { } /> + + + + } + /> + + + + } + /> ); diff --git a/docs/superpowers/specs/2026-06-16-tbank-broker-portfolios-design.md b/docs/superpowers/specs/2026-06-16-tbank-broker-portfolios-design.md new file mode 100644 index 0000000..fcc85a9 --- /dev/null +++ b/docs/superpowers/specs/2026-06-16-tbank-broker-portfolios-design.md @@ -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 | Примечания | +| --- | --- | --- | +| Счета | `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. diff --git a/package-lock.json b/package-lock.json index 970d15f..c3e72cb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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"