From feaff2103e27ea7aad9f0c1563797f058a4015fd Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Thu, 18 Jun 2026 06:34:55 +0300 Subject: [PATCH] perf(broker): parallel instrument name loading with per-service rate limit queues - Split single p-queue (5 req/s) into 3 isolated queues: operations (5/s), instruments (20/s), users (5/s) - Removed dead instruments param from mapBrokerPortfolio - portfolio/positions endpoints share raw GetPortfolio cache - Docs: T_BANK_INSTRUMENTS_RATE_LIMIT, CACHE_TBANK_POSITIONS_TTL, rate limiting section in tbank-invest.md --- AGENTS.md | 3 +- apps/backend/src/config/configuration.ts | 1 + .../tbank/mappers/portfolio.mapper.spec.ts | 1 - .../modules/tbank/mappers/portfolio.mapper.ts | 1 - .../tbank/services/broker-accounts.service.ts | 11 +- .../services/broker-instruments.service.ts | 1 + .../services/broker-portfolio.service.spec.ts | 1 + .../services/broker-portfolio.service.ts | 64 +- .../tbank/services/tbank-client.service.ts | 21 +- apps/docs/docs/backend/caching.md | 1 + apps/docs/docs/backend/configuration.md | 7 +- apps/docs/docs/backend/tbank-invest.md | 15 + ...06-17-broker-operations-ui-improvements.md | 318 +++ ...broker-positions-pagination-and-loading.md | 1996 +++++++++++++++++ ...6-06-18-broker-performance-optimization.md | 88 + 15 files changed, 2491 insertions(+), 38 deletions(-) create mode 100644 docs/superpowers/plans/2026-06-17-broker-operations-ui-improvements.md create mode 100644 docs/superpowers/plans/2026-06-17-broker-positions-pagination-and-loading.md create mode 100644 docs/superpowers/specs/2026-06-18-broker-performance-optimization.md diff --git a/AGENTS.md b/AGENTS.md index 592192d..8c50cb8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -59,7 +59,8 @@ Live MOEX integration tests opt-in: `npm run test:integration -w apps/backend`. | `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_RATE_LIMIT_PER_SECOND` | 5 | Rate limiter для OperationsService и UsersService (запросов/с) | +| `T_BANK_INSTRUMENTS_RATE_LIMIT` | 20 | Rate limiter для InstrumentsService (запросов/с) | | `T_BANK_REQUEST_TIMEOUT_MS` | 10000 | Deadline gRPC-запроса (мс) | | `CACHE_MARKET_DATA_TTL` | 900 | TTL рыночных данных (с) | | `CACHE_HISTORY_TTL` | 3600 | TTL истории (с) | diff --git a/apps/backend/src/config/configuration.ts b/apps/backend/src/config/configuration.ts index c0a6ede..d51e259 100644 --- a/apps/backend/src/config/configuration.ts +++ b/apps/backend/src/config/configuration.ts @@ -20,6 +20,7 @@ export default registerAs('app', () => ({ 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), + instrumentsRateLimitPerSecond: parseInt(process.env.T_BANK_INSTRUMENTS_RATE_LIMIT || '20', 10), requestTimeoutMs: parseInt(process.env.T_BANK_REQUEST_TIMEOUT_MS || '10000', 10), }, cache: { diff --git a/apps/backend/src/modules/tbank/mappers/portfolio.mapper.spec.ts b/apps/backend/src/modules/tbank/mappers/portfolio.mapper.spec.ts index f9881bf..b939fa9 100644 --- a/apps/backend/src/modules/tbank/mappers/portfolio.mapper.spec.ts +++ b/apps/backend/src/modules/tbank/mappers/portfolio.mapper.spec.ts @@ -37,7 +37,6 @@ describe('portfolio.mapper', () => { blocked: [{ currency: 'rub', units: '10', nano: 0 }], securities: [], }, - instruments: new Map([['uid-1', { name: 'Sberbank', ticker: 'SBER' }]]), }); expect(result.account.id).toBe('acc-1'); diff --git a/apps/backend/src/modules/tbank/mappers/portfolio.mapper.ts b/apps/backend/src/modules/tbank/mappers/portfolio.mapper.ts index 3b60b4b..4248ae7 100644 --- a/apps/backend/src/modules/tbank/mappers/portfolio.mapper.ts +++ b/apps/backend/src/modules/tbank/mappers/portfolio.mapper.ts @@ -16,7 +16,6 @@ type MapBrokerPortfolioInput = { account: BrokerAccount; portfolio: TBankPortfolioResponse; positions: TBankPositionsResponse; - instruments: Map>; }; function isBrokerMoney(value: BrokerMoney | null): value is BrokerMoney { diff --git a/apps/backend/src/modules/tbank/services/broker-accounts.service.ts b/apps/backend/src/modules/tbank/services/broker-accounts.service.ts index 5d51948..63a03fd 100644 --- a/apps/backend/src/modules/tbank/services/broker-accounts.service.ts +++ b/apps/backend/src/modules/tbank/services/broker-accounts.service.ts @@ -41,9 +41,14 @@ export class BrokerAccountsService { const response = await this.tbankClient.callUnary< Record, TBankAccountsResponse - >('UsersService/GetAccounts', usersClient.getAccounts.bind(usersClient), { - status: 'ACCOUNT_STATUS_OPEN', - }); + >( + 'UsersService/GetAccounts', + usersClient.getAccounts.bind(usersClient), + { + status: 'ACCOUNT_STATUS_OPEN', + }, + 'users', + ); return (response.accounts ?? []).filter(isSupportedBrokerAccount).map(mapAccount); } diff --git a/apps/backend/src/modules/tbank/services/broker-instruments.service.ts b/apps/backend/src/modules/tbank/services/broker-instruments.service.ts index 8762c2b..be65b9f 100644 --- a/apps/backend/src/modules/tbank/services/broker-instruments.service.ts +++ b/apps/backend/src/modules/tbank/services/broker-instruments.service.ts @@ -31,6 +31,7 @@ export class BrokerInstrumentsService { 'InstrumentsService/GetInstrumentBy', instrumentsClient.getInstrumentBy.bind(instrumentsClient), { idType: 'INSTRUMENT_ID_TYPE_UID', id: instrumentUid }, + 'instruments', ); return response.instrument ?? null; diff --git a/apps/backend/src/modules/tbank/services/broker-portfolio.service.spec.ts b/apps/backend/src/modules/tbank/services/broker-portfolio.service.spec.ts index 458a7cd..6a1caa4 100644 --- a/apps/backend/src/modules/tbank/services/broker-portfolio.service.spec.ts +++ b/apps/backend/src/modules/tbank/services/broker-portfolio.service.spec.ts @@ -89,6 +89,7 @@ describe('BrokerPortfolioService', () => { cachedAt: null, }), ); + vi.mocked(instruments.findByInstrumentUid).mockResolvedValue(null); } it('throws 404 for missing account', async () => { diff --git a/apps/backend/src/modules/tbank/services/broker-portfolio.service.ts b/apps/backend/src/modules/tbank/services/broker-portfolio.service.ts index 0553119..c4f2668 100644 --- a/apps/backend/src/modules/tbank/services/broker-portfolio.service.ts +++ b/apps/backend/src/modules/tbank/services/broker-portfolio.service.ts @@ -32,26 +32,12 @@ export class BrokerPortfolioService { 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 }, - ), + this.fetchCachedPortfolio(accountId), + this.fetchPositions(accountId), ]); - const instrumentMap = await this.buildInstrumentMap(portfolio); - - return mapBrokerPortfolio({ account, portfolio, positions, instruments: instrumentMap }); + return mapBrokerPortfolio({ account, portfolio, positions }); }, 'tbankPortfolioTtl', ); @@ -78,14 +64,7 @@ export class BrokerPortfolioService { TBANK_CACHE_KEYS.positions, [accountId, cursor ?? '', String(limit), type ?? ''], async () => { - const operationsClient = this.tbankClient.getServiceClient('OperationsService') as any; - const portfolio = await this.tbankClient.callUnary< - { accountId: string; currency: string }, - TBankPortfolioResponse - >('OperationsService/GetPortfolio', operationsClient.getPortfolio.bind(operationsClient), { - accountId, - currency: 'RUB', - }); + const portfolio = await this.fetchCachedPortfolio(accountId); const filteredPositions = type ? (portfolio.positions ?? []).filter( @@ -121,9 +100,11 @@ export class BrokerPortfolioService { (portfolio.positions ?? []).map((position) => position.instrumentUid).filter(Boolean), ), ) as string[]; + const results = await Promise.allSettled( ids.map(async (id) => [id, await this.instrumentsService.findByInstrumentUid(id)] as const), ); + const entries = results.flatMap((result) => result.status === 'fulfilled' ? [result.value] : [], ); @@ -132,4 +113,37 @@ export class BrokerPortfolioService { entries.filter((entry): entry is readonly [string, TBankInstrument] => entry[1] !== null), ); } + + private async fetchCachedPortfolio(accountId: string): Promise { + return this.cacheService + .getOrFetch( + 'tbank:raw-portfolio', + [accountId], + async () => { + const operationsClient = this.tbankClient.getServiceClient('OperationsService') as any; + return this.tbankClient.callUnary< + { accountId: string; currency: string }, + TBankPortfolioResponse + >( + 'OperationsService/GetPortfolio', + operationsClient.getPortfolio.bind(operationsClient), + { + accountId, + currency: 'RUB', + }, + ); + }, + 'tbankPortfolioTtl', + ) + .then((r) => r.data); + } + + private async fetchPositions(accountId: string): Promise { + const operationsClient = this.tbankClient.getServiceClient('OperationsService') as any; + return this.tbankClient.callUnary<{ accountId: string }, TBankPositionsResponse>( + 'OperationsService/GetPositions', + operationsClient.getPositions.bind(operationsClient), + { accountId }, + ); + } } diff --git a/apps/backend/src/modules/tbank/services/tbank-client.service.ts b/apps/backend/src/modules/tbank/services/tbank-client.service.ts index 780b327..3f68fb2 100644 --- a/apps/backend/src/modules/tbank/services/tbank-client.service.ts +++ b/apps/backend/src/modules/tbank/services/tbank-client.service.ts @@ -30,20 +30,28 @@ type GrpcUnary = ( type GrpcServiceConstructor = new (address: string, credentials: ChannelCredentials) => Client; +type QueueName = 'operations' | 'instruments' | 'users'; + @Injectable() export class TBankClientService { private readonly logger = new Logger(TBankClientService.name); - private readonly queue: PQueue; + private readonly queues: Record; private readonly requestTimeoutMs: number; private readonly packageDefinition: ReturnType; private readonly clientCache = new Map(); constructor(private readonly configService: ConfigService) { this.requestTimeoutMs = this.configService.get('app.tbank.requestTimeoutMs', 10000); - this.queue = new PQueue({ - interval: 1000, - intervalCap: this.configService.get('app.tbank.rateLimitPerSecond', 5), - }); + const operationsRate = this.configService.get('app.tbank.rateLimitPerSecond', 5); + const instrumentsRate = this.configService.get( + 'app.tbank.instrumentsRateLimitPerSecond', + 20, + ); + this.queues = { + operations: new PQueue({ interval: 1000, intervalCap: operationsRate }), + instruments: new PQueue({ interval: 1000, intervalCap: instrumentsRate }), + users: new PQueue({ interval: 1000, intervalCap: operationsRate }), + }; const protoRoot = this.resolveProtoRoot(); const definition = loadSync( @@ -120,8 +128,9 @@ export class TBankClientService { label: string, method: GrpcUnary, request: TRequest, + queueName: QueueName = 'operations', ): Promise { - return this.queue.add( + return this.queues[queueName].add( () => new Promise((resolve, reject) => { const metadata = this.createMetadata(); diff --git a/apps/docs/docs/backend/caching.md b/apps/docs/docs/backend/caching.md index 0072435..1165224 100644 --- a/apps/docs/docs/backend/caching.md +++ b/apps/docs/docs/backend/caching.md @@ -80,6 +80,7 @@ export class CacheModule {} | Дивиденды | `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 | `tbankPositionsTtl` | 60s (1 мин) | `CACHE_TBANK_POSITIONS_TTL` | | Операции T-Bank | `tbankOperationsTtl` | 300s (5 мин) | `CACHE_TBANK_OPERATIONS_TTL` | | Инструменты T-Bank | `tbankInstrumentTtl` | 86400s (24 ч) | `CACHE_TBANK_INSTRUMENT_TTL` | diff --git a/apps/docs/docs/backend/configuration.md b/apps/docs/docs/backend/configuration.md index d6e5941..5399ddc 100644 --- a/apps/docs/docs/backend/configuration.md +++ b/apps/docs/docs/backend/configuration.md @@ -15,7 +15,8 @@ | `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_RATE_LIMIT_PER_SECOND` | `5` | Rate limiter для OperationsService и UsersService (запросов/с) | +| `T_BANK_INSTRUMENTS_RATE_LIMIT` | `20` | Rate limiter для InstrumentsService (запросов/с) | | `T_BANK_REQUEST_TIMEOUT_MS` | `10000` | Deadline gRPC-запроса (мс) | | `CACHE_MARKET_DATA_TTL` | `900` | TTL рыночных данных (секунды) | | `CACHE_HISTORY_TTL` | `3600` | TTL истории торгов (секунды) | @@ -25,6 +26,7 @@ | `CACHE_DIVIDENDS_TTL` | `86400` | TTL дивидендов (секунды) | | `CACHE_TBANK_ACCOUNTS_TTL` | `3600` | TTL списка брокерских счетов T-Bank (секунды) | | `CACHE_TBANK_PORTFOLIO_TTL` | `60` | TTL брокерского портфеля T-Bank (секунды) | +| `CACHE_TBANK_POSITIONS_TTL` | `60` | TTL страницы позиций T-Bank (секунды) | | `CACHE_TBANK_OPERATIONS_TTL` | `300` | TTL страницы операций T-Bank (секунды) | | `CACHE_TBANK_INSTRUMENT_TTL` | `86400` | TTL метаданных инструментов T-Bank (секунды) | @@ -49,6 +51,9 @@ registerAs('app', () => ({ 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), + instrumentsRateLimitPerSecond: parseInt( + process.env.T_BANK_INSTRUMENTS_RATE_LIMIT || '20', 10, + ), requestTimeoutMs: parseInt(process.env.T_BANK_REQUEST_TIMEOUT_MS || '10000', 10), }, cache: { diff --git a/apps/docs/docs/backend/tbank-invest.md b/apps/docs/docs/backend/tbank-invest.md index 058a5fe..794b1fb 100644 --- a/apps/docs/docs/backend/tbank-invest.md +++ b/apps/docs/docs/backend/tbank-invest.md @@ -60,6 +60,7 @@ Direct-read endpoints используют короткий in-memory cache, ч - счета: `CACHE_TBANK_ACCOUNTS_TTL` - портфель: `CACHE_TBANK_PORTFOLIO_TTL` +- позиции: `CACHE_TBANK_POSITIONS_TTL` - страницы операций: `CACHE_TBANK_OPERATIONS_TTL` - метаданные инструментов: `CACHE_TBANK_INSTRUMENT_TTL` @@ -73,6 +74,20 @@ Direct-read endpoints используют короткий in-memory cache, ч Эти таблицы не связаны с ручными портфелями `Portfolio` и `Position`. +## Rate limiting + +gRPC-запросы к T-Bank API распределяются по трём отдельным `p-queue` для изоляции групп методов: + +| Очередь | Методы | Rate limit | Env-переменная | +|---|---|---|---| +| operations | `OperationsService`, `UsersService` | 5 req/s | `T_BANK_RATE_LIMIT_PER_SECOND` | +| instruments | `InstrumentsService` | 20 req/s | `T_BANK_INSTRUMENTS_RATE_LIMIT` | +| users | `UsersService` (отдельные вызовы) | 5 req/s | `T_BANK_RATE_LIMIT_PER_SECOND` | + +Методы `UsersService/GetAccounts` маршрутизируются в очередь `users`, остальные методы +`UsersService` — в `operations`. Это предотвращает блокировку запросов портфеля +запросами инструментов и наоборот. + ## Безопасность Текущая версия рассчитана на single-user сценарий: используется один server-side `T_BANK_TOKEN`, а diff --git a/docs/superpowers/plans/2026-06-17-broker-operations-ui-improvements.md b/docs/superpowers/plans/2026-06-17-broker-operations-ui-improvements.md new file mode 100644 index 0000000..cbe555a --- /dev/null +++ b/docs/superpowers/plans/2026-06-17-broker-operations-ui-improvements.md @@ -0,0 +1,318 @@ +# Broker Operations UI Improvements — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Clean up operations table badges, add "+" for positive amounts, smooth pagination with styled buttons. + +**Architecture:** All changes are frontend-only (React components, TanStack Query hook, display helpers, tests). No backend changes. + +**Tech Stack:** React 18, TanStack Query v5, Vitest + Testing Library + +--- + +### Task 1: Remove `getBrokerOperationImpactLabel` from brokerDisplay + +**Files:** +- Modify: `apps/frontend/src/pages/broker/brokerDisplay.ts:165-176` +- Modify: `apps/frontend/src/pages/broker/brokerDisplay.test.ts:195-201` + +- [ ] **Step 1: Remove the test for impact labels** + +In `brokerDisplay.test.ts`, delete the test block "provides Russian impact labels" (lines 195-201) and remove `getBrokerOperationImpactLabel` from the import. + +```tsx +// Import line changes from: +import { + getBrokerInstrumentPath, + getBrokerOperationImpact, + getBrokerOperationImpactLabel, + getBrokerOperationTypeLabel, + getBrokerPositionGroup, +} from './brokerDisplay'; +// to: +import { + getBrokerInstrumentPath, + getBrokerOperationImpact, + getBrokerOperationTypeLabel, + getBrokerPositionGroup, +} from './brokerDisplay'; +``` + +Delete the entire `it('provides Russian impact labels', ...)` block (lines 195-201). + +- [ ] **Step 2: Run tests to verify the test removal succeeds** + +Run: `npx vitest run apps/frontend/src/pages/broker/brokerDisplay.test.ts` +Expected: PASS (1 less test) + +- [ ] **Step 3: Remove `getBrokerOperationImpactLabel` from source** + +In `brokerDisplay.ts`, delete the `getBrokerOperationImpactLabel` function (lines 165-176) and remove its export. + +- [ ] **Step 4: Run tests to verify** + +Run: `npx vitest run apps/frontend/src/pages/broker/brokerDisplay.test.ts` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add apps/frontend/src/pages/broker/brokerDisplay.ts apps/frontend/src/pages/broker/brokerDisplay.test.ts +git commit -m "refactor: remove unused getBrokerOperationImpactLabel helper" +``` + +--- + +### Task 2: Update BrokerPages.test.tsx for new expectations + +**Files:** +- Modify: `apps/frontend/src/pages/broker/BrokerPages.test.tsx` + +- [ ] **Step 1: Update test to expect no badges and "+" prefix** + +Remove lines 314-315 (badge checks): +```tsx +expect(screen.getByText('Пополняет')).toBeInTheDocument(); +expect(screen.getByText('Списывает')).toBeInTheDocument(); +``` + +Update test name at line 225 from: +``` +it('renders broker operations with Russian labels, linked instruments and impact badges', () => { +``` +to: +``` +it('renders broker operations with Russian labels, linked instruments and colored amounts', () => { +``` + +Add a check for the "+" prefix on positive amounts after the existing check at line 312-313: +```tsx +expect(screen.getByText('Выплата купона')).toBeInTheDocument(); +expect(screen.getByText('Налог')).toBeInTheDocument(); +// Add: +expect(screen.getByText(/\+120,00\s*₽/)).toBeInTheDocument(); +``` + +Remove the old checks for "Страница 1" and "Страница 2" text (lines 424-437) and instead verify the pagination buttons exist. Update the pagination test block at line 322: +```tsx +it('requests broker operations by cursor with a page size of 10', async () => { + // ...setup stays the same... + + // Replace these: + // expect(screen.getByText('Страница 1')).toBeInTheDocument(); + // with check that page buttons exist: + const nextButton = screen.getByRole('button', { name: '→' }); + const prevButton = screen.getByRole('button', { name: '←' }); + expect(prevButton).toBeDisabled(); + expect(nextButton).not.toBeDisabled(); + + await user.click(nextButton); + + expect(operationsSpy).toHaveBeenLastCalledWith('acc-1', { + limit: 10, + cursor: 'cursor-page-2', + }); + // expect(screen.getByText('Страница 2')).toBeInTheDocument(); // remove + expect(screen.getByText('2')).toBeInTheDocument(); // page number shown without label + + await user.click(screen.getByRole('button', { name: '←' })); + + expect(operationsSpy).toHaveBeenLastCalledWith('acc-1', { limit: 10, cursor: undefined }); + expect(screen.getByText('1')).toBeInTheDocument(); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `npx vitest run apps/frontend/src/pages/broker/BrokerPages.test.tsx` +Expected: FAIL (buttons named '→' / '←' not found yet, "+" text not found) + +- [ ] **Step 3: Commit** + +```bash +git add apps/frontend/src/pages/broker/BrokerPages.test.tsx +git commit -m "test: update broker page tests for new UI expectations" +``` + +--- + +### Task 3: Remove badges and add "+" prefix, style pagination buttons + +**Files:** +- Modify: `apps/frontend/src/pages/broker/BrokerOperationsTable.tsx` + +- [ ] **Step 1: Remove badge-related code** + +In `BrokerOperationsTable.tsx`: + +Delete the `impactStyles` object (lines 30-47). + +Delete the `OperationType` component (lines 87-108). + +Update imports — remove `getBrokerOperationImpactLabel` and `BrokerOperationImpact`: +```tsx +import { + getBrokerInstrumentPath, + getBrokerOperationImpact, + getBrokerOperationTypeLabel, +} from './brokerDisplay'; +``` + +Update the "Тип" column cell — replace `` with just: +```tsx +{getBrokerOperationTypeLabel(operation)} +``` + +- [ ] **Step 2: Add "+" prefix to positive amounts** + +Modify `formatMoney` function: +```tsx +function formatMoney(value: BrokerMoney | null | undefined) { + if (!value) return '-'; + const formatted = new Intl.NumberFormat('ru-RU', { + style: 'currency', + currency: value.currency || 'RUB', + maximumFractionDigits: 2, + }).format(value.value); + return value.value > 0 ? `+${formatted}` : formatted; +} +``` + +- [ ] **Step 3: Style pagination buttons** + +Add button style constants before the component: +```tsx +const pagButtonStyle: React.CSSProperties = { + padding: '6px 14px', + borderRadius: 6, + border: '1px solid #e0e0e0', + background: 'var(--color-surface)', + color: 'var(--color-text)', + fontSize: 14, + fontWeight: 600, + cursor: 'pointer', + lineHeight: 1.4, +}; + +const pagButtonDisabledStyle: React.CSSProperties = { + ...pagButtonStyle, + opacity: 0.35, + cursor: 'not-allowed', +}; +``` + +Update pagination controls — replace existing "Назад" / "Вперед" buttons and "Страница N" text: +```tsx +
+ + + {pageNumber} + + +
+``` + +- [ ] **Step 4: Run tests** + +Run: `npx vitest run apps/frontend/src/pages/broker/BrokerPages.test.tsx` +Expected: PASS + +- [ ] **Step 5: Run full test suite** + +Run: `npm run test:frontend` +Expected: PASS + +- [ ] **Step 6: Commit** + +```bash +git add apps/frontend/src/pages/broker/BrokerOperationsTable.tsx +git commit -m "feat: remove impact badges, add + prefix, style pagination buttons" +``` + +--- + +### Task 4: Add keepPreviousData to operations query + +**Files:** +- Modify: `apps/frontend/src/hooks/useBrokerOperations.ts` + +- [ ] **Step 1: Add keepPreviousData** + +Update `useBrokerOperations.ts`: +```tsx +import { keepPreviousData, useQuery } from '@tanstack/react-query'; + +export function useBrokerOperations( + accountId: string | undefined, + query: BrokerOperationQuery = {}, +) { + return useQuery({ + queryKey: ['broker', 'operations', accountId, query], + enabled: Boolean(accountId), + queryFn: async () => (await getBrokerOperations(accountId!, query)).data, + placeholderData: keepPreviousData, + staleTime: 300_000, + retry: 2, + refetchOnWindowFocus: false, + }); +} +``` + +- [ ] **Step 2: Run full test suite** + +Run: `npm run test:frontend` +Expected: PASS + +- [ ] **Step 3: Commit** + +```bash +git add apps/frontend/src/hooks/useBrokerOperations.ts +git commit -m "feat: add keepPreviousData for smooth pagination" +``` + +--- + +### Task 5: Run lint and verify + +- [ ] **Step 1: Run lint** + +Run: `npm run lint` +Expected: PASS (no lint errors) + +- [ ] **Step 2: Run full test suite** + +Run: `npm run test:frontend` +Expected: PASS + +- [ ] **Step 3: Verify build** + +Run: `npm run build:frontend` +Expected: PASS + +- [ ] **Step 4: Final commit if any fixes** + +```bash +git commit -m "chore: fix lint issues" +``` diff --git a/docs/superpowers/plans/2026-06-17-broker-positions-pagination-and-loading.md b/docs/superpowers/plans/2026-06-17-broker-positions-pagination-and-loading.md new file mode 100644 index 0000000..b9cce72 --- /dev/null +++ b/docs/superpowers/plans/2026-06-17-broker-positions-pagination-and-loading.md @@ -0,0 +1,1996 @@ +# Broker Portfolio Enhancements Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) for syntax tracking. + +**Goal:** Add cursor-based pagination for broker positions, shimmer skeleton loading, and instrument name display in operations. + +**Architecture:** Backend extracts positions from portfolio into a new paginated `GET /positions` endpoint. Frontend gets a `useBrokerPositions` hook, `SkeletonBlock`/`TableSkeleton` components, and shimmer CSS animations. The `name` field from T-Bank's `OperationItem` is mapped through to the frontend. + +**Tech Stack:** NestJS (backend), React 18 + TanStack Query v5 (frontend), CSS custom properties + +--- + +### Task 1: Backend — Types and DTOs for positions page + operation name + +**Files:** +- Modify: `apps/backend/src/modules/tbank/types/broker.types.ts` +- Create: `apps/backend/src/modules/tbank/dto/broker-position-response.dto.ts` +- Create: `apps/backend/src/modules/tbank/dto/broker-positions-page-response.dto.ts` +- Modify: `apps/backend/src/modules/tbank/dto/broker-portfolio-response.dto.ts` +- Modify: `apps/backend/src/modules/tbank/dto/broker-operation-response.dto.ts` +- Modify: `apps/backend/src/modules/tbank/dto/broker-envelope.dto.ts` + +- [ ] **Step 1: Remove `positions` from `BrokerPortfolio` type, add `BrokerPositionsPage`, add `name` to `BrokerOperation`** + +Edit `apps/backend/src/modules/tbank/types/broker.types.ts`: + +Remove `positions: BrokerPosition[]` from `BrokerPortfolio`. + +Add after `BrokerPosition` type: +```ts +export type BrokerPositionsPage = { + accountId: string; + items: BrokerPosition[]; + nextCursor: string | null; + hasNext: boolean; + asOf: string; +}; +``` + +Add `name` to `BrokerOperation`: +```ts +export type BrokerOperation = { + cursor: string | null; + accountId: string; + id: string | null; + parentOperationId: string | null; + date: string | null; + type: string; + category: BrokerOperationCategory; + description: string | null; + name: 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; +}; +``` + +- [ ] **Step 2: Create `BrokerPositionResponseDto` (extracted from portfolio DTO)** + +Create `apps/backend/src/modules/tbank/dto/broker-position-response.dto.ts`: +```ts +import { ApiProperty } from '@nestjs/swagger'; +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; +} +``` + +- [ ] **Step 3: Create `BrokerPositionsPageResponseDto`** + +Create `apps/backend/src/modules/tbank/dto/broker-positions-page-response.dto.ts`: +```ts +import { ApiProperty } from '@nestjs/swagger'; +import { BrokerPositionResponseDto } from './broker-position-response.dto'; + +export class BrokerPositionsPageResponseDto { + @ApiProperty() + accountId!: string; + + @ApiProperty({ type: [BrokerPositionResponseDto] }) + items!: BrokerPositionResponseDto[]; + + @ApiProperty({ nullable: true }) + nextCursor!: string | null; + + @ApiProperty() + hasNext!: boolean; + + @ApiProperty() + asOf!: string; +} +``` + +- [ ] **Step 4: Remove `positions` from `BrokerPortfolioResponseDto`** + +Edit `apps/backend/src/modules/tbank/dto/broker-portfolio-response.dto.ts`: + +Remove the import of `BrokerPositionResponseDto` (no longer needed here since `BrokerPositionResponseDto` is now in its own file). + +Remove the entire `BrokerPositionResponseDto` class. + +Remove the `positions` property from `BrokerPortfolioResponseDto`: +```ts +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() + asOf!: string; +} +``` + +- [ ] **Step 5: Add `name` to `BrokerOperationResponseDto`** + +Edit `apps/backend/src/modules/tbank/dto/broker-operation-response.dto.ts`: + +Add after `description`: +```ts + @ApiProperty({ nullable: true }) + name!: string | null; +``` + +- [ ] **Step 6: Add `BrokerPositionsEnvelopeDto` to envelope** + +Edit `apps/backend/src/modules/tbank/dto/broker-envelope.dto.ts`: + +Add import: +```ts +import { BrokerPositionsPageResponseDto } from './broker-positions-page-response.dto'; +``` + +Add after `BrokerOperationsEnvelopeDto`: +```ts +export class BrokerPositionsEnvelopeDto { + @ApiProperty({ type: BrokerPositionsPageResponseDto }) + data!: BrokerPositionsPageResponseDto; + + @ApiProperty({ type: BrokerResponseMetaDto }) + meta!: BrokerResponseMetaDto; +} +``` + +- [ ] **Step 7: Commit** + +```bash +git add apps/backend/src/modules/tbank/types/broker.types.ts \ + apps/backend/src/modules/tbank/dto/broker-position-response.dto.ts \ + apps/backend/src/modules/tbank/dto/broker-positions-page-response.dto.ts \ + apps/backend/src/modules/tbank/dto/broker-portfolio-response.dto.ts \ + apps/backend/src/modules/tbank/dto/broker-operation-response.dto.ts \ + apps/backend/src/modules/tbank/dto/broker-envelope.dto.ts +git commit -m "feat(tbank): add positions page types/DTOs and operation name field" +``` + +--- + +### Task 2: Backend — Mappers (separate positions, add name to operations) + +**Files:** +- Modify: `apps/backend/src/modules/tbank/mappers/portfolio.mapper.ts` +- Modify: `apps/backend/src/modules/tbank/mappers/operation.mapper.ts` + +- [ ] **Step 1: Extract `mapBrokerPosition` from `mapBrokerPortfolio`, remove positions from portfolio mapping** + +Edit `apps/backend/src/modules/tbank/mappers/portfolio.mapper.ts`: + +Replace the file content with: +```ts +import type { + BrokerAccount, + BrokerMoney, + BrokerPortfolio, + BrokerPosition, + BrokerPositionsPage, +} 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>; +}; + +function isBrokerMoney(value: BrokerMoney | null): value is BrokerMoney { + return value !== null; +} + +export function mapBrokerPosition( + input: { + position: { figi?: string; instrumentUid?: string; positionUid?: string; ticker?: string; classCode?: string; instrumentType?: string; quantity?: { units?: string; nano?: number }; blockedLots?: { units?: string; nano?: number }; currentPrice?: { currency?: string; units?: string; nano?: number }; averagePositionPrice?: { currency?: string; units?: string; nano?: number }; expectedYield?: { units?: string; nano?: number }; dailyYield?: { currency?: string; units?: string; nano?: number } }; + instruments: Map>; + }, +): BrokerPosition { + const quantity = mapQuotationToNumber(input.position.quantity); + const currentPrice = mapMoneyValue(input.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 = + (input.position.instrumentUid && input.instruments.get(input.position.instrumentUid)) || + (input.position.positionUid && input.instruments.get(input.position.positionUid)) || + undefined; + + return { + figi: input.position.figi ?? null, + instrumentUid: input.position.instrumentUid ?? null, + positionUid: input.position.positionUid ?? null, + ticker: input.position.ticker || instrument?.ticker || null, + classCode: input.position.classCode || instrument?.classCode || null, + instrumentType: input.position.instrumentType || instrument?.instrumentType || null, + name: instrument?.name ?? null, + quantity, + blockedLots: mapQuotationToNumber(input.position.blockedLots), + currentPrice, + currentValue, + averagePositionPrice: mapMoneyValue(input.position.averagePositionPrice), + expectedYieldPercent: mapQuotationToNumber(input.position.expectedYield), + dailyYield: mapMoneyValue(input.position.dailyYield), + }; +} + +export function mapBrokerPortfolio(input: MapBrokerPortfolioInput): BrokerPortfolio { + 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), + asOf: new Date().toISOString(), + }; +} + +export function mapBrokerPositionsPage( + input: { + accountId: string; + portfolio: TBankPortfolioResponse; + instruments: Map>; + cursor?: string; + limit: number; + }, +): BrokerPositionsPage { + const allPositions = (input.portfolio.positions ?? []).map((position) => + mapBrokerPosition({ position, instruments: input.instruments }), + ); + + let startIndex = 0; + if (input.cursor) { + const found = allPositions.findIndex( + (p) => p.positionUid === input.cursor, + ); + startIndex = found >= 0 ? found + 1 : allPositions.length; + } + + const pageItems = allPositions.slice(startIndex, startIndex + input.limit); + const hasNext = startIndex + input.limit < allPositions.length; + const nextCursor = hasNext ? pageItems[pageItems.length - 1]?.positionUid ?? null : null; + + return { + accountId: input.accountId, + items: pageItems, + nextCursor, + hasNext, + asOf: new Date().toISOString(), + }; +} +``` + +- [ ] **Step 2: Add `name` to `mapOperation`** + +Edit `apps/backend/src/modules/tbank/mappers/operation.mapper.ts`: + +Add `name: item.name ?? null,` after the `description` line in the mapOperation return object (line 110): +```ts + description: item.description || item.name || null, + name: item.name ?? null, +``` + +- [ ] **Step 3: Commit** + +```bash +git add apps/backend/src/modules/tbank/mappers/portfolio.mapper.ts \ + apps/backend/src/modules/tbank/mappers/operation.mapper.ts +git commit -m "feat(tbank): extract mapBrokerPosition, add mapBrokerPositionsPage, add name to operation" +``` + +--- + +### Task 3: Backend — BrokerPortfolioService with getPositions() + +**Files:** +- Modify: `apps/backend/src/modules/tbank/services/broker-portfolio.service.ts` + +- [ ] **Step 1: Add `getPositions()` method, remove positions from getPortfolio** + +Edit `apps/backend/src/modules/tbank/services/broker-portfolio.service.ts`: + +Add import for `BrokerPositionsPage`: +```ts +import type { BrokerPortfolio, BrokerPositionsPage } from '../types/broker.types'; +``` + +Replace the file content to: +1. Keep `getPortfolio()` but remove positions from the mapped result (just don't include them — the mapper no longer returns them) +2. Add `getPositions()` method + +Full file: +```ts +import { Injectable, NotFoundException } from '@nestjs/common'; +import { CacheService } from '../../cache/cache.service'; +import { mapBrokerPortfolio, mapBrokerPositionsPage } from '../mappers/portfolio.mapper'; +import { TBANK_CACHE_KEYS } from '../tbank.config'; +import type { BrokerPortfolio, BrokerPositionsPage } 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 }, + }; + } + + async getPositions( + accountId: string, + cursor?: string, + limit = 10, + ): Promise<{ + data: BrokerPositionsPage; + 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.positions, + [accountId], + async () => { + const operationsClient = this.tbankClient.getServiceClient('OperationsService') as any; + const portfolio = await this.tbankClient.callUnary< + { accountId: string; currency: string }, + TBankPortfolioResponse + >( + 'OperationsService/GetPortfolio', + operationsClient.getPortfolio.bind(operationsClient), + { accountId, currency: 'RUB' }, + ); + + const instrumentMap = await this.buildInstrumentMap(portfolio); + + return mapBrokerPositionsPage({ accountId, portfolio, instruments: instrumentMap, cursor, limit }); + }, + 'tbankPositionsTtl', + ); + + return { + data: result.data, + meta: { fromCache: result.fromCache, cachedAt: result.cachedAt }, + }; + } + + private async buildInstrumentMap( + portfolio: TBankPortfolioResponse, + ): Promise>> { + const ids = Array.from( + new Set( + (portfolio.positions ?? []).map((position) => position.instrumentUid).filter(Boolean), + ), + ) as string[]; + const results = await Promise.allSettled( + ids.map(async (id) => [id, await this.instrumentsService.findByInstrumentUid(id)] as const), + ); + const entries = results.flatMap((result) => + result.status === 'fulfilled' ? [result.value] : [], + ); + + return new Map( + entries.filter((entry): entry is readonly [string, TBankInstrument] => entry[1] !== null), + ); + } +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add apps/backend/src/modules/tbank/services/broker-portfolio.service.ts +git commit -m "feat(tbank): add getPositions() method to BrokerPortfolioService" +``` + +--- + +### Task 4: Backend — Controller + Envelope + Config for positions endpoint + +**Files:** +- Modify: `apps/backend/src/modules/tbank/tbank.controller.ts` +- Modify: `apps/backend/src/config/configuration.ts` + +- [ ] **Step 1: Add `GET /accounts/:accountId/positions` endpoint** + +Edit `apps/backend/src/modules/tbank/tbank.controller.ts`: + +Add imports: +```ts +import { BrokerPositionsEnvelopeDto } from './dto/broker-envelope.dto'; +import { BrokerPositionQueryDto } from './dto/broker-position-query.dto'; +``` + +Add after `getPortfolio` method: +```ts + @Get('accounts/:accountId/positions') + @ApiOperation({ summary: 'Get paginated T-Bank broker account positions' }) + @ApiOkResponse({ type: BrokerPositionsEnvelopeDto }) + async getPositions( + @Param('accountId') accountId: string, + @Query() query: BrokerPositionQueryDto, + ) { + const result = await this.brokerPortfolioService.getPositions( + accountId, + query.cursor, + query.limit, + ); + return new ApiResponse(result.data, result.meta.fromCache, result.meta.cachedAt); + } +``` + +- [ ] **Step 2: Create `BrokerPositionQueryDto`** + +Create `apps/backend/src/modules/tbank/dto/broker-position-query.dto.ts`: +```ts +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { IsNumber, IsOptional, IsString, Max, Min } from 'class-validator'; + +export class BrokerPositionQueryDto { + @ApiPropertyOptional({ description: 'Cursor for pagination (positionUid)' }) + @IsOptional() + @IsString() + cursor?: string; + + @ApiPropertyOptional({ default: 10 }) + @IsOptional() + @Type(() => Number) + @IsNumber() + @Min(1) + @Max(100) + limit?: number = 10; +} +``` + +- [ ] **Step 3: Add `tbankPositionsTtl` to configuration** + +Edit `apps/backend/src/config/configuration.ts`: + +Add after `tbankOperationsTtl` (line 34): +```ts + tbankPositionsTtl: parseInt(process.env.CACHE_TBANK_POSITIONS_TTL || '60', 10), +``` + +- [ ] **Step 4: Commit** + +```bash +git add apps/backend/src/modules/tbank/tbank.controller.ts \ + apps/backend/src/modules/tbank/dto/broker-position-query.dto.ts \ + apps/backend/src/config/configuration.ts +git commit -m "feat(tbank): add GET /positions endpoint with cursor pagination" +``` + +--- + +### Task 5: Backend — Update portfolio service tests + add positions tests + +**Files:** +- Modify: `apps/backend/src/modules/tbank/services/broker-portfolio.service.spec.ts` +- Modify: `apps/backend/src/modules/tbank/tbank.config.spec.ts` + +- [ ] **Step 1: Update tests — remove positions assertions, add getPositions tests** + +Edit `apps/backend/src/modules/tbank/services/broker-portfolio.service.spec.ts`: + +Replace the file with: +```ts +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 through cache without positions', 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) => ({ + 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); + // positions not in portfolio anymore + expect('positions' in result.data).toBe(false); + expect(cache.getOrFetch).toHaveBeenCalledWith( + 'tbank:portfolio', + ['acc-1'], + expect.any(Function), + 'tbankPortfolioTtl', + ); + }); + + describe('getPositions', () => { + it('throws 404 for missing account', async () => { + vi.mocked(accounts.findById).mockResolvedValue(null); + const service = new BrokerPortfolioService(accounts, instruments, client, cache); + + await expect(service.getPositions('missing')).rejects.toThrow(NotFoundException); + }); + + it('returns first page of positions', 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) => ({ + data: await fetchFn(), + fromCache: false, + cachedAt: null, + }), + ); + vi.mocked(client.getServiceClient).mockReturnValue({ + getPortfolio: vi.fn(), + } as any); + vi.mocked(client.callUnary).mockResolvedValueOnce({ + accountId: 'acc-1', + totalAmountPortfolio: { currency: 'rub', units: '1000', nano: 0 }, + positions: [ + { + figi: 'figi-1', + instrumentUid: 'uid-1', + positionUid: 'pos-1', + quantity: { units: '10', nano: 0 }, + }, + { + figi: 'figi-2', + instrumentUid: 'uid-2', + positionUid: 'pos-2', + quantity: { units: '20', nano: 0 }, + }, + ], + }); + + const service = new BrokerPortfolioService(accounts, instruments, client, cache); + const result = await service.getPositions('acc-1', undefined, 1); + + expect(result.data.accountId).toBe('acc-1'); + expect(result.data.items).toHaveLength(1); + expect(result.data.items[0].positionUid).toBe('pos-1'); + expect(result.data.hasNext).toBe(true); + expect(result.data.nextCursor).toBe('pos-1'); + }); + + it('paginates using cursor', 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) => ({ + data: await fetchFn(), + fromCache: false, + cachedAt: null, + }), + ); + vi.mocked(client.getServiceClient).mockReturnValue({ + getPortfolio: vi.fn(), + } as any); + vi.mocked(client.callUnary).mockResolvedValueOnce({ + accountId: 'acc-1', + totalAmountPortfolio: { currency: 'rub', units: '1000', nano: 0 }, + positions: [ + { figi: 'f1', instrumentUid: 'u1', positionUid: 'p1', quantity: { units: '10', nano: 0 } }, + { figi: 'f2', instrumentUid: 'u2', positionUid: 'p2', quantity: { units: '20', nano: 0 } }, + { figi: 'f3', instrumentUid: 'u3', positionUid: 'p3', quantity: { units: '30', nano: 0 } }, + ], + }); + + const service = new BrokerPortfolioService(accounts, instruments, client, cache); + const result = await service.getPositions('acc-1', 'p1', 1); + + expect(result.data.items).toHaveLength(1); + expect(result.data.items[0].positionUid).toBe('p2'); + expect(result.data.nextCursor).toBe('p2'); + expect(result.data.hasNext).toBe(true); + }); + + it('returns last page with hasNext=false', 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) => ({ + data: await fetchFn(), + fromCache: false, + cachedAt: null, + }), + ); + vi.mocked(client.getServiceClient).mockReturnValue({ + getPortfolio: vi.fn(), + } as any); + vi.mocked(client.callUnary).mockResolvedValueOnce({ + accountId: 'acc-1', + totalAmountPortfolio: { currency: 'rub', units: '1000', nano: 0 }, + positions: [ + { figi: 'f1', instrumentUid: 'u1', positionUid: 'p1', quantity: { units: '10', nano: 0 } }, + ], + }); + + const service = new BrokerPortfolioService(accounts, instruments, client, cache); + const result = await service.getPositions('acc-1', undefined, 10); + + expect(result.data.items).toHaveLength(1); + expect(result.data.hasNext).toBe(false); + expect(result.data.nextCursor).toBeNull(); + }); + + it('caches positions with tbankPositionsTtl', 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) => ({ + data: await fetchFn(), + fromCache: false, + cachedAt: null, + }), + ); + vi.mocked(client.getServiceClient).mockReturnValue({ + getPortfolio: vi.fn(), + } as any); + vi.mocked(client.callUnary).mockResolvedValueOnce({ + accountId: 'acc-1', + totalAmountPortfolio: { currency: 'rub', units: '1000', nano: 0 }, + positions: [], + }); + + const service = new BrokerPortfolioService(accounts, instruments, client, cache); + await service.getPositions('acc-1'); + + expect(cache.getOrFetch).toHaveBeenCalledWith( + 'tbank:positions', + ['acc-1'], + expect.any(Function), + 'tbankPositionsTtl', + ); + }); + }); +}); +``` + +- [ ] **Step 2: Run tests** + +```bash +npx vitest run apps/backend/src/modules/tbank/services/broker-portfolio.service.spec.ts -w apps/backend +``` + +Expected: ALL PASS + +- [ ] **Step 3: Add tbankPositionsTtl to tbank config test** + +Edit `apps/backend/src/modules/tbank/tbank.config.spec.ts`: + +Add to the first `it` block after line 23: +```ts + expect(config.cache.tbankPositionsTtl).toBe(60); +``` + +Add to the second `it` block — set env and assert: +```ts + process.env.CACHE_TBANK_POSITIONS_TTL = '45'; +``` + +And add assertion: +```ts + expect(config.cache.tbankPositionsTtl).toBe(45); +``` + +- [ ] **Step 4: Run config tests** + +```bash +npx vitest run apps/backend/src/modules/tbank/tbank.config.spec.ts -w apps/backend +``` + +Expected: ALL PASS + +- [ ] **Step 5: Commit** + +```bash +git add apps/backend/src/modules/tbank/services/broker-portfolio.service.spec.ts \ + apps/backend/src/modules/tbank/tbank.config.spec.ts +git commit -m "test(tbank): update portfolio tests, add getPositions tests" +``` + +--- + +### Task 6: Frontend — CSS shimmer animations + +**Files:** +- Modify: `apps/frontend/src/styles.css` + +- [ ] **Step 1: Add shimmer keyframes and skeleton class** + +Append to `apps/frontend/src/styles.css`: +```css +@keyframes shimmer { + 0% { background-position: 200% 0; } + 100% { background-position: -200% 0; } +} + +.skeleton { + background: linear-gradient( + 90deg, + var(--color-bg) 25%, + #f0f0f0 50%, + var(--color-bg) 75% + ); + background-size: 200% 100%; + animation: shimmer 1.5s ease-in-out infinite; + border-radius: 4px; +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add apps/frontend/src/styles.css +git commit -m "feat(frontend): add shimmer animation and .skeleton CSS class" +``` + +--- + +### Task 7: Frontend — Types, API, and hooks + +**Files:** +- Create: `apps/frontend/src/hooks/useBrokerPositions.ts` +- Modify: `apps/frontend/src/api/responses.ts` +- Modify: `apps/frontend/src/api/broker.ts` +- Modify: `apps/frontend/src/api/broker.test.ts` + +- [ ] **Step 1: Update frontend types** + +Edit `apps/frontend/src/api/responses.ts`: + +Remove `positions: BrokerPosition[]` from `BrokerPortfolio`. + +Add after `BrokerOperationsPage`: +```ts +export interface BrokerPositionsPage { + accountId: string; + items: BrokerPosition[]; + nextCursor: string | null; + hasNext: boolean; + asOf: string; +} +``` + +Add `name: string | null` to `BrokerOperation` (after `description`): +```ts + description: string | null; + name: string | null; +``` + +- [ ] **Step 2: Add `getBrokerPositions` API function** + +Edit `apps/frontend/src/api/broker.ts`: + +Add import: +```ts +import type { + ApiResponseMeta, + BrokerAccount, + BrokerOperationsPage, + BrokerPortfolio, + BrokerPositionsPage, +} from './responses'; +``` + +Add after `getBrokerOperations`: +```ts +export function getBrokerPositions( + accountId: string, + query: { cursor?: string; limit?: number } = {}, +): Promise<{ data: BrokerPositionsPage; meta: ApiResponseMeta }> { + return request( + `/api/v1/broker/accounts/${encodeURIComponent(accountId)}/positions`, + { + cursor: query.cursor, + limit: query.limit ? String(query.limit) : undefined, + }, + ); +} +``` + +- [ ] **Step 3: Update broker.test.ts — add positions test** + +Edit `apps/frontend/src/api/broker.test.ts`: + +Replace the file: +```ts +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { getBrokerOperations, getBrokerPositions } 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), + ); + }); + + it('serializes positions 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 getBrokerPositions('acc-1', { cursor: 'pos-1', limit: 5 }); + + expect(fetch).toHaveBeenCalledWith( + expect.stringContaining('/api/v1/broker/accounts/acc-1/positions?cursor=pos-1&limit=5'), + expect.any(Object), + ); + }); +}); +``` + +- [ ] **Step 4: Create `useBrokerPositions` hook** + +Create `apps/frontend/src/hooks/useBrokerPositions.ts`: +```ts +import { keepPreviousData, useQuery } from '@tanstack/react-query'; +import { getBrokerPositions } from '../api/broker'; +import type { BrokerPositionsPage } from '../api/responses'; + +export function useBrokerPositions( + accountId: string | undefined, + query: { cursor?: string; limit?: number } = {}, +) { + return useQuery({ + queryKey: ['broker', 'positions', accountId, query], + enabled: Boolean(accountId), + queryFn: async () => (await getBrokerPositions(accountId!, query)).data, + staleTime: 60_000, + retry: 2, + placeholderData: keepPreviousData, + refetchOnWindowFocus: false, + }); +} +``` + +- [ ] **Step 5: Commit** + +```bash +git add apps/frontend/src/api/responses.ts \ + apps/frontend/src/api/broker.ts \ + apps/frontend/src/api/broker.test.ts \ + apps/frontend/src/hooks/useBrokerPositions.ts +git commit -m "feat(frontend): add BrokerPositionsPage types, API, and hook" +``` + +--- + +### Task 8: Frontend — SkeletonBlock and TableSkeleton components + +**Files:** +- Create: `apps/frontend/src/components/SkeletonBlock.tsx` +- Create: `apps/frontend/src/components/TableSkeleton.tsx` + +- [ ] **Step 1: Create `SkeletonBlock`** + +Create `apps/frontend/src/components/SkeletonBlock.tsx`: +```tsx +export function SkeletonBlock({ width, height, borderRadius = 4 }: { + width?: string | number; + height?: string | number; + borderRadius?: number; +}) { + return ( +
+ ); +} +``` + +- [ ] **Step 2: Create `TableSkeleton`** + +Create `apps/frontend/src/components/TableSkeleton.tsx`: +```tsx +import { SkeletonBlock } from './SkeletonBlock'; + +const tdStyle = { + borderBottom: '1px solid #eeeeee', + padding: '10px 8px', + verticalAlign: 'top', +} satisfies React.CSSProperties; + +type Column = { width: string }; + +export function TableSkeleton({ rows = 5, columns }: { rows?: number; columns: Column[] }) { + return ( + + {Array.from({ length: rows }).map((_, i) => ( + + {columns.map((col, j) => ( + + + + ))} + + ))} + + ); +} +``` + +- [ ] **Step 3: Commit** + +```bash +git add apps/frontend/src/components/SkeletonBlock.tsx \ + apps/frontend/src/components/TableSkeleton.tsx +git commit -m "feat(frontend): add SkeletonBlock and TableSkeleton components" +``` + +--- + +### Task 9: Frontend — BrokerPositionsSection with pagination + skeleton + +**Files:** +- Modify: `apps/frontend/src/pages/broker/BrokerPositionsSection.tsx` + +- [ ] **Step 1: Rewrite BrokerPositionsSection with pagination props** + +Replace `apps/frontend/src/pages/broker/BrokerPositionsSection.tsx`: +```tsx +import { Link } from 'react-router-dom'; +import type { BrokerMoney, BrokerPosition } from '../../api/responses'; +import { getBrokerInstrumentPath, getBrokerPositionGroup } from './brokerDisplay'; +import { TableSkeleton } from '../../components/TableSkeleton'; + +type BrokerPositionGroupConfig = { + key: 'shares' | 'bonds' | 'other'; + title: string; +}; + +const GROUPS: BrokerPositionGroupConfig[] = [ + { key: 'shares', title: 'Акции' }, + { key: 'bonds', title: 'Облигации' }, + { key: 'other', title: 'Другие инструменты' }, +]; + +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; + +const pagButtonStyle = { + padding: '6px 14px', + borderRadius: 6, + border: '1px solid #e0e0e0', + background: 'var(--color-surface)', + color: 'var(--color-text)', + fontSize: 14, + fontWeight: 600, + cursor: 'pointer', + lineHeight: 1.4, +} satisfies React.CSSProperties; + +const pagButtonDisabledStyle = { + ...pagButtonStyle, + opacity: 0.35, + cursor: 'not-allowed', +} satisfies React.CSSProperties; + +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 formatQuantity(value: number | null | undefined) { + return value == null ? '-' : value.toLocaleString('ru-RU'); +} + +function PositionTicker({ position }: { position: BrokerPosition }) { + const label = position.ticker || position.figi || '-'; + const path = getBrokerInstrumentPath({ + ticker: position.ticker, + instrumentType: position.instrumentType, + classCode: position.classCode, + }); + + if (!path || label === '-') { + return {label}; + } + + return ( + + {label} + + ); +} + +function PositionTable({ title, positions }: { title: string; positions: BrokerPosition[] }) { + return ( +
+

{title}

+
+ + + + + + + + + + + + {positions.map((position) => ( + + + + + + + + ))} + +
ТикерНазваниеКоличествоЦенаСтоимость
+ + + + {position.name || '-'} + + + {formatQuantity(position.quantity)} + + {formatMoney(position.currentPrice)} + + {formatMoney(position.currentValue)} +
+
+
+ ); +} + +type BrokerPositionsSectionProps = { + page: { items: BrokerPosition[] } | undefined; + isLoading: boolean; + pageNumber: number; + canGoBack: boolean; + canGoForward: boolean; + onPrevious: () => void; + onNext: () => void; +}; + +export function BrokerPositionsSection({ + page, + isLoading, + pageNumber, + canGoBack, + canGoForward, + onPrevious, + onNext, +}: BrokerPositionsSectionProps) { + const positions = page?.items ?? []; + + const grouped = GROUPS.map((group) => ({ + ...group, + positions: positions.filter((position) => getBrokerPositionGroup(position) === group.key), + })).filter((group) => group.positions.length > 0); + + return ( +
+
+

Позиции

+
+ + + {pageNumber} + + +
+
+ + {isLoading && grouped.length === 0 ? ( +
+ + + + + + + + + + + +
ТикерНазваниеКоличествоЦенаСтоимость
+
+ ) : grouped.length === 0 ? ( +

В портфеле нет позиций

+ ) : isLoading ? ( +
+
+ {grouped.map((group) => ( +
+

{group.title}

+
+ + + + + + + + + + + +
ТикерНазваниеКоличествоЦенаСтоимость
+
+
+ ))} +
+
+ ) : ( +
+ {grouped.map((group) => ( + + ))} +
+ )} +
+ ); +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add apps/frontend/src/pages/broker/BrokerPositionsSection.tsx +git commit -m "feat(frontend): add pagination and skeleton to BrokerPositionsSection" +``` + +--- + +### Task 10: Frontend — BrokerOperationsTable with shimmer + instrument name + +**Files:** +- Modify: `apps/frontend/src/pages/broker/BrokerOperationsTable.tsx` + +- [ ] **Step 1: Add shimmer loading and instrument name display** + +Edit `apps/frontend/src/pages/broker/BrokerOperationsTable.tsx`: + +Add import: +```tsx +import { TableSkeleton } from '../../components/TableSkeleton'; +``` + +Replace `OperationInstrument`: +```tsx +function OperationInstrument({ operation }: { operation: BrokerOperation }) { + const ticker = operation.ticker || operation.description || '-'; + const path = getBrokerInstrumentPath({ + ticker: operation.ticker, + instrumentType: operation.instrumentType, + classCode: operation.classCode, + }); + const name = operation.name || operation.description; + + if (!path && !name) return -; + if (!path) return {name}; + if (!ticker || ticker === '-') return {name}; + + return ( +
+ {ticker} + {name && name !== ticker && ( + {name} + )} +
+ ); +} +``` + +Replace the `isLoading` check block (lines 147-195): + +Keep the same structure but replace the loading state: +```tsx + {isLoading ? ( +
+ + + + + + + + + + +
ДатаТипИнструментСумма
+
+ ) : operations.length === 0 ? ( +``` + +- [ ] **Step 2: Commit** + +```bash +git add apps/frontend/src/pages/broker/BrokerOperationsTable.tsx +git commit -m "feat(frontend): add shimmer loading and instrument name in operations table" +``` + +--- + +### Task 11: Frontend — BrokerAccountDetailPage with positions hook + skeleton + +**Files:** +- Modify: `apps/frontend/src/pages/broker/BrokerAccountDetailPage.tsx` + +- [ ] **Step 1: Rewrite with positions hook, skeleton loading** + +Replace `apps/frontend/src/pages/broker/BrokerAccountDetailPage.tsx`: +```tsx +import { useState } from 'react'; +import { useParams } from 'react-router-dom'; +import type { BrokerMoney } from '../../api/responses'; +import { useBrokerOperations } from '../../hooks/useBrokerOperations'; +import { useBrokerPortfolio } from '../../hooks/useBrokerPortfolio'; +import { useBrokerPositions } from '../../hooks/useBrokerPositions'; +import { BrokerOperationsTable } from './BrokerOperationsTable'; +import { BrokerPositionsSection } from './BrokerPositionsSection'; +import { SkeletonBlock } from '../../components/SkeletonBlock'; + +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); +} + +export function BrokerAccountDetailPage() { + const { accountId } = useParams(); + const [operationCursor, setOperationCursor] = useState(undefined); + const [operationCursorStack, setOperationCursorStack] = useState>([]); + const [positionCursor, setPositionCursor] = useState(undefined); + const [positionCursorStack, setPositionCursorStack] = useState>([]); + const portfolio = useBrokerPortfolio(accountId); + const operations = useBrokerOperations(accountId, { limit: 10, cursor: operationCursor }); + const positions = useBrokerPositions(accountId, { limit: 10, cursor: positionCursor }); + + if (portfolio.isLoading) { + return ( +
+
+ + +
+
+ {[1, 2, 3].map((i) => ( +
+ +
+ +
+ ))} +
+
+ + + + + + + + + + + + {Array.from({ length: 4 }).map((_, i) => ( + + {Array.from({ length: 5 }).map((_, j) => ( + + ))} + + ))} + +
ТикерНазваниеКоличествоЦенаСтоимость
+ +
+
+
+ ); + } + + if (portfolio.error || !portfolio.data) { + return

Не удалось загрузить портфель

; + } + + function handleNextOperationsPage() { + const nextCursor = operations.data?.nextCursor; + if (!nextCursor || !operations.data?.hasNext) return; + setOperationCursorStack((previous) => [...previous, operationCursor]); + setOperationCursor(nextCursor); + } + + function handlePreviousOperationsPage() { + if (operationCursorStack.length === 0) return; + const nextStack = operationCursorStack.slice(0, -1); + const previousCursor = operationCursorStack[operationCursorStack.length - 1]; + setOperationCursorStack(nextStack); + setOperationCursor(previousCursor); + } + + function handleNextPositionsPage() { + const nextCursor = positions.data?.nextCursor; + if (!nextCursor || !positions.data?.hasNext) return; + setPositionCursorStack((previous) => [...previous, positionCursor]); + setPositionCursor(nextCursor); + } + + function handlePreviousPositionsPage() { + if (positionCursorStack.length === 0) return; + const nextStack = positionCursorStack.slice(0, -1); + const previousCursor = positionCursorStack[positionCursorStack.length - 1]; + setPositionCursorStack(nextStack); + setPositionCursor(previousCursor); + } + + return ( +
+
+

+ {portfolio.data.account.name} +

+
+ {formatMoney(portfolio.data.totals.portfolio)} + + День: {formatMoney(portfolio.data.yields.daily)} + + + Ожидаемая: {portfolio.data.yields.expectedPercent ?? '-'}% + +
+
+ +
+ {portfolio.data.cash.map((money) => ( +
+
+ {money.currency} +
+ {formatMoney(money)} +
+ ))} +
+ + 0} + canGoForward={Boolean(positions.data?.hasNext && positions.data.nextCursor)} + onPrevious={handlePreviousPositionsPage} + onNext={handleNextPositionsPage} + /> + + 0} + canGoForward={Boolean(operations.data?.hasNext && operations.data.nextCursor)} + onPrevious={handlePreviousOperationsPage} + onNext={handleNextOperationsPage} + /> +
+ ); +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add apps/frontend/src/pages/broker/BrokerAccountDetailPage.tsx +git commit -m "feat(frontend): add positions hook and skeleton loading to account detail page" +``` + +--- + +### Task 12: Frontend — BrokerAccountsPage skeleton cards + +**Files:** +- Modify: `apps/frontend/src/pages/broker/BrokerAccountsPage.tsx` + +- [ ] **Step 1: Replace text loading with skeleton cards** + +Edit `apps/frontend/src/pages/broker/BrokerAccountsPage.tsx`: + +Add import: +```tsx +import { SkeletonBlock } from '../../components/SkeletonBlock'; +``` + +Replace: +```tsx + if (isLoading) return

