Compare commits
No commits in common. "8c2a6c9e3c5dec6b672ffbf40a287de4d543dc87" and "106467e5c4650eaf175e342d1b2028af3c4aa55e" have entirely different histories.
8c2a6c9e3c
...
106467e5c4
@ -20,7 +20,6 @@ npm workspaces монорепозиторий: `apps/backend` (NestJS), `apps/fr
|
|||||||
|
|
||||||
- `apps/docs` — единственная опубликованная человекочитаемая документация проекта (Docusaurus).
|
- `apps/docs` — единственная опубликованная человекочитаемая документация проекта (Docusaurus).
|
||||||
- Root `docs` хранит только согласованные SDD-спецификации в `docs/superpowers/specs/`.
|
- Root `docs` хранит только согласованные SDD-спецификации в `docs/superpowers/specs/`.
|
||||||
- Все SDD spec-файлы в `docs/superpowers/specs/` пишутся на русском языке; англоязычные термины допустимы для API, кода, протоколов и официальных названий.
|
|
||||||
- ADR для опубликованной документации находятся в `apps/docs/docs/adr/`.
|
- ADR для опубликованной документации находятся в `apps/docs/docs/adr/`.
|
||||||
- OpenAPI source of truth — live Swagger JSON бэкенда на `/api/docs-json`; frontend generated types находятся в `apps/frontend/src/api/types.ts`.
|
- 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.
|
- Superpowers plans и временные execution logs не коммитить по умолчанию. Если нужен план для ревью, держать его кратким и переносить устойчивые решения в spec/ADR/docs.
|
||||||
@ -54,12 +53,6 @@ Live MOEX integration tests opt-in: `npm run test:integration -w apps/backend`.
|
|||||||
| `MOEX_RATE_LIMIT` | 10 | Запросов/с к MOEX |
|
| `MOEX_RATE_LIMIT` | 10 | Запросов/с к MOEX |
|
||||||
| `MOEX_CIRCUIT_BREAKER_THRESHOLD` | 5 | Количество ошибок до открытия circuit breaker |
|
| `MOEX_CIRCUIT_BREAKER_THRESHOLD` | 5 | Количество ошибок до открытия circuit breaker |
|
||||||
| `MOEX_CIRCUIT_BREAKER_RESET_SECONDS` | 30 | Время до попытки закрыть 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_MARKET_DATA_TTL` | 900 | TTL рыночных данных (с) |
|
||||||
| `CACHE_HISTORY_TTL` | 3600 | TTL истории (с) |
|
| `CACHE_HISTORY_TTL` | 3600 | TTL истории (с) |
|
||||||
| `CACHE_CANDLES_TTL` | 3600 | TTL свечей (с) |
|
| `CACHE_CANDLES_TTL` | 3600 | TTL свечей (с) |
|
||||||
|
|||||||
@ -1,33 +0,0 @@
|
|||||||
-----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,13 +1,4 @@
|
|||||||
{
|
{
|
||||||
"collection": "@nestjs/schematics",
|
"collection": "@nestjs/schematics",
|
||||||
"sourceRoot": "src",
|
"sourceRoot": "src"
|
||||||
"compilerOptions": {
|
|
||||||
"assets": [
|
|
||||||
{
|
|
||||||
"include": "modules/tbank/proto/contracts/**/*",
|
|
||||||
"outDir": "dist"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"watchAssets": true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -13,8 +13,6 @@
|
|||||||
"test:integration": "MOEX_LIVE_TESTS=1 VITE_CJS_IGNORE_WARNING=1 vitest run \"src/**/*.integration.spec.ts\""
|
"test:integration": "MOEX_LIVE_TESTS=1 VITE_CJS_IGNORE_WARNING=1 vitest run \"src/**/*.integration.spec.ts\""
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@grpc/grpc-js": "^1.14.4",
|
|
||||||
"@grpc/proto-loader": "^0.8.1",
|
|
||||||
"@libsql/client": "^0.17.3",
|
"@libsql/client": "^0.17.3",
|
||||||
"@nestjs/axios": "^3.0.0",
|
"@nestjs/axios": "^3.0.0",
|
||||||
"@nestjs/cache-manager": "^2.0.0",
|
"@nestjs/cache-manager": "^2.0.0",
|
||||||
@ -32,9 +30,7 @@
|
|||||||
"class-transformer": "^0.5.0",
|
"class-transformer": "^0.5.0",
|
||||||
"class-validator": "^0.14.0",
|
"class-validator": "^0.14.0",
|
||||||
"cookie-parser": "^1.4.7",
|
"cookie-parser": "^1.4.7",
|
||||||
"long": "^5.3.2",
|
|
||||||
"p-queue": "^7.3.0",
|
"p-queue": "^7.3.0",
|
||||||
"protobufjs": "^8.6.4",
|
|
||||||
"reflect-metadata": "^0.1.13",
|
"reflect-metadata": "^0.1.13",
|
||||||
"rxjs": "^7.8.0",
|
"rxjs": "^7.8.0",
|
||||||
"swagger-ui-express": "^5.0.0"
|
"swagger-ui-express": "^5.0.0"
|
||||||
|
|||||||
@ -1,50 +0,0 @@
|
|||||||
-- 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");
|
|
||||||
@ -7,35 +7,35 @@ datasource db {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model Portfolio {
|
model Portfolio {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
userId Int
|
userId Int
|
||||||
name String
|
name String
|
||||||
description String?
|
description String?
|
||||||
currency String @default("RUB")
|
currency String @default("RUB")
|
||||||
targets String?
|
targets String?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
positions Position[]
|
positions Position[]
|
||||||
|
|
||||||
@@unique([userId, name])
|
@@unique([userId, name])
|
||||||
}
|
}
|
||||||
|
|
||||||
model Position {
|
model Position {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
portfolioId Int
|
portfolioId Int
|
||||||
secid String
|
secid String
|
||||||
type String @default("share")
|
type String @default("share")
|
||||||
quantity Int
|
quantity Int
|
||||||
buyPrice Float?
|
buyPrice Float?
|
||||||
buyDate DateTime?
|
buyDate DateTime?
|
||||||
notes String?
|
notes String?
|
||||||
tags String?
|
tags String?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
portfolio Portfolio @relation(fields: [portfolioId], references: [id], onDelete: Cascade)
|
portfolio Portfolio @relation(fields: [portfolioId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
@@unique([portfolioId, secid])
|
@@unique([portfolioId, secid])
|
||||||
}
|
}
|
||||||
@ -51,44 +51,3 @@ model User {
|
|||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
portfolios Portfolio[]
|
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,7 +10,6 @@ import { CandlesModule } from './modules/candles/candles.module';
|
|||||||
import { PortfolioModule } from './modules/portfolio/portfolio.module';
|
import { PortfolioModule } from './modules/portfolio/portfolio.module';
|
||||||
import { PrismaModule } from './modules/prisma/prisma.module';
|
import { PrismaModule } from './modules/prisma/prisma.module';
|
||||||
import { AuthModule } from './modules/auth/auth.module';
|
import { AuthModule } from './modules/auth/auth.module';
|
||||||
import { TBankModule } from './modules/tbank/tbank.module';
|
|
||||||
import configuration from './config/configuration';
|
import configuration from './config/configuration';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
@ -26,7 +25,6 @@ import configuration from './config/configuration';
|
|||||||
BondsModule,
|
BondsModule,
|
||||||
CandlesModule,
|
CandlesModule,
|
||||||
PortfolioModule,
|
PortfolioModule,
|
||||||
TBankModule,
|
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
export class AppModule {}
|
||||||
|
|||||||
@ -14,14 +14,6 @@ export default registerAs('app', () => ({
|
|||||||
10,
|
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: {
|
cache: {
|
||||||
marketDataTtl: parseInt(process.env.CACHE_MARKET_DATA_TTL || '900', 10),
|
marketDataTtl: parseInt(process.env.CACHE_MARKET_DATA_TTL || '900', 10),
|
||||||
historyTtl: parseInt(process.env.CACHE_HISTORY_TTL || '3600', 10),
|
historyTtl: parseInt(process.env.CACHE_HISTORY_TTL || '3600', 10),
|
||||||
@ -29,10 +21,6 @@ export default registerAs('app', () => ({
|
|||||||
securityTtl: parseInt(process.env.CACHE_SECURITY_TTL || '86400', 10),
|
securityTtl: parseInt(process.env.CACHE_SECURITY_TTL || '86400', 10),
|
||||||
searchTtl: parseInt(process.env.CACHE_SEARCH_TTL || '3600', 10),
|
searchTtl: parseInt(process.env.CACHE_SEARCH_TTL || '3600', 10),
|
||||||
dividendsTtl: parseInt(process.env.CACHE_DIVIDENDS_TTL || '86400', 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: {
|
auth: {
|
||||||
jwtSecret: process.env.JWT_SECRET || 'dev-jwt-secret-change-in-production',
|
jwtSecret: process.env.JWT_SECRET || 'dev-jwt-secret-change-in-production',
|
||||||
|
|||||||
@ -1,21 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@ -1,45 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@ -1,15 +0,0 @@
|
|||||||
import { ApiProperty } from '@nestjs/swagger';
|
|
||||||
|
|
||||||
export class BrokerMoneyDto {
|
|
||||||
@ApiProperty()
|
|
||||||
currency!: string;
|
|
||||||
|
|
||||||
@ApiProperty()
|
|
||||||
units!: string;
|
|
||||||
|
|
||||||
@ApiProperty()
|
|
||||||
nano!: number;
|
|
||||||
|
|
||||||
@ApiProperty()
|
|
||||||
value!: number;
|
|
||||||
}
|
|
||||||
@ -1,43 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@ -1,86 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@ -1,17 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@ -1,110 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@ -1,43 +0,0 @@
|
|||||||
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',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,22 +0,0 @@
|
|||||||
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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@ -1,33 +0,0 @@
|
|||||||
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');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,45 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@ -1,64 +0,0 @@
|
|||||||
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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,138 +0,0 @@
|
|||||||
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(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@ -1,54 +0,0 @@
|
|||||||
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 },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,84 +0,0 @@
|
|||||||
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(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@ -1,144 +0,0 @@
|
|||||||
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; //Инструмент, торгуемый на площадке брокера.
|
|
||||||
}
|
|
||||||
@ -1,104 +0,0 @@
|
|||||||
// Copyright 2026 Google LLC
|
|
||||||
//
|
|
||||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
// you may not use this file except in compliance with the License.
|
|
||||||
// You may obtain a copy of the License at
|
|
||||||
//
|
|
||||||
// http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
//
|
|
||||||
// Unless required by applicable law or agreed to in writing, software
|
|
||||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
// See the License for the specific language governing permissions and
|
|
||||||
// limitations under the License.
|
|
||||||
|
|
||||||
syntax = "proto3";
|
|
||||||
|
|
||||||
package google.api;
|
|
||||||
|
|
||||||
import "google/protobuf/descriptor.proto";
|
|
||||||
|
|
||||||
option go_package = "google.golang.org/genproto/googleapis/api/annotations;annotations";
|
|
||||||
option java_multiple_files = true;
|
|
||||||
option java_outer_classname = "FieldBehaviorProto";
|
|
||||||
option java_package = "com.google.api";
|
|
||||||
option objc_class_prefix = "GAPI";
|
|
||||||
|
|
||||||
extend google.protobuf.FieldOptions {
|
|
||||||
// A designation of a specific field behavior (required, output only, etc.)
|
|
||||||
// in protobuf messages.
|
|
||||||
//
|
|
||||||
// Examples:
|
|
||||||
//
|
|
||||||
// string name = 1 [(google.api.field_behavior) = REQUIRED];
|
|
||||||
// State state = 1 [(google.api.field_behavior) = OUTPUT_ONLY];
|
|
||||||
// google.protobuf.Duration ttl = 1
|
|
||||||
// [(google.api.field_behavior) = INPUT_ONLY];
|
|
||||||
// google.protobuf.Timestamp expire_time = 1
|
|
||||||
// [(google.api.field_behavior) = OUTPUT_ONLY,
|
|
||||||
// (google.api.field_behavior) = IMMUTABLE];
|
|
||||||
repeated google.api.FieldBehavior field_behavior = 1052 [packed = false];
|
|
||||||
}
|
|
||||||
|
|
||||||
// An indicator of the behavior of a given field (for example, that a field
|
|
||||||
// is required in requests, or given as output but ignored as input).
|
|
||||||
// This **does not** change the behavior in protocol buffers itself; it only
|
|
||||||
// denotes the behavior and may affect how API tooling handles the field.
|
|
||||||
//
|
|
||||||
// Note: This enum **may** receive new values in the future.
|
|
||||||
enum FieldBehavior {
|
|
||||||
// Conventional default for enums. Do not use this.
|
|
||||||
FIELD_BEHAVIOR_UNSPECIFIED = 0;
|
|
||||||
|
|
||||||
// Specifically denotes a field as optional.
|
|
||||||
// While all fields in protocol buffers are optional, this may be specified
|
|
||||||
// for emphasis if appropriate.
|
|
||||||
OPTIONAL = 1;
|
|
||||||
|
|
||||||
// Denotes a field as required.
|
|
||||||
// This indicates that the field **must** be provided as part of the request,
|
|
||||||
// and failure to do so will cause an error (usually `INVALID_ARGUMENT`).
|
|
||||||
REQUIRED = 2;
|
|
||||||
|
|
||||||
// Denotes a field as output only.
|
|
||||||
// This indicates that the field is provided in responses, but including the
|
|
||||||
// field in a request does nothing (the server *must* ignore it and
|
|
||||||
// *must not* throw an error as a result of the field's presence).
|
|
||||||
OUTPUT_ONLY = 3;
|
|
||||||
|
|
||||||
// Denotes a field as input only.
|
|
||||||
// This indicates that the field is provided in requests, and the
|
|
||||||
// corresponding field is not included in output.
|
|
||||||
INPUT_ONLY = 4;
|
|
||||||
|
|
||||||
// Denotes a field as immutable.
|
|
||||||
// This indicates that the field may be set once in a request to create a
|
|
||||||
// resource, but may not be changed thereafter.
|
|
||||||
IMMUTABLE = 5;
|
|
||||||
|
|
||||||
// Denotes that a (repeated) field is an unordered list.
|
|
||||||
// This indicates that the service may provide the elements of the list
|
|
||||||
// in any arbitrary order, rather than the order the user originally
|
|
||||||
// provided. Additionally, the list's order may or may not be stable.
|
|
||||||
UNORDERED_LIST = 6;
|
|
||||||
|
|
||||||
// Denotes that this field returns a non-empty default value if not set.
|
|
||||||
// This indicates that if the user provides the empty value in a request,
|
|
||||||
// a non-empty value will be returned. The user will not be aware of what
|
|
||||||
// non-empty value to expect.
|
|
||||||
NON_EMPTY_DEFAULT = 7;
|
|
||||||
|
|
||||||
// Denotes that the field in a resource (a message annotated with
|
|
||||||
// google.api.resource) is used in the resource name to uniquely identify the
|
|
||||||
// resource. For AIP-compliant APIs, this should only be applied to the
|
|
||||||
// `name` field on the resource.
|
|
||||||
//
|
|
||||||
// This behavior should not be applied to references to other resources within
|
|
||||||
// the message.
|
|
||||||
//
|
|
||||||
// The identifier field of resources often have different field behavior
|
|
||||||
// depending on the request it is embedded in (e.g. for Create methods name
|
|
||||||
// is optional and unused, while for Update methods it is required). Instead
|
|
||||||
// of method-specific annotations, only `IDENTIFIER` is required.
|
|
||||||
IDENTIFIER = 8;
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@ -1,648 +0,0 @@
|
|||||||
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; //Сумма операции.
|
|
||||||
}
|
|
||||||
@ -1,249 +0,0 @@
|
|||||||
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; // Значение запрашиваемого параметра.
|
|
||||||
}
|
|
||||||
@ -1,49 +0,0 @@
|
|||||||
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',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,50 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,38 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,155 +0,0 @@
|
|||||||
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');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,106 +0,0 @@
|
|||||||
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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,64 +0,0 @@
|
|||||||
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',
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,88 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,70 +0,0 @@
|
|||||||
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',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,81 +0,0 @@
|
|||||||
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),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,115 +0,0 @@
|
|||||||
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'),
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,191 +0,0 @@
|
|||||||
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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,41 +0,0 @@
|
|||||||
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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,22 +0,0 @@
|
|||||||
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;
|
|
||||||
@ -1,61 +0,0 @@
|
|||||||
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 });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,67 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,29 +0,0 @@
|
|||||||
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 {}
|
|
||||||
@ -1,90 +0,0 @@
|
|||||||
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;
|
|
||||||
};
|
|
||||||
@ -1,148 +0,0 @@
|
|||||||
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;
|
|
||||||
};
|
|
||||||
@ -1,30 +0,0 @@
|
|||||||
# 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,6 +12,5 @@
|
|||||||
| [ADR-008](ADR-008-auth-system) | Accepted | Authentication и Authorization |
|
| [ADR-008](ADR-008-auth-system) | Accepted | Authentication и Authorization |
|
||||||
| [ADR-009](ADR-009-portfolio-domain) | Accepted | Доменная модель портфеля |
|
| [ADR-009](ADR-009-portfolio-domain) | Accepted | Доменная модель портфеля |
|
||||||
| [ADR-010](ADR-010-backend-price-computation) | Accepted | Расчёт цен на backend |
|
| [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-разделе.
|
Все опубликованные ADR находятся в `apps/docs/docs/adr/` и отображаются в этом Docusaurus-разделе.
|
||||||
|
|||||||
@ -37,7 +37,7 @@ flowchart LR
|
|||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
getOrFetch<T>(
|
getOrFetch<T>(
|
||||||
keyPrefix: string, // 'marketdata' | 'history' | 'candles' | 'search' | 'bond' | 'dividends' | 'tbank:*'
|
keyPrefix: string, // 'marketdata' | 'history' | 'candles' | 'search' | 'bond' | 'dividends'
|
||||||
keyParts: string[], // ['shares', 'SBER'] | ['SBER', '2026-06-13', '2026-06-14']
|
keyParts: string[], // ['shares', 'SBER'] | ['SBER', '2026-06-13', '2026-06-14']
|
||||||
fetchFn: () => Promise<T>,
|
fetchFn: () => Promise<T>,
|
||||||
ttlConfigKey: string, // 'marketDataTtl' | 'historyTtl' | etc.
|
ttlConfigKey: string, // 'marketDataTtl' | 'historyTtl' | etc.
|
||||||
@ -78,14 +78,3 @@ export class CacheModule {}
|
|||||||
| Спецификация инструмента | `securityTtl` | 86400s (24 ч) | `CACHE_SECURITY_TTL` |
|
| Спецификация инструмента | `securityTtl` | 86400s (24 ч) | `CACHE_SECURITY_TTL` |
|
||||||
| Поиск | `searchTtl` | 3600s (1 ч) | `CACHE_SEARCH_TTL` |
|
| Поиск | `searchTtl` | 3600s (1 ч) | `CACHE_SEARCH_TTL` |
|
||||||
| Дивиденды | `dividendsTtl` | 86400s (24 ч) | `CACHE_DIVIDENDS_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,22 +11,12 @@
|
|||||||
| `MOEX_RATE_LIMIT` | `10` | Максимум запросов в секунду к MOEX |
|
| `MOEX_RATE_LIMIT` | `10` | Максимум запросов в секунду к MOEX |
|
||||||
| `MOEX_CIRCUIT_BREAKER_THRESHOLD` | `5` | Количество ошибок до открытия circuit breaker |
|
| `MOEX_CIRCUIT_BREAKER_THRESHOLD` | `5` | Количество ошибок до открытия circuit breaker |
|
||||||
| `MOEX_CIRCUIT_BREAKER_RESET_SECONDS` | `30` | Время в секундах до сброса 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_MARKET_DATA_TTL` | `900` | TTL рыночных данных (секунды) |
|
||||||
| `CACHE_HISTORY_TTL` | `3600` | TTL истории торгов (секунды) |
|
| `CACHE_HISTORY_TTL` | `3600` | TTL истории торгов (секунды) |
|
||||||
| `CACHE_CANDLES_TTL` | `3600` | TTL свечей (секунды) |
|
| `CACHE_CANDLES_TTL` | `3600` | TTL свечей (секунды) |
|
||||||
| `CACHE_SECURITY_TTL` | `86400` | TTL спецификации инструмента (секунды) |
|
| `CACHE_SECURITY_TTL` | `86400` | TTL спецификации инструмента (секунды) |
|
||||||
| `CACHE_SEARCH_TTL` | `3600` | TTL результатов поиска (секунды) |
|
| `CACHE_SEARCH_TTL` | `3600` | TTL результатов поиска (секунды) |
|
||||||
| `CACHE_DIVIDENDS_TTL` | `86400` | 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 (секунды) |
|
|
||||||
|
|
||||||
## Файл конфигурации
|
## Файл конфигурации
|
||||||
|
|
||||||
@ -43,14 +33,6 @@ registerAs('app', () => ({
|
|||||||
process.env.MOEX_CIRCUIT_BREAKER_RESET_SECONDS || '30', 10,
|
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: {
|
cache: {
|
||||||
marketDataTtl: parseInt(process.env.CACHE_MARKET_DATA_TTL || '900', 10),
|
marketDataTtl: parseInt(process.env.CACHE_MARKET_DATA_TTL || '900', 10),
|
||||||
historyTtl: parseInt(process.env.CACHE_HISTORY_TTL || '3600', 10),
|
historyTtl: parseInt(process.env.CACHE_HISTORY_TTL || '3600', 10),
|
||||||
@ -58,10 +40,6 @@ registerAs('app', () => ({
|
|||||||
securityTtl: parseInt(process.env.CACHE_SECURITY_TTL || '86400', 10),
|
securityTtl: parseInt(process.env.CACHE_SECURITY_TTL || '86400', 10),
|
||||||
searchTtl: parseInt(process.env.CACHE_SEARCH_TTL || '3600', 10),
|
searchTtl: parseInt(process.env.CACHE_SEARCH_TTL || '3600', 10),
|
||||||
dividendsTtl: parseInt(process.env.CACHE_DIVIDENDS_TTL || '86400', 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
|
flowchart TB
|
||||||
AppModule["AppModule"]
|
AppModule["AppModule"]
|
||||||
GlobalModules["Глобальные модули<br/>ConfigModule<br/>PrismaModule<br/>CacheModule<br/>MoexClientModule"]
|
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<br/>TBankModule"]
|
FeatureModules["Feature-модули<br/>HealthModule<br/>AuthModule<br/>PortfolioModule<br/>SecuritiesModule<br/>SharesModule<br/>BondsModule<br/>CandlesModule"]
|
||||||
|
|
||||||
AppModule --> GlobalModules
|
AppModule --> GlobalModules
|
||||||
AppModule --> FeatureModules
|
AppModule --> FeatureModules
|
||||||
@ -21,12 +21,10 @@ flowchart TB
|
|||||||
AuthModule["AuthModule"]
|
AuthModule["AuthModule"]
|
||||||
PortfolioModule["PortfolioModule"]
|
PortfolioModule["PortfolioModule"]
|
||||||
MarketModules["SecuritiesModule<br/>SharesModule<br/>BondsModule<br/>CandlesModule"]
|
MarketModules["SecuritiesModule<br/>SharesModule<br/>BondsModule<br/>CandlesModule"]
|
||||||
TBankModule["TBankModule"]
|
|
||||||
|
|
||||||
PrismaService["PrismaService"]
|
PrismaService["PrismaService"]
|
||||||
CacheService["CacheService"]
|
CacheService["CacheService"]
|
||||||
MoexClientService["MoexClientService"]
|
MoexClientService["MoexClientService"]
|
||||||
TBankClientService["TBankClientService"]
|
|
||||||
|
|
||||||
PrismaModule --> PrismaService
|
PrismaModule --> PrismaService
|
||||||
CacheModule --> CacheService
|
CacheModule --> CacheService
|
||||||
@ -39,10 +37,6 @@ flowchart TB
|
|||||||
|
|
||||||
MarketModules --> CacheService
|
MarketModules --> CacheService
|
||||||
MarketModules --> MoexClientService
|
MarketModules --> MoexClientService
|
||||||
|
|
||||||
TBankModule --> CacheService
|
|
||||||
TBankModule --> PrismaService
|
|
||||||
TBankModule --> TBankClientService
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Список модулей
|
## Список модулей
|
||||||
@ -59,7 +53,6 @@ flowchart TB
|
|||||||
| `BondsModule` | Нет | `modules/bonds/` | Облигации |
|
| `BondsModule` | Нет | `modules/bonds/` | Облигации |
|
||||||
| `CandlesModule` | Нет | `modules/candles/` | Свечи OHLCV |
|
| `CandlesModule` | Нет | `modules/candles/` | Свечи OHLCV |
|
||||||
| `PortfolioModule` | Нет | `modules/portfolio/` | Пользовательские портфели и аналитика |
|
| `PortfolioModule` | Нет | `modules/portfolio/` | Пользовательские портфели и аналитика |
|
||||||
| `TBankModule` | Нет | `modules/tbank/` | Read-only брокерские портфели T-Bank Invest |
|
|
||||||
|
|
||||||
### PrismaModule
|
### PrismaModule
|
||||||
|
|
||||||
@ -138,18 +131,3 @@ flowchart TB
|
|||||||
- Обогащение позиций текущими ценами из MOEX
|
- Обогащение позиций текущими ценами из MOEX
|
||||||
- Расчёт summary, PnL и долей портфеля
|
- Расчёт summary, PnL и долей портфеля
|
||||||
- Все endpoints защищены JWT
|
- Все 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,12 +3,6 @@
|
|||||||
`PortfolioModule` позволяет пользователям создавать и вести виртуальные инвестиционные портфели для
|
`PortfolioModule` позволяет пользователям создавать и вести виртуальные инвестиционные портфели для
|
||||||
аналитики и отслеживания позиций.
|
аналитики и отслеживания позиций.
|
||||||
|
|
||||||
## Ручные портфели и брокерские портфели
|
|
||||||
|
|
||||||
`PortfolioModule` остаётся доменом ручных виртуальных портфелей. Брокерские счета T-Bank Invest
|
|
||||||
экспортируются отдельным `TBankModule` под `/api/v1/broker/*` и не сохраняются как записи
|
|
||||||
`Portfolio`.
|
|
||||||
|
|
||||||
## Обзор
|
## Обзор
|
||||||
|
|
||||||
- **Backend:** `PortfolioModule` (`apps/backend/src/modules/portfolio/`)
|
- **Backend:** `PortfolioModule` (`apps/backend/src/modules/portfolio/`)
|
||||||
|
|||||||
@ -1,81 +0,0 @@
|
|||||||
# 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,7 +17,6 @@ const sidebars: SidebarsConfig = {
|
|||||||
'backend/configuration',
|
'backend/configuration',
|
||||||
'backend/caching',
|
'backend/caching',
|
||||||
'backend/moex-client',
|
'backend/moex-client',
|
||||||
'backend/tbank-invest',
|
|
||||||
'backend/securities',
|
'backend/securities',
|
||||||
'backend/portfolio',
|
'backend/portfolio',
|
||||||
],
|
],
|
||||||
@ -64,7 +63,6 @@ const sidebars: SidebarsConfig = {
|
|||||||
'adr/ADR-008-auth-system',
|
'adr/ADR-008-auth-system',
|
||||||
'adr/ADR-009-portfolio-domain',
|
'adr/ADR-009-portfolio-domain',
|
||||||
'adr/ADR-010-backend-price-computation',
|
'adr/ADR-010-backend-price-computation',
|
||||||
'adr/ADR-011-tbank-invest-grpc',
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@ -1,27 +0,0 @@
|
|||||||
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),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,51 +0,0 @@
|
|||||||
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,24 +17,6 @@ describe('request', () => {
|
|||||||
expect(result.data.status).toBe('ok');
|
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 () => {
|
it('includes Authorization header when token is set', async () => {
|
||||||
setAccessToken('test-token');
|
setAccessToken('test-token');
|
||||||
let capturedAuth: string | null = null;
|
let capturedAuth: string | null = null;
|
||||||
|
|||||||
@ -40,31 +40,14 @@ async function refreshTokens(): Promise<boolean> {
|
|||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
});
|
});
|
||||||
if (!res.ok) return false;
|
if (!res.ok) return false;
|
||||||
const json = await res.json();
|
const json: ApiEnvelope<{ data: AuthResponse; meta: ApiResponseMeta }> = await res.json();
|
||||||
accessToken = normalizeEnvelope<AuthResponse>(json).data.accessToken;
|
accessToken = json.data.data.accessToken;
|
||||||
return true;
|
return true;
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
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> {
|
async function handleUnauthorized(): Promise<boolean> {
|
||||||
if (isRefreshing && refreshPromise) {
|
if (isRefreshing && refreshPromise) {
|
||||||
return refreshPromise;
|
return refreshPromise;
|
||||||
@ -131,8 +114,8 @@ export async function request<T>(
|
|||||||
throw new Error(`Ошибка API: ${res.status} ${res.statusText}${text ? ` - ${text}` : ''}`);
|
throw new Error(`Ошибка API: ${res.status} ${res.statusText}${text ? ` - ${text}` : ''}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const json = await res.json();
|
const json: ApiEnvelope<{ data: T; meta: ApiResponseMeta }> = await res.json();
|
||||||
return normalizeEnvelope<T>(json);
|
return json.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getHealth(): Promise<{ data: HealthResponse; meta: ApiResponseMeta }> {
|
export function getHealth(): Promise<{ data: HealthResponse; meta: ApiResponseMeta }> {
|
||||||
|
|||||||
@ -245,94 +245,3 @@ export interface ScreenerResult {
|
|||||||
pageSize: number;
|
pageSize: number;
|
||||||
totalPages: 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,74 +383,6 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: 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 type webhooks = Record<string, never>;
|
||||||
export interface components {
|
export interface components {
|
||||||
@ -757,117 +689,6 @@ export interface components {
|
|||||||
data: components['schemas']['AnalyticsResponseDto'];
|
data: components['schemas']['AnalyticsResponseDto'];
|
||||||
meta: components['schemas']['PortfolioResponseMetaDto'];
|
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;
|
responses: never;
|
||||||
parameters: never;
|
parameters: never;
|
||||||
@ -1479,97 +1300,4 @@ 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,7 +20,6 @@ export function Layout() {
|
|||||||
padding: '12px 24px',
|
padding: '12px 24px',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
flexWrap: 'wrap',
|
|
||||||
gap: 24,
|
gap: 24,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@ -35,9 +34,7 @@ export function Layout() {
|
|||||||
>
|
>
|
||||||
MoexVibe
|
MoexVibe
|
||||||
</Link>
|
</Link>
|
||||||
<div style={{ flex: '1 1 280px', minWidth: 220, maxWidth: 420 }}>
|
<SearchBar />
|
||||||
<SearchBar />
|
|
||||||
</div>
|
|
||||||
<Link
|
<Link
|
||||||
to="/portfolios"
|
to="/portfolios"
|
||||||
style={{
|
style={{
|
||||||
@ -49,17 +46,6 @@ export function Layout() {
|
|||||||
>
|
>
|
||||||
Портфели
|
Портфели
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
|
||||||
to="/broker"
|
|
||||||
style={{
|
|
||||||
fontSize: 14,
|
|
||||||
color: 'var(--color-text)',
|
|
||||||
textDecoration: 'none',
|
|
||||||
fontWeight: 500,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Брокер
|
|
||||||
</Link>
|
|
||||||
<Link
|
<Link
|
||||||
to="/screener"
|
to="/screener"
|
||||||
style={{
|
style={{
|
||||||
@ -72,15 +58,7 @@ export function Layout() {
|
|||||||
Скринер
|
Скринер
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<div
|
<div style={{ marginLeft: 'auto', display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||||
style={{
|
|
||||||
marginLeft: 'auto',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 12,
|
|
||||||
flexWrap: 'wrap',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{isAuthenticated ? (
|
{isAuthenticated ? (
|
||||||
<>
|
<>
|
||||||
<Link
|
<Link
|
||||||
@ -126,15 +104,7 @@ export function Layout() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<main
|
<main style={{ flex: 1, padding: 24, maxWidth: 1200, width: '100%', margin: '0 auto' }}>
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
padding: 'clamp(16px, 4vw, 24px)',
|
|
||||||
maxWidth: 1200,
|
|
||||||
width: '100%',
|
|
||||||
margin: '0 auto',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -38,7 +38,7 @@ export function PortfolioCard({ portfolio }: { portfolio: Portfolio }) {
|
|||||||
<h3 style={{ margin: 0, fontSize: 16, fontWeight: 600, flex: 1 }}>{portfolio.name}</h3>
|
<h3 style={{ margin: 0, fontSize: 16, fontWeight: 600, flex: 1 }}>{portfolio.name}</h3>
|
||||||
<div style={{ textAlign: 'right' }}>
|
<div style={{ textAlign: 'right' }}>
|
||||||
<div style={{ fontSize: 20, fontWeight: 700, lineHeight: 1.2 }}>
|
<div style={{ fontSize: 20, fontWeight: 700, lineHeight: 1.2 }}>
|
||||||
{portfolio.totalValue?.toLocaleString('ru-RU', {
|
{portfolio.totalValue.toLocaleString('ru-RU', {
|
||||||
minimumFractionDigits: 2,
|
minimumFractionDigits: 2,
|
||||||
maximumFractionDigits: 2,
|
maximumFractionDigits: 2,
|
||||||
})}
|
})}
|
||||||
|
|||||||
@ -19,7 +19,7 @@ export function PortfolioSummary({ portfolio }: { portfolio: PortfolioDetail })
|
|||||||
Общая стоимость
|
Общая стоимость
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: 24, fontWeight: 700 }}>
|
<div style={{ fontSize: 24, fontWeight: 700 }}>
|
||||||
{portfolio.totalValue?.toLocaleString('ru-RU', {
|
{portfolio.totalValue.toLocaleString('ru-RU', {
|
||||||
minimumFractionDigits: 2,
|
minimumFractionDigits: 2,
|
||||||
maximumFractionDigits: 2,
|
maximumFractionDigits: 2,
|
||||||
})}
|
})}
|
||||||
|
|||||||
@ -1,41 +0,0 @@
|
|||||||
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');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,13 +0,0 @@
|
|||||||
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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@ -1,17 +0,0 @@
|
|||||||
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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@ -1,14 +0,0 @@
|
|||||||
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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@ -1,179 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,50 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,137 +0,0 @@
|
|||||||
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,8 +10,6 @@ import { ProtectedRoute } from './components/ProtectedRoute';
|
|||||||
import { PortfoliosListPage } from './pages/portfolios/PortfoliosListPage';
|
import { PortfoliosListPage } from './pages/portfolios/PortfoliosListPage';
|
||||||
import { PortfolioDetailPage } from './pages/portfolios/PortfolioDetailPage';
|
import { PortfolioDetailPage } from './pages/portfolios/PortfolioDetailPage';
|
||||||
import { ScreenerPage } from './pages/screener/ScreenerPage';
|
import { ScreenerPage } from './pages/screener/ScreenerPage';
|
||||||
import { BrokerAccountsPage } from './pages/broker/BrokerAccountsPage';
|
|
||||||
import { BrokerAccountDetailPage } from './pages/broker/BrokerAccountDetailPage';
|
|
||||||
|
|
||||||
export function AppRoutes() {
|
export function AppRoutes() {
|
||||||
return (
|
return (
|
||||||
@ -47,22 +45,6 @@ export function AppRoutes() {
|
|||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route
|
|
||||||
path="/broker"
|
|
||||||
element={
|
|
||||||
<ProtectedRoute>
|
|
||||||
<BrokerAccountsPage />
|
|
||||||
</ProtectedRoute>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="/broker/:accountId"
|
|
||||||
element={
|
|
||||||
<ProtectedRoute>
|
|
||||||
<BrokerAccountDetailPage />
|
|
||||||
</ProtectedRoute>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,394 +0,0 @@
|
|||||||
# Проектирование брокерских портфелей 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,8 +21,6 @@
|
|||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@grpc/grpc-js": "^1.14.4",
|
|
||||||
"@grpc/proto-loader": "^0.8.1",
|
|
||||||
"@libsql/client": "^0.17.3",
|
"@libsql/client": "^0.17.3",
|
||||||
"@nestjs/axios": "^3.0.0",
|
"@nestjs/axios": "^3.0.0",
|
||||||
"@nestjs/cache-manager": "^2.0.0",
|
"@nestjs/cache-manager": "^2.0.0",
|
||||||
@ -40,9 +38,7 @@
|
|||||||
"class-transformer": "^0.5.0",
|
"class-transformer": "^0.5.0",
|
||||||
"class-validator": "^0.14.0",
|
"class-validator": "^0.14.0",
|
||||||
"cookie-parser": "^1.4.7",
|
"cookie-parser": "^1.4.7",
|
||||||
"long": "^5.3.2",
|
|
||||||
"p-queue": "^7.3.0",
|
"p-queue": "^7.3.0",
|
||||||
"protobufjs": "^8.6.4",
|
|
||||||
"reflect-metadata": "^0.1.13",
|
"reflect-metadata": "^0.1.13",
|
||||||
"rxjs": "^7.8.0",
|
"rxjs": "^7.8.0",
|
||||||
"swagger-ui-express": "^5.0.0"
|
"swagger-ui-express": "^5.0.0"
|
||||||
@ -6365,60 +6361,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"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": {
|
"node_modules/@hapi/hoek": {
|
||||||
"version": "9.3.0",
|
"version": "9.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz",
|
||||||
@ -6820,16 +6762,6 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
"@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": {
|
"node_modules/@kurkle/color": {
|
||||||
"version": "0.3.4",
|
"version": "0.3.4",
|
||||||
"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
|
"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
|
||||||
@ -8046,63 +7978,6 @@
|
|||||||
"react-dom": "^18.0.0 || ^19.0.0"
|
"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": {
|
"node_modules/@radix-ui/primitive": {
|
||||||
"version": "1.1.3",
|
"version": "1.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz",
|
||||||
@ -12853,6 +12728,7 @@
|
|||||||
"version": "8.0.1",
|
"version": "8.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
|
||||||
"integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==",
|
"integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==",
|
||||||
|
"dev": true,
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"string-width": "^4.2.0",
|
"string-width": "^4.2.0",
|
||||||
@ -12867,6 +12743,7 @@
|
|||||||
"version": "7.0.0",
|
"version": "7.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
|
||||||
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
|
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ansi-styles": "^4.0.0",
|
"ansi-styles": "^4.0.0",
|
||||||
@ -17037,6 +16914,7 @@
|
|||||||
"version": "2.0.5",
|
"version": "2.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
||||||
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
|
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
|
||||||
|
"dev": true,
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "6.* || 8.* || >= 10.*"
|
"node": "6.* || 8.* || >= 10.*"
|
||||||
@ -20294,12 +20172,6 @@
|
|||||||
"integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==",
|
"integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/lodash.clonedeep": {
|
||||||
"version": "4.5.0",
|
"version": "4.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz",
|
||||||
@ -20597,6 +20469,7 @@
|
|||||||
"version": "5.3.2",
|
"version": "5.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz",
|
||||||
"integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==",
|
"integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==",
|
||||||
|
"devOptional": true,
|
||||||
"license": "Apache-2.0"
|
"license": "Apache-2.0"
|
||||||
},
|
},
|
||||||
"node_modules/longest-streak": {
|
"node_modules/longest-streak": {
|
||||||
@ -26349,18 +26222,6 @@
|
|||||||
"integrity": "sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==",
|
"integrity": "sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==",
|
||||||
"license": "ISC"
|
"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": {
|
"node_modules/proxy-addr": {
|
||||||
"version": "2.0.7",
|
"version": "2.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
|
||||||
@ -27538,6 +27399,7 @@
|
|||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||||
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
|
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
@ -31962,6 +31824,7 @@
|
|||||||
"version": "5.0.8",
|
"version": "5.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
|
||||||
"integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
|
"integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
|
||||||
|
"dev": true,
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
@ -31993,6 +31856,7 @@
|
|||||||
"version": "17.7.2",
|
"version": "17.7.2",
|
||||||
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
|
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
|
||||||
"integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==",
|
"integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cliui": "^8.0.1",
|
"cliui": "^8.0.1",
|
||||||
@ -32011,6 +31875,7 @@
|
|||||||
"version": "21.1.1",
|
"version": "21.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
|
||||||
"integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
|
"integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
|
||||||
|
"dev": true,
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user