codex/broker-operations-ui #19

Merged
ksv741 merged 2 commits from codex/broker-operations-ui into main 2026-06-18 07:29:01 +03:00
15 changed files with 2491 additions and 38 deletions
Showing only changes of commit feaff2103e - Show all commits

View File

@ -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 истории (с) |

View File

@ -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: {

View File

@ -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');

View File

@ -16,7 +16,6 @@ type MapBrokerPortfolioInput = {
account: BrokerAccount;
portfolio: TBankPortfolioResponse;
positions: TBankPositionsResponse;
instruments: Map<string, Partial<TBankInstrument>>;
};
function isBrokerMoney(value: BrokerMoney | null): value is BrokerMoney {

View File

@ -41,9 +41,14 @@ export class BrokerAccountsService {
const response = await this.tbankClient.callUnary<
Record<string, string>,
TBankAccountsResponse
>('UsersService/GetAccounts', usersClient.getAccounts.bind(usersClient), {
>(
'UsersService/GetAccounts',
usersClient.getAccounts.bind(usersClient),
{
status: 'ACCOUNT_STATUS_OPEN',
});
},
'users',
);
return (response.accounts ?? []).filter(isSupportedBrokerAccount).map(mapAccount);
}

View File

@ -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;

View File

@ -89,6 +89,7 @@ describe('BrokerPortfolioService', () => {
cachedAt: null,
}),
);
vi.mocked(instruments.findByInstrumentUid).mockResolvedValue(null);
}
it('throws 404 for missing account', async () => {

View File

@ -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<TBankPortfolioResponse> {
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<TBankPositionsResponse> {
const operationsClient = this.tbankClient.getServiceClient('OperationsService') as any;
return this.tbankClient.callUnary<{ accountId: string }, TBankPositionsResponse>(
'OperationsService/GetPositions',
operationsClient.getPositions.bind(operationsClient),
{ accountId },
);
}
}

View File

@ -30,20 +30,28 @@ type GrpcUnary<TRequest, TResponse> = (
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<QueueName, PQueue>;
private readonly requestTimeoutMs: number;
private readonly packageDefinition: ReturnType<typeof loadPackageDefinition>;
private readonly clientCache = new Map<string, Client>();
constructor(private readonly configService: ConfigService) {
this.requestTimeoutMs = this.configService.get<number>('app.tbank.requestTimeoutMs', 10000);
this.queue = new PQueue({
interval: 1000,
intervalCap: this.configService.get<number>('app.tbank.rateLimitPerSecond', 5),
});
const operationsRate = this.configService.get<number>('app.tbank.rateLimitPerSecond', 5);
const instrumentsRate = this.configService.get<number>(
'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<TRequest, TResponse>,
request: TRequest,
queueName: QueueName = 'operations',
): Promise<TResponse> {
return this.queue.add(
return this.queues[queueName].add(
() =>
new Promise<TResponse>((resolve, reject) => {
const metadata = this.createMetadata();

View File

@ -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` |

View File

@ -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: {

View File

@ -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`, а

View File

@ -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 `<OperationType operation={operation} />` with just:
```tsx
<span>{getBrokerOperationTypeLabel(operation)}</span>
```
- [ ] **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
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<button
type="button"
onClick={onPrevious}
disabled={!canGoBack}
style={canGoBack ? pagButtonStyle : pagButtonDisabledStyle}
>
</button>
<span
style={{
minWidth: 20,
textAlign: 'center',
color: 'var(--color-text-secondary)',
fontSize: 14,
fontWeight: 600,
}}
>
{pageNumber}
</span>
<button
type="button"
onClick={onNext}
disabled={!canGoForward}
style={canGoForward ? pagButtonStyle : pagButtonDisabledStyle}
>
</button>
</div>
```
- [ ] **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<BrokerOperationsPage>({
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"
```

File diff suppressed because it is too large Load Diff

View File

@ -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`