moex-vibe/docs/superpowers/plans/2026-06-17-broker-positions-pagination-and-loading.md
Sergey Krylov feaff2103e 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
2026-06-18 06:34:55 +03:00

1997 lines
61 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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<string, Partial<TBankInstrument>>;
};
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<string, Partial<TBankInstrument>>;
},
): 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<string, Partial<TBankInstrument>>;
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<Map<string, Partial<TBankInstrument>>> {
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<unknown>) => ({
data: await fetchFn(),
fromCache: false,
cachedAt: null,
}),
);
vi.mocked(client.getServiceClient).mockReturnValue({
getPortfolio: vi.fn(),
getPositions: vi.fn(),
} as any);
vi.mocked(client.callUnary)
.mockResolvedValueOnce({
accountId: 'acc-1',
totalAmountPortfolio: { currency: 'rub', units: '1000', nano: 0 },
positions: [],
})
.mockResolvedValueOnce({
accountId: 'acc-1',
money: [{ currency: 'rub', units: '1000', nano: 0 }],
blocked: [],
securities: [],
});
const service = new BrokerPortfolioService(accounts, instruments, client, cache);
const result = await service.getPortfolio('acc-1');
expect(result.data.account.id).toBe('acc-1');
expect(result.data.cash[0].value).toBe(1000);
// 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<unknown>) => ({
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<unknown>) => ({
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<unknown>) => ({
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<unknown>) => ({
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<BrokerPositionsPage>(
`/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<BrokerPositionsPage>({
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 (
<div
className="skeleton"
style={{
width: width ?? '100%',
height: height ?? 16,
borderRadius,
}}
/>
);
}
```
- [ ] **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 (
<tbody>
{Array.from({ length: rows }).map((_, i) => (
<tr key={i}>
{columns.map((col, j) => (
<td key={j} style={tdStyle}>
<SkeletonBlock height={12} width={col.width} />
</td>
))}
</tr>
))}
</tbody>
);
}
```
- [ ] **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 <strong>{label}</strong>;
}
return (
<Link to={path} style={{ fontWeight: 700 }}>
{label}
</Link>
);
}
function PositionTable({ title, positions }: { title: string; positions: BrokerPosition[] }) {
return (
<section>
<h3 style={{ fontSize: 18, marginBottom: 10 }}>{title}</h3>
<div style={{ overflowX: 'auto', background: 'var(--color-surface)' }}>
<table aria-label={`Брокерские позиции: ${title}`} style={tableStyle}>
<thead>
<tr>
<th align="left" style={thStyle}>Тикер</th>
<th align="left" style={thStyle}>Название</th>
<th align="right" style={thStyle}>Количество</th>
<th align="right" style={thStyle}>Цена</th>
<th align="right" style={thStyle}>Стоимость</th>
</tr>
</thead>
<tbody>
{positions.map((position) => (
<tr
key={
position.positionUid || position.instrumentUid || position.ticker || position.figi
}
>
<td style={tdStyle}>
<PositionTicker position={position} />
</td>
<td style={tdStyle}>
<span style={{ color: 'var(--color-text-secondary)' }}>
{position.name || '-'}
</span>
</td>
<td align="right" style={tdStyle}>
{formatQuantity(position.quantity)}
</td>
<td align="right" style={tdStyle}>
{formatMoney(position.currentPrice)}
</td>
<td align="right" style={tdStyle}>
{formatMoney(position.currentValue)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
);
}
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 (
<section>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 12,
justifyContent: 'space-between',
marginBottom: 12,
}}
>
<h2 style={{ fontSize: 20, margin: 0 }}>Позиции</h2>
<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>
</div>
{isLoading && grouped.length === 0 ? (
<div style={{ overflowX: 'auto', background: 'var(--color-surface)' }}>
<table style={tableStyle}>
<thead>
<tr>
<th align="left" style={thStyle}>Тикер</th>
<th align="left" style={thStyle}>Название</th>
<th align="right" style={thStyle}>Количество</th>
<th align="right" style={thStyle}>Цена</th>
<th align="right" style={thStyle}>Стоимость</th>
</tr>
</thead>
<TableSkeleton rows={4} columns={[
{ width: '30%' }, { width: '50%' }, { width: '20%' }, { width: '25%' }, { width: '25%' },
]} />
</table>
</div>
) : grouped.length === 0 ? (
<p style={{ color: 'var(--color-text-secondary)' }}>В портфеле нет позиций</p>
) : isLoading ? (
<div>
<div style={{ display: 'grid', gap: 20 }}>
{grouped.map((group) => (
<div key={group.key}>
<h3 style={{ fontSize: 18, marginBottom: 10 }}>{group.title}</h3>
<div style={{ overflowX: 'auto', background: 'var(--color-surface)' }}>
<table style={tableStyle}>
<thead>
<tr>
<th align="left" style={thStyle}>Тикер</th>
<th align="left" style={thStyle}>Название</th>
<th align="right" style={thStyle}>Количество</th>
<th align="right" style={thStyle}>Цена</th>
<th align="right" style={thStyle}>Стоимость</th>
</tr>
</thead>
<TableSkeleton rows={2} columns={[
{ width: '30%' }, { width: '50%' }, { width: '20%' }, { width: '25%' }, { width: '25%' },
]} />
</table>
</div>
</div>
))}
</div>
</div>
) : (
<div style={{ display: 'grid', gap: 20 }}>
{grouped.map((group) => (
<PositionTable key={group.key} title={group.title} positions={group.positions} />
))}
</div>
)}
</section>
);
}
```
- [ ] **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 <span>-</span>;
if (!path) return <span>{name}</span>;
if (!ticker || ticker === '-') return <Link to={path}>{name}</Link>;
return (
<div style={{ display: 'grid', gap: 2 }}>
<Link to={path} style={{ fontWeight: 700 }}>{ticker}</Link>
{name && name !== ticker && (
<span style={{ color: 'var(--color-text-secondary)', fontSize: 12 }}>{name}</span>
)}
</div>
);
}
```
Replace the `isLoading` check block (lines 147-195):
Keep the same structure but replace the loading state:
```tsx
{isLoading ? (
<div style={{ overflowX: 'auto', background: 'var(--color-surface)' }}>
<table style={tableStyle}>
<thead>
<tr>
<th align="left" style={thStyle}>Дата</th>
<th align="left" style={thStyle}>Тип</th>
<th align="left" style={thStyle}>Инструмент</th>
<th align="right" style={thStyle}>Сумма</th>
</tr>
</thead>
<TableSkeleton rows={5} columns={[
{ width: '35%' }, { width: '30%' }, { width: '40%' }, { width: '25%' },
]} />
</table>
</div>
) : 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<string | undefined>(undefined);
const [operationCursorStack, setOperationCursorStack] = useState<Array<string | undefined>>([]);
const [positionCursor, setPositionCursor] = useState<string | undefined>(undefined);
const [positionCursorStack, setPositionCursorStack] = useState<Array<string | undefined>>([]);
const portfolio = useBrokerPortfolio(accountId);
const operations = useBrokerOperations(accountId, { limit: 10, cursor: operationCursor });
const positions = useBrokerPositions(accountId, { limit: 10, cursor: positionCursor });
if (portfolio.isLoading) {
return (
<div style={{ display: 'grid', gap: 24 }}>
<div style={{ display: 'grid', gap: 12 }}>
<SkeletonBlock height={32} width="60%" />
<SkeletonBlock height={24} width="40%" />
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))', gap: 12 }}>
{[1, 2, 3].map((i) => (
<div key={i} style={{ padding: 16, background: 'var(--color-surface)', border: '1px solid #e0e0e0', borderRadius: 8 }}>
<SkeletonBlock height={14} width="40%" />
<div style={{ height: 8 }} />
<SkeletonBlock height={20} width="60%" />
</div>
))}
</div>
<div style={{ overflowX: 'auto', background: 'var(--color-surface)' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 14 }}>
<thead>
<tr>
<th align="left" style={{ borderBottom: '1px solid #e0e0e0', color: 'var(--color-text-secondary)', fontWeight: 600, padding: '10px 8px' }}>Тикер</th>
<th align="left" style={{ borderBottom: '1px solid #e0e0e0', color: 'var(--color-text-secondary)', fontWeight: 600, padding: '10px 8px' }}>Название</th>
<th align="right" style={{ borderBottom: '1px solid #e0e0e0', color: 'var(--color-text-secondary)', fontWeight: 600, padding: '10px 8px' }}>Количество</th>
<th align="right" style={{ borderBottom: '1px solid #e0e0e0', color: 'var(--color-text-secondary)', fontWeight: 600, padding: '10px 8px' }}>Цена</th>
<th align="right" style={{ borderBottom: '1px solid #e0e0e0', color: 'var(--color-text-secondary)', fontWeight: 600, padding: '10px 8px' }}>Стоимость</th>
</tr>
</thead>
<tbody>
{Array.from({ length: 4 }).map((_, i) => (
<tr key={i}>
{Array.from({ length: 5 }).map((_, j) => (
<td key={j} style={{ borderBottom: '1px solid #eeeeee', padding: '10px 8px', verticalAlign: 'top' }}>
<SkeletonBlock height={12} width={`${30 + j * 10}%`} />
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
if (portfolio.error || !portfolio.data) {
return <p style={{ color: 'var(--color-negative)' }}>Не удалось загрузить портфель</p>;
}
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 (
<div style={{ display: 'grid', gap: 24 }}>
<header>
<h1 style={{ fontSize: 28, lineHeight: 1.2, marginBottom: 12 }}>
{portfolio.data.account.name}
</h1>
<div style={{ display: 'flex', gap: 16, flexWrap: 'wrap', alignItems: 'baseline' }}>
<strong style={{ fontSize: 24 }}>{formatMoney(portfolio.data.totals.portfolio)}</strong>
<span style={{ color: 'var(--color-text-secondary)' }}>
День: {formatMoney(portfolio.data.yields.daily)}
</span>
<span style={{ color: 'var(--color-text-secondary)' }}>
Ожидаемая: {portfolio.data.yields.expectedPercent ?? '-'}%
</span>
</div>
</header>
<section
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))',
gap: 12,
}}
>
{portfolio.data.cash.map((money) => (
<div
key={money.currency}
style={{
background: 'var(--color-surface)',
border: '1px solid #e0e0e0',
borderRadius: 8,
padding: 16,
}}
>
<div style={{ color: 'var(--color-text-secondary)', fontSize: 13 }}>
{money.currency}
</div>
<strong>{formatMoney(money)}</strong>
</div>
))}
</section>
<BrokerPositionsSection
page={positions.data}
isLoading={positions.isLoading}
pageNumber={positionCursorStack.length + 1}
canGoBack={positionCursorStack.length > 0}
canGoForward={Boolean(positions.data?.hasNext && positions.data.nextCursor)}
onPrevious={handlePreviousPositionsPage}
onNext={handleNextPositionsPage}
/>
<BrokerOperationsTable
isLoading={operations.isLoading}
page={operations.data}
pageNumber={operationCursorStack.length + 1}
canGoBack={operationCursorStack.length > 0}
canGoForward={Boolean(operations.data?.hasNext && operations.data.nextCursor)}
onPrevious={handlePreviousOperationsPage}
onNext={handleNextOperationsPage}
/>
</div>
);
}
```
- [ ] **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 <p>Загрузка брокерских счетов...</p>;
```
With:
```tsx
if (isLoading) {
return (
<div>
<div style={{ display: 'flex', alignItems: 'baseline', gap: 12, marginBottom: 20 }}>
<h1 style={{ fontSize: 28, lineHeight: 1.2 }}>Брокерские счета</h1>
</div>
<div
style={{
display: 'grid',
gap: 16,
gridTemplateColumns: 'repeat(auto-fit, minmax(260px, 1fr))',
}}
>
{[1, 2, 3].map((i) => (
<div
key={i}
style={{
padding: 20,
background: 'var(--color-surface)',
border: '1px solid #e0e0e0',
borderRadius: 8,
boxShadow: 'var(--shadow)',
}}
>
<SkeletonBlock height={20} width="60%" />
<div style={{ height: 10 }} />
<SkeletonBlock height={12} width="40%" />
<div style={{ height: 6 }} />
<SkeletonBlock height={12} width="30%" />
<div style={{ height: 6 }} />
<SkeletonBlock height={12} width="50%" />
</div>
))}
</div>
</div>
);
}
```
- [ ] **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"
```