Compare commits
No commits in common. "8c2a6c9e3c5dec6b672ffbf40a287de4d543dc87" and "ddeab01b9ee4bf382d50fb21b5a3c2db37f7748f" have entirely different histories.
8c2a6c9e3c
...
ddeab01b9e
@ -54,12 +54,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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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");
|
|
||||||
@ -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
|
|
||||||
}
|
|
||||||
|
|||||||
@ -17,7 +17,6 @@ export default registerAs('app', () => ({
|
|||||||
tbank: {
|
tbank: {
|
||||||
token: process.env.T_BANK_TOKEN || '',
|
token: process.env.T_BANK_TOKEN || '',
|
||||||
baseUrl: process.env.T_BANK_BASE_URL || 'invest-public-api.tbank.ru:443',
|
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',
|
appName: process.env.T_BANK_APP_NAME || 'ksv741.moex-vibe',
|
||||||
rateLimitPerSecond: parseInt(process.env.T_BANK_RATE_LIMIT_PER_SECOND || '5', 10),
|
rateLimitPerSecond: parseInt(process.env.T_BANK_RATE_LIMIT_PER_SECOND || '5', 10),
|
||||||
requestTimeoutMs: parseInt(process.env.T_BANK_REQUEST_TIMEOUT_MS || '10000', 10),
|
requestTimeoutMs: parseInt(process.env.T_BANK_REQUEST_TIMEOUT_MS || '10000', 10),
|
||||||
|
|||||||
@ -1,8 +1,5 @@
|
|||||||
import { ApiProperty } from '@nestjs/swagger';
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
import { BrokerAccountResponseDto } from './broker-account-response.dto';
|
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 {
|
export class BrokerResponseMetaDto {
|
||||||
@ApiProperty({ nullable: true })
|
@ApiProperty({ nullable: true })
|
||||||
@ -19,27 +16,3 @@ export class BrokerAccountsEnvelopeDto {
|
|||||||
@ApiProperty({ type: BrokerResponseMetaDto })
|
@ApiProperty({ type: BrokerResponseMetaDto })
|
||||||
meta!: 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,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,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,9 +1,6 @@
|
|||||||
import { ServiceUnavailableException } from '@nestjs/common';
|
import { ServiceUnavailableException } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { ChannelCredentials, ClientUnaryCall, Metadata, ServiceError, status } from '@grpc/grpc-js';
|
import { 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';
|
import { TBankClientService } from './tbank-client.service';
|
||||||
|
|
||||||
describe('TBankClientService', () => {
|
describe('TBankClientService', () => {
|
||||||
@ -38,36 +35,6 @@ describe('TBankClientService', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
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 () => {
|
it('throws integration unavailable when token is missing', async () => {
|
||||||
const missingConfig = {
|
const missingConfig = {
|
||||||
get: vi.fn((key: string, fallback?: unknown) =>
|
get: vi.fn((key: string, fallback?: unknown) =>
|
||||||
|
|||||||
@ -17,7 +17,6 @@ import {
|
|||||||
} from '@grpc/grpc-js';
|
} from '@grpc/grpc-js';
|
||||||
import { loadSync } from '@grpc/proto-loader';
|
import { loadSync } from '@grpc/proto-loader';
|
||||||
import PQueue from 'p-queue';
|
import PQueue from 'p-queue';
|
||||||
import { existsSync, readFileSync } from 'node:fs';
|
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { TBANK_PROTO_FILES, TBANK_PROTO_PACKAGE } from '../tbank.config';
|
import { TBANK_PROTO_FILES, TBANK_PROTO_PACKAGE } from '../tbank.config';
|
||||||
|
|
||||||
@ -45,29 +44,19 @@ export class TBankClientService {
|
|||||||
intervalCap: this.configService.get<number>('app.tbank.rateLimitPerSecond', 5),
|
intervalCap: this.configService.get<number>('app.tbank.rateLimitPerSecond', 5),
|
||||||
});
|
});
|
||||||
|
|
||||||
const protoRoot = this.resolveProtoRoot();
|
const protoRoot = join(__dirname, '..', 'proto', 'contracts');
|
||||||
const definition = loadSync(
|
const definition = loadSync(Object.values(TBANK_PROTO_FILES), {
|
||||||
Object.values(TBANK_PROTO_FILES).map((fileName) => join(protoRoot, fileName)),
|
|
||||||
{
|
|
||||||
includeDirs: [protoRoot],
|
includeDirs: [protoRoot],
|
||||||
keepCase: false,
|
keepCase: false,
|
||||||
longs: String,
|
longs: String,
|
||||||
enums: String,
|
enums: String,
|
||||||
defaults: true,
|
defaults: true,
|
||||||
oneofs: true,
|
oneofs: true,
|
||||||
},
|
});
|
||||||
);
|
|
||||||
|
|
||||||
this.packageDefinition = loadPackageDefinition(definition);
|
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 {
|
createMetadata(): Metadata {
|
||||||
const token = this.configService.get<string>('app.tbank.token', '');
|
const token = this.configService.get<string>('app.tbank.token', '');
|
||||||
if (!token) {
|
if (!token) {
|
||||||
@ -108,7 +97,7 @@ export class TBankClientService {
|
|||||||
const ServiceCtor = namespace[serviceName] as GrpcServiceConstructor;
|
const ServiceCtor = namespace[serviceName] as GrpcServiceConstructor;
|
||||||
const client = new ServiceCtor(
|
const client = new ServiceCtor(
|
||||||
this.configService.get<string>('app.tbank.baseUrl', 'invest-public-api.tbank.ru:443'),
|
this.configService.get<string>('app.tbank.baseUrl', 'invest-public-api.tbank.ru:443'),
|
||||||
this.createChannelCredentials(),
|
ChannelCredentials.createSsl(),
|
||||||
);
|
);
|
||||||
|
|
||||||
this.clientCache.set(serviceName, client);
|
this.clientCache.set(serviceName, client);
|
||||||
@ -148,22 +137,6 @@ export class TBankClientService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
private mapGrpcError(label: string, error: ServiceError): Error {
|
||||||
const trackingId = error.metadata?.get('x-tracking-id')?.[0];
|
const trackingId = error.metadata?.get('x-tracking-id')?.[0];
|
||||||
const retryAfter = error.metadata?.get('x-ratelimit-reset')?.[0];
|
const retryAfter = error.metadata?.get('x-ratelimit-reset')?.[0];
|
||||||
|
|||||||
@ -26,7 +26,6 @@ describe('T-Bank configuration', () => {
|
|||||||
it('reads T-Bank token and TTL overrides from environment', () => {
|
it('reads T-Bank token and TTL overrides from environment', () => {
|
||||||
process.env.T_BANK_TOKEN = 'secret-token';
|
process.env.T_BANK_TOKEN = 'secret-token';
|
||||||
process.env.T_BANK_BASE_URL = 'sandbox-invest-public-api.tbank.ru:443';
|
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.T_BANK_RATE_LIMIT_PER_SECOND = '2';
|
||||||
process.env.CACHE_TBANK_ACCOUNTS_TTL = '120';
|
process.env.CACHE_TBANK_ACCOUNTS_TTL = '120';
|
||||||
|
|
||||||
@ -34,7 +33,6 @@ describe('T-Bank configuration', () => {
|
|||||||
|
|
||||||
expect(config.tbank.token).toBe('secret-token');
|
expect(config.tbank.token).toBe('secret-token');
|
||||||
expect(config.tbank.baseUrl).toBe('sandbox-invest-public-api.tbank.ru:443');
|
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.tbank.rateLimitPerSecond).toBe(2);
|
||||||
expect(config.cache.tbankAccountsTtl).toBe(120);
|
expect(config.cache.tbankAccountsTtl).toBe(120);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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 +1,18 @@
|
|||||||
import { Controller, Get, Param, Post, Query } from '@nestjs/common';
|
import { Controller, Get } from '@nestjs/common';
|
||||||
import { ApiBearerAuth, ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger';
|
import { ApiBearerAuth, ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
import { ApiResponse } from '../../common/dto/api-response.dto';
|
import { BrokerAccountsEnvelopeDto } from './dto/broker-envelope.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 { 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')
|
@ApiTags('Broker')
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@Roles('user')
|
|
||||||
@Controller('broker')
|
@Controller('broker')
|
||||||
export class TBankController {
|
export class TBankController {
|
||||||
constructor(
|
constructor(private readonly brokerAccountsService: BrokerAccountsService) {}
|
||||||
private readonly brokerAccountsService: BrokerAccountsService,
|
|
||||||
private readonly brokerPortfolioService: BrokerPortfolioService,
|
|
||||||
private readonly brokerOperationsService: BrokerOperationsService,
|
|
||||||
private readonly brokerOperationSyncService: BrokerOperationSyncService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
@Get('accounts')
|
@Get('accounts')
|
||||||
@ApiOperation({ summary: 'Get open T-Bank brokerage and IIS accounts' })
|
@ApiOperation({ summary: 'Get open T-Bank brokerage and IIS accounts' })
|
||||||
@ApiOkResponse({ type: BrokerAccountsEnvelopeDto })
|
@ApiOkResponse({ type: BrokerAccountsEnvelopeDto })
|
||||||
async getAccounts() {
|
async getAccounts() {
|
||||||
const result = await this.brokerAccountsService.findAll();
|
return 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 +1,11 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { TBankController } from './tbank.controller';
|
import { TBankController } from './tbank.controller';
|
||||||
import { BrokerAccountsService } from './services/broker-accounts.service';
|
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';
|
import { TBankClientService } from './services/tbank-client.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
controllers: [TBankController],
|
controllers: [TBankController],
|
||||||
providers: [
|
providers: [TBankClientService, BrokerAccountsService],
|
||||||
TBankClientService,
|
exports: [TBankClientService, BrokerAccountsService],
|
||||||
BrokerAccountsService,
|
|
||||||
BrokerInstrumentsService,
|
|
||||||
BrokerPortfolioService,
|
|
||||||
BrokerOperationsService,
|
|
||||||
BrokerOperationSyncService,
|
|
||||||
],
|
|
||||||
exports: [
|
|
||||||
TBankClientService,
|
|
||||||
BrokerAccountsService,
|
|
||||||
BrokerInstrumentsService,
|
|
||||||
BrokerPortfolioService,
|
|
||||||
BrokerOperationsService,
|
|
||||||
BrokerOperationSyncService,
|
|
||||||
],
|
|
||||||
})
|
})
|
||||||
export class TBankModule {}
|
export class TBankModule {}
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user