# Broker Portfolio Enhancements Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) for syntax tracking. **Goal:** Add cursor-based pagination for broker positions, shimmer skeleton loading, and instrument name display in operations. **Architecture:** Backend extracts positions from portfolio into a new paginated `GET /positions` endpoint. Frontend gets a `useBrokerPositions` hook, `SkeletonBlock`/`TableSkeleton` components, and shimmer CSS animations. The `name` field from T-Bank's `OperationItem` is mapped through to the frontend. **Tech Stack:** NestJS (backend), React 18 + TanStack Query v5 (frontend), CSS custom properties --- ### Task 1: Backend — Types and DTOs for positions page + operation name **Files:** - Modify: `apps/backend/src/modules/tbank/types/broker.types.ts` - Create: `apps/backend/src/modules/tbank/dto/broker-position-response.dto.ts` - Create: `apps/backend/src/modules/tbank/dto/broker-positions-page-response.dto.ts` - Modify: `apps/backend/src/modules/tbank/dto/broker-portfolio-response.dto.ts` - Modify: `apps/backend/src/modules/tbank/dto/broker-operation-response.dto.ts` - Modify: `apps/backend/src/modules/tbank/dto/broker-envelope.dto.ts` - [ ] **Step 1: Remove `positions` from `BrokerPortfolio` type, add `BrokerPositionsPage`, add `name` to `BrokerOperation`** Edit `apps/backend/src/modules/tbank/types/broker.types.ts`: Remove `positions: BrokerPosition[]` from `BrokerPortfolio`. Add after `BrokerPosition` type: ```ts export type BrokerPositionsPage = { accountId: string; items: BrokerPosition[]; nextCursor: string | null; hasNext: boolean; asOf: string; }; ``` Add `name` to `BrokerOperation`: ```ts export type BrokerOperation = { cursor: string | null; accountId: string; id: string | null; parentOperationId: string | null; date: string | null; type: string; category: BrokerOperationCategory; description: string | null; name: string | null; state: string | null; instrumentUid: string | null; figi: string | null; ticker: string | null; classCode: string | null; instrumentType: string | null; payment: BrokerMoney | null; price: BrokerMoney | null; commission: BrokerMoney | null; yield: BrokerMoney | null; accruedInt: BrokerMoney | null; quantity: number | null; quantityDone: number | null; }; ``` - [ ] **Step 2: Create `BrokerPositionResponseDto` (extracted from portfolio DTO)** Create `apps/backend/src/modules/tbank/dto/broker-position-response.dto.ts`: ```ts import { ApiProperty } from '@nestjs/swagger'; import { BrokerMoneyDto } from './broker-money.dto'; export class BrokerPositionResponseDto { @ApiProperty({ nullable: true }) figi!: string | null; @ApiProperty({ nullable: true }) instrumentUid!: string | null; @ApiProperty({ nullable: true }) positionUid!: string | null; @ApiProperty({ nullable: true }) ticker!: string | null; @ApiProperty({ nullable: true }) classCode!: string | null; @ApiProperty({ nullable: true }) instrumentType!: string | null; @ApiProperty({ nullable: true }) name!: string | null; @ApiProperty({ nullable: true }) quantity!: number | null; @ApiProperty({ nullable: true }) blockedLots!: number | null; @ApiProperty({ type: BrokerMoneyDto, nullable: true }) currentPrice!: BrokerMoneyDto | null; @ApiProperty({ type: BrokerMoneyDto, nullable: true }) currentValue!: BrokerMoneyDto | null; @ApiProperty({ type: BrokerMoneyDto, nullable: true }) averagePositionPrice!: BrokerMoneyDto | null; @ApiProperty({ nullable: true }) expectedYieldPercent!: number | null; @ApiProperty({ type: BrokerMoneyDto, nullable: true }) dailyYield!: BrokerMoneyDto | null; } ``` - [ ] **Step 3: Create `BrokerPositionsPageResponseDto`** Create `apps/backend/src/modules/tbank/dto/broker-positions-page-response.dto.ts`: ```ts import { ApiProperty } from '@nestjs/swagger'; import { BrokerPositionResponseDto } from './broker-position-response.dto'; export class BrokerPositionsPageResponseDto { @ApiProperty() accountId!: string; @ApiProperty({ type: [BrokerPositionResponseDto] }) items!: BrokerPositionResponseDto[]; @ApiProperty({ nullable: true }) nextCursor!: string | null; @ApiProperty() hasNext!: boolean; @ApiProperty() asOf!: string; } ``` - [ ] **Step 4: Remove `positions` from `BrokerPortfolioResponseDto`** Edit `apps/backend/src/modules/tbank/dto/broker-portfolio-response.dto.ts`: Remove the import of `BrokerPositionResponseDto` (no longer needed here since `BrokerPositionResponseDto` is now in its own file). Remove the entire `BrokerPositionResponseDto` class. Remove the `positions` property from `BrokerPortfolioResponseDto`: ```ts export class BrokerPortfolioResponseDto { @ApiProperty({ type: BrokerAccountResponseDto }) account!: BrokerAccountResponseDto; @ApiProperty({ type: BrokerPortfolioTotalsDto }) totals!: BrokerPortfolioTotalsDto; @ApiProperty({ type: BrokerPortfolioYieldsDto }) yields!: BrokerPortfolioYieldsDto; @ApiProperty({ type: [BrokerMoneyDto] }) cash!: BrokerMoneyDto[]; @ApiProperty({ type: [BrokerMoneyDto] }) blockedCash!: BrokerMoneyDto[]; @ApiProperty() asOf!: string; } ``` - [ ] **Step 5: Add `name` to `BrokerOperationResponseDto`** Edit `apps/backend/src/modules/tbank/dto/broker-operation-response.dto.ts`: Add after `description`: ```ts @ApiProperty({ nullable: true }) name!: string | null; ``` - [ ] **Step 6: Add `BrokerPositionsEnvelopeDto` to envelope** Edit `apps/backend/src/modules/tbank/dto/broker-envelope.dto.ts`: Add import: ```ts import { BrokerPositionsPageResponseDto } from './broker-positions-page-response.dto'; ``` Add after `BrokerOperationsEnvelopeDto`: ```ts export class BrokerPositionsEnvelopeDto { @ApiProperty({ type: BrokerPositionsPageResponseDto }) data!: BrokerPositionsPageResponseDto; @ApiProperty({ type: BrokerResponseMetaDto }) meta!: BrokerResponseMetaDto; } ``` - [ ] **Step 7: Commit** ```bash git add apps/backend/src/modules/tbank/types/broker.types.ts \ apps/backend/src/modules/tbank/dto/broker-position-response.dto.ts \ apps/backend/src/modules/tbank/dto/broker-positions-page-response.dto.ts \ apps/backend/src/modules/tbank/dto/broker-portfolio-response.dto.ts \ apps/backend/src/modules/tbank/dto/broker-operation-response.dto.ts \ apps/backend/src/modules/tbank/dto/broker-envelope.dto.ts git commit -m "feat(tbank): add positions page types/DTOs and operation name field" ``` --- ### Task 2: Backend — Mappers (separate positions, add name to operations) **Files:** - Modify: `apps/backend/src/modules/tbank/mappers/portfolio.mapper.ts` - Modify: `apps/backend/src/modules/tbank/mappers/operation.mapper.ts` - [ ] **Step 1: Extract `mapBrokerPosition` from `mapBrokerPortfolio`, remove positions from portfolio mapping** Edit `apps/backend/src/modules/tbank/mappers/portfolio.mapper.ts`: Replace the file content with: ```ts import type { BrokerAccount, BrokerMoney, BrokerPortfolio, BrokerPosition, BrokerPositionsPage, } from '../types/broker.types'; import type { TBankInstrument, TBankPortfolioResponse, TBankPositionsResponse, } from '../types/tbank-proto.types'; import { mapMoneyValue, mapQuotationToNumber } from './money.mapper'; type MapBrokerPortfolioInput = { account: BrokerAccount; portfolio: TBankPortfolioResponse; positions: TBankPositionsResponse; instruments: Map>; }; function isBrokerMoney(value: BrokerMoney | null): value is BrokerMoney { return value !== null; } export function mapBrokerPosition( input: { position: { figi?: string; instrumentUid?: string; positionUid?: string; ticker?: string; classCode?: string; instrumentType?: string; quantity?: { units?: string; nano?: number }; blockedLots?: { units?: string; nano?: number }; currentPrice?: { currency?: string; units?: string; nano?: number }; averagePositionPrice?: { currency?: string; units?: string; nano?: number }; expectedYield?: { units?: string; nano?: number }; dailyYield?: { currency?: string; units?: string; nano?: number } }; instruments: Map>; }, ): BrokerPosition { const quantity = mapQuotationToNumber(input.position.quantity); const currentPrice = mapMoneyValue(input.position.currentPrice); const currentValue = currentPrice && quantity !== null ? { ...currentPrice, units: String(Math.trunc(currentPrice.value * quantity)), nano: 0, value: Number((currentPrice.value * quantity).toFixed(9)), } : null; const instrument = (input.position.instrumentUid && input.instruments.get(input.position.instrumentUid)) || (input.position.positionUid && input.instruments.get(input.position.positionUid)) || undefined; return { figi: input.position.figi ?? null, instrumentUid: input.position.instrumentUid ?? null, positionUid: input.position.positionUid ?? null, ticker: input.position.ticker || instrument?.ticker || null, classCode: input.position.classCode || instrument?.classCode || null, instrumentType: input.position.instrumentType || instrument?.instrumentType || null, name: instrument?.name ?? null, quantity, blockedLots: mapQuotationToNumber(input.position.blockedLots), currentPrice, currentValue, averagePositionPrice: mapMoneyValue(input.position.averagePositionPrice), expectedYieldPercent: mapQuotationToNumber(input.position.expectedYield), dailyYield: mapMoneyValue(input.position.dailyYield), }; } export function mapBrokerPortfolio(input: MapBrokerPortfolioInput): BrokerPortfolio { return { account: input.account, totals: { shares: mapMoneyValue(input.portfolio.totalAmountShares), bonds: mapMoneyValue(input.portfolio.totalAmountBonds), etf: mapMoneyValue(input.portfolio.totalAmountEtf), currencies: mapMoneyValue(input.portfolio.totalAmountCurrencies), futures: mapMoneyValue(input.portfolio.totalAmountFutures), options: mapMoneyValue(input.portfolio.totalAmountOptions), structuredProducts: mapMoneyValue(input.portfolio.totalAmountSp), dfa: mapMoneyValue(input.portfolio.totalAmountDfa), portfolio: mapMoneyValue(input.portfolio.totalAmountPortfolio), }, yields: { expectedPercent: mapQuotationToNumber(input.portfolio.expectedYield), daily: mapMoneyValue(input.portfolio.dailyYield), dailyPercent: mapQuotationToNumber(input.portfolio.dailyYieldRelative), }, cash: (input.positions.money ?? []).map(mapMoneyValue).filter(isBrokerMoney), blockedCash: (input.positions.blocked ?? []).map(mapMoneyValue).filter(isBrokerMoney), asOf: new Date().toISOString(), }; } export function mapBrokerPositionsPage( input: { accountId: string; portfolio: TBankPortfolioResponse; instruments: Map>; cursor?: string; limit: number; }, ): BrokerPositionsPage { const allPositions = (input.portfolio.positions ?? []).map((position) => mapBrokerPosition({ position, instruments: input.instruments }), ); let startIndex = 0; if (input.cursor) { const found = allPositions.findIndex( (p) => p.positionUid === input.cursor, ); startIndex = found >= 0 ? found + 1 : allPositions.length; } const pageItems = allPositions.slice(startIndex, startIndex + input.limit); const hasNext = startIndex + input.limit < allPositions.length; const nextCursor = hasNext ? pageItems[pageItems.length - 1]?.positionUid ?? null : null; return { accountId: input.accountId, items: pageItems, nextCursor, hasNext, asOf: new Date().toISOString(), }; } ``` - [ ] **Step 2: Add `name` to `mapOperation`** Edit `apps/backend/src/modules/tbank/mappers/operation.mapper.ts`: Add `name: item.name ?? null,` after the `description` line in the mapOperation return object (line 110): ```ts description: item.description || item.name || null, name: item.name ?? null, ``` - [ ] **Step 3: Commit** ```bash git add apps/backend/src/modules/tbank/mappers/portfolio.mapper.ts \ apps/backend/src/modules/tbank/mappers/operation.mapper.ts git commit -m "feat(tbank): extract mapBrokerPosition, add mapBrokerPositionsPage, add name to operation" ``` --- ### Task 3: Backend — BrokerPortfolioService with getPositions() **Files:** - Modify: `apps/backend/src/modules/tbank/services/broker-portfolio.service.ts` - [ ] **Step 1: Add `getPositions()` method, remove positions from getPortfolio** Edit `apps/backend/src/modules/tbank/services/broker-portfolio.service.ts`: Add import for `BrokerPositionsPage`: ```ts import type { BrokerPortfolio, BrokerPositionsPage } from '../types/broker.types'; ``` Replace the file content to: 1. Keep `getPortfolio()` but remove positions from the mapped result (just don't include them — the mapper no longer returns them) 2. Add `getPositions()` method Full file: ```ts import { Injectable, NotFoundException } from '@nestjs/common'; import { CacheService } from '../../cache/cache.service'; import { mapBrokerPortfolio, mapBrokerPositionsPage } from '../mappers/portfolio.mapper'; import { TBANK_CACHE_KEYS } from '../tbank.config'; import type { BrokerPortfolio, BrokerPositionsPage } from '../types/broker.types'; import type { TBankInstrument, TBankPortfolioResponse, TBankPositionsResponse, } from '../types/tbank-proto.types'; import { BrokerAccountsService } from './broker-accounts.service'; import { BrokerInstrumentsService } from './broker-instruments.service'; import { TBankClientService } from './tbank-client.service'; @Injectable() export class BrokerPortfolioService { constructor( private readonly accountsService: BrokerAccountsService, private readonly instrumentsService: BrokerInstrumentsService, private readonly tbankClient: TBankClientService, private readonly cacheService: CacheService, ) {} async getPortfolio(accountId: string): Promise<{ data: BrokerPortfolio; meta: { fromCache: boolean; cachedAt: string | null }; }> { const account = await this.accountsService.findById(accountId); if (!account) throw new NotFoundException('Broker account not found'); const result = await this.cacheService.getOrFetch( TBANK_CACHE_KEYS.portfolio, [accountId], async () => { const operationsClient = this.tbankClient.getServiceClient('OperationsService') as any; const [portfolio, positions] = await Promise.all([ this.tbankClient.callUnary< { accountId: string; currency: string }, TBankPortfolioResponse >( 'OperationsService/GetPortfolio', operationsClient.getPortfolio.bind(operationsClient), { accountId, currency: 'RUB' }, ), this.tbankClient.callUnary<{ accountId: string }, TBankPositionsResponse>( 'OperationsService/GetPositions', operationsClient.getPositions.bind(operationsClient), { accountId }, ), ]); const instrumentMap = await this.buildInstrumentMap(portfolio); return mapBrokerPortfolio({ account, portfolio, positions, instruments: instrumentMap }); }, 'tbankPortfolioTtl', ); return { data: result.data, meta: { fromCache: result.fromCache, cachedAt: result.cachedAt }, }; } async getPositions( accountId: string, cursor?: string, limit = 10, ): Promise<{ data: BrokerPositionsPage; meta: { fromCache: boolean; cachedAt: string | null }; }> { const account = await this.accountsService.findById(accountId); if (!account) throw new NotFoundException('Broker account not found'); const result = await this.cacheService.getOrFetch( TBANK_CACHE_KEYS.positions, [accountId], async () => { const operationsClient = this.tbankClient.getServiceClient('OperationsService') as any; const portfolio = await this.tbankClient.callUnary< { accountId: string; currency: string }, TBankPortfolioResponse >( 'OperationsService/GetPortfolio', operationsClient.getPortfolio.bind(operationsClient), { accountId, currency: 'RUB' }, ); const instrumentMap = await this.buildInstrumentMap(portfolio); return mapBrokerPositionsPage({ accountId, portfolio, instruments: instrumentMap, cursor, limit }); }, 'tbankPositionsTtl', ); return { data: result.data, meta: { fromCache: result.fromCache, cachedAt: result.cachedAt }, }; } private async buildInstrumentMap( portfolio: TBankPortfolioResponse, ): Promise>> { const ids = Array.from( new Set( (portfolio.positions ?? []).map((position) => position.instrumentUid).filter(Boolean), ), ) as string[]; const results = await Promise.allSettled( ids.map(async (id) => [id, await this.instrumentsService.findByInstrumentUid(id)] as const), ); const entries = results.flatMap((result) => result.status === 'fulfilled' ? [result.value] : [], ); return new Map( entries.filter((entry): entry is readonly [string, TBankInstrument] => entry[1] !== null), ); } } ``` - [ ] **Step 2: Commit** ```bash git add apps/backend/src/modules/tbank/services/broker-portfolio.service.ts git commit -m "feat(tbank): add getPositions() method to BrokerPortfolioService" ``` --- ### Task 4: Backend — Controller + Envelope + Config for positions endpoint **Files:** - Modify: `apps/backend/src/modules/tbank/tbank.controller.ts` - Modify: `apps/backend/src/config/configuration.ts` - [ ] **Step 1: Add `GET /accounts/:accountId/positions` endpoint** Edit `apps/backend/src/modules/tbank/tbank.controller.ts`: Add imports: ```ts import { BrokerPositionsEnvelopeDto } from './dto/broker-envelope.dto'; import { BrokerPositionQueryDto } from './dto/broker-position-query.dto'; ``` Add after `getPortfolio` method: ```ts @Get('accounts/:accountId/positions') @ApiOperation({ summary: 'Get paginated T-Bank broker account positions' }) @ApiOkResponse({ type: BrokerPositionsEnvelopeDto }) async getPositions( @Param('accountId') accountId: string, @Query() query: BrokerPositionQueryDto, ) { const result = await this.brokerPortfolioService.getPositions( accountId, query.cursor, query.limit, ); return new ApiResponse(result.data, result.meta.fromCache, result.meta.cachedAt); } ``` - [ ] **Step 2: Create `BrokerPositionQueryDto`** Create `apps/backend/src/modules/tbank/dto/broker-position-query.dto.ts`: ```ts import { ApiPropertyOptional } from '@nestjs/swagger'; import { Type } from 'class-transformer'; import { IsNumber, IsOptional, IsString, Max, Min } from 'class-validator'; export class BrokerPositionQueryDto { @ApiPropertyOptional({ description: 'Cursor for pagination (positionUid)' }) @IsOptional() @IsString() cursor?: string; @ApiPropertyOptional({ default: 10 }) @IsOptional() @Type(() => Number) @IsNumber() @Min(1) @Max(100) limit?: number = 10; } ``` - [ ] **Step 3: Add `tbankPositionsTtl` to configuration** Edit `apps/backend/src/config/configuration.ts`: Add after `tbankOperationsTtl` (line 34): ```ts tbankPositionsTtl: parseInt(process.env.CACHE_TBANK_POSITIONS_TTL || '60', 10), ``` - [ ] **Step 4: Commit** ```bash git add apps/backend/src/modules/tbank/tbank.controller.ts \ apps/backend/src/modules/tbank/dto/broker-position-query.dto.ts \ apps/backend/src/config/configuration.ts git commit -m "feat(tbank): add GET /positions endpoint with cursor pagination" ``` --- ### Task 5: Backend — Update portfolio service tests + add positions tests **Files:** - Modify: `apps/backend/src/modules/tbank/services/broker-portfolio.service.spec.ts` - Modify: `apps/backend/src/modules/tbank/tbank.config.spec.ts` - [ ] **Step 1: Update tests — remove positions assertions, add getPositions tests** Edit `apps/backend/src/modules/tbank/services/broker-portfolio.service.spec.ts`: Replace the file with: ```ts import { NotFoundException } from '@nestjs/common'; import { CacheService } from '../../cache/cache.service'; import { BrokerAccountsService } from './broker-accounts.service'; import { BrokerInstrumentsService } from './broker-instruments.service'; import { BrokerPortfolioService } from './broker-portfolio.service'; import { TBankClientService } from './tbank-client.service'; describe('BrokerPortfolioService', () => { const accounts = { findById: vi.fn() } as unknown as BrokerAccountsService; const instruments = { findByInstrumentUid: vi.fn() } as unknown as BrokerInstrumentsService; const client = { getServiceClient: vi.fn(), callUnary: vi.fn() } as unknown as TBankClientService; const cache = { getOrFetch: vi.fn() } as unknown as CacheService; beforeEach(() => { vi.clearAllMocks(); }); it('throws 404 for excluded or missing account', async () => { vi.mocked(accounts.findById).mockResolvedValue(null); const service = new BrokerPortfolioService(accounts, instruments, client, cache); await expect(service.getPortfolio('missing')).rejects.toThrow(NotFoundException); }); it('fetches portfolio through cache without positions', async () => { vi.mocked(accounts.findById).mockResolvedValue({ id: 'acc-1', type: 'brokerage', name: 'Broker', status: 'ACCOUNT_STATUS_OPEN', openedAt: null, accessLevel: null, }); vi.mocked(cache.getOrFetch).mockImplementation( async (_prefix: string, _parts: string[], fetchFn: () => Promise) => ({ data: await fetchFn(), fromCache: false, cachedAt: null, }), ); vi.mocked(client.getServiceClient).mockReturnValue({ getPortfolio: vi.fn(), getPositions: vi.fn(), } as any); vi.mocked(client.callUnary) .mockResolvedValueOnce({ accountId: 'acc-1', totalAmountPortfolio: { currency: 'rub', units: '1000', nano: 0 }, positions: [], }) .mockResolvedValueOnce({ accountId: 'acc-1', money: [{ currency: 'rub', units: '1000', nano: 0 }], blocked: [], securities: [], }); const service = new BrokerPortfolioService(accounts, instruments, client, cache); const result = await service.getPortfolio('acc-1'); expect(result.data.account.id).toBe('acc-1'); expect(result.data.cash[0].value).toBe(1000); // positions not in portfolio anymore expect('positions' in result.data).toBe(false); expect(cache.getOrFetch).toHaveBeenCalledWith( 'tbank:portfolio', ['acc-1'], expect.any(Function), 'tbankPortfolioTtl', ); }); describe('getPositions', () => { it('throws 404 for missing account', async () => { vi.mocked(accounts.findById).mockResolvedValue(null); const service = new BrokerPortfolioService(accounts, instruments, client, cache); await expect(service.getPositions('missing')).rejects.toThrow(NotFoundException); }); it('returns first page of positions', async () => { vi.mocked(accounts.findById).mockResolvedValue({ id: 'acc-1', type: 'brokerage', name: 'Broker', status: 'ACCOUNT_STATUS_OPEN', openedAt: null, accessLevel: null, }); vi.mocked(cache.getOrFetch).mockImplementation( async (_prefix: string, _parts: string[], fetchFn: () => Promise) => ({ data: await fetchFn(), fromCache: false, cachedAt: null, }), ); vi.mocked(client.getServiceClient).mockReturnValue({ getPortfolio: vi.fn(), } as any); vi.mocked(client.callUnary).mockResolvedValueOnce({ accountId: 'acc-1', totalAmountPortfolio: { currency: 'rub', units: '1000', nano: 0 }, positions: [ { figi: 'figi-1', instrumentUid: 'uid-1', positionUid: 'pos-1', quantity: { units: '10', nano: 0 }, }, { figi: 'figi-2', instrumentUid: 'uid-2', positionUid: 'pos-2', quantity: { units: '20', nano: 0 }, }, ], }); const service = new BrokerPortfolioService(accounts, instruments, client, cache); const result = await service.getPositions('acc-1', undefined, 1); expect(result.data.accountId).toBe('acc-1'); expect(result.data.items).toHaveLength(1); expect(result.data.items[0].positionUid).toBe('pos-1'); expect(result.data.hasNext).toBe(true); expect(result.data.nextCursor).toBe('pos-1'); }); it('paginates using cursor', async () => { vi.mocked(accounts.findById).mockResolvedValue({ id: 'acc-1', type: 'brokerage', name: 'Broker', status: 'ACCOUNT_STATUS_OPEN', openedAt: null, accessLevel: null, }); vi.mocked(cache.getOrFetch).mockImplementation( async (_prefix: string, _parts: string[], fetchFn: () => Promise) => ({ data: await fetchFn(), fromCache: false, cachedAt: null, }), ); vi.mocked(client.getServiceClient).mockReturnValue({ getPortfolio: vi.fn(), } as any); vi.mocked(client.callUnary).mockResolvedValueOnce({ accountId: 'acc-1', totalAmountPortfolio: { currency: 'rub', units: '1000', nano: 0 }, positions: [ { figi: 'f1', instrumentUid: 'u1', positionUid: 'p1', quantity: { units: '10', nano: 0 } }, { figi: 'f2', instrumentUid: 'u2', positionUid: 'p2', quantity: { units: '20', nano: 0 } }, { figi: 'f3', instrumentUid: 'u3', positionUid: 'p3', quantity: { units: '30', nano: 0 } }, ], }); const service = new BrokerPortfolioService(accounts, instruments, client, cache); const result = await service.getPositions('acc-1', 'p1', 1); expect(result.data.items).toHaveLength(1); expect(result.data.items[0].positionUid).toBe('p2'); expect(result.data.nextCursor).toBe('p2'); expect(result.data.hasNext).toBe(true); }); it('returns last page with hasNext=false', async () => { vi.mocked(accounts.findById).mockResolvedValue({ id: 'acc-1', type: 'brokerage', name: 'Broker', status: 'ACCOUNT_STATUS_OPEN', openedAt: null, accessLevel: null, }); vi.mocked(cache.getOrFetch).mockImplementation( async (_prefix: string, _parts: string[], fetchFn: () => Promise) => ({ data: await fetchFn(), fromCache: false, cachedAt: null, }), ); vi.mocked(client.getServiceClient).mockReturnValue({ getPortfolio: vi.fn(), } as any); vi.mocked(client.callUnary).mockResolvedValueOnce({ accountId: 'acc-1', totalAmountPortfolio: { currency: 'rub', units: '1000', nano: 0 }, positions: [ { figi: 'f1', instrumentUid: 'u1', positionUid: 'p1', quantity: { units: '10', nano: 0 } }, ], }); const service = new BrokerPortfolioService(accounts, instruments, client, cache); const result = await service.getPositions('acc-1', undefined, 10); expect(result.data.items).toHaveLength(1); expect(result.data.hasNext).toBe(false); expect(result.data.nextCursor).toBeNull(); }); it('caches positions with tbankPositionsTtl', async () => { vi.mocked(accounts.findById).mockResolvedValue({ id: 'acc-1', type: 'brokerage', name: 'Broker', status: 'ACCOUNT_STATUS_OPEN', openedAt: null, accessLevel: null, }); vi.mocked(cache.getOrFetch).mockImplementation( async (_prefix: string, _parts: string[], fetchFn: () => Promise) => ({ data: await fetchFn(), fromCache: false, cachedAt: null, }), ); vi.mocked(client.getServiceClient).mockReturnValue({ getPortfolio: vi.fn(), } as any); vi.mocked(client.callUnary).mockResolvedValueOnce({ accountId: 'acc-1', totalAmountPortfolio: { currency: 'rub', units: '1000', nano: 0 }, positions: [], }); const service = new BrokerPortfolioService(accounts, instruments, client, cache); await service.getPositions('acc-1'); expect(cache.getOrFetch).toHaveBeenCalledWith( 'tbank:positions', ['acc-1'], expect.any(Function), 'tbankPositionsTtl', ); }); }); }); ``` - [ ] **Step 2: Run tests** ```bash npx vitest run apps/backend/src/modules/tbank/services/broker-portfolio.service.spec.ts -w apps/backend ``` Expected: ALL PASS - [ ] **Step 3: Add tbankPositionsTtl to tbank config test** Edit `apps/backend/src/modules/tbank/tbank.config.spec.ts`: Add to the first `it` block after line 23: ```ts expect(config.cache.tbankPositionsTtl).toBe(60); ``` Add to the second `it` block — set env and assert: ```ts process.env.CACHE_TBANK_POSITIONS_TTL = '45'; ``` And add assertion: ```ts expect(config.cache.tbankPositionsTtl).toBe(45); ``` - [ ] **Step 4: Run config tests** ```bash npx vitest run apps/backend/src/modules/tbank/tbank.config.spec.ts -w apps/backend ``` Expected: ALL PASS - [ ] **Step 5: Commit** ```bash git add apps/backend/src/modules/tbank/services/broker-portfolio.service.spec.ts \ apps/backend/src/modules/tbank/tbank.config.spec.ts git commit -m "test(tbank): update portfolio tests, add getPositions tests" ``` --- ### Task 6: Frontend — CSS shimmer animations **Files:** - Modify: `apps/frontend/src/styles.css` - [ ] **Step 1: Add shimmer keyframes and skeleton class** Append to `apps/frontend/src/styles.css`: ```css @keyframes shimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } } .skeleton { background: linear-gradient( 90deg, var(--color-bg) 25%, #f0f0f0 50%, var(--color-bg) 75% ); background-size: 200% 100%; animation: shimmer 1.5s ease-in-out infinite; border-radius: 4px; } ``` - [ ] **Step 2: Commit** ```bash git add apps/frontend/src/styles.css git commit -m "feat(frontend): add shimmer animation and .skeleton CSS class" ``` --- ### Task 7: Frontend — Types, API, and hooks **Files:** - Create: `apps/frontend/src/hooks/useBrokerPositions.ts` - Modify: `apps/frontend/src/api/responses.ts` - Modify: `apps/frontend/src/api/broker.ts` - Modify: `apps/frontend/src/api/broker.test.ts` - [ ] **Step 1: Update frontend types** Edit `apps/frontend/src/api/responses.ts`: Remove `positions: BrokerPosition[]` from `BrokerPortfolio`. Add after `BrokerOperationsPage`: ```ts export interface BrokerPositionsPage { accountId: string; items: BrokerPosition[]; nextCursor: string | null; hasNext: boolean; asOf: string; } ``` Add `name: string | null` to `BrokerOperation` (after `description`): ```ts description: string | null; name: string | null; ``` - [ ] **Step 2: Add `getBrokerPositions` API function** Edit `apps/frontend/src/api/broker.ts`: Add import: ```ts import type { ApiResponseMeta, BrokerAccount, BrokerOperationsPage, BrokerPortfolio, BrokerPositionsPage, } from './responses'; ``` Add after `getBrokerOperations`: ```ts export function getBrokerPositions( accountId: string, query: { cursor?: string; limit?: number } = {}, ): Promise<{ data: BrokerPositionsPage; meta: ApiResponseMeta }> { return request( `/api/v1/broker/accounts/${encodeURIComponent(accountId)}/positions`, { cursor: query.cursor, limit: query.limit ? String(query.limit) : undefined, }, ); } ``` - [ ] **Step 3: Update broker.test.ts — add positions test** Edit `apps/frontend/src/api/broker.test.ts`: Replace the file: ```ts import { afterEach, describe, expect, it, vi } from 'vitest'; import { getBrokerOperations, getBrokerPositions } from './broker'; describe('broker api', () => { afterEach(() => { vi.restoreAllMocks(); }); it('serializes operations query parameters', async () => { vi.spyOn(globalThis, 'fetch').mockResolvedValue({ ok: true, json: async () => ({ data: { data: { accountId: 'acc-1', items: [], nextCursor: null, hasNext: false, asOf: 'now' }, meta: { fromCache: false, cachedAt: null }, }, }), } as Response); await getBrokerOperations('acc-1', { cursor: 'c1', limit: 50 }); expect(fetch).toHaveBeenCalledWith( expect.stringContaining('/api/v1/broker/accounts/acc-1/operations?cursor=c1&limit=50'), expect.any(Object), ); }); it('serializes positions query parameters', async () => { vi.spyOn(globalThis, 'fetch').mockResolvedValue({ ok: true, json: async () => ({ data: { data: { accountId: 'acc-1', items: [], nextCursor: null, hasNext: false, asOf: 'now' }, meta: { fromCache: false, cachedAt: null }, }, }), } as Response); await getBrokerPositions('acc-1', { cursor: 'pos-1', limit: 5 }); expect(fetch).toHaveBeenCalledWith( expect.stringContaining('/api/v1/broker/accounts/acc-1/positions?cursor=pos-1&limit=5'), expect.any(Object), ); }); }); ``` - [ ] **Step 4: Create `useBrokerPositions` hook** Create `apps/frontend/src/hooks/useBrokerPositions.ts`: ```ts import { keepPreviousData, useQuery } from '@tanstack/react-query'; import { getBrokerPositions } from '../api/broker'; import type { BrokerPositionsPage } from '../api/responses'; export function useBrokerPositions( accountId: string | undefined, query: { cursor?: string; limit?: number } = {}, ) { return useQuery({ queryKey: ['broker', 'positions', accountId, query], enabled: Boolean(accountId), queryFn: async () => (await getBrokerPositions(accountId!, query)).data, staleTime: 60_000, retry: 2, placeholderData: keepPreviousData, refetchOnWindowFocus: false, }); } ``` - [ ] **Step 5: Commit** ```bash git add apps/frontend/src/api/responses.ts \ apps/frontend/src/api/broker.ts \ apps/frontend/src/api/broker.test.ts \ apps/frontend/src/hooks/useBrokerPositions.ts git commit -m "feat(frontend): add BrokerPositionsPage types, API, and hook" ``` --- ### Task 8: Frontend — SkeletonBlock and TableSkeleton components **Files:** - Create: `apps/frontend/src/components/SkeletonBlock.tsx` - Create: `apps/frontend/src/components/TableSkeleton.tsx` - [ ] **Step 1: Create `SkeletonBlock`** Create `apps/frontend/src/components/SkeletonBlock.tsx`: ```tsx export function SkeletonBlock({ width, height, borderRadius = 4 }: { width?: string | number; height?: string | number; borderRadius?: number; }) { return (
); } ``` - [ ] **Step 2: Create `TableSkeleton`** Create `apps/frontend/src/components/TableSkeleton.tsx`: ```tsx import { SkeletonBlock } from './SkeletonBlock'; const tdStyle = { borderBottom: '1px solid #eeeeee', padding: '10px 8px', verticalAlign: 'top', } satisfies React.CSSProperties; type Column = { width: string }; export function TableSkeleton({ rows = 5, columns }: { rows?: number; columns: Column[] }) { return ( {Array.from({ length: rows }).map((_, i) => ( {columns.map((col, j) => ( ))} ))} ); } ``` - [ ] **Step 3: Commit** ```bash git add apps/frontend/src/components/SkeletonBlock.tsx \ apps/frontend/src/components/TableSkeleton.tsx git commit -m "feat(frontend): add SkeletonBlock and TableSkeleton components" ``` --- ### Task 9: Frontend — BrokerPositionsSection with pagination + skeleton **Files:** - Modify: `apps/frontend/src/pages/broker/BrokerPositionsSection.tsx` - [ ] **Step 1: Rewrite BrokerPositionsSection with pagination props** Replace `apps/frontend/src/pages/broker/BrokerPositionsSection.tsx`: ```tsx import { Link } from 'react-router-dom'; import type { BrokerMoney, BrokerPosition } from '../../api/responses'; import { getBrokerInstrumentPath, getBrokerPositionGroup } from './brokerDisplay'; import { TableSkeleton } from '../../components/TableSkeleton'; type BrokerPositionGroupConfig = { key: 'shares' | 'bonds' | 'other'; title: string; }; const GROUPS: BrokerPositionGroupConfig[] = [ { key: 'shares', title: 'Акции' }, { key: 'bonds', title: 'Облигации' }, { key: 'other', title: 'Другие инструменты' }, ]; const tableStyle = { width: '100%', borderCollapse: 'collapse', fontSize: 14, } satisfies React.CSSProperties; const thStyle = { borderBottom: '1px solid #e0e0e0', color: 'var(--color-text-secondary)', fontWeight: 600, padding: '10px 8px', } satisfies React.CSSProperties; const tdStyle = { borderBottom: '1px solid #eeeeee', padding: '10px 8px', verticalAlign: 'top', } satisfies React.CSSProperties; const pagButtonStyle = { padding: '6px 14px', borderRadius: 6, border: '1px solid #e0e0e0', background: 'var(--color-surface)', color: 'var(--color-text)', fontSize: 14, fontWeight: 600, cursor: 'pointer', lineHeight: 1.4, } satisfies React.CSSProperties; const pagButtonDisabledStyle = { ...pagButtonStyle, opacity: 0.35, cursor: 'not-allowed', } satisfies React.CSSProperties; function formatMoney(value: BrokerMoney | null | undefined) { if (!value) return '-'; return new Intl.NumberFormat('ru-RU', { style: 'currency', currency: value.currency || 'RUB', maximumFractionDigits: 2, }).format(value.value); } function formatQuantity(value: number | null | undefined) { return value == null ? '-' : value.toLocaleString('ru-RU'); } function PositionTicker({ position }: { position: BrokerPosition }) { const label = position.ticker || position.figi || '-'; const path = getBrokerInstrumentPath({ ticker: position.ticker, instrumentType: position.instrumentType, classCode: position.classCode, }); if (!path || label === '-') { return {label}; } return ( {label} ); } function PositionTable({ title, positions }: { title: string; positions: BrokerPosition[] }) { return (

{title}

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

Позиции

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

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

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

{group.title}

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

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

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

{portfolio.data.account.name}

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

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

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

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

{[1, 2, 3].map((i) => (
))}
); } ``` - [ ] **Step 2: Commit** ```bash git add apps/frontend/src/pages/broker/BrokerAccountsPage.tsx git commit -m "feat(frontend): add skeleton cards to broker accounts page" ``` --- ### Task 13: Frontend — Update BrokerPages tests **Files:** - Modify: `apps/frontend/src/pages/broker/BrokerPages.test.tsx` - [ ] **Step 1: Update tests — remove positions from portfolio mock, add positions hook mock** Edit `apps/frontend/src/pages/broker/BrokerPages.test.tsx`: Add import: ```tsx import * as positionsHook from '../../hooks/useBrokerPositions'; ``` Update the portfolio mock in "renders positions and operations for account detail" (line 54-91): Remove `positions` from the portfolio data mock: ```tsx vi.spyOn(portfolioHook, 'useBrokerPortfolio').mockReturnValue({ data: { account: { id: 'acc-1', type: 'brokerage', name: 'Broker', status: 'ACCOUNT_STATUS_OPEN', openedAt: null, accessLevel: null, }, totals: { portfolio: { currency: 'RUB', units: '1000', nano: 0, value: 1000 } }, yields: { expectedPercent: 5, daily: null, dailyPercent: null }, cash: [{ currency: 'RUB', units: '100', nano: 0, value: 100 }], blockedCash: [], asOf: '2026-06-16T00:00:00.000Z', }, isLoading: false, error: null, } as any); ``` Add positions mock: ```tsx vi.spyOn(positionsHook, 'useBrokerPositions').mockReturnValue({ data: { accountId: 'acc-1', items: [ { figi: null, instrumentUid: 'uid-1', positionUid: null, ticker: 'SBER', classCode: 'TQBR', instrumentType: 'share', name: 'Sberbank', quantity: 10, blockedLots: null, currentPrice: null, currentValue: { currency: 'RUB', units: '1000', nano: 0, value: 1000 }, averagePositionPrice: null, expectedYieldPercent: null, dailyYield: null, }, ], nextCursor: null, hasNext: false, asOf: '2026-06-16T00:00:00.000Z', }, isLoading: false, error: null, } as any); ``` Repeat for the other tests: - "renders broker positions as separate linked stock and bond tables" (line 139): remove `positions` from portfolio mock, add positions hook mock - "renders broker operations with Russian labels" (line 225): remove `positions` from portfolio mock, add positions hook mock - "requests broker operations by cursor" (line 321): remove `positions` from portfolio mock, add positions hook mock For the table test (line 139), add a richer positions mock: ```tsx vi.spyOn(positionsHook, 'useBrokerPositions').mockReturnValue({ data: { accountId: 'acc-1', items: [ { figi: null, instrumentUid: 'share-uid', positionUid: null, ticker: 'SBER', classCode: 'TQBR', instrumentType: 'share', name: 'Sberbank', quantity: 10, blockedLots: null, currentPrice: { currency: 'RUB', units: '250', nano: 0, value: 250 }, currentValue: { currency: 'RUB', units: '2500', nano: 0, value: 2500 }, averagePositionPrice: null, expectedYieldPercent: 20, dailyYield: null, }, { figi: null, instrumentUid: 'bond-uid', positionUid: null, ticker: 'SU26238RMFS5', classCode: 'TQOB', instrumentType: 'bond', name: 'ОФЗ 26238', quantity: 2, blockedLots: null, currentPrice: { currency: 'RUB', units: '900', nano: 0, value: 900 }, currentValue: { currency: 'RUB', units: '1800', nano: 0, value: 1800 }, averagePositionPrice: null, expectedYieldPercent: 10, dailyYield: null, }, ], nextCursor: null, hasNext: false, asOf: '2026-06-17T00:00:00.000Z', }, isLoading: false, error: null, } as any); ``` For the two operation tests (line 225 and 321), provide empty positions list: ```tsx vi.spyOn(positionsHook, 'useBrokerPositions').mockReturnValue({ data: { accountId: 'acc-1', items: [], nextCursor: null, hasNext: false, asOf: '2026-06-17T00:00:00.000Z', }, isLoading: false, error: null, } as any); ``` - [ ] **Step 2: Run tests** ```bash npx vitest run apps/frontend/src/pages/broker/BrokerPages.test.tsx -w apps/frontend ``` Expected: ALL PASS - [ ] **Step 3: Run all frontend tests** ```bash npm run test:frontend ``` Expected: ALL PASS - [ ] **Step 4: Run all backend tests** ```bash npm run test:backend ``` Expected: ALL PASS - [ ] **Step 5: Run lint** ```bash npm run lint ``` Expected: ALL PASS - [ ] **Step 6: Build frontend** ```bash npm run build:frontend ``` Expected: SUCCESS - [ ] **Step 7: Commit** ```bash git add apps/frontend/src/pages/broker/BrokerPages.test.tsx git commit -m "test(frontend): update broker tests for positions hook and removal from portfolio" ``` --- ### Task 14: Full build and test verification - [ ] **Step 1: Run full backend test suite** ```bash npm run test:backend ``` - [ ] **Step 2: Run full frontend test suite** ```bash npm run test:frontend ``` - [ ] **Step 3: Run lint** ```bash npm run lint ``` - [ ] **Step 4: Build frontend** ```bash npm run build:frontend ``` - [ ] **Step 5: Build backend** ```bash npm run build:backend ``` - [ ] **Step 6: Final commit if fixes needed** ```bash git add -A git commit -m "chore: fix lint and build after broker portfolio enhancements" ```