codex/tbank-broker-portfolios-design #15
@ -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 свечей (с) |
|
||||
|
||||
33
apps/backend/certs/russian-trusted-root-ca.pem
Normal file
33
apps/backend/certs/russian-trusted-root-ca.pem
Normal file
@ -0,0 +1,33 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIFwjCCA6qgAwIBAgICEAAwDQYJKoZIhvcNAQELBQAwcDELMAkGA1UEBhMCUlUx
|
||||
PzA9BgNVBAoMNlRoZSBNaW5pc3RyeSBvZiBEaWdpdGFsIERldmVsb3BtZW50IGFu
|
||||
ZCBDb21tdW5pY2F0aW9uczEgMB4GA1UEAwwXUnVzc2lhbiBUcnVzdGVkIFJvb3Qg
|
||||
Q0EwHhcNMjIwMzAxMjEwNDE1WhcNMzIwMjI3MjEwNDE1WjBwMQswCQYDVQQGEwJS
|
||||
VTE/MD0GA1UECgw2VGhlIE1pbmlzdHJ5IG9mIERpZ2l0YWwgRGV2ZWxvcG1lbnQg
|
||||
YW5kIENvbW11bmljYXRpb25zMSAwHgYDVQQDDBdSdXNzaWFuIFRydXN0ZWQgUm9v
|
||||
dCBDQTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAMfFOZ8pUAL3+r2n
|
||||
qqE0Zp52selXsKGFYoG0GM5bwz1bSFtCt+AZQMhkWQheI3poZAToYJu69pHLKS6Q
|
||||
XBiwBC1cvzYmUYKMYZC7jE5YhEU2bSL0mX7NaMxMDmH2/NwuOVRj8OImVa5s1F4U
|
||||
zn4Kv3PFlDBjjSjXKVY9kmjUBsXQrIHeaqmUIsPIlNWUnimXS0I0abExqkbdrXbX
|
||||
YwCOXhOO2pDUx3ckmJlCMUGacUTnylyQW2VsJIyIGA8V0xzdaeUXg0VZ6ZmNUr5Y
|
||||
Ber/EAOLPb8NYpsAhJe2mXjMB/J9HNsoFMBFJ0lLOT/+dQvjbdRZoOT8eqJpWnVD
|
||||
U+QL/qEZnz57N88OWM3rabJkRNdU/Z7x5SFIM9FrqtN8xewsiBWBI0K6XFuOBOTD
|
||||
4V08o4TzJ8+Ccq5XlCUW2L48pZNCYuBDfBh7FxkB7qDgGDiaftEkZZfApRg2E+M9
|
||||
G8wkNKTPLDc4wH0FDTijhgxR3Y4PiS1HL2Zhw7bD3CbslmEGgfnnZojNkJtcLeBH
|
||||
BLa52/dSwNU4WWLubaYSiAmA9IUMX1/RpfpxOxd4Ykmhz97oFbUaDJFipIggx5sX
|
||||
ePAlkTdWnv+RWBxlJwMQ25oEHmRguNYf4Zr/Rxr9cS93Y+mdXIZaBEE0KS2iLRqa
|
||||
OiWBki9IMQU4phqPOBAaG7A+eP8PAgMBAAGjZjBkMB0GA1UdDgQWBBTh0YHlzlpf
|
||||
BKrS6badZrHF+qwshzAfBgNVHSMEGDAWgBTh0YHlzlpfBKrS6badZrHF+qwshzAS
|
||||
BgNVHRMBAf8ECDAGAQH/AgEEMA4GA1UdDwEB/wQEAwIBhjANBgkqhkiG9w0BAQsF
|
||||
AAOCAgEAALIY1wkilt/urfEVM5vKzr6utOeDWCUczmWX/RX4ljpRdgF+5fAIS4vH
|
||||
tmXkqpSCOVeWUrJV9QvZn6L227ZwuE15cWi8DCDal3Ue90WgAJJZMfTshN4OI8cq
|
||||
W9E4EG9wglbEtMnObHlms8F3CHmrw3k6KmUkWGoa+/ENmcVl68u/cMRl1JbW2bM+
|
||||
/3A+SAg2c6iPDlehczKx2oa95QW0SkPPWGuNA/CE8CpyANIhu9XFrj3RQ3EqeRcS
|
||||
AQQod1RNuHpfETLU/A2gMmvn/w/sx7TB3W5BPs6rprOA37tutPq9u6FTZOcG1Oqj
|
||||
C/B7yTqgI7rbyvox7DEXoX7rIiEqyNNUguTk/u3SZ4VXE2kmxdmSh3TQvybfbnXV
|
||||
4JbCZVaqiZraqc7oZMnRoWrXRG3ztbnbes/9qhRGI7PqXqeKJBztxRTEVj8ONs1d
|
||||
WN5szTwaPIvhkhO3CO5ErU2rVdUr89wKpNXbBODFKRtgxUT70YpmJ46VVaqdAhOZ
|
||||
D9EUUn4YaeLaS8AjSF/h7UkjOibNc4qVDiPP+rkehFWM66PVnP1Msh93tc+taIfC
|
||||
EYVMxjh8zNbFuoc7fzvvrFILLe7ifvEIUqSVIC/AzplM/Jxw7buXFeGP1qVCBEHq
|
||||
391d/9RAfaZ12zkwFsl+IKwE/OZxW8AHa9i1p4GO0YSNuczzEm4=
|
||||
-----END CERTIFICATE-----
|
||||
@ -1,4 +1,13 @@
|
||||
{
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src"
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"assets": [
|
||||
{
|
||||
"include": "modules/tbank/proto/contracts/**/*",
|
||||
"outDir": "dist"
|
||||
}
|
||||
],
|
||||
"watchAssets": true
|
||||
}
|
||||
}
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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");
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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 {}
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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;
|
||||
}
|
||||
45
apps/backend/src/modules/tbank/dto/broker-envelope.dto.ts
Normal file
45
apps/backend/src/modules/tbank/dto/broker-envelope.dto.ts
Normal file
@ -0,0 +1,45 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { BrokerAccountResponseDto } from './broker-account-response.dto';
|
||||
import { BrokerOperationSyncResponseDto } from './broker-operation-sync-query.dto';
|
||||
import { BrokerOperationsPageResponseDto } from './broker-operation-response.dto';
|
||||
import { BrokerPortfolioResponseDto } from './broker-portfolio-response.dto';
|
||||
|
||||
export class BrokerResponseMetaDto {
|
||||
@ApiProperty({ nullable: true })
|
||||
cachedAt!: string | null;
|
||||
|
||||
@ApiProperty()
|
||||
fromCache!: boolean;
|
||||
}
|
||||
|
||||
export class BrokerAccountsEnvelopeDto {
|
||||
@ApiProperty({ type: [BrokerAccountResponseDto] })
|
||||
data!: BrokerAccountResponseDto[];
|
||||
|
||||
@ApiProperty({ type: BrokerResponseMetaDto })
|
||||
meta!: BrokerResponseMetaDto;
|
||||
}
|
||||
|
||||
export class BrokerPortfolioEnvelopeDto {
|
||||
@ApiProperty({ type: BrokerPortfolioResponseDto })
|
||||
data!: BrokerPortfolioResponseDto;
|
||||
|
||||
@ApiProperty({ type: BrokerResponseMetaDto })
|
||||
meta!: BrokerResponseMetaDto;
|
||||
}
|
||||
|
||||
export class BrokerOperationsEnvelopeDto {
|
||||
@ApiProperty({ type: BrokerOperationsPageResponseDto })
|
||||
data!: BrokerOperationsPageResponseDto;
|
||||
|
||||
@ApiProperty({ type: BrokerResponseMetaDto })
|
||||
meta!: BrokerResponseMetaDto;
|
||||
}
|
||||
|
||||
export class BrokerOperationSyncEnvelopeDto {
|
||||
@ApiProperty({ type: BrokerOperationSyncResponseDto })
|
||||
data!: BrokerOperationSyncResponseDto;
|
||||
|
||||
@ApiProperty({ type: BrokerResponseMetaDto })
|
||||
meta!: BrokerResponseMetaDto;
|
||||
}
|
||||
15
apps/backend/src/modules/tbank/dto/broker-money.dto.ts
Normal file
15
apps/backend/src/modules/tbank/dto/broker-money.dto.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class BrokerMoneyDto {
|
||||
@ApiProperty()
|
||||
currency!: string;
|
||||
|
||||
@ApiProperty()
|
||||
units!: string;
|
||||
|
||||
@ApiProperty()
|
||||
nano!: number;
|
||||
|
||||
@ApiProperty()
|
||||
value!: number;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
22
apps/backend/src/modules/tbank/mappers/account.mapper.ts
Normal file
22
apps/backend/src/modules/tbank/mappers/account.mapper.ts
Normal file
@ -0,0 +1,22 @@
|
||||
import { TBANK_ACCOUNT_TYPES, TBANK_OPEN_ACCOUNT_STATUS } from '../tbank.config';
|
||||
import type { BrokerAccount } from '../types/broker.types';
|
||||
import type { TBankAccount } from '../types/tbank-proto.types';
|
||||
import { mapTimestampToIso } from './money.mapper';
|
||||
|
||||
export function isSupportedBrokerAccount(account: TBankAccount): boolean {
|
||||
return (
|
||||
account.status === TBANK_OPEN_ACCOUNT_STATUS &&
|
||||
(account.type === TBANK_ACCOUNT_TYPES.brokerage || account.type === TBANK_ACCOUNT_TYPES.iis)
|
||||
);
|
||||
}
|
||||
|
||||
export function mapAccount(account: TBankAccount): BrokerAccount {
|
||||
return {
|
||||
id: account.id,
|
||||
type: account.type === TBANK_ACCOUNT_TYPES.iis ? 'iis' : 'brokerage',
|
||||
name: account.name || account.id,
|
||||
status: account.status,
|
||||
openedAt: mapTimestampToIso(account.openedDate),
|
||||
accessLevel: account.accessLevel ?? null,
|
||||
};
|
||||
}
|
||||
33
apps/backend/src/modules/tbank/mappers/money.mapper.spec.ts
Normal file
33
apps/backend/src/modules/tbank/mappers/money.mapper.spec.ts
Normal file
@ -0,0 +1,33 @@
|
||||
import { mapMoneyValue, mapQuotationToNumber, mapTimestampToIso } from './money.mapper';
|
||||
|
||||
describe('money.mapper', () => {
|
||||
it('maps positive MoneyValue with nano precision', () => {
|
||||
expect(mapMoneyValue({ currency: 'rub', units: '123', nano: 450000000 })).toEqual({
|
||||
currency: 'RUB',
|
||||
units: '123',
|
||||
nano: 450000000,
|
||||
value: 123.45,
|
||||
});
|
||||
});
|
||||
|
||||
it('maps negative MoneyValue with negative nano', () => {
|
||||
expect(mapMoneyValue({ currency: 'rub', units: '-5', nano: -250000000 })).toEqual({
|
||||
currency: 'RUB',
|
||||
units: '-5',
|
||||
nano: -250000000,
|
||||
value: -5.25,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null for absent MoneyValue', () => {
|
||||
expect(mapMoneyValue(undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it('maps quotation to number', () => {
|
||||
expect(mapQuotationToNumber({ units: '12', nano: 345000000 })).toBe(12.345);
|
||||
});
|
||||
|
||||
it('maps unix timestamp seconds to ISO string', () => {
|
||||
expect(mapTimestampToIso({ seconds: '1781577000', nanos: 0 })).toBe('2026-06-16T02:30:00.000Z');
|
||||
});
|
||||
});
|
||||
45
apps/backend/src/modules/tbank/mappers/money.mapper.ts
Normal file
45
apps/backend/src/modules/tbank/mappers/money.mapper.ts
Normal file
@ -0,0 +1,45 @@
|
||||
import type { BrokerMoney } from '../types/broker.types';
|
||||
import type { TBankMoneyValue, TBankQuotation, TBankTimestamp } from '../types/tbank-proto.types';
|
||||
|
||||
const NANO_FACTOR = 1_000_000_000;
|
||||
|
||||
export function mapMoneyValue(value: TBankMoneyValue | null | undefined): BrokerMoney | null {
|
||||
if (!value) return null;
|
||||
|
||||
const units = String(value.units ?? '0');
|
||||
const nano = value.nano ?? 0;
|
||||
const numericUnits = Number(units);
|
||||
const decimal = numericUnits + nano / NANO_FACTOR;
|
||||
|
||||
return {
|
||||
currency: (value.currency || '').toUpperCase(),
|
||||
units,
|
||||
nano,
|
||||
value: Number(decimal.toFixed(9)),
|
||||
};
|
||||
}
|
||||
|
||||
export function mapQuotationToNumber(value: TBankQuotation | null | undefined): number | null {
|
||||
if (!value) return null;
|
||||
|
||||
const units = Number(value.units ?? 0);
|
||||
const nano = value.nano ?? 0;
|
||||
|
||||
return Number((units + nano / NANO_FACTOR).toFixed(9));
|
||||
}
|
||||
|
||||
export function mapTimestampToIso(value: TBankTimestamp | null | undefined): string | null {
|
||||
if (!value?.seconds) return null;
|
||||
|
||||
const millis = Number(value.seconds) * 1000 + Math.floor((value.nanos ?? 0) / 1_000_000);
|
||||
|
||||
return new Date(millis).toISOString();
|
||||
}
|
||||
|
||||
export function mapInteger(value: string | number | null | undefined): number | null {
|
||||
if (value === null || value === undefined || value === '') return null;
|
||||
|
||||
const parsed = Number(value);
|
||||
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
138
apps/backend/src/modules/tbank/mappers/operation.mapper.ts
Normal file
138
apps/backend/src/modules/tbank/mappers/operation.mapper.ts
Normal file
@ -0,0 +1,138 @@
|
||||
import type {
|
||||
BrokerOperation,
|
||||
BrokerOperationCategory,
|
||||
BrokerOperationsPage,
|
||||
} from '../types/broker.types';
|
||||
import type {
|
||||
TBankOperationItem,
|
||||
TBankOperationsByCursorResponse,
|
||||
} from '../types/tbank-proto.types';
|
||||
import { mapInteger, mapMoneyValue, mapTimestampToIso } from './money.mapper';
|
||||
|
||||
const TRADE_TYPES = new Set([
|
||||
'OPERATION_TYPE_BUY',
|
||||
'OPERATION_TYPE_BUY_CARD',
|
||||
'OPERATION_TYPE_SELL',
|
||||
'OPERATION_TYPE_SELL_CARD',
|
||||
'OPERATION_TYPE_BUY_MARGIN',
|
||||
'OPERATION_TYPE_SELL_MARGIN',
|
||||
'OPERATION_TYPE_DELIVERY_BUY',
|
||||
'OPERATION_TYPE_DELIVERY_SELL',
|
||||
]);
|
||||
|
||||
const INCOME_TYPES = new Set([
|
||||
'OPERATION_TYPE_DIVIDEND',
|
||||
'OPERATION_TYPE_COUPON',
|
||||
'OPERATION_TYPE_BOND_REPAYMENT',
|
||||
'OPERATION_TYPE_BOND_REPAYMENT_FULL',
|
||||
'OPERATION_TYPE_OVERNIGHT',
|
||||
'OPERATION_TYPE_OVER_INCOME',
|
||||
'OPERATION_TYPE_ACCRUING_VARMARGIN',
|
||||
'OPERATION_TYPE_TAX_REPO_REFUND',
|
||||
'OPERATION_TYPE_TAX_REPO_REFUND_PROGRESSIVE',
|
||||
'OPERATION_TYPE_DIV_EXT',
|
||||
'OPERATION_TYPE_DFA_REDEMPTION',
|
||||
]);
|
||||
|
||||
const TAX_TYPES = new Set([
|
||||
'OPERATION_TYPE_TAX',
|
||||
'OPERATION_TYPE_BOND_TAX',
|
||||
'OPERATION_TYPE_DIVIDEND_TAX',
|
||||
'OPERATION_TYPE_TAX_CORRECTION',
|
||||
'OPERATION_TYPE_BENEFIT_TAX',
|
||||
'OPERATION_TYPE_TAX_PROGRESSIVE',
|
||||
'OPERATION_TYPE_BOND_TAX_PROGRESSIVE',
|
||||
'OPERATION_TYPE_DIVIDEND_TAX_PROGRESSIVE',
|
||||
'OPERATION_TYPE_BENEFIT_TAX_PROGRESSIVE',
|
||||
'OPERATION_TYPE_TAX_CORRECTION_PROGRESSIVE',
|
||||
'OPERATION_TYPE_TAX_REPO',
|
||||
'OPERATION_TYPE_TAX_REPO_PROGRESSIVE',
|
||||
'OPERATION_TYPE_TAX_REPO_HOLD',
|
||||
'OPERATION_TYPE_TAX_REPO_HOLD_PROGRESSIVE',
|
||||
'OPERATION_TYPE_TAX_CORRECTION_COUPON',
|
||||
]);
|
||||
|
||||
const FEE_TYPES = new Set([
|
||||
'OPERATION_TYPE_SERVICE_FEE',
|
||||
'OPERATION_TYPE_MARGIN_FEE',
|
||||
'OPERATION_TYPE_BROKER_FEE',
|
||||
'OPERATION_TYPE_SUCCESS_FEE',
|
||||
'OPERATION_TYPE_TRACK_MFEE',
|
||||
'OPERATION_TYPE_TRACK_PFEE',
|
||||
'OPERATION_TYPE_CASH_FEE',
|
||||
'OPERATION_TYPE_OUT_FEE',
|
||||
'OPERATION_TYPE_OUT_STAMP_DUTY',
|
||||
'OPERATION_TYPE_OUTPUT_PENALTY',
|
||||
'OPERATION_TYPE_ADVICE_FEE',
|
||||
'OPERATION_TYPE_OVER_COM',
|
||||
'OPERATION_TYPE_OTHER_FEE',
|
||||
'OPERATION_TYPE_FUNDING',
|
||||
]);
|
||||
|
||||
const TRANSFER_TYPES = new Set([
|
||||
'OPERATION_TYPE_INPUT',
|
||||
'OPERATION_TYPE_OUTPUT',
|
||||
'OPERATION_TYPE_INPUT_SECURITIES',
|
||||
'OPERATION_TYPE_OUTPUT_SECURITIES',
|
||||
'OPERATION_TYPE_OUTPUT_SWIFT',
|
||||
'OPERATION_TYPE_INPUT_SWIFT',
|
||||
'OPERATION_TYPE_OUTPUT_ACQUIRING',
|
||||
'OPERATION_TYPE_INPUT_ACQUIRING',
|
||||
'OPERATION_TYPE_TRANS_IIS_BS',
|
||||
'OPERATION_TYPE_TRANS_BS_BS',
|
||||
'OPERATION_TYPE_OUT_MULTI',
|
||||
'OPERATION_TYPE_INP_MULTI',
|
||||
'OPERATION_TYPE_OVER_PLACEMENT',
|
||||
]);
|
||||
|
||||
export function categorizeOperationType(type: string | null | undefined): BrokerOperationCategory {
|
||||
if (!type) return 'other';
|
||||
if (TRADE_TYPES.has(type)) return 'trade';
|
||||
if (INCOME_TYPES.has(type)) return 'income';
|
||||
if (TAX_TYPES.has(type)) return 'tax';
|
||||
if (FEE_TYPES.has(type)) return 'fee';
|
||||
if (TRANSFER_TYPES.has(type)) return 'transfer';
|
||||
|
||||
return 'other';
|
||||
}
|
||||
|
||||
export function mapOperation(item: TBankOperationItem, accountId: string): BrokerOperation {
|
||||
const type = item.type || 'OPERATION_TYPE_UNSPECIFIED';
|
||||
|
||||
return {
|
||||
cursor: item.cursor ?? null,
|
||||
accountId: item.brokerAccountId || accountId,
|
||||
id: item.id ?? null,
|
||||
parentOperationId: item.parentOperationId ?? null,
|
||||
date: mapTimestampToIso(item.date),
|
||||
type,
|
||||
category: categorizeOperationType(type),
|
||||
description: item.description || item.name || null,
|
||||
state: item.state ?? null,
|
||||
instrumentUid: item.instrumentUid ?? null,
|
||||
figi: item.figi ?? null,
|
||||
ticker: item.ticker ?? null,
|
||||
classCode: item.classCode ?? null,
|
||||
instrumentType: item.instrumentType ?? null,
|
||||
payment: mapMoneyValue(item.payment),
|
||||
price: mapMoneyValue(item.price),
|
||||
commission: mapMoneyValue(item.commission),
|
||||
yield: mapMoneyValue(item.yield),
|
||||
accruedInt: mapMoneyValue(item.accruedInt),
|
||||
quantity: mapInteger(item.quantity),
|
||||
quantityDone: mapInteger(item.quantityDone),
|
||||
};
|
||||
}
|
||||
|
||||
export function mapOperationsPage(
|
||||
accountId: string,
|
||||
response: TBankOperationsByCursorResponse,
|
||||
): BrokerOperationsPage {
|
||||
return {
|
||||
accountId,
|
||||
items: (response.items ?? []).map((item) => mapOperation(item, accountId)),
|
||||
nextCursor: response.nextCursor || null,
|
||||
hasNext: response.hasNext ?? false,
|
||||
asOf: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
@ -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 },
|
||||
});
|
||||
});
|
||||
});
|
||||
84
apps/backend/src/modules/tbank/mappers/portfolio.mapper.ts
Normal file
84
apps/backend/src/modules/tbank/mappers/portfolio.mapper.ts
Normal file
@ -0,0 +1,84 @@
|
||||
import type {
|
||||
BrokerAccount,
|
||||
BrokerMoney,
|
||||
BrokerPortfolio,
|
||||
BrokerPosition,
|
||||
} from '../types/broker.types';
|
||||
import type {
|
||||
TBankInstrument,
|
||||
TBankPortfolioResponse,
|
||||
TBankPositionsResponse,
|
||||
} from '../types/tbank-proto.types';
|
||||
import { mapMoneyValue, mapQuotationToNumber } from './money.mapper';
|
||||
|
||||
type MapBrokerPortfolioInput = {
|
||||
account: BrokerAccount;
|
||||
portfolio: TBankPortfolioResponse;
|
||||
positions: TBankPositionsResponse;
|
||||
instruments: Map<string, Partial<TBankInstrument>>;
|
||||
};
|
||||
|
||||
function isBrokerMoney(value: BrokerMoney | null): value is BrokerMoney {
|
||||
return value !== null;
|
||||
}
|
||||
|
||||
export function mapBrokerPortfolio(input: MapBrokerPortfolioInput): BrokerPortfolio {
|
||||
const mappedPositions = (input.portfolio.positions ?? []).map<BrokerPosition>((position) => {
|
||||
const quantity = mapQuotationToNumber(position.quantity);
|
||||
const currentPrice = mapMoneyValue(position.currentPrice);
|
||||
const currentValue =
|
||||
currentPrice && quantity !== null
|
||||
? {
|
||||
...currentPrice,
|
||||
units: String(Math.trunc(currentPrice.value * quantity)),
|
||||
nano: 0,
|
||||
value: Number((currentPrice.value * quantity).toFixed(9)),
|
||||
}
|
||||
: null;
|
||||
const instrument =
|
||||
(position.instrumentUid && input.instruments.get(position.instrumentUid)) ||
|
||||
(position.positionUid && input.instruments.get(position.positionUid)) ||
|
||||
undefined;
|
||||
|
||||
return {
|
||||
figi: position.figi ?? null,
|
||||
instrumentUid: position.instrumentUid ?? null,
|
||||
positionUid: position.positionUid ?? null,
|
||||
ticker: position.ticker || instrument?.ticker || null,
|
||||
classCode: position.classCode || instrument?.classCode || null,
|
||||
instrumentType: position.instrumentType || instrument?.instrumentType || null,
|
||||
name: instrument?.name ?? null,
|
||||
quantity,
|
||||
blockedLots: mapQuotationToNumber(position.blockedLots),
|
||||
currentPrice,
|
||||
currentValue,
|
||||
averagePositionPrice: mapMoneyValue(position.averagePositionPrice),
|
||||
expectedYieldPercent: mapQuotationToNumber(position.expectedYield),
|
||||
dailyYield: mapMoneyValue(position.dailyYield),
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
account: input.account,
|
||||
totals: {
|
||||
shares: mapMoneyValue(input.portfolio.totalAmountShares),
|
||||
bonds: mapMoneyValue(input.portfolio.totalAmountBonds),
|
||||
etf: mapMoneyValue(input.portfolio.totalAmountEtf),
|
||||
currencies: mapMoneyValue(input.portfolio.totalAmountCurrencies),
|
||||
futures: mapMoneyValue(input.portfolio.totalAmountFutures),
|
||||
options: mapMoneyValue(input.portfolio.totalAmountOptions),
|
||||
structuredProducts: mapMoneyValue(input.portfolio.totalAmountSp),
|
||||
dfa: mapMoneyValue(input.portfolio.totalAmountDfa),
|
||||
portfolio: mapMoneyValue(input.portfolio.totalAmountPortfolio),
|
||||
},
|
||||
yields: {
|
||||
expectedPercent: mapQuotationToNumber(input.portfolio.expectedYield),
|
||||
daily: mapMoneyValue(input.portfolio.dailyYield),
|
||||
dailyPercent: mapQuotationToNumber(input.portfolio.dailyYieldRelative),
|
||||
},
|
||||
cash: (input.positions.money ?? []).map(mapMoneyValue).filter(isBrokerMoney),
|
||||
blockedCash: (input.positions.blocked ?? []).map(mapMoneyValue).filter(isBrokerMoney),
|
||||
positions: mappedPositions,
|
||||
asOf: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
144
apps/backend/src/modules/tbank/proto/contracts/common.proto
Normal file
144
apps/backend/src/modules/tbank/proto/contracts/common.proto
Normal file
@ -0,0 +1,144 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package tinkoff.public.invest.api.contract.v1;
|
||||
|
||||
option go_package = "./;investapi";
|
||||
option java_package = "ru.tinkoff.piapi.contract.v1";
|
||||
option java_multiple_files = true;
|
||||
option csharp_namespace = "Tinkoff.InvestApi.V1";
|
||||
option objc_class_prefix = "TIAPI";
|
||||
option php_namespace = "Tinkoff\\Invest\\V1";
|
||||
|
||||
import "google/protobuf/timestamp.proto";
|
||||
|
||||
//Тип инструмента.
|
||||
enum InstrumentType {
|
||||
INSTRUMENT_TYPE_UNSPECIFIED = 0;
|
||||
INSTRUMENT_TYPE_BOND = 1; //Облигация.
|
||||
INSTRUMENT_TYPE_SHARE = 2; //Акция.
|
||||
INSTRUMENT_TYPE_CURRENCY = 3; //Валюта.
|
||||
INSTRUMENT_TYPE_ETF = 4; //Exchange-traded fund. Фонд.
|
||||
INSTRUMENT_TYPE_FUTURES = 5; //Фьючерс.
|
||||
INSTRUMENT_TYPE_SP = 6; //Структурная нота.
|
||||
INSTRUMENT_TYPE_OPTION = 7; //Опцион.
|
||||
INSTRUMENT_TYPE_CLEARING_CERTIFICATE = 8; //Clearing certificate.
|
||||
INSTRUMENT_TYPE_INDEX = 9; //Индекс.
|
||||
INSTRUMENT_TYPE_COMMODITY = 10; //Товар.
|
||||
INSTRUMENT_TYPE_DFA = 11; //Цифровой актив.
|
||||
}
|
||||
|
||||
//Статус запрашиваемых инструментов.
|
||||
enum InstrumentStatus {
|
||||
INSTRUMENT_STATUS_UNSPECIFIED = 0; //Значение не определено.
|
||||
INSTRUMENT_STATUS_BASE = 1; //По умолчанию — базовый список инструментов, которыми можно торговать через T-Invest API. Сейчас списки доступных бумаг в API и других интерфейсах совпадают — кроме внебиржевых бумаг, но в будущем списки могут различаться.
|
||||
INSTRUMENT_STATUS_ALL = 2; //Список всех инструментов.
|
||||
}
|
||||
|
||||
//Денежная сумма в определенной валюте.
|
||||
message MoneyValue {
|
||||
|
||||
// Строковый ISO-код валюты.
|
||||
string currency = 1;
|
||||
|
||||
// Целая часть суммы, может быть отрицательным числом.
|
||||
int64 units = 2;
|
||||
|
||||
// Дробная часть суммы, может быть отрицательным числом.
|
||||
int32 nano = 3;
|
||||
}
|
||||
|
||||
//Котировка — денежная сумма без указания валюты.
|
||||
message Quotation {
|
||||
|
||||
// Целая часть суммы, может быть отрицательным числом.
|
||||
int64 units = 1;
|
||||
|
||||
// Дробная часть суммы, может быть отрицательным числом.
|
||||
int32 nano = 2;
|
||||
}
|
||||
|
||||
//Режим торгов инструмента
|
||||
enum SecurityTradingStatus {
|
||||
SECURITY_TRADING_STATUS_UNSPECIFIED = 0; //Торговый статус не определен.
|
||||
SECURITY_TRADING_STATUS_NOT_AVAILABLE_FOR_TRADING = 1; //Недоступен для торгов.
|
||||
SECURITY_TRADING_STATUS_OPENING_PERIOD = 2; //Период открытия торгов.
|
||||
SECURITY_TRADING_STATUS_CLOSING_PERIOD = 3; //Период закрытия торгов.
|
||||
SECURITY_TRADING_STATUS_BREAK_IN_TRADING = 4; //Перерыв в торговле.
|
||||
SECURITY_TRADING_STATUS_NORMAL_TRADING = 5; //Нормальная торговля.
|
||||
SECURITY_TRADING_STATUS_CLOSING_AUCTION = 6; //Аукцион закрытия.
|
||||
SECURITY_TRADING_STATUS_DARK_POOL_AUCTION = 7; //Аукцион крупных пакетов.
|
||||
SECURITY_TRADING_STATUS_DISCRETE_AUCTION = 8; //Дискретный аукцион.
|
||||
SECURITY_TRADING_STATUS_OPENING_AUCTION_PERIOD = 9; //Аукцион открытия.
|
||||
SECURITY_TRADING_STATUS_TRADING_AT_CLOSING_AUCTION_PRICE = 10; //Период торгов по цене аукциона закрытия.
|
||||
SECURITY_TRADING_STATUS_SESSION_ASSIGNED = 11; //Сессия назначена.
|
||||
SECURITY_TRADING_STATUS_SESSION_CLOSE = 12; //Сессия закрыта.
|
||||
SECURITY_TRADING_STATUS_SESSION_OPEN = 13; //Сессия открыта.
|
||||
SECURITY_TRADING_STATUS_DEALER_NORMAL_TRADING = 14; //Доступна торговля в режиме внутренней ликвидности брокера.
|
||||
SECURITY_TRADING_STATUS_DEALER_BREAK_IN_TRADING = 15; //Перерыв торговли в режиме внутренней ликвидности брокера.
|
||||
SECURITY_TRADING_STATUS_DEALER_NOT_AVAILABLE_FOR_TRADING = 16; //Недоступна торговля в режиме внутренней ликвидности брокера.
|
||||
SECURITY_TRADING_STATUS_STABILIZATION_AUCTION = 17; //Аукцион обновления цен.
|
||||
}
|
||||
|
||||
message PingRequest {
|
||||
optional google.protobuf.Timestamp time = 1; //Время формирования запроса.
|
||||
}
|
||||
|
||||
message PingDelaySettings {
|
||||
optional int32 ping_delay_ms = 15; //Задержка (пинг) сообщений: 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; //Инструмент, торгуемый на площадке брокера.
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
1751
apps/backend/src/modules/tbank/proto/contracts/instruments.proto
Normal file
1751
apps/backend/src/modules/tbank/proto/contracts/instruments.proto
Normal file
File diff suppressed because it is too large
Load Diff
648
apps/backend/src/modules/tbank/proto/contracts/operations.proto
Normal file
648
apps/backend/src/modules/tbank/proto/contracts/operations.proto
Normal file
@ -0,0 +1,648 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package tinkoff.public.invest.api.contract.v1;
|
||||
|
||||
option go_package = "./;investapi";
|
||||
option java_package = "ru.tinkoff.piapi.contract.v1";
|
||||
option java_multiple_files = true;
|
||||
option csharp_namespace = "Tinkoff.InvestApi.V1";
|
||||
option objc_class_prefix = "TIAPI";
|
||||
option php_namespace = "Tinkoff\\Invest\\V1";
|
||||
|
||||
import "google/protobuf/timestamp.proto";
|
||||
import "common.proto";
|
||||
import "google/api/field_behavior.proto";
|
||||
|
||||
service OperationsService {/*С помощью методов сервиса можно получить:<br/><br/> **1**. Список операций по счету.<br/> **2**.
|
||||
Портфель по счету.<br/> **3**. Позиции ценных бумаг на счете.<br/> **4**.
|
||||
Доступный остаток для вывода средств.<br/> **5**. Различные отчеты.*/
|
||||
//Deprecated GetOperations — список операций по счету
|
||||
//При работе с методом учитывайте [особенности взаимодействия](/invest/services/operations/operations_problems).
|
||||
rpc GetOperations(OperationsRequest) returns (OperationsResponse);
|
||||
|
||||
//GetPortfolio — портфель по счету
|
||||
rpc GetPortfolio(PortfolioRequest) returns (PortfolioResponse);
|
||||
|
||||
//GetPositions — список позиций по счету
|
||||
rpc GetPositions(PositionsRequest) returns (PositionsResponse);
|
||||
|
||||
//GetWithdrawLimits — доступный остаток для вывода средств
|
||||
rpc GetWithdrawLimits(WithdrawLimitsRequest) returns (WithdrawLimitsResponse);
|
||||
|
||||
//GetBrokerReport — брокерский отчет.
|
||||
rpc GetBrokerReport(BrokerReportRequest) returns (BrokerReportResponse);
|
||||
|
||||
//GetDividendsForeignIssuer — отчет «Справка о доходах за пределами РФ»
|
||||
rpc GetDividendsForeignIssuer(GetDividendsForeignIssuerRequest) returns (GetDividendsForeignIssuerResponse);
|
||||
|
||||
//GetOperationsByCursor — список операций по счету с пагинацией
|
||||
//При работе с методом учитывайте [особенности взаимодействия](/invest/services/operations/operations_problems).
|
||||
rpc GetOperationsByCursor(GetOperationsByCursorRequest) returns (GetOperationsByCursorResponse);
|
||||
}
|
||||
|
||||
service OperationsStreamService {
|
||||
//PortfolioStream — стрим обновлений портфеля
|
||||
rpc PortfolioStream(PortfolioStreamRequest) returns (stream PortfolioStreamResponse);
|
||||
|
||||
//PositionsStream — стрим обновлений информации по изменению позиций портфеля
|
||||
rpc PositionsStream(PositionsStreamRequest) returns (stream PositionsStreamResponse);
|
||||
|
||||
//OperationsStream — стрим обновлений операций
|
||||
rpc OperationsStream(OperationsStreamRequest) returns (stream OperationsStreamResponse);
|
||||
}
|
||||
|
||||
//Запрос получения списка операций по счету.
|
||||
message OperationsRequest {
|
||||
string account_id = 1 [(google.api.field_behavior) = REQUIRED]; //Идентификатор счета клиента.
|
||||
optional google.protobuf.Timestamp from = 2; //Начало периода по UTC.
|
||||
optional google.protobuf.Timestamp to = 3; //Окончание периода по UTC.
|
||||
optional OperationState state = 4; //Статус запрашиваемых операций.
|
||||
optional string figi = 5; //FIGI-идентификатор инструмента для фильтрации.
|
||||
}
|
||||
|
||||
//Список операций.
|
||||
message OperationsResponse {
|
||||
repeated Operation operations = 1; //Массив операций.
|
||||
}
|
||||
|
||||
//Данные по операции.
|
||||
message Operation {
|
||||
string id = 1; //Идентификатор операции.
|
||||
string parent_operation_id = 2; //Идентификатор родительской операции.
|
||||
string currency = 3; //Валюта операции.
|
||||
MoneyValue payment = 4; //Сумма операции.
|
||||
MoneyValue price = 5; //Цена операции за 1 инструмент. Чтобы получить стоимость лота, нужно умножить на лотность инструмента.
|
||||
OperationState state = 6; //Статус операции.
|
||||
int64 quantity = 7; //Количество единиц инструмента.
|
||||
int64 quantity_rest = 8; //Неисполненный остаток по сделке.
|
||||
string figi = 9; //FIGI-идентификатор инструмента, связанного с операцией.
|
||||
string instrument_type = 10; //Тип инструмента. Возможные значения: <br/><br/>`bond` — облигация; <br/>`share` — акция; <br/>`currency` — валюта; <br/>`etf` — фонд; <br/>`futures` — фьючерс.
|
||||
google.protobuf.Timestamp date = 11; //Дата и время операции в формате часовом поясе UTC.
|
||||
string type = 12; //Текстовое описание типа операции.
|
||||
OperationType operation_type = 13; //Тип операции.
|
||||
repeated OperationTrade trades = 14; //Массив сделок.
|
||||
string asset_uid = 16; //Идентификатор актива
|
||||
string position_uid = 17; //Уникальный идентификатор позиции.
|
||||
string instrument_uid = 18; //Уникальный идентификатор инструмента.
|
||||
repeated ChildOperationItem child_operations = 19; //Массив дочерних операций.
|
||||
}
|
||||
|
||||
//Сделка по операции.
|
||||
message OperationTrade {
|
||||
string trade_id = 1; //Идентификатор сделки.
|
||||
google.protobuf.Timestamp date_time = 2; //Дата и время сделки по UTC.
|
||||
int64 quantity = 3; //Количество инструментов.
|
||||
MoneyValue price = 4; //Цена за 1 инструмент. Чтобы получить стоимость лота, нужно умножить на лотность инструмента.
|
||||
}
|
||||
|
||||
//Запрос получения текущего портфеля по счету.
|
||||
message PortfolioRequest {
|
||||
string account_id = 1 [(google.api.field_behavior) = REQUIRED]; //Идентификатор счета пользователя.
|
||||
optional CurrencyRequest currency = 2; //Валюта, в которой нужно рассчитать портфель.
|
||||
enum CurrencyRequest {
|
||||
RUB = 0; //Рубли
|
||||
USD = 1; //Доллары
|
||||
EUR = 2; //Евро
|
||||
}
|
||||
}
|
||||
|
||||
//Текущий портфель по счету.
|
||||
message PortfolioResponse {
|
||||
MoneyValue total_amount_shares = 1; //Общая стоимость акций в портфеле.
|
||||
MoneyValue total_amount_bonds = 2; //Общая стоимость облигаций в портфеле.
|
||||
MoneyValue total_amount_etf = 3; //Общая стоимость фондов в портфеле.
|
||||
MoneyValue total_amount_currencies = 4; //Общая стоимость валют в портфеле.
|
||||
MoneyValue total_amount_futures = 5; //Общая стоимость фьючерсов в портфеле.
|
||||
Quotation expected_yield = 6; //Текущая относительная доходность портфеля в %.
|
||||
repeated PortfolioPosition positions = 7; //Список позиций портфеля.
|
||||
string account_id = 8; //Идентификатор счета пользователя.
|
||||
|
||||
MoneyValue total_amount_options = 9; //Общая стоимость опционов в портфеле.
|
||||
MoneyValue total_amount_sp = 10; //Общая стоимость структурных нот в портфеле.
|
||||
MoneyValue total_amount_portfolio = 11; //Общая стоимость портфеля.
|
||||
repeated VirtualPortfolioPosition virtual_positions = 12; //Массив виртуальных позиций портфеля.
|
||||
MoneyValue daily_yield = 15; // Рассчитанная доходность портфеля за день в рублях.
|
||||
Quotation daily_yield_relative = 16; //Относительная доходность в день в %.
|
||||
MoneyValue total_amount_dfa = 17; //Общая стоимость смарт-активов в портфеле в рублях.
|
||||
}
|
||||
|
||||
//Запрос позиций портфеля по счету.
|
||||
message PositionsRequest {
|
||||
string account_id = 1 [(google.api.field_behavior) = REQUIRED]; //Идентификатор счета пользователя.
|
||||
}
|
||||
|
||||
//Список позиций по счету.
|
||||
message PositionsResponse {
|
||||
repeated MoneyValue money = 1; //Массив валютных позиций портфеля.
|
||||
repeated MoneyValue blocked = 2; //Массив заблокированных валютных позиций портфеля.
|
||||
repeated PositionsSecurities securities = 3; //Список ценно-бумажных позиций портфеля.
|
||||
bool limits_loading_in_progress = 4; //Признак идущей выгрузки лимитов в данный момент.
|
||||
repeated PositionsFutures futures = 5; //Список фьючерсов портфеля.
|
||||
repeated PositionsOptions options = 6; //Список опционов портфеля.
|
||||
string account_id = 15; //Идентификатор счёта пользователя.
|
||||
}
|
||||
|
||||
//Запрос доступного остатка для вывода.
|
||||
message WithdrawLimitsRequest {
|
||||
string account_id = 1 [(google.api.field_behavior) = REQUIRED]; //Идентификатор счета пользователя.
|
||||
}
|
||||
|
||||
//Доступный остаток для вывода.
|
||||
message WithdrawLimitsResponse {
|
||||
repeated MoneyValue money = 1; //Массив валютных позиций портфеля.
|
||||
repeated MoneyValue blocked = 2; //Массив заблокированных валютных позиций портфеля.
|
||||
repeated MoneyValue blocked_guarantee = 3; //Заблокировано под гарантийное обеспечение фьючерсов.
|
||||
}
|
||||
|
||||
//Позиции портфеля.
|
||||
message PortfolioPosition {
|
||||
string figi = 1; //FIGI-идентификатор инструмента.
|
||||
string instrument_type = 2; //Тип инструмента.
|
||||
Quotation quantity = 3; //Количество инструмента в портфеле в штуках.
|
||||
MoneyValue average_position_price = 4; //Средневзвешенная цена позиции. Для пересчета возможна задержка до одной секунды.
|
||||
Quotation expected_yield = 5; //Текущая рассчитанная доходность позиции.
|
||||
MoneyValue current_nkd = 6; // Текущий НКД.
|
||||
Quotation average_position_price_pt = 7 [ deprecated = true ]; // Deprecated Средняя цена позиции в пунктах (для фьючерсов). Для пересчета возможна задержка до одной секунды.
|
||||
MoneyValue current_price = 8; //Текущая цена за 1 инструмент. Чтобы получить стоимость лота, нужно умножить на лотность инструмента.
|
||||
MoneyValue average_position_price_fifo = 9; //Средняя цена позиции по методу FIFO. Для пересчета возможна задержка до одной секунды.
|
||||
Quotation quantity_lots = 10 [ deprecated = true ]; //Deprecated Количество лотов в портфеле.
|
||||
bool blocked = 21; //Заблокировано на бирже.
|
||||
Quotation blocked_lots = 22; //Количество бумаг, заблокированных выставленными заявками.
|
||||
string position_uid = 24; //Уникальный идентификатор позиции.
|
||||
string instrument_uid = 25; //Уникальный идентификатор инструмента.
|
||||
MoneyValue var_margin = 26; //Вариационная маржа.
|
||||
Quotation expected_yield_fifo = 27; //Текущая рассчитанная доходность позиции.
|
||||
MoneyValue daily_yield = 31; // Рассчитанная доходность портфеля за день.
|
||||
string ticker = 32; //Тикер инструмента.
|
||||
string class_code = 33; //Класс-код (секция торгов).
|
||||
optional MoneyValue var_margin_settled = 34; //Вариационная маржа (расчетная).
|
||||
}
|
||||
|
||||
message VirtualPortfolioPosition {
|
||||
string position_uid = 1; //Уникальный идентификатор позиции.
|
||||
string instrument_uid = 2; //Уникальный идентификатор инструмента.
|
||||
string figi = 3; //FIGI-идентификатор инструмента.
|
||||
string instrument_type = 4; //Тип инструмента.
|
||||
Quotation quantity = 5; //Количество инструмента в портфеле в штуках.
|
||||
MoneyValue average_position_price = 6; //Средневзвешенная цена позиции. Для пересчета возможна задержка до одной секунды.
|
||||
Quotation expected_yield = 7; //Текущая рассчитанная доходность позиции.
|
||||
Quotation expected_yield_fifo = 8; //Текущая рассчитанная доходность позиции.
|
||||
google.protobuf.Timestamp expire_date = 9; //Дата, до которой нужно продать виртуальные бумаги. После этой даты виртуальная позиция «сгораетт».
|
||||
MoneyValue current_price = 10; //Текущая цена за 1 инструмент. Чтобы получить стоимость лота, нужно умножить на лотность инструмента.
|
||||
MoneyValue average_position_price_fifo = 11; //Средняя цена позиции по методу FIFO. Для пересчета возможна задержка до одной секунды.
|
||||
MoneyValue daily_yield = 31; // Рассчитанная доходность портфеля за день.
|
||||
string ticker = 32; //Тикер инструмента.
|
||||
string class_code = 33; //Класс-код (секция торгов).
|
||||
}
|
||||
|
||||
//Баланс позиции ценной бумаги.
|
||||
message PositionsSecurities {
|
||||
string figi = 1; //FIGI-идентификатор бумаги.
|
||||
int64 blocked = 2; //Количество бумаг, заблокированных выставленными заявками.
|
||||
int64 balance = 3; //Текущий незаблокированный баланс.
|
||||
string position_uid = 4; //Уникальный идентификатор позиции.
|
||||
string instrument_uid = 5; //Уникальный идентификатор инструмента.
|
||||
string ticker = 6; //Тикер инструмента.
|
||||
string class_code = 7; //Класс-код (секция торгов).
|
||||
bool exchange_blocked = 11; //Заблокировано на бирже.
|
||||
string instrument_type = 16; //Тип инструмента.
|
||||
}
|
||||
|
||||
//Баланс фьючерса.
|
||||
message PositionsFutures {
|
||||
string figi = 1; //FIGI-идентификатор фьючерса.
|
||||
int64 blocked = 2; //Количество бумаг, заблокированных выставленными заявками.
|
||||
int64 balance = 3; //Текущий незаблокированный баланс.
|
||||
string position_uid = 4; //Уникальный идентификатор позиции.
|
||||
string instrument_uid = 5; //Уникальный идентификатор инструмента.
|
||||
string ticker = 6; //Тикер инструмента.
|
||||
string class_code = 7; //Класс-код (секция торгов).
|
||||
}
|
||||
|
||||
//Баланс опциона.
|
||||
message PositionsOptions {
|
||||
string position_uid = 1; //Уникальный идентификатор позиции опциона.
|
||||
string instrument_uid = 2; //Уникальный идентификатор инструмента.
|
||||
string ticker = 3; //Тикер инструмента.
|
||||
string class_code = 4; //Класс-код (секция торгов).
|
||||
int64 blocked = 11; //Количество бумаг, заблокированных выставленными заявками.
|
||||
int64 balance = 21; //Текущий незаблокированный баланс.
|
||||
}
|
||||
|
||||
message BrokerReportRequest {
|
||||
oneof payload {
|
||||
GenerateBrokerReportRequest generate_broker_report_request = 1;
|
||||
GetBrokerReportRequest get_broker_report_request = 2;
|
||||
}
|
||||
}
|
||||
|
||||
message BrokerReportResponse {
|
||||
oneof payload {
|
||||
GenerateBrokerReportResponse generate_broker_report_response = 1;
|
||||
GetBrokerReportResponse get_broker_report_response = 2;
|
||||
}
|
||||
}
|
||||
|
||||
message GenerateBrokerReportRequest {
|
||||
string account_id = 1 [(google.api.field_behavior) = REQUIRED]; //Идентификатор счета клиента.
|
||||
google.protobuf.Timestamp from = 2 [(google.api.field_behavior) = REQUIRED]; //Начало периода по UTC.
|
||||
google.protobuf.Timestamp to = 3 [(google.api.field_behavior) = REQUIRED]; //Окончание периода по UTC.
|
||||
}
|
||||
|
||||
message GenerateBrokerReportResponse {
|
||||
string task_id = 1; //Идентификатор задачи формирования брокерского отчета.
|
||||
}
|
||||
|
||||
message GetBrokerReportRequest {
|
||||
string task_id = 1 [(google.api.field_behavior) = REQUIRED]; //Идентификатор задачи формирования брокерского отчета.
|
||||
optional int32 page = 2; //Номер страницы отчета, начинается с 1. Значение по умолчанию — 0.
|
||||
}
|
||||
|
||||
message GetBrokerReportResponse {
|
||||
repeated BrokerReport broker_report = 1;
|
||||
int32 itemsCount = 2; //Количество записей в отчете.
|
||||
int32 pagesCount = 3; //Количество страниц с данными отчета, начинается с 0.
|
||||
int32 page = 4; //Текущая страница, начинается с 0.
|
||||
string task_id = 5; //Идентификатор задачи формирования брокерского отчета.
|
||||
}
|
||||
|
||||
message BrokerReport {
|
||||
string trade_id = 1;//Номер сделки.
|
||||
string order_id = 2; //Номер поручения.
|
||||
string figi = 3; //FIGI-идентификаторинструмента.
|
||||
string execute_sign = 4; //Признак исполнения.
|
||||
google.protobuf.Timestamp trade_datetime = 5; //Дата и время заключения по UTC.
|
||||
string exchange = 6; //Торговая площадка.
|
||||
string class_code = 7; //Режим торгов.
|
||||
string direction = 8; //Вид сделки.
|
||||
string name = 9; //Сокращенное наименование актива.
|
||||
string ticker = 10; //Код актива.
|
||||
MoneyValue price = 11; //Цена за единицу.
|
||||
int64 quantity = 12; //Количество.
|
||||
MoneyValue order_amount = 13; //Сумма без НКД.
|
||||
Quotation aci_value = 14; //НКД.
|
||||
MoneyValue total_order_amount = 15; //Сумма сделки.
|
||||
MoneyValue broker_commission = 16; //Комиссия брокера.
|
||||
MoneyValue exchange_commission = 17; //Комиссия биржи.
|
||||
MoneyValue exchange_clearing_commission = 18; //Комиссия клирингового центра.
|
||||
Quotation repo_rate = 19; //Ставка РЕПО, %.
|
||||
string party = 20; //Контрагент или брокерарокер.
|
||||
google.protobuf.Timestamp clear_value_date = 21; //Дата расчетов по UTC.
|
||||
google.protobuf.Timestamp sec_value_date = 22; //Дата поставки по UTC.
|
||||
string broker_status = 23; //Статус брокера.
|
||||
string separate_agreement_type = 24; //Тип договора.
|
||||
string separate_agreement_number = 25; //Номер договора.
|
||||
string separate_agreement_date = 26; //Дата договора.
|
||||
string delivery_type = 27; //Тип расчета по сделке.
|
||||
}
|
||||
|
||||
//Статус запрашиваемых операций.
|
||||
enum OperationState {
|
||||
OPERATION_STATE_UNSPECIFIED = 0; //Статус операции не определен.
|
||||
OPERATION_STATE_EXECUTED = 1; //Исполнена частично или полностью.
|
||||
OPERATION_STATE_CANCELED = 2; //Отменена.
|
||||
OPERATION_STATE_PROGRESS = 3; //Исполняется.
|
||||
}
|
||||
|
||||
//Тип операции.
|
||||
enum OperationType {
|
||||
OPERATION_TYPE_UNSPECIFIED = 0; //Тип операции не определен.
|
||||
OPERATION_TYPE_INPUT = 1; //Пополнение брокерского счета.
|
||||
OPERATION_TYPE_BOND_TAX = 2; //Удержание НДФЛ по купонам.
|
||||
OPERATION_TYPE_OUTPUT_SECURITIES = 3; //Вывод ЦБ.
|
||||
OPERATION_TYPE_OVERNIGHT = 4; //Доход по сделке РЕПО овернайт.
|
||||
OPERATION_TYPE_TAX = 5; //Удержание налога.
|
||||
OPERATION_TYPE_BOND_REPAYMENT_FULL = 6; //Полное погашение облигаций.
|
||||
OPERATION_TYPE_SELL_CARD = 7; //Продажа ЦБ с карты.
|
||||
OPERATION_TYPE_DIVIDEND_TAX = 8; //Удержание налога по дивидендам.
|
||||
OPERATION_TYPE_OUTPUT = 9; //Вывод денежных средств.
|
||||
OPERATION_TYPE_BOND_REPAYMENT = 10; //Частичное погашение облигаций.
|
||||
OPERATION_TYPE_TAX_CORRECTION = 11; //Корректировка налога.
|
||||
OPERATION_TYPE_SERVICE_FEE = 12; //Удержание комиссии за обслуживание брокерского счета.
|
||||
OPERATION_TYPE_BENEFIT_TAX = 13; //Удержание налога за материальную выгоду.
|
||||
OPERATION_TYPE_MARGIN_FEE = 14; //Удержание комиссии за непокрытую позицию.
|
||||
OPERATION_TYPE_BUY = 15; //Покупка ЦБ.
|
||||
OPERATION_TYPE_BUY_CARD = 16; //Покупка ЦБ с карты.
|
||||
OPERATION_TYPE_INPUT_SECURITIES = 17; //Перевод ценных бумаг из другого депозитария.
|
||||
OPERATION_TYPE_SELL_MARGIN = 18; //Продажа в результате Margin-call.
|
||||
OPERATION_TYPE_BROKER_FEE = 19; //Удержание комиссии за операцию.
|
||||
OPERATION_TYPE_BUY_MARGIN = 20; //Покупка в результате Margin-call.
|
||||
OPERATION_TYPE_DIVIDEND = 21; //Выплата дивидендов.
|
||||
OPERATION_TYPE_SELL = 22; //Продажа ЦБ.
|
||||
OPERATION_TYPE_COUPON = 23; //Выплата купонов.
|
||||
OPERATION_TYPE_SUCCESS_FEE = 24; //Удержание комиссии SuccessFee.
|
||||
OPERATION_TYPE_DIVIDEND_TRANSFER = 25; //Передача дивидендного дохода.
|
||||
OPERATION_TYPE_ACCRUING_VARMARGIN = 26; //Зачисление вариационной маржи.
|
||||
OPERATION_TYPE_WRITING_OFF_VARMARGIN = 27; //Списание вариационной маржи.
|
||||
OPERATION_TYPE_DELIVERY_BUY = 28; //Покупка в рамках экспирации фьючерсного контракта.
|
||||
OPERATION_TYPE_DELIVERY_SELL = 29; //Продажа в рамках экспирации фьючерсного контракта.
|
||||
OPERATION_TYPE_TRACK_MFEE = 30; //Комиссия за управление по счету автоследования.
|
||||
OPERATION_TYPE_TRACK_PFEE = 31; //Комиссия за результат по счету автоследования.
|
||||
OPERATION_TYPE_TAX_PROGRESSIVE = 32; //Удержание налога по ставке 15%.
|
||||
OPERATION_TYPE_BOND_TAX_PROGRESSIVE = 33; //Удержание налога по купонам по ставке 15%.
|
||||
OPERATION_TYPE_DIVIDEND_TAX_PROGRESSIVE = 34; //Удержание налога по дивидендам по ставке 15%.
|
||||
OPERATION_TYPE_BENEFIT_TAX_PROGRESSIVE = 35; //Удержание налога за материальную выгоду по ставке 15%.
|
||||
OPERATION_TYPE_TAX_CORRECTION_PROGRESSIVE = 36; //Корректировка налога по ставке 15%.
|
||||
OPERATION_TYPE_TAX_REPO_PROGRESSIVE = 37; //Удержание налога за возмещение по сделкам РЕПО по ставке 15%.
|
||||
OPERATION_TYPE_TAX_REPO = 38; //Удержание налога за возмещение по сделкам РЕПО.
|
||||
OPERATION_TYPE_TAX_REPO_HOLD = 39; //Удержание налога по сделкам РЕПО.
|
||||
OPERATION_TYPE_TAX_REPO_REFUND = 40; //Возврат налога по сделкам РЕПО.
|
||||
OPERATION_TYPE_TAX_REPO_HOLD_PROGRESSIVE = 41; //Удержание налога по сделкам РЕПО по ставке 15%.
|
||||
OPERATION_TYPE_TAX_REPO_REFUND_PROGRESSIVE = 42; //Возврат налога по сделкам РЕПО по ставке 15%.
|
||||
OPERATION_TYPE_DIV_EXT = 43; //Выплата дивидендов на карту.
|
||||
OPERATION_TYPE_TAX_CORRECTION_COUPON = 44; //Корректировка налога по купонам.
|
||||
OPERATION_TYPE_CASH_FEE = 45; //Комиссия за валютный остаток.
|
||||
OPERATION_TYPE_OUT_FEE = 46; //Комиссия за вывод валюты с брокерского счета.
|
||||
OPERATION_TYPE_OUT_STAMP_DUTY = 47; //Гербовый сбор.
|
||||
OPERATION_TYPE_OUTPUT_SWIFT = 50; // SWIFT-перевод.
|
||||
OPERATION_TYPE_INPUT_SWIFT = 51; // SWIFT-перевод.
|
||||
OPERATION_TYPE_OUTPUT_ACQUIRING = 53; // Перевод на карту.
|
||||
OPERATION_TYPE_INPUT_ACQUIRING = 54; // Перевод с карты.
|
||||
OPERATION_TYPE_OUTPUT_PENALTY = 55; // Комиссия за вывод средств.
|
||||
OPERATION_TYPE_ADVICE_FEE = 56; // Списание оплаты за сервис Советов.
|
||||
OPERATION_TYPE_TRANS_IIS_BS = 57; // Перевод ценных бумаг с ИИС на брокерский счет.
|
||||
OPERATION_TYPE_TRANS_BS_BS = 58; // Перевод ценных бумаг с одного брокерского счета на другой.
|
||||
OPERATION_TYPE_OUT_MULTI = 59; // Вывод денежных средств со счета.
|
||||
OPERATION_TYPE_INP_MULTI = 60; // Пополнение денежных средств со счета.
|
||||
OPERATION_TYPE_OVER_PLACEMENT = 61; // Размещение биржевого овернайта.
|
||||
OPERATION_TYPE_OVER_COM = 62; // Списание комиссии.
|
||||
OPERATION_TYPE_OVER_INCOME = 63; // Доход от оверанайта.
|
||||
OPERATION_TYPE_OPTION_EXPIRATION = 64; // Экспирация опциона.
|
||||
OPERATION_TYPE_FUTURE_EXPIRATION = 65; // Экспирация фьючерса.
|
||||
OPERATION_TYPE_OTHER_FEE = 66; // Прочие комиссии;
|
||||
OPERATION_TYPE_OTHER = 67; // Операция по счету;
|
||||
OPERATION_TYPE_DFA_REDEMPTION = 68; // погашение ЦФА-токена;
|
||||
OPERATION_TYPE_PRIMARY_ORDER =69; // отмена заявки на первичное размещение по ЦФА;
|
||||
OPERATION_TYPE_FUNDING = 70; //Списание фандинга.
|
||||
}
|
||||
|
||||
message GetDividendsForeignIssuerRequest {
|
||||
oneof payload {
|
||||
GenerateDividendsForeignIssuerReportRequest generate_div_foreign_issuer_report = 1; //Объект запроса формирования отчета.
|
||||
GetDividendsForeignIssuerReportRequest get_div_foreign_issuer_report = 2; //Объект запроса сформированного отчета.
|
||||
}
|
||||
}
|
||||
|
||||
message GetDividendsForeignIssuerResponse {
|
||||
oneof payload {
|
||||
GenerateDividendsForeignIssuerReportResponse generate_div_foreign_issuer_report_response = 1; //Объект результата задачи запуска формирования отчета.
|
||||
GetDividendsForeignIssuerReportResponse div_foreign_issuer_report = 2; //Отчет «Справка о доходах за пределами РФ».
|
||||
}
|
||||
}
|
||||
|
||||
//Объект запроса формирования отчета «Справка о доходах за пределами РФ».
|
||||
message GenerateDividendsForeignIssuerReportRequest {
|
||||
string account_id = 1 [(google.api.field_behavior) = REQUIRED]; //Идентификатор счета клиента.
|
||||
google.protobuf.Timestamp from = 2 [(google.api.field_behavior) = REQUIRED]; //Начало периода по UTC.
|
||||
google.protobuf.Timestamp to = 3 [(google.api.field_behavior) = REQUIRED]; //Окончание периода по UTC. Как правило, можно сформировать отчет по дату на несколько дней меньше текущей. Начало и окончание периода должны быть в рамках одного календарного года.
|
||||
}
|
||||
|
||||
// Объект запроса сформированного отчета «Справка о доходах за пределами РФ».
|
||||
message GetDividendsForeignIssuerReportRequest {
|
||||
string task_id = 1 [(google.api.field_behavior) = REQUIRED]; //Идентификатор задачи формирования отчета.
|
||||
optional int32 page = 2; //Номер страницы отчета (начинается с 0), значение по умолчанию: 0.
|
||||
}
|
||||
|
||||
// Объект результата задачи запуска формирования отчета «Справка о доходах за пределами РФ».
|
||||
message GenerateDividendsForeignIssuerReportResponse {
|
||||
string task_id = 1; //Идентификатор задачи формирования отчета.
|
||||
}
|
||||
|
||||
message GetDividendsForeignIssuerReportResponse {
|
||||
repeated DividendsForeignIssuerReport dividends_foreign_issuer_report = 1;
|
||||
int32 itemsCount = 2; //Количество записей в отчете.
|
||||
int32 pagesCount = 3; //Количество страниц с данными отчета, начинается с 0.
|
||||
int32 page = 4; //Текущая страница, начинается с 0.
|
||||
}
|
||||
|
||||
// Отчет «Справка о доходах за пределами РФ».
|
||||
message DividendsForeignIssuerReport {
|
||||
google.protobuf.Timestamp record_date = 1; //Дата фиксации реестра.
|
||||
google.protobuf.Timestamp payment_date = 2; //Дата выплаты.
|
||||
string security_name = 3; //Наименование ценной бумаги.
|
||||
string isin = 4; //ISIN-идентификатор ценной бумаги.
|
||||
string issuer_country = 5; //Страна эмитента. Для депозитарных расписок указывается страна эмитента базового актива.
|
||||
int64 quantity = 6; //Количество ценных бумаг.
|
||||
Quotation dividend = 7; //Выплаты на одну бумагу
|
||||
Quotation external_commission = 8; //Комиссия внешних платежных агентов.
|
||||
Quotation dividend_gross = 9; //Сумма до удержания налога.
|
||||
Quotation tax = 10; //Сумма налога, удержанного агентом.
|
||||
Quotation dividend_amount = 11; //Итоговая сумма выплаты.
|
||||
string currency = 12; //Валюта.
|
||||
}
|
||||
|
||||
//Запрос установки stream-соединения.
|
||||
message PortfolioStreamRequest {
|
||||
repeated string accounts = 1; //Массив идентификаторов счетов пользователя.
|
||||
PingDelaySettings ping_settings = 15; //Запрос настройки пинга.
|
||||
}
|
||||
|
||||
//Информация по позициям и доходностям портфелей.
|
||||
message PortfolioStreamResponse {
|
||||
oneof payload {
|
||||
PortfolioSubscriptionResult subscriptions = 1; //Объект результата подписки.
|
||||
PortfolioResponse portfolio = 2; //Объект стриминга портфеля.
|
||||
Ping ping = 3; //Проверка активности стрима.
|
||||
}
|
||||
}
|
||||
|
||||
//Объект результата подписки.
|
||||
message PortfolioSubscriptionResult {
|
||||
repeated AccountSubscriptionStatus accounts = 1; //Массив счетов клиента.
|
||||
string tracking_id = 7; //Уникальный идентификатор запроса, подробнее: [tracking_id](/invest/intro/developer/protocols/grpc#tracking-id).
|
||||
string stream_id = 8; //Идентификатор открытого соединения
|
||||
}
|
||||
|
||||
//Счет клиента.
|
||||
message AccountSubscriptionStatus {
|
||||
string account_id = 1; //Идентификатор счета.
|
||||
PortfolioSubscriptionStatus subscription_status = 6; //Результат подписки.
|
||||
}
|
||||
|
||||
//Результат подписки.
|
||||
enum PortfolioSubscriptionStatus {
|
||||
PORTFOLIO_SUBSCRIPTION_STATUS_UNSPECIFIED = 0; //Тип не определен.
|
||||
PORTFOLIO_SUBSCRIPTION_STATUS_SUCCESS = 1; //Успешно.
|
||||
PORTFOLIO_SUBSCRIPTION_STATUS_ACCOUNT_NOT_FOUND = 2; //Счет не найден или недостаточно прав.
|
||||
PORTFOLIO_SUBSCRIPTION_STATUS_INTERNAL_ERROR = 3; //Произошла ошибка.
|
||||
}
|
||||
|
||||
//Запрос списка операций по счету с пагинацией.
|
||||
message GetOperationsByCursorRequest {
|
||||
string account_id = 1 [(google.api.field_behavior) = REQUIRED]; //Идентификатор счета клиента, обязательный параметр. Остальные параметры опциональны.
|
||||
optional string instrument_id = 2; //Идентификатор инструмента. Принимает значение `figi`, `instrument_uid` или `ticker + '_' + class_code`.
|
||||
optional google.protobuf.Timestamp from = 6; //Начало периода по UTC.
|
||||
optional google.protobuf.Timestamp to = 7; //Окончание периода по UTC.
|
||||
optional string cursor = 11; //Идентификатор элемента, с которого начать формировать ответ.
|
||||
optional int32 limit = 12; //Лимит количества операций. По умолчанию — `100`, максимальное значение — `1000`.
|
||||
repeated OperationType operation_types = 13; //Тип операции. Принимает значение из списка `OperationType`.
|
||||
optional OperationState state = 14; //Статус запрашиваемых операций. Возможные значения указаны в `OperationState`.
|
||||
optional bool without_commissions = 15; //Флаг возврата комиссии. По умолчанию — `false`.
|
||||
optional bool without_trades = 16; //Флаг получения ответа без массива сделок.
|
||||
optional bool without_overnights = 17; //Флаг показа overnight операций.
|
||||
}
|
||||
|
||||
//Список операций по счету с пагинацией.
|
||||
message GetOperationsByCursorResponse {
|
||||
bool has_next = 1; //Признак, есть ли следующий элемент.
|
||||
string next_cursor = 2; //Следующий курсор.
|
||||
repeated OperationItem items = 6; //Список операций.
|
||||
}
|
||||
|
||||
//Данные об операции.
|
||||
message OperationItem {
|
||||
string cursor = 1; //Курсор.
|
||||
string broker_account_id = 6; //Номер счета клиента.
|
||||
string id = 16; //Идентификатор операции, может меняться с течением времени.
|
||||
string parent_operation_id = 17; //Идентификатор родительской операции. Может измениться, если изменился ID родительской операции.
|
||||
string name = 18; //Название операции.
|
||||
google.protobuf.Timestamp date = 21; //Дата поручения.
|
||||
OperationType type = 22; //Тип операции.
|
||||
string description = 23; //Описание операции.
|
||||
OperationState state = 24; //Статус поручения.
|
||||
string instrument_uid = 31; //Уникальный идентификатор инструмента.
|
||||
string figi = 32; //FIGI.
|
||||
string instrument_type = 33; //Тип инструмента.
|
||||
InstrumentType instrument_kind = 34; //Тип инструмента.
|
||||
string position_uid = 35; //Уникальный идентификатор позиции.
|
||||
string ticker = 36; //Тикер инструмента.
|
||||
string class_code = 37; //Класс-код (секция торгов).
|
||||
MoneyValue payment = 41; //Сумма операции.
|
||||
MoneyValue price = 42; //Цена операции за 1 инструмент.
|
||||
MoneyValue commission = 43; //Комиссия.
|
||||
MoneyValue yield = 44; //Доходность.
|
||||
Quotation yield_relative = 45; //Относительная доходность.
|
||||
MoneyValue accrued_int = 46; //Накопленный купонный доход.
|
||||
int64 quantity = 51; //Количество единиц инструмента.
|
||||
int64 quantity_rest = 52; //Неисполненный остаток по сделке.
|
||||
int64 quantity_done = 53; //Исполненный остаток.
|
||||
google.protobuf.Timestamp cancel_date_time = 56; //Дата и время снятия заявки.
|
||||
string cancel_reason = 57; //Причина отмены операции.
|
||||
OperationItemTrades trades_info = 61; //Массив сделок.
|
||||
string asset_uid = 64; //Идентификатор актива.
|
||||
repeated ChildOperationItem child_operations = 65; //Массив дочерних операций.
|
||||
}
|
||||
|
||||
//Массив с информацией о сделках.
|
||||
message OperationItemTrades {
|
||||
repeated OperationItemTrade trades = 6;
|
||||
}
|
||||
|
||||
//Сделка по операции.
|
||||
message OperationItemTrade {
|
||||
string num = 1; //Номер сделки.
|
||||
google.protobuf.Timestamp date = 6; //Дата сделки.
|
||||
int64 quantity = 11; //Количество в единицах.
|
||||
MoneyValue price = 16; //Цена.
|
||||
MoneyValue yield = 21; //Доходность.
|
||||
Quotation yield_relative = 22; //Относительная доходность.
|
||||
}
|
||||
|
||||
//Запрос установки stream-соединения позиций.
|
||||
message PositionsStreamRequest {
|
||||
repeated string accounts = 1; //Массив идентификаторов счетов пользователя.
|
||||
bool with_initial_positions = 3; //Получение состояния позиций на момент подключения.
|
||||
PingDelaySettings ping_settings = 15; //Запрос настройки пинга.
|
||||
}
|
||||
|
||||
//Информация по изменению позиций портфеля.
|
||||
message PositionsStreamResponse {
|
||||
oneof payload {
|
||||
PositionsSubscriptionResult subscriptions = 1; //Объект результата подписки.
|
||||
PositionData position = 2; //Объект стриминга позиций.
|
||||
Ping ping = 3; //Проверка активности стрима.
|
||||
PositionsResponse initial_positions = 5; //Текущие позиции.
|
||||
}
|
||||
}
|
||||
|
||||
//Объект результата подписки.
|
||||
message PositionsSubscriptionResult {
|
||||
repeated PositionsSubscriptionStatus accounts = 1; //Массив счетов клиента.
|
||||
string tracking_id = 7; //Уникальный идентификатор запроса, подробнее: [tracking_id](/invest/intro/developer/protocols/grpc#tracking-id).
|
||||
string stream_id = 8; //Идентификатор открытого соединения
|
||||
}
|
||||
|
||||
//Счет клиента.
|
||||
message PositionsSubscriptionStatus {
|
||||
string account_id = 1; //Идентификатор счета.
|
||||
PositionsAccountSubscriptionStatus subscription_status = 6; //Результат подписки.
|
||||
}
|
||||
|
||||
//Результат подписки.
|
||||
enum PositionsAccountSubscriptionStatus {
|
||||
POSITIONS_SUBSCRIPTION_STATUS_UNSPECIFIED = 0; //Тип не определен.
|
||||
POSITIONS_SUBSCRIPTION_STATUS_SUCCESS = 1; //Успешно.
|
||||
POSITIONS_SUBSCRIPTION_STATUS_ACCOUNT_NOT_FOUND = 2; //Счет не найден или недостаточно прав.
|
||||
POSITIONS_SUBSCRIPTION_STATUS_INTERNAL_ERROR = 3; //Произошла ошибка.
|
||||
}
|
||||
|
||||
//Данные о позиции портфеля.
|
||||
message PositionData {
|
||||
string account_id = 1; //Идентификатор счета.
|
||||
repeated PositionsMoney money = 2; //Массив валютных позиций портфеля.
|
||||
repeated PositionsSecurities securities = 3; //Список ценно-бумажных позиций портфеля.
|
||||
repeated PositionsFutures futures = 4; //Список фьючерсов портфеля.
|
||||
repeated PositionsOptions options = 5; //Список опционов портфеля.
|
||||
google.protobuf.Timestamp date = 6; //Дата и время операции в формате UTC.
|
||||
}
|
||||
|
||||
//Валютная позиция портфеля.
|
||||
message PositionsMoney {
|
||||
MoneyValue available_value = 1; //Доступное количество валютный позиций.
|
||||
MoneyValue blocked_value = 2; //Заблокированное количество валютных позиций.
|
||||
}
|
||||
|
||||
message ChildOperationItem {
|
||||
string instrument_uid = 1; //Уникальный идентификатор инструмента.
|
||||
MoneyValue payment = 2; //Сумма операции.
|
||||
}
|
||||
|
||||
//Запрос установки stream-соединения операций.
|
||||
message OperationsStreamRequest {
|
||||
repeated string accounts = 1; //Массив идентификаторов счетов пользователя.
|
||||
PingDelaySettings ping_settings = 15; //Запрос настройки пинга.
|
||||
}
|
||||
|
||||
//Информация по операциям.
|
||||
message OperationsStreamResponse {
|
||||
oneof payload {
|
||||
OperationsSubscriptionResult subscriptions = 1; //Объект результата подписки.
|
||||
OperationData operation = 2; //Объект стриминга операций.
|
||||
Ping ping = 3; //Проверка активности стрима.
|
||||
}
|
||||
}
|
||||
|
||||
//Объект результата подписки.
|
||||
message OperationsSubscriptionResult {
|
||||
repeated string accounts = 1; //Массив счетов клиента.
|
||||
OperationsAccountSubscriptionStatus subscription_status = 2; //Результат подписки.
|
||||
string tracking_id = 7; //Уникальный идентификатор запроса, подробнее: [tracking_id](/invest/intro/developer/protocols/grpc#tracking-id).
|
||||
string stream_id = 8; //Идентификатор открытого соединения
|
||||
}
|
||||
|
||||
//Результат подписки.
|
||||
enum OperationsAccountSubscriptionStatus {
|
||||
OPERATIONS_SUBSCRIPTION_STATUS_UNSPECIFIED = 0; //Тип не определен.
|
||||
OPERATIONS_SUBSCRIPTION_STATUS_SUCCESS = 1; //Успешно.
|
||||
OPERATIONS_SUBSCRIPTION_STATUS_ACCOUNT_NOT_FOUND = 2; //Счет не найден или недостаточно прав.
|
||||
OPERATIONS_SUBSCRIPTION_STATUS_INTERNAL_ERROR = 3; //Произошла ошибка.
|
||||
}
|
||||
|
||||
//Данные об операции.
|
||||
message OperationData {
|
||||
string broker_account_id = 1; //Идентификатор счета.
|
||||
string id = 2; //Номер поручения.
|
||||
string parent_operation_id = 3; //Номер родительского поручения.
|
||||
string name = 4; //Название инструмента.
|
||||
google.protobuf.Timestamp date = 5; //Дата.
|
||||
OperationType type = 6; //Тип операции.
|
||||
OperationState state = 7; //Статус поручения.
|
||||
string instrument_uid = 8; //Уникальный идентификатор инструмента.
|
||||
string figi = 9; //FIGI-идентификатор инструмента.
|
||||
string instrument_type = 10; //Тип инструмента.
|
||||
InstrumentType instrument_kind = 11; //Тип инструмента.
|
||||
string position_uid = 12; //Идентификатор позиции.
|
||||
string ticker = 13; //Тикер инструмента.
|
||||
string class_code = 14; //Класс-код (секция торгов).
|
||||
MoneyValue payment = 15; //Сумма операции.
|
||||
}
|
||||
249
apps/backend/src/modules/tbank/proto/contracts/users.proto
Normal file
249
apps/backend/src/modules/tbank/proto/contracts/users.proto
Normal file
@ -0,0 +1,249 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package tinkoff.public.invest.api.contract.v1;
|
||||
|
||||
option go_package = "./;investapi";
|
||||
option java_package = "ru.tinkoff.piapi.contract.v1";
|
||||
option java_multiple_files = true;
|
||||
option csharp_namespace = "Tinkoff.InvestApi.V1";
|
||||
option objc_class_prefix = "TIAPI";
|
||||
option php_namespace = "Tinkoff\\Invest\\V1";
|
||||
|
||||
import "google/protobuf/timestamp.proto";
|
||||
import "google/api/field_behavior.proto";
|
||||
import "common.proto";
|
||||
|
||||
service UsersService { /*С помощью сервиса можно получить: <br/> 1.
|
||||
список счетов пользователя; <br/> 2. маржинальные показатели по сч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; // Значение запрашиваемого параметра.
|
||||
}
|
||||
@ -0,0 +1,49 @@
|
||||
import { BrokerAccountsService } from './broker-accounts.service';
|
||||
import { TBankClientService } from './tbank-client.service';
|
||||
import { CacheService } from '../../cache/cache.service';
|
||||
|
||||
describe('BrokerAccountsService', () => {
|
||||
const client = {
|
||||
getServiceClient: vi.fn(),
|
||||
callUnary: vi.fn(),
|
||||
} as unknown as TBankClientService;
|
||||
const cache = {
|
||||
getOrFetch: vi.fn(),
|
||||
} as unknown as CacheService;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('returns only open brokerage and IIS accounts from cache wrapper', async () => {
|
||||
vi.mocked(cache.getOrFetch).mockImplementation(
|
||||
async (_prefix: string, _parts: string[], fetchFn: () => Promise<unknown>) => ({
|
||||
data: await fetchFn(),
|
||||
fromCache: false,
|
||||
cachedAt: '2026-06-16T02:30:00.000Z',
|
||||
}),
|
||||
);
|
||||
vi.mocked(client.getServiceClient).mockReturnValue({ getAccounts: vi.fn() } as any);
|
||||
vi.mocked(client.callUnary).mockResolvedValue({
|
||||
accounts: [
|
||||
{ id: '1', type: 'ACCOUNT_TYPE_TINKOFF', name: 'Broker', status: 'ACCOUNT_STATUS_OPEN' },
|
||||
{ id: '2', type: 'ACCOUNT_TYPE_TINKOFF_IIS', name: 'IIS', status: 'ACCOUNT_STATUS_OPEN' },
|
||||
{ id: '3', type: 'ACCOUNT_TYPE_INVEST_BOX', name: 'Box', status: 'ACCOUNT_STATUS_OPEN' },
|
||||
{ id: '4', type: 'ACCOUNT_TYPE_TINKOFF', name: 'Closed', status: 'ACCOUNT_STATUS_CLOSED' },
|
||||
],
|
||||
});
|
||||
|
||||
const service = new BrokerAccountsService(client, cache);
|
||||
const result = await service.findAll();
|
||||
|
||||
expect(result.data).toHaveLength(2);
|
||||
expect(result.data.map((account) => account.type)).toEqual(['brokerage', 'iis']);
|
||||
expect(result.meta.fromCache).toBe(false);
|
||||
expect(cache.getOrFetch).toHaveBeenCalledWith(
|
||||
'tbank:accounts',
|
||||
['open-brokerage-iis'],
|
||||
expect.any(Function),
|
||||
'tbankAccountsTtl',
|
||||
);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,50 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { CacheService } from '../../cache/cache.service';
|
||||
import { isSupportedBrokerAccount, mapAccount } from '../mappers/account.mapper';
|
||||
import { TBANK_CACHE_KEYS } from '../tbank.config';
|
||||
import type { BrokerAccount } from '../types/broker.types';
|
||||
import type { TBankAccountsResponse } from '../types/tbank-proto.types';
|
||||
import { TBankClientService } from './tbank-client.service';
|
||||
|
||||
@Injectable()
|
||||
export class BrokerAccountsService {
|
||||
constructor(
|
||||
private readonly tbankClient: TBankClientService,
|
||||
private readonly cacheService: CacheService,
|
||||
) {}
|
||||
|
||||
async findAll(): Promise<{
|
||||
data: BrokerAccount[];
|
||||
meta: { fromCache: boolean; cachedAt: string | null };
|
||||
}> {
|
||||
const result = await this.cacheService.getOrFetch(
|
||||
TBANK_CACHE_KEYS.accounts,
|
||||
['open-brokerage-iis'],
|
||||
() => this.fetchAccounts(),
|
||||
'tbankAccountsTtl',
|
||||
);
|
||||
|
||||
return {
|
||||
data: result.data,
|
||||
meta: { fromCache: result.fromCache, cachedAt: result.cachedAt },
|
||||
};
|
||||
}
|
||||
|
||||
async findById(accountId: string): Promise<BrokerAccount | null> {
|
||||
const accounts = await this.findAll();
|
||||
|
||||
return accounts.data.find((account) => account.id === accountId) ?? null;
|
||||
}
|
||||
|
||||
private async fetchAccounts(): Promise<BrokerAccount[]> {
|
||||
const usersClient = this.tbankClient.getServiceClient('UsersService') as any;
|
||||
const response = await this.tbankClient.callUnary<
|
||||
Record<string, string>,
|
||||
TBankAccountsResponse
|
||||
>('UsersService/GetAccounts', usersClient.getAccounts.bind(usersClient), {
|
||||
status: 'ACCOUNT_STATUS_OPEN',
|
||||
});
|
||||
|
||||
return (response.accounts ?? []).filter(isSupportedBrokerAccount).map(mapAccount);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,38 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { CacheService } from '../../cache/cache.service';
|
||||
import { TBANK_CACHE_KEYS } from '../tbank.config';
|
||||
import type { TBankInstrument, TBankInstrumentResponse } from '../types/tbank-proto.types';
|
||||
import { TBankClientService } from './tbank-client.service';
|
||||
|
||||
@Injectable()
|
||||
export class BrokerInstrumentsService {
|
||||
constructor(
|
||||
private readonly tbankClient: TBankClientService,
|
||||
private readonly cacheService: CacheService,
|
||||
) {}
|
||||
|
||||
async findByInstrumentUid(instrumentUid: string): Promise<TBankInstrument | null> {
|
||||
const result = await this.cacheService.getOrFetch(
|
||||
TBANK_CACHE_KEYS.instrument,
|
||||
[instrumentUid],
|
||||
() => this.fetchByUid(instrumentUid),
|
||||
'tbankInstrumentTtl',
|
||||
);
|
||||
|
||||
return result.data;
|
||||
}
|
||||
|
||||
private async fetchByUid(instrumentUid: string): Promise<TBankInstrument | null> {
|
||||
const instrumentsClient = this.tbankClient.getServiceClient('InstrumentsService') as any;
|
||||
const response = await this.tbankClient.callUnary<
|
||||
{ idType: string; id: string },
|
||||
TBankInstrumentResponse
|
||||
>(
|
||||
'InstrumentsService/GetInstrumentBy',
|
||||
instrumentsClient.getInstrumentBy.bind(instrumentsClient),
|
||||
{ idType: 'INSTRUMENT_ID_TYPE_UID', id: instrumentUid },
|
||||
);
|
||||
|
||||
return response.instrument ?? null;
|
||||
}
|
||||
}
|
||||
@ -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');
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,106 @@
|
||||
import { Injectable, InternalServerErrorException } from '@nestjs/common';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import type { BrokerOperation } from '../types/broker.types';
|
||||
import { BrokerOperationsService } from './broker-operations.service';
|
||||
|
||||
type BrokerOperationSyncRange = {
|
||||
from: string;
|
||||
to: string;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class BrokerOperationSyncService {
|
||||
constructor(
|
||||
private readonly operationsService: BrokerOperationsService,
|
||||
private readonly prisma: PrismaService,
|
||||
) {}
|
||||
|
||||
async syncAccount(
|
||||
accountId: string,
|
||||
range: BrokerOperationSyncRange,
|
||||
): Promise<{ upserted: number }> {
|
||||
let cursor: string | undefined;
|
||||
let lastCursor: string | null = null;
|
||||
let upserted = 0;
|
||||
|
||||
do {
|
||||
const page = await this.operationsService.getOperations(accountId, {
|
||||
from: range.from,
|
||||
to: range.to,
|
||||
cursor,
|
||||
limit: 1000,
|
||||
state: 'OPERATION_STATE_EXECUTED',
|
||||
});
|
||||
|
||||
for (const operation of page.data.items) {
|
||||
await this.upsertOperation(accountId, operation);
|
||||
upserted++;
|
||||
}
|
||||
|
||||
const nextCursor = page.data.nextCursor ?? undefined;
|
||||
if (page.data.hasNext && !nextCursor) {
|
||||
throw new InternalServerErrorException('T-Bank returned hasNext without nextCursor');
|
||||
}
|
||||
|
||||
if (nextCursor) {
|
||||
lastCursor = nextCursor;
|
||||
}
|
||||
|
||||
cursor = nextCursor;
|
||||
if (!page.data.hasNext) {
|
||||
break;
|
||||
}
|
||||
} while (cursor);
|
||||
|
||||
await this.prisma.brokerOperationSyncState.upsert({
|
||||
where: { accountId },
|
||||
create: {
|
||||
accountId,
|
||||
lastCursor,
|
||||
lastSyncedFrom: new Date(range.from),
|
||||
lastSyncedTo: new Date(range.to),
|
||||
},
|
||||
update: {
|
||||
lastCursor,
|
||||
lastSyncedFrom: new Date(range.from),
|
||||
lastSyncedTo: new Date(range.to),
|
||||
syncedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
return { upserted };
|
||||
}
|
||||
|
||||
private async upsertOperation(accountId: string, operation: BrokerOperation): Promise<void> {
|
||||
const cursor =
|
||||
operation.cursor || `${operation.id || 'operation'}:${operation.date || 'no-date'}`;
|
||||
const data = {
|
||||
accountId,
|
||||
cursor,
|
||||
operationId: operation.id,
|
||||
parentOperationId: operation.parentOperationId,
|
||||
date: operation.date ? new Date(operation.date) : null,
|
||||
type: operation.type,
|
||||
category: operation.category,
|
||||
state: operation.state,
|
||||
instrumentUid: operation.instrumentUid,
|
||||
figi: operation.figi,
|
||||
ticker: operation.ticker,
|
||||
classCode: operation.classCode,
|
||||
payment: operation.payment ? JSON.stringify(operation.payment) : null,
|
||||
price: operation.price ? JSON.stringify(operation.price) : null,
|
||||
commission: operation.commission ? JSON.stringify(operation.commission) : null,
|
||||
yield: operation.yield ? JSON.stringify(operation.yield) : null,
|
||||
accruedInt: operation.accruedInt ? JSON.stringify(operation.accruedInt) : null,
|
||||
quantity: operation.quantity,
|
||||
quantityDone: operation.quantityDone,
|
||||
raw: JSON.stringify(operation),
|
||||
};
|
||||
|
||||
await this.prisma.brokerOperation.upsert({
|
||||
where: { accountId_cursor: { accountId, cursor } },
|
||||
create: data,
|
||||
update: data,
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,64 @@
|
||||
import { NotFoundException } from '@nestjs/common';
|
||||
import { CacheService } from '../../cache/cache.service';
|
||||
import { BrokerAccountsService } from './broker-accounts.service';
|
||||
import { BrokerOperationsService } from './broker-operations.service';
|
||||
import { TBankClientService } from './tbank-client.service';
|
||||
|
||||
describe('BrokerOperationsService', () => {
|
||||
const accounts = { findById: vi.fn() } as unknown as BrokerAccountsService;
|
||||
const client = { getServiceClient: vi.fn(), callUnary: vi.fn() } as unknown as TBankClientService;
|
||||
const cache = { getOrFetch: vi.fn() } as unknown as CacheService;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('throws 404 for excluded or missing account', async () => {
|
||||
vi.mocked(accounts.findById).mockResolvedValue(null);
|
||||
const service = new BrokerOperationsService(accounts, client, cache);
|
||||
|
||||
await expect(service.getOperations('missing', {})).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
|
||||
it('builds cursor request and maps operation page', async () => {
|
||||
vi.mocked(accounts.findById).mockResolvedValue({
|
||||
id: 'acc-1',
|
||||
type: 'brokerage',
|
||||
name: 'Broker',
|
||||
status: 'ACCOUNT_STATUS_OPEN',
|
||||
openedAt: null,
|
||||
accessLevel: null,
|
||||
});
|
||||
vi.mocked(cache.getOrFetch).mockImplementation(
|
||||
async (_prefix: string, _parts: string[], fetchFn: () => Promise<unknown>) => ({
|
||||
data: await fetchFn(),
|
||||
fromCache: false,
|
||||
cachedAt: null,
|
||||
}),
|
||||
);
|
||||
vi.mocked(client.getServiceClient).mockReturnValue({ getOperationsByCursor: vi.fn() } as any);
|
||||
vi.mocked(client.callUnary).mockResolvedValue({
|
||||
hasNext: false,
|
||||
items: [{ cursor: 'c1', brokerAccountId: 'acc-1', type: 'OPERATION_TYPE_BUY' }],
|
||||
});
|
||||
|
||||
const service = new BrokerOperationsService(accounts, client, cache);
|
||||
const result = await service.getOperations('acc-1', {
|
||||
from: '2026-01-01T00:00:00.000Z',
|
||||
to: '2026-06-16T00:00:00.000Z',
|
||||
limit: 1000,
|
||||
state: 'OPERATION_STATE_EXECUTED',
|
||||
});
|
||||
|
||||
expect(result.data.items[0].category).toBe('trade');
|
||||
expect(client.callUnary).toHaveBeenCalledWith(
|
||||
'OperationsService/GetOperationsByCursor',
|
||||
expect.any(Function),
|
||||
expect.objectContaining({
|
||||
accountId: 'acc-1',
|
||||
limit: 1000,
|
||||
state: 'OPERATION_STATE_EXECUTED',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,88 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { CacheService } from '../../cache/cache.service';
|
||||
import type { BrokerOperationQueryDto } from '../dto/broker-operation-query.dto';
|
||||
import { mapOperationsPage } from '../mappers/operation.mapper';
|
||||
import { TBANK_CACHE_KEYS } from '../tbank.config';
|
||||
import type { BrokerOperationsPage } from '../types/broker.types';
|
||||
import type { TBankOperationsByCursorResponse } from '../types/tbank-proto.types';
|
||||
import { BrokerAccountsService } from './broker-accounts.service';
|
||||
import { TBankClientService } from './tbank-client.service';
|
||||
|
||||
@Injectable()
|
||||
export class BrokerOperationsService {
|
||||
constructor(
|
||||
private readonly accountsService: BrokerAccountsService,
|
||||
private readonly tbankClient: TBankClientService,
|
||||
private readonly cacheService: CacheService,
|
||||
) {}
|
||||
|
||||
async getOperations(
|
||||
accountId: string,
|
||||
query: BrokerOperationQueryDto,
|
||||
): Promise<{
|
||||
data: BrokerOperationsPage;
|
||||
meta: { fromCache: boolean; cachedAt: string | null };
|
||||
}> {
|
||||
const account = await this.accountsService.findById(accountId);
|
||||
if (!account) throw new NotFoundException('Broker account not found');
|
||||
|
||||
const request = this.buildRequest(accountId, query);
|
||||
const result = await this.cacheService.getOrFetch(
|
||||
TBANK_CACHE_KEYS.operations,
|
||||
[accountId, JSON.stringify(request)],
|
||||
() => this.fetchOperations(accountId, request),
|
||||
'tbankOperationsTtl',
|
||||
);
|
||||
|
||||
return {
|
||||
data: result.data,
|
||||
meta: { fromCache: result.fromCache, cachedAt: result.cachedAt },
|
||||
};
|
||||
}
|
||||
|
||||
private buildRequest(accountId: string, query: BrokerOperationQueryDto): Record<string, unknown> {
|
||||
const now = new Date();
|
||||
const startOfYear = new Date(Date.UTC(now.getUTCFullYear(), 0, 1));
|
||||
const operationTypes = query.operationTypes
|
||||
? query.operationTypes
|
||||
.split(',')
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean)
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
accountId,
|
||||
instrumentId: query.instrumentId,
|
||||
from: {
|
||||
seconds: Math.floor(new Date(query.from ?? startOfYear.toISOString()).getTime() / 1000),
|
||||
},
|
||||
to: {
|
||||
seconds: Math.floor(new Date(query.to ?? now.toISOString()).getTime() / 1000),
|
||||
},
|
||||
cursor: query.cursor,
|
||||
limit: query.limit ?? 100,
|
||||
operationTypes,
|
||||
state: query.state ?? 'OPERATION_STATE_EXECUTED',
|
||||
withoutCommissions: false,
|
||||
withoutTrades: false,
|
||||
withoutOvernights: false,
|
||||
};
|
||||
}
|
||||
|
||||
private async fetchOperations(
|
||||
accountId: string,
|
||||
request: Record<string, unknown>,
|
||||
): Promise<BrokerOperationsPage> {
|
||||
const operationsClient = this.tbankClient.getServiceClient('OperationsService') as any;
|
||||
const response = await this.tbankClient.callUnary<
|
||||
Record<string, unknown>,
|
||||
TBankOperationsByCursorResponse
|
||||
>(
|
||||
'OperationsService/GetOperationsByCursor',
|
||||
operationsClient.getOperationsByCursor.bind(operationsClient),
|
||||
request,
|
||||
);
|
||||
|
||||
return mapOperationsPage(accountId, response);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,70 @@
|
||||
import { NotFoundException } from '@nestjs/common';
|
||||
import { CacheService } from '../../cache/cache.service';
|
||||
import { BrokerAccountsService } from './broker-accounts.service';
|
||||
import { BrokerInstrumentsService } from './broker-instruments.service';
|
||||
import { BrokerPortfolioService } from './broker-portfolio.service';
|
||||
import { TBankClientService } from './tbank-client.service';
|
||||
|
||||
describe('BrokerPortfolioService', () => {
|
||||
const accounts = { findById: vi.fn() } as unknown as BrokerAccountsService;
|
||||
const instruments = { findByInstrumentUid: vi.fn() } as unknown as BrokerInstrumentsService;
|
||||
const client = { getServiceClient: vi.fn(), callUnary: vi.fn() } as unknown as TBankClientService;
|
||||
const cache = { getOrFetch: vi.fn() } as unknown as CacheService;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('throws 404 for excluded or missing account', async () => {
|
||||
vi.mocked(accounts.findById).mockResolvedValue(null);
|
||||
const service = new BrokerPortfolioService(accounts, instruments, client, cache);
|
||||
|
||||
await expect(service.getPortfolio('missing')).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
|
||||
it('fetches portfolio and positions through cache', async () => {
|
||||
vi.mocked(accounts.findById).mockResolvedValue({
|
||||
id: 'acc-1',
|
||||
type: 'brokerage',
|
||||
name: 'Broker',
|
||||
status: 'ACCOUNT_STATUS_OPEN',
|
||||
openedAt: null,
|
||||
accessLevel: null,
|
||||
});
|
||||
vi.mocked(cache.getOrFetch).mockImplementation(
|
||||
async (_prefix: string, _parts: string[], fetchFn: () => Promise<unknown>) => ({
|
||||
data: await fetchFn(),
|
||||
fromCache: false,
|
||||
cachedAt: null,
|
||||
}),
|
||||
);
|
||||
vi.mocked(client.getServiceClient).mockReturnValue({
|
||||
getPortfolio: vi.fn(),
|
||||
getPositions: vi.fn(),
|
||||
} as any);
|
||||
vi.mocked(client.callUnary)
|
||||
.mockResolvedValueOnce({
|
||||
accountId: 'acc-1',
|
||||
totalAmountPortfolio: { currency: 'rub', units: '1000', nano: 0 },
|
||||
positions: [],
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
accountId: 'acc-1',
|
||||
money: [{ currency: 'rub', units: '1000', nano: 0 }],
|
||||
blocked: [],
|
||||
securities: [],
|
||||
});
|
||||
|
||||
const service = new BrokerPortfolioService(accounts, instruments, client, cache);
|
||||
const result = await service.getPortfolio('acc-1');
|
||||
|
||||
expect(result.data.account.id).toBe('acc-1');
|
||||
expect(result.data.cash[0].value).toBe(1000);
|
||||
expect(cache.getOrFetch).toHaveBeenCalledWith(
|
||||
'tbank:portfolio',
|
||||
['acc-1'],
|
||||
expect.any(Function),
|
||||
'tbankPortfolioTtl',
|
||||
);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,81 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { CacheService } from '../../cache/cache.service';
|
||||
import { mapBrokerPortfolio } from '../mappers/portfolio.mapper';
|
||||
import { TBANK_CACHE_KEYS } from '../tbank.config';
|
||||
import type { BrokerPortfolio } from '../types/broker.types';
|
||||
import type {
|
||||
TBankInstrument,
|
||||
TBankPortfolioResponse,
|
||||
TBankPositionsResponse,
|
||||
} from '../types/tbank-proto.types';
|
||||
import { BrokerAccountsService } from './broker-accounts.service';
|
||||
import { BrokerInstrumentsService } from './broker-instruments.service';
|
||||
import { TBankClientService } from './tbank-client.service';
|
||||
|
||||
@Injectable()
|
||||
export class BrokerPortfolioService {
|
||||
constructor(
|
||||
private readonly accountsService: BrokerAccountsService,
|
||||
private readonly instrumentsService: BrokerInstrumentsService,
|
||||
private readonly tbankClient: TBankClientService,
|
||||
private readonly cacheService: CacheService,
|
||||
) {}
|
||||
|
||||
async getPortfolio(accountId: string): Promise<{
|
||||
data: BrokerPortfolio;
|
||||
meta: { fromCache: boolean; cachedAt: string | null };
|
||||
}> {
|
||||
const account = await this.accountsService.findById(accountId);
|
||||
if (!account) throw new NotFoundException('Broker account not found');
|
||||
|
||||
const result = await this.cacheService.getOrFetch(
|
||||
TBANK_CACHE_KEYS.portfolio,
|
||||
[accountId],
|
||||
async () => {
|
||||
const operationsClient = this.tbankClient.getServiceClient('OperationsService') as any;
|
||||
const [portfolio, positions] = await Promise.all([
|
||||
this.tbankClient.callUnary<
|
||||
{ accountId: string; currency: string },
|
||||
TBankPortfolioResponse
|
||||
>(
|
||||
'OperationsService/GetPortfolio',
|
||||
operationsClient.getPortfolio.bind(operationsClient),
|
||||
{ accountId, currency: 'RUB' },
|
||||
),
|
||||
this.tbankClient.callUnary<{ accountId: string }, TBankPositionsResponse>(
|
||||
'OperationsService/GetPositions',
|
||||
operationsClient.getPositions.bind(operationsClient),
|
||||
{ accountId },
|
||||
),
|
||||
]);
|
||||
|
||||
const instrumentMap = await this.buildInstrumentMap(portfolio);
|
||||
|
||||
return mapBrokerPortfolio({ account, portfolio, positions, instruments: instrumentMap });
|
||||
},
|
||||
'tbankPortfolioTtl',
|
||||
);
|
||||
|
||||
return {
|
||||
data: result.data,
|
||||
meta: { fromCache: result.fromCache, cachedAt: result.cachedAt },
|
||||
};
|
||||
}
|
||||
|
||||
private async buildInstrumentMap(
|
||||
portfolio: TBankPortfolioResponse,
|
||||
): Promise<Map<string, Partial<TBankInstrument>>> {
|
||||
const ids = Array.from(
|
||||
new Set(
|
||||
(portfolio.positions ?? []).map((position) => position.instrumentUid).filter(Boolean),
|
||||
),
|
||||
) as string[];
|
||||
const entries = await Promise.all(
|
||||
ids.map(async (id) => [id, await this.instrumentsService.findByInstrumentUid(id)] as const),
|
||||
);
|
||||
|
||||
return new Map(
|
||||
entries.filter((entry): entry is readonly [string, TBankInstrument] => entry[1] !== null),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,115 @@
|
||||
import { ServiceUnavailableException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { ChannelCredentials, ClientUnaryCall, Metadata, ServiceError, status } from '@grpc/grpc-js';
|
||||
import { mkdtempSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { TBankClientService } from './tbank-client.service';
|
||||
|
||||
describe('TBankClientService', () => {
|
||||
const unaryCall = {
|
||||
cancel: vi.fn(),
|
||||
getPeer: vi.fn(),
|
||||
getAuthContext: vi.fn(),
|
||||
} as unknown as ClientUnaryCall;
|
||||
|
||||
const config = {
|
||||
get: vi.fn((key: string, fallback?: unknown) => {
|
||||
const values: Record<string, unknown> = {
|
||||
'app.tbank.token': 'token-1',
|
||||
'app.tbank.appName': 'ksv741.moex-vibe',
|
||||
'app.tbank.rateLimitPerSecond': 5,
|
||||
'app.tbank.requestTimeoutMs': 10000,
|
||||
};
|
||||
|
||||
return values[key] ?? fallback;
|
||||
}),
|
||||
} as unknown as ConfigService;
|
||||
|
||||
it('builds redacted authorization metadata', () => {
|
||||
const service = new TBankClientService(config);
|
||||
const metadata = service.createMetadata();
|
||||
|
||||
expect(metadata.get('Authorization')).toEqual(['Bearer token-1']);
|
||||
expect(metadata.get('x-app-name')).toEqual(['ksv741.moex-vibe']);
|
||||
expect(service.redactMetadata(metadata)).toEqual({
|
||||
Authorization: '<redacted>',
|
||||
'x-app-name': 'ksv741.moex-vibe',
|
||||
});
|
||||
});
|
||||
|
||||
it('creates service clients from vendored proto contracts', () => {
|
||||
const service = new TBankClientService(config);
|
||||
|
||||
expect(() => service.getServiceClient('UsersService')).not.toThrow();
|
||||
});
|
||||
|
||||
it('creates grpc SSL credentials with configured custom CA certificate', () => {
|
||||
const caPath = join(mkdtempSync(join(tmpdir(), 'tbank-ca-')), 'root.pem');
|
||||
writeFileSync(caPath, '-----BEGIN CERTIFICATE-----\ntest-ca\n-----END CERTIFICATE-----\n');
|
||||
const createSslSpy = vi.spyOn(ChannelCredentials, 'createSsl');
|
||||
const caConfig = {
|
||||
get: vi.fn((key: string, fallback?: unknown) => {
|
||||
const values: Record<string, unknown> = {
|
||||
'app.tbank.token': 'token-1',
|
||||
'app.tbank.caCertPath': caPath,
|
||||
};
|
||||
|
||||
return values[key] ?? fallback;
|
||||
}),
|
||||
} as unknown as ConfigService;
|
||||
const service = new TBankClientService(caConfig);
|
||||
|
||||
service.getServiceClient('UsersService');
|
||||
|
||||
const [rootCerts] = createSslSpy.mock.calls.at(-1) ?? [];
|
||||
expect(Buffer.isBuffer(rootCerts)).toBe(true);
|
||||
expect((rootCerts as Buffer).toString('utf8')).toContain('test-ca');
|
||||
createSslSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('throws integration unavailable when token is missing', async () => {
|
||||
const missingConfig = {
|
||||
get: vi.fn((key: string, fallback?: unknown) =>
|
||||
key === 'app.tbank.token' ? '' : (fallback as unknown),
|
||||
),
|
||||
} as unknown as ConfigService;
|
||||
const service = new TBankClientService(missingConfig);
|
||||
|
||||
await expect(
|
||||
service.callUnary(
|
||||
'UsersService/GetAccounts',
|
||||
(_request, _metadata, _options, callback) => {
|
||||
callback(null, {});
|
||||
return unaryCall;
|
||||
},
|
||||
{},
|
||||
),
|
||||
).rejects.toThrow(ServiceUnavailableException);
|
||||
});
|
||||
|
||||
it('wraps grpc errors with status code and tracking id', async () => {
|
||||
const service = new TBankClientService(config);
|
||||
const error = Object.assign(new Error('Too many requests'), {
|
||||
code: status.RESOURCE_EXHAUSTED,
|
||||
details: 'Too many requests',
|
||||
metadata: new Metadata(),
|
||||
}) as ServiceError;
|
||||
error.metadata.set('x-tracking-id', 'tracking-1');
|
||||
|
||||
await expect(
|
||||
service.callUnary(
|
||||
'OperationsService/GetPortfolio',
|
||||
(_request, _metadata, _options, callback) => {
|
||||
callback(error, null);
|
||||
return unaryCall;
|
||||
},
|
||||
{},
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
response: expect.objectContaining({
|
||||
message: expect.stringContaining('T-Bank upstream error'),
|
||||
}),
|
||||
});
|
||||
});
|
||||
});
|
||||
191
apps/backend/src/modules/tbank/services/tbank-client.service.ts
Normal file
191
apps/backend/src/modules/tbank/services/tbank-client.service.ts
Normal file
@ -0,0 +1,191 @@
|
||||
import {
|
||||
BadGatewayException,
|
||||
Injectable,
|
||||
Logger,
|
||||
ServiceUnavailableException,
|
||||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import {
|
||||
CallOptions,
|
||||
ChannelCredentials,
|
||||
Client,
|
||||
ClientUnaryCall,
|
||||
loadPackageDefinition,
|
||||
Metadata,
|
||||
ServiceError,
|
||||
status,
|
||||
} from '@grpc/grpc-js';
|
||||
import { loadSync } from '@grpc/proto-loader';
|
||||
import PQueue from 'p-queue';
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { TBANK_PROTO_FILES, TBANK_PROTO_PACKAGE } from '../tbank.config';
|
||||
|
||||
type GrpcUnary<TRequest, TResponse> = (
|
||||
request: TRequest,
|
||||
metadata: Metadata,
|
||||
options: CallOptions,
|
||||
callback: (error: ServiceError | null, response: TResponse | null) => void,
|
||||
) => ClientUnaryCall;
|
||||
|
||||
type GrpcServiceConstructor = new (address: string, credentials: ChannelCredentials) => Client;
|
||||
|
||||
@Injectable()
|
||||
export class TBankClientService {
|
||||
private readonly logger = new Logger(TBankClientService.name);
|
||||
private readonly queue: PQueue;
|
||||
private readonly requestTimeoutMs: number;
|
||||
private readonly packageDefinition: ReturnType<typeof loadPackageDefinition>;
|
||||
private readonly clientCache = new Map<string, Client>();
|
||||
|
||||
constructor(private readonly configService: ConfigService) {
|
||||
this.requestTimeoutMs = this.configService.get<number>('app.tbank.requestTimeoutMs', 10000);
|
||||
this.queue = new PQueue({
|
||||
interval: 1000,
|
||||
intervalCap: this.configService.get<number>('app.tbank.rateLimitPerSecond', 5),
|
||||
});
|
||||
|
||||
const protoRoot = this.resolveProtoRoot();
|
||||
const definition = loadSync(
|
||||
Object.values(TBANK_PROTO_FILES).map((fileName) => join(protoRoot, fileName)),
|
||||
{
|
||||
includeDirs: [protoRoot],
|
||||
keepCase: false,
|
||||
longs: String,
|
||||
enums: String,
|
||||
defaults: true,
|
||||
oneofs: true,
|
||||
},
|
||||
);
|
||||
|
||||
this.packageDefinition = loadPackageDefinition(definition);
|
||||
}
|
||||
|
||||
private resolveProtoRoot(): string {
|
||||
const distProtoRoot = join(__dirname, '..', 'proto', 'contracts');
|
||||
if (existsSync(distProtoRoot)) return distProtoRoot;
|
||||
|
||||
return join(process.cwd(), 'src', 'modules', 'tbank', 'proto', 'contracts');
|
||||
}
|
||||
|
||||
createMetadata(): Metadata {
|
||||
const token = this.configService.get<string>('app.tbank.token', '');
|
||||
if (!token) {
|
||||
throw new ServiceUnavailableException('T-Bank integration is not configured');
|
||||
}
|
||||
|
||||
const metadata = new Metadata();
|
||||
metadata.set('Authorization', `Bearer ${token}`);
|
||||
|
||||
const appName = this.configService.get<string>('app.tbank.appName', '');
|
||||
if (appName) metadata.set('x-app-name', appName);
|
||||
|
||||
return metadata;
|
||||
}
|
||||
|
||||
redactMetadata(metadata: Metadata): Record<string, string> {
|
||||
const result: Record<string, string> = {};
|
||||
|
||||
for (const key of Object.keys(metadata.getMap())) {
|
||||
if (key.toLowerCase() === 'authorization') {
|
||||
result.Authorization = '<redacted>';
|
||||
continue;
|
||||
}
|
||||
|
||||
result[key] = String(metadata.get(key)[0]);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
getServiceClient(
|
||||
serviceName: 'UsersService' | 'OperationsService' | 'InstrumentsService',
|
||||
): Client {
|
||||
const cached = this.clientCache.get(serviceName);
|
||||
if (cached) return cached;
|
||||
|
||||
const namespace = this.resolveProtoNamespace();
|
||||
const ServiceCtor = namespace[serviceName] as GrpcServiceConstructor;
|
||||
const client = new ServiceCtor(
|
||||
this.configService.get<string>('app.tbank.baseUrl', 'invest-public-api.tbank.ru:443'),
|
||||
this.createChannelCredentials(),
|
||||
);
|
||||
|
||||
this.clientCache.set(serviceName, client);
|
||||
|
||||
return client;
|
||||
}
|
||||
|
||||
async callUnary<TRequest, TResponse>(
|
||||
label: string,
|
||||
method: GrpcUnary<TRequest, TResponse>,
|
||||
request: TRequest,
|
||||
): Promise<TResponse> {
|
||||
const metadata = this.createMetadata();
|
||||
const deadline = new Date(Date.now() + this.requestTimeoutMs);
|
||||
|
||||
return this.queue.add(
|
||||
() =>
|
||||
new Promise<TResponse>((resolve, reject) => {
|
||||
method(request, metadata, { deadline }, (error, response) => {
|
||||
if (error) {
|
||||
reject(this.mapGrpcError(label, error));
|
||||
return;
|
||||
}
|
||||
|
||||
resolve(response as TResponse);
|
||||
});
|
||||
}),
|
||||
) as Promise<TResponse>;
|
||||
}
|
||||
|
||||
private resolveProtoNamespace(): Record<string, unknown> {
|
||||
return TBANK_PROTO_PACKAGE.split('.').reduce<Record<string, unknown>>(
|
||||
(current, part) => {
|
||||
return current[part] as Record<string, unknown>;
|
||||
},
|
||||
this.packageDefinition as Record<string, unknown>,
|
||||
);
|
||||
}
|
||||
|
||||
private createChannelCredentials(): ChannelCredentials {
|
||||
const caCertPath = this.configService.get<string>('app.tbank.caCertPath', '');
|
||||
if (!caCertPath) return ChannelCredentials.createSsl();
|
||||
|
||||
try {
|
||||
return ChannelCredentials.createSsl(readFileSync(caCertPath));
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to read T-Bank CA certificate from ${caCertPath}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
throw new ServiceUnavailableException('T-Bank CA certificate is not readable');
|
||||
}
|
||||
}
|
||||
|
||||
private mapGrpcError(label: string, error: ServiceError): Error {
|
||||
const trackingId = error.metadata?.get('x-tracking-id')?.[0];
|
||||
const retryAfter = error.metadata?.get('x-ratelimit-reset')?.[0];
|
||||
const publicMessage =
|
||||
error.code === status.RESOURCE_EXHAUSTED
|
||||
? 'T-Bank upstream error: rate limit exceeded'
|
||||
: `T-Bank upstream error while calling ${label}`;
|
||||
|
||||
this.logger.warn(
|
||||
JSON.stringify({
|
||||
label,
|
||||
code: error.code,
|
||||
trackingId,
|
||||
retryAfter,
|
||||
message: error.message,
|
||||
}),
|
||||
);
|
||||
|
||||
return new BadGatewayException({
|
||||
message: publicMessage,
|
||||
trackingId: trackingId ? String(trackingId) : null,
|
||||
retryAfter: retryAfter ? String(retryAfter) : null,
|
||||
});
|
||||
}
|
||||
}
|
||||
41
apps/backend/src/modules/tbank/tbank.config.spec.ts
Normal file
41
apps/backend/src/modules/tbank/tbank.config.spec.ts
Normal file
@ -0,0 +1,41 @@
|
||||
import configuration from '../../config/configuration';
|
||||
|
||||
describe('T-Bank configuration', () => {
|
||||
const originalEnv = process.env;
|
||||
|
||||
beforeEach(() => {
|
||||
process.env = { ...originalEnv };
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
process.env = originalEnv;
|
||||
});
|
||||
|
||||
it('uses conservative defaults for T-Bank integration', () => {
|
||||
delete process.env.T_BANK_BASE_URL;
|
||||
delete process.env.T_BANK_RATE_LIMIT_PER_SECOND;
|
||||
delete process.env.CACHE_TBANK_PORTFOLIO_TTL;
|
||||
|
||||
const config = configuration();
|
||||
|
||||
expect(config.tbank.baseUrl).toBe('invest-public-api.tbank.ru:443');
|
||||
expect(config.tbank.rateLimitPerSecond).toBe(5);
|
||||
expect(config.cache.tbankPortfolioTtl).toBe(60);
|
||||
});
|
||||
|
||||
it('reads T-Bank token and TTL overrides from environment', () => {
|
||||
process.env.T_BANK_TOKEN = 'secret-token';
|
||||
process.env.T_BANK_BASE_URL = 'sandbox-invest-public-api.tbank.ru:443';
|
||||
process.env.T_BANK_CA_CERT_PATH = '/tmp/tbank-root-ca.pem';
|
||||
process.env.T_BANK_RATE_LIMIT_PER_SECOND = '2';
|
||||
process.env.CACHE_TBANK_ACCOUNTS_TTL = '120';
|
||||
|
||||
const config = configuration();
|
||||
|
||||
expect(config.tbank.token).toBe('secret-token');
|
||||
expect(config.tbank.baseUrl).toBe('sandbox-invest-public-api.tbank.ru:443');
|
||||
expect(config.tbank.caCertPath).toBe('/tmp/tbank-root-ca.pem');
|
||||
expect(config.tbank.rateLimitPerSecond).toBe(2);
|
||||
expect(config.cache.tbankAccountsTtl).toBe(120);
|
||||
});
|
||||
});
|
||||
22
apps/backend/src/modules/tbank/tbank.config.ts
Normal file
22
apps/backend/src/modules/tbank/tbank.config.ts
Normal file
@ -0,0 +1,22 @@
|
||||
export const TBANK_PROTO_PACKAGE = 'tinkoff.public.invest.api.contract.v1';
|
||||
|
||||
export const TBANK_PROTO_FILES = {
|
||||
users: 'users.proto',
|
||||
operations: 'operations.proto',
|
||||
instruments: 'instruments.proto',
|
||||
} as const;
|
||||
|
||||
export const TBANK_ACCOUNT_TYPES = {
|
||||
brokerage: 'ACCOUNT_TYPE_TINKOFF',
|
||||
iis: 'ACCOUNT_TYPE_TINKOFF_IIS',
|
||||
} as const;
|
||||
|
||||
export const TBANK_OPEN_ACCOUNT_STATUS = 'ACCOUNT_STATUS_OPEN';
|
||||
|
||||
export const TBANK_CACHE_KEYS = {
|
||||
accounts: 'tbank:accounts',
|
||||
portfolio: 'tbank:portfolio',
|
||||
positions: 'tbank:positions',
|
||||
operations: 'tbank:operations',
|
||||
instrument: 'tbank:instrument',
|
||||
} as const;
|
||||
61
apps/backend/src/modules/tbank/tbank.controller.spec.ts
Normal file
61
apps/backend/src/modules/tbank/tbank.controller.spec.ts
Normal file
@ -0,0 +1,61 @@
|
||||
import { ROLES_KEY } from '../auth/decorators/roles.decorator';
|
||||
import { ApiResponse } from '../../common/dto/api-response.dto';
|
||||
import { TBankController } from './tbank.controller';
|
||||
import { BrokerAccountsService } from './services/broker-accounts.service';
|
||||
import { BrokerOperationSyncService } from './services/broker-operation-sync.service';
|
||||
import { BrokerOperationsService } from './services/broker-operations.service';
|
||||
import { BrokerPortfolioService } from './services/broker-portfolio.service';
|
||||
|
||||
describe('TBankController', () => {
|
||||
const accounts = { findAll: vi.fn() } as unknown as BrokerAccountsService;
|
||||
const portfolio = { getPortfolio: vi.fn() } as unknown as BrokerPortfolioService;
|
||||
const operations = { getOperations: vi.fn() } as unknown as BrokerOperationsService;
|
||||
const sync = { syncAccount: vi.fn() } as unknown as BrokerOperationSyncService;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('allows authenticated users to access broker endpoints', () => {
|
||||
expect(Reflect.getMetadata(ROLES_KEY, TBankController)).toEqual(['user']);
|
||||
});
|
||||
|
||||
it('returns accounts in a single API envelope', async () => {
|
||||
vi.mocked(accounts.findAll).mockResolvedValueOnce({
|
||||
data: [
|
||||
{
|
||||
id: 'acc-1',
|
||||
type: 'brokerage',
|
||||
name: 'Broker',
|
||||
status: 'ACCOUNT_STATUS_OPEN',
|
||||
openedAt: null,
|
||||
accessLevel: null,
|
||||
},
|
||||
],
|
||||
meta: { fromCache: true, cachedAt: '2026-06-17T00:00:00.000Z' },
|
||||
});
|
||||
|
||||
const controller = new TBankController(accounts, portfolio, operations, sync);
|
||||
const response = await controller.getAccounts();
|
||||
|
||||
expect(response).toBeInstanceOf(ApiResponse);
|
||||
expect(response.data).toHaveLength(1);
|
||||
expect(response.meta).toEqual({ fromCache: true, cachedAt: '2026-06-17T00:00:00.000Z' });
|
||||
});
|
||||
|
||||
it('exposes a sync trigger for durable operation history', async () => {
|
||||
vi.mocked(sync.syncAccount).mockResolvedValueOnce({ upserted: 2 });
|
||||
|
||||
const controller = new TBankController(accounts, portfolio, operations, sync);
|
||||
const response = await controller.syncOperations('acc-1', {
|
||||
from: '2026-06-01T00:00:00.000Z',
|
||||
to: '2026-06-17T00:00:00.000Z',
|
||||
});
|
||||
|
||||
expect(sync.syncAccount).toHaveBeenCalledWith('acc-1', {
|
||||
from: '2026-06-01T00:00:00.000Z',
|
||||
to: '2026-06-17T00:00:00.000Z',
|
||||
});
|
||||
expect(response.data).toEqual({ upserted: 2 });
|
||||
});
|
||||
});
|
||||
67
apps/backend/src/modules/tbank/tbank.controller.ts
Normal file
67
apps/backend/src/modules/tbank/tbank.controller.ts
Normal file
@ -0,0 +1,67 @@
|
||||
import { Controller, Get, Param, Post, Query } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { ApiResponse } from '../../common/dto/api-response.dto';
|
||||
import { Roles } from '../auth/decorators/roles.decorator';
|
||||
import {
|
||||
BrokerAccountsEnvelopeDto,
|
||||
BrokerOperationSyncEnvelopeDto,
|
||||
BrokerOperationsEnvelopeDto,
|
||||
BrokerPortfolioEnvelopeDto,
|
||||
} from './dto/broker-envelope.dto';
|
||||
import { BrokerOperationQueryDto } from './dto/broker-operation-query.dto';
|
||||
import { BrokerOperationSyncQueryDto } from './dto/broker-operation-sync-query.dto';
|
||||
import { BrokerAccountsService } from './services/broker-accounts.service';
|
||||
import { BrokerOperationSyncService } from './services/broker-operation-sync.service';
|
||||
import { BrokerOperationsService } from './services/broker-operations.service';
|
||||
import { BrokerPortfolioService } from './services/broker-portfolio.service';
|
||||
|
||||
@ApiTags('Broker')
|
||||
@ApiBearerAuth()
|
||||
@Roles('user')
|
||||
@Controller('broker')
|
||||
export class TBankController {
|
||||
constructor(
|
||||
private readonly brokerAccountsService: BrokerAccountsService,
|
||||
private readonly brokerPortfolioService: BrokerPortfolioService,
|
||||
private readonly brokerOperationsService: BrokerOperationsService,
|
||||
private readonly brokerOperationSyncService: BrokerOperationSyncService,
|
||||
) {}
|
||||
|
||||
@Get('accounts')
|
||||
@ApiOperation({ summary: 'Get open T-Bank brokerage and IIS accounts' })
|
||||
@ApiOkResponse({ type: BrokerAccountsEnvelopeDto })
|
||||
async getAccounts() {
|
||||
const result = await this.brokerAccountsService.findAll();
|
||||
return new ApiResponse(result.data, result.meta.fromCache, result.meta.cachedAt);
|
||||
}
|
||||
|
||||
@Get('accounts/:accountId/portfolio')
|
||||
@ApiOperation({ summary: 'Get T-Bank broker account portfolio with cash and positions' })
|
||||
@ApiOkResponse({ type: BrokerPortfolioEnvelopeDto })
|
||||
async getPortfolio(@Param('accountId') accountId: string) {
|
||||
const result = await this.brokerPortfolioService.getPortfolio(accountId);
|
||||
return new ApiResponse(result.data, result.meta.fromCache, result.meta.cachedAt);
|
||||
}
|
||||
|
||||
@Get('accounts/:accountId/operations')
|
||||
@ApiOperation({ summary: 'Get paginated T-Bank broker account operations' })
|
||||
@ApiOkResponse({ type: BrokerOperationsEnvelopeDto })
|
||||
async getOperations(
|
||||
@Param('accountId') accountId: string,
|
||||
@Query() query: BrokerOperationQueryDto,
|
||||
) {
|
||||
const result = await this.brokerOperationsService.getOperations(accountId, query);
|
||||
return new ApiResponse(result.data, result.meta.fromCache, result.meta.cachedAt);
|
||||
}
|
||||
|
||||
@Post('accounts/:accountId/operations/sync')
|
||||
@ApiOperation({ summary: 'Synchronize T-Bank broker account operations into local history' })
|
||||
@ApiOkResponse({ type: BrokerOperationSyncEnvelopeDto })
|
||||
async syncOperations(
|
||||
@Param('accountId') accountId: string,
|
||||
@Query() query: BrokerOperationSyncQueryDto,
|
||||
) {
|
||||
const result = await this.brokerOperationSyncService.syncAccount(accountId, query);
|
||||
return new ApiResponse(result);
|
||||
}
|
||||
}
|
||||
29
apps/backend/src/modules/tbank/tbank.module.ts
Normal file
29
apps/backend/src/modules/tbank/tbank.module.ts
Normal file
@ -0,0 +1,29 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TBankController } from './tbank.controller';
|
||||
import { BrokerAccountsService } from './services/broker-accounts.service';
|
||||
import { BrokerInstrumentsService } from './services/broker-instruments.service';
|
||||
import { BrokerOperationSyncService } from './services/broker-operation-sync.service';
|
||||
import { BrokerOperationsService } from './services/broker-operations.service';
|
||||
import { BrokerPortfolioService } from './services/broker-portfolio.service';
|
||||
import { TBankClientService } from './services/tbank-client.service';
|
||||
|
||||
@Module({
|
||||
controllers: [TBankController],
|
||||
providers: [
|
||||
TBankClientService,
|
||||
BrokerAccountsService,
|
||||
BrokerInstrumentsService,
|
||||
BrokerPortfolioService,
|
||||
BrokerOperationsService,
|
||||
BrokerOperationSyncService,
|
||||
],
|
||||
exports: [
|
||||
TBankClientService,
|
||||
BrokerAccountsService,
|
||||
BrokerInstrumentsService,
|
||||
BrokerPortfolioService,
|
||||
BrokerOperationsService,
|
||||
BrokerOperationSyncService,
|
||||
],
|
||||
})
|
||||
export class TBankModule {}
|
||||
90
apps/backend/src/modules/tbank/types/broker.types.ts
Normal file
90
apps/backend/src/modules/tbank/types/broker.types.ts
Normal file
@ -0,0 +1,90 @@
|
||||
export type BrokerMoney = {
|
||||
currency: string;
|
||||
units: string;
|
||||
nano: number;
|
||||
value: number;
|
||||
};
|
||||
|
||||
export type BrokerAccount = {
|
||||
id: string;
|
||||
type: 'brokerage' | 'iis';
|
||||
name: string;
|
||||
status: string;
|
||||
openedAt: string | null;
|
||||
accessLevel: string | null;
|
||||
};
|
||||
|
||||
export type BrokerPosition = {
|
||||
figi: string | null;
|
||||
instrumentUid: string | null;
|
||||
positionUid: string | null;
|
||||
ticker: string | null;
|
||||
classCode: string | null;
|
||||
instrumentType: string | null;
|
||||
name: string | null;
|
||||
quantity: number | null;
|
||||
blockedLots: number | null;
|
||||
currentPrice: BrokerMoney | null;
|
||||
currentValue: BrokerMoney | null;
|
||||
averagePositionPrice: BrokerMoney | null;
|
||||
expectedYieldPercent: number | null;
|
||||
dailyYield: BrokerMoney | null;
|
||||
};
|
||||
|
||||
export type BrokerPortfolio = {
|
||||
account: BrokerAccount;
|
||||
totals: {
|
||||
shares: BrokerMoney | null;
|
||||
bonds: BrokerMoney | null;
|
||||
etf: BrokerMoney | null;
|
||||
currencies: BrokerMoney | null;
|
||||
futures: BrokerMoney | null;
|
||||
options: BrokerMoney | null;
|
||||
structuredProducts: BrokerMoney | null;
|
||||
dfa: BrokerMoney | null;
|
||||
portfolio: BrokerMoney | null;
|
||||
};
|
||||
yields: {
|
||||
expectedPercent: number | null;
|
||||
daily: BrokerMoney | null;
|
||||
dailyPercent: number | null;
|
||||
};
|
||||
cash: BrokerMoney[];
|
||||
blockedCash: BrokerMoney[];
|
||||
positions: BrokerPosition[];
|
||||
asOf: string;
|
||||
};
|
||||
|
||||
export type BrokerOperationCategory = 'trade' | 'income' | 'tax' | 'fee' | 'transfer' | 'other';
|
||||
|
||||
export type BrokerOperation = {
|
||||
cursor: string | null;
|
||||
accountId: string;
|
||||
id: string | null;
|
||||
parentOperationId: string | null;
|
||||
date: string | null;
|
||||
type: string;
|
||||
category: BrokerOperationCategory;
|
||||
description: string | null;
|
||||
state: string | null;
|
||||
instrumentUid: string | null;
|
||||
figi: string | null;
|
||||
ticker: string | null;
|
||||
classCode: string | null;
|
||||
instrumentType: string | null;
|
||||
payment: BrokerMoney | null;
|
||||
price: BrokerMoney | null;
|
||||
commission: BrokerMoney | null;
|
||||
yield: BrokerMoney | null;
|
||||
accruedInt: BrokerMoney | null;
|
||||
quantity: number | null;
|
||||
quantityDone: number | null;
|
||||
};
|
||||
|
||||
export type BrokerOperationsPage = {
|
||||
accountId: string;
|
||||
items: BrokerOperation[];
|
||||
nextCursor: string | null;
|
||||
hasNext: boolean;
|
||||
asOf: string;
|
||||
};
|
||||
148
apps/backend/src/modules/tbank/types/tbank-proto.types.ts
Normal file
148
apps/backend/src/modules/tbank/types/tbank-proto.types.ts
Normal file
@ -0,0 +1,148 @@
|
||||
export type TBankTimestamp = {
|
||||
seconds?: number | string;
|
||||
nanos?: number;
|
||||
};
|
||||
|
||||
export type TBankMoneyValue = {
|
||||
currency?: string;
|
||||
units?: number | string;
|
||||
nano?: number;
|
||||
};
|
||||
|
||||
export type TBankQuotation = {
|
||||
units?: number | string;
|
||||
nano?: number;
|
||||
};
|
||||
|
||||
export type TBankAccount = {
|
||||
id: string;
|
||||
type: string;
|
||||
name?: string;
|
||||
status: string;
|
||||
openedDate?: TBankTimestamp;
|
||||
closedDate?: TBankTimestamp;
|
||||
accessLevel?: string;
|
||||
};
|
||||
|
||||
export type TBankAccountsResponse = {
|
||||
accounts?: TBankAccount[];
|
||||
};
|
||||
|
||||
export type TBankPortfolioPosition = {
|
||||
figi?: string;
|
||||
instrumentType?: string;
|
||||
quantity?: TBankQuotation;
|
||||
averagePositionPrice?: TBankMoneyValue;
|
||||
expectedYield?: TBankQuotation;
|
||||
currentNkd?: TBankMoneyValue;
|
||||
currentPrice?: TBankMoneyValue;
|
||||
averagePositionPriceFifo?: TBankMoneyValue;
|
||||
blocked?: boolean;
|
||||
blockedLots?: TBankQuotation;
|
||||
positionUid?: string;
|
||||
instrumentUid?: string;
|
||||
expectedYieldFifo?: TBankQuotation;
|
||||
dailyYield?: TBankMoneyValue;
|
||||
ticker?: string;
|
||||
classCode?: string;
|
||||
};
|
||||
|
||||
export type TBankPortfolioResponse = {
|
||||
accountId?: string;
|
||||
totalAmountShares?: TBankMoneyValue;
|
||||
totalAmountBonds?: TBankMoneyValue;
|
||||
totalAmountEtf?: TBankMoneyValue;
|
||||
totalAmountCurrencies?: TBankMoneyValue;
|
||||
totalAmountFutures?: TBankMoneyValue;
|
||||
expectedYield?: TBankQuotation;
|
||||
positions?: TBankPortfolioPosition[];
|
||||
totalAmountOptions?: TBankMoneyValue;
|
||||
totalAmountSp?: TBankMoneyValue;
|
||||
totalAmountPortfolio?: TBankMoneyValue;
|
||||
dailyYield?: TBankMoneyValue;
|
||||
dailyYieldRelative?: TBankQuotation;
|
||||
totalAmountDfa?: TBankMoneyValue;
|
||||
};
|
||||
|
||||
export type TBankPositionsSecurity = {
|
||||
figi?: string;
|
||||
blocked?: string | number;
|
||||
balance?: string | number;
|
||||
positionUid?: string;
|
||||
instrumentUid?: string;
|
||||
ticker?: string;
|
||||
classCode?: string;
|
||||
exchangeBlocked?: boolean;
|
||||
instrumentType?: string;
|
||||
};
|
||||
|
||||
export type TBankPositionsResponse = {
|
||||
accountId?: string;
|
||||
money?: TBankMoneyValue[];
|
||||
blocked?: TBankMoneyValue[];
|
||||
securities?: TBankPositionsSecurity[];
|
||||
};
|
||||
|
||||
export type TBankOperationTrade = {
|
||||
num?: string;
|
||||
date?: TBankTimestamp;
|
||||
quantity?: string | number;
|
||||
price?: TBankMoneyValue;
|
||||
yield?: TBankMoneyValue;
|
||||
yieldRelative?: TBankQuotation;
|
||||
};
|
||||
|
||||
export type TBankOperationItem = {
|
||||
cursor?: string;
|
||||
brokerAccountId?: string;
|
||||
id?: string;
|
||||
parentOperationId?: string;
|
||||
name?: string;
|
||||
date?: TBankTimestamp;
|
||||
type?: string;
|
||||
description?: string;
|
||||
state?: string;
|
||||
instrumentUid?: string;
|
||||
figi?: string;
|
||||
instrumentType?: string;
|
||||
instrumentKind?: string;
|
||||
positionUid?: string;
|
||||
ticker?: string;
|
||||
classCode?: string;
|
||||
payment?: TBankMoneyValue;
|
||||
price?: TBankMoneyValue;
|
||||
commission?: TBankMoneyValue;
|
||||
yield?: TBankMoneyValue;
|
||||
yieldRelative?: TBankQuotation;
|
||||
accruedInt?: TBankMoneyValue;
|
||||
quantity?: string | number;
|
||||
quantityRest?: string | number;
|
||||
quantityDone?: string | number;
|
||||
tradesInfo?: { trades?: TBankOperationTrade[] };
|
||||
};
|
||||
|
||||
export type TBankOperationsByCursorResponse = {
|
||||
hasNext?: boolean;
|
||||
nextCursor?: string;
|
||||
items?: TBankOperationItem[];
|
||||
};
|
||||
|
||||
export type TBankInstrument = {
|
||||
figi?: string;
|
||||
ticker?: string;
|
||||
classCode?: string;
|
||||
isin?: string;
|
||||
lot?: number;
|
||||
currency?: string;
|
||||
name?: string;
|
||||
exchange?: string;
|
||||
instrumentType?: string;
|
||||
uid?: string;
|
||||
positionUid?: string;
|
||||
assetUid?: string;
|
||||
instrumentKind?: string;
|
||||
};
|
||||
|
||||
export type TBankInstrumentResponse = {
|
||||
instrument?: TBankInstrument;
|
||||
};
|
||||
30
apps/docs/docs/adr/ADR-011-tbank-invest-grpc.md
Normal file
30
apps/docs/docs/adr/ADR-011-tbank-invest-grpc.md
Normal file
@ -0,0 +1,30 @@
|
||||
# ADR-011: Интеграция с T-Bank Invest использует gRPC
|
||||
|
||||
**Статус:** Accepted
|
||||
|
||||
**Дата:** 2026-06-16
|
||||
|
||||
## Контекст
|
||||
|
||||
MoexVibe нужна read-only интеграция с T-Bank Invest для брокерских счетов и ИИС: текущие позиции,
|
||||
деньги на счёте и история операций. T-Bank предоставляет gRPC, REST-прокси, WebSocket и официальный
|
||||
JS SDK.
|
||||
|
||||
## Решение
|
||||
|
||||
Использовать тонкую backend gRPC-интеграцию на основе официальных proto-контрактов. REST оставить
|
||||
для ручной диагностики, а официальный JS SDK не делать прямой зависимостью первого варианта.
|
||||
|
||||
## Обоснование
|
||||
|
||||
- gRPC — основной протокол T-Bank Invest API.
|
||||
- Unary methods покрывают счета, портфель, позиции, операции и инструменты.
|
||||
- Stream methods можно добавить позже без изменения публичного MoexVibe API.
|
||||
- Собственный транспортный слой позволяет контролировать rate limiting, cache TTL, redaction
|
||||
metadata, test doubles и будущий переход от одного server token к per-user token storage.
|
||||
|
||||
## Последствия
|
||||
|
||||
- Backend хранит vendored proto-контракты T-Bank Invest.
|
||||
- Backend владеет T-Bank-specific rate limits и cache TTL.
|
||||
- Интеграция остаётся read-only, пока отдельный ADR не разрешит торговые операции и заявки.
|
||||
@ -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-разделе.
|
||||
|
||||
@ -37,7 +37,7 @@ flowchart LR
|
||||
|
||||
```typescript
|
||||
getOrFetch<T>(
|
||||
keyPrefix: string, // 'marketdata' | 'history' | 'candles' | 'search' | 'bond' | 'dividends'
|
||||
keyPrefix: string, // 'marketdata' | 'history' | 'candles' | 'search' | 'bond' | 'dividends' | 'tbank:*'
|
||||
keyParts: string[], // ['shares', 'SBER'] | ['SBER', '2026-06-13', '2026-06-14']
|
||||
fetchFn: () => Promise<T>,
|
||||
ttlConfigKey: string, // 'marketDataTtl' | 'historyTtl' | etc.
|
||||
@ -78,3 +78,14 @@ export class CacheModule {}
|
||||
| Спецификация инструмента | `securityTtl` | 86400s (24 ч) | `CACHE_SECURITY_TTL` |
|
||||
| Поиск | `searchTtl` | 3600s (1 ч) | `CACHE_SEARCH_TTL` |
|
||||
| Дивиденды | `dividendsTtl` | 86400s (24 ч) | `CACHE_DIVIDENDS_TTL` |
|
||||
| Счета T-Bank | `tbankAccountsTtl` | 3600s (1 ч) | `CACHE_TBANK_ACCOUNTS_TTL` |
|
||||
| Портфель T-Bank | `tbankPortfolioTtl` | 60s (1 мин) | `CACHE_TBANK_PORTFOLIO_TTL` |
|
||||
| Операции T-Bank | `tbankOperationsTtl` | 300s (5 мин) | `CACHE_TBANK_OPERATIONS_TTL` |
|
||||
| Инструменты T-Bank | `tbankInstrumentTtl` | 86400s (24 ч) | `CACHE_TBANK_INSTRUMENT_TTL` |
|
||||
|
||||
## T-Bank cache
|
||||
|
||||
`TBankModule` использует те же механики `CacheService`, но с отдельными key prefixes
|
||||
`tbank:accounts`, `tbank:portfolio`, `tbank:positions`, `tbank:operations` и `tbank:instrument`.
|
||||
Это позволяет держать агрессивно короткий TTL для текущего портфеля и более длинный TTL для
|
||||
справочных данных инструментов.
|
||||
|
||||
@ -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),
|
||||
},
|
||||
}));
|
||||
```
|
||||
|
||||
@ -8,7 +8,7 @@
|
||||
flowchart TB
|
||||
AppModule["AppModule"]
|
||||
GlobalModules["Глобальные модули<br/>ConfigModule<br/>PrismaModule<br/>CacheModule<br/>MoexClientModule"]
|
||||
FeatureModules["Feature-модули<br/>HealthModule<br/>AuthModule<br/>PortfolioModule<br/>SecuritiesModule<br/>SharesModule<br/>BondsModule<br/>CandlesModule"]
|
||||
FeatureModules["Feature-модули<br/>HealthModule<br/>AuthModule<br/>PortfolioModule<br/>SecuritiesModule<br/>SharesModule<br/>BondsModule<br/>CandlesModule<br/>TBankModule"]
|
||||
|
||||
AppModule --> GlobalModules
|
||||
AppModule --> FeatureModules
|
||||
@ -21,10 +21,12 @@ flowchart TB
|
||||
AuthModule["AuthModule"]
|
||||
PortfolioModule["PortfolioModule"]
|
||||
MarketModules["SecuritiesModule<br/>SharesModule<br/>BondsModule<br/>CandlesModule"]
|
||||
TBankModule["TBankModule"]
|
||||
|
||||
PrismaService["PrismaService"]
|
||||
CacheService["CacheService"]
|
||||
MoexClientService["MoexClientService"]
|
||||
TBankClientService["TBankClientService"]
|
||||
|
||||
PrismaModule --> PrismaService
|
||||
CacheModule --> CacheService
|
||||
@ -37,6 +39,10 @@ flowchart TB
|
||||
|
||||
MarketModules --> CacheService
|
||||
MarketModules --> MoexClientService
|
||||
|
||||
TBankModule --> CacheService
|
||||
TBankModule --> PrismaService
|
||||
TBankModule --> TBankClientService
|
||||
```
|
||||
|
||||
## Список модулей
|
||||
@ -53,6 +59,7 @@ flowchart TB
|
||||
| `BondsModule` | Нет | `modules/bonds/` | Облигации |
|
||||
| `CandlesModule` | Нет | `modules/candles/` | Свечи OHLCV |
|
||||
| `PortfolioModule` | Нет | `modules/portfolio/` | Пользовательские портфели и аналитика |
|
||||
| `TBankModule` | Нет | `modules/tbank/` | Read-only брокерские портфели T-Bank Invest |
|
||||
|
||||
### PrismaModule
|
||||
|
||||
@ -131,3 +138,18 @@ flowchart TB
|
||||
- Обогащение позиций текущими ценами из MOEX
|
||||
- Расчёт summary, PnL и долей портфеля
|
||||
- Все endpoints защищены JWT
|
||||
|
||||
### TBankModule
|
||||
|
||||
Read-only интеграция с T-Bank Invest для брокерских счетов и ИИС.
|
||||
|
||||
- `TBankClientService` создаёт gRPC clients по vendored proto-контрактам и добавляет metadata
|
||||
`Authorization` + `x-app-name`
|
||||
- `BrokerAccountsService` фильтрует только открытые брокерские счета и ИИС
|
||||
- `BrokerPortfolioService` объединяет портфель, позиции, cash и метаданные инструментов
|
||||
- `BrokerOperationsService` отдаёт cursor-paginated историю операций
|
||||
- `BrokerOperationSyncService` сохраняет историю операций в отдельные Prisma-таблицы
|
||||
- Broker endpoints требуют роль `user`; текущая single-user версия использует один server-side
|
||||
`T_BANK_TOKEN`
|
||||
- Direct-read endpoints используют `CacheService` с T-Bank TTL и не записывают данные в ручной
|
||||
`PortfolioModule`
|
||||
|
||||
@ -3,6 +3,12 @@
|
||||
`PortfolioModule` позволяет пользователям создавать и вести виртуальные инвестиционные портфели для
|
||||
аналитики и отслеживания позиций.
|
||||
|
||||
## Ручные портфели и брокерские портфели
|
||||
|
||||
`PortfolioModule` остаётся доменом ручных виртуальных портфелей. Брокерские счета T-Bank Invest
|
||||
экспортируются отдельным `TBankModule` под `/api/v1/broker/*` и не сохраняются как записи
|
||||
`Portfolio`.
|
||||
|
||||
## Обзор
|
||||
|
||||
- **Backend:** `PortfolioModule` (`apps/backend/src/modules/portfolio/`)
|
||||
|
||||
81
apps/docs/docs/backend/tbank-invest.md
Normal file
81
apps/docs/docs/backend/tbank-invest.md
Normal file
@ -0,0 +1,81 @@
|
||||
# T-Bank Invest
|
||||
|
||||
`TBankModule` — read-only интеграция backend с T-Bank Invest API. Frontend не обращается к
|
||||
T-Bank напрямую: он использует endpoints MoexVibe под `/api/v1/broker`.
|
||||
|
||||
## Область поддержки
|
||||
|
||||
Первая версия показывает только открытые брокерские счета и ИИС:
|
||||
|
||||
- `ACCOUNT_TYPE_TINKOFF`
|
||||
- `ACCOUNT_TYPE_TINKOFF_IIS`
|
||||
|
||||
Инвесткопилка, счета ЦФА, дебетовые счета, накопительные счета и счета фондов денежного рынка
|
||||
игнорируются.
|
||||
|
||||
## Протокол
|
||||
|
||||
MoexVibe использует gRPC endpoint `invest-public-api.tbank.ru:443`. REST-прокси T-Bank считается
|
||||
инструментом для ручной диагностики, а не основным протоколом интеграции.
|
||||
|
||||
Backend отправляет metadata:
|
||||
|
||||
```text
|
||||
Authorization: Bearer <T_BANK_TOKEN>
|
||||
x-app-name: ksv741.moex-vibe
|
||||
```
|
||||
|
||||
Токен читается из env-переменных backend и не возвращается во frontend, Swagger responses или логи.
|
||||
|
||||
Если gRPC-запросы падают с `self-signed certificate in certificate chain`, значит Node.js не доверяет
|
||||
root CA, которым локальная сеть или proxy подписывает TLS-цепочку. В этом случае нужно экспортировать
|
||||
доверенный root certificate в PEM-файл и указать путь в `T_BANK_CA_CERT_PATH`. Проверку TLS отключать
|
||||
не нужно.
|
||||
|
||||
## Backend endpoints
|
||||
|
||||
Все endpoints защищены JWT, требуют роль `user` и возвращают стандартную оболочку `{ data, meta }`.
|
||||
|
||||
| Endpoint | Описание |
|
||||
|---|---|
|
||||
| `GET /api/v1/broker/accounts` | Открытые брокерские счета и ИИС |
|
||||
| `GET /api/v1/broker/accounts/:accountId/portfolio` | Итоги портфеля, позиции, деньги и заблокированные деньги |
|
||||
| `GET /api/v1/broker/accounts/:accountId/operations` | История операций с cursor pagination |
|
||||
| `POST /api/v1/broker/accounts/:accountId/operations/sync` | Синхронизация истории операций в локальные Prisma-таблицы |
|
||||
|
||||
## Методы T-Bank
|
||||
|
||||
| Задача | Метод T-Bank |
|
||||
|---|---|
|
||||
| Счета | `UsersService/GetAccounts` |
|
||||
| Итоги портфеля | `OperationsService/GetPortfolio` |
|
||||
| Деньги и settled-позиции | `OperationsService/GetPositions` |
|
||||
| История операций | `OperationsService/GetOperationsByCursor` |
|
||||
| Метаданные инструментов | `InstrumentsService/GetInstrumentBy` |
|
||||
|
||||
## Кеширование и синхронизация
|
||||
|
||||
Direct-read endpoints используют короткий in-memory cache, чтобы не спамить T-Bank API при
|
||||
переключении страниц и повторных запросах:
|
||||
|
||||
- счета: `CACHE_TBANK_ACCOUNTS_TTL`
|
||||
- портфель: `CACHE_TBANK_PORTFOLIO_TTL`
|
||||
- страницы операций: `CACHE_TBANK_OPERATIONS_TTL`
|
||||
- метаданные инструментов: `CACHE_TBANK_INSTRUMENT_TTL`
|
||||
|
||||
Для долговременной истории операций есть отдельные таблицы Prisma:
|
||||
|
||||
- `BrokerOperation` — нормализованная операция и сырой JSON payload;
|
||||
- `BrokerOperationSyncState` — состояние последней синхронизации по брокерскому счёту.
|
||||
|
||||
Синхронизация запускается явно через endpoint `POST .../operations/sync` с query параметрами `from`
|
||||
и `to` в ISO-8601 формате.
|
||||
|
||||
Эти таблицы не связаны с ручными портфелями `Portfolio` и `Position`.
|
||||
|
||||
## Безопасность
|
||||
|
||||
Текущая версия рассчитана на single-user сценарий: используется один server-side `T_BANK_TOKEN`, а
|
||||
broker endpoints доступны аутентифицированным пользователям с ролью `user`. Перед multi-user режимом
|
||||
нужно добавить зашифрованное хранение пользовательских T-Bank токенов и привязку каждого брокерского
|
||||
счёта к владельцу.
|
||||
@ -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',
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
27
apps/frontend/src/api/broker.test.ts
Normal file
27
apps/frontend/src/api/broker.test.ts
Normal file
@ -0,0 +1,27 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { getBrokerOperations } from './broker';
|
||||
|
||||
describe('broker api', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('serializes operations query parameters', async () => {
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: {
|
||||
data: { accountId: 'acc-1', items: [], nextCursor: null, hasNext: false, asOf: 'now' },
|
||||
meta: { fromCache: false, cachedAt: null },
|
||||
},
|
||||
}),
|
||||
} as Response);
|
||||
|
||||
await getBrokerOperations('acc-1', { cursor: 'c1', limit: 50 });
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining('/api/v1/broker/accounts/acc-1/operations?cursor=c1&limit=50'),
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
});
|
||||
51
apps/frontend/src/api/broker.ts
Normal file
51
apps/frontend/src/api/broker.ts
Normal file
@ -0,0 +1,51 @@
|
||||
import { request } from './client';
|
||||
import type {
|
||||
ApiResponseMeta,
|
||||
BrokerAccount,
|
||||
BrokerOperationsPage,
|
||||
BrokerPortfolio,
|
||||
} from './responses';
|
||||
|
||||
export type BrokerOperationQuery = {
|
||||
from?: string;
|
||||
to?: string;
|
||||
cursor?: string;
|
||||
limit?: number;
|
||||
instrumentId?: string;
|
||||
operationTypes?: string;
|
||||
state?: string;
|
||||
};
|
||||
|
||||
export function getBrokerAccounts(): Promise<{
|
||||
data: BrokerAccount[];
|
||||
meta: ApiResponseMeta;
|
||||
}> {
|
||||
return request<BrokerAccount[]>('/api/v1/broker/accounts');
|
||||
}
|
||||
|
||||
export function getBrokerPortfolio(accountId: string): Promise<{
|
||||
data: BrokerPortfolio;
|
||||
meta: ApiResponseMeta;
|
||||
}> {
|
||||
return request<BrokerPortfolio>(
|
||||
`/api/v1/broker/accounts/${encodeURIComponent(accountId)}/portfolio`,
|
||||
);
|
||||
}
|
||||
|
||||
export function getBrokerOperations(
|
||||
accountId: string,
|
||||
query: BrokerOperationQuery = {},
|
||||
): Promise<{ data: BrokerOperationsPage; meta: ApiResponseMeta }> {
|
||||
return request<BrokerOperationsPage>(
|
||||
`/api/v1/broker/accounts/${encodeURIComponent(accountId)}/operations`,
|
||||
{
|
||||
from: query.from,
|
||||
to: query.to,
|
||||
cursor: query.cursor,
|
||||
limit: query.limit ? String(query.limit) : undefined,
|
||||
instrumentId: query.instrumentId,
|
||||
operationTypes: query.operationTypes,
|
||||
state: query.state,
|
||||
},
|
||||
);
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -40,14 +40,31 @@ async function refreshTokens(): Promise<boolean> {
|
||||
credentials: 'include',
|
||||
});
|
||||
if (!res.ok) return false;
|
||||
const json: ApiEnvelope<{ data: AuthResponse; meta: ApiResponseMeta }> = await res.json();
|
||||
accessToken = json.data.data.accessToken;
|
||||
const json = await res.json();
|
||||
accessToken = normalizeEnvelope<AuthResponse>(json).data.accessToken;
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeEnvelope<T>(json: unknown): { data: T; meta: ApiResponseMeta } {
|
||||
const envelope = json as ApiEnvelope<T | { data: T; meta: ApiResponseMeta }>;
|
||||
if (
|
||||
envelope.data &&
|
||||
typeof envelope.data === 'object' &&
|
||||
'data' in envelope.data &&
|
||||
'meta' in envelope.data
|
||||
) {
|
||||
return envelope.data as { data: T; meta: ApiResponseMeta };
|
||||
}
|
||||
|
||||
return {
|
||||
data: envelope.data as T,
|
||||
meta: envelope.meta,
|
||||
};
|
||||
}
|
||||
|
||||
async function handleUnauthorized(): Promise<boolean> {
|
||||
if (isRefreshing && refreshPromise) {
|
||||
return refreshPromise;
|
||||
@ -114,8 +131,8 @@ export async function request<T>(
|
||||
throw new Error(`Ошибка API: ${res.status} ${res.statusText}${text ? ` - ${text}` : ''}`);
|
||||
}
|
||||
|
||||
const json: ApiEnvelope<{ data: T; meta: ApiResponseMeta }> = await res.json();
|
||||
return json.data;
|
||||
const json = await res.json();
|
||||
return normalizeEnvelope<T>(json);
|
||||
}
|
||||
|
||||
export function getHealth(): Promise<{ data: HealthResponse; meta: ApiResponseMeta }> {
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -383,6 +383,74 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
'/api/v1/broker/accounts': {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** Get open T-Bank brokerage and IIS accounts */
|
||||
get: operations['TBankController_getAccounts'];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
'/api/v1/broker/accounts/{accountId}/portfolio': {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** Get T-Bank broker account portfolio with cash and positions */
|
||||
get: operations['TBankController_getPortfolio'];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
'/api/v1/broker/accounts/{accountId}/operations': {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** Get paginated T-Bank broker account operations */
|
||||
get: operations['TBankController_getOperations'];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
'/api/v1/broker/accounts/{accountId}/operations/sync': {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/** Synchronize T-Bank broker account operations into local history */
|
||||
post: operations['TBankController_syncOperations'];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
}
|
||||
export type webhooks = Record<string, never>;
|
||||
export interface components {
|
||||
@ -689,6 +757,117 @@ export interface components {
|
||||
data: components['schemas']['AnalyticsResponseDto'];
|
||||
meta: components['schemas']['PortfolioResponseMetaDto'];
|
||||
};
|
||||
BrokerAccountResponseDto: {
|
||||
id: string;
|
||||
/** @enum {string} */
|
||||
type: 'brokerage' | 'iis';
|
||||
name: string;
|
||||
status: string;
|
||||
openedAt: Record<string, never> | null;
|
||||
accessLevel: Record<string, never> | null;
|
||||
};
|
||||
BrokerResponseMetaDto: {
|
||||
cachedAt: Record<string, never> | null;
|
||||
fromCache: boolean;
|
||||
};
|
||||
BrokerAccountsEnvelopeDto: {
|
||||
data: components['schemas']['BrokerAccountResponseDto'][];
|
||||
meta: components['schemas']['BrokerResponseMetaDto'];
|
||||
};
|
||||
BrokerMoneyDto: {
|
||||
currency: string;
|
||||
units: string;
|
||||
nano: number;
|
||||
value: number;
|
||||
};
|
||||
BrokerPortfolioTotalsDto: {
|
||||
shares: components['schemas']['BrokerMoneyDto'] | null;
|
||||
bonds: components['schemas']['BrokerMoneyDto'] | null;
|
||||
etf: components['schemas']['BrokerMoneyDto'] | null;
|
||||
currencies: components['schemas']['BrokerMoneyDto'] | null;
|
||||
futures: components['schemas']['BrokerMoneyDto'] | null;
|
||||
options: components['schemas']['BrokerMoneyDto'] | null;
|
||||
structuredProducts: components['schemas']['BrokerMoneyDto'] | null;
|
||||
dfa: components['schemas']['BrokerMoneyDto'] | null;
|
||||
portfolio: components['schemas']['BrokerMoneyDto'] | null;
|
||||
};
|
||||
BrokerPortfolioYieldsDto: {
|
||||
expectedPercent: Record<string, never> | null;
|
||||
daily: components['schemas']['BrokerMoneyDto'] | null;
|
||||
dailyPercent: Record<string, never> | null;
|
||||
};
|
||||
BrokerPositionResponseDto: {
|
||||
figi: Record<string, never> | null;
|
||||
instrumentUid: Record<string, never> | null;
|
||||
positionUid: Record<string, never> | null;
|
||||
ticker: Record<string, never> | null;
|
||||
classCode: Record<string, never> | null;
|
||||
instrumentType: Record<string, never> | null;
|
||||
name: Record<string, never> | null;
|
||||
quantity: Record<string, never> | null;
|
||||
blockedLots: Record<string, never> | null;
|
||||
currentPrice: components['schemas']['BrokerMoneyDto'] | null;
|
||||
currentValue: components['schemas']['BrokerMoneyDto'] | null;
|
||||
averagePositionPrice: components['schemas']['BrokerMoneyDto'] | null;
|
||||
expectedYieldPercent: Record<string, never> | null;
|
||||
dailyYield: components['schemas']['BrokerMoneyDto'] | null;
|
||||
};
|
||||
BrokerPortfolioResponseDto: {
|
||||
account: components['schemas']['BrokerAccountResponseDto'];
|
||||
totals: components['schemas']['BrokerPortfolioTotalsDto'];
|
||||
yields: components['schemas']['BrokerPortfolioYieldsDto'];
|
||||
cash: components['schemas']['BrokerMoneyDto'][];
|
||||
blockedCash: components['schemas']['BrokerMoneyDto'][];
|
||||
positions: components['schemas']['BrokerPositionResponseDto'][];
|
||||
asOf: string;
|
||||
};
|
||||
BrokerPortfolioEnvelopeDto: {
|
||||
data: components['schemas']['BrokerPortfolioResponseDto'];
|
||||
meta: components['schemas']['BrokerResponseMetaDto'];
|
||||
};
|
||||
BrokerOperationResponseDto: {
|
||||
cursor: Record<string, never> | null;
|
||||
accountId: string;
|
||||
id: Record<string, never> | null;
|
||||
parentOperationId: Record<string, never> | null;
|
||||
date: Record<string, never> | null;
|
||||
type: string;
|
||||
/** @enum {string} */
|
||||
category: 'trade' | 'income' | 'tax' | 'fee' | 'transfer' | 'other';
|
||||
description: Record<string, never> | null;
|
||||
state: Record<string, never> | null;
|
||||
instrumentUid: Record<string, never> | null;
|
||||
figi: Record<string, never> | null;
|
||||
ticker: Record<string, never> | null;
|
||||
classCode: Record<string, never> | null;
|
||||
instrumentType: Record<string, never> | null;
|
||||
payment: components['schemas']['BrokerMoneyDto'] | null;
|
||||
price: components['schemas']['BrokerMoneyDto'] | null;
|
||||
commission: components['schemas']['BrokerMoneyDto'] | null;
|
||||
yield: components['schemas']['BrokerMoneyDto'] | null;
|
||||
accruedInt: components['schemas']['BrokerMoneyDto'] | null;
|
||||
quantity: Record<string, never> | null;
|
||||
quantityDone: Record<string, never> | null;
|
||||
};
|
||||
BrokerOperationsPageResponseDto: {
|
||||
accountId: string;
|
||||
items: components['schemas']['BrokerOperationResponseDto'][];
|
||||
nextCursor: Record<string, never> | null;
|
||||
hasNext: boolean;
|
||||
asOf: string;
|
||||
};
|
||||
BrokerOperationsEnvelopeDto: {
|
||||
data: components['schemas']['BrokerOperationsPageResponseDto'];
|
||||
meta: components['schemas']['BrokerResponseMetaDto'];
|
||||
};
|
||||
BrokerOperationSyncResponseDto: {
|
||||
/** @example 42 */
|
||||
upserted: number;
|
||||
};
|
||||
BrokerOperationSyncEnvelopeDto: {
|
||||
data: components['schemas']['BrokerOperationSyncResponseDto'];
|
||||
meta: components['schemas']['BrokerResponseMetaDto'];
|
||||
};
|
||||
};
|
||||
responses: never;
|
||||
parameters: never;
|
||||
@ -1300,4 +1479,97 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
TBankController_getAccounts: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
'application/json': components['schemas']['BrokerAccountsEnvelopeDto'];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
TBankController_getPortfolio: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
accountId: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
'application/json': components['schemas']['BrokerPortfolioEnvelopeDto'];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
TBankController_getOperations: {
|
||||
parameters: {
|
||||
query?: {
|
||||
from?: string;
|
||||
to?: string;
|
||||
cursor?: string;
|
||||
limit?: number;
|
||||
instrumentId?: string;
|
||||
operationTypes?: string;
|
||||
state?: string;
|
||||
};
|
||||
header?: never;
|
||||
path: {
|
||||
accountId: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
'application/json': components['schemas']['BrokerOperationsEnvelopeDto'];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
TBankController_syncOperations: {
|
||||
parameters: {
|
||||
query: {
|
||||
from: string;
|
||||
to: string;
|
||||
};
|
||||
header?: never;
|
||||
path: {
|
||||
accountId: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
'application/json': components['schemas']['BrokerOperationSyncEnvelopeDto'];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@ -20,6 +20,7 @@ export function Layout() {
|
||||
padding: '12px 24px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
flexWrap: 'wrap',
|
||||
gap: 24,
|
||||
}}
|
||||
>
|
||||
@ -34,7 +35,9 @@ export function Layout() {
|
||||
>
|
||||
MoexVibe
|
||||
</Link>
|
||||
<div style={{ flex: '1 1 280px', minWidth: 220, maxWidth: 420 }}>
|
||||
<SearchBar />
|
||||
</div>
|
||||
<Link
|
||||
to="/portfolios"
|
||||
style={{
|
||||
@ -46,6 +49,17 @@ export function Layout() {
|
||||
>
|
||||
Портфели
|
||||
</Link>
|
||||
<Link
|
||||
to="/broker"
|
||||
style={{
|
||||
fontSize: 14,
|
||||
color: 'var(--color-text)',
|
||||
textDecoration: 'none',
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
Брокер
|
||||
</Link>
|
||||
<Link
|
||||
to="/screener"
|
||||
style={{
|
||||
@ -58,7 +72,15 @@ export function Layout() {
|
||||
Скринер
|
||||
</Link>
|
||||
|
||||
<div style={{ marginLeft: 'auto', display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<div
|
||||
style={{
|
||||
marginLeft: 'auto',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
flexWrap: 'wrap',
|
||||
}}
|
||||
>
|
||||
{isAuthenticated ? (
|
||||
<>
|
||||
<Link
|
||||
@ -104,7 +126,15 @@ export function Layout() {
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
<main style={{ flex: 1, padding: 24, maxWidth: 1200, width: '100%', margin: '0 auto' }}>
|
||||
<main
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: 'clamp(16px, 4vw, 24px)',
|
||||
maxWidth: 1200,
|
||||
width: '100%',
|
||||
margin: '0 auto',
|
||||
}}
|
||||
>
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@ -38,7 +38,7 @@ export function PortfolioCard({ portfolio }: { portfolio: Portfolio }) {
|
||||
<h3 style={{ margin: 0, fontSize: 16, fontWeight: 600, flex: 1 }}>{portfolio.name}</h3>
|
||||
<div style={{ textAlign: 'right' }}>
|
||||
<div style={{ fontSize: 20, fontWeight: 700, lineHeight: 1.2 }}>
|
||||
{portfolio.totalValue.toLocaleString('ru-RU', {
|
||||
{portfolio.totalValue?.toLocaleString('ru-RU', {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
})}
|
||||
|
||||
@ -19,7 +19,7 @@ export function PortfolioSummary({ portfolio }: { portfolio: PortfolioDetail })
|
||||
Общая стоимость
|
||||
</div>
|
||||
<div style={{ fontSize: 24, fontWeight: 700 }}>
|
||||
{portfolio.totalValue.toLocaleString('ru-RU', {
|
||||
{portfolio.totalValue?.toLocaleString('ru-RU', {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
})}
|
||||
|
||||
41
apps/frontend/src/hooks/useBrokerAccounts.test.tsx
Normal file
41
apps/frontend/src/hooks/useBrokerAccounts.test.tsx
Normal file
@ -0,0 +1,41 @@
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { renderHook, waitFor } from '@testing-library/react';
|
||||
import { type ReactNode } from 'react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { getBrokerAccounts } from '../api/broker';
|
||||
import { useBrokerAccounts } from './useBrokerAccounts';
|
||||
|
||||
vi.mock('../api/broker', () => ({
|
||||
getBrokerAccounts: vi.fn(),
|
||||
}));
|
||||
|
||||
function createWrapper() {
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
|
||||
return function Wrapper({ children }: { children: ReactNode }) {
|
||||
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
|
||||
};
|
||||
}
|
||||
|
||||
describe('useBrokerAccounts', () => {
|
||||
it('returns broker accounts from API', async () => {
|
||||
vi.mocked(getBrokerAccounts).mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
id: 'acc-1',
|
||||
type: 'brokerage',
|
||||
name: 'Broker',
|
||||
status: 'ACCOUNT_STATUS_OPEN',
|
||||
openedAt: null,
|
||||
accessLevel: null,
|
||||
},
|
||||
],
|
||||
meta: { fromCache: false, cachedAt: null },
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useBrokerAccounts(), { wrapper: createWrapper() });
|
||||
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||
expect(result.current.data?.[0].name).toBe('Broker');
|
||||
});
|
||||
});
|
||||
13
apps/frontend/src/hooks/useBrokerAccounts.ts
Normal file
13
apps/frontend/src/hooks/useBrokerAccounts.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { getBrokerAccounts } from '../api/broker';
|
||||
import type { BrokerAccount } from '../api/responses';
|
||||
|
||||
export function useBrokerAccounts() {
|
||||
return useQuery<BrokerAccount[]>({
|
||||
queryKey: ['broker', 'accounts'],
|
||||
queryFn: async () => (await getBrokerAccounts()).data,
|
||||
staleTime: 3_600_000,
|
||||
retry: 2,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
}
|
||||
17
apps/frontend/src/hooks/useBrokerOperations.ts
Normal file
17
apps/frontend/src/hooks/useBrokerOperations.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { getBrokerOperations, type BrokerOperationQuery } from '../api/broker';
|
||||
import type { BrokerOperationsPage } from '../api/responses';
|
||||
|
||||
export function useBrokerOperations(
|
||||
accountId: string | undefined,
|
||||
query: BrokerOperationQuery = {},
|
||||
) {
|
||||
return useQuery<BrokerOperationsPage>({
|
||||
queryKey: ['broker', 'operations', accountId, query],
|
||||
enabled: Boolean(accountId),
|
||||
queryFn: async () => (await getBrokerOperations(accountId!, query)).data,
|
||||
staleTime: 300_000,
|
||||
retry: 2,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
}
|
||||
14
apps/frontend/src/hooks/useBrokerPortfolio.ts
Normal file
14
apps/frontend/src/hooks/useBrokerPortfolio.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { getBrokerPortfolio } from '../api/broker';
|
||||
import type { BrokerPortfolio } from '../api/responses';
|
||||
|
||||
export function useBrokerPortfolio(accountId: string | undefined) {
|
||||
return useQuery<BrokerPortfolio>({
|
||||
queryKey: ['broker', 'portfolio', accountId],
|
||||
enabled: Boolean(accountId),
|
||||
queryFn: async () => (await getBrokerPortfolio(accountId!)).data,
|
||||
staleTime: 60_000,
|
||||
retry: 2,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
}
|
||||
179
apps/frontend/src/pages/broker/BrokerAccountDetailPage.tsx
Normal file
179
apps/frontend/src/pages/broker/BrokerAccountDetailPage.tsx
Normal file
@ -0,0 +1,179 @@
|
||||
import { useParams } from 'react-router-dom';
|
||||
import type { BrokerMoney } from '../../api/responses';
|
||||
import { useBrokerOperations } from '../../hooks/useBrokerOperations';
|
||||
import { useBrokerPortfolio } from '../../hooks/useBrokerPortfolio';
|
||||
|
||||
function formatMoney(value: BrokerMoney | null | undefined) {
|
||||
if (!value) return '-';
|
||||
|
||||
return new Intl.NumberFormat('ru-RU', {
|
||||
style: 'currency',
|
||||
currency: value.currency || 'RUB',
|
||||
maximumFractionDigits: 2,
|
||||
}).format(value.value);
|
||||
}
|
||||
|
||||
function formatDate(value: string | null) {
|
||||
if (!value) return '-';
|
||||
|
||||
return new Date(value).toLocaleString('ru-RU');
|
||||
}
|
||||
|
||||
const tableStyle = {
|
||||
width: '100%',
|
||||
borderCollapse: 'collapse',
|
||||
fontSize: 14,
|
||||
} satisfies React.CSSProperties;
|
||||
|
||||
const thStyle = {
|
||||
borderBottom: '1px solid #e0e0e0',
|
||||
color: 'var(--color-text-secondary)',
|
||||
fontWeight: 600,
|
||||
padding: '10px 8px',
|
||||
} satisfies React.CSSProperties;
|
||||
|
||||
const tdStyle = {
|
||||
borderBottom: '1px solid #eeeeee',
|
||||
padding: '10px 8px',
|
||||
verticalAlign: 'top',
|
||||
} satisfies React.CSSProperties;
|
||||
|
||||
export function BrokerAccountDetailPage() {
|
||||
const { accountId } = useParams();
|
||||
const portfolio = useBrokerPortfolio(accountId);
|
||||
const operations = useBrokerOperations(accountId, { limit: 100 });
|
||||
|
||||
if (portfolio.isLoading) return <p>Загрузка портфеля...</p>;
|
||||
if (portfolio.error || !portfolio.data) {
|
||||
return <p style={{ color: 'var(--color-negative)' }}>Не удалось загрузить портфель</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'grid', gap: 24 }}>
|
||||
<header>
|
||||
<h1 style={{ fontSize: 28, lineHeight: 1.2, marginBottom: 12 }}>
|
||||
{portfolio.data.account.name}
|
||||
</h1>
|
||||
<div style={{ display: 'flex', gap: 16, flexWrap: 'wrap', alignItems: 'baseline' }}>
|
||||
<strong style={{ fontSize: 24 }}>{formatMoney(portfolio.data.totals.portfolio)}</strong>
|
||||
<span style={{ color: 'var(--color-text-secondary)' }}>
|
||||
День: {formatMoney(portfolio.data.yields.daily)}
|
||||
</span>
|
||||
<span style={{ color: 'var(--color-text-secondary)' }}>
|
||||
Ожидаемая: {portfolio.data.yields.expectedPercent ?? '-'}%
|
||||
</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))',
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
{portfolio.data.cash.map((money) => (
|
||||
<div
|
||||
key={money.currency}
|
||||
style={{
|
||||
background: 'var(--color-surface)',
|
||||
border: '1px solid #e0e0e0',
|
||||
borderRadius: 8,
|
||||
padding: 16,
|
||||
}}
|
||||
>
|
||||
<div style={{ color: 'var(--color-text-secondary)', fontSize: 13 }}>
|
||||
{money.currency}
|
||||
</div>
|
||||
<strong>{formatMoney(money)}</strong>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 style={{ fontSize: 20, marginBottom: 12 }}>Позиции</h2>
|
||||
<div style={{ overflowX: 'auto', background: 'var(--color-surface)' }}>
|
||||
<table style={tableStyle}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th align="left" style={thStyle}>
|
||||
Инструмент
|
||||
</th>
|
||||
<th align="right" style={thStyle}>
|
||||
Количество
|
||||
</th>
|
||||
<th align="right" style={thStyle}>
|
||||
Стоимость
|
||||
</th>
|
||||
<th align="right" style={thStyle}>
|
||||
Доходность
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{portfolio.data.positions.map((position) => (
|
||||
<tr key={position.positionUid || position.instrumentUid || position.ticker}>
|
||||
<td style={tdStyle}>
|
||||
<strong>{position.ticker || position.name || position.figi}</strong>
|
||||
{position.name && (
|
||||
<div style={{ color: 'var(--color-text-secondary)' }}>{position.name}</div>
|
||||
)}
|
||||
</td>
|
||||
<td align="right" style={tdStyle}>
|
||||
{position.quantity ?? '-'}
|
||||
</td>
|
||||
<td align="right" style={tdStyle}>
|
||||
{formatMoney(position.currentValue)}
|
||||
</td>
|
||||
<td align="right" style={tdStyle}>
|
||||
{position.expectedYieldPercent ?? '-'}%
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 style={{ fontSize: 20, marginBottom: 12 }}>Операции</h2>
|
||||
{operations.isLoading ? (
|
||||
<p>Загрузка операций...</p>
|
||||
) : (
|
||||
<div style={{ overflowX: 'auto', background: 'var(--color-surface)' }}>
|
||||
<table style={tableStyle}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th align="left" style={thStyle}>
|
||||
Дата
|
||||
</th>
|
||||
<th align="left" style={thStyle}>
|
||||
Тип
|
||||
</th>
|
||||
<th align="left" style={thStyle}>
|
||||
Инструмент
|
||||
</th>
|
||||
<th align="right" style={thStyle}>
|
||||
Сумма
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(operations.data?.items ?? []).map((operation) => (
|
||||
<tr key={operation.cursor || operation.id}>
|
||||
<td style={tdStyle}>{formatDate(operation.date)}</td>
|
||||
<td style={tdStyle}>{operation.type}</td>
|
||||
<td style={tdStyle}>{operation.ticker || operation.description || '-'}</td>
|
||||
<td align="right" style={tdStyle}>
|
||||
{formatMoney(operation.payment)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
50
apps/frontend/src/pages/broker/BrokerAccountsPage.tsx
Normal file
50
apps/frontend/src/pages/broker/BrokerAccountsPage.tsx
Normal file
@ -0,0 +1,50 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useBrokerAccounts } from '../../hooks/useBrokerAccounts';
|
||||
|
||||
const cardStyle = {
|
||||
display: 'block',
|
||||
padding: 20,
|
||||
background: 'var(--color-surface)',
|
||||
border: '1px solid #e0e0e0',
|
||||
borderRadius: 8,
|
||||
color: 'var(--color-text)',
|
||||
textDecoration: 'none',
|
||||
boxShadow: 'var(--shadow)',
|
||||
} satisfies React.CSSProperties;
|
||||
|
||||
export function BrokerAccountsPage() {
|
||||
const { data: accounts, isLoading, error } = useBrokerAccounts();
|
||||
|
||||
if (isLoading) return <p>Загрузка брокерских счетов...</p>;
|
||||
if (error) return <p style={{ color: 'var(--color-negative)' }}>Не удалось загрузить счета</p>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', gap: 12, marginBottom: 20 }}>
|
||||
<h1 style={{ fontSize: 28, lineHeight: 1.2 }}>Брокерские счета</h1>
|
||||
<span style={{ color: 'var(--color-text-secondary)', fontSize: 14 }}>
|
||||
{(accounts ?? []).length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gap: 16,
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(260px, 1fr))',
|
||||
}}
|
||||
>
|
||||
{(accounts ?? []).map((account) => (
|
||||
<Link key={account.id} to={`/broker/${encodeURIComponent(account.id)}`} style={cardStyle}>
|
||||
<div style={{ fontSize: 18, fontWeight: 700, marginBottom: 10 }}>{account.name}</div>
|
||||
<div style={{ display: 'grid', gap: 6, color: 'var(--color-text-secondary)' }}>
|
||||
<span>{account.type === 'iis' ? 'ИИС' : 'Брокерский счет'}</span>
|
||||
<span>{account.status}</span>
|
||||
<span>{account.id}</span>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
137
apps/frontend/src/pages/broker/BrokerPages.test.tsx
Normal file
137
apps/frontend/src/pages/broker/BrokerPages.test.tsx
Normal file
@ -0,0 +1,137 @@
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { type ReactElement } from 'react';
|
||||
import { MemoryRouter, Route, Routes } from 'react-router-dom';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import * as accountHook from '../../hooks/useBrokerAccounts';
|
||||
import * as operationsHook from '../../hooks/useBrokerOperations';
|
||||
import * as portfolioHook from '../../hooks/useBrokerPortfolio';
|
||||
import { BrokerAccountDetailPage } from './BrokerAccountDetailPage';
|
||||
import { BrokerAccountsPage } from './BrokerAccountsPage';
|
||||
|
||||
function renderWithClient(ui: ReactElement, initialEntries = ['/broker']) {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
|
||||
return render(
|
||||
<QueryClientProvider client={client}>
|
||||
<MemoryRouter initialEntries={initialEntries}>{ui}</MemoryRouter>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('Broker pages', () => {
|
||||
it('renders broker and IIS accounts', () => {
|
||||
vi.spyOn(accountHook, 'useBrokerAccounts').mockReturnValue({
|
||||
data: [
|
||||
{
|
||||
id: 'acc-1',
|
||||
type: 'brokerage',
|
||||
name: 'Broker',
|
||||
status: 'ACCOUNT_STATUS_OPEN',
|
||||
openedAt: null,
|
||||
accessLevel: null,
|
||||
},
|
||||
{
|
||||
id: 'acc-2',
|
||||
type: 'iis',
|
||||
name: 'IIS',
|
||||
status: 'ACCOUNT_STATUS_OPEN',
|
||||
openedAt: null,
|
||||
accessLevel: null,
|
||||
},
|
||||
],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
} as any);
|
||||
|
||||
renderWithClient(<BrokerAccountsPage />);
|
||||
|
||||
expect(screen.getByText('Broker')).toBeInTheDocument();
|
||||
expect(screen.getByText('IIS')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders positions and operations for account detail', () => {
|
||||
vi.spyOn(portfolioHook, 'useBrokerPortfolio').mockReturnValue({
|
||||
data: {
|
||||
account: {
|
||||
id: 'acc-1',
|
||||
type: 'brokerage',
|
||||
name: 'Broker',
|
||||
status: 'ACCOUNT_STATUS_OPEN',
|
||||
openedAt: null,
|
||||
accessLevel: null,
|
||||
},
|
||||
totals: { portfolio: { currency: 'RUB', units: '1000', nano: 0, value: 1000 } },
|
||||
yields: { expectedPercent: 5, daily: null, dailyPercent: null },
|
||||
cash: [{ currency: 'RUB', units: '100', nano: 0, value: 100 }],
|
||||
blockedCash: [],
|
||||
positions: [
|
||||
{
|
||||
figi: null,
|
||||
instrumentUid: 'uid-1',
|
||||
positionUid: null,
|
||||
ticker: 'SBER',
|
||||
classCode: 'TQBR',
|
||||
instrumentType: 'share',
|
||||
name: 'Sberbank',
|
||||
quantity: 10,
|
||||
blockedLots: null,
|
||||
currentPrice: null,
|
||||
currentValue: { currency: 'RUB', units: '1000', nano: 0, value: 1000 },
|
||||
averagePositionPrice: null,
|
||||
expectedYieldPercent: null,
|
||||
dailyYield: null,
|
||||
},
|
||||
],
|
||||
asOf: '2026-06-16T00:00:00.000Z',
|
||||
},
|
||||
isLoading: false,
|
||||
error: null,
|
||||
} as any);
|
||||
vi.spyOn(operationsHook, 'useBrokerOperations').mockReturnValue({
|
||||
data: {
|
||||
accountId: 'acc-1',
|
||||
items: [
|
||||
{
|
||||
cursor: 'cursor-1',
|
||||
accountId: 'acc-1',
|
||||
id: 'op-1',
|
||||
parentOperationId: null,
|
||||
date: '2026-06-16T00:00:00.000Z',
|
||||
category: 'trade',
|
||||
type: 'OPERATION_TYPE_BUY',
|
||||
description: 'Buy',
|
||||
state: 'OPERATION_STATE_EXECUTED',
|
||||
instrumentUid: 'uid-1',
|
||||
figi: null,
|
||||
ticker: 'SBER',
|
||||
classCode: 'TQBR',
|
||||
instrumentType: 'share',
|
||||
payment: { currency: 'RUB', units: '-1000', nano: 0, value: -1000 },
|
||||
price: null,
|
||||
commission: null,
|
||||
yield: null,
|
||||
accruedInt: null,
|
||||
quantity: 10,
|
||||
quantityDone: 10,
|
||||
},
|
||||
],
|
||||
nextCursor: null,
|
||||
hasNext: false,
|
||||
asOf: '2026-06-16T00:00:00.000Z',
|
||||
},
|
||||
isLoading: false,
|
||||
error: null,
|
||||
} as any);
|
||||
|
||||
renderWithClient(
|
||||
<Routes>
|
||||
<Route path="/broker/:accountId" element={<BrokerAccountDetailPage />} />
|
||||
</Routes>,
|
||||
['/broker/acc-1'],
|
||||
);
|
||||
|
||||
expect(screen.getAllByText('SBER').length).toBeGreaterThan(0);
|
||||
expect(screen.getByText('OPERATION_TYPE_BUY')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@ -10,6 +10,8 @@ import { ProtectedRoute } from './components/ProtectedRoute';
|
||||
import { PortfoliosListPage } from './pages/portfolios/PortfoliosListPage';
|
||||
import { PortfolioDetailPage } from './pages/portfolios/PortfolioDetailPage';
|
||||
import { ScreenerPage } from './pages/screener/ScreenerPage';
|
||||
import { BrokerAccountsPage } from './pages/broker/BrokerAccountsPage';
|
||||
import { BrokerAccountDetailPage } from './pages/broker/BrokerAccountDetailPage';
|
||||
|
||||
export function AppRoutes() {
|
||||
return (
|
||||
@ -45,6 +47,22 @@ export function AppRoutes() {
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/broker"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<BrokerAccountsPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/broker/:accountId"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<BrokerAccountDetailPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
</Route>
|
||||
</Routes>
|
||||
);
|
||||
|
||||
@ -0,0 +1,394 @@
|
||||
# Проектирование брокерских портфелей T-Bank
|
||||
|
||||
**Статус:** одобрено для планирования реализации
|
||||
|
||||
**Дата:** 2026-06-16
|
||||
|
||||
**Владелец:** MoexVibe
|
||||
|
||||
## Контекст
|
||||
|
||||
MoexVibe уже поддерживает вручную управляемые виртуальные портфели на основе рыночных данных MOEX.
|
||||
Следующий шаг — интеграция T-Bank Invest только для чтения, чтобы приложение показывало реальные
|
||||
брокерские счета, позиции, денежные остатки и историю операций.
|
||||
|
||||
Первая версия интеграции персональная и серверная. Бэкенд читает один `T_BANK_TOKEN` из
|
||||
`apps/backend/.env`. В будущих версиях это можно заменить хранением токенов на уровне пользователя,
|
||||
но первая реализация не должна требовать более крупной модели секретов.
|
||||
|
||||
## Факты о внешнем API
|
||||
|
||||
T-Bank Invest API — это gRPC API. REST доступен как прокси, а WebSocket существует для клиентов,
|
||||
которым он нужен, но основным протоколом является gRPC. Production endpoint:
|
||||
`invest-public-api.tbank.ru:443`; sandbox endpoint: `sandbox-invest-public-api.tbank.ru:443`.
|
||||
|
||||
Авторизация передается как gRPC-метаданные:
|
||||
|
||||
```text
|
||||
Authorization: Bearer <T_BANK_TOKEN>
|
||||
```
|
||||
|
||||
Нужные официальные сервисы и методы:
|
||||
|
||||
| Потребность | Метод T-Bank | Примечания |
|
||||
| --- | --- | --- |
|
||||
| Счета | `UsersService/GetAccounts` | Фильтровать только открытые брокерские счета и ИИС. |
|
||||
| Текущая оценка портфеля | `OperationsService/GetPortfolio` | Возвращает итоги, позиции, дневную доходность, ожидаемую доходность. |
|
||||
| Деньги и рассчитанные позиции | `OperationsService/GetPositions` | Возвращает деньги, заблокированные деньги, бумаги, фьючерсы, опционы. |
|
||||
| История операций | `OperationsService/GetOperationsByCursor` | Курсорная пагинация, лимит до 1000, фильтры по типам операций. |
|
||||
| Метаданные инструмента | `InstrumentsService/GetInstrumentBy` | Получение `instrument_uid`, тикера, class code, лота, названия, ISIN. |
|
||||
|
||||
Официальная документация рекомендует оставаться ниже 50 запросов в секунду суммарно по счетам и
|
||||
токенам. Документированные лимиты unary-запросов включают 100 запросов в минуту для сервиса счетов и
|
||||
200 запросов в минуту для сервиса операций. T-Bank также возвращает метаданные по лимитам запросов,
|
||||
например `x-ratelimit-limit`, `x-ratelimit-remaining` и `x-ratelimit-reset`.
|
||||
|
||||
Stream-сервисы существуют для обновлений портфеля, позиций и операций. Они пригодятся позже, но
|
||||
первая реализация использует unary-чтение плюс локальные кеширование и синхронизацию: это проще, тестируемее и не
|
||||
держит лишние долгоживущие соединения.
|
||||
|
||||
Ссылки:
|
||||
|
||||
- [T-Invest API intro](https://developer.tbank.ru/invest/intro/intro)
|
||||
- [gRPC protocol](https://developer.tbank.ru/invest/intro/developer/protocols/grpc/)
|
||||
- [Limits](https://developer.tbank.ru/invest/intro/intro/limits)
|
||||
- [UsersService](https://developer.tbank.ru/invest/api/users-service)
|
||||
- [OperationsService](https://developer.tbank.ru/invest/api/operations-service)
|
||||
- [JS SDK](https://developer.tbank.ru/invest/sdk/faq_js)
|
||||
- [Official proto contracts](https://opensource.tbank.ru/invest/invest-contracts)
|
||||
|
||||
## Цели
|
||||
|
||||
- Показать реальные брокерские счета T-Bank и ИИС в отдельном разделе брокерских портфелей.
|
||||
- Показать текущие позиции по каждому счету: акции, облигации, другие бумаги, если они вернулись из
|
||||
API, и денежные остатки.
|
||||
- Показать историю операций по каждому счету: покупки, продажи, комиссии, налоги, купоны, дивиденды,
|
||||
пополнения, выводы, погашения, корректировки и другие типы операций, которые возвращает T-Bank.
|
||||
- Не создавать лишнюю нагрузку на T-Bank: использовать ограничение частоты запросов на бэкенде,
|
||||
короткоживущий кеш чтения и устойчивую локальную синхронизацию операций.
|
||||
- Изолировать фронтенд от T-Bank. Бэкенд остается единственным клиентом внешнего API.
|
||||
- Опубликовать человекочитаемую архитектурную и пользовательскую документацию в `apps/docs`.
|
||||
|
||||
## Не цели
|
||||
|
||||
- В этой фазе нет торговли, выставления заявок, переводов или пополнения счетов.
|
||||
- В этой фазе нет Инвесткопилки, ЦФА-счета, дебетового счета, накопительного счета или счета фонда
|
||||
денежного рынка.
|
||||
- В этой фазе нет управления T-Bank токенами на уровне пользователя.
|
||||
- В первой версии нет постоянно запущенных stream workers.
|
||||
- Нет собственного движка расчета налогов сверх отображения налоговых операций, которые предоставляет
|
||||
брокер.
|
||||
|
||||
## Область счетов
|
||||
|
||||
Используем `UsersService/GetAccounts` и оставляем только:
|
||||
|
||||
- `ACCOUNT_TYPE_TINKOFF`
|
||||
- `ACCOUNT_TYPE_TINKOFF_IIS`
|
||||
|
||||
Также оставляем только открытые счета:
|
||||
|
||||
- `ACCOUNT_STATUS_OPEN`
|
||||
|
||||
Исключенные типы счетов:
|
||||
|
||||
- `ACCOUNT_TYPE_INVEST_BOX`
|
||||
- `ACCOUNT_TYPE_INVEST_FUND`
|
||||
- `ACCOUNT_TYPE_DEBIT`
|
||||
- `ACCOUNT_TYPE_SAVING`
|
||||
- `ACCOUNT_TYPE_DFA`
|
||||
- `ACCOUNT_TYPE_UNSPECIFIED`
|
||||
|
||||
## Решение по протоколу
|
||||
|
||||
Используем тонкую gRPC-интеграцию на стороне бэкенда вместо REST-прокси или жесткой зависимости от
|
||||
официального JS SDK.
|
||||
|
||||
### Почему gRPC
|
||||
|
||||
- Это основной протокол T-Bank Invest API.
|
||||
- Он дает прямой доступ к unary API и будущим stream API.
|
||||
- Он оставляет метаданные запроса, `x-tracking-id` и метаданные лимитов видимыми на границе
|
||||
транспорта.
|
||||
- Он соответствует правилу MoexVibe: бэкенд является единственным клиентом внешних данных.
|
||||
|
||||
### Почему не REST первым
|
||||
|
||||
REST полезен для ручной отладки и Swagger-примеров, но это прокси поверх той же поверхности сервисов.
|
||||
Если строиться на REST, появится лишний слой перевода, а будущая поддержка stream API потребует
|
||||
отдельного дизайна.
|
||||
|
||||
### Почему не SDK первым
|
||||
|
||||
Официальный JS SDK существует и может быть полезен как справочник. Текущие npm-метаданные
|
||||
`@tinkoff/invest-js` показывают, что SDK уже зависит от gRPC-библиотек, включая `@grpc/grpc-js`,
|
||||
`@grpc/proto-loader`, `nice-grpc` и `protobufjs`. Прямое использование SDK скроет транспортные
|
||||
детали, которыми MoexVibe должен владеть сам: ограничение частоты запросов, сбор метаданных,
|
||||
тестовые двойники, обновление сгенерированных типов и будущий источник токенов. Граница модуля при
|
||||
этом должна позволять заменить внутренний транспортный адаптер на вызовы SDK, если позже это станет очевидно
|
||||
дешевле.
|
||||
|
||||
## Архитектура бэкенда
|
||||
|
||||
Создать новый feature-модуль:
|
||||
|
||||
```text
|
||||
apps/backend/src/modules/tbank/
|
||||
├── tbank.module.ts
|
||||
├── tbank.config.ts
|
||||
├── tbank.controller.ts
|
||||
├── services/
|
||||
│ ├── tbank-client.service.ts
|
||||
│ ├── broker-accounts.service.ts
|
||||
│ ├── broker-portfolio.service.ts
|
||||
│ ├── broker-operations.service.ts
|
||||
│ └── broker-instruments.service.ts
|
||||
├── dto/
|
||||
│ ├── broker-account-response.dto.ts
|
||||
│ ├── broker-portfolio-response.dto.ts
|
||||
│ ├── broker-operation-query.dto.ts
|
||||
│ └── broker-operation-response.dto.ts
|
||||
└── mappers/
|
||||
├── money.mapper.ts
|
||||
├── account.mapper.ts
|
||||
├── portfolio.mapper.ts
|
||||
└── operation.mapper.ts
|
||||
```
|
||||
|
||||
`TBankClientService` отвечает за:
|
||||
|
||||
- создание gRPC channel;
|
||||
- метаданные `Authorization`;
|
||||
- опциональные метаданные `x-app-name`, например `ksv741.moex-vibe`;
|
||||
- таймаут запроса;
|
||||
- повторные попытки для временных gRPC-сбоев;
|
||||
- ограничение частоты запросов на уровне сервисов;
|
||||
- сбор `x-tracking-id` и метаданных лимитов для логов и ответов об ошибках.
|
||||
|
||||
Доменные сервисы зависят от `TBankClientService`, а не напрямую от сгенерированных gRPC-клиентов.
|
||||
Это оставляет будущий переход от одного серверного токена к providers токенов на уровне пользователя
|
||||
локальным для клиентского слоя.
|
||||
|
||||
## Публичный API бэкенда
|
||||
|
||||
Все endpoints требуют существующую JWT-аутентификацию. В первой версии любой аутентифицированный
|
||||
пользователь MoexVibe технически сможет прочитать одни и те же брокерские данные серверного токена.
|
||||
До реализации хранилища токенов на уровне пользователя deployment должен считаться
|
||||
single-user/admin-only.
|
||||
|
||||
| Endpoint | Метод | Описание |
|
||||
| --- | --- | --- |
|
||||
| `/api/v1/broker/accounts` | GET | Список открытых брокерских счетов T-Bank и ИИС. |
|
||||
| `/api/v1/broker/accounts/:accountId/portfolio` | GET | Текущий портфель счета, позиции, итоги, деньги. |
|
||||
| `/api/v1/broker/accounts/:accountId/operations` | GET | История операций с пагинацией и фильтрами. |
|
||||
|
||||
Параметры query для операций:
|
||||
|
||||
| Параметр | Тип | По умолчанию | Примечания |
|
||||
| --- | --- | --- | --- |
|
||||
| `from` | ISO datetime | начало текущего года | UTC в запросе к T-Bank. |
|
||||
| `to` | ISO datetime | сейчас | UTC в запросе к T-Bank. |
|
||||
| `cursor` | string | отсутствует | Передается в `GetOperationsByCursor`. |
|
||||
| `limit` | integer | 100 | Ограничить до `1..1000`. |
|
||||
| `instrumentId` | string | отсутствует | Поддерживает figi, instrument UID или `ticker_classCode`. |
|
||||
| `operationTypes` | string list | отсутствует | Опциональные имена enum типов операций T-Bank. |
|
||||
| `state` | enum | `OPERATION_STATE_EXECUTED` | По умолчанию исполненные операции для учетных представлений портфеля. |
|
||||
|
||||
## Модель ответа
|
||||
|
||||
Используем JSON DTO, удобные для фронтенда, а не сырые proto-объекты.
|
||||
|
||||
Денежные значения нормализуются из T-Bank `MoneyValue { currency, units, nano }` в:
|
||||
|
||||
```typescript
|
||||
type BrokerMoney = {
|
||||
currency: string;
|
||||
units: string;
|
||||
nano: number;
|
||||
value: number;
|
||||
};
|
||||
```
|
||||
|
||||
`units` остается строкой, чтобы сохранить исходное int64-значение. `value` — удобное decimal-число
|
||||
для отображения и графиков. Финансовые расчеты, которым нужна точная арифметика, должны использовать
|
||||
decimal helpers, а не арифметику с плавающей точкой.
|
||||
|
||||
Форма ответа портфеля:
|
||||
|
||||
```typescript
|
||||
type BrokerPortfolio = {
|
||||
account: BrokerAccount;
|
||||
totals: {
|
||||
shares: BrokerMoney | null;
|
||||
bonds: BrokerMoney | null;
|
||||
etf: BrokerMoney | null;
|
||||
currencies: BrokerMoney | null;
|
||||
futures: BrokerMoney | null;
|
||||
options: BrokerMoney | null;
|
||||
structuredProducts: BrokerMoney | null;
|
||||
dfa: BrokerMoney | null;
|
||||
portfolio: BrokerMoney | null;
|
||||
};
|
||||
yields: {
|
||||
expectedPercent: number | null;
|
||||
daily: BrokerMoney | null;
|
||||
dailyPercent: number | null;
|
||||
};
|
||||
cash: BrokerMoney[];
|
||||
blockedCash: BrokerMoney[];
|
||||
positions: BrokerPosition[];
|
||||
asOf: string;
|
||||
};
|
||||
```
|
||||
|
||||
Форма ответа операций:
|
||||
|
||||
```typescript
|
||||
type BrokerOperationsPage = {
|
||||
accountId: string;
|
||||
items: BrokerOperation[];
|
||||
nextCursor: string | null;
|
||||
hasNext: boolean;
|
||||
asOf: string;
|
||||
};
|
||||
```
|
||||
|
||||
Каждая операция хранит исходный enum T-Bank в поле `type`, а также отображаемую категорию MoexVibe:
|
||||
|
||||
| Категория | Примеры операций T-Bank |
|
||||
| --- | --- |
|
||||
| `trade` | `OPERATION_TYPE_BUY`, `OPERATION_TYPE_SELL`, маржинальные/поставочные варианты |
|
||||
| `income` | `OPERATION_TYPE_DIVIDEND`, `OPERATION_TYPE_COUPON`, погашения, overnight income |
|
||||
| `tax` | `OPERATION_TYPE_TAX`, налог на дивиденды, налог на облигации, прогрессивные налоги |
|
||||
| `fee` | брокерская комиссия, сервисная комиссия, маржинальная комиссия, success fee, прочие комиссии |
|
||||
| `transfer` | пополнение, вывод, перевод бумаг, SWIFT/acquiring/multi transfers |
|
||||
| `other` | unspecified и еще не категоризованные типы операций T-Bank |
|
||||
|
||||
Нельзя отбрасывать неизвестные типы операций. Их нужно хранить и показывать как `other` с исходным
|
||||
enum-значением.
|
||||
|
||||
## Стратегия синхронизации операций
|
||||
|
||||
Фаза 1 может читать операции напрямую из T-Bank с кешированием. Фаза 2 должна сохранять
|
||||
нормализованные операции локально, потому что история операций — это audit trail, и она не должна
|
||||
зависеть от повторного обхода одних и тех же удаленных страниц.
|
||||
|
||||
Дизайн устойчивой синхронизации:
|
||||
|
||||
- Хранить одну строку на каждый элемент операции T-Bank с ключом `(accountId, cursor)`, если cursor
|
||||
присутствует.
|
||||
- Также хранить `operationId`, `parentOperationId`, `date`, `type`, `state`, `instrumentUid`,
|
||||
`figi`, `ticker`, `classCode`, `payment`, `price`, `commission`, `yield`, `accruedInt`,
|
||||
`quantity`, `quantityDone`, `raw`.
|
||||
- Хранить состояние синхронизации по каждому счету: последний успешный диапазон, последний cursor, timestamp
|
||||
последней синхронизации.
|
||||
- Делать backfill исторических данных окнами по датам, например по одному календарному году за запуск.
|
||||
- Обновлять скользящее недавнее окно, например последние 3 дня, потому что operation IDs и parent
|
||||
IDs могут меняться согласно комментариям в proto.
|
||||
- Сохранять сырой JSON payload операции для аудита/отладки и отдавать наружу нормализованные DTO.
|
||||
|
||||
## Стратегия кеширования
|
||||
|
||||
Использовать существующий backend `CacheService` для короткоживущих чтений и добавить key prefixes
|
||||
для T-Bank. Frontend TanStack Query должен использовать совпадающие или более короткие stale times.
|
||||
|
||||
| Данные | TTL бэкенда | Причина |
|
||||
| --- | --- | --- |
|
||||
| Счета | 1 час | Список счетов меняется редко. |
|
||||
| Итоги портфеля и позиции | 30-60 секунд | Текущий пользовательский вид должен ощущаться свежим, но не спамить T-Bank. |
|
||||
| Деньги/лимиты вывода | 30-60 секунд | Такая же свежесть, как у позиций. |
|
||||
| Метаданные инструмента | 24 часа | Название инструмента, лот, ISIN, UID стабильны. |
|
||||
| Страница операций, недавнее окно | 60-300 секунд | Полезно до появления устойчивой синхронизации. |
|
||||
| Исторические страницы операций | 24 часа или только БД | История в основном неизменна вне недавнего окна корректировок. |
|
||||
|
||||
Добавить переменные окружения:
|
||||
|
||||
| Переменная | Значение по умолчанию | Описание |
|
||||
| --- | --- | --- |
|
||||
| `T_BANK_TOKEN` | нет | Серверный T-Bank Invest token. Обязателен, когда интеграция включена. |
|
||||
| `T_BANK_BASE_URL` | `invest-public-api.tbank.ru:443` | gRPC endpoint. |
|
||||
| `T_BANK_APP_NAME` | `ksv741.moex-vibe` | Опциональная gRPC metadata. |
|
||||
| `T_BANK_RATE_LIMIT_PER_SECOND` | `5` | Консервативный локальный limiter для всех вызовов T-Bank. |
|
||||
| `CACHE_TBANK_ACCOUNTS_TTL` | `3600` | TTL кеша счетов. |
|
||||
| `CACHE_TBANK_PORTFOLIO_TTL` | `60` | TTL кеша портфеля и позиций. |
|
||||
| `CACHE_TBANK_OPERATIONS_TTL` | `300` | TTL кеша страницы недавних операций. |
|
||||
| `CACHE_TBANK_INSTRUMENT_TTL` | `86400` | TTL кеша метаданных инструмента. |
|
||||
|
||||
Rate limiter должен оставаться ниже документированных публичных лимитов. При 429 или исчерпанных
|
||||
метаданных лимита нужно отступать до reset, если метаданные доступны.
|
||||
|
||||
## Обработка ошибок
|
||||
|
||||
- Отсутствует `T_BANK_TOKEN`: вернуть понятную application error в стиле 503 и пометить интеграцию
|
||||
недоступной; в development не ронять весь бэкенд.
|
||||
- Ошибка авторизации от T-Bank: вернуть 502/503 с общим сообщением; никогда не отдавать token или
|
||||
полные метаданные наружу.
|
||||
- Счет не найден или исключен по type/status: вернуть 404 из endpoints MoexVibe.
|
||||
- T-Bank 429: вернуть 429 или 503 с retry metadata, если она доступна.
|
||||
- Временные gRPC-ошибки T-Bank: повторные попытки с небольшим exponential backoff, затем типизированная
|
||||
upstream error с `trackingId`, если он присутствует.
|
||||
- Неизвестные enum values при mapping: сохранить исходное значение и категоризовать как `other`.
|
||||
|
||||
## Безопасность
|
||||
|
||||
- `T_BANK_TOKEN` нельзя логировать, возвращать в API responses или коммитить.
|
||||
- Логирование gRPC metadata должно редактировать `Authorization`.
|
||||
- Первый deployment по дизайну single-user. Перед включением доступа для нескольких пользователей
|
||||
MoexVibe нужно добавить encrypted token storage на уровне пользователя и authorization rules, которые связывают
|
||||
каждый брокерский счет с владельцем.
|
||||
- Интеграция только для чтения. В первый модуль нельзя включать clients для orders, stop-orders, transfers
|
||||
или pay-in.
|
||||
|
||||
## Вид продукта на фронтенде
|
||||
|
||||
Добавить раздел брокерских портфелей отдельно от вручную управляемых виртуальных портфелей:
|
||||
|
||||
- Страница списка: карточки счетов для брокерских счетов и ИИС, с полной стоимостью, деньгами,
|
||||
дневным изменением и временем последнего обновления.
|
||||
- Страница счета: вкладки `Позиции`, `Операции` и позже `Аналитика`.
|
||||
- Таблица позиций: название инструмента, тикер, тип, количество, текущая цена, текущая стоимость,
|
||||
ожидаемая доходность, дневная доходность, заблокированное количество.
|
||||
- Секция денег: доступные и заблокированные деньги по валютам.
|
||||
- Таблица операций: дата, type/category, инструмент, количество, платеж, комиссия,
|
||||
признаки налога/дохода, статус, раскрываемые детали сделки.
|
||||
|
||||
Не смешивать реальные брокерские счета с существующей ручной моделью `Portfolio`. Держать их отдельно
|
||||
в UI и backend API. Позже MoexVibe сможет добавить представления сравнения или import flows из брокерских
|
||||
операций в виртуальные портфели.
|
||||
|
||||
## Документация
|
||||
|
||||
Во время реализации опубликовать устойчивую документацию в `apps/docs`:
|
||||
|
||||
- `apps/docs/docs/backend/tbank-invest.md`: архитектура модуля, методы API, переменные окружения,
|
||||
обработка ошибок и область только для чтения.
|
||||
- Обновить `apps/docs/docs/backend/modules.md`: добавить `TBankInvestModule`.
|
||||
- Обновить `apps/docs/docs/backend/configuration.md`: задокументировать `T_BANK_*` и переменные
|
||||
TTL кеша.
|
||||
- Обновить `apps/docs/docs/backend/caching.md`: добавить стратегию кеширования для T-Bank.
|
||||
- Обновить `apps/docs/docs/backend/portfolio.md`: разделить ручные портфели и брокерские портфели.
|
||||
- Добавить ADR `apps/docs/docs/adr/ADR-011-tbank-invest-grpc.md`: зафиксировать решение gRPC over
|
||||
REST/SDK.
|
||||
- Обновить `apps/docs/sidebars.ts`, чтобы включить новую страницу бэкенда и ADR.
|
||||
|
||||
## Критерии приемки
|
||||
|
||||
- Бэкенд показывает только открытые счета `ACCOUNT_TYPE_TINKOFF` и `ACCOUNT_TYPE_TINKOFF_IIS`.
|
||||
- Бэкенд отдает текущий портфель счета с позициями и деньгами, не раскрывая сырые данные токена.
|
||||
- Бэкенд отдает историю операций с пагинацией и включает категории buy, sell, tax, fee, coupon,
|
||||
dividend, deposit, withdrawal и unknown operations.
|
||||
- Вызовы T-Bank используют консервативный rate limiter и стратегию кеширования.
|
||||
- Отсутствующий или некорректный token дает понятную ошибку integration-unavailable.
|
||||
- Опубликованная документация в `apps/docs` описывает архитектуру, конфигурацию, кеширование и
|
||||
решение по gRPC.
|
||||
- Тесты покрывают фильтрацию счетов, mapping денег, категоризацию операций, валидацию query,
|
||||
cache keys и mapping upstream-ошибок.
|
||||
|
||||
## Этапы реализации
|
||||
|
||||
1. Базовая gRPC-инфраструктура бэкенда и DTO mappers.
|
||||
2. Endpoints только для чтения для счетов и портфеля.
|
||||
3. Endpoint истории операций с курсорной пагинацией.
|
||||
4. Список брокерских счетов и detail pages счета на фронтенде.
|
||||
5. Устойчивая синхронизация операций в базе данных.
|
||||
6. Опциональные stream workers для near-real-time refresh после стабилизации unary sync.
|
||||
151
package-lock.json
generated
151
package-lock.json
generated
@ -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"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user