Compare commits

...

10 Commits

Author SHA1 Message Date
8c2a6c9e3c fix: trust tbank grpc root certificate
All checks were successful
CI / lint (pull_request) Successful in 2m11s
CI / test (pull_request) Successful in 1m58s
CI / build (pull_request) Successful in 2m3s
CI / lint (push) Successful in 1m57s
CI / test (push) Successful in 2m4s
CI / build (push) Successful in 2m8s
2026-06-17 07:39:37 +03:00
ea916dfec9 fix: restrict and expose tbank broker sync 2026-06-17 06:57:49 +03:00
b10a2cfb0d docs: document tbank invest integration 2026-06-17 06:49:09 +03:00
3fe4e7a4ec fix: harden tbank operation sync 2026-06-17 06:47:18 +03:00
6e30177294 feat: persist tbank broker operations 2026-06-17 06:44:45 +03:00
ead40bff6f feat: add broker portfolio UI 2026-06-16 23:05:52 +03:00
50bff3dbe7 feat: add broker frontend api hooks 2026-06-16 22:56:20 +03:00
b848d256d1 fix: load tbank proto contracts at runtime 2026-06-16 22:54:21 +03:00
d01d2b9f7f feat: expose tbank broker operations 2026-06-16 22:46:19 +03:00
bd6b2589c0 feat: expose tbank broker portfolio 2026-06-16 22:43:40 +03:00
52 changed files with 2558 additions and 40 deletions

View File

@ -54,6 +54,12 @@ Live MOEX integration tests opt-in: `npm run test:integration -w apps/backend`.
| `MOEX_RATE_LIMIT` | 10 | Запросов/с к MOEX | | `MOEX_RATE_LIMIT` | 10 | Запросов/с к MOEX |
| `MOEX_CIRCUIT_BREAKER_THRESHOLD` | 5 | Количество ошибок до открытия circuit breaker | | `MOEX_CIRCUIT_BREAKER_THRESHOLD` | 5 | Количество ошибок до открытия circuit breaker |
| `MOEX_CIRCUIT_BREAKER_RESET_SECONDS` | 30 | Время до попытки закрыть circuit breaker | | `MOEX_CIRCUIT_BREAKER_RESET_SECONDS` | 30 | Время до попытки закрыть circuit breaker |
| `T_BANK_TOKEN` | `''` | Server-side токен T-Bank Invest |
| `T_BANK_BASE_URL` | `invest-public-api.tbank.ru:443` | gRPC endpoint T-Bank Invest |
| `T_BANK_CA_CERT_PATH` | `''` | Путь к PEM root CA для gRPC TLS, если локальная сеть подменяет сертификаты |
| `T_BANK_APP_NAME` | `ksv741.moex-vibe` | Metadata приложения для T-Bank |
| `T_BANK_RATE_LIMIT_PER_SECOND` | 5 | Локальный rate limiter для T-Bank |
| `T_BANK_REQUEST_TIMEOUT_MS` | 10000 | Deadline gRPC-запроса (мс) |
| `CACHE_MARKET_DATA_TTL` | 900 | TTL рыночных данных (с) | | `CACHE_MARKET_DATA_TTL` | 900 | TTL рыночных данных (с) |
| `CACHE_HISTORY_TTL` | 3600 | TTL истории (с) | | `CACHE_HISTORY_TTL` | 3600 | TTL истории (с) |
| `CACHE_CANDLES_TTL` | 3600 | TTL свечей (с) | | `CACHE_CANDLES_TTL` | 3600 | TTL свечей (с) |

View File

@ -0,0 +1,33 @@
-----BEGIN CERTIFICATE-----
MIIFwjCCA6qgAwIBAgICEAAwDQYJKoZIhvcNAQELBQAwcDELMAkGA1UEBhMCUlUx
PzA9BgNVBAoMNlRoZSBNaW5pc3RyeSBvZiBEaWdpdGFsIERldmVsb3BtZW50IGFu
ZCBDb21tdW5pY2F0aW9uczEgMB4GA1UEAwwXUnVzc2lhbiBUcnVzdGVkIFJvb3Qg
Q0EwHhcNMjIwMzAxMjEwNDE1WhcNMzIwMjI3MjEwNDE1WjBwMQswCQYDVQQGEwJS
VTE/MD0GA1UECgw2VGhlIE1pbmlzdHJ5IG9mIERpZ2l0YWwgRGV2ZWxvcG1lbnQg
YW5kIENvbW11bmljYXRpb25zMSAwHgYDVQQDDBdSdXNzaWFuIFRydXN0ZWQgUm9v
dCBDQTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAMfFOZ8pUAL3+r2n
qqE0Zp52selXsKGFYoG0GM5bwz1bSFtCt+AZQMhkWQheI3poZAToYJu69pHLKS6Q
XBiwBC1cvzYmUYKMYZC7jE5YhEU2bSL0mX7NaMxMDmH2/NwuOVRj8OImVa5s1F4U
zn4Kv3PFlDBjjSjXKVY9kmjUBsXQrIHeaqmUIsPIlNWUnimXS0I0abExqkbdrXbX
YwCOXhOO2pDUx3ckmJlCMUGacUTnylyQW2VsJIyIGA8V0xzdaeUXg0VZ6ZmNUr5Y
Ber/EAOLPb8NYpsAhJe2mXjMB/J9HNsoFMBFJ0lLOT/+dQvjbdRZoOT8eqJpWnVD
U+QL/qEZnz57N88OWM3rabJkRNdU/Z7x5SFIM9FrqtN8xewsiBWBI0K6XFuOBOTD
4V08o4TzJ8+Ccq5XlCUW2L48pZNCYuBDfBh7FxkB7qDgGDiaftEkZZfApRg2E+M9
G8wkNKTPLDc4wH0FDTijhgxR3Y4PiS1HL2Zhw7bD3CbslmEGgfnnZojNkJtcLeBH
BLa52/dSwNU4WWLubaYSiAmA9IUMX1/RpfpxOxd4Ykmhz97oFbUaDJFipIggx5sX
ePAlkTdWnv+RWBxlJwMQ25oEHmRguNYf4Zr/Rxr9cS93Y+mdXIZaBEE0KS2iLRqa
OiWBki9IMQU4phqPOBAaG7A+eP8PAgMBAAGjZjBkMB0GA1UdDgQWBBTh0YHlzlpf
BKrS6badZrHF+qwshzAfBgNVHSMEGDAWgBTh0YHlzlpfBKrS6badZrHF+qwshzAS
BgNVHRMBAf8ECDAGAQH/AgEEMA4GA1UdDwEB/wQEAwIBhjANBgkqhkiG9w0BAQsF
AAOCAgEAALIY1wkilt/urfEVM5vKzr6utOeDWCUczmWX/RX4ljpRdgF+5fAIS4vH
tmXkqpSCOVeWUrJV9QvZn6L227ZwuE15cWi8DCDal3Ue90WgAJJZMfTshN4OI8cq
W9E4EG9wglbEtMnObHlms8F3CHmrw3k6KmUkWGoa+/ENmcVl68u/cMRl1JbW2bM+
/3A+SAg2c6iPDlehczKx2oa95QW0SkPPWGuNA/CE8CpyANIhu9XFrj3RQ3EqeRcS
AQQod1RNuHpfETLU/A2gMmvn/w/sx7TB3W5BPs6rprOA37tutPq9u6FTZOcG1Oqj
C/B7yTqgI7rbyvox7DEXoX7rIiEqyNNUguTk/u3SZ4VXE2kmxdmSh3TQvybfbnXV
4JbCZVaqiZraqc7oZMnRoWrXRG3ztbnbes/9qhRGI7PqXqeKJBztxRTEVj8ONs1d
WN5szTwaPIvhkhO3CO5ErU2rVdUr89wKpNXbBODFKRtgxUT70YpmJ46VVaqdAhOZ
D9EUUn4YaeLaS8AjSF/h7UkjOibNc4qVDiPP+rkehFWM66PVnP1Msh93tc+taIfC
EYVMxjh8zNbFuoc7fzvvrFILLe7ifvEIUqSVIC/AzplM/Jxw7buXFeGP1qVCBEHq
391d/9RAfaZ12zkwFsl+IKwE/OZxW8AHa9i1p4GO0YSNuczzEm4=
-----END CERTIFICATE-----

View File

@ -1,4 +1,13 @@
{ {
"collection": "@nestjs/schematics", "collection": "@nestjs/schematics",
"sourceRoot": "src" "sourceRoot": "src",
"compilerOptions": {
"assets": [
{
"include": "modules/tbank/proto/contracts/**/*",
"outDir": "dist"
}
],
"watchAssets": true
}
} }

View File

@ -0,0 +1,50 @@
-- CreateTable
CREATE TABLE "BrokerOperation" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"accountId" TEXT NOT NULL,
"cursor" TEXT,
"operationId" TEXT,
"parentOperationId" TEXT,
"date" DATETIME,
"type" TEXT NOT NULL,
"category" TEXT NOT NULL,
"state" TEXT,
"instrumentUid" TEXT,
"figi" TEXT,
"ticker" TEXT,
"classCode" TEXT,
"payment" TEXT,
"price" TEXT,
"commission" TEXT,
"yield" TEXT,
"accruedInt" TEXT,
"quantity" INTEGER,
"quantityDone" INTEGER,
"raw" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
-- CreateTable
CREATE TABLE "BrokerOperationSyncState" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"accountId" TEXT NOT NULL,
"lastCursor" TEXT,
"lastSyncedFrom" DATETIME,
"lastSyncedTo" DATETIME,
"syncedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
-- CreateIndex
CREATE INDEX "BrokerOperation_accountId_date_idx" ON "BrokerOperation"("accountId", "date");
-- CreateIndex
CREATE INDEX "BrokerOperation_accountId_type_idx" ON "BrokerOperation"("accountId", "type");
-- CreateIndex
CREATE UNIQUE INDEX "BrokerOperation_accountId_cursor_key" ON "BrokerOperation"("accountId", "cursor");
-- CreateIndex
CREATE UNIQUE INDEX "BrokerOperationSyncState_accountId_key" ON "BrokerOperationSyncState"("accountId");

View File

@ -51,3 +51,44 @@ model User {
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
portfolios Portfolio[] portfolios Portfolio[]
} }
model BrokerOperation {
id Int @id @default(autoincrement())
accountId String
cursor String?
operationId String?
parentOperationId String?
date DateTime?
type String
category String
state String?
instrumentUid String?
figi String?
ticker String?
classCode String?
payment String?
price String?
commission String?
yield String?
accruedInt String?
quantity Int?
quantityDone Int?
raw String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([accountId, cursor])
@@index([accountId, date])
@@index([accountId, type])
}
model BrokerOperationSyncState {
id Int @id @default(autoincrement())
accountId String @unique
lastCursor String?
lastSyncedFrom DateTime?
lastSyncedTo DateTime?
syncedAt DateTime @default(now())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}

View File

