- Split single p-queue (5 req/s) into 3 isolated queues: operations (5/s), instruments (20/s), users (5/s) - Removed dead instruments param from mapBrokerPortfolio - portfolio/positions endpoints share raw GetPortfolio cache - Docs: T_BANK_INSTRUMENTS_RATE_LIMIT, CACHE_TBANK_POSITIONS_TTL, rate limiting section in tbank-invest.md
1997 lines
61 KiB
Markdown
1997 lines
61 KiB
Markdown
# Broker Portfolio Enhancements Implementation Plan
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) for syntax tracking.
|
||
|
||
**Goal:** Add cursor-based pagination for broker positions, shimmer skeleton loading, and instrument name display in operations.
|
||
|
||
**Architecture:** Backend extracts positions from portfolio into a new paginated `GET /positions` endpoint. Frontend gets a `useBrokerPositions` hook, `SkeletonBlock`/`TableSkeleton` components, and shimmer CSS animations. The `name` field from T-Bank's `OperationItem` is mapped through to the frontend.
|
||
|
||
**Tech Stack:** NestJS (backend), React 18 + TanStack Query v5 (frontend), CSS custom properties
|
||
|
||
---
|
||
|
||
### Task 1: Backend — Types and DTOs for positions page + operation name
|
||
|
||
**Files:**
|
||
- Modify: `apps/backend/src/modules/tbank/types/broker.types.ts`
|
||
- Create: `apps/backend/src/modules/tbank/dto/broker-position-response.dto.ts`
|
||
- Create: `apps/backend/src/modules/tbank/dto/broker-positions-page-response.dto.ts`
|
||
- Modify: `apps/backend/src/modules/tbank/dto/broker-portfolio-response.dto.ts`
|
||
- Modify: `apps/backend/src/modules/tbank/dto/broker-operation-response.dto.ts`
|
||
- Modify: `apps/backend/src/modules/tbank/dto/broker-envelope.dto.ts`
|
||
|
||
- [ ] **Step 1: Remove `positions` from `BrokerPortfolio` type, add `BrokerPositionsPage`, add `name` to `BrokerOperation`**
|
||
|
||
Edit `apps/backend/src/modules/tbank/types/broker.types.ts`:
|
||
|
||
Remove `positions: BrokerPosition[]` from `BrokerPortfolio`.
|
||
|
||
Add after `BrokerPosition` type:
|
||
```ts
|
||
export type BrokerPositionsPage = {
|
||
accountId: string;
|
||
items: BrokerPosition[];
|
||
nextCursor: string | null;
|
||
hasNext: boolean;
|
||
asOf: string;
|
||
};
|
||
```
|
||
|
||
Add `name` to `BrokerOperation`:
|
||
```ts
|
||
export type BrokerOperation = {
|
||
cursor: string | null;
|
||
accountId: string;
|
||
id: string | null;
|
||
parentOperationId: string | null;
|
||
date: string | null;
|
||
type: string;
|
||
category: BrokerOperationCategory;
|
||
description: string | null;
|
||
name: string | null;
|
||
state: string | null;
|
||
instrumentUid: string | null;
|
||
figi: string | null;
|
||
ticker: string | null;
|
||
classCode: string | null;
|
||
instrumentType: string | null;
|
||
payment: BrokerMoney | null;
|
||
price: BrokerMoney | null;
|
||
commission: BrokerMoney | null;
|
||
yield: BrokerMoney | null;
|
||
accruedInt: BrokerMoney | null;
|
||
quantity: number | null;
|
||
quantityDone: number | null;
|
||
};
|
||
```
|
||
|
||
- [ ] **Step 2: Create `BrokerPositionResponseDto` (extracted from portfolio DTO)**
|
||
|
||
Create `apps/backend/src/modules/tbank/dto/broker-position-response.dto.ts`:
|
||
```ts
|
||
import { ApiProperty } from '@nestjs/swagger';
|
||
import { BrokerMoneyDto } from './broker-money.dto';
|
||
|
||
export class BrokerPositionResponseDto {
|
||
@ApiProperty({ nullable: true })
|
||
figi!: string | null;
|
||
|
||
@ApiProperty({ nullable: true })
|
||
instrumentUid!: string | null;
|
||
|
||
@ApiProperty({ nullable: true })
|
||
positionUid!: string | null;
|
||
|
||
@ApiProperty({ nullable: true })
|
||
ticker!: string | null;
|
||
|
||
@ApiProperty({ nullable: true })
|
||
classCode!: string | null;
|
||
|
||
@ApiProperty({ nullable: true })
|
||
instrumentType!: string | null;
|
||
|
||
@ApiProperty({ nullable: true })
|
||
name!: string | null;
|
||
|
||
@ApiProperty({ nullable: true })
|
||
quantity!: number | null;
|
||
|
||
@ApiProperty({ nullable: true })
|
||
blockedLots!: number | null;
|
||
|
||
@ApiProperty({ type: BrokerMoneyDto, nullable: true })
|
||
currentPrice!: BrokerMoneyDto | null;
|
||
|
||
@ApiProperty({ type: BrokerMoneyDto, nullable: true })
|
||
currentValue!: BrokerMoneyDto | null;
|
||
|
||
@ApiProperty({ type: BrokerMoneyDto, nullable: true })
|
||
averagePositionPrice!: BrokerMoneyDto | null;
|
||
|
||
@ApiProperty({ nullable: true })
|
||
expectedYieldPercent!: number | null;
|
||
|
||
@ApiProperty({ type: BrokerMoneyDto, nullable: true })
|
||
dailyYield!: BrokerMoneyDto | null;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Create `BrokerPositionsPageResponseDto`**
|
||
|
||
Create `apps/backend/src/modules/tbank/dto/broker-positions-page-response.dto.ts`:
|
||
```ts
|
||
import { ApiProperty } from '@nestjs/swagger';
|
||
import { BrokerPositionResponseDto } from './broker-position-response.dto';
|
||
|
||
export class BrokerPositionsPageResponseDto {
|
||
@ApiProperty()
|
||
accountId!: string;
|
||
|
||
@ApiProperty({ type: [BrokerPositionResponseDto] })
|
||
items!: BrokerPositionResponseDto[];
|
||
|
||
@ApiProperty({ nullable: true })
|
||
nextCursor!: string | null;
|
||
|
||
@ApiProperty()
|
||
hasNext!: boolean;
|
||
|
||
@ApiProperty()
|
||
asOf!: string;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Remove `positions` from `BrokerPortfolioResponseDto`**
|
||
|
||
Edit `apps/backend/src/modules/tbank/dto/broker-portfolio-response.dto.ts`:
|
||
|
||
Remove the import of `BrokerPositionResponseDto` (no longer needed here since `BrokerPositionResponseDto` is now in its own file).
|
||
|
||
Remove the entire `BrokerPositionResponseDto` class.
|
||
|
||
Remove the `positions` property from `BrokerPortfolioResponseDto`:
|
||
```ts
|
||
export class BrokerPortfolioResponseDto {
|
||
@ApiProperty({ type: BrokerAccountResponseDto })
|
||
account!: BrokerAccountResponseDto;
|
||
|
||
@ApiProperty({ type: BrokerPortfolioTotalsDto })
|
||
totals!: BrokerPortfolioTotalsDto;
|
||
|
||
@ApiProperty({ type: BrokerPortfolioYieldsDto })
|
||
yields!: BrokerPortfolioYieldsDto;
|
||
|
||
@ApiProperty({ type: [BrokerMoneyDto] })
|
||
cash!: BrokerMoneyDto[];
|
||
|
||
@ApiProperty({ type: [BrokerMoneyDto] })
|
||
blockedCash!: BrokerMoneyDto[];
|
||
|
||
@ApiProperty()
|
||
asOf!: string;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: Add `name` to `BrokerOperationResponseDto`**
|
||
|
||
Edit `apps/backend/src/modules/tbank/dto/broker-operation-response.dto.ts`:
|
||
|
||
Add after `description`:
|
||
```ts
|
||
@ApiProperty({ nullable: true })
|
||
name!: string | null;
|
||
```
|
||
|
||
- [ ] **Step 6: Add `BrokerPositionsEnvelopeDto` to envelope**
|
||
|
||
Edit `apps/backend/src/modules/tbank/dto/broker-envelope.dto.ts`:
|
||
|
||
Add import:
|
||
```ts
|
||
import { BrokerPositionsPageResponseDto } from './broker-positions-page-response.dto';
|
||
```
|
||
|
||
Add after `BrokerOperationsEnvelopeDto`:
|
||
```ts
|
||
export class BrokerPositionsEnvelopeDto {
|
||
@ApiProperty({ type: BrokerPositionsPageResponseDto })
|
||
data!: BrokerPositionsPageResponseDto;
|
||
|
||
@ApiProperty({ type: BrokerResponseMetaDto })
|
||
meta!: BrokerResponseMetaDto;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 7: Commit**
|
||
|
||
```bash
|
||
git add apps/backend/src/modules/tbank/types/broker.types.ts \
|
||
apps/backend/src/modules/tbank/dto/broker-position-response.dto.ts \
|
||
apps/backend/src/modules/tbank/dto/broker-positions-page-response.dto.ts \
|
||
apps/backend/src/modules/tbank/dto/broker-portfolio-response.dto.ts \
|
||
apps/backend/src/modules/tbank/dto/broker-operation-response.dto.ts \
|
||
apps/backend/src/modules/tbank/dto/broker-envelope.dto.ts
|
||
git commit -m "feat(tbank): add positions page types/DTOs and operation name field"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 2: Backend — Mappers (separate positions, add name to operations)
|
||
|
||
**Files:**
|
||
- Modify: `apps/backend/src/modules/tbank/mappers/portfolio.mapper.ts`
|
||
- Modify: `apps/backend/src/modules/tbank/mappers/operation.mapper.ts`
|
||
|
||
- [ ] **Step 1: Extract `mapBrokerPosition` from `mapBrokerPortfolio`, remove positions from portfolio mapping**
|
||
|
||
Edit `apps/backend/src/modules/tbank/mappers/portfolio.mapper.ts`:
|
||
|
||
Replace the file content with:
|
||
```ts
|
||
import type {
|
||
BrokerAccount,
|
||
BrokerMoney,
|
||
BrokerPortfolio,
|
||
BrokerPosition,
|
||
BrokerPositionsPage,
|
||
} from '../types/broker.types';
|
||
import type {
|
||
TBankInstrument,
|
||
TBankPortfolioResponse,
|
||
TBankPositionsResponse,
|
||
} from '../types/tbank-proto.types';
|
||
import { mapMoneyValue, mapQuotationToNumber } from './money.mapper';
|
||
|
||
type MapBrokerPortfolioInput = {
|
||
account: BrokerAccount;
|
||
portfolio: TBankPortfolioResponse;
|
||
positions: TBankPositionsResponse;
|
||
instruments: Map<string, Partial<TBankInstrument>>;
|
||
};
|
||
|
||
function isBrokerMoney(value: BrokerMoney | null): value is BrokerMoney {
|
||
return value !== null;
|
||
}
|
||
|
||
export function mapBrokerPosition(
|
||
input: {
|
||
position: { figi?: string; instrumentUid?: string; positionUid?: string; ticker?: string; classCode?: string; instrumentType?: string; quantity?: { units?: string; nano?: number }; blockedLots?: { units?: string; nano?: number }; currentPrice?: { currency?: string; units?: string; nano?: number }; averagePositionPrice?: { currency?: string; units?: string; nano?: number }; expectedYield?: { units?: string; nano?: number }; dailyYield?: { currency?: string; units?: string; nano?: number } };
|
||
instruments: Map<string, Partial<TBankInstrument>>;
|
||
},
|
||
): BrokerPosition {
|
||
const quantity = mapQuotationToNumber(input.position.quantity);
|
||
const currentPrice = mapMoneyValue(input.position.currentPrice);
|
||
const currentValue =
|
||
currentPrice && quantity !== null
|
||
? {
|
||
...currentPrice,
|
||
units: String(Math.trunc(currentPrice.value * quantity)),
|
||
nano: 0,
|
||
value: Number((currentPrice.value * quantity).toFixed(9)),
|
||
}
|
||
: null;
|
||
const instrument =
|
||
(input.position.instrumentUid && input.instruments.get(input.position.instrumentUid)) ||
|
||
(input.position.positionUid && input.instruments.get(input.position.positionUid)) ||
|
||
undefined;
|
||
|
||
return {
|
||
figi: input.position.figi ?? null,
|
||
instrumentUid: input.position.instrumentUid ?? null,
|
||
positionUid: input.position.positionUid ?? null,
|
||
ticker: input.position.ticker || instrument?.ticker || null,
|
||
classCode: input.position.classCode || instrument?.classCode || null,
|
||
instrumentType: input.position.instrumentType || instrument?.instrumentType || null,
|
||
name: instrument?.name ?? null,
|
||
quantity,
|
||
blockedLots: mapQuotationToNumber(input.position.blockedLots),
|
||
currentPrice,
|
||
currentValue,
|
||
averagePositionPrice: mapMoneyValue(input.position.averagePositionPrice),
|
||
expectedYieldPercent: mapQuotationToNumber(input.position.expectedYield),
|
||
dailyYield: mapMoneyValue(input.position.dailyYield),
|
||
};
|
||
}
|
||
|
||
export function mapBrokerPortfolio(input: MapBrokerPortfolioInput): BrokerPortfolio {
|
||
return {
|
||
account: input.account,
|
||
totals: {
|
||
shares: mapMoneyValue(input.portfolio.totalAmountShares),
|
||
bonds: mapMoneyValue(input.portfolio.totalAmountBonds),
|
||
etf: mapMoneyValue(input.portfolio.totalAmountEtf),
|
||
currencies: mapMoneyValue(input.portfolio.totalAmountCurrencies),
|
||
futures: mapMoneyValue(input.portfolio.totalAmountFutures),
|
||
options: mapMoneyValue(input.portfolio.totalAmountOptions),
|
||
structuredProducts: mapMoneyValue(input.portfolio.totalAmountSp),
|
||
dfa: mapMoneyValue(input.portfolio.totalAmountDfa),
|
||
portfolio: mapMoneyValue(input.portfolio.totalAmountPortfolio),
|
||
},
|
||
yields: {
|
||
expectedPercent: mapQuotationToNumber(input.portfolio.expectedYield),
|
||
daily: mapMoneyValue(input.portfolio.dailyYield),
|
||
dailyPercent: mapQuotationToNumber(input.portfolio.dailyYieldRelative),
|
||
},
|
||
cash: (input.positions.money ?? []).map(mapMoneyValue).filter(isBrokerMoney),
|
||
blockedCash: (input.positions.blocked ?? []).map(mapMoneyValue).filter(isBrokerMoney),
|
||
asOf: new Date().toISOString(),
|
||
};
|
||
}
|
||
|
||
export function mapBrokerPositionsPage(
|
||
input: {
|
||
accountId: string;
|
||
portfolio: TBankPortfolioResponse;
|
||
instruments: Map<string, Partial<TBankInstrument>>;
|
||
cursor?: string;
|
||
limit: number;
|
||
},
|
||
): BrokerPositionsPage {
|
||
const allPositions = (input.portfolio.positions ?? []).map((position) =>
|
||
mapBrokerPosition({ position, instruments: input.instruments }),
|
||
);
|
||
|
||
let startIndex = 0;
|
||
if (input.cursor) {
|
||
const found = allPositions.findIndex(
|
||
(p) => p.positionUid === input.cursor,
|
||
);
|
||
startIndex = found >= 0 ? found + 1 : allPositions.length;
|
||
}
|
||
|
||
const pageItems = allPositions.slice(startIndex, startIndex + input.limit);
|
||
const hasNext = startIndex + input.limit < allPositions.length;
|
||
const nextCursor = hasNext ? pageItems[pageItems.length - 1]?.positionUid ?? null : null;
|
||
|
||
return {
|
||
accountId: input.accountId,
|
||
items: pageItems,
|
||
nextCursor,
|
||
hasNext,
|
||
asOf: new Date().toISOString(),
|
||
};
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Add `name` to `mapOperation`**
|
||
|
||
Edit `apps/backend/src/modules/tbank/mappers/operation.mapper.ts`:
|
||
|
||
Add `name: item.name ?? null,` after the `description` line in the mapOperation return object (line 110):
|
||
```ts
|
||
description: item.description || item.name || null,
|
||
name: item.name ?? null,
|
||
```
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add apps/backend/src/modules/tbank/mappers/portfolio.mapper.ts \
|
||
apps/backend/src/modules/tbank/mappers/operation.mapper.ts
|
||
git commit -m "feat(tbank): extract mapBrokerPosition, add mapBrokerPositionsPage, add name to operation"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 3: Backend — BrokerPortfolioService with getPositions()
|
||
|
||
**Files:**
|
||
- Modify: `apps/backend/src/modules/tbank/services/broker-portfolio.service.ts`
|
||
|
||
- [ ] **Step 1: Add `getPositions()` method, remove positions from getPortfolio**
|
||
|
||
Edit `apps/backend/src/modules/tbank/services/broker-portfolio.service.ts`:
|
||
|
||
Add import for `BrokerPositionsPage`:
|
||
```ts
|
||
import type { BrokerPortfolio, BrokerPositionsPage } from '../types/broker.types';
|
||
```
|
||
|
||
Replace the file content to:
|
||
1. Keep `getPortfolio()` but remove positions from the mapped result (just don't include them — the mapper no longer returns them)
|
||
2. Add `getPositions()` method
|
||
|
||
Full file:
|
||
```ts
|
||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||
import { CacheService } from '../../cache/cache.service';
|
||
import { mapBrokerPortfolio, mapBrokerPositionsPage } from '../mappers/portfolio.mapper';
|
||
import { TBANK_CACHE_KEYS } from '../tbank.config';
|
||
import type { BrokerPortfolio, BrokerPositionsPage } from '../types/broker.types';
|
||
import type {
|
||
TBankInstrument,
|
||
TBankPortfolioResponse,
|
||
TBankPositionsResponse,
|
||
} from '../types/tbank-proto.types';
|
||
import { BrokerAccountsService } from './broker-accounts.service';
|
||
import { BrokerInstrumentsService } from './broker-instruments.service';
|
||
import { TBankClientService } from './tbank-client.service';
|
||
|
||
@Injectable()
|
||
export class BrokerPortfolioService {
|
||
constructor(
|
||
private readonly accountsService: BrokerAccountsService,
|
||
private readonly instrumentsService: BrokerInstrumentsService,
|
||
private readonly tbankClient: TBankClientService,
|
||
private readonly cacheService: CacheService,
|
||
) {}
|
||
|
||
async getPortfolio(accountId: string): Promise<{
|
||
data: BrokerPortfolio;
|
||
meta: { fromCache: boolean; cachedAt: string | null };
|
||
}> {
|
||
const account = await this.accountsService.findById(accountId);
|
||
if (!account) throw new NotFoundException('Broker account not found');
|
||
|
||
const result = await this.cacheService.getOrFetch(
|
||
TBANK_CACHE_KEYS.portfolio,
|
||
[accountId],
|
||
async () => {
|
||
const operationsClient = this.tbankClient.getServiceClient('OperationsService') as any;
|
||
const [portfolio, positions] = await Promise.all([
|
||
this.tbankClient.callUnary<
|
||
{ accountId: string; currency: string },
|
||
TBankPortfolioResponse
|
||
>(
|
||
'OperationsService/GetPortfolio',
|
||
operationsClient.getPortfolio.bind(operationsClient),
|
||
{ accountId, currency: 'RUB' },
|
||
),
|
||
this.tbankClient.callUnary<{ accountId: string }, TBankPositionsResponse>(
|
||
'OperationsService/GetPositions',
|
||
operationsClient.getPositions.bind(operationsClient),
|
||
{ accountId },
|
||
),
|
||
]);
|
||
|
||
const instrumentMap = await this.buildInstrumentMap(portfolio);
|
||
|
||
return mapBrokerPortfolio({ account, portfolio, positions, instruments: instrumentMap });
|
||
},
|
||
'tbankPortfolioTtl',
|
||
);
|
||
|
||
return {
|
||
data: result.data,
|
||
meta: { fromCache: result.fromCache, cachedAt: result.cachedAt },
|
||
};
|
||
}
|
||
|
||
async getPositions(
|
||
accountId: string,
|
||
cursor?: string,
|
||
limit = 10,
|
||
): Promise<{
|
||
data: BrokerPositionsPage;
|
||
meta: { fromCache: boolean; cachedAt: string | null };
|
||
}> {
|
||
const account = await this.accountsService.findById(accountId);
|
||
if (!account) throw new NotFoundException('Broker account not found');
|
||
|
||
const result = await this.cacheService.getOrFetch(
|
||
TBANK_CACHE_KEYS.positions,
|
||
[accountId],
|
||
async () => {
|
||
const operationsClient = this.tbankClient.getServiceClient('OperationsService') as any;
|
||
const portfolio = await this.tbankClient.callUnary<
|
||
{ accountId: string; currency: string },
|
||
TBankPortfolioResponse
|
||
>(
|
||
'OperationsService/GetPortfolio',
|
||
operationsClient.getPortfolio.bind(operationsClient),
|
||
{ accountId, currency: 'RUB' },
|
||
);
|
||
|
||
const instrumentMap = await this.buildInstrumentMap(portfolio);
|
||
|
||
return mapBrokerPositionsPage({ accountId, portfolio, instruments: instrumentMap, cursor, limit });
|
||
},
|
||
'tbankPositionsTtl',
|
||
);
|
||
|
||
return {
|
||
data: result.data,
|
||
meta: { fromCache: result.fromCache, cachedAt: result.cachedAt },
|
||
};
|
||
}
|
||
|
||
private async buildInstrumentMap(
|
||
portfolio: TBankPortfolioResponse,
|
||
): Promise<Map<string, Partial<TBankInstrument>>> {
|
||
const ids = Array.from(
|
||
new Set(
|
||
(portfolio.positions ?? []).map((position) => position.instrumentUid).filter(Boolean),
|
||
),
|
||
) as string[];
|
||
const results = await Promise.allSettled(
|
||
ids.map(async (id) => [id, await this.instrumentsService.findByInstrumentUid(id)] as const),
|
||
);
|
||
const entries = results.flatMap((result) =>
|
||
result.status === 'fulfilled' ? [result.value] : [],
|
||
);
|
||
|
||
return new Map(
|
||
entries.filter((entry): entry is readonly [string, TBankInstrument] => entry[1] !== null),
|
||
);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Commit**
|
||
|
||
```bash
|
||
git add apps/backend/src/modules/tbank/services/broker-portfolio.service.ts
|
||
git commit -m "feat(tbank): add getPositions() method to BrokerPortfolioService"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 4: Backend — Controller + Envelope + Config for positions endpoint
|
||
|
||
**Files:**
|
||
- Modify: `apps/backend/src/modules/tbank/tbank.controller.ts`
|
||
- Modify: `apps/backend/src/config/configuration.ts`
|
||
|
||
- [ ] **Step 1: Add `GET /accounts/:accountId/positions` endpoint**
|
||
|
||
Edit `apps/backend/src/modules/tbank/tbank.controller.ts`:
|
||
|
||
Add imports:
|
||
```ts
|
||
import { BrokerPositionsEnvelopeDto } from './dto/broker-envelope.dto';
|
||
import { BrokerPositionQueryDto } from './dto/broker-position-query.dto';
|
||
```
|
||
|
||
Add after `getPortfolio` method:
|
||
```ts
|
||
@Get('accounts/:accountId/positions')
|
||
@ApiOperation({ summary: 'Get paginated T-Bank broker account positions' })
|
||
@ApiOkResponse({ type: BrokerPositionsEnvelopeDto })
|
||
async getPositions(
|
||
@Param('accountId') accountId: string,
|
||
@Query() query: BrokerPositionQueryDto,
|
||
) {
|
||
const result = await this.brokerPortfolioService.getPositions(
|
||
accountId,
|
||
query.cursor,
|
||
query.limit,
|
||
);
|
||
return new ApiResponse(result.data, result.meta.fromCache, result.meta.cachedAt);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Create `BrokerPositionQueryDto`**
|
||
|
||
Create `apps/backend/src/modules/tbank/dto/broker-position-query.dto.ts`:
|
||
```ts
|
||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||
import { Type } from 'class-transformer';
|
||
import { IsNumber, IsOptional, IsString, Max, Min } from 'class-validator';
|
||
|
||
export class BrokerPositionQueryDto {
|
||
@ApiPropertyOptional({ description: 'Cursor for pagination (positionUid)' })
|
||
@IsOptional()
|
||
@IsString()
|
||
cursor?: string;
|
||
|
||
@ApiPropertyOptional({ default: 10 })
|
||
@IsOptional()
|
||
@Type(() => Number)
|
||
@IsNumber()
|
||
@Min(1)
|
||
@Max(100)
|
||
limit?: number = 10;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Add `tbankPositionsTtl` to configuration**
|
||
|
||
Edit `apps/backend/src/config/configuration.ts`:
|
||
|
||
Add after `tbankOperationsTtl` (line 34):
|
||
```ts
|
||
tbankPositionsTtl: parseInt(process.env.CACHE_TBANK_POSITIONS_TTL || '60', 10),
|
||
```
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add apps/backend/src/modules/tbank/tbank.controller.ts \
|
||
apps/backend/src/modules/tbank/dto/broker-position-query.dto.ts \
|
||
apps/backend/src/config/configuration.ts
|
||
git commit -m "feat(tbank): add GET /positions endpoint with cursor pagination"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 5: Backend — Update portfolio service tests + add positions tests
|
||
|
||
**Files:**
|
||
- Modify: `apps/backend/src/modules/tbank/services/broker-portfolio.service.spec.ts`
|
||
- Modify: `apps/backend/src/modules/tbank/tbank.config.spec.ts`
|
||
|
||
- [ ] **Step 1: Update tests — remove positions assertions, add getPositions tests**
|
||
|
||
Edit `apps/backend/src/modules/tbank/services/broker-portfolio.service.spec.ts`:
|
||
|
||
Replace the file with:
|
||
```ts
|
||
import { NotFoundException } from '@nestjs/common';
|
||
import { CacheService } from '../../cache/cache.service';
|
||
import { BrokerAccountsService } from './broker-accounts.service';
|
||
import { BrokerInstrumentsService } from './broker-instruments.service';
|
||
import { BrokerPortfolioService } from './broker-portfolio.service';
|
||
import { TBankClientService } from './tbank-client.service';
|
||
|
||
describe('BrokerPortfolioService', () => {
|
||
const accounts = { findById: vi.fn() } as unknown as BrokerAccountsService;
|
||
const instruments = { findByInstrumentUid: vi.fn() } as unknown as BrokerInstrumentsService;
|
||
const client = { getServiceClient: vi.fn(), callUnary: vi.fn() } as unknown as TBankClientService;
|
||
const cache = { getOrFetch: vi.fn() } as unknown as CacheService;
|
||
|
||
beforeEach(() => {
|
||
vi.clearAllMocks();
|
||
});
|
||
|
||
it('throws 404 for excluded or missing account', async () => {
|
||
vi.mocked(accounts.findById).mockResolvedValue(null);
|
||
const service = new BrokerPortfolioService(accounts, instruments, client, cache);
|
||
|
||
await expect(service.getPortfolio('missing')).rejects.toThrow(NotFoundException);
|
||
});
|
||
|
||
it('fetches portfolio through cache without positions', async () => {
|
||
vi.mocked(accounts.findById).mockResolvedValue({
|
||
id: 'acc-1',
|
||
type: 'brokerage',
|
||
name: 'Broker',
|
||
status: 'ACCOUNT_STATUS_OPEN',
|
||
openedAt: null,
|
||
accessLevel: null,
|
||
});
|
||
vi.mocked(cache.getOrFetch).mockImplementation(
|
||
async (_prefix: string, _parts: string[], fetchFn: () => Promise<unknown>) => ({
|
||
data: await fetchFn(),
|
||
fromCache: false,
|
||
cachedAt: null,
|
||
}),
|
||
);
|
||
vi.mocked(client.getServiceClient).mockReturnValue({
|
||
getPortfolio: vi.fn(),
|
||
getPositions: vi.fn(),
|
||
} as any);
|
||
vi.mocked(client.callUnary)
|
||
.mockResolvedValueOnce({
|
||
accountId: 'acc-1',
|
||
totalAmountPortfolio: { currency: 'rub', units: '1000', nano: 0 },
|
||
positions: [],
|
||
})
|
||
.mockResolvedValueOnce({
|
||
accountId: 'acc-1',
|
||
money: [{ currency: 'rub', units: '1000', nano: 0 }],
|
||
blocked: [],
|
||
securities: [],
|
||
});
|
||
|
||
const service = new BrokerPortfolioService(accounts, instruments, client, cache);
|
||
const result = await service.getPortfolio('acc-1');
|
||
|
||
expect(result.data.account.id).toBe('acc-1');
|
||
expect(result.data.cash[0].value).toBe(1000);
|
||
// positions not in portfolio anymore
|
||
expect('positions' in result.data).toBe(false);
|
||
expect(cache.getOrFetch).toHaveBeenCalledWith(
|
||
'tbank:portfolio',
|
||
['acc-1'],
|
||
expect.any(Function),
|
||
'tbankPortfolioTtl',
|
||
);
|
||
});
|
||
|
||
describe('getPositions', () => {
|
||
it('throws 404 for missing account', async () => {
|
||
vi.mocked(accounts.findById).mockResolvedValue(null);
|
||
const service = new BrokerPortfolioService(accounts, instruments, client, cache);
|
||
|
||
await expect(service.getPositions('missing')).rejects.toThrow(NotFoundException);
|
||
});
|
||
|
||
it('returns first page of positions', async () => {
|
||
vi.mocked(accounts.findById).mockResolvedValue({
|
||
id: 'acc-1',
|
||
type: 'brokerage',
|
||
name: 'Broker',
|
||
status: 'ACCOUNT_STATUS_OPEN',
|
||
openedAt: null,
|
||
accessLevel: null,
|
||
});
|
||
vi.mocked(cache.getOrFetch).mockImplementation(
|
||
async (_prefix: string, _parts: string[], fetchFn: () => Promise<unknown>) => ({
|
||
data: await fetchFn(),
|
||
fromCache: false,
|
||
cachedAt: null,
|
||
}),
|
||
);
|
||
vi.mocked(client.getServiceClient).mockReturnValue({
|
||
getPortfolio: vi.fn(),
|
||
} as any);
|
||
vi.mocked(client.callUnary).mockResolvedValueOnce({
|
||
accountId: 'acc-1',
|
||
totalAmountPortfolio: { currency: 'rub', units: '1000', nano: 0 },
|
||
positions: [
|
||
{
|
||
figi: 'figi-1',
|
||
instrumentUid: 'uid-1',
|
||
positionUid: 'pos-1',
|
||
quantity: { units: '10', nano: 0 },
|
||
},
|
||
{
|
||
figi: 'figi-2',
|
||
instrumentUid: 'uid-2',
|
||
positionUid: 'pos-2',
|
||
quantity: { units: '20', nano: 0 },
|
||
},
|
||
],
|
||
});
|
||
|
||
const service = new BrokerPortfolioService(accounts, instruments, client, cache);
|
||
const result = await service.getPositions('acc-1', undefined, 1);
|
||
|
||
expect(result.data.accountId).toBe('acc-1');
|
||
expect(result.data.items).toHaveLength(1);
|
||
expect(result.data.items[0].positionUid).toBe('pos-1');
|
||
expect(result.data.hasNext).toBe(true);
|
||
expect(result.data.nextCursor).toBe('pos-1');
|
||
});
|
||
|
||
it('paginates using cursor', async () => {
|
||
vi.mocked(accounts.findById).mockResolvedValue({
|
||
id: 'acc-1',
|
||
type: 'brokerage',
|
||
name: 'Broker',
|
||
status: 'ACCOUNT_STATUS_OPEN',
|
||
openedAt: null,
|
||
accessLevel: null,
|
||
});
|
||
vi.mocked(cache.getOrFetch).mockImplementation(
|
||
async (_prefix: string, _parts: string[], fetchFn: () => Promise<unknown>) => ({
|
||
data: await fetchFn(),
|
||
fromCache: false,
|
||
cachedAt: null,
|
||
}),
|
||
);
|
||
vi.mocked(client.getServiceClient).mockReturnValue({
|
||
getPortfolio: vi.fn(),
|
||
} as any);
|
||
vi.mocked(client.callUnary).mockResolvedValueOnce({
|
||
accountId: 'acc-1',
|
||
totalAmountPortfolio: { currency: 'rub', units: '1000', nano: 0 },
|
||
positions: [
|
||
{ figi: 'f1', instrumentUid: 'u1', positionUid: 'p1', quantity: { units: '10', nano: 0 } },
|
||
{ figi: 'f2', instrumentUid: 'u2', positionUid: 'p2', quantity: { units: '20', nano: 0 } },
|
||
{ figi: 'f3', instrumentUid: 'u3', positionUid: 'p3', quantity: { units: '30', nano: 0 } },
|
||
],
|
||
});
|
||
|
||
const service = new BrokerPortfolioService(accounts, instruments, client, cache);
|
||
const result = await service.getPositions('acc-1', 'p1', 1);
|
||
|
||
expect(result.data.items).toHaveLength(1);
|
||
expect(result.data.items[0].positionUid).toBe('p2');
|
||
expect(result.data.nextCursor).toBe('p2');
|
||
expect(result.data.hasNext).toBe(true);
|
||
});
|
||
|
||
it('returns last page with hasNext=false', async () => {
|
||
vi.mocked(accounts.findById).mockResolvedValue({
|
||
id: 'acc-1',
|
||
type: 'brokerage',
|
||
name: 'Broker',
|
||
status: 'ACCOUNT_STATUS_OPEN',
|
||
openedAt: null,
|
||
accessLevel: null,
|
||
});
|
||
vi.mocked(cache.getOrFetch).mockImplementation(
|
||
async (_prefix: string, _parts: string[], fetchFn: () => Promise<unknown>) => ({
|
||
data: await fetchFn(),
|
||
fromCache: false,
|
||
cachedAt: null,
|
||
}),
|
||
);
|
||
vi.mocked(client.getServiceClient).mockReturnValue({
|
||
getPortfolio: vi.fn(),
|
||
} as any);
|
||
vi.mocked(client.callUnary).mockResolvedValueOnce({
|
||
accountId: 'acc-1',
|
||
totalAmountPortfolio: { currency: 'rub', units: '1000', nano: 0 },
|
||
positions: [
|
||
{ figi: 'f1', instrumentUid: 'u1', positionUid: 'p1', quantity: { units: '10', nano: 0 } },
|
||
],
|
||
});
|
||
|
||
const service = new BrokerPortfolioService(accounts, instruments, client, cache);
|
||
const result = await service.getPositions('acc-1', undefined, 10);
|
||
|
||
expect(result.data.items).toHaveLength(1);
|
||
expect(result.data.hasNext).toBe(false);
|
||
expect(result.data.nextCursor).toBeNull();
|
||
});
|
||
|
||
it('caches positions with tbankPositionsTtl', async () => {
|
||
vi.mocked(accounts.findById).mockResolvedValue({
|
||
id: 'acc-1',
|
||
type: 'brokerage',
|
||
name: 'Broker',
|
||
status: 'ACCOUNT_STATUS_OPEN',
|
||
openedAt: null,
|
||
accessLevel: null,
|
||
});
|
||
vi.mocked(cache.getOrFetch).mockImplementation(
|
||
async (_prefix: string, _parts: string[], fetchFn: () => Promise<unknown>) => ({
|
||
data: await fetchFn(),
|
||
fromCache: false,
|
||
cachedAt: null,
|
||
}),
|
||
);
|
||
vi.mocked(client.getServiceClient).mockReturnValue({
|
||
getPortfolio: vi.fn(),
|
||
} as any);
|
||
vi.mocked(client.callUnary).mockResolvedValueOnce({
|
||
accountId: 'acc-1',
|
||
totalAmountPortfolio: { currency: 'rub', units: '1000', nano: 0 },
|
||
positions: [],
|
||
});
|
||
|
||
const service = new BrokerPortfolioService(accounts, instruments, client, cache);
|
||
await service.getPositions('acc-1');
|
||
|
||
expect(cache.getOrFetch).toHaveBeenCalledWith(
|
||
'tbank:positions',
|
||
['acc-1'],
|
||
expect.any(Function),
|
||
'tbankPositionsTtl',
|
||
);
|
||
});
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run tests**
|
||
|
||
```bash
|
||
npx vitest run apps/backend/src/modules/tbank/services/broker-portfolio.service.spec.ts -w apps/backend
|
||
```
|
||
|
||
Expected: ALL PASS
|
||
|
||
- [ ] **Step 3: Add tbankPositionsTtl to tbank config test**
|
||
|
||
Edit `apps/backend/src/modules/tbank/tbank.config.spec.ts`:
|
||
|
||
Add to the first `it` block after line 23:
|
||
```ts
|
||
expect(config.cache.tbankPositionsTtl).toBe(60);
|
||
```
|
||
|
||
Add to the second `it` block — set env and assert:
|
||
```ts
|
||
process.env.CACHE_TBANK_POSITIONS_TTL = '45';
|
||
```
|
||
|
||
And add assertion:
|
||
```ts
|
||
expect(config.cache.tbankPositionsTtl).toBe(45);
|
||
```
|
||
|
||
- [ ] **Step 4: Run config tests**
|
||
|
||
```bash
|
||
npx vitest run apps/backend/src/modules/tbank/tbank.config.spec.ts -w apps/backend
|
||
```
|
||
|
||
Expected: ALL PASS
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add apps/backend/src/modules/tbank/services/broker-portfolio.service.spec.ts \
|
||
apps/backend/src/modules/tbank/tbank.config.spec.ts
|
||
git commit -m "test(tbank): update portfolio tests, add getPositions tests"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 6: Frontend — CSS shimmer animations
|
||
|
||
**Files:**
|
||
- Modify: `apps/frontend/src/styles.css`
|
||
|
||
- [ ] **Step 1: Add shimmer keyframes and skeleton class**
|
||
|
||
Append to `apps/frontend/src/styles.css`:
|
||
```css
|
||
@keyframes shimmer {
|
||
0% { background-position: 200% 0; }
|
||
100% { background-position: -200% 0; }
|
||
}
|
||
|
||
.skeleton {
|
||
background: linear-gradient(
|
||
90deg,
|
||
var(--color-bg) 25%,
|
||
#f0f0f0 50%,
|
||
var(--color-bg) 75%
|
||
);
|
||
background-size: 200% 100%;
|
||
animation: shimmer 1.5s ease-in-out infinite;
|
||
border-radius: 4px;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Commit**
|
||
|
||
```bash
|
||
git add apps/frontend/src/styles.css
|
||
git commit -m "feat(frontend): add shimmer animation and .skeleton CSS class"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 7: Frontend — Types, API, and hooks
|
||
|
||
**Files:**
|
||
- Create: `apps/frontend/src/hooks/useBrokerPositions.ts`
|
||
- Modify: `apps/frontend/src/api/responses.ts`
|
||
- Modify: `apps/frontend/src/api/broker.ts`
|
||
- Modify: `apps/frontend/src/api/broker.test.ts`
|
||
|
||
- [ ] **Step 1: Update frontend types**
|
||
|
||
Edit `apps/frontend/src/api/responses.ts`:
|
||
|
||
Remove `positions: BrokerPosition[]` from `BrokerPortfolio`.
|
||
|
||
Add after `BrokerOperationsPage`:
|
||
```ts
|
||
export interface BrokerPositionsPage {
|
||
accountId: string;
|
||
items: BrokerPosition[];
|
||
nextCursor: string | null;
|
||
hasNext: boolean;
|
||
asOf: string;
|
||
}
|
||
```
|
||
|
||
Add `name: string | null` to `BrokerOperation` (after `description`):
|
||
```ts
|
||
description: string | null;
|
||
name: string | null;
|
||
```
|
||
|
||
- [ ] **Step 2: Add `getBrokerPositions` API function**
|
||
|
||
Edit `apps/frontend/src/api/broker.ts`:
|
||
|
||
Add import:
|
||
```ts
|
||
import type {
|
||
ApiResponseMeta,
|
||
BrokerAccount,
|
||
BrokerOperationsPage,
|
||
BrokerPortfolio,
|
||
BrokerPositionsPage,
|
||
} from './responses';
|
||
```
|
||
|
||
Add after `getBrokerOperations`:
|
||
```ts
|
||
export function getBrokerPositions(
|
||
accountId: string,
|
||
query: { cursor?: string; limit?: number } = {},
|
||
): Promise<{ data: BrokerPositionsPage; meta: ApiResponseMeta }> {
|
||
return request<BrokerPositionsPage>(
|
||
`/api/v1/broker/accounts/${encodeURIComponent(accountId)}/positions`,
|
||
{
|
||
cursor: query.cursor,
|
||
limit: query.limit ? String(query.limit) : undefined,
|
||
},
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Update broker.test.ts — add positions test**
|
||
|
||
Edit `apps/frontend/src/api/broker.test.ts`:
|
||
|
||
Replace the file:
|
||
```ts
|
||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||
import { getBrokerOperations, getBrokerPositions } from './broker';
|
||
|
||
describe('broker api', () => {
|
||
afterEach(() => {
|
||
vi.restoreAllMocks();
|
||
});
|
||
|
||
it('serializes operations query parameters', async () => {
|
||
vi.spyOn(globalThis, 'fetch').mockResolvedValue({
|
||
ok: true,
|
||
json: async () => ({
|
||
data: {
|
||
data: { accountId: 'acc-1', items: [], nextCursor: null, hasNext: false, asOf: 'now' },
|
||
meta: { fromCache: false, cachedAt: null },
|
||
},
|
||
}),
|
||
} as Response);
|
||
|
||
await getBrokerOperations('acc-1', { cursor: 'c1', limit: 50 });
|
||
|
||
expect(fetch).toHaveBeenCalledWith(
|
||
expect.stringContaining('/api/v1/broker/accounts/acc-1/operations?cursor=c1&limit=50'),
|
||
expect.any(Object),
|
||
);
|
||
});
|
||
|
||
it('serializes positions query parameters', async () => {
|
||
vi.spyOn(globalThis, 'fetch').mockResolvedValue({
|
||
ok: true,
|
||
json: async () => ({
|
||
data: {
|
||
data: { accountId: 'acc-1', items: [], nextCursor: null, hasNext: false, asOf: 'now' },
|
||
meta: { fromCache: false, cachedAt: null },
|
||
},
|
||
}),
|
||
} as Response);
|
||
|
||
await getBrokerPositions('acc-1', { cursor: 'pos-1', limit: 5 });
|
||
|
||
expect(fetch).toHaveBeenCalledWith(
|
||
expect.stringContaining('/api/v1/broker/accounts/acc-1/positions?cursor=pos-1&limit=5'),
|
||
expect.any(Object),
|
||
);
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 4: Create `useBrokerPositions` hook**
|
||
|
||
Create `apps/frontend/src/hooks/useBrokerPositions.ts`:
|
||
```ts
|
||
import { keepPreviousData, useQuery } from '@tanstack/react-query';
|
||
import { getBrokerPositions } from '../api/broker';
|
||
import type { BrokerPositionsPage } from '../api/responses';
|
||
|
||
export function useBrokerPositions(
|
||
accountId: string | undefined,
|
||
query: { cursor?: string; limit?: number } = {},
|
||
) {
|
||
return useQuery<BrokerPositionsPage>({
|
||
queryKey: ['broker', 'positions', accountId, query],
|
||
enabled: Boolean(accountId),
|
||
queryFn: async () => (await getBrokerPositions(accountId!, query)).data,
|
||
staleTime: 60_000,
|
||
retry: 2,
|
||
placeholderData: keepPreviousData,
|
||
refetchOnWindowFocus: false,
|
||
});
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add apps/frontend/src/api/responses.ts \
|
||
apps/frontend/src/api/broker.ts \
|
||
apps/frontend/src/api/broker.test.ts \
|
||
apps/frontend/src/hooks/useBrokerPositions.ts
|
||
git commit -m "feat(frontend): add BrokerPositionsPage types, API, and hook"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 8: Frontend — SkeletonBlock and TableSkeleton components
|
||
|
||
**Files:**
|
||
- Create: `apps/frontend/src/components/SkeletonBlock.tsx`
|
||
- Create: `apps/frontend/src/components/TableSkeleton.tsx`
|
||
|
||
- [ ] **Step 1: Create `SkeletonBlock`**
|
||
|
||
Create `apps/frontend/src/components/SkeletonBlock.tsx`:
|
||
```tsx
|
||
export function SkeletonBlock({ width, height, borderRadius = 4 }: {
|
||
width?: string | number;
|
||
height?: string | number;
|
||
borderRadius?: number;
|
||
}) {
|
||
return (
|
||
<div
|
||
className="skeleton"
|
||
style={{
|
||
width: width ?? '100%',
|
||
height: height ?? 16,
|
||
borderRadius,
|
||
}}
|
||
/>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Create `TableSkeleton`**
|
||
|
||
Create `apps/frontend/src/components/TableSkeleton.tsx`:
|
||
```tsx
|
||
import { SkeletonBlock } from './SkeletonBlock';
|
||
|
||
const tdStyle = {
|
||
borderBottom: '1px solid #eeeeee',
|
||
padding: '10px 8px',
|
||
verticalAlign: 'top',
|
||
} satisfies React.CSSProperties;
|
||
|
||
type Column = { width: string };
|
||
|
||
export function TableSkeleton({ rows = 5, columns }: { rows?: number; columns: Column[] }) {
|
||
return (
|
||
<tbody>
|
||
{Array.from({ length: rows }).map((_, i) => (
|
||
<tr key={i}>
|
||
{columns.map((col, j) => (
|
||
<td key={j} style={tdStyle}>
|
||
<SkeletonBlock height={12} width={col.width} />
|
||
</td>
|
||
))}
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add apps/frontend/src/components/SkeletonBlock.tsx \
|
||
apps/frontend/src/components/TableSkeleton.tsx
|
||
git commit -m "feat(frontend): add SkeletonBlock and TableSkeleton components"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 9: Frontend — BrokerPositionsSection with pagination + skeleton
|
||
|
||
**Files:**
|
||
- Modify: `apps/frontend/src/pages/broker/BrokerPositionsSection.tsx`
|
||
|
||
- [ ] **Step 1: Rewrite BrokerPositionsSection with pagination props**
|
||
|
||
Replace `apps/frontend/src/pages/broker/BrokerPositionsSection.tsx`:
|
||
```tsx
|
||
import { Link } from 'react-router-dom';
|
||
import type { BrokerMoney, BrokerPosition } from '../../api/responses';
|
||
import { getBrokerInstrumentPath, getBrokerPositionGroup } from './brokerDisplay';
|
||
import { TableSkeleton } from '../../components/TableSkeleton';
|
||
|
||
type BrokerPositionGroupConfig = {
|
||
key: 'shares' | 'bonds' | 'other';
|
||
title: string;
|
||
};
|
||
|
||
const GROUPS: BrokerPositionGroupConfig[] = [
|
||
{ key: 'shares', title: 'Акции' },
|
||
{ key: 'bonds', title: 'Облигации' },
|
||
{ key: 'other', title: 'Другие инструменты' },
|
||
];
|
||
|
||
const tableStyle = {
|
||
width: '100%',
|
||
borderCollapse: 'collapse',
|
||
fontSize: 14,
|
||
} satisfies React.CSSProperties;
|
||
|
||
const thStyle = {
|
||
borderBottom: '1px solid #e0e0e0',
|
||
color: 'var(--color-text-secondary)',
|
||
fontWeight: 600,
|
||
padding: '10px 8px',
|
||
} satisfies React.CSSProperties;
|
||
|
||
const tdStyle = {
|
||
borderBottom: '1px solid #eeeeee',
|
||
padding: '10px 8px',
|
||
verticalAlign: 'top',
|
||
} satisfies React.CSSProperties;
|
||
|
||
const pagButtonStyle = {
|
||
padding: '6px 14px',
|
||
borderRadius: 6,
|
||
border: '1px solid #e0e0e0',
|
||
background: 'var(--color-surface)',
|
||
color: 'var(--color-text)',
|
||
fontSize: 14,
|
||
fontWeight: 600,
|
||
cursor: 'pointer',
|
||
lineHeight: 1.4,
|
||
} satisfies React.CSSProperties;
|
||
|
||
const pagButtonDisabledStyle = {
|
||
...pagButtonStyle,
|
||
opacity: 0.35,
|
||
cursor: 'not-allowed',
|
||
} satisfies React.CSSProperties;
|
||
|
||
function formatMoney(value: BrokerMoney | null | undefined) {
|
||
if (!value) return '-';
|
||
return new Intl.NumberFormat('ru-RU', {
|
||
style: 'currency',
|
||
currency: value.currency || 'RUB',
|
||
maximumFractionDigits: 2,
|
||
}).format(value.value);
|
||
}
|
||
|
||
function formatQuantity(value: number | null | undefined) {
|
||
return value == null ? '-' : value.toLocaleString('ru-RU');
|
||
}
|
||
|
||
function PositionTicker({ position }: { position: BrokerPosition }) {
|
||
const label = position.ticker || position.figi || '-';
|
||
const path = getBrokerInstrumentPath({
|
||
ticker: position.ticker,
|
||
instrumentType: position.instrumentType,
|
||
classCode: position.classCode,
|
||
});
|
||
|
||
if (!path || label === '-') {
|
||
return <strong>{label}</strong>;
|
||
}
|
||
|
||
return (
|
||
<Link to={path} style={{ fontWeight: 700 }}>
|
||
{label}
|
||
</Link>
|
||
);
|
||
}
|
||
|
||
function PositionTable({ title, positions }: { title: string; positions: BrokerPosition[] }) {
|
||
return (
|
||
<section>
|
||
<h3 style={{ fontSize: 18, marginBottom: 10 }}>{title}</h3>
|
||
<div style={{ overflowX: 'auto', background: 'var(--color-surface)' }}>
|
||
<table aria-label={`Брокерские позиции: ${title}`} style={tableStyle}>
|
||
<thead>
|
||
<tr>
|
||
<th align="left" style={thStyle}>Тикер</th>
|
||
<th align="left" style={thStyle}>Название</th>
|
||
<th align="right" style={thStyle}>Количество</th>
|
||
<th align="right" style={thStyle}>Цена</th>
|
||
<th align="right" style={thStyle}>Стоимость</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{positions.map((position) => (
|
||
<tr
|
||
key={
|
||
position.positionUid || position.instrumentUid || position.ticker || position.figi
|
||
}
|
||
>
|
||
<td style={tdStyle}>
|
||
<PositionTicker position={position} />
|
||
</td>
|
||
<td style={tdStyle}>
|
||
<span style={{ color: 'var(--color-text-secondary)' }}>
|
||
{position.name || '-'}
|
||
</span>
|
||
</td>
|
||
<td align="right" style={tdStyle}>
|
||
{formatQuantity(position.quantity)}
|
||
</td>
|
||
<td align="right" style={tdStyle}>
|
||
{formatMoney(position.currentPrice)}
|
||
</td>
|
||
<td align="right" style={tdStyle}>
|
||
{formatMoney(position.currentValue)}
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
type BrokerPositionsSectionProps = {
|
||
page: { items: BrokerPosition[] } | undefined;
|
||
isLoading: boolean;
|
||
pageNumber: number;
|
||
canGoBack: boolean;
|
||
canGoForward: boolean;
|
||
onPrevious: () => void;
|
||
onNext: () => void;
|
||
};
|
||
|
||
export function BrokerPositionsSection({
|
||
page,
|
||
isLoading,
|
||
pageNumber,
|
||
canGoBack,
|
||
canGoForward,
|
||
onPrevious,
|
||
onNext,
|
||
}: BrokerPositionsSectionProps) {
|
||
const positions = page?.items ?? [];
|
||
|
||
const grouped = GROUPS.map((group) => ({
|
||
...group,
|
||
positions: positions.filter((position) => getBrokerPositionGroup(position) === group.key),
|
||
})).filter((group) => group.positions.length > 0);
|
||
|
||
return (
|
||
<section>
|
||
<div
|
||
style={{
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
gap: 12,
|
||
justifyContent: 'space-between',
|
||
marginBottom: 12,
|
||
}}
|
||
>
|
||
<h2 style={{ fontSize: 20, margin: 0 }}>Позиции</h2>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||
<button
|
||
type="button"
|
||
onClick={onPrevious}
|
||
disabled={!canGoBack}
|
||
style={canGoBack ? pagButtonStyle : pagButtonDisabledStyle}
|
||
>
|
||
←
|
||
</button>
|
||
<span
|
||
style={{
|
||
minWidth: 20,
|
||
textAlign: 'center',
|
||
color: 'var(--color-text-secondary)',
|
||
fontSize: 14,
|
||
fontWeight: 600,
|
||
}}
|
||
>
|
||
{pageNumber}
|
||
</span>
|
||
<button
|
||
type="button"
|
||
onClick={onNext}
|
||
disabled={!canGoForward}
|
||
style={canGoForward ? pagButtonStyle : pagButtonDisabledStyle}
|
||
>
|
||
→
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{isLoading && grouped.length === 0 ? (
|
||
<div style={{ overflowX: 'auto', background: 'var(--color-surface)' }}>
|
||
<table style={tableStyle}>
|
||
<thead>
|
||
<tr>
|
||
<th align="left" style={thStyle}>Тикер</th>
|
||
<th align="left" style={thStyle}>Название</th>
|
||
<th align="right" style={thStyle}>Количество</th>
|
||
<th align="right" style={thStyle}>Цена</th>
|
||
<th align="right" style={thStyle}>Стоимость</th>
|
||
</tr>
|
||
</thead>
|
||
<TableSkeleton rows={4} columns={[
|
||
{ width: '30%' }, { width: '50%' }, { width: '20%' }, { width: '25%' }, { width: '25%' },
|
||
]} />
|
||
</table>
|
||
</div>
|
||
) : grouped.length === 0 ? (
|
||
<p style={{ color: 'var(--color-text-secondary)' }}>В портфеле нет позиций</p>
|
||
) : isLoading ? (
|
||
<div>
|
||
<div style={{ display: 'grid', gap: 20 }}>
|
||
{grouped.map((group) => (
|
||
<div key={group.key}>
|
||
<h3 style={{ fontSize: 18, marginBottom: 10 }}>{group.title}</h3>
|
||
<div style={{ overflowX: 'auto', background: 'var(--color-surface)' }}>
|
||
<table style={tableStyle}>
|
||
<thead>
|
||
<tr>
|
||
<th align="left" style={thStyle}>Тикер</th>
|
||
<th align="left" style={thStyle}>Название</th>
|
||
<th align="right" style={thStyle}>Количество</th>
|
||
<th align="right" style={thStyle}>Цена</th>
|
||
<th align="right" style={thStyle}>Стоимость</th>
|
||
</tr>
|
||
</thead>
|
||
<TableSkeleton rows={2} columns={[
|
||
{ width: '30%' }, { width: '50%' }, { width: '20%' }, { width: '25%' }, { width: '25%' },
|
||
]} />
|
||
</table>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div style={{ display: 'grid', gap: 20 }}>
|
||
{grouped.map((group) => (
|
||
<PositionTable key={group.key} title={group.title} positions={group.positions} />
|
||
))}
|
||
</div>
|
||
)}
|
||
</section>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Commit**
|
||
|
||
```bash
|
||
git add apps/frontend/src/pages/broker/BrokerPositionsSection.tsx
|
||
git commit -m "feat(frontend): add pagination and skeleton to BrokerPositionsSection"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 10: Frontend — BrokerOperationsTable with shimmer + instrument name
|
||
|
||
**Files:**
|
||
- Modify: `apps/frontend/src/pages/broker/BrokerOperationsTable.tsx`
|
||
|
||
- [ ] **Step 1: Add shimmer loading and instrument name display**
|
||
|
||
Edit `apps/frontend/src/pages/broker/BrokerOperationsTable.tsx`:
|
||
|
||
Add import:
|
||
```tsx
|
||
import { TableSkeleton } from '../../components/TableSkeleton';
|
||
```
|
||
|
||
Replace `OperationInstrument`:
|
||
```tsx
|
||
function OperationInstrument({ operation }: { operation: BrokerOperation }) {
|
||
const ticker = operation.ticker || operation.description || '-';
|
||
const path = getBrokerInstrumentPath({
|
||
ticker: operation.ticker,
|
||
instrumentType: operation.instrumentType,
|
||
classCode: operation.classCode,
|
||
});
|
||
const name = operation.name || operation.description;
|
||
|
||
if (!path && !name) return <span>-</span>;
|
||
if (!path) return <span>{name}</span>;
|
||
if (!ticker || ticker === '-') return <Link to={path}>{name}</Link>;
|
||
|
||
return (
|
||
<div style={{ display: 'grid', gap: 2 }}>
|
||
<Link to={path} style={{ fontWeight: 700 }}>{ticker}</Link>
|
||
{name && name !== ticker && (
|
||
<span style={{ color: 'var(--color-text-secondary)', fontSize: 12 }}>{name}</span>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
Replace the `isLoading` check block (lines 147-195):
|
||
|
||
Keep the same structure but replace the loading state:
|
||
```tsx
|
||
{isLoading ? (
|
||
<div style={{ overflowX: 'auto', background: 'var(--color-surface)' }}>
|
||
<table style={tableStyle}>
|
||
<thead>
|
||
<tr>
|
||
<th align="left" style={thStyle}>Дата</th>
|
||
<th align="left" style={thStyle}>Тип</th>
|
||
<th align="left" style={thStyle}>Инструмент</th>
|
||
<th align="right" style={thStyle}>Сумма</th>
|
||
</tr>
|
||
</thead>
|
||
<TableSkeleton rows={5} columns={[
|
||
{ width: '35%' }, { width: '30%' }, { width: '40%' }, { width: '25%' },
|
||
]} />
|
||
</table>
|
||
</div>
|
||
) : operations.length === 0 ? (
|
||
```
|
||
|
||
- [ ] **Step 2: Commit**
|
||
|
||
```bash
|
||
git add apps/frontend/src/pages/broker/BrokerOperationsTable.tsx
|
||
git commit -m "feat(frontend): add shimmer loading and instrument name in operations table"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 11: Frontend — BrokerAccountDetailPage with positions hook + skeleton
|
||
|
||
**Files:**
|
||
- Modify: `apps/frontend/src/pages/broker/BrokerAccountDetailPage.tsx`
|
||
|
||
- [ ] **Step 1: Rewrite with positions hook, skeleton loading**
|
||
|
||
Replace `apps/frontend/src/pages/broker/BrokerAccountDetailPage.tsx`:
|
||
```tsx
|
||
import { useState } from 'react';
|
||
import { useParams } from 'react-router-dom';
|
||
import type { BrokerMoney } from '../../api/responses';
|
||
import { useBrokerOperations } from '../../hooks/useBrokerOperations';
|
||
import { useBrokerPortfolio } from '../../hooks/useBrokerPortfolio';
|
||
import { useBrokerPositions } from '../../hooks/useBrokerPositions';
|
||
import { BrokerOperationsTable } from './BrokerOperationsTable';
|
||
import { BrokerPositionsSection } from './BrokerPositionsSection';
|
||
import { SkeletonBlock } from '../../components/SkeletonBlock';
|
||
|
||
function formatMoney(value: BrokerMoney | null | undefined) {
|
||
if (!value) return '-';
|
||
return new Intl.NumberFormat('ru-RU', {
|
||
style: 'currency',
|
||
currency: value.currency || 'RUB',
|
||
maximumFractionDigits: 2,
|
||
}).format(value.value);
|
||
}
|
||
|
||
export function BrokerAccountDetailPage() {
|
||
const { accountId } = useParams();
|
||
const [operationCursor, setOperationCursor] = useState<string | undefined>(undefined);
|
||
const [operationCursorStack, setOperationCursorStack] = useState<Array<string | undefined>>([]);
|
||
const [positionCursor, setPositionCursor] = useState<string | undefined>(undefined);
|
||
const [positionCursorStack, setPositionCursorStack] = useState<Array<string | undefined>>([]);
|
||
const portfolio = useBrokerPortfolio(accountId);
|
||
const operations = useBrokerOperations(accountId, { limit: 10, cursor: operationCursor });
|
||
const positions = useBrokerPositions(accountId, { limit: 10, cursor: positionCursor });
|
||
|
||
if (portfolio.isLoading) {
|
||
return (
|
||
<div style={{ display: 'grid', gap: 24 }}>
|
||
<div style={{ display: 'grid', gap: 12 }}>
|
||
<SkeletonBlock height={32} width="60%" />
|
||
<SkeletonBlock height={24} width="40%" />
|
||
</div>
|
||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))', gap: 12 }}>
|
||
{[1, 2, 3].map((i) => (
|
||
<div key={i} style={{ padding: 16, background: 'var(--color-surface)', border: '1px solid #e0e0e0', borderRadius: 8 }}>
|
||
<SkeletonBlock height={14} width="40%" />
|
||
<div style={{ height: 8 }} />
|
||
<SkeletonBlock height={20} width="60%" />
|
||
</div>
|
||
))}
|
||
</div>
|
||
<div style={{ overflowX: 'auto', background: 'var(--color-surface)' }}>
|
||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 14 }}>
|
||
<thead>
|
||
<tr>
|
||
<th align="left" style={{ borderBottom: '1px solid #e0e0e0', color: 'var(--color-text-secondary)', fontWeight: 600, padding: '10px 8px' }}>Тикер</th>
|
||
<th align="left" style={{ borderBottom: '1px solid #e0e0e0', color: 'var(--color-text-secondary)', fontWeight: 600, padding: '10px 8px' }}>Название</th>
|
||
<th align="right" style={{ borderBottom: '1px solid #e0e0e0', color: 'var(--color-text-secondary)', fontWeight: 600, padding: '10px 8px' }}>Количество</th>
|
||
<th align="right" style={{ borderBottom: '1px solid #e0e0e0', color: 'var(--color-text-secondary)', fontWeight: 600, padding: '10px 8px' }}>Цена</th>
|
||
<th align="right" style={{ borderBottom: '1px solid #e0e0e0', color: 'var(--color-text-secondary)', fontWeight: 600, padding: '10px 8px' }}>Стоимость</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{Array.from({ length: 4 }).map((_, i) => (
|
||
<tr key={i}>
|
||
{Array.from({ length: 5 }).map((_, j) => (
|
||
<td key={j} style={{ borderBottom: '1px solid #eeeeee', padding: '10px 8px', verticalAlign: 'top' }}>
|
||
<SkeletonBlock height={12} width={`${30 + j * 10}%`} />
|
||
</td>
|
||
))}
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (portfolio.error || !portfolio.data) {
|
||
return <p style={{ color: 'var(--color-negative)' }}>Не удалось загрузить портфель</p>;
|
||
}
|
||
|
||
function handleNextOperationsPage() {
|
||
const nextCursor = operations.data?.nextCursor;
|
||
if (!nextCursor || !operations.data?.hasNext) return;
|
||
setOperationCursorStack((previous) => [...previous, operationCursor]);
|
||
setOperationCursor(nextCursor);
|
||
}
|
||
|
||
function handlePreviousOperationsPage() {
|
||
if (operationCursorStack.length === 0) return;
|
||
const nextStack = operationCursorStack.slice(0, -1);
|
||
const previousCursor = operationCursorStack[operationCursorStack.length - 1];
|
||
setOperationCursorStack(nextStack);
|
||
setOperationCursor(previousCursor);
|
||
}
|
||
|
||
function handleNextPositionsPage() {
|
||
const nextCursor = positions.data?.nextCursor;
|
||
if (!nextCursor || !positions.data?.hasNext) return;
|
||
setPositionCursorStack((previous) => [...previous, positionCursor]);
|
||
setPositionCursor(nextCursor);
|
||
}
|
||
|
||
function handlePreviousPositionsPage() {
|
||
if (positionCursorStack.length === 0) return;
|
||
const nextStack = positionCursorStack.slice(0, -1);
|
||
const previousCursor = positionCursorStack[positionCursorStack.length - 1];
|
||
setPositionCursorStack(nextStack);
|
||
setPositionCursor(previousCursor);
|
||
}
|
||
|
||
return (
|
||
<div style={{ display: 'grid', gap: 24 }}>
|
||
<header>
|
||
<h1 style={{ fontSize: 28, lineHeight: 1.2, marginBottom: 12 }}>
|
||
{portfolio.data.account.name}
|
||
</h1>
|
||
<div style={{ display: 'flex', gap: 16, flexWrap: 'wrap', alignItems: 'baseline' }}>
|
||
<strong style={{ fontSize: 24 }}>{formatMoney(portfolio.data.totals.portfolio)}</strong>
|
||
<span style={{ color: 'var(--color-text-secondary)' }}>
|
||
День: {formatMoney(portfolio.data.yields.daily)}
|
||
</span>
|
||
<span style={{ color: 'var(--color-text-secondary)' }}>
|
||
Ожидаемая: {portfolio.data.yields.expectedPercent ?? '-'}%
|
||
</span>
|
||
</div>
|
||
</header>
|
||
|
||
<section
|
||
style={{
|
||
display: 'grid',
|
||
gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))',
|
||
gap: 12,
|
||
}}
|
||
>
|
||
{portfolio.data.cash.map((money) => (
|
||
<div
|
||
key={money.currency}
|
||
style={{
|
||
background: 'var(--color-surface)',
|
||
border: '1px solid #e0e0e0',
|
||
borderRadius: 8,
|
||
padding: 16,
|
||
}}
|
||
>
|
||
<div style={{ color: 'var(--color-text-secondary)', fontSize: 13 }}>
|
||
{money.currency}
|
||
</div>
|
||
<strong>{formatMoney(money)}</strong>
|
||
</div>
|
||
))}
|
||
</section>
|
||
|
||
<BrokerPositionsSection
|
||
page={positions.data}
|
||
isLoading={positions.isLoading}
|
||
pageNumber={positionCursorStack.length + 1}
|
||
canGoBack={positionCursorStack.length > 0}
|
||
canGoForward={Boolean(positions.data?.hasNext && positions.data.nextCursor)}
|
||
onPrevious={handlePreviousPositionsPage}
|
||
onNext={handleNextPositionsPage}
|
||
/>
|
||
|
||
<BrokerOperationsTable
|
||
isLoading={operations.isLoading}
|
||
page={operations.data}
|
||
pageNumber={operationCursorStack.length + 1}
|
||
canGoBack={operationCursorStack.length > 0}
|
||
canGoForward={Boolean(operations.data?.hasNext && operations.data.nextCursor)}
|
||
onPrevious={handlePreviousOperationsPage}
|
||
onNext={handleNextOperationsPage}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Commit**
|
||
|
||
```bash
|
||
git add apps/frontend/src/pages/broker/BrokerAccountDetailPage.tsx
|
||
git commit -m "feat(frontend): add positions hook and skeleton loading to account detail page"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 12: Frontend — BrokerAccountsPage skeleton cards
|
||
|
||
**Files:**
|
||
- Modify: `apps/frontend/src/pages/broker/BrokerAccountsPage.tsx`
|
||
|
||
- [ ] **Step 1: Replace text loading with skeleton cards**
|
||
|
||
Edit `apps/frontend/src/pages/broker/BrokerAccountsPage.tsx`:
|
||
|
||
Add import:
|
||
```tsx
|
||
import { SkeletonBlock } from '../../components/SkeletonBlock';
|
||
```
|
||
|
||
Replace:
|
||
```tsx
|
||
if (isLoading) return <p>Загрузка брокерских счетов...</p>;
|
||
```
|
||
|
||
With:
|
||
```tsx
|
||
if (isLoading) {
|
||
return (
|
||
<div>
|
||
<div style={{ display: 'flex', alignItems: 'baseline', gap: 12, marginBottom: 20 }}>
|
||
<h1 style={{ fontSize: 28, lineHeight: 1.2 }}>Брокерские счета</h1>
|
||
</div>
|
||
<div
|
||
style={{
|
||
display: 'grid',
|
||
gap: 16,
|
||
gridTemplateColumns: 'repeat(auto-fit, minmax(260px, 1fr))',
|
||
}}
|
||
>
|
||
{[1, 2, 3].map((i) => (
|
||
<div
|
||
key={i}
|
||
style={{
|
||
padding: 20,
|
||
background: 'var(--color-surface)',
|
||
border: '1px solid #e0e0e0',
|
||
borderRadius: 8,
|
||
boxShadow: 'var(--shadow)',
|
||
}}
|
||
>
|
||
<SkeletonBlock height={20} width="60%" />
|
||
<div style={{ height: 10 }} />
|
||
<SkeletonBlock height={12} width="40%" />
|
||
<div style={{ height: 6 }} />
|
||
<SkeletonBlock height={12} width="30%" />
|
||
<div style={{ height: 6 }} />
|
||
<SkeletonBlock height={12} width="50%" />
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Commit**
|
||
|
||
```bash
|
||
git add apps/frontend/src/pages/broker/BrokerAccountsPage.tsx
|
||
git commit -m "feat(frontend): add skeleton cards to broker accounts page"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 13: Frontend — Update BrokerPages tests
|
||
|
||
**Files:**
|
||
- Modify: `apps/frontend/src/pages/broker/BrokerPages.test.tsx`
|
||
|
||
- [ ] **Step 1: Update tests — remove positions from portfolio mock, add positions hook mock**
|
||
|
||
Edit `apps/frontend/src/pages/broker/BrokerPages.test.tsx`:
|
||
|
||
Add import:
|
||
```tsx
|
||
import * as positionsHook from '../../hooks/useBrokerPositions';
|
||
```
|
||
|
||
Update the portfolio mock in "renders positions and operations for account detail" (line 54-91):
|
||
|
||
Remove `positions` from the portfolio data mock:
|
||
```tsx
|
||
vi.spyOn(portfolioHook, 'useBrokerPortfolio').mockReturnValue({
|
||
data: {
|
||
account: {
|
||
id: 'acc-1',
|
||
type: 'brokerage',
|
||
name: 'Broker',
|
||
status: 'ACCOUNT_STATUS_OPEN',
|
||
openedAt: null,
|
||
accessLevel: null,
|
||
},
|
||
totals: { portfolio: { currency: 'RUB', units: '1000', nano: 0, value: 1000 } },
|
||
yields: { expectedPercent: 5, daily: null, dailyPercent: null },
|
||
cash: [{ currency: 'RUB', units: '100', nano: 0, value: 100 }],
|
||
blockedCash: [],
|
||
asOf: '2026-06-16T00:00:00.000Z',
|
||
},
|
||
isLoading: false,
|
||
error: null,
|
||
} as any);
|
||
```
|
||
|
||
Add positions mock:
|
||
```tsx
|
||
vi.spyOn(positionsHook, 'useBrokerPositions').mockReturnValue({
|
||
data: {
|
||
accountId: 'acc-1',
|
||
items: [
|
||
{
|
||
figi: null,
|
||
instrumentUid: 'uid-1',
|
||
positionUid: null,
|
||
ticker: 'SBER',
|
||
classCode: 'TQBR',
|
||
instrumentType: 'share',
|
||
name: 'Sberbank',
|
||
quantity: 10,
|
||
blockedLots: null,
|
||
currentPrice: null,
|
||
currentValue: { currency: 'RUB', units: '1000', nano: 0, value: 1000 },
|
||
averagePositionPrice: null,
|
||
expectedYieldPercent: null,
|
||
dailyYield: null,
|
||
},
|
||
],
|
||
nextCursor: null,
|
||
hasNext: false,
|
||
asOf: '2026-06-16T00:00:00.000Z',
|
||
},
|
||
isLoading: false,
|
||
error: null,
|
||
} as any);
|
||
```
|
||
|
||
Repeat for the other tests:
|
||
- "renders broker positions as separate linked stock and bond tables" (line 139): remove `positions` from portfolio mock, add positions hook mock
|
||
- "renders broker operations with Russian labels" (line 225): remove `positions` from portfolio mock, add positions hook mock
|
||
- "requests broker operations by cursor" (line 321): remove `positions` from portfolio mock, add positions hook mock
|
||
|
||
For the table test (line 139), add a richer positions mock:
|
||
```tsx
|
||
vi.spyOn(positionsHook, 'useBrokerPositions').mockReturnValue({
|
||
data: {
|
||
accountId: 'acc-1',
|
||
items: [
|
||
{
|
||
figi: null,
|
||
instrumentUid: 'share-uid',
|
||
positionUid: null,
|
||
ticker: 'SBER',
|
||
classCode: 'TQBR',
|
||
instrumentType: 'share',
|
||
name: 'Sberbank',
|
||
quantity: 10,
|
||
blockedLots: null,
|
||
currentPrice: { currency: 'RUB', units: '250', nano: 0, value: 250 },
|
||
currentValue: { currency: 'RUB', units: '2500', nano: 0, value: 2500 },
|
||
averagePositionPrice: null,
|
||
expectedYieldPercent: 20,
|
||
dailyYield: null,
|
||
},
|
||
{
|
||
figi: null,
|
||
instrumentUid: 'bond-uid',
|
||
positionUid: null,
|
||
ticker: 'SU26238RMFS5',
|
||
classCode: 'TQOB',
|
||
instrumentType: 'bond',
|
||
name: 'ОФЗ 26238',
|
||
quantity: 2,
|
||
blockedLots: null,
|
||
currentPrice: { currency: 'RUB', units: '900', nano: 0, value: 900 },
|
||
currentValue: { currency: 'RUB', units: '1800', nano: 0, value: 1800 },
|
||
averagePositionPrice: null,
|
||
expectedYieldPercent: 10,
|
||
dailyYield: null,
|
||
},
|
||
],
|
||
nextCursor: null,
|
||
hasNext: false,
|
||
asOf: '2026-06-17T00:00:00.000Z',
|
||
},
|
||
isLoading: false,
|
||
error: null,
|
||
} as any);
|
||
```
|
||
|
||
For the two operation tests (line 225 and 321), provide empty positions list:
|
||
```tsx
|
||
vi.spyOn(positionsHook, 'useBrokerPositions').mockReturnValue({
|
||
data: {
|
||
accountId: 'acc-1',
|
||
items: [],
|
||
nextCursor: null,
|
||
hasNext: false,
|
||
asOf: '2026-06-17T00:00:00.000Z',
|
||
},
|
||
isLoading: false,
|
||
error: null,
|
||
} as any);
|
||
```
|
||
|
||
- [ ] **Step 2: Run tests**
|
||
|
||
```bash
|
||
npx vitest run apps/frontend/src/pages/broker/BrokerPages.test.tsx -w apps/frontend
|
||
```
|
||
|
||
Expected: ALL PASS
|
||
|
||
- [ ] **Step 3: Run all frontend tests**
|
||
|
||
```bash
|
||
npm run test:frontend
|
||
```
|
||
|
||
Expected: ALL PASS
|
||
|
||
- [ ] **Step 4: Run all backend tests**
|
||
|
||
```bash
|
||
npm run test:backend
|
||
```
|
||
|
||
Expected: ALL PASS
|
||
|
||
- [ ] **Step 5: Run lint**
|
||
|
||
```bash
|
||
npm run lint
|
||
```
|
||
|
||
Expected: ALL PASS
|
||
|
||
- [ ] **Step 6: Build frontend**
|
||
|
||
```bash
|
||
npm run build:frontend
|
||
```
|
||
|
||
Expected: SUCCESS
|
||
|
||
- [ ] **Step 7: Commit**
|
||
|
||
```bash
|
||
git add apps/frontend/src/pages/broker/BrokerPages.test.tsx
|
||
git commit -m "test(frontend): update broker tests for positions hook and removal from portfolio"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 14: Full build and test verification
|
||
|
||
- [ ] **Step 1: Run full backend test suite**
|
||
|
||
```bash
|
||
npm run test:backend
|
||
```
|
||
|
||
- [ ] **Step 2: Run full frontend test suite**
|
||
|
||
```bash
|
||
npm run test:frontend
|
||
```
|
||
|
||
- [ ] **Step 3: Run lint**
|
||
|
||
```bash
|
||
npm run lint
|
||
```
|
||
|
||
- [ ] **Step 4: Build frontend**
|
||
|
||
```bash
|
||
npm run build:frontend
|
||
```
|
||
|
||
- [ ] **Step 5: Build backend**
|
||
|
||
```bash
|
||
npm run build:backend
|
||
```
|
||
|
||
- [ ] **Step 6: Final commit if fixes needed**
|
||
|
||
```bash
|
||
git add -A
|
||
git commit -m "chore: fix lint and build after broker portfolio enhancements"
|
||
```
|