Загрузка брокерских счетов...

; +``` + +With: +```tsx + if (isLoading) { + return ( +
+
+

Брокерские счета

+
+
+ {[1, 2, 3].map((i) => ( +
+ +
+ +
+ +
+ +
+ ))} +
+
+ ); + } +``` + +- [ ] **Step 2: Commit** + +```bash +git add apps/frontend/src/pages/broker/BrokerAccountsPage.tsx +git commit -m "feat(frontend): add skeleton cards to broker accounts page" +``` + +--- + +### Task 13: Frontend — Update BrokerPages tests + +**Files:** +- Modify: `apps/frontend/src/pages/broker/BrokerPages.test.tsx` + +- [ ] **Step 1: Update tests — remove positions from portfolio mock, add positions hook mock** + +Edit `apps/frontend/src/pages/broker/BrokerPages.test.tsx`: + +Add import: +```tsx +import * as positionsHook from '../../hooks/useBrokerPositions'; +``` + +Update the portfolio mock in "renders positions and operations for account detail" (line 54-91): + +Remove `positions` from the portfolio data mock: +```tsx + 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: [], + asOf: '2026-06-16T00:00:00.000Z', + }, + isLoading: false, + error: null, + } as any); +``` + +Add positions mock: +```tsx + vi.spyOn(positionsHook, 'useBrokerPositions').mockReturnValue({ + data: { + accountId: 'acc-1', + items: [ + { + 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, + }, + ], + nextCursor: null, + hasNext: false, + asOf: '2026-06-16T00:00:00.000Z', + }, + isLoading: false, + error: null, + } as any); +``` + +Repeat for the other tests: +- "renders broker positions as separate linked stock and bond tables" (line 139): remove `positions` from portfolio mock, add positions hook mock +- "renders broker operations with Russian labels" (line 225): remove `positions` from portfolio mock, add positions hook mock +- "requests broker operations by cursor" (line 321): remove `positions` from portfolio mock, add positions hook mock + +For the table test (line 139), add a richer positions mock: +```tsx + vi.spyOn(positionsHook, 'useBrokerPositions').mockReturnValue({ + data: { + accountId: 'acc-1', + items: [ + { + figi: null, + instrumentUid: 'share-uid', + positionUid: null, + ticker: 'SBER', + classCode: 'TQBR', + instrumentType: 'share', + name: 'Sberbank', + quantity: 10, + blockedLots: null, + currentPrice: { currency: 'RUB', units: '250', nano: 0, value: 250 }, + currentValue: { currency: 'RUB', units: '2500', nano: 0, value: 2500 }, + averagePositionPrice: null, + expectedYieldPercent: 20, + dailyYield: null, + }, + { + figi: null, + instrumentUid: 'bond-uid', + positionUid: null, + ticker: 'SU26238RMFS5', + classCode: 'TQOB', + instrumentType: 'bond', + name: 'ОФЗ 26238', + quantity: 2, + blockedLots: null, + currentPrice: { currency: 'RUB', units: '900', nano: 0, value: 900 }, + currentValue: { currency: 'RUB', units: '1800', nano: 0, value: 1800 }, + averagePositionPrice: null, + expectedYieldPercent: 10, + dailyYield: null, + }, + ], + nextCursor: null, + hasNext: false, + asOf: '2026-06-17T00:00:00.000Z', + }, + isLoading: false, + error: null, + } as any); +``` + +For the two operation tests (line 225 and 321), provide empty positions list: +```tsx + vi.spyOn(positionsHook, 'useBrokerPositions').mockReturnValue({ + data: { + accountId: 'acc-1', + items: [], + nextCursor: null, + hasNext: false, + asOf: '2026-06-17T00:00:00.000Z', + }, + isLoading: false, + error: null, + } as any); +``` + +- [ ] **Step 2: Run tests** + +```bash +npx vitest run apps/frontend/src/pages/broker/BrokerPages.test.tsx -w apps/frontend +``` + +Expected: ALL PASS + +- [ ] **Step 3: Run all frontend tests** + +```bash +npm run test:frontend +``` + +Expected: ALL PASS + +- [ ] **Step 4: Run all backend tests** + +```bash +npm run test:backend +``` + +Expected: ALL PASS + +- [ ] **Step 5: Run lint** + +```bash +npm run lint +``` + +Expected: ALL PASS + +- [ ] **Step 6: Build frontend** + +```bash +npm run build:frontend +``` + +Expected: SUCCESS + +- [ ] **Step 7: Commit** + +```bash +git add apps/frontend/src/pages/broker/BrokerPages.test.tsx +git commit -m "test(frontend): update broker tests for positions hook and removal from portfolio" +``` + +--- + +### Task 14: Full build and test verification + +- [ ] **Step 1: Run full backend test suite** + +```bash +npm run test:backend +``` + +- [ ] **Step 2: Run full frontend test suite** + +```bash +npm run test:frontend +``` + +- [ ] **Step 3: Run lint** + +```bash +npm run lint +``` + +- [ ] **Step 4: Build frontend** + +```bash +npm run build:frontend +``` + +- [ ] **Step 5: Build backend** + +```bash +npm run build:backend +``` + +- [ ] **Step 6: Final commit if fixes needed** + +```bash +git add -A +git commit -m "chore: fix lint and build after broker portfolio enhancements" +``` diff --git a/docs/superpowers/specs/2026-06-18-broker-performance-optimization.md b/docs/superpowers/specs/2026-06-18-broker-performance-optimization.md new file mode 100644 index 0000000..a024049 --- /dev/null +++ b/docs/superpowers/specs/2026-06-18-broker-performance-optimization.md @@ -0,0 +1,88 @@ +# Broker API Performance Optimization + +## Проблема + +- `GET /api/v1/broker/accounts/:id/portfolio` — **~5s** +- `GET /api/v1/broker/accounts/:id/positions` — **1-3s** + +## Диагностика + +### 1. Мёртвый код в `getPortfolio` + +`buildInstrumentMap` делает N gRPC вызовов `GetInstrumentBy` (по одному на каждый `instrumentUid` в портфеле), но результат **не используется** в `mapBrokerPortfolio`. Это чистое WASTE. + +### 2. Блокирующий instrument enrichment в `getPositions` + +`buildInstrumentMap` вызывает `findByInstrumentUid` для каждого инструмента через gRPC. Даже при `Promise.allSettled`, все вызовы проходят через единый `p-queue` с 5 req/s. Для 10-20 позиций = 2-4 секунды ожидания в очереди. + +Из всех полей `GetInstrumentBy` `mapBrokerPosition` использует только `name` — `ticker`, `classCode`, `instrumentType` уже есть в ответе `PortfolioPosition` (proto fields 32, 33, 2). + +### 3. Единый rate limiter + +Один `p-queue` на 5 req/s для всех gRPC вызовов. Instrument lookups конкурируют за очередь с portfolio/operations запросами. + +## Изменения + +### Change 1: Убрать `buildInstrumentMap` из `getPortfolio` + +**Файлы:** `broker-portfolio.service.ts` + +Удалить вызов `buildInstrumentMap` и передачу `instruments` в `mapBrokerPortfolio`. Исключить `BrokerInstrumentsService` из зависимостей (если не используется больше нигде в сервисе). + +### Change 2: Instrument enrichment из кэша без блокировки + +**Файлы:** `broker-portfolio.service.ts`, `cache.service.ts` + +- `buildInstrumentMap` пытается достать данные из кэша без триггера gRPC +- Если данных нет — возвращаем `null` для имени (не блокируем ответ) +- Новый метод `CacheService.getIfPresent(key)` — проверяет кэш без вызова fetchFn + +### Change 3: Разделить rate limiter на 3 очереди + +**Файлы:** `tbank-client.service.ts` + +Заменить единый `p-queue` на: + +| Очередь | Rate | Сервисы | +|---|---|---| +| `operationsQueue` | 5 req/s | OperationsService | +| `instrumentsQueue` | 20 req/s | InstrumentsService | +| `usersQueue` | 5 req/s | UsersService | + +Метод `callUnary` принимает параметр `queueName`. Клиентские методы выбирают очередь по типу сервиса. + +### Change 4: Увеличить rate limit по умолчанию + +**Файлы:** `configuration.ts` + +`rateLimitPerSecond` по умолчанию: 5 → 20. + +### Change 5: Shared cache сырого `GetPortfolio` + +**Файлы:** `broker-portfolio.service.ts` + +Оба эндпоинта вызывают `GetPortfolio` с одинаковым `accountId`. Кэшировать сырой ответ отдельно (TTL 60s, ключ `tbank:raw-portfolio:{accountId}`), чтобы второй запрос в том же окне не дублировал вызов. + +## Ожидаемый эффект + +| Endpoint | До | После | +|---|---|---| +| Portfolio | ~5s | ~0.3-0.5s (2 параллельных gRPC, без instrument enrichment) | +| Positions | 1-3s | ~0.2-0.3s (1 gRPC GetPortfolio, name из кэша / null) | + +## Этапы реализации (по порядку) + +1. Убрать `buildInstrumentMap` из `getPortfolio` +2. `CacheService.getIfPresent()` для instrument enrichment в positions +3. Разделить rate limiter на очереди +4. Увеличить rate limit по умолчанию +5. Shared cache сырого GetPortfolio + +Каждый этап отдельным коммитом. + +## Acceptance Criteria + +1. Portfolio endpoint < 1s при тёплом кэше account/instrument, < 1.5s при холодном +2. Positions endpoint < 0.5s при тёплом кэше, < 1s при холодном +3. Все существующие тесты проходят +4. Instrument name показывается если есть в кэше, иначе `null`