@ -17,6 +17,7 @@ export default registerAs('app', () => ({
tbank: { tbank: {
token: process.env.T_BANK_TOKEN || '', token: process.env.T_BANK_TOKEN || '',
baseUrl: process.env.T_BANK_BASE_URL || 'invest-public-api.tbank.ru:443', baseUrl: process.env.T_BANK_BASE_URL || 'invest-public-api.tbank.ru:443',
caCertPath: process.env.T_BANK_CA_CERT_PATH || '',
appName: process.env.T_BANK_APP_NAME || 'ksv741.moex-vibe', appName: process.env.T_BANK_APP_NAME || 'ksv741.moex-vibe',
rateLimitPerSecond: parseInt(process.env.T_BANK_RATE_LIMIT_PER_SECOND || '5', 10), rateLimitPerSecond: parseInt(process.env.T_BANK_RATE_LIMIT_PER_SECOND || '5', 10),
requestTimeoutMs: parseInt(process.env.T_BANK_REQUEST_TIMEOUT_MS || '10000', 10), requestTimeoutMs: parseInt(process.env.T_BANK_REQUEST_TIMEOUT_MS || '10000', 10),

View File

@ -1,5 +1,8 @@
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
import { BrokerAccountResponseDto } from './broker-account-response.dto'; import { BrokerAccountResponseDto } from './broker-account-response.dto';
import { BrokerOperationSyncResponseDto } from './broker-operation-sync-query.dto';
import { BrokerOperationsPageResponseDto } from './broker-operation-response.dto';
import { BrokerPortfolioResponseDto } from './broker-portfolio-response.dto';
export class BrokerResponseMetaDto { export class BrokerResponseMetaDto {
@ApiProperty({ nullable: true }) @ApiProperty({ nullable: true })
@ -16,3 +19,27 @@ export class BrokerAccountsEnvelopeDto {
@ApiProperty({ type: BrokerResponseMetaDto }) @ApiProperty({ type: BrokerResponseMetaDto })
meta!: BrokerResponseMetaDto; meta!: BrokerResponseMetaDto;
} }
export class BrokerPortfolioEnvelopeDto {
@ApiProperty({ type: BrokerPortfolioResponseDto })
data!: BrokerPortfolioResponseDto;
@ApiProperty({ type: BrokerResponseMetaDto })
meta!: BrokerResponseMetaDto;
}
export class BrokerOperationsEnvelopeDto {
@ApiProperty({ type: BrokerOperationsPageResponseDto })
data!: BrokerOperationsPageResponseDto;
@ApiProperty({ type: BrokerResponseMetaDto })
meta!: BrokerResponseMetaDto;
}
export class BrokerOperationSyncEnvelopeDto {
@ApiProperty({ type: BrokerOperationSyncResponseDto })
data!: BrokerOperationSyncResponseDto;
@ApiProperty({ type: BrokerResponseMetaDto })
meta!: BrokerResponseMetaDto;
}

View File

@ -0,0 +1,15 @@
import { ApiProperty } from '@nestjs/swagger';
export class BrokerMoneyDto {
@ApiProperty()
currency!: string;
@ApiProperty()
units!: string;
@ApiProperty()
nano!: number;
@ApiProperty()
value!: number;
}

View File

@ -0,0 +1,43 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import { IsDateString, IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
export class BrokerOperationQueryDto {
@ApiPropertyOptional()
@IsOptional()
@IsDateString()
from?: string;
@ApiPropertyOptional()
@IsOptional()
@IsDateString()
to?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
cursor?: string;
@ApiPropertyOptional({ minimum: 1, maximum: 1000, default: 100 })
@IsOptional()
@Transform(({ value }) => (value === undefined ? undefined : Number(value)))
@IsInt()
@Min(1)
@Max(1000)
limit?: number;
@ApiPropertyOptional()
@IsOptional()
@IsString()
instrumentId?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
operationTypes?: string;
@ApiPropertyOptional({ default: 'OPERATION_STATE_EXECUTED' })
@IsOptional()
@IsString()
state?: string;
}

View File

@ -0,0 +1,86 @@
import { ApiProperty } from '@nestjs/swagger';
import { BrokerMoneyDto } from './broker-money.dto';
const operationCategories = ['trade', 'income', 'tax', 'fee', 'transfer', 'other'] as const;
export class BrokerOperationResponseDto {
@ApiProperty({ nullable: true })
cursor!: string | null;
@ApiProperty()
accountId!: string;
@ApiProperty({ nullable: true })
id!: string | null;
@ApiProperty({ nullable: true })
parentOperationId!: string | null;
@ApiProperty({ nullable: true })
date!: string | null;
@ApiProperty()
type!: string;
@ApiProperty({ enum: operationCategories })
category!: (typeof operationCategories)[number];
@ApiProperty({ nullable: true })
description!: string | null;
@ApiProperty({ nullable: true })
state!: string | null;
@ApiProperty({ nullable: true })
instrumentUid!: string | null;
@ApiProperty({ nullable: true })
figi!: string | null;
@ApiProperty({ nullable: true })
ticker!: string | null;
@ApiProperty({ nullable: true })
classCode!: string | null;
@ApiProperty({ nullable: true })
instrumentType!: string | null;
@ApiProperty({ type: BrokerMoneyDto, nullable: true })
payment!: BrokerMoneyDto | null;
@ApiProperty({ type: BrokerMoneyDto, nullable: true })
price!: BrokerMoneyDto | null;
@ApiProperty({ type: BrokerMoneyDto, nullable: true })
commission!: BrokerMoneyDto | null;
@ApiProperty({ type: BrokerMoneyDto, nullable: true })
yield!: BrokerMoneyDto | null;
@ApiProperty({ type: BrokerMoneyDto, nullable: true })
accruedInt!: BrokerMoneyDto | null;
@ApiProperty({ nullable: true })
quantity!: number | null;
@ApiProperty({ nullable: true })
quantityDone!: number | null;
}
export class BrokerOperationsPageResponseDto {
@ApiProperty()
accountId!: string;
@ApiProperty({ type: [BrokerOperationResponseDto] })
items!: BrokerOperationResponseDto[];
@ApiProperty({ nullable: true })
nextCursor!: string | null;
@ApiProperty()
hasNext!: boolean;
@ApiProperty()
asOf!: string;
}

View File

@ -0,0 +1,17 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsISO8601 } from 'class-validator';
export class BrokerOperationSyncQueryDto {
@ApiProperty({ example: '2026-06-01T00:00:00.000Z' })
@IsISO8601()
from!: string;
@ApiProperty({ example: '2026-06-17T00:00:00.000Z' })
@IsISO8601()
to!: string;
}
export class BrokerOperationSyncResponseDto {
@ApiProperty({ example: 42 })
upserted!: number;
}

View File

@ -0,0 +1,110 @@
import { ApiProperty } from '@nestjs/swagger';
import { BrokerAccountResponseDto } from './broker-account-response.dto';
import { BrokerMoneyDto } from './broker-money.dto';
export class BrokerPositionResponseDto {
@ApiProperty({ nullable: true })
figi!: string | null;
@ApiProperty({ nullable: true })
instrumentUid!: string | null;
@ApiProperty({ nullable: true })
positionUid!: string | null;
@ApiProperty({ nullable: true })
ticker!: string | null;
@ApiProperty({ nullable: true })
classCode!: string | null;
@ApiProperty({ nullable: true })
instrumentType!: string | null;
@ApiProperty({ nullable: true })
name!: string | null;
@ApiProperty({ nullable: true })
quantity!: number | null;
@ApiProperty({ nullable: true })
blockedLots!: number | null;
@ApiProperty({ type: BrokerMoneyDto, nullable: true })
currentPrice!: BrokerMoneyDto | null;
@ApiProperty({ type: BrokerMoneyDto, nullable: true })
currentValue!: BrokerMoneyDto | null;
@ApiProperty({ type: BrokerMoneyDto, nullable: true })
averagePositionPrice!: BrokerMoneyDto | null;
@ApiProperty({ nullable: true })
expectedYieldPercent!: number | null;
@ApiProperty({ type: BrokerMoneyDto, nullable: true })
dailyYield!: BrokerMoneyDto | null;
}
export class BrokerPortfolioTotalsDto {
@ApiProperty({ type: BrokerMoneyDto, nullable: true })
shares!: BrokerMoneyDto | null;
@ApiProperty({ type: BrokerMoneyDto, nullable: true })
bonds!: BrokerMoneyDto | null;
@ApiProperty({ type: BrokerMoneyDto, nullable: true })
etf!: BrokerMoneyDto | null;
@ApiProperty({ type: BrokerMoneyDto, nullable: true })
currencies!: BrokerMoneyDto | null;
@ApiProperty({ type: BrokerMoneyDto, nullable: true })
futures!: BrokerMoneyDto | null;
@ApiProperty({ type: BrokerMoneyDto, nullable: true })
options!: BrokerMoneyDto | null;
@ApiProperty({ type: BrokerMoneyDto, nullable: true })
structuredProducts!: BrokerMoneyDto | null;
@ApiProperty({ type: BrokerMoneyDto, nullable: true })
dfa!: BrokerMoneyDto | null;
@ApiProperty({ type: BrokerMoneyDto, nullable: true })
portfolio!: BrokerMoneyDto | null;
}
export class BrokerPortfolioYieldsDto {
@ApiProperty({ nullable: true })
expectedPercent!: number | null;
@ApiProperty({ type: BrokerMoneyDto, nullable: true })
daily!: BrokerMoneyDto | null;
@ApiProperty({ nullable: true })
dailyPercent!: number | null;
}
export class BrokerPortfolioResponseDto {
@ApiProperty({ type: BrokerAccountResponseDto })
account!: BrokerAccountResponseDto;
@ApiProperty({ type: BrokerPortfolioTotalsDto })
totals!: BrokerPortfolioTotalsDto;
@ApiProperty({ type: BrokerPortfolioYieldsDto })
yields!: BrokerPortfolioYieldsDto;
@ApiProperty({ type: [BrokerMoneyDto] })
cash!: BrokerMoneyDto[];
@ApiProperty({ type: [BrokerMoneyDto] })
blockedCash!: BrokerMoneyDto[];
@ApiProperty({ type: [BrokerPositionResponseDto] })
positions!: BrokerPositionResponseDto[];
@ApiProperty()
asOf!: string;
}

View File

@ -0,0 +1,54 @@
import { mapBrokerPortfolio } from './portfolio.mapper';
import type { BrokerAccount } from '../types/broker.types';
describe('portfolio.mapper', () => {
const account: BrokerAccount = {
id: 'acc-1',
type: 'brokerage',
name: 'Broker',
status: 'ACCOUNT_STATUS_OPEN',
openedAt: null,
accessLevel: 'ACCOUNT_ACCESS_LEVEL_FULL_ACCESS',
};
it('combines portfolio totals, cash, and enriched positions', () => {
const result = mapBrokerPortfolio({
account,
portfolio: {
accountId: 'acc-1',
totalAmountShares: { currency: 'rub', units: '1000', nano: 0 },
totalAmountPortfolio: { currency: 'rub', units: '1500', nano: 0 },
expectedYield: { units: '10', nano: 500000000 },
positions: [
{
figi: 'BBG004730N88',
instrumentUid: 'uid-1',
ticker: 'SBER',
classCode: 'TQBR',
instrumentType: 'share',
quantity: { units: '10', nano: 0 },
currentPrice: { currency: 'rub', units: '250', nano: 0 },
averagePositionPrice: { currency: 'rub', units: '200', nano: 0 },
},
],
},
positions: {
money: [{ currency: 'rub', units: '500', nano: 0 }],
blocked: [{ currency: 'rub', units: '10', nano: 0 }],
securities: [],
},
instruments: new Map([['uid-1', { name: 'Sberbank', ticker: 'SBER' }]]),
});
expect(result.account.id).toBe('acc-1');
expect(result.totals.shares?.value).toBe(1000);
expect(result.cash[0].value).toBe(500);
expect(result.blockedCash[0].value).toBe(10);
expect(result.positions[0]).toMatchObject({
ticker: 'SBER',
name: 'Sberbank',
quantity: 10,
currentValue: { value: 2500 },
});
});
});

View File

@ -0,0 +1,84 @@
import type {
BrokerAccount,
BrokerMoney,
BrokerPortfolio,
BrokerPosition,
} from '../types/broker.types';
import type {
TBankInstrument,
TBankPortfolioResponse,
TBankPositionsResponse,
} from '../types/tbank-proto.types';
import { mapMoneyValue, mapQuotationToNumber } from './money.mapper';
type MapBrokerPortfolioInput = {
account: BrokerAccount;
portfolio: TBankPortfolioResponse;
positions: TBankPositionsResponse;
instruments: Map<string, Partial<TBankInstrument>>;
};
function isBrokerMoney(value: BrokerMoney | null): value is BrokerMoney {
return value !== null;
}
export function mapBrokerPortfolio(input: MapBrokerPortfolioInput): BrokerPortfolio {
const mappedPositions = (input.portfolio.positions ?? []).map<BrokerPosition>((position) => {
const quantity = mapQuotationToNumber(position.quantity);
const currentPrice = mapMoneyValue(position.currentPrice);
const currentValue =
currentPrice && quantity !== null
? {
...currentPrice,
units: String(Math.trunc(currentPrice.value * quantity)),
nano: 0,
value: Number((currentPrice.value * quantity).toFixed(9)),
}
: null;
const instrument =
(position.instrumentUid && input.instruments.get(position.instrumentUid)) ||
(position.positionUid && input.instruments.get(position.positionUid)) ||
undefined;
return {
figi: position.figi ?? null,
instrumentUid: position.instrumentUid ?? null,
positionUid: position.positionUid ?? null,
ticker: position.ticker || instrument?.ticker || null,
classCode: position.classCode || instrument?.classCode || null,
instrumentType: position.instrumentType || instrument?.instrumentType || null,
name: instrument?.name ?? null,
quantity,
blockedLots: mapQuotationToNumber(position.blockedLots),
currentPrice,
currentValue,
averagePositionPrice: mapMoneyValue(position.averagePositionPrice),
expectedYieldPercent: mapQuotationToNumber(position.expectedYield),
dailyYield: mapMoneyValue(position.dailyYield),
};
});
return {
account: input.account,
totals: {
shares: mapMoneyValue(input.portfolio.totalAmountShares),
bonds: mapMoneyValue(input.portfolio.totalAmountBonds),
etf: mapMoneyValue(input.portfolio.totalAmountEtf),
currencies: mapMoneyValue(input.portfolio.totalAmountCurrencies),
futures: mapMoneyValue(input.portfolio.totalAmountFutures),
options: mapMoneyValue(input.portfolio.totalAmountOptions),
structuredProducts: mapMoneyValue(input.portfolio.totalAmountSp),
dfa: mapMoneyValue(input.portfolio.totalAmountDfa),
portfolio: mapMoneyValue(input.portfolio.totalAmountPortfolio),
},
yields: {
expectedPercent: mapQuotationToNumber(input.portfolio.expectedYield),
daily: mapMoneyValue(input.portfolio.dailyYield),
dailyPercent: mapQuotationToNumber(input.portfolio.dailyYieldRelative),
},
cash: (input.positions.money ?? []).map(mapMoneyValue).filter(isBrokerMoney),
blockedCash: (input.positions.blocked ?? []).map(mapMoneyValue).filter(isBrokerMoney),
positions: mappedPositions,
asOf: new Date().toISOString(),
};
}

View File

@ -0,0 +1,38 @@
import { Injectable } from '@nestjs/common';
import { CacheService } from '../../cache/cache.service';
import { TBANK_CACHE_KEYS } from '../tbank.config';
import type { TBankInstrument, TBankInstrumentResponse } from '../types/tbank-proto.types';
import { TBankClientService } from './tbank-client.service';
@Injectable()
export class BrokerInstrumentsService {
constructor(
private readonly tbankClient: TBankClientService,
private readonly cacheService: CacheService,
) {}
async findByInstrumentUid(instrumentUid: string): Promise<TBankInstrument | null> {
const result = await this.cacheService.getOrFetch(
TBANK_CACHE_KEYS.instrument,
[instrumentUid],
() => this.fetchByUid(instrumentUid),
'tbankInstrumentTtl',
);
return result.data;
}
private async fetchByUid(instrumentUid: string): Promise<TBankInstrument | null> {
const instrumentsClient = this.tbankClient.getServiceClient('InstrumentsService') as any;
const response = await this.tbankClient.callUnary<
{ idType: string; id: string },
TBankInstrumentResponse
>(
'InstrumentsService/GetInstrumentBy',
instrumentsClient.getInstrumentBy.bind(instrumentsClient),
{ idType: 'INSTRUMENT_ID_TYPE_UID', id: instrumentUid },
);
return response.instrument ?? null;
}
}

View File

@ -0,0 +1,155 @@
import { PrismaService } from '../../prisma/prisma.service';
import { BrokerOperationSyncService } from './broker-operation-sync.service';
import { BrokerOperationsService } from './broker-operations.service';
describe('BrokerOperationSyncService', () => {
const operations = { getOperations: vi.fn() } as unknown as BrokerOperationsService;
const prisma = {
brokerOperation: { upsert: vi.fn() },
brokerOperationSyncState: { upsert: vi.fn() },
} as unknown as PrismaService;
beforeEach(() => {
vi.clearAllMocks();
});
it('syncs operation pages and stores raw payload', async () => {
vi.mocked(operations.getOperations)
.mockResolvedValueOnce({
data: {
accountId: 'acc-1',
hasNext: true,
nextCursor: 'next',
asOf: '2026-06-16T00:00:00.000Z',
items: [
{
cursor: 'c1',
accountId: 'acc-1',
id: 'op-1',
parentOperationId: null,
date: '2026-06-16T00:00:00.000Z',
type: 'OPERATION_TYPE_BUY',
category: 'trade',
description: null,
state: 'OPERATION_STATE_EXECUTED',
instrumentUid: 'uid-1',
figi: null,
ticker: 'SBER',
classCode: 'TQBR',
instrumentType: 'share',
payment: { currency: 'RUB', units: '-1000', nano: 0, value: -1000 },
price: null,
commission: null,
yield: null,
accruedInt: null,
quantity: 10,
quantityDone: 10,
},
],
},
meta: { fromCache: false, cachedAt: null },
})
.mockResolvedValueOnce({
data: { accountId: 'acc-1', hasNext: false, nextCursor: null, asOf: 'now', items: [] },
meta: { fromCache: false, cachedAt: null },
});
const service = new BrokerOperationSyncService(operations, prisma);
const result = await service.syncAccount('acc-1', {
from: '2026-06-01T00:00:00.000Z',
to: '2026-06-16T00:00:00.000Z',
});
expect(result.upserted).toBe(1);
expect(operations.getOperations).toHaveBeenNthCalledWith(2, 'acc-1', {
from: '2026-06-01T00:00:00.000Z',
to: '2026-06-16T00:00:00.000Z',
cursor: 'next',
limit: 1000,
state: 'OPERATION_STATE_EXECUTED',
});
expect(prisma.brokerOperation.upsert).toHaveBeenCalledWith(
expect.objectContaining({
where: { accountId_cursor: { accountId: 'acc-1', cursor: 'c1' } },
}),
);
expect(prisma.brokerOperationSyncState.upsert).toHaveBeenCalledWith(
expect.objectContaining({
update: expect.objectContaining({ lastCursor: 'next' }),
}),
);
});
it('stores operations under the synced account id', async () => {
vi.mocked(operations.getOperations).mockResolvedValueOnce({
data: {
accountId: 'acc-1',
hasNext: false,
nextCursor: null,
asOf: '2026-06-16T00:00:00.000Z',
items: [
{
cursor: 'c1',
accountId: 'unexpected-account',
id: 'op-1',
parentOperationId: null,
date: null,
type: 'OPERATION_TYPE_BUY',
category: 'trade',
description: null,
state: null,
instrumentUid: null,
figi: null,
ticker: null,
classCode: null,
instrumentType: null,
payment: null,
price: null,
commission: null,
yield: null,
accruedInt: null,
quantity: null,
quantityDone: null,
},
],
},
meta: { fromCache: false, cachedAt: null },
});
const service = new BrokerOperationSyncService(operations, prisma);
await service.syncAccount('acc-1', {
from: '2026-06-01T00:00:00.000Z',
to: '2026-06-16T00:00:00.000Z',
});
expect(prisma.brokerOperation.upsert).toHaveBeenCalledWith(
expect.objectContaining({
where: { accountId_cursor: { accountId: 'acc-1', cursor: 'c1' } },
create: expect.objectContaining({ accountId: 'acc-1' }),
update: expect.objectContaining({ accountId: 'acc-1' }),
}),
);
});
it('fails when a page claims more data without a next cursor', async () => {
vi.mocked(operations.getOperations).mockResolvedValueOnce({
data: {
accountId: 'acc-1',
hasNext: true,
nextCursor: null,
asOf: '2026-06-16T00:00:00.000Z',
items: [],
},
meta: { fromCache: false, cachedAt: null },
});
const service = new BrokerOperationSyncService(operations, prisma);
await expect(
service.syncAccount('acc-1', {
from: '2026-06-01T00:00:00.000Z',
to: '2026-06-16T00:00:00.000Z',
}),
).rejects.toThrow('T-Bank returned hasNext without nextCursor');
});
});

View File

@ -0,0 +1,106 @@
import { Injectable, InternalServerErrorException } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import type { BrokerOperation } from '../types/broker.types';
import { BrokerOperationsService } from './broker-operations.service';
type BrokerOperationSyncRange = {
from: string;
to: string;
};
@Injectable()
export class BrokerOperationSyncService {
constructor(
private readonly operationsService: BrokerOperationsService,
private readonly prisma: PrismaService,
) {}
async syncAccount(
accountId: string,
range: BrokerOperationSyncRange,
): Promise<{ upserted: number }> {
let cursor: string | undefined;
let lastCursor: string | null = null;
let upserted = 0;
do {
const page = await this.operationsService.getOperations(accountId, {
from: range.from,
to: range.to,
cursor,
limit: 1000,
state: 'OPERATION_STATE_EXECUTED',
});
for (const operation of page.data.items) {
await this.upsertOperation(accountId, operation);
upserted++;
}
const nextCursor = page.data.nextCursor ?? undefined;
if (page.data.hasNext && !nextCursor) {
throw new InternalServerErrorException('T-Bank returned hasNext without nextCursor');
}
if (nextCursor) {
lastCursor = nextCursor;
}
cursor = nextCursor;
if (!page.data.hasNext) {
break;
}
} while (cursor);
await this.prisma.brokerOperationSyncState.upsert({
where: { accountId },
create: {
accountId,
lastCursor,
lastSyncedFrom: new Date(range.from),
lastSyncedTo: new Date(range.to),
},
update: {
lastCursor,
lastSyncedFrom: new Date(range.from),
lastSyncedTo: new Date(range.to),
syncedAt: new Date(),
},
});
return { upserted };
}
private async upsertOperation(accountId: string, operation: BrokerOperation): Promise<void> {
const cursor =
operation.cursor || `${operation.id || 'operation'}:${operation.date || 'no-date'}`;
const data = {
accountId,
cursor,
operationId: operation.id,
parentOperationId: operation.parentOperationId,
date: operation.date ? new Date(operation.date) : null,
type: operation.type,
category: operation.category,
state: operation.state,
instrumentUid: operation.instrumentUid,
figi: operation.figi,
ticker: operation.ticker,
classCode: operation.classCode,
payment: operation.payment ? JSON.stringify(operation.payment) : null,
price: operation.price ? JSON.stringify(operation.price) : null,
commission: operation.commission ? JSON.stringify(operation.commission) : null,
yield: operation.yield ? JSON.stringify(operation.yield) : null,
accruedInt: operation.accruedInt ? JSON.stringify(operation.accruedInt) : null,
quantity: operation.quantity,
quantityDone: operation.quantityDone,
raw: JSON.stringify(operation),
};
await this.prisma.brokerOperation.upsert({
where: { accountId_cursor: { accountId, cursor } },
create: data,
update: data,
});
}
}

View File

@ -0,0 +1,64 @@
import { NotFoundException } from '@nestjs/common';
import { CacheService } from '../../cache/cache.service';
import { BrokerAccountsService } from './broker-accounts.service';
import { BrokerOperationsService } from './broker-operations.service';
import { TBankClientService } from './tbank-client.service';
describe('BrokerOperationsService', () => {
const accounts = { findById: vi.fn() } as unknown as BrokerAccountsService;
const client = { getServiceClient: vi.fn(), callUnary: vi.fn() } as unknown as TBankClientService;
const cache = { getOrFetch: vi.fn() } as unknown as CacheService;
beforeEach(() => {
vi.clearAllMocks();
});
it('throws 404 for excluded or missing account', async () => {
vi.mocked(accounts.findById).mockResolvedValue(null);
const service = new BrokerOperationsService(accounts, client, cache);
await expect(service.getOperations('missing', {})).rejects.toThrow(NotFoundException);
});
it('builds cursor request and maps operation page', async () => {
vi.mocked(accounts.findById).mockResolvedValue({
id: 'acc-1',
type: 'brokerage',
name: 'Broker',
status: 'ACCOUNT_STATUS_OPEN',
openedAt: null,
accessLevel: null,
});
vi.mocked(cache.getOrFetch).mockImplementation(
async (_prefix: string, _parts: string[], fetchFn: () => Promise<unknown>) => ({
data: await fetchFn(),
fromCache: false,
cachedAt: null,
}),
);
vi.mocked(client.getServiceClient).mockReturnValue({ getOperationsByCursor: vi.fn() } as any);
vi.mocked(client.callUnary).mockResolvedValue({
hasNext: false,
items: [{ cursor: 'c1', brokerAccountId: 'acc-1', type: 'OPERATION_TYPE_BUY' }],
});
const service = new BrokerOperationsService(accounts, client, cache);
const result = await service.getOperations('acc-1', {
from: '2026-01-01T00:00:00.000Z',
to: '2026-06-16T00:00:00.000Z',
limit: 1000,
state: 'OPERATION_STATE_EXECUTED',
});
expect(result.data.items[0].category).toBe('trade');
expect(client.callUnary).toHaveBeenCalledWith(
'OperationsService/GetOperationsByCursor',
expect.any(Function),
expect.objectContaining({
accountId: 'acc-1',
limit: 1000,
state: 'OPERATION_STATE_EXECUTED',
}),
);
});
});

View File

@ -0,0 +1,88 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { CacheService } from '../../cache/cache.service';
import type { BrokerOperationQueryDto } from '../dto/broker-operation-query.dto';
import { mapOperationsPage } from '../mappers/operation.mapper';
import { TBANK_CACHE_KEYS } from '../tbank.config';
import type { BrokerOperationsPage } from '../types/broker.types';
import type { TBankOperationsByCursorResponse } from '../types/tbank-proto.types';
import { BrokerAccountsService } from './broker-accounts.service';
import { TBankClientService } from './tbank-client.service';
@Injectable()
export class BrokerOperationsService {
constructor(
private readonly accountsService: BrokerAccountsService,
private readonly tbankClient: TBankClientService,
private readonly cacheService: CacheService,
) {}
async getOperations(
accountId: string,
query: BrokerOperationQueryDto,
): Promise<{
data: BrokerOperationsPage;
meta: { fromCache: boolean; cachedAt: string | null };
}> {
const account = await this.accountsService.findById(accountId);
if (!account) throw new NotFoundException('Broker account not found');
const request = this.buildRequest(accountId, query);
const result = await this.cacheService.getOrFetch(
TBANK_CACHE_KEYS.operations,
[accountId, JSON.stringify(request)],
() => this.fetchOperations(accountId, request),
'tbankOperationsTtl',
);
return {
data: result.data,
meta: { fromCache: result.fromCache, cachedAt: result.cachedAt },
};
}
private buildRequest(accountId: string, query: BrokerOperationQueryDto): Record<string, unknown> {
const now = new Date();
const startOfYear = new Date(Date.UTC(now.getUTCFullYear(), 0, 1));
const operationTypes = query.operationTypes
? query.operationTypes
.split(',')
.map((value) => value.trim())
.filter(Boolean)
: undefined;
return {
accountId,
instrumentId: query.instrumentId,
from: {
seconds: Math.floor(new Date(query.from ?? startOfYear.toISOString()).getTime() / 1000),
},
to: {
seconds: Math.floor(new Date(query.to ?? now.toISOString()).getTime() / 1000),
},
cursor: query.cursor,
limit: query.limit ?? 100,
operationTypes,
state: query.state ?? 'OPERATION_STATE_EXECUTED',
withoutCommissions: false,
withoutTrades: false,
withoutOvernights: false,
};
}
private async fetchOperations(
accountId: string,
request: Record<string, unknown>,
): Promise<BrokerOperationsPage> {
const operationsClient = this.tbankClient.getServiceClient('OperationsService') as any;
const response = await this.tbankClient.callUnary<
Record<string, unknown>,
TBankOperationsByCursorResponse
>(
'OperationsService/GetOperationsByCursor',
operationsClient.getOperationsByCursor.bind(operationsClient),
request,
);
return mapOperationsPage(accountId, response);
}
}

View File

@ -0,0 +1,70 @@
import { NotFoundException } from '@nestjs/common';
import { CacheService } from '../../cache/cache.service';
import { BrokerAccountsService } from './broker-accounts.service';
import { BrokerInstrumentsService } from './broker-instruments.service';
import { BrokerPortfolioService } from './broker-portfolio.service';
import { TBankClientService } from './tbank-client.service';
describe('BrokerPortfolioService', () => {
const accounts = { findById: vi.fn() } as unknown as BrokerAccountsService;
const instruments = { findByInstrumentUid: vi.fn() } as unknown as BrokerInstrumentsService;
const client = { getServiceClient: vi.fn(), callUnary: vi.fn() } as unknown as TBankClientService;
const cache = { getOrFetch: vi.fn() } as unknown as CacheService;
beforeEach(() => {
vi.clearAllMocks();
});
it('throws 404 for excluded or missing account', async () => {
vi.mocked(accounts.findById).mockResolvedValue(null);
const service = new BrokerPortfolioService(accounts, instruments, client, cache);
await expect(service.getPortfolio('missing')).rejects.toThrow(NotFoundException);
});
it('fetches portfolio and positions through cache', async () => {
vi.mocked(accounts.findById).mockResolvedValue({
id: 'acc-1',
type: 'brokerage',
name: 'Broker',
status: 'ACCOUNT_STATUS_OPEN',
openedAt: null,
accessLevel: null,
});
vi.mocked(cache.getOrFetch).mockImplementation(
async (_prefix: string, _parts: string[], fetchFn: () => Promise<unknown>) => ({
data: await fetchFn(),
fromCache: false,
cachedAt: null,
}),
);
vi.mocked(client.getServiceClient).mockReturnValue({
getPortfolio: vi.fn(),
getPositions: vi.fn(),
} as any);
vi.mocked(client.callUnary)
.mockResolvedValueOnce({
accountId: 'acc-1',
totalAmountPortfolio: { currency: 'rub', units: '1000', nano: 0 },
positions: [],
})
.mockResolvedValueOnce({
accountId: 'acc-1',
money: [{ currency: 'rub', units: '1000', nano: 0 }],
blocked: [],
securities: [],
});
const service = new BrokerPortfolioService(accounts, instruments, client, cache);
const result = await service.getPortfolio('acc-1');
expect(result.data.account.id).toBe('acc-1');
expect(result.data.cash[0].value).toBe(1000);
expect(cache.getOrFetch).toHaveBeenCalledWith(
'tbank:portfolio',
['acc-1'],
expect.any(Function),
'tbankPortfolioTtl',
);
});
});

View File

@ -0,0 +1,81 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { CacheService } from '../../cache/cache.service';
import { mapBrokerPortfolio } from '../mappers/portfolio.mapper';
import { TBANK_CACHE_KEYS } from '../tbank.config';
import type { BrokerPortfolio } from '../types/broker.types';
import type {
TBankInstrument,
TBankPortfolioResponse,
TBankPositionsResponse,
} from '../types/tbank-proto.types';
import { BrokerAccountsService } from './broker-accounts.service';
import { BrokerInstrumentsService } from './broker-instruments.service';
import { TBankClientService } from './tbank-client.service';
@Injectable()
export class BrokerPortfolioService {
constructor(
private readonly accountsService: BrokerAccountsService,
private readonly instrumentsService: BrokerInstrumentsService,
private readonly tbankClient: TBankClientService,
private readonly cacheService: CacheService,
) {}
async getPortfolio(accountId: string): Promise<{
data: BrokerPortfolio;
meta: { fromCache: boolean; cachedAt: string | null };
}> {
const account = await this.accountsService.findById(accountId);
if (!account) throw new NotFoundException('Broker account not found');
const result = await this.cacheService.getOrFetch(
TBANK_CACHE_KEYS.portfolio,
[accountId],
async () => {
const operationsClient = this.tbankClient.getServiceClient('OperationsService') as any;
const [portfolio, positions] = await Promise.all([
this.tbankClient.callUnary<
{ accountId: string; currency: string },
TBankPortfolioResponse
>(
'OperationsService/GetPortfolio',
operationsClient.getPortfolio.bind(operationsClient),
{ accountId, currency: 'RUB' },
),
this.tbankClient.callUnary<{ accountId: string }, TBankPositionsResponse>(
'OperationsService/GetPositions',
operationsClient.getPositions.bind(operationsClient),
{ accountId },
),
]);
const instrumentMap = await this.buildInstrumentMap(portfolio);
return mapBrokerPortfolio({ account, portfolio, positions, instruments: instrumentMap });
},
'tbankPortfolioTtl',
);
return {
data: result.data,
meta: { fromCache: result.fromCache, cachedAt: result.cachedAt },
};
}
private async buildInstrumentMap(
portfolio: TBankPortfolioResponse,
): Promise<Map<string, Partial<TBankInstrument>>> {
const ids = Array.from(
new Set(
(portfolio.positions ?? []).map((position) => position.instrumentUid).filter(Boolean),
),
) as string[];
const entries = await Promise.all(
ids.map(async (id) => [id, await this.instrumentsService.findByInstrumentUid(id)] as const),
);
return new Map(
entries.filter((entry): entry is readonly [string, TBankInstrument] => entry[1] !== null),
);
}
}

View File

@ -1,6 +1,9 @@
import { ServiceUnavailableException } from '@nestjs/common'; import { ServiceUnavailableException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { ClientUnaryCall, Metadata, ServiceError, status } from '@grpc/grpc-js'; import { ChannelCredentials, ClientUnaryCall, Metadata, ServiceError, status } from '@grpc/grpc-js';
import { mkdtempSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { TBankClientService } from './tbank-client.service'; import { TBankClientService } from './tbank-client.service';
describe('TBankClientService', () => { describe('TBankClientService', () => {
@ -35,6 +38,36 @@ describe('TBankClientService', () => {
}); });
}); });
it('creates service clients from vendored proto contracts', () => {
const service = new TBankClientService(config);
expect(() => service.getServiceClient('UsersService')).not.toThrow();
});
it('creates grpc SSL credentials with configured custom CA certificate', () => {
const caPath = join(mkdtempSync(join(tmpdir(), 'tbank-ca-')), 'root.pem');
writeFileSync(caPath, '-----BEGIN CERTIFICATE-----\ntest-ca\n-----END CERTIFICATE-----\n');
const createSslSpy = vi.spyOn(ChannelCredentials, 'createSsl');
const caConfig = {
get: vi.fn((key: string, fallback?: unknown) => {
const values: Record<string, unknown> = {
'app.tbank.token': 'token-1',
'app.tbank.caCertPath': caPath,
};
return values[key] ?? fallback;
}),
} as unknown as ConfigService;
const service = new TBankClientService(caConfig);
service.getServiceClient('UsersService');
const [rootCerts] = createSslSpy.mock.calls.at(-1) ?? [];
expect(Buffer.isBuffer(rootCerts)).toBe(true);
expect((rootCerts as Buffer).toString('utf8')).toContain('test-ca');
createSslSpy.mockRestore();
});
it('throws integration unavailable when token is missing', async () => { it('throws integration unavailable when token is missing', async () => {
const missingConfig = { const missingConfig = {
get: vi.fn((key: string, fallback?: unknown) => get: vi.fn((key: string, fallback?: unknown) =>

View File

@ -17,6 +17,7 @@ import {
} from '@grpc/grpc-js'; } from '@grpc/grpc-js';
import { loadSync } from '@grpc/proto-loader'; import { loadSync } from '@grpc/proto-loader';
import PQueue from 'p-queue'; import PQueue from 'p-queue';
import { existsSync, readFileSync } from 'node:fs';
import { join } from 'node:path'; import { join } from 'node:path';
import { TBANK_PROTO_FILES, TBANK_PROTO_PACKAGE } from '../tbank.config'; import { TBANK_PROTO_FILES, TBANK_PROTO_PACKAGE } from '../tbank.config';
@ -44,19 +45,29 @@ export class TBankClientService {
intervalCap: this.configService.get<number>('app.tbank.rateLimitPerSecond', 5), intervalCap: this.configService.get<number>('app.tbank.rateLimitPerSecond', 5),
}); });
const protoRoot = join(__dirname, '..', 'proto', 'contracts'); const protoRoot = this.resolveProtoRoot();
const definition = loadSync(Object.values(TBANK_PROTO_FILES), { const definition = loadSync(
Object.values(TBANK_PROTO_FILES).map((fileName) => join(protoRoot, fileName)),
{
includeDirs: [protoRoot], includeDirs: [protoRoot],
keepCase: false, keepCase: false,
longs: String, longs: String,
enums: String, enums: String,
defaults: true, defaults: true,
oneofs: true, oneofs: true,
}); },
);
this.packageDefinition = loadPackageDefinition(definition); this.packageDefinition = loadPackageDefinition(definition);
} }
private resolveProtoRoot(): string {
const distProtoRoot = join(__dirname, '..', 'proto', 'contracts');
if (existsSync(distProtoRoot)) return distProtoRoot;
return join(process.cwd(), 'src', 'modules', 'tbank', 'proto', 'contracts');
}
createMetadata(): Metadata { createMetadata(): Metadata {
const token = this.configService.get<string>('app.tbank.token', ''); const token = this.configService.get<string>('app.tbank.token', '');
if (!token) { if (!token) {
@ -97,7 +108,7 @@ export class TBankClientService {
const ServiceCtor = namespace[serviceName] as GrpcServiceConstructor; const ServiceCtor = namespace[serviceName] as GrpcServiceConstructor;
const client = new ServiceCtor( const client = new ServiceCtor(
this.configService.get<string>('app.tbank.baseUrl', 'invest-public-api.tbank.ru:443'), this.configService.get<string>('app.tbank.baseUrl', 'invest-public-api.tbank.ru:443'),
ChannelCredentials.createSsl(), this.createChannelCredentials(),
); );
this.clientCache.set(serviceName, client); this.clientCache.set(serviceName, client);
@ -137,6 +148,22 @@ export class TBankClientService {
); );
} }
private createChannelCredentials(): ChannelCredentials {
const caCertPath = this.configService.get<string>('app.tbank.caCertPath', '');
if (!caCertPath) return ChannelCredentials.createSsl();
try {
return ChannelCredentials.createSsl(readFileSync(caCertPath));
} catch (error) {
this.logger.error(
`Failed to read T-Bank CA certificate from ${caCertPath}: ${
error instanceof Error ? error.message : String(error)
}`,
);
throw new ServiceUnavailableException('T-Bank CA certificate is not readable');
}
}
private mapGrpcError(label: string, error: ServiceError): Error { private mapGrpcError(label: string, error: ServiceError): Error {
const trackingId = error.metadata?.get('x-tracking-id')?.[0]; const trackingId = error.metadata?.get('x-tracking-id')?.[0];
const retryAfter = error.metadata?.get('x-ratelimit-reset')?.[0]; const retryAfter = error.metadata?.get('x-ratelimit-reset')?.[0];

View File

@ -26,6 +26,7 @@ describe('T-Bank configuration', () => {
it('reads T-Bank token and TTL overrides from environment', () => { it('reads T-Bank token and TTL overrides from environment', () => {
process.env.T_BANK_TOKEN = 'secret-token'; process.env.T_BANK_TOKEN = 'secret-token';
process.env.T_BANK_BASE_URL = 'sandbox-invest-public-api.tbank.ru:443'; process.env.T_BANK_BASE_URL = 'sandbox-invest-public-api.tbank.ru:443';
process.env.T_BANK_CA_CERT_PATH = '/tmp/tbank-root-ca.pem';
process.env.T_BANK_RATE_LIMIT_PER_SECOND = '2'; process.env.T_BANK_RATE_LIMIT_PER_SECOND = '2';
process.env.CACHE_TBANK_ACCOUNTS_TTL = '120'; process.env.CACHE_TBANK_ACCOUNTS_TTL = '120';
@ -33,6 +34,7 @@ describe('T-Bank configuration', () => {
expect(config.tbank.token).toBe('secret-token'); expect(config.tbank.token).toBe('secret-token');
expect(config.tbank.baseUrl).toBe('sandbox-invest-public-api.tbank.ru:443'); expect(config.tbank.baseUrl).toBe('sandbox-invest-public-api.tbank.ru:443');
expect(config.tbank.caCertPath).toBe('/tmp/tbank-root-ca.pem');
expect(config.tbank.rateLimitPerSecond).toBe(2); expect(config.tbank.rateLimitPerSecond).toBe(2);
expect(config.cache.tbankAccountsTtl).toBe(120); expect(config.cache.tbankAccountsTtl).toBe(120);
}); });

View File

@ -0,0 +1,61 @@
import { ROLES_KEY } from '../auth/decorators/roles.decorator';
import { ApiResponse } from '../../common/dto/api-response.dto';
import { TBankController } from './tbank.controller';
import { BrokerAccountsService } from './services/broker-accounts.service';
import { BrokerOperationSyncService } from './services/broker-operation-sync.service';
import { BrokerOperationsService } from './services/broker-operations.service';
import { BrokerPortfolioService } from './services/broker-portfolio.service';
describe('TBankController', () => {
const accounts = { findAll: vi.fn() } as unknown as BrokerAccountsService;
const portfolio = { getPortfolio: vi.fn() } as unknown as BrokerPortfolioService;
const operations = { getOperations: vi.fn() } as unknown as BrokerOperationsService;
const sync = { syncAccount: vi.fn() } as unknown as BrokerOperationSyncService;
beforeEach(() => {
vi.clearAllMocks();
});
it('allows authenticated users to access broker endpoints', () => {
expect(Reflect.getMetadata(ROLES_KEY, TBankController)).toEqual(['user']);
});
it('returns accounts in a single API envelope', async () => {
vi.mocked(accounts.findAll).mockResolvedValueOnce({
data: [
{
id: 'acc-1',
type: 'brokerage',
name: 'Broker',
status: 'ACCOUNT_STATUS_OPEN',
openedAt: null,
accessLevel: null,
},
],
meta: { fromCache: true, cachedAt: '2026-06-17T00:00:00.000Z' },
});
const controller = new TBankController(accounts, portfolio, operations, sync);
const response = await controller.getAccounts();
expect(response).toBeInstanceOf(ApiResponse);
expect(response.data).toHaveLength(1);
expect(response.meta).toEqual({ fromCache: true, cachedAt: '2026-06-17T00:00:00.000Z' });
});
it('exposes a sync trigger for durable operation history', async () => {
vi.mocked(sync.syncAccount).mockResolvedValueOnce({ upserted: 2 });
const controller = new TBankController(accounts, portfolio, operations, sync);
const response = await controller.syncOperations('acc-1', {
from: '2026-06-01T00:00:00.000Z',
to: '2026-06-17T00:00:00.000Z',
});
expect(sync.syncAccount).toHaveBeenCalledWith('acc-1', {
from: '2026-06-01T00:00:00.000Z',
to: '2026-06-17T00:00:00.000Z',
});
expect(response.data).toEqual({ upserted: 2 });
});
});

View File

@ -1,18 +1,67 @@
import { Controller, Get } from '@nestjs/common'; import { Controller, Get, Param, Post, Query } from '@nestjs/common';
import { ApiBearerAuth, ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger'; import { ApiBearerAuth, ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger';
import { BrokerAccountsEnvelopeDto } from './dto/broker-envelope.dto'; import { ApiResponse } from '../../common/dto/api-response.dto';
import { Roles } from '../auth/decorators/roles.decorator';
import {
BrokerAccountsEnvelopeDto,
BrokerOperationSyncEnvelopeDto,
BrokerOperationsEnvelopeDto,
BrokerPortfolioEnvelopeDto,
} from './dto/broker-envelope.dto';
import { BrokerOperationQueryDto } from './dto/broker-operation-query.dto';
import { BrokerOperationSyncQueryDto } from './dto/broker-operation-sync-query.dto';
import { BrokerAccountsService } from './services/broker-accounts.service'; import { BrokerAccountsService } from './services/broker-accounts.service';
import { BrokerOperationSyncService } from './services/broker-operation-sync.service';
import { BrokerOperationsService } from './services/broker-operations.service';
import { BrokerPortfolioService } from './services/broker-portfolio.service';
@ApiTags('Broker') @ApiTags('Broker')
@ApiBearerAuth() @ApiBearerAuth()
@Roles('user')
@Controller('broker') @Controller('broker')
export class TBankController { export class TBankController {
constructor(private readonly brokerAccountsService: BrokerAccountsService) {} constructor(
private readonly brokerAccountsService: BrokerAccountsService,
private readonly brokerPortfolioService: BrokerPortfolioService,
private readonly brokerOperationsService: BrokerOperationsService,
private readonly brokerOperationSyncService: BrokerOperationSyncService,
) {}
@Get('accounts') @Get('accounts')
@ApiOperation({ summary: 'Get open T-Bank brokerage and IIS accounts' }) @ApiOperation({ summary: 'Get open T-Bank brokerage and IIS accounts' })
@ApiOkResponse({ type: BrokerAccountsEnvelopeDto }) @ApiOkResponse({ type: BrokerAccountsEnvelopeDto })
async getAccounts() { async getAccounts() {
return this.brokerAccountsService.findAll(); 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);
} }
} }

View File

@ -1,11 +1,29 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { TBankController } from './tbank.controller'; import { TBankController } from './tbank.controller';
import { BrokerAccountsService } from './services/broker-accounts.service'; import { BrokerAccountsService } from './services/broker-accounts.service';
import { BrokerInstrumentsService } from './services/broker-instruments.service';
import { BrokerOperationSyncService } from './services/broker-operation-sync.service';
import { BrokerOperationsService } from './services/broker-operations.service';
import { BrokerPortfolioService } from './services/broker-portfolio.service';
import { TBankClientService } from './services/tbank-client.service'; import { TBankClientService } from './services/tbank-client.service';
@Module({ @Module({
controllers: [TBankController], controllers: [TBankController],
providers: [TBankClientService, BrokerAccountsService], providers: [
exports: [TBankClientService, BrokerAccountsService], TBankClientService,
BrokerAccountsService,
BrokerInstrumentsService,
BrokerPortfolioService,
BrokerOperationsService,
BrokerOperationSyncService,
],
exports: [
TBankClientService,
BrokerAccountsService,
BrokerInstrumentsService,
BrokerPortfolioService,
BrokerOperationsService,
BrokerOperationSyncService,
],
}) })
export class TBankModule {} export class TBankModule {}

View File

@ -0,0 +1,30 @@
# ADR-011: Интеграция с T-Bank Invest использует gRPC
**Статус:** Accepted
**Дата:** 2026-06-16
## Контекст
MoexVibe нужна read-only интеграция с T-Bank Invest для брокерских счетов и ИИС: текущие позиции,
деньги на счёте и история операций. T-Bank предоставляет gRPC, REST-прокси, WebSocket и официальный
JS SDK.
## Решение
Использовать тонкую backend gRPC-интеграцию на основе официальных proto-контрактов. REST оставить
для ручной диагностики, а официальный JS SDK не делать прямой зависимостью первого варианта.
## Обоснование
- gRPC — основной протокол T-Bank Invest API.
- Unary methods покрывают счета, портфель, позиции, операции и инструменты.
- Stream methods можно добавить позже без изменения публичного MoexVibe API.
- Собственный транспортный слой позволяет контролировать rate limiting, cache TTL, redaction
metadata, test doubles и будущий переход от одного server token к per-user token storage.
## Последствия
- Backend хранит vendored proto-контракты T-Bank Invest.
- Backend владеет T-Bank-specific rate limits и cache TTL.
- Интеграция остаётся read-only, пока отдельный ADR не разрешит торговые операции и заявки.

View File

@ -12,5 +12,6 @@
| [ADR-008](ADR-008-auth-system) | Accepted | Authentication и Authorization | | [ADR-008](ADR-008-auth-system) | Accepted | Authentication и Authorization |
| [ADR-009](ADR-009-portfolio-domain) | Accepted | Доменная модель портфеля | | [ADR-009](ADR-009-portfolio-domain) | Accepted | Доменная модель портфеля |
| [ADR-010](ADR-010-backend-price-computation) | Accepted | Расчёт цен на backend | | [ADR-010](ADR-010-backend-price-computation) | Accepted | Расчёт цен на backend |
| [ADR-011](ADR-011-tbank-invest-grpc) | Accepted | Интеграция с T-Bank Invest через gRPC |
Все опубликованные ADR находятся в `apps/docs/docs/adr/` и отображаются в этом Docusaurus-разделе. Все опубликованные ADR находятся в `apps/docs/docs/adr/` и отображаются в этом Docusaurus-разделе.

View File

@ -37,7 +37,7 @@ flowchart LR
```typescript ```typescript
getOrFetch<T>( getOrFetch<T>(
keyPrefix: string, // 'marketdata' | 'history' | 'candles' | 'search' | 'bond' | 'dividends' keyPrefix: string, // 'marketdata' | 'history' | 'candles' | 'search' | 'bond' | 'dividends' | 'tbank:*'
keyParts: string[], // ['shares', 'SBER'] | ['SBER', '2026-06-13', '2026-06-14'] keyParts: string[], // ['shares', 'SBER'] | ['SBER', '2026-06-13', '2026-06-14']
fetchFn: () => Promise<T>, fetchFn: () => Promise<T>,
ttlConfigKey: string, // 'marketDataTtl' | 'historyTtl' | etc. ttlConfigKey: string, // 'marketDataTtl' | 'historyTtl' | etc.
@ -78,3 +78,14 @@ export class CacheModule {}
| Спецификация инструмента | `securityTtl` | 86400s (24 ч) | `CACHE_SECURITY_TTL` | | Спецификация инструмента | `securityTtl` | 86400s (24 ч) | `CACHE_SECURITY_TTL` |
| Поиск | `searchTtl` | 3600s (1 ч) | `CACHE_SEARCH_TTL` | | Поиск | `searchTtl` | 3600s (1 ч) | `CACHE_SEARCH_TTL` |
| Дивиденды | `dividendsTtl` | 86400s (24 ч) | `CACHE_DIVIDENDS_TTL` | | Дивиденды | `dividendsTtl` | 86400s (24 ч) | `CACHE_DIVIDENDS_TTL` |
| Счета T-Bank | `tbankAccountsTtl` | 3600s (1 ч) | `CACHE_TBANK_ACCOUNTS_TTL` |
| Портфель T-Bank | `tbankPortfolioTtl` | 60s (1 мин) | `CACHE_TBANK_PORTFOLIO_TTL` |
| Операции T-Bank | `tbankOperationsTtl` | 300s (5 мин) | `CACHE_TBANK_OPERATIONS_TTL` |
| Инструменты T-Bank | `tbankInstrumentTtl` | 86400s (24 ч) | `CACHE_TBANK_INSTRUMENT_TTL` |
## T-Bank cache
`TBankModule` использует те же механики `CacheService`, но с отдельными key prefixes
`tbank:accounts`, `tbank:portfolio`, `tbank:positions`, `tbank:operations` и `tbank:instrument`.
Это позволяет держать агрессивно короткий TTL для текущего портфеля и более длинный TTL для
справочных данных инструментов.

View File

@ -11,12 +11,22 @@
| `MOEX_RATE_LIMIT` | `10` | Максимум запросов в секунду к MOEX | | `MOEX_RATE_LIMIT` | `10` | Максимум запросов в секунду к MOEX |
| `MOEX_CIRCUIT_BREAKER_THRESHOLD` | `5` | Количество ошибок до открытия circuit breaker | | `MOEX_CIRCUIT_BREAKER_THRESHOLD` | `5` | Количество ошибок до открытия circuit breaker |
| `MOEX_CIRCUIT_BREAKER_RESET_SECONDS` | `30` | Время в секундах до сброса circuit breaker | | `MOEX_CIRCUIT_BREAKER_RESET_SECONDS` | `30` | Время в секундах до сброса circuit breaker |
| `T_BANK_TOKEN` | `''` | Server-side токен T-Bank Invest |
| `T_BANK_BASE_URL` | `invest-public-api.tbank.ru:443` | gRPC endpoint T-Bank Invest |
| `T_BANK_CA_CERT_PATH` | `''` | Путь к PEM root CA для gRPC TLS, если локальная сеть подменяет сертификаты |
| `T_BANK_APP_NAME` | `ksv741.moex-vibe` | Metadata приложения для T-Bank |
| `T_BANK_RATE_LIMIT_PER_SECOND` | `5` | Локальный rate limiter для T-Bank |
| `T_BANK_REQUEST_TIMEOUT_MS` | `10000` | Deadline gRPC-запроса (мс) |
| `CACHE_MARKET_DATA_TTL` | `900` | TTL рыночных данных (секунды) | | `CACHE_MARKET_DATA_TTL` | `900` | TTL рыночных данных (секунды) |
| `CACHE_HISTORY_TTL` | `3600` | TTL истории торгов (секунды) | | `CACHE_HISTORY_TTL` | `3600` | TTL истории торгов (секунды) |
| `CACHE_CANDLES_TTL` | `3600` | TTL свечей (секунды) | | `CACHE_CANDLES_TTL` | `3600` | TTL свечей (секунды) |
| `CACHE_SECURITY_TTL` | `86400` | TTL спецификации инструмента (секунды) | | `CACHE_SECURITY_TTL` | `86400` | TTL спецификации инструмента (секунды) |
| `CACHE_SEARCH_TTL` | `3600` | TTL результатов поиска (секунды) | | `CACHE_SEARCH_TTL` | `3600` | TTL результатов поиска (секунды) |
| `CACHE_DIVIDENDS_TTL` | `86400` | TTL дивидендов (секунды) | | `CACHE_DIVIDENDS_TTL` | `86400` | TTL дивидендов (секунды) |
| `CACHE_TBANK_ACCOUNTS_TTL` | `3600` | TTL списка брокерских счетов T-Bank (секунды) |
| `CACHE_TBANK_PORTFOLIO_TTL` | `60` | TTL брокерского портфеля T-Bank (секунды) |
| `CACHE_TBANK_OPERATIONS_TTL` | `300` | TTL страницы операций T-Bank (секунды) |
| `CACHE_TBANK_INSTRUMENT_TTL` | `86400` | TTL метаданных инструментов T-Bank (секунды) |
## Файл конфигурации ## Файл конфигурации
@ -33,6 +43,14 @@ registerAs('app', () => ({
process.env.MOEX_CIRCUIT_BREAKER_RESET_SECONDS || '30', 10, process.env.MOEX_CIRCUIT_BREAKER_RESET_SECONDS || '30', 10,
), ),
}, },
tbank: {
token: process.env.T_BANK_TOKEN || '',
baseUrl: process.env.T_BANK_BASE_URL || 'invest-public-api.tbank.ru:443',
caCertPath: process.env.T_BANK_CA_CERT_PATH || '',
appName: process.env.T_BANK_APP_NAME || 'ksv741.moex-vibe',
rateLimitPerSecond: parseInt(process.env.T_BANK_RATE_LIMIT_PER_SECOND || '5', 10),
requestTimeoutMs: parseInt(process.env.T_BANK_REQUEST_TIMEOUT_MS || '10000', 10),
},
cache: { cache: {
marketDataTtl: parseInt(process.env.CACHE_MARKET_DATA_TTL || '900', 10), marketDataTtl: parseInt(process.env.CACHE_MARKET_DATA_TTL || '900', 10),
historyTtl: parseInt(process.env.CACHE_HISTORY_TTL || '3600', 10), historyTtl: parseInt(process.env.CACHE_HISTORY_TTL || '3600', 10),
@ -40,6 +58,10 @@ registerAs('app', () => ({
securityTtl: parseInt(process.env.CACHE_SECURITY_TTL || '86400', 10), securityTtl: parseInt(process.env.CACHE_SECURITY_TTL || '86400', 10),
searchTtl: parseInt(process.env.CACHE_SEARCH_TTL || '3600', 10), searchTtl: parseInt(process.env.CACHE_SEARCH_TTL || '3600', 10),
dividendsTtl: parseInt(process.env.CACHE_DIVIDENDS_TTL || '86400', 10), dividendsTtl: parseInt(process.env.CACHE_DIVIDENDS_TTL || '86400', 10),
tbankAccountsTtl: parseInt(process.env.CACHE_TBANK_ACCOUNTS_TTL || '3600', 10),
tbankPortfolioTtl: parseInt(process.env.CACHE_TBANK_PORTFOLIO_TTL || '60', 10),
tbankOperationsTtl: parseInt(process.env.CACHE_TBANK_OPERATIONS_TTL || '300', 10),
tbankInstrumentTtl: parseInt(process.env.CACHE_TBANK_INSTRUMENT_TTL || '86400', 10),
}, },
})); }));
``` ```

View File

@ -8,7 +8,7 @@
flowchart TB flowchart TB
AppModule["AppModule"] AppModule["AppModule"]
GlobalModules["Глобальные модули<br/>ConfigModule<br/>PrismaModule<br/>CacheModule<br/>MoexClientModule"] GlobalModules["Глобальные модули<br/>ConfigModule<br/>PrismaModule<br/>CacheModule<br/>MoexClientModule"]
FeatureModules["Feature-модули<br/>HealthModule<br/>AuthModule<br/>PortfolioModule<br/>SecuritiesModule<br/>SharesModule<br/>BondsModule<br/>CandlesModule"] FeatureModules["Feature-модули<br/>HealthModule<br/>AuthModule<br/>PortfolioModule<br/>SecuritiesModule<br/>SharesModule<br/>BondsModule<br/>CandlesModule<br/>TBankModule"]
AppModule --> GlobalModules AppModule --> GlobalModules
AppModule --> FeatureModules AppModule --> FeatureModules
@ -21,10 +21,12 @@ flowchart TB
AuthModule["AuthModule"] AuthModule["AuthModule"]
PortfolioModule["PortfolioModule"] PortfolioModule["PortfolioModule"]
MarketModules["SecuritiesModule<br/>SharesModule<br/>BondsModule<br/>CandlesModule"] MarketModules["SecuritiesModule<br/>SharesModule<br/>BondsModule<br/>CandlesModule"]
TBankModule["TBankModule"]
PrismaService["PrismaService"] PrismaService["PrismaService"]
CacheService["CacheService"] CacheService["CacheService"]
MoexClientService["MoexClientService"] MoexClientService["MoexClientService"]
TBankClientService["TBankClientService"]
PrismaModule --> PrismaService PrismaModule --> PrismaService
CacheModule --> CacheService CacheModule --> CacheService
@ -37,6 +39,10 @@ flowchart TB
MarketModules --> CacheService MarketModules --> CacheService
MarketModules --> MoexClientService MarketModules --> MoexClientService
TBankModule --> CacheService
TBankModule --> PrismaService
TBankModule --> TBankClientService
``` ```
## Список модулей ## Список модулей
@ -53,6 +59,7 @@ flowchart TB
| `BondsModule` | Нет | `modules/bonds/` | Облигации | | `BondsModule` | Нет | `modules/bonds/` | Облигации |
| `CandlesModule` | Нет | `modules/candles/` | Свечи OHLCV | | `CandlesModule` | Нет | `modules/candles/` | Свечи OHLCV |
| `PortfolioModule` | Нет | `modules/portfolio/` | Пользовательские портфели и аналитика | | `PortfolioModule` | Нет | `modules/portfolio/` | Пользовательские портфели и аналитика |
| `TBankModule` | Нет | `modules/tbank/` | Read-only брокерские портфели T-Bank Invest |
### PrismaModule ### PrismaModule
@ -131,3 +138,18 @@ flowchart TB
- Обогащение позиций текущими ценами из MOEX - Обогащение позиций текущими ценами из MOEX
- Расчёт summary, PnL и долей портфеля - Расчёт summary, PnL и долей портфеля
- Все endpoints защищены JWT - Все endpoints защищены JWT
### TBankModule
Read-only интеграция с T-Bank Invest для брокерских счетов и ИИС.
- `TBankClientService` создаёт gRPC clients по vendored proto-контрактам и добавляет metadata
`Authorization` + `x-app-name`
- `BrokerAccountsService` фильтрует только открытые брокерские счета и ИИС
- `BrokerPortfolioService` объединяет портфель, позиции, cash и метаданные инструментов
- `BrokerOperationsService` отдаёт cursor-paginated историю операций
- `BrokerOperationSyncService` сохраняет историю операций в отдельные Prisma-таблицы
- Broker endpoints требуют роль `user`; текущая single-user версия использует один server-side
`T_BANK_TOKEN`
- Direct-read endpoints используют `CacheService` с T-Bank TTL и не записывают данные в ручной
`PortfolioModule`

View File

@ -3,6 +3,12 @@
`PortfolioModule` позволяет пользователям создавать и вести виртуальные инвестиционные портфели для `PortfolioModule` позволяет пользователям создавать и вести виртуальные инвестиционные портфели для
аналитики и отслеживания позиций. аналитики и отслеживания позиций.
## Ручные портфели и брокерские портфели
`PortfolioModule` остаётся доменом ручных виртуальных портфелей. Брокерские счета T-Bank Invest
экспортируются отдельным `TBankModule` под `/api/v1/broker/*` и не сохраняются как записи
`Portfolio`.
## Обзор ## Обзор
- **Backend:** `PortfolioModule` (`apps/backend/src/modules/portfolio/`) - **Backend:** `PortfolioModule` (`apps/backend/src/modules/portfolio/`)

View File

@ -0,0 +1,81 @@
# T-Bank Invest
`TBankModule` — read-only интеграция backend с T-Bank Invest API. Frontend не обращается к
T-Bank напрямую: он использует endpoints MoexVibe под `/api/v1/broker`.
## Область поддержки
Первая версия показывает только открытые брокерские счета и ИИС:
- `ACCOUNT_TYPE_TINKOFF`
- `ACCOUNT_TYPE_TINKOFF_IIS`
Инвесткопилка, счета ЦФА, дебетовые счета, накопительные счета и счета фондов денежного рынка
игнорируются.
## Протокол
MoexVibe использует gRPC endpoint `invest-public-api.tbank.ru:443`. REST-прокси T-Bank считается
инструментом для ручной диагностики, а не основным протоколом интеграции.
Backend отправляет metadata:
```text
Authorization: Bearer <T_BANK_TOKEN>
x-app-name: ksv741.moex-vibe
```
Токен читается из env-переменных backend и не возвращается во frontend, Swagger responses или логи.
Если gRPC-запросы падают с `self-signed certificate in certificate chain`, значит Node.js не доверяет
root CA, которым локальная сеть или proxy подписывает TLS-цепочку. В этом случае нужно экспортировать
доверенный root certificate в PEM-файл и указать путь в `T_BANK_CA_CERT_PATH`. Проверку TLS отключать
не нужно.
## Backend endpoints
Все endpoints защищены JWT, требуют роль `user` и возвращают стандартную оболочку `{ data, meta }`.
| Endpoint | Описание |
|---|---|
| `GET /api/v1/broker/accounts` | Открытые брокерские счета и ИИС |
| `GET /api/v1/broker/accounts/:accountId/portfolio` | Итоги портфеля, позиции, деньги и заблокированные деньги |
| `GET /api/v1/broker/accounts/:accountId/operations` | История операций с cursor pagination |
| `POST /api/v1/broker/accounts/:accountId/operations/sync` | Синхронизация истории операций в локальные Prisma-таблицы |
## Методы T-Bank
| Задача | Метод T-Bank |
|---|---|
| Счета | `UsersService/GetAccounts` |
| Итоги портфеля | `OperationsService/GetPortfolio` |
| Деньги и settled-позиции | `OperationsService/GetPositions` |
| История операций | `OperationsService/GetOperationsByCursor` |
| Метаданные инструментов | `InstrumentsService/GetInstrumentBy` |
## Кеширование и синхронизация
Direct-read endpoints используют короткий in-memory cache, чтобы не спамить T-Bank API при
переключении страниц и повторных запросах:
- счета: `CACHE_TBANK_ACCOUNTS_TTL`
- портфель: `CACHE_TBANK_PORTFOLIO_TTL`
- страницы операций: `CACHE_TBANK_OPERATIONS_TTL`
- метаданные инструментов: `CACHE_TBANK_INSTRUMENT_TTL`
Для долговременной истории операций есть отдельные таблицы Prisma:
- `BrokerOperation` — нормализованная операция и сырой JSON payload;
- `BrokerOperationSyncState` — состояние последней синхронизации по брокерскому счёту.
Синхронизация запускается явно через endpoint `POST .../operations/sync` с query параметрами `from`
и `to` в ISO-8601 формате.
Эти таблицы не связаны с ручными портфелями `Portfolio` и `Position`.
## Безопасность
Текущая версия рассчитана на single-user сценарий: используется один server-side `T_BANK_TOKEN`, а
broker endpoints доступны аутентифицированным пользователям с ролью `user`. Перед multi-user режимом
нужно добавить зашифрованное хранение пользовательских T-Bank токенов и привязку каждого брокерского
счёта к владельцу.

View File

@ -17,6 +17,7 @@ const sidebars: SidebarsConfig = {
'backend/configuration', 'backend/configuration',
'backend/caching', 'backend/caching',
'backend/moex-client', 'backend/moex-client',
'backend/tbank-invest',
'backend/securities', 'backend/securities',
'backend/portfolio', 'backend/portfolio',
], ],
@ -63,6 +64,7 @@ const sidebars: SidebarsConfig = {
'adr/ADR-008-auth-system', 'adr/ADR-008-auth-system',
'adr/ADR-009-portfolio-domain', 'adr/ADR-009-portfolio-domain',
'adr/ADR-010-backend-price-computation', 'adr/ADR-010-backend-price-computation',
'adr/ADR-011-tbank-invest-grpc',
], ],
}, },
], ],

View File

@ -0,0 +1,27 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { getBrokerOperations } from './broker';
describe('broker api', () => {
afterEach(() => {
vi.restoreAllMocks();
});
it('serializes operations query parameters', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValue({
ok: true,
json: async () => ({
data: {
data: { accountId: 'acc-1', items: [], nextCursor: null, hasNext: false, asOf: 'now' },
meta: { fromCache: false, cachedAt: null },
},
}),
} as Response);
await getBrokerOperations('acc-1', { cursor: 'c1', limit: 50 });
expect(fetch).toHaveBeenCalledWith(
expect.stringContaining('/api/v1/broker/accounts/acc-1/operations?cursor=c1&limit=50'),
expect.any(Object),
);
});
});

View File

@ -0,0 +1,51 @@
import { request } from './client';
import type {
ApiResponseMeta,
BrokerAccount,
BrokerOperationsPage,
BrokerPortfolio,
} from './responses';
export type BrokerOperationQuery = {
from?: string;
to?: string;
cursor?: string;
limit?: number;
instrumentId?: string;
operationTypes?: string;
state?: string;
};
export function getBrokerAccounts(): Promise<{
data: BrokerAccount[];
meta: ApiResponseMeta;
}> {
return request<BrokerAccount[]>('/api/v1/broker/accounts');
}
export function getBrokerPortfolio(accountId: string): Promise<{
data: BrokerPortfolio;
meta: ApiResponseMeta;
}> {
return request<BrokerPortfolio>(
`/api/v1/broker/accounts/${encodeURIComponent(accountId)}/portfolio`,
);
}
export function getBrokerOperations(
accountId: string,
query: BrokerOperationQuery = {},
): Promise<{ data: BrokerOperationsPage; meta: ApiResponseMeta }> {
return request<BrokerOperationsPage>(
`/api/v1/broker/accounts/${encodeURIComponent(accountId)}/operations`,
{
from: query.from,
to: query.to,
cursor: query.cursor,
limit: query.limit ? String(query.limit) : undefined,
instrumentId: query.instrumentId,
operationTypes: query.operationTypes,
state: query.state,
},
);
}

View File

@ -17,6 +17,24 @@ describe('request', () => {
expect(result.data.status).toBe('ok'); expect(result.data.status).toBe('ok');
}); });
it('supports the single API envelope shape documented by Swagger', async () => {
server.use(
http.get(`${API}/test-single-envelope`, () =>
HttpResponse.json({
data: { ok: true },
meta: { fromCache: true, cachedAt: '2026-06-17T00:00:00.000Z' },
}),
),
);
const result = await request<{ ok: boolean }>('/api/v1/test-single-envelope');
expect(result).toEqual({
data: { ok: true },
meta: { fromCache: true, cachedAt: '2026-06-17T00:00:00.000Z' },
});
});
it('includes Authorization header when token is set', async () => { it('includes Authorization header when token is set', async () => {
setAccessToken('test-token'); setAccessToken('test-token');
let capturedAuth: string | null = null; let capturedAuth: string | null = null;

View File

@ -40,14 +40,31 @@ async function refreshTokens(): Promise<boolean> {
credentials: 'include', credentials: 'include',
}); });
if (!res.ok) return false; if (!res.ok) return false;
const json: ApiEnvelope<{ data: AuthResponse; meta: ApiResponseMeta }> = await res.json(); const json = await res.json();
accessToken = json.data.data.accessToken; accessToken = normalizeEnvelope<AuthResponse>(json).data.accessToken;
return true; return true;
} catch { } catch {
return false; return false;
} }
} }
function normalizeEnvelope<T>(json: unknown): { data: T; meta: ApiResponseMeta } {
const envelope = json as ApiEnvelope<T | { data: T; meta: ApiResponseMeta }>;
if (
envelope.data &&
typeof envelope.data === 'object' &&
'data' in envelope.data &&
'meta' in envelope.data
) {
return envelope.data as { data: T; meta: ApiResponseMeta };
}
return {
data: envelope.data as T,
meta: envelope.meta,
};
}
async function handleUnauthorized(): Promise<boolean> { async function handleUnauthorized(): Promise<boolean> {
if (isRefreshing && refreshPromise) { if (isRefreshing && refreshPromise) {
return refreshPromise; return refreshPromise;
@ -114,8 +131,8 @@ export async function request<T>(
throw new Error(`Ошибка API: ${res.status} ${res.statusText}${text ? ` - ${text}` : ''}`); throw new Error(`Ошибка API: ${res.status} ${res.statusText}${text ? ` - ${text}` : ''}`);
} }
const json: ApiEnvelope<{ data: T; meta: ApiResponseMeta }> = await res.json(); const json = await res.json();
return json.data; return normalizeEnvelope<T>(json);
} }
export function getHealth(): Promise<{ data: HealthResponse; meta: ApiResponseMeta }> { export function getHealth(): Promise<{ data: HealthResponse; meta: ApiResponseMeta }> {

View File

@ -245,3 +245,94 @@ export interface ScreenerResult {
pageSize: number; pageSize: number;
totalPages: number; totalPages: number;
} }
export interface BrokerMoney {
currency: string;
units: string;
nano: number;
value: number;
}
export interface BrokerAccount {
id: string;
type: 'brokerage' | 'iis';
name: string;
status: string;
openedAt: string | null;
accessLevel: string | null;
}
export interface BrokerPosition {
figi: string | null;
instrumentUid: string | null;
positionUid: string | null;
ticker: string | null;
classCode: string | null;
instrumentType: string | null;
name: string | null;
quantity: number | null;
blockedLots: number | null;
currentPrice: BrokerMoney | null;
currentValue: BrokerMoney | null;
averagePositionPrice: BrokerMoney | null;
expectedYieldPercent: number | null;
dailyYield: BrokerMoney | null;
}
export interface BrokerPortfolio {
account: BrokerAccount;
totals: {
shares: BrokerMoney | null;
bonds: BrokerMoney | null;
etf: BrokerMoney | null;
currencies: BrokerMoney | null;
futures: BrokerMoney | null;
options: BrokerMoney | null;
structuredProducts: BrokerMoney | null;
dfa: BrokerMoney | null;
portfolio: BrokerMoney | null;
};
yields: {
expectedPercent: number | null;
daily: BrokerMoney | null;
dailyPercent: number | null;
};
cash: BrokerMoney[];
blockedCash: BrokerMoney[];
positions: BrokerPosition[];
asOf: string;
}
export type BrokerOperationCategory = 'trade' | 'income' | 'tax' | 'fee' | 'transfer' | 'other';
export interface BrokerOperation {
cursor: string | null;
accountId: string;
id: string | null;
parentOperationId: string | null;
date: string | null;
type: string;
category: BrokerOperationCategory;
description: string | null;
state: string | null;
instrumentUid: string | null;
figi: string | null;
ticker: string | null;
classCode: string | null;
instrumentType: string | null;
payment: BrokerMoney | null;
price: BrokerMoney | null;
commission: BrokerMoney | null;
yield: BrokerMoney | null;
accruedInt: BrokerMoney | null;
quantity: number | null;
quantityDone: number | null;
}
export interface BrokerOperationsPage {
accountId: string;
items: BrokerOperation[];
nextCursor: string | null;
hasNext: boolean;
asOf: string;
}

View File

@ -383,6 +383,74 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
'/api/v1/broker/accounts': {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Get open T-Bank brokerage and IIS accounts */
get: operations['TBankController_getAccounts'];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
'/api/v1/broker/accounts/{accountId}/portfolio': {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Get T-Bank broker account portfolio with cash and positions */
get: operations['TBankController_getPortfolio'];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
'/api/v1/broker/accounts/{accountId}/operations': {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Get paginated T-Bank broker account operations */
get: operations['TBankController_getOperations'];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
'/api/v1/broker/accounts/{accountId}/operations/sync': {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Synchronize T-Bank broker account operations into local history */
post: operations['TBankController_syncOperations'];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
} }
export type webhooks = Record<string, never>; export type webhooks = Record<string, never>;
export interface components { export interface components {
@ -689,6 +757,117 @@ export interface components {
data: components['schemas']['AnalyticsResponseDto']; data: components['schemas']['AnalyticsResponseDto'];
meta: components['schemas']['PortfolioResponseMetaDto']; meta: components['schemas']['PortfolioResponseMetaDto'];
}; };
BrokerAccountResponseDto: {
id: string;
/** @enum {string} */
type: 'brokerage' | 'iis';
name: string;
status: string;
openedAt: Record<string, never> | null;
accessLevel: Record<string, never> | null;
};
BrokerResponseMetaDto: {
cachedAt: Record<string, never> | null;
fromCache: boolean;
};
BrokerAccountsEnvelopeDto: {
data: components['schemas']['BrokerAccountResponseDto'][];
meta: components['schemas']['BrokerResponseMetaDto'];
};
BrokerMoneyDto: {
currency: string;
units: string;
nano: number;
value: number;
};
BrokerPortfolioTotalsDto: {
shares: components['schemas']['BrokerMoneyDto'] | null;
bonds: components['schemas']['BrokerMoneyDto'] | null;
etf: components['schemas']['BrokerMoneyDto'] | null;
currencies: components['schemas']['BrokerMoneyDto'] | null;
futures: components['schemas']['BrokerMoneyDto'] | null;
options: components['schemas']['BrokerMoneyDto'] | null;
structuredProducts: components['schemas']['BrokerMoneyDto'] | null;
dfa: components['schemas']['BrokerMoneyDto'] | null;
portfolio: components['schemas']['BrokerMoneyDto'] | null;
};
BrokerPortfolioYieldsDto: {
expectedPercent: Record<string, never> | null;
daily: components['schemas']['BrokerMoneyDto'] | null;
dailyPercent: Record<string, never> | null;
};
BrokerPositionResponseDto: {
figi: Record<string, never> | null;
instrumentUid: Record<string, never> | null;
positionUid: Record<string, never> | null;
ticker: Record<string, never> | null;
classCode: Record<string, never> | null;
instrumentType: Record<string, never> | null;
name: Record<string, never> | null;
quantity: Record<string, never> | null;
blockedLots: Record<string, never> | null;
currentPrice: components['schemas']['BrokerMoneyDto'] | null;
currentValue: components['schemas']['BrokerMoneyDto'] | null;
averagePositionPrice: components['schemas']['BrokerMoneyDto'] | null;
expectedYieldPercent: Record<string, never> | null;
dailyYield: components['schemas']['BrokerMoneyDto'] | null;
};
BrokerPortfolioResponseDto: {
account: components['schemas']['BrokerAccountResponseDto'];
totals: components['schemas']['BrokerPortfolioTotalsDto'];
yields: components['schemas']['BrokerPortfolioYieldsDto'];
cash: components['schemas']['BrokerMoneyDto'][];
blockedCash: components['schemas']['BrokerMoneyDto'][];
positions: components['schemas']['BrokerPositionResponseDto'][];
asOf: string;
};
BrokerPortfolioEnvelopeDto: {
data: components['schemas']['BrokerPortfolioResponseDto'];
meta: components['schemas']['BrokerResponseMetaDto'];
};
BrokerOperationResponseDto: {
cursor: Record<string, never> | null;
accountId: string;
id: Record<string, never> | null;
parentOperationId: Record<string, never> | null;
date: Record<string, never> | null;
type: string;
/** @enum {string} */
category: 'trade' | 'income' | 'tax' | 'fee' | 'transfer' | 'other';
description: Record<string, never> | null;
state: Record<string, never> | null;
instrumentUid: Record<string, never> | null;
figi: Record<string, never> | null;
ticker: Record<string, never> | null;
classCode: Record<string, never> | null;
instrumentType: Record<string, never> | null;
payment: components['schemas']['BrokerMoneyDto'] | null;
price: components['schemas']['BrokerMoneyDto'] | null;
commission: components['schemas']['BrokerMoneyDto'] | null;
yield: components['schemas']['BrokerMoneyDto'] | null;
accruedInt: components['schemas']['BrokerMoneyDto'] | null;
quantity: Record<string, never> | null;
quantityDone: Record<string, never> | null;
};
BrokerOperationsPageResponseDto: {
accountId: string;
items: components['schemas']['BrokerOperationResponseDto'][];
nextCursor: Record<string, never> | null;
hasNext: boolean;
asOf: string;
};
BrokerOperationsEnvelopeDto: {
data: components['schemas']['BrokerOperationsPageResponseDto'];
meta: components['schemas']['BrokerResponseMetaDto'];
};
BrokerOperationSyncResponseDto: {
/** @example 42 */
upserted: number;
};
BrokerOperationSyncEnvelopeDto: {
data: components['schemas']['BrokerOperationSyncResponseDto'];
meta: components['schemas']['BrokerResponseMetaDto'];
};
}; };
responses: never; responses: never;
parameters: never; parameters: never;
@ -1300,4 +1479,97 @@ export interface operations {
}; };
}; };
}; };
TBankController_getAccounts: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content: {
'application/json': components['schemas']['BrokerAccountsEnvelopeDto'];
};
};
};
};
TBankController_getPortfolio: {
parameters: {
query?: never;
header?: never;
path: {
accountId: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content: {
'application/json': components['schemas']['BrokerPortfolioEnvelopeDto'];
};
};
};
};
TBankController_getOperations: {
parameters: {
query?: {
from?: string;
to?: string;
cursor?: string;
limit?: number;
instrumentId?: string;
operationTypes?: string;
state?: string;
};
header?: never;
path: {
accountId: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content: {
'application/json': components['schemas']['BrokerOperationsEnvelopeDto'];
};
};
};
};
TBankController_syncOperations: {
parameters: {
query: {
from: string;
to: string;
};
header?: never;
path: {
accountId: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content: {
'application/json': components['schemas']['BrokerOperationSyncEnvelopeDto'];
};
};
};
};
} }

