- 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
61 KiB
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
positionsfromBrokerPortfoliotype, addBrokerPositionsPage, addnametoBrokerOperation
Edit apps/backend/src/modules/tbank/types/broker.types.ts:
Remove positions: BrokerPosition[] from BrokerPortfolio.
Add after BrokerPosition type:
export type BrokerPositionsPage = {
accountId: string;
items: BrokerPosition[];
nextCursor: string | null;
hasNext: boolean;
asOf: string;
};
Add name to BrokerOperation:
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:
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:
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
positionsfromBrokerPortfolioResponseDto
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:
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
nametoBrokerOperationResponseDto
Edit apps/backend/src/modules/tbank/dto/broker-operation-response.dto.ts:
Add after description:
@ApiProperty({ nullable: true })
name!: string | null;
- Step 6: Add
BrokerPositionsEnvelopeDtoto envelope
Edit apps/backend/src/modules/tbank/dto/broker-envelope.dto.ts:
Add import:
import { BrokerPositionsPageResponseDto } from './broker-positions-page-response.dto';
Add after BrokerOperationsEnvelopeDto:
export class BrokerPositionsEnvelopeDto {
@ApiProperty({ type: BrokerPositionsPageResponseDto })
data!: BrokerPositionsPageResponseDto;
@ApiProperty({ type: BrokerResponseMetaDto })
meta!: BrokerResponseMetaDto;
}
- Step 7: Commit
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
mapBrokerPositionfrommapBrokerPortfolio, remove positions from portfolio mapping
Edit apps/backend/src/modules/tbank/mappers/portfolio.mapper.ts:
Replace the file content with:
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
nametomapOperation
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):
description: item.description || item.name || null,
name: item.name ?? null,
- Step 3: Commit
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:
import type { BrokerPortfolio, BrokerPositionsPage } from '../types/broker.types';
Replace the file content to:
- Keep
getPortfolio()but remove positions from the mapped result (just don't include them — the mapper no longer returns them) - Add
getPositions()method
Full file:
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
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/positionsendpoint
Edit apps/backend/src/modules/tbank/tbank.controller.ts:
Add imports:
import { BrokerPositionsEnvelopeDto } from './dto/broker-envelope.dto';
import { BrokerPositionQueryDto } from './dto/broker-position-query.dto';
Add after getPortfolio method:
@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:
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
tbankPositionsTtlto configuration
Edit apps/backend/src/config/configuration.ts:
Add after tbankOperationsTtl (line 34):
tbankPositionsTtl: parseInt(process.env.CACHE_TBANK_POSITIONS_TTL || '60', 10),
- Step 4: Commit
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:
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
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:
expect(config.cache.tbankPositionsTtl).toBe(60);
Add to the second it block — set env and assert:
process.env.CACHE_TBANK_POSITIONS_TTL = '45';
And add assertion:
expect(config.cache.tbankPositionsTtl).toBe(45);
- Step 4: Run config tests
npx vitest run apps/backend/src/modules/tbank/tbank.config.spec.ts -w apps/backend
Expected: ALL PASS
- Step 5: Commit
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:
@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
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:
export interface BrokerPositionsPage {
accountId: string;
items: BrokerPosition[];
nextCursor: string | null;
hasNext: boolean;
asOf: string;
}
Add name: string | null to BrokerOperation (after description):
description: string | null;
name: string | null;
- Step 2: Add
getBrokerPositionsAPI function
Edit apps/frontend/src/api/broker.ts:
Add import:
import type {
ApiResponseMeta,
BrokerAccount,
BrokerOperationsPage,
BrokerPortfolio,
BrokerPositionsPage,
} from './responses';
Add after getBrokerOperations:
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:
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
useBrokerPositionshook
Create apps/frontend/src/hooks/useBrokerPositions.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
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:
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:
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
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:
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
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:
import { TableSkeleton } from '../../components/TableSkeleton';
Replace OperationInstrument:
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:
{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
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:
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
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:
import { SkeletonBlock } from '../../components/SkeletonBlock';
Replace:
if (isLoading) return <p>Загрузка брокерских счетов...</p>;
With:
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
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:
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:
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:
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
positionsfrom portfolio mock, add positions hook mock - "renders broker operations with Russian labels" (line 225): remove
positionsfrom portfolio mock, add positions hook mock - "requests broker operations by cursor" (line 321): remove
positionsfrom portfolio mock, add positions hook mock
For the table test (line 139), add a richer positions mock:
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:
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
npx vitest run apps/frontend/src/pages/broker/BrokerPages.test.tsx -w apps/frontend
Expected: ALL PASS
- Step 3: Run all frontend tests
npm run test:frontend
Expected: ALL PASS
- Step 4: Run all backend tests
npm run test:backend
Expected: ALL PASS
- Step 5: Run lint
npm run lint
Expected: ALL PASS
- Step 6: Build frontend
npm run build:frontend
Expected: SUCCESS
- Step 7: Commit
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
npm run test:backend
- Step 2: Run full frontend test suite
npm run test:frontend
- Step 3: Run lint
npm run lint
- Step 4: Build frontend
npm run build:frontend
- Step 5: Build backend
npm run build:backend
- Step 6: Final commit if fixes needed
git add -A
git commit -m "chore: fix lint and build after broker portfolio enhancements"