Sergey Krylov 1dc27a6e9b
All checks were successful
CI / ci (push) Successful in 3m13s
feat: добавил инструкции к SDD подходу
2026-06-19 07:38:33 +03:00

61 KiB
Raw Permalink Blame History

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:

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

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:

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

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 mapBrokerPosition from mapBrokerPortfolio, 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 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):

    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:

  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:

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/positions endpoint

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 tbankPositionsTtl to 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 getBrokerPositions API 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 useBrokerPositions hook

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

    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"