View File

@ -20,6 +20,7 @@ export function Layout() {
padding: '12px 24px', padding: '12px 24px',
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
flexWrap: 'wrap',
gap: 24, gap: 24,
}} }}
> >
@ -34,7 +35,9 @@ export function Layout() {
> >
MoexVibe MoexVibe
</Link> </Link>
<div style={{ flex: '1 1 280px', minWidth: 220, maxWidth: 420 }}>
<SearchBar /> <SearchBar />
</div>
<Link <Link
to="/portfolios" to="/portfolios"
style={{ style={{
@ -46,6 +49,17 @@ export function Layout() {
> >
Портфели Портфели
</Link> </Link>
<Link
to="/broker"
style={{
fontSize: 14,
color: 'var(--color-text)',
textDecoration: 'none',
fontWeight: 500,
}}
>
Брокер
</Link>
<Link <Link
to="/screener" to="/screener"
style={{ style={{
@ -58,7 +72,15 @@ export function Layout() {
Скринер Скринер
</Link> </Link>
<div style={{ marginLeft: 'auto', display: 'flex', alignItems: 'center', gap: 12 }}> <div
style={{
marginLeft: 'auto',
display: 'flex',
alignItems: 'center',
gap: 12,
flexWrap: 'wrap',
}}
>
{isAuthenticated ? ( {isAuthenticated ? (
<> <>
<Link <Link
@ -104,7 +126,15 @@ export function Layout() {
)} )}
</div> </div>
</header> </header>
<main style={{ flex: 1, padding: 24, maxWidth: 1200, width: '100%', margin: '0 auto' }}> <main
style={{
flex: 1,
padding: 'clamp(16px, 4vw, 24px)',
maxWidth: 1200,
width: '100%',
margin: '0 auto',
}}
>
<Outlet /> <Outlet />
</main> </main>
</div> </div>

View File

@ -38,7 +38,7 @@ export function PortfolioCard({ portfolio }: { portfolio: Portfolio }) {
<h3 style={{ margin: 0, fontSize: 16, fontWeight: 600, flex: 1 }}>{portfolio.name}</h3> <h3 style={{ margin: 0, fontSize: 16, fontWeight: 600, flex: 1 }}>{portfolio.name}</h3>
<div style={{ textAlign: 'right' }}> <div style={{ textAlign: 'right' }}>
<div style={{ fontSize: 20, fontWeight: 700, lineHeight: 1.2 }}> <div style={{ fontSize: 20, fontWeight: 700, lineHeight: 1.2 }}>
{portfolio.totalValue.toLocaleString('ru-RU', { {portfolio.totalValue?.toLocaleString('ru-RU', {
minimumFractionDigits: 2, minimumFractionDigits: 2,
maximumFractionDigits: 2, maximumFractionDigits: 2,
})} })}

View File

@ -19,7 +19,7 @@ export function PortfolioSummary({ portfolio }: { portfolio: PortfolioDetail })
Общая стоимость Общая стоимость
</div> </div>
<div style={{ fontSize: 24, fontWeight: 700 }}> <div style={{ fontSize: 24, fontWeight: 700 }}>
{portfolio.totalValue.toLocaleString('ru-RU', { {portfolio.totalValue?.toLocaleString('ru-RU', {
minimumFractionDigits: 2, minimumFractionDigits: 2,
maximumFractionDigits: 2, maximumFractionDigits: 2,
})} })}

View File

@ -0,0 +1,41 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { renderHook, waitFor } from '@testing-library/react';
import { type ReactNode } from 'react';
import { describe, expect, it, vi } from 'vitest';
import { getBrokerAccounts } from '../api/broker';
import { useBrokerAccounts } from './useBrokerAccounts';
vi.mock('../api/broker', () => ({
getBrokerAccounts: vi.fn(),
}));
function createWrapper() {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return function Wrapper({ children }: { children: ReactNode }) {
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
};
}
describe('useBrokerAccounts', () => {
it('returns broker accounts from API', async () => {
vi.mocked(getBrokerAccounts).mockResolvedValue({
data: [
{
id: 'acc-1',
type: 'brokerage',
name: 'Broker',
status: 'ACCOUNT_STATUS_OPEN',
openedAt: null,
accessLevel: null,
},
],
meta: { fromCache: false, cachedAt: null },
});
const { result } = renderHook(() => useBrokerAccounts(), { wrapper: createWrapper() });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data?.[0].name).toBe('Broker');
});
});

View File

@ -0,0 +1,13 @@
import { useQuery } from '@tanstack/react-query';
import { getBrokerAccounts } from '../api/broker';
import type { BrokerAccount } from '../api/responses';
export function useBrokerAccounts() {
return useQuery<BrokerAccount[]>({
queryKey: ['broker', 'accounts'],
queryFn: async () => (await getBrokerAccounts()).data,
staleTime: 3_600_000,
retry: 2,
refetchOnWindowFocus: false,
});
}

View File

@ -0,0 +1,17 @@
import { useQuery } from '@tanstack/react-query';
import { getBrokerOperations, type BrokerOperationQuery } from '../api/broker';
import type { BrokerOperationsPage } from '../api/responses';
export function useBrokerOperations(
accountId: string | undefined,
query: BrokerOperationQuery = {},
) {
return useQuery<BrokerOperationsPage>({
queryKey: ['broker', 'operations', accountId, query],
enabled: Boolean(accountId),
queryFn: async () => (await getBrokerOperations(accountId!, query)).data,
staleTime: 300_000,
retry: 2,
refetchOnWindowFocus: false,
});
}

View File

@ -0,0 +1,14 @@
import { useQuery } from '@tanstack/react-query';
import { getBrokerPortfolio } from '../api/broker';
import type { BrokerPortfolio } from '../api/responses';
export function useBrokerPortfolio(accountId: string | undefined) {
return useQuery<BrokerPortfolio>({
queryKey: ['broker', 'portfolio', accountId],
enabled: Boolean(accountId),
queryFn: async () => (await getBrokerPortfolio(accountId!)).data,
staleTime: 60_000,
retry: 2,
refetchOnWindowFocus: false,
});
}

View File

@ -0,0 +1,179 @@
import { useParams } from 'react-router-dom';
import type { BrokerMoney } from '../../api/responses';
import { useBrokerOperations } from '../../hooks/useBrokerOperations';
import { useBrokerPortfolio } from '../../hooks/useBrokerPortfolio';
function formatMoney(value: BrokerMoney | null | undefined) {
if (!value) return '-';
return new Intl.NumberFormat('ru-RU', {
style: 'currency',
currency: value.currency || 'RUB',
maximumFractionDigits: 2,
}).format(value.value);
}
function formatDate(value: string | null) {
if (!value) return '-';
return new Date(value).toLocaleString('ru-RU');
}
const tableStyle = {
width: '100%',
borderCollapse: 'collapse',
fontSize: 14,
} satisfies React.CSSProperties;
const thStyle = {
borderBottom: '1px solid #e0e0e0',
color: 'var(--color-text-secondary)',
fontWeight: 600,
padding: '10px 8px',
} satisfies React.CSSProperties;
const tdStyle = {
borderBottom: '1px solid #eeeeee',
padding: '10px 8px',
verticalAlign: 'top',
} satisfies React.CSSProperties;
export function BrokerAccountDetailPage() {
const { accountId } = useParams();
const portfolio = useBrokerPortfolio(accountId);
const operations = useBrokerOperations(accountId, { limit: 100 });
if (portfolio.isLoading) return <p>Загрузка портфеля...</p>;
if (portfolio.error || !portfolio.data) {
return <p style={{ color: 'var(--color-negative)' }}>Не удалось загрузить портфель</p>;
}
return (
<div style={{ display: 'grid', gap: 24 }}>
<header>
<h1 style={{ fontSize: 28, lineHeight: 1.2, marginBottom: 12 }}>
{portfolio.data.account.name}
</h1>
<div style={{ display: 'flex', gap: 16, flexWrap: 'wrap', alignItems: 'baseline' }}>
<strong style={{ fontSize: 24 }}>{formatMoney(portfolio.data.totals.portfolio)}</strong>
<span style={{ color: 'var(--color-text-secondary)' }}>
День: {formatMoney(portfolio.data.yields.daily)}
</span>
<span style={{ color: 'var(--color-text-secondary)' }}>
Ожидаемая: {portfolio.data.yields.expectedPercent ?? '-'}%
</span>
</div>
</header>
<section
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))',
gap: 12,
}}
>
{portfolio.data.cash.map((money) => (
<div
key={money.currency}
style={{
background: 'var(--color-surface)',
border: '1px solid #e0e0e0',
borderRadius: 8,
padding: 16,
}}
>
<div style={{ color: 'var(--color-text-secondary)', fontSize: 13 }}>
{money.currency}
</div>
<strong>{formatMoney(money)}</strong>
</div>
))}
</section>
<section>
<h2 style={{ fontSize: 20, marginBottom: 12 }}>Позиции</h2>
<div style={{ overflowX: 'auto', background: 'var(--color-surface)' }}>
<table style={tableStyle}>
<thead>
<tr>
<th align="left" style={thStyle}>
Инструмент
</th>
<th align="right" style={thStyle}>
Количество
</th>
<th align="right" style={thStyle}>
Стоимость
</th>
<th align="right" style={thStyle}>
Доходность
</th>
</tr>
</thead>
<tbody>
{portfolio.data.positions.map((position) => (
<tr key={position.positionUid || position.instrumentUid || position.ticker}>
<td style={tdStyle}>
<strong>{position.ticker || position.name || position.figi}</strong>
{position.name && (
<div style={{ color: 'var(--color-text-secondary)' }}>{position.name}</div>
)}
</td>
<td align="right" style={tdStyle}>
{position.quantity ?? '-'}
</td>
<td align="right" style={tdStyle}>
{formatMoney(position.currentValue)}
</td>
<td align="right" style={tdStyle}>
{position.expectedYieldPercent ?? '-'}%
</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
<section>
<h2 style={{ fontSize: 20, marginBottom: 12 }}>Операции</h2>
{operations.isLoading ? (
<p>Загрузка операций...</p>
) : (
<div style={{ overflowX: 'auto', background: 'var(--color-surface)' }}>
<table style={tableStyle}>
<thead>
<tr>
<th align="left" style={thStyle}>
Дата
</th>
<th align="left" style={thStyle}>
Тип
</th>
<th align="left" style={thStyle}>
Инструмент
</th>
<th align="right" style={thStyle}>
Сумма
</th>
</tr>
</thead>
<tbody>
{(operations.data?.items ?? []).map((operation) => (
<tr key={operation.cursor || operation.id}>
<td style={tdStyle}>{formatDate(operation.date)}</td>
<td style={tdStyle}>{operation.type}</td>
<td style={tdStyle}>{operation.ticker || operation.description || '-'}</td>
<td align="right" style={tdStyle}>
{formatMoney(operation.payment)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
</div>
);
}

View File

@ -0,0 +1,50 @@
import { Link } from 'react-router-dom';
import { useBrokerAccounts } from '../../hooks/useBrokerAccounts';
const cardStyle = {
display: 'block',
padding: 20,
background: 'var(--color-surface)',
border: '1px solid #e0e0e0',
borderRadius: 8,
color: 'var(--color-text)',
textDecoration: 'none',
boxShadow: 'var(--shadow)',
} satisfies React.CSSProperties;
export function BrokerAccountsPage() {
const { data: accounts, isLoading, error } = useBrokerAccounts();
if (isLoading) return <p>Загрузка брокерских счетов...</p>;
if (error) return <p style={{ color: 'var(--color-negative)' }}>Не удалось загрузить счета</p>;
return (
<div>
<div style={{ display: 'flex', alignItems: 'baseline', gap: 12, marginBottom: 20 }}>
<h1 style={{ fontSize: 28, lineHeight: 1.2 }}>Брокерские счета</h1>
<span style={{ color: 'var(--color-text-secondary)', fontSize: 14 }}>
{(accounts ?? []).length}
</span>
</div>
<div
style={{
display: 'grid',
gap: 16,
gridTemplateColumns: 'repeat(auto-fit, minmax(260px, 1fr))',
}}
>
{(accounts ?? []).map((account) => (
<Link key={account.id} to={`/broker/${encodeURIComponent(account.id)}`} style={cardStyle}>
<div style={{ fontSize: 18, fontWeight: 700, marginBottom: 10 }}>{account.name}</div>
<div style={{ display: 'grid', gap: 6, color: 'var(--color-text-secondary)' }}>
<span>{account.type === 'iis' ? 'ИИС' : 'Брокерский счет'}</span>
<span>{account.status}</span>
<span>{account.id}</span>
</div>
</Link>
))}
</div>
</div>
);
}

View File

@ -0,0 +1,137 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { render, screen } from '@testing-library/react';
import { type ReactElement } from 'react';
import { MemoryRouter, Route, Routes } from 'react-router-dom';
import { describe, expect, it, vi } from 'vitest';
import * as accountHook from '../../hooks/useBrokerAccounts';
import * as operationsHook from '../../hooks/useBrokerOperations';
import * as portfolioHook from '../../hooks/useBrokerPortfolio';
import { BrokerAccountDetailPage } from './BrokerAccountDetailPage';
import { BrokerAccountsPage } from './BrokerAccountsPage';
function renderWithClient(ui: ReactElement, initialEntries = ['/broker']) {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={client}>
<MemoryRouter initialEntries={initialEntries}>{ui}</MemoryRouter>
</QueryClientProvider>,
);
}
describe('Broker pages', () => {
it('renders broker and IIS accounts', () => {
vi.spyOn(accountHook, 'useBrokerAccounts').mockReturnValue({
data: [
{
id: 'acc-1',
type: 'brokerage',
name: 'Broker',
status: 'ACCOUNT_STATUS_OPEN',
openedAt: null,
accessLevel: null,
},
{
id: 'acc-2',
type: 'iis',
name: 'IIS',
status: 'ACCOUNT_STATUS_OPEN',
openedAt: null,
accessLevel: null,
},
],
isLoading: false,
error: null,
} as any);
renderWithClient(<BrokerAccountsPage />);
expect(screen.getByText('Broker')).toBeInTheDocument();
expect(screen.getByText('IIS')).toBeInTheDocument();
});
it('renders positions and operations for account detail', () => {
vi.spyOn(portfolioHook, 'useBrokerPortfolio').mockReturnValue({
data: {
account: {
id: 'acc-1',
type: 'brokerage',
name: 'Broker',
status: 'ACCOUNT_STATUS_OPEN',
openedAt: null,
accessLevel: null,
},
totals: { portfolio: { currency: 'RUB', units: '1000', nano: 0, value: 1000 } },
yields: { expectedPercent: 5, daily: null, dailyPercent: null },
cash: [{ currency: 'RUB', units: '100', nano: 0, value: 100 }],
blockedCash: [],
positions: [
{
figi: null,
instrumentUid: 'uid-1',
positionUid: null,
ticker: 'SBER',
classCode: 'TQBR',
instrumentType: 'share',
name: 'Sberbank',
quantity: 10,
blockedLots: null,
currentPrice: null,
currentValue: { currency: 'RUB', units: '1000', nano: 0, value: 1000 },
averagePositionPrice: null,
expectedYieldPercent: null,
dailyYield: null,
},
],
asOf: '2026-06-16T00:00:00.000Z',
},
isLoading: false,
error: null,
} as any);
vi.spyOn(operationsHook, 'useBrokerOperations').mockReturnValue({
data: {
accountId: 'acc-1',
items: [
{
cursor: 'cursor-1',
accountId: 'acc-1',
id: 'op-1',
parentOperationId: null,
date: '2026-06-16T00:00:00.000Z',
category: 'trade',
type: 'OPERATION_TYPE_BUY',
description: 'Buy',
state: 'OPERATION_STATE_EXECUTED',
instrumentUid: 'uid-1',
figi: null,
ticker: 'SBER',
classCode: 'TQBR',
instrumentType: 'share',
payment: { currency: 'RUB', units: '-1000', nano: 0, value: -1000 },
price: null,
commission: null,
yield: null,
accruedInt: null,
quantity: 10,
quantityDone: 10,
},
],
nextCursor: null,
hasNext: false,
asOf: '2026-06-16T00:00:00.000Z',
},
isLoading: false,
error: null,
} as any);
renderWithClient(
<Routes>
<Route path="/broker/:accountId" element={<BrokerAccountDetailPage />} />
</Routes>,
['/broker/acc-1'],
);
expect(screen.getAllByText('SBER').length).toBeGreaterThan(0);
expect(screen.getByText('OPERATION_TYPE_BUY')).toBeInTheDocument();
});
});

View File

@ -10,6 +10,8 @@ import { ProtectedRoute } from './components/ProtectedRoute';
import { PortfoliosListPage } from './pages/portfolios/PortfoliosListPage'; import { PortfoliosListPage } from './pages/portfolios/PortfoliosListPage';
import { PortfolioDetailPage } from './pages/portfolios/PortfolioDetailPage'; import { PortfolioDetailPage } from './pages/portfolios/PortfolioDetailPage';
import { ScreenerPage } from './pages/screener/ScreenerPage'; import { ScreenerPage } from './pages/screener/ScreenerPage';
import { BrokerAccountsPage } from './pages/broker/BrokerAccountsPage';
import { BrokerAccountDetailPage } from './pages/broker/BrokerAccountDetailPage';
export function AppRoutes() { export function AppRoutes() {
return ( return (
@ -45,6 +47,22 @@ export function AppRoutes() {
</ProtectedRoute> </ProtectedRoute>
} }
/> />
<Route
path="/broker"
element={
<ProtectedRoute>
<BrokerAccountsPage />
</ProtectedRoute>
}
/>
<Route
path="/broker/:accountId"
element={
<ProtectedRoute>
<BrokerAccountDetailPage />
</ProtectedRoute>
}
/>
</Route> </Route>
</Routes> </Routes>
); );