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_CIRCUIT_BREAKER_THRESHOLD` | 5 | Количество ошибок до открытия circuit breaker |
|
||||
| `MOEX_CIRCUIT_BREAKER_RESET_SECONDS` | 30 | Время до попытки закрыть circuit breaker |
|
||||
| `T_BANK_TOKEN` | `''` | Server-side токен T-Bank Invest |
|
||||
| `T_BANK_BASE_URL` | `invest-public-api.tbank.ru:443` | gRPC endpoint T-Bank Invest |
|
||||
| `T_BANK_CA_CERT_PATH` | `''` | Путь к PEM root CA для gRPC TLS, если локальная сеть подменяет сертификаты |
|
||||
| `T_BANK_APP_NAME` | `ksv741.moex-vibe` | Metadata приложения для T-Bank |
|
||||
| `T_BANK_RATE_LIMIT_PER_SECOND` | 5 | Локальный rate limiter для T-Bank |
|
||||
| `T_BANK_REQUEST_TIMEOUT_MS` | 10000 | Deadline gRPC-запроса (мс) |
|
||||
| `CACHE_MARKET_DATA_TTL` | 900 | TTL рыночных данных (с) |
|
||||
| `CACHE_HISTORY_TTL` | 3600 | TTL истории (с) |
|
||||
| `CACHE_CANDLES_TTL` | 3600 | TTL свечей (с) |
|
||||
|
||||
@ -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",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"assets": [
|
||||
{
|
||||
"include": "modules/tbank/proto/contracts/**/*",
|
||||
"outDir": "dist"
|
||||
}
|
||||
],
|
||||
"watchAssets": true
|
||||
}
|
||||
"sourceRoot": "src"
|
||||
}
|
||||
|
||||
@ -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
|
||||
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: {
|
||||
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),
|
||||
|
||||
@ -1,8 +1,5 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { BrokerAccountResponseDto } from './broker-account-response.dto';
|
||||
import { BrokerOperationSyncResponseDto } from './broker-operation-sync-query.dto';
|
||||
import { BrokerOperationsPageResponseDto } from './broker-operation-response.dto';
|
||||
import { BrokerPortfolioResponseDto } from './broker-portfolio-response.dto';
|
||||
|
||||
export class BrokerResponseMetaDto {
|
||||
@ApiProperty({ nullable: true })
|
||||
@ -19,27 +16,3 @@ export class BrokerAccountsEnvelopeDto {
|
||||
@ApiProperty({ type: BrokerResponseMetaDto })
|
||||
meta!: BrokerResponseMetaDto;
|
||||
}
|
||||
|
||||
export class BrokerPortfolioEnvelopeDto {
|
||||
@ApiProperty({ type: BrokerPortfolioResponseDto })
|
||||
data!: BrokerPortfolioResponseDto;
|
||||
|
||||
@ApiProperty({ type: BrokerResponseMetaDto })
|
||||
meta!: BrokerResponseMetaDto;
|
||||
}
|
||||
|
||||
export class BrokerOperationsEnvelopeDto {
|
||||
@ApiProperty({ type: BrokerOperationsPageResponseDto })
|
||||
data!: BrokerOperationsPageResponseDto;
|
||||
|
||||
@ApiProperty({ type: BrokerResponseMetaDto })
|
||||
meta!: BrokerResponseMetaDto;
|
||||
}
|
||||
|
||||
export class BrokerOperationSyncEnvelopeDto {
|
||||
@ApiProperty({ type: BrokerOperationSyncResponseDto })
|
||||
data!: BrokerOperationSyncResponseDto;
|
||||
|
||||
@ApiProperty({ type: BrokerResponseMetaDto })
|
||||
meta!: BrokerResponseMetaDto;
|
||||
}
|
||||
|
||||
@ -1,15 +0,0 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class BrokerMoneyDto {
|
||||
@ApiProperty()
|
||||
currency!: string;
|
||||
|
||||
@ApiProperty()
|
||||
units!: string;
|
||||
|
||||
@ApiProperty()
|
||||
nano!: number;
|
||||
|
||||
@ApiProperty()
|
||||
value!: number;
|
||||
}
|
||||
@ -1,43 +0,0 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Transform } from 'class-transformer';
|
||||
import { IsDateString, IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
|
||||
|
||||
export class BrokerOperationQueryDto {
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
from?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
to?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
cursor?: string;
|
||||
|
||||
@ApiPropertyOptional({ minimum: 1, maximum: 1000, default: 100 })
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => (value === undefined ? undefined : Number(value)))
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(1000)
|
||||
limit?: number;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
instrumentId?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
operationTypes?: string;
|
||||
|
||||
@ApiPropertyOptional({ default: 'OPERATION_STATE_EXECUTED' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
state?: string;
|
||||
}
|
||||
@ -1,86 +0,0 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { BrokerMoneyDto } from './broker-money.dto';
|
||||
|
||||
const operationCategories = ['trade', 'income', 'tax', 'fee', 'transfer', 'other'] as const;
|
||||
|
||||
export class BrokerOperationResponseDto {
|
||||
@ApiProperty({ nullable: true })
|
||||
cursor!: string | null;
|
||||
|
||||
@ApiProperty()
|
||||
accountId!: string;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
id!: string | null;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
parentOperationId!: string | null;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
date!: string | null;
|
||||
|
||||
@ApiProperty()
|
||||
type!: string;
|
||||
|
||||
@ApiProperty({ enum: operationCategories })
|
||||
category!: (typeof operationCategories)[number];
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
description!: string | null;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
state!: string | null;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
instrumentUid!: string | null;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
figi!: string | null;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
ticker!: string | null;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
classCode!: string | null;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
instrumentType!: string | null;
|
||||
|
||||
@ApiProperty({ type: BrokerMoneyDto, nullable: true })
|
||||
payment!: BrokerMoneyDto | null;
|
||||
|
||||
@ApiProperty({ type: BrokerMoneyDto, nullable: true })
|
||||
price!: BrokerMoneyDto | null;
|
||||
|
||||
@ApiProperty({ type: BrokerMoneyDto, nullable: true })
|
||||
commission!: BrokerMoneyDto | null;
|
||||
|
||||
@ApiProperty({ type: BrokerMoneyDto, nullable: true })
|
||||
yield!: BrokerMoneyDto | null;
|
||||
|
||||
@ApiProperty({ type: BrokerMoneyDto, nullable: true })
|
||||
accruedInt!: BrokerMoneyDto | null;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
quantity!: number | null;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
quantityDone!: number | null;
|
||||
}
|
||||
|
||||
export class BrokerOperationsPageResponseDto {
|
||||
@ApiProperty()
|
||||
accountId!: string;
|
||||
|
||||
@ApiProperty({ type: [BrokerOperationResponseDto] })
|
||||
items!: BrokerOperationResponseDto[];
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
nextCursor!: string | null;
|
||||
|
||||
@ApiProperty()
|
||||
hasNext!: boolean;
|
||||
|
||||
@ApiProperty()
|
||||
asOf!: string;
|
||||
}
|
||||
@ -1,17 +0,0 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsISO8601 } from 'class-validator';
|
||||
|
||||
export class BrokerOperationSyncQueryDto {
|
||||
@ApiProperty({ example: '2026-06-01T00:00:00.000Z' })
|
||||
@IsISO8601()
|
||||
from!: string;
|
||||
|
||||
@ApiProperty({ example: '2026-06-17T00:00:00.000Z' })
|
||||
@IsISO8601()
|
||||
to!: string;
|
||||
}
|
||||
|
||||
export class BrokerOperationSyncResponseDto {
|
||||
@ApiProperty({ example: 42 })
|
||||
upserted!: number;
|
||||
}
|
||||
@ -1,110 +0,0 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { BrokerAccountResponseDto } from './broker-account-response.dto';
|
||||
import { BrokerMoneyDto } from './broker-money.dto';
|
||||
|
||||
export class BrokerPositionResponseDto {
|
||||
@ApiProperty({ nullable: true })
|
||||
figi!: string | null;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
instrumentUid!: string | null;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
positionUid!: string | null;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
ticker!: string | null;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
classCode!: string | null;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
instrumentType!: string | null;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
name!: string | null;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
quantity!: number | null;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
blockedLots!: number | null;
|
||||
|
||||
@ApiProperty({ type: BrokerMoneyDto, nullable: true })
|
||||
currentPrice!: BrokerMoneyDto | null;
|
||||
|
||||
@ApiProperty({ type: BrokerMoneyDto, nullable: true })
|
||||
currentValue!: BrokerMoneyDto | null;
|
||||
|
||||
@ApiProperty({ type: BrokerMoneyDto, nullable: true })
|
||||
averagePositionPrice!: BrokerMoneyDto | null;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
expectedYieldPercent!: number | null;
|
||||
|
||||
@ApiProperty({ type: BrokerMoneyDto, nullable: true })
|
||||
dailyYield!: BrokerMoneyDto | null;
|
||||
}
|
||||
|
||||
export class BrokerPortfolioTotalsDto {
|
||||
@ApiProperty({ type: BrokerMoneyDto, nullable: true })
|
||||
shares!: BrokerMoneyDto | null;
|
||||
|
||||
@ApiProperty({ type: BrokerMoneyDto, nullable: true })
|
||||
bonds!: BrokerMoneyDto | null;
|
||||
|
||||
@ApiProperty({ type: BrokerMoneyDto, nullable: true })
|
||||
etf!: BrokerMoneyDto | null;
|
||||
|
||||
@ApiProperty({ type: BrokerMoneyDto, nullable: true })
|
||||
currencies!: BrokerMoneyDto | null;
|
||||
|
||||
@ApiProperty({ type: BrokerMoneyDto, nullable: true })
|
||||
futures!: BrokerMoneyDto | null;
|
||||
|
||||
@ApiProperty({ type: BrokerMoneyDto, nullable: true })
|
||||
options!: BrokerMoneyDto | null;
|
||||
|
||||
@ApiProperty({ type: BrokerMoneyDto, nullable: true })
|
||||
structuredProducts!: BrokerMoneyDto | null;
|
||||
|
||||
@ApiProperty({ type: BrokerMoneyDto, nullable: true })
|
||||
dfa!: BrokerMoneyDto | null;
|
||||
|
||||
@ApiProperty({ type: BrokerMoneyDto, nullable: true })
|
||||
portfolio!: BrokerMoneyDto | null;
|
||||
}
|
||||
|
||||
export class BrokerPortfolioYieldsDto {
|
||||
@ApiProperty({ nullable: true })
|
||||
expectedPercent!: number | null;
|
||||
|
||||
@ApiProperty({ type: BrokerMoneyDto, nullable: true })
|
||||
daily!: BrokerMoneyDto | null;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
dailyPercent!: number | null;
|
||||
}
|
||||
|
||||
export class BrokerPortfolioResponseDto {
|
||||
@ApiProperty({ type: BrokerAccountResponseDto })
|
||||
account!: BrokerAccountResponseDto;
|
||||
|
||||
@ApiProperty({ type: BrokerPortfolioTotalsDto })
|
||||
totals!: BrokerPortfolioTotalsDto;
|
||||
|
||||
@ApiProperty({ type: BrokerPortfolioYieldsDto })
|
||||
yields!: BrokerPortfolioYieldsDto;
|
||||
|
||||
@ApiProperty({ type: [BrokerMoneyDto] })
|
||||
cash!: BrokerMoneyDto[];
|
||||
|
||||
@ApiProperty({ type: [BrokerMoneyDto] })
|
||||
blockedCash!: BrokerMoneyDto[];
|
||||
|
||||
@ApiProperty({ type: [BrokerPositionResponseDto] })
|
||||
positions!: BrokerPositionResponseDto[];
|
||||
|
||||
@ApiProperty()
|
||||
asOf!: string;
|
||||
}
|
||||
@ -1,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 { ConfigService } from '@nestjs/config';
|
||||
import { ChannelCredentials, ClientUnaryCall, Metadata, ServiceError, status } from '@grpc/grpc-js';
|
||||
import { mkdtempSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { ClientUnaryCall, Metadata, ServiceError, status } from '@grpc/grpc-js';
|
||||
import { TBankClientService } from './tbank-client.service';
|
||||
|
||||
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 () => {
|
||||
const missingConfig = {
|
||||
get: vi.fn((key: string, fallback?: unknown) =>
|
||||
|
||||
@ -17,7 +17,6 @@ import {
|
||||
} from '@grpc/grpc-js';
|
||||
import { loadSync } from '@grpc/proto-loader';
|
||||
import PQueue from 'p-queue';
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { TBANK_PROTO_FILES, TBANK_PROTO_PACKAGE } from '../tbank.config';
|
||||
|
||||
@ -45,29 +44,19 @@ export class TBankClientService {
|
||||
intervalCap: this.configService.get<number>('app.tbank.rateLimitPerSecond', 5),
|
||||
});
|
||||
|
||||
const protoRoot = this.resolveProtoRoot();
|
||||
const definition = loadSync(
|
||||
Object.values(TBANK_PROTO_FILES).map((fileName) => join(protoRoot, fileName)),
|
||||
{
|
||||
const protoRoot = join(__dirname, '..', 'proto', 'contracts');
|
||||
const definition = loadSync(Object.values(TBANK_PROTO_FILES), {
|
||||
includeDirs: [protoRoot],
|
||||
keepCase: false,
|
||||
longs: String,
|
||||
enums: String,
|
||||
defaults: true,
|
||||
oneofs: true,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
this.packageDefinition = loadPackageDefinition(definition);
|
||||
}
|
||||
|
||||
private resolveProtoRoot(): string {
|
||||
const distProtoRoot = join(__dirname, '..', 'proto', 'contracts');
|
||||
if (existsSync(distProtoRoot)) return distProtoRoot;
|
||||
|
||||
return join(process.cwd(), 'src', 'modules', 'tbank', 'proto', 'contracts');
|
||||
}
|
||||
|
||||
createMetadata(): Metadata {
|
||||
const token = this.configService.get<string>('app.tbank.token', '');
|
||||
if (!token) {
|
||||
@ -108,7 +97,7 @@ export class TBankClientService {
|
||||
const ServiceCtor = namespace[serviceName] as GrpcServiceConstructor;
|
||||
const client = new ServiceCtor(
|
||||
this.configService.get<string>('app.tbank.baseUrl', 'invest-public-api.tbank.ru:443'),
|
||||
this.createChannelCredentials(),
|
||||
ChannelCredentials.createSsl(),
|
||||
);
|
||||
|
||||
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 {
|
||||
const trackingId = error.metadata?.get('x-tracking-id')?.[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', () => {
|
||||
process.env.T_BANK_TOKEN = 'secret-token';
|
||||
process.env.T_BANK_BASE_URL = 'sandbox-invest-public-api.tbank.ru:443';
|
||||
process.env.T_BANK_CA_CERT_PATH = '/tmp/tbank-root-ca.pem';
|
||||
process.env.T_BANK_RATE_LIMIT_PER_SECOND = '2';
|
||||
process.env.CACHE_TBANK_ACCOUNTS_TTL = '120';
|
||||
|
||||
@ -34,7 +33,6 @@ describe('T-Bank configuration', () => {
|
||||
|
||||
expect(config.tbank.token).toBe('secret-token');
|
||||
expect(config.tbank.baseUrl).toBe('sandbox-invest-public-api.tbank.ru:443');
|
||||
expect(config.tbank.caCertPath).toBe('/tmp/tbank-root-ca.pem');
|
||||
expect(config.tbank.rateLimitPerSecond).toBe(2);
|
||||
expect(config.cache.tbankAccountsTtl).toBe(120);
|
||||
});
|
||||
|
||||
@ -1,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 { ApiResponse } from '../../common/dto/api-response.dto';
|
||||
import { Roles } from '../auth/decorators/roles.decorator';
|
||||
import {
|
||||
BrokerAccountsEnvelopeDto,
|
||||
BrokerOperationSyncEnvelopeDto,
|
||||
BrokerOperationsEnvelopeDto,
|
||||
BrokerPortfolioEnvelopeDto,
|
||||
} from './dto/broker-envelope.dto';
|
||||
import { BrokerOperationQueryDto } from './dto/broker-operation-query.dto';
|
||||
import { BrokerOperationSyncQueryDto } from './dto/broker-operation-sync-query.dto';
|
||||
import { BrokerAccountsEnvelopeDto } from './dto/broker-envelope.dto';
|
||||
import { BrokerAccountsService } from './services/broker-accounts.service';
|
||||
import { BrokerOperationSyncService } from './services/broker-operation-sync.service';
|
||||
import { BrokerOperationsService } from './services/broker-operations.service';
|
||||
import { BrokerPortfolioService } from './services/broker-portfolio.service';
|
||||
|
||||
@ApiTags('Broker')
|
||||
@ApiBearerAuth()
|
||||
@Roles('user')
|
||||
@Controller('broker')
|
||||
export class TBankController {
|
||||
constructor(
|
||||
private readonly brokerAccountsService: BrokerAccountsService,
|
||||
private readonly brokerPortfolioService: BrokerPortfolioService,
|
||||
private readonly brokerOperationsService: BrokerOperationsService,
|
||||
private readonly brokerOperationSyncService: BrokerOperationSyncService,
|
||||
) {}
|
||||
constructor(private readonly brokerAccountsService: BrokerAccountsService) {}
|
||||
|
||||
@Get('accounts')
|
||||
@ApiOperation({ summary: 'Get open T-Bank brokerage and IIS accounts' })
|
||||
@ApiOkResponse({ type: BrokerAccountsEnvelopeDto })
|
||||
async getAccounts() {
|
||||
const result = await this.brokerAccountsService.findAll();
|
||||
return new ApiResponse(result.data, result.meta.fromCache, result.meta.cachedAt);
|
||||
}
|
||||
|
||||
@Get('accounts/:accountId/portfolio')
|
||||
@ApiOperation({ summary: 'Get T-Bank broker account portfolio with cash and positions' })
|
||||
@ApiOkResponse({ type: BrokerPortfolioEnvelopeDto })
|
||||
async getPortfolio(@Param('accountId') accountId: string) {
|
||||
const result = await this.brokerPortfolioService.getPortfolio(accountId);
|
||||
return new ApiResponse(result.data, result.meta.fromCache, result.meta.cachedAt);
|
||||
}
|
||||
|
||||
@Get('accounts/:accountId/operations')
|
||||
@ApiOperation({ summary: 'Get paginated T-Bank broker account operations' })
|
||||
@ApiOkResponse({ type: BrokerOperationsEnvelopeDto })
|
||||
async getOperations(
|
||||
@Param('accountId') accountId: string,
|
||||
@Query() query: BrokerOperationQueryDto,
|
||||
) {
|
||||
const result = await this.brokerOperationsService.getOperations(accountId, query);
|
||||
return new ApiResponse(result.data, result.meta.fromCache, result.meta.cachedAt);
|
||||
}
|
||||
|
||||
@Post('accounts/:accountId/operations/sync')
|
||||
@ApiOperation({ summary: 'Synchronize T-Bank broker account operations into local history' })
|
||||
@ApiOkResponse({ type: BrokerOperationSyncEnvelopeDto })
|
||||
async syncOperations(
|
||||
@Param('accountId') accountId: string,
|
||||
@Query() query: BrokerOperationSyncQueryDto,
|
||||
) {
|
||||
const result = await this.brokerOperationSyncService.syncAccount(accountId, query);
|
||||
return new ApiResponse(result);
|
||||
return this.brokerAccountsService.findAll();
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,29 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TBankController } from './tbank.controller';
|
||||
import { BrokerAccountsService } from './services/broker-accounts.service';
|
||||
import { BrokerInstrumentsService } from './services/broker-instruments.service';
|
||||
import { BrokerOperationSyncService } from './services/broker-operation-sync.service';
|
||||
import { BrokerOperationsService } from './services/broker-operations.service';
|
||||
import { BrokerPortfolioService } from './services/broker-portfolio.service';
|
||||
import { TBankClientService } from './services/tbank-client.service';
|
||||
|
||||
@Module({
|
||||
controllers: [TBankController],
|
||||
providers: [
|
||||
TBankClientService,
|
||||
BrokerAccountsService,
|
||||
BrokerInstrumentsService,
|
||||
BrokerPortfolioService,
|
||||
BrokerOperationsService,
|
||||
BrokerOperationSyncService,
|
||||
],
|
||||
exports: [
|
||||
TBankClientService,
|
||||
BrokerAccountsService,
|
||||
BrokerInstrumentsService,
|
||||
BrokerPortfolioService,
|
||||
BrokerOperationsService,
|
||||
BrokerOperationSyncService,
|
||||
],
|
||||
providers: [TBankClientService, BrokerAccountsService],
|
||||
exports: [TBankClientService, BrokerAccountsService],
|
||||
})
|
||||
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-009](ADR-009-portfolio-domain) | Accepted | Доменная модель портфеля |
|
||||
| [ADR-010](ADR-010-backend-price-computation) | Accepted | Расчёт цен на backend |
|
||||
| [ADR-011](ADR-011-tbank-invest-grpc) | Accepted | Интеграция с T-Bank Invest через gRPC |
|
||||
|
||||
Все опубликованные ADR находятся в `apps/docs/docs/adr/` и отображаются в этом Docusaurus-разделе.
|
||||
|
||||
@ -37,7 +37,7 @@ flowchart LR
|
||||
|
||||
```typescript
|
||||
getOrFetch<T>(
|
||||
keyPrefix: string, // 'marketdata' | 'history' | 'candles' | 'search' | 'bond' | 'dividends' | 'tbank:*'
|
||||
keyPrefix: string, // 'marketdata' | 'history' | 'candles' | 'search' | 'bond' | 'dividends'
|
||||
keyParts: string[], // ['shares', 'SBER'] | ['SBER', '2026-06-13', '2026-06-14']
|
||||
fetchFn: () => Promise<T>,
|
||||
ttlConfigKey: string, // 'marketDataTtl' | 'historyTtl' | etc.
|
||||
@ -78,14 +78,3 @@ export class CacheModule {}
|
||||
| Спецификация инструмента | `securityTtl` | 86400s (24 ч) | `CACHE_SECURITY_TTL` |
|
||||
| Поиск | `searchTtl` | 3600s (1 ч) | `CACHE_SEARCH_TTL` |
|
||||
| Дивиденды | `dividendsTtl` | 86400s (24 ч) | `CACHE_DIVIDENDS_TTL` |
|
||||
| Счета T-Bank | `tbankAccountsTtl` | 3600s (1 ч) | `CACHE_TBANK_ACCOUNTS_TTL` |
|
||||
| Портфель T-Bank | `tbankPortfolioTtl` | 60s (1 мин) | `CACHE_TBANK_PORTFOLIO_TTL` |
|
||||
| Операции T-Bank | `tbankOperationsTtl` | 300s (5 мин) | `CACHE_TBANK_OPERATIONS_TTL` |
|
||||
| Инструменты T-Bank | `tbankInstrumentTtl` | 86400s (24 ч) | `CACHE_TBANK_INSTRUMENT_TTL` |
|
||||
|
||||
## T-Bank cache
|
||||
|
||||
`TBankModule` использует те же механики `CacheService`, но с отдельными key prefixes
|
||||
`tbank:accounts`, `tbank:portfolio`, `tbank:positions`, `tbank:operations` и `tbank:instrument`.
|
||||
Это позволяет держать агрессивно короткий TTL для текущего портфеля и более длинный TTL для
|
||||
справочных данных инструментов.
|
||||
|
||||
@ -11,22 +11,12 @@
|
||||
| `MOEX_RATE_LIMIT` | `10` | Максимум запросов в секунду к MOEX |
|
||||
| `MOEX_CIRCUIT_BREAKER_THRESHOLD` | `5` | Количество ошибок до открытия circuit breaker |
|
||||
| `MOEX_CIRCUIT_BREAKER_RESET_SECONDS` | `30` | Время в секундах до сброса circuit breaker |
|
||||
| `T_BANK_TOKEN` | `''` | Server-side токен T-Bank Invest |
|
||||
| `T_BANK_BASE_URL` | `invest-public-api.tbank.ru:443` | gRPC endpoint T-Bank Invest |
|
||||
| `T_BANK_CA_CERT_PATH` | `''` | Путь к PEM root CA для gRPC TLS, если локальная сеть подменяет сертификаты |
|
||||
| `T_BANK_APP_NAME` | `ksv741.moex-vibe` | Metadata приложения для T-Bank |
|
||||
| `T_BANK_RATE_LIMIT_PER_SECOND` | `5` | Локальный rate limiter для T-Bank |
|
||||
| `T_BANK_REQUEST_TIMEOUT_MS` | `10000` | Deadline gRPC-запроса (мс) |
|
||||
| `CACHE_MARKET_DATA_TTL` | `900` | TTL рыночных данных (секунды) |
|
||||
| `CACHE_HISTORY_TTL` | `3600` | TTL истории торгов (секунды) |
|
||||
| `CACHE_CANDLES_TTL` | `3600` | TTL свечей (секунды) |
|
||||
| `CACHE_SECURITY_TTL` | `86400` | TTL спецификации инструмента (секунды) |
|
||||
| `CACHE_SEARCH_TTL` | `3600` | TTL результатов поиска (секунды) |
|
||||
| `CACHE_DIVIDENDS_TTL` | `86400` | TTL дивидендов (секунды) |
|
||||
| `CACHE_TBANK_ACCOUNTS_TTL` | `3600` | TTL списка брокерских счетов T-Bank (секунды) |
|
||||
| `CACHE_TBANK_PORTFOLIO_TTL` | `60` | TTL брокерского портфеля T-Bank (секунды) |
|
||||
| `CACHE_TBANK_OPERATIONS_TTL` | `300` | TTL страницы операций T-Bank (секунды) |
|
||||
| `CACHE_TBANK_INSTRUMENT_TTL` | `86400` | TTL метаданных инструментов T-Bank (секунды) |
|
||||
|
||||
## Файл конфигурации
|
||||
|
||||
@ -43,14 +33,6 @@ registerAs('app', () => ({
|
||||
process.env.MOEX_CIRCUIT_BREAKER_RESET_SECONDS || '30', 10,
|
||||
),
|
||||
},
|
||||
tbank: {
|
||||
token: process.env.T_BANK_TOKEN || '',
|
||||
baseUrl: process.env.T_BANK_BASE_URL || 'invest-public-api.tbank.ru:443',
|
||||
caCertPath: process.env.T_BANK_CA_CERT_PATH || '',
|
||||
appName: process.env.T_BANK_APP_NAME || 'ksv741.moex-vibe',
|
||||
rateLimitPerSecond: parseInt(process.env.T_BANK_RATE_LIMIT_PER_SECOND || '5', 10),
|
||||
requestTimeoutMs: parseInt(process.env.T_BANK_REQUEST_TIMEOUT_MS || '10000', 10),
|
||||
},
|
||||
cache: {
|
||||
marketDataTtl: parseInt(process.env.CACHE_MARKET_DATA_TTL || '900', 10),
|
||||
historyTtl: parseInt(process.env.CACHE_HISTORY_TTL || '3600', 10),
|
||||
@ -58,10 +40,6 @@ registerAs('app', () => ({
|
||||
securityTtl: parseInt(process.env.CACHE_SECURITY_TTL || '86400', 10),
|
||||
searchTtl: parseInt(process.env.CACHE_SEARCH_TTL || '3600', 10),
|
||||
dividendsTtl: parseInt(process.env.CACHE_DIVIDENDS_TTL || '86400', 10),
|
||||
tbankAccountsTtl: parseInt(process.env.CACHE_TBANK_ACCOUNTS_TTL || '3600', 10),
|
||||
tbankPortfolioTtl: parseInt(process.env.CACHE_TBANK_PORTFOLIO_TTL || '60', 10),
|
||||
tbankOperationsTtl: parseInt(process.env.CACHE_TBANK_OPERATIONS_TTL || '300', 10),
|
||||
tbankInstrumentTtl: parseInt(process.env.CACHE_TBANK_INSTRUMENT_TTL || '86400', 10),
|
||||
},
|
||||
}));
|
||||
```
|
||||
|
||||
@ -8,7 +8,7 @@
|
||||
flowchart TB
|
||||
AppModule["AppModule"]
|
||||
GlobalModules["Глобальные модули<br/>ConfigModule<br/>PrismaModule<br/>CacheModule<br/>MoexClientModule"]
|
||||
FeatureModules["Feature-модули<br/>HealthModule<br/>AuthModule<br/>PortfolioModule<br/>SecuritiesModule<br/>SharesModule<br/>BondsModule<br/>CandlesModule<br/>TBankModule"]
|
||||
FeatureModules["Feature-модули<br/>HealthModule<br/>AuthModule<br/>PortfolioModule<br/>SecuritiesModule<br/>SharesModule<br/>BondsModule<br/>CandlesModule"]
|
||||
|
||||
AppModule --> GlobalModules
|
||||
AppModule --> FeatureModules
|
||||
@ -21,12 +21,10 @@ flowchart TB
|
||||
AuthModule["AuthModule"]
|
||||
PortfolioModule["PortfolioModule"]
|
||||
MarketModules["SecuritiesModule<br/>SharesModule<br/>BondsModule<br/>CandlesModule"]
|
||||
TBankModule["TBankModule"]
|
||||
|
||||
PrismaService["PrismaService"]
|
||||
CacheService["CacheService"]
|
||||
MoexClientService["MoexClientService"]
|
||||
TBankClientService["TBankClientService"]
|
||||
|
||||
PrismaModule --> PrismaService
|
||||
CacheModule --> CacheService
|
||||
@ -39,10 +37,6 @@ flowchart TB
|
||||
|
||||
MarketModules --> CacheService
|
||||
MarketModules --> MoexClientService
|
||||
|
||||
TBankModule --> CacheService
|
||||
TBankModule --> PrismaService
|
||||
TBankModule --> TBankClientService
|
||||
```
|
||||
|
||||
## Список модулей
|
||||
@ -59,7 +53,6 @@ flowchart TB
|
||||
| `BondsModule` | Нет | `modules/bonds/` | Облигации |
|
||||
| `CandlesModule` | Нет | `modules/candles/` | Свечи OHLCV |
|
||||
| `PortfolioModule` | Нет | `modules/portfolio/` | Пользовательские портфели и аналитика |
|
||||
| `TBankModule` | Нет | `modules/tbank/` | Read-only брокерские портфели T-Bank Invest |
|
||||
|
||||
### PrismaModule
|
||||
|
||||
@ -138,18 +131,3 @@ flowchart TB
|
||||
- Обогащение позиций текущими ценами из MOEX
|
||||
- Расчёт summary, PnL и долей портфеля
|
||||
- Все endpoints защищены JWT
|
||||
|
||||
### TBankModule
|
||||
|
||||
Read-only интеграция с T-Bank Invest для брокерских счетов и ИИС.
|
||||
|
||||
- `TBankClientService` создаёт gRPC clients по vendored proto-контрактам и добавляет metadata
|
||||
`Authorization` + `x-app-name`
|
||||
- `BrokerAccountsService` фильтрует только открытые брокерские счета и ИИС
|
||||
- `BrokerPortfolioService` объединяет портфель, позиции, cash и метаданные инструментов
|
||||
- `BrokerOperationsService` отдаёт cursor-paginated историю операций
|
||||
- `BrokerOperationSyncService` сохраняет историю операций в отдельные Prisma-таблицы
|
||||
- Broker endpoints требуют роль `user`; текущая single-user версия использует один server-side
|
||||
`T_BANK_TOKEN`
|
||||
- Direct-read endpoints используют `CacheService` с T-Bank TTL и не записывают данные в ручной
|
||||
`PortfolioModule`
|
||||
|
||||
@ -3,12 +3,6 @@
|
||||
`PortfolioModule` позволяет пользователям создавать и вести виртуальные инвестиционные портфели для
|
||||
аналитики и отслеживания позиций.
|
||||
|
||||
## Ручные портфели и брокерские портфели
|
||||
|
||||
`PortfolioModule` остаётся доменом ручных виртуальных портфелей. Брокерские счета T-Bank Invest
|
||||
экспортируются отдельным `TBankModule` под `/api/v1/broker/*` и не сохраняются как записи
|
||||
`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/caching',
|
||||
'backend/moex-client',
|
||||
'backend/tbank-invest',
|
||||
'backend/securities',
|
||||
'backend/portfolio',
|
||||
],
|
||||
@ -64,7 +63,6 @@ const sidebars: SidebarsConfig = {
|
||||
'adr/ADR-008-auth-system',
|
||||
'adr/ADR-009-portfolio-domain',
|
||||
'adr/ADR-010-backend-price-computation',
|
||||
'adr/ADR-011-tbank-invest-grpc',
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@ -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');
|
||||
});
|
||||
|
||||
it('supports the single API envelope shape documented by Swagger', async () => {
|
||||
server.use(
|
||||
http.get(`${API}/test-single-envelope`, () =>
|
||||
HttpResponse.json({
|
||||
data: { ok: true },
|
||||
meta: { fromCache: true, cachedAt: '2026-06-17T00:00:00.000Z' },
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const result = await request<{ ok: boolean }>('/api/v1/test-single-envelope');
|
||||
|
||||
expect(result).toEqual({
|
||||
data: { ok: true },
|
||||
meta: { fromCache: true, cachedAt: '2026-06-17T00:00:00.000Z' },
|
||||
});
|
||||
});
|
||||
|
||||
it('includes Authorization header when token is set', async () => {
|
||||
setAccessToken('test-token');
|
||||
let capturedAuth: string | null = null;
|
||||
|
||||
@ -40,31 +40,14 @@ async function refreshTokens(): Promise<boolean> {
|
||||
credentials: 'include',
|
||||
});
|
||||
if (!res.ok) return false;
|
||||
const json = await res.json();
|
||||
accessToken = normalizeEnvelope<AuthResponse>(json).data.accessToken;
|
||||
const json: ApiEnvelope<{ data: AuthResponse; meta: ApiResponseMeta }> = await res.json();
|
||||
accessToken = json.data.data.accessToken;
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeEnvelope<T>(json: unknown): { data: T; meta: ApiResponseMeta } {
|
||||
const envelope = json as ApiEnvelope<T | { data: T; meta: ApiResponseMeta }>;
|
||||
if (
|
||||
envelope.data &&
|
||||
typeof envelope.data === 'object' &&
|
||||
'data' in envelope.data &&
|
||||
'meta' in envelope.data
|
||||
) {
|
||||
return envelope.data as { data: T; meta: ApiResponseMeta };
|
||||
}
|
||||
|
||||
return {
|
||||
data: envelope.data as T,
|
||||
meta: envelope.meta,
|
||||
};
|
||||
}
|
||||
|
||||
async function handleUnauthorized(): Promise<boolean> {
|
||||
if (isRefreshing && refreshPromise) {
|
||||
return refreshPromise;
|
||||
@ -131,8 +114,8 @@ export async function request<T>(
|
||||
throw new Error(`Ошибка API: ${res.status} ${res.statusText}${text ? ` - ${text}` : ''}`);
|
||||
}
|
||||
|
||||
const json = await res.json();
|
||||
return normalizeEnvelope<T>(json);
|
||||
const json: ApiEnvelope<{ data: T; meta: ApiResponseMeta }> = await res.json();
|
||||
return json.data;
|
||||
}
|
||||
|
||||
export function getHealth(): Promise<{ data: HealthResponse; meta: ApiResponseMeta }> {
|
||||
|
||||
@ -245,94 +245,3 @@ export interface ScreenerResult {
|
||||
pageSize: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
export interface BrokerMoney {
|
||||
currency: string;
|
||||
units: string;
|
||||
nano: number;
|
||||
value: number;
|
||||
}
|
||||
|
||||
export interface BrokerAccount {
|
||||
id: string;
|
||||
type: 'brokerage' | 'iis';
|
||||
name: string;
|
||||
status: string;
|
||||
openedAt: string | null;
|
||||
accessLevel: string | null;
|
||||
}
|
||||
|
||||
export interface BrokerPosition {
|
||||
figi: string | null;
|
||||
instrumentUid: string | null;
|
||||
positionUid: string | null;
|
||||
ticker: string | null;
|
||||
classCode: string | null;
|
||||
instrumentType: string | null;
|
||||
name: string | null;
|
||||
quantity: number | null;
|
||||
blockedLots: number | null;
|
||||
currentPrice: BrokerMoney | null;
|
||||
currentValue: BrokerMoney | null;
|
||||
averagePositionPrice: BrokerMoney | null;
|
||||
expectedYieldPercent: number | null;
|
||||
dailyYield: BrokerMoney | null;
|
||||
}
|
||||
|
||||
export interface BrokerPortfolio {
|
||||
account: BrokerAccount;
|
||||
totals: {
|
||||
shares: BrokerMoney | null;
|
||||
bonds: BrokerMoney | null;
|
||||
etf: BrokerMoney | null;
|
||||
currencies: BrokerMoney | null;
|
||||
futures: BrokerMoney | null;
|
||||
options: BrokerMoney | null;
|
||||
structuredProducts: BrokerMoney | null;
|
||||
dfa: BrokerMoney | null;
|
||||
portfolio: BrokerMoney | null;
|
||||
};
|
||||
yields: {
|
||||
expectedPercent: number | null;
|
||||
daily: BrokerMoney | null;
|
||||
dailyPercent: number | null;
|
||||
};
|
||||
cash: BrokerMoney[];
|
||||
blockedCash: BrokerMoney[];
|
||||
positions: BrokerPosition[];
|
||||
asOf: string;
|
||||
}
|
||||
|
||||
export type BrokerOperationCategory = 'trade' | 'income' | 'tax' | 'fee' | 'transfer' | 'other';
|
||||
|
||||
export interface BrokerOperation {
|
||||
cursor: string | null;
|
||||
accountId: string;
|
||||
id: string | null;
|
||||
parentOperationId: string | null;
|
||||
date: string | null;
|
||||
type: string;
|
||||
category: BrokerOperationCategory;
|
||||
description: string | null;
|
||||
state: string | null;
|
||||
instrumentUid: string | null;
|
||||
figi: string | null;
|
||||
ticker: string | null;
|
||||
classCode: string | null;
|
||||
instrumentType: string | null;
|
||||
payment: BrokerMoney | null;
|
||||
price: BrokerMoney | null;
|
||||
commission: BrokerMoney | null;
|
||||
yield: BrokerMoney | null;
|
||||
accruedInt: BrokerMoney | null;
|
||||
quantity: number | null;
|
||||
quantityDone: number | null;
|
||||
}
|
||||
|
||||
export interface BrokerOperationsPage {
|
||||
accountId: string;
|
||||
items: BrokerOperation[];
|
||||
nextCursor: string | null;
|
||||
hasNext: boolean;
|
||||
asOf: string;
|
||||
}
|
||||
|
||||
@ -383,74 +383,6 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
'/api/v1/broker/accounts': {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** Get open T-Bank brokerage and IIS accounts */
|
||||
get: operations['TBankController_getAccounts'];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
'/api/v1/broker/accounts/{accountId}/portfolio': {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** Get T-Bank broker account portfolio with cash and positions */
|
||||
get: operations['TBankController_getPortfolio'];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
'/api/v1/broker/accounts/{accountId}/operations': {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** Get paginated T-Bank broker account operations */
|
||||
get: operations['TBankController_getOperations'];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
'/api/v1/broker/accounts/{accountId}/operations/sync': {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/** Synchronize T-Bank broker account operations into local history */
|
||||
post: operations['TBankController_syncOperations'];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
}
|
||||
export type webhooks = Record<string, never>;
|
||||
export interface components {
|
||||
@ -757,117 +689,6 @@ export interface components {
|
||||
data: components['schemas']['AnalyticsResponseDto'];
|
||||
meta: components['schemas']['PortfolioResponseMetaDto'];
|
||||
};
|
||||
BrokerAccountResponseDto: {
|
||||
id: string;
|
||||
/** @enum {string} */
|
||||
type: 'brokerage' | 'iis';
|
||||
name: string;
|
||||
status: string;
|
||||
openedAt: Record<string, never> | null;
|
||||
accessLevel: Record<string, never> | null;
|
||||
};
|
||||
BrokerResponseMetaDto: {
|
||||
cachedAt: Record<string, never> | null;
|
||||
fromCache: boolean;
|
||||
};
|
||||
BrokerAccountsEnvelopeDto: {
|
||||
data: components['schemas']['BrokerAccountResponseDto'][];
|
||||
meta: components['schemas']['BrokerResponseMetaDto'];
|
||||
};
|
||||
BrokerMoneyDto: {
|
||||
currency: string;
|
||||
units: string;
|
||||
nano: number;
|
||||
value: number;
|
||||
};
|
||||
BrokerPortfolioTotalsDto: {
|
||||
shares: components['schemas']['BrokerMoneyDto'] | null;
|
||||
bonds: components['schemas']['BrokerMoneyDto'] | null;
|
||||
etf: components['schemas']['BrokerMoneyDto'] | null;
|
||||
currencies: components['schemas']['BrokerMoneyDto'] | null;
|
||||
futures: components['schemas']['BrokerMoneyDto'] | null;
|
||||
options: components['schemas']['BrokerMoneyDto'] | null;
|
||||
structuredProducts: components['schemas']['BrokerMoneyDto'] | null;
|
||||
dfa: components['schemas']['BrokerMoneyDto'] | null;
|
||||
portfolio: components['schemas']['BrokerMoneyDto'] | null;
|
||||
};
|
||||
BrokerPortfolioYieldsDto: {
|
||||
expectedPercent: Record<string, never> | null;
|
||||
daily: components['schemas']['BrokerMoneyDto'] | null;
|
||||
dailyPercent: Record<string, never> | null;
|
||||
};
|
||||
BrokerPositionResponseDto: {
|
||||
figi: Record<string, never> | null;
|
||||
instrumentUid: Record<string, never> | null;
|
||||
positionUid: Record<string, never> | null;
|
||||
ticker: Record<string, never> | null;
|
||||
classCode: Record<string, never> | null;
|
||||
instrumentType: Record<string, never> | null;
|
||||
name: Record<string, never> | null;
|
||||
quantity: Record<string, never> | null;
|
||||
blockedLots: Record<string, never> | null;
|
||||
currentPrice: components['schemas']['BrokerMoneyDto'] | null;
|
||||
currentValue: components['schemas']['BrokerMoneyDto'] | null;
|
||||
averagePositionPrice: components['schemas']['BrokerMoneyDto'] | null;
|
||||
expectedYieldPercent: Record<string, never> | null;
|
||||
dailyYield: components['schemas']['BrokerMoneyDto'] | null;
|
||||
};
|
||||
BrokerPortfolioResponseDto: {
|
||||
account: components['schemas']['BrokerAccountResponseDto'];
|
||||
totals: components['schemas']['BrokerPortfolioTotalsDto'];
|
||||
yields: components['schemas']['BrokerPortfolioYieldsDto'];
|
||||
cash: components['schemas']['BrokerMoneyDto'][];
|
||||
blockedCash: components['schemas']['BrokerMoneyDto'][];
|
||||
positions: components['schemas']['BrokerPositionResponseDto'][];
|
||||
asOf: string;
|
||||
};
|
||||
BrokerPortfolioEnvelopeDto: {
|
||||
data: components['schemas']['BrokerPortfolioResponseDto'];
|
||||
meta: components['schemas']['BrokerResponseMetaDto'];
|
||||
};
|
||||
BrokerOperationResponseDto: {
|
||||
cursor: Record<string, never> | null;
|
||||
accountId: string;
|
||||
id: Record<string, never> | null;
|
||||
parentOperationId: Record<string, never> | null;
|
||||
date: Record<string, never> | null;
|
||||
type: string;
|
||||
/** @enum {string} */
|
||||
category: 'trade' | 'income' | 'tax' | 'fee' | 'transfer' | 'other';
|
||||
description: Record<string, never> | null;
|
||||
state: Record<string, never> | null;
|
||||
instrumentUid: Record<string, never> | null;
|
||||
figi: Record<string, never> | null;
|
||||
ticker: Record<string, never> | null;
|
||||
classCode: Record<string, never> | null;
|
||||
instrumentType: Record<string, never> | null;
|
||||
payment: components['schemas']['BrokerMoneyDto'] | null;
|
||||
price: components['schemas']['BrokerMoneyDto'] | null;
|
||||
commission: components['schemas']['BrokerMoneyDto'] | null;
|
||||
yield: components['schemas']['BrokerMoneyDto'] | null;
|
||||
accruedInt: components['schemas']['BrokerMoneyDto'] | null;
|
||||
quantity: Record<string, never> | null;
|
||||
quantityDone: Record<string, never> | null;
|
||||
};
|
||||
BrokerOperationsPageResponseDto: {
|
||||
accountId: string;
|
||||
items: components['schemas']['BrokerOperationResponseDto'][];
|
||||
nextCursor: Record<string, never> | null;
|
||||
hasNext: boolean;
|
||||
asOf: string;
|
||||
};
|
||||
BrokerOperationsEnvelopeDto: {
|
||||
data: components['schemas']['BrokerOperationsPageResponseDto'];
|
||||
meta: components['schemas']['BrokerResponseMetaDto'];
|
||||
};
|
||||
BrokerOperationSyncResponseDto: {
|
||||
/** @example 42 */
|
||||
upserted: number;
|
||||
};
|
||||
BrokerOperationSyncEnvelopeDto: {
|
||||
data: components['schemas']['BrokerOperationSyncResponseDto'];
|
||||
meta: components['schemas']['BrokerResponseMetaDto'];
|
||||
};
|
||||
};
|
||||
responses: never;
|
||||
parameters: never;
|
||||
@ -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',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
flexWrap: 'wrap',
|
||||
gap: 24,
|
||||
}}
|
||||
>
|
||||
@ -35,9 +34,7 @@ export function Layout() {
|
||||
>
|
||||
MoexVibe
|
||||
</Link>
|
||||
<div style={{ flex: '1 1 280px', minWidth: 220, maxWidth: 420 }}>
|
||||
<SearchBar />
|
||||
</div>
|
||||
<Link
|
||||
to="/portfolios"
|
||||
style={{
|
||||
@ -49,17 +46,6 @@ export function Layout() {
|
||||
>
|
||||
Портфели
|
||||
</Link>
|
||||
<Link
|
||||
to="/broker"
|
||||
style={{
|
||||
fontSize: 14,
|
||||
color: 'var(--color-text)',
|
||||
textDecoration: 'none',
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
Брокер
|
||||
</Link>
|
||||
<Link
|
||||
to="/screener"
|
||||
style={{
|
||||
@ -72,15 +58,7 @@ export function Layout() {
|
||||
Скринер
|
||||
</Link>
|
||||
|
||||
<div
|
||||
style={{
|
||||
marginLeft: 'auto',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
flexWrap: 'wrap',
|
||||
}}
|
||||
>
|
||||
<div style={{ marginLeft: 'auto', display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
{isAuthenticated ? (
|
||||
<>
|
||||
<Link
|
||||
@ -126,15 +104,7 @@ export function Layout() {
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
<main
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: 'clamp(16px, 4vw, 24px)',
|
||||
maxWidth: 1200,
|
||||
width: '100%',
|
||||
margin: '0 auto',
|
||||
}}
|
||||
>
|
||||
<main style={{ flex: 1, padding: 24, maxWidth: 1200, width: '100%', margin: '0 auto' }}>
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@ -38,7 +38,7 @@ export function PortfolioCard({ portfolio }: { portfolio: Portfolio }) {
|
||||
<h3 style={{ margin: 0, fontSize: 16, fontWeight: 600, flex: 1 }}>{portfolio.name}</h3>
|
||||
<div style={{ textAlign: 'right' }}>
|
||||
<div style={{ fontSize: 20, fontWeight: 700, lineHeight: 1.2 }}>
|
||||
{portfolio.totalValue?.toLocaleString('ru-RU', {
|
||||
{portfolio.totalValue.toLocaleString('ru-RU', {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
})}
|
||||
|
||||
@ -19,7 +19,7 @@ export function PortfolioSummary({ portfolio }: { portfolio: PortfolioDetail })
|
||||
Общая стоимость
|
||||
</div>
|
||||
<div style={{ fontSize: 24, fontWeight: 700 }}>
|
||||
{portfolio.totalValue?.toLocaleString('ru-RU', {
|
||||
{portfolio.totalValue.toLocaleString('ru-RU', {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
})}
|
||||
|
||||
@ -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 { PortfolioDetailPage } from './pages/portfolios/PortfolioDetailPage';
|
||||
import { ScreenerPage } from './pages/screener/ScreenerPage';
|
||||
import { BrokerAccountsPage } from './pages/broker/BrokerAccountsPage';
|
||||
import { BrokerAccountDetailPage } from './pages/broker/BrokerAccountDetailPage';
|
||||
|
||||
export function AppRoutes() {
|
||||
return (
|
||||
@ -47,22 +45,6 @@ export function AppRoutes() {
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/broker"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<BrokerAccountsPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/broker/:accountId"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<BrokerAccountDetailPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
</Route>
|
||||
</Routes>
|
||||
);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user