diff --git a/AGENTS.md b/AGENTS.md
index 89ad694..592192d 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -9,6 +9,7 @@ npm workspaces монорепозиторий: `apps/backend` (NestJS), `apps/fr
- **SDD (Specification-Driven Development)**: перед значимыми изменениями сначала зафиксировать спецификацию нужного масштаба — PRD/цели, доменную модель, ADR, API-контракт, frontend/backend architecture и этапы реализации. Для небольших maintenance-правок достаточно короткого обоснования и acceptance criteria.
- **Superpowers**: использовать релевантные Skills при старте задачи. Обычно: brainstorming для уточнения дизайна, systematic-debugging для багов, test-driven-development для feature/bugfix, writing-plans/executing-plans для крупных многошаговых работ, frontend-design для UI, requesting-code-review перед завершением крупных изменений.
- **MCP-инструменты**: использовать MCP для анализа, дизайна, работы с API, генерации кода и проверки локального UI, когда это полезно задаче.
+- **Visual Companion**: при обсуждении дизайна UI (mockups, макеты, варианты внешнего вида) использовать visual companion в браузере.
## Git workflow
diff --git a/apps/backend/src/config/configuration.ts b/apps/backend/src/config/configuration.ts
index cb3472a..c0a6ede 100644
--- a/apps/backend/src/config/configuration.ts
+++ b/apps/backend/src/config/configuration.ts
@@ -32,6 +32,7 @@ export default registerAs('app', () => ({
tbankAccountsTtl: parseInt(process.env.CACHE_TBANK_ACCOUNTS_TTL || '3600', 10),
tbankPortfolioTtl: parseInt(process.env.CACHE_TBANK_PORTFOLIO_TTL || '60', 10),
tbankOperationsTtl: parseInt(process.env.CACHE_TBANK_OPERATIONS_TTL || '300', 10),
+ tbankPositionsTtl: parseInt(process.env.CACHE_TBANK_POSITIONS_TTL || '60', 10),
tbankInstrumentTtl: parseInt(process.env.CACHE_TBANK_INSTRUMENT_TTL || '86400', 10),
},
auth: {
diff --git a/apps/backend/src/modules/tbank/dto/broker-envelope.dto.ts b/apps/backend/src/modules/tbank/dto/broker-envelope.dto.ts
index d98eb0a..a718486 100644
--- a/apps/backend/src/modules/tbank/dto/broker-envelope.dto.ts
+++ b/apps/backend/src/modules/tbank/dto/broker-envelope.dto.ts
@@ -2,6 +2,7 @@ import { ApiProperty } from '@nestjs/swagger';
import { BrokerAccountResponseDto } from './broker-account-response.dto';
import { BrokerOperationSyncResponseDto } from './broker-operation-sync-query.dto';
import { BrokerOperationsPageResponseDto } from './broker-operation-response.dto';
+import { BrokerPositionsPageResponseDto } from './broker-positions-page-response.dto';
import { BrokerPortfolioResponseDto } from './broker-portfolio-response.dto';
export class BrokerResponseMetaDto {
@@ -36,6 +37,14 @@ export class BrokerOperationsEnvelopeDto {
meta!: BrokerResponseMetaDto;
}
+export class BrokerPositionsEnvelopeDto {
+ @ApiProperty({ type: BrokerPositionsPageResponseDto })
+ data!: BrokerPositionsPageResponseDto;
+
+ @ApiProperty({ type: BrokerResponseMetaDto })
+ meta!: BrokerResponseMetaDto;
+}
+
export class BrokerOperationSyncEnvelopeDto {
@ApiProperty({ type: BrokerOperationSyncResponseDto })
data!: BrokerOperationSyncResponseDto;
diff --git a/apps/backend/src/modules/tbank/dto/broker-operation-response.dto.ts b/apps/backend/src/modules/tbank/dto/broker-operation-response.dto.ts
index d6b365d..e2a5afc 100644
--- a/apps/backend/src/modules/tbank/dto/broker-operation-response.dto.ts
+++ b/apps/backend/src/modules/tbank/dto/broker-operation-response.dto.ts
@@ -28,6 +28,9 @@ export class BrokerOperationResponseDto {
@ApiProperty({ nullable: true })
description!: string | null;
+ @ApiProperty({ nullable: true })
+ name!: string | null;
+
@ApiProperty({ nullable: true })
state!: string | null;
diff --git a/apps/backend/src/modules/tbank/dto/broker-portfolio-response.dto.ts b/apps/backend/src/modules/tbank/dto/broker-portfolio-response.dto.ts
index e86f56c..c06b5cc 100644
--- a/apps/backend/src/modules/tbank/dto/broker-portfolio-response.dto.ts
+++ b/apps/backend/src/modules/tbank/dto/broker-portfolio-response.dto.ts
@@ -2,50 +2,6 @@ import { ApiProperty } from '@nestjs/swagger';
import { BrokerAccountResponseDto } from './broker-account-response.dto';
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;
-}
-
export class BrokerPortfolioTotalsDto {
@ApiProperty({ type: BrokerMoneyDto, nullable: true })
shares!: BrokerMoneyDto | null;
@@ -102,9 +58,6 @@ export class BrokerPortfolioResponseDto {
@ApiProperty({ type: [BrokerMoneyDto] })
blockedCash!: BrokerMoneyDto[];
- @ApiProperty({ type: [BrokerPositionResponseDto] })
- positions!: BrokerPositionResponseDto[];
-
@ApiProperty()
asOf!: string;
}
diff --git a/apps/backend/src/modules/tbank/dto/broker-position-query.dto.ts b/apps/backend/src/modules/tbank/dto/broker-position-query.dto.ts
new file mode 100644
index 0000000..c73fe51
--- /dev/null
+++ b/apps/backend/src/modules/tbank/dto/broker-position-query.dto.ts
@@ -0,0 +1,23 @@
+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;
+
+ @ApiPropertyOptional({ description: 'Filter by instrument type (share, bond, etf, etc.)' })
+ @IsOptional()
+ @IsString()
+ type?: string;
+}
diff --git a/apps/backend/src/modules/tbank/dto/broker-position-response.dto.ts b/apps/backend/src/modules/tbank/dto/broker-position-response.dto.ts
new file mode 100644
index 0000000..655d553
--- /dev/null
+++ b/apps/backend/src/modules/tbank/dto/broker-position-response.dto.ts
@@ -0,0 +1,46 @@
+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;
+}
diff --git a/apps/backend/src/modules/tbank/dto/broker-positions-page-response.dto.ts b/apps/backend/src/modules/tbank/dto/broker-positions-page-response.dto.ts
new file mode 100644
index 0000000..71decea
--- /dev/null
+++ b/apps/backend/src/modules/tbank/dto/broker-positions-page-response.dto.ts
@@ -0,0 +1,19 @@
+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;
+}
diff --git a/apps/backend/src/modules/tbank/mappers/operation.mapper.ts b/apps/backend/src/modules/tbank/mappers/operation.mapper.ts
index b7f3e0e..3d9fc4b 100644
--- a/apps/backend/src/modules/tbank/mappers/operation.mapper.ts
+++ b/apps/backend/src/modules/tbank/mappers/operation.mapper.ts
@@ -108,6 +108,7 @@ export function mapOperation(item: TBankOperationItem, accountId: string): Broke
type,
category: categorizeOperationType(type),
description: item.description || item.name || null,
+ name: item.name ?? null,
state: item.state ?? null,
instrumentUid: item.instrumentUid ?? null,
figi: item.figi ?? null,
diff --git a/apps/backend/src/modules/tbank/mappers/portfolio.mapper.spec.ts b/apps/backend/src/modules/tbank/mappers/portfolio.mapper.spec.ts
index 945b08e..f9881bf 100644
--- a/apps/backend/src/modules/tbank/mappers/portfolio.mapper.spec.ts
+++ b/apps/backend/src/modules/tbank/mappers/portfolio.mapper.spec.ts
@@ -11,7 +11,7 @@ describe('portfolio.mapper', () => {
accessLevel: 'ACCOUNT_ACCESS_LEVEL_FULL_ACCESS',
};
- it('combines portfolio totals, cash, and enriched positions', () => {
+ it('combines portfolio totals and cash (positions removed)', () => {
const result = mapBrokerPortfolio({
account,
portfolio: {
@@ -44,11 +44,5 @@ describe('portfolio.mapper', () => {
expect(result.totals.shares?.value).toBe(1000);
expect(result.cash[0].value).toBe(500);
expect(result.blockedCash[0].value).toBe(10);
- expect(result.positions[0]).toMatchObject({
- ticker: 'SBER',
- name: 'Sberbank',
- quantity: 10,
- currentValue: { value: 2500 },
- });
});
});
diff --git a/apps/backend/src/modules/tbank/mappers/portfolio.mapper.ts b/apps/backend/src/modules/tbank/mappers/portfolio.mapper.ts
index cce8974..3b60b4b 100644
--- a/apps/backend/src/modules/tbank/mappers/portfolio.mapper.ts
+++ b/apps/backend/src/modules/tbank/mappers/portfolio.mapper.ts
@@ -3,6 +3,7 @@ import type {
BrokerMoney,
BrokerPortfolio,
BrokerPosition,
+ BrokerPositionsPage,
} from '../types/broker.types';
import type {
TBankInstrument,
@@ -22,42 +23,58 @@ 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 | number; nano?: number };
+ blockedLots?: { units?: string | number; nano?: number };
+ currentPrice?: { currency?: string; units?: string | number; nano?: number };
+ averagePositionPrice?: { currency?: string; units?: string | number; nano?: number };
+ expectedYield?: { units?: string | number; nano?: number };
+ dailyYield?: { currency?: string; units?: string | number; nano?: number };
+ };
+ instruments: Map>;
+}): BrokerPosition {
+ const quantity = mapQuotationToNumber(input.position.quantity);
+ const currentPrice = mapMoneyValue(input.position.currentPrice);
+ const currentValue =
+ currentPrice && quantity !== null
+ ? {
+ ...currentPrice,
+ units: String(Math.trunc(currentPrice.value * quantity)),
+ nano: 0,
+ value: Number((currentPrice.value * quantity).toFixed(9)),
+ }
+ : null;
+ const instrument =
+ (input.position.instrumentUid && input.instruments.get(input.position.instrumentUid)) ||
+ (input.position.positionUid && input.instruments.get(input.position.positionUid)) ||
+ undefined;
+
+ return {
+ figi: input.position.figi ?? null,
+ instrumentUid: input.position.instrumentUid ?? null,
+ positionUid: input.position.positionUid ?? null,
+ ticker: input.position.ticker || instrument?.ticker || null,
+ classCode: input.position.classCode || instrument?.classCode || null,
+ instrumentType: input.position.instrumentType || instrument?.instrumentType || null,
+ name: instrument?.name ?? null,
+ quantity,
+ blockedLots: mapQuotationToNumber(input.position.blockedLots),
+ currentPrice,
+ currentValue,
+ averagePositionPrice: mapMoneyValue(input.position.averagePositionPrice),
+ expectedYieldPercent: mapQuotationToNumber(input.position.expectedYield),
+ dailyYield: mapMoneyValue(input.position.dailyYield),
+ };
+}
+
export function mapBrokerPortfolio(input: MapBrokerPortfolioInput): BrokerPortfolio {
- const mappedPositions = (input.portfolio.positions ?? []).map((position) => {
- const quantity = mapQuotationToNumber(position.quantity);
- const currentPrice = mapMoneyValue(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 =
- (position.instrumentUid && input.instruments.get(position.instrumentUid)) ||
- (position.positionUid && input.instruments.get(position.positionUid)) ||
- undefined;
-
- return {
- figi: position.figi ?? null,
- instrumentUid: position.instrumentUid ?? null,
- positionUid: position.positionUid ?? null,
- ticker: position.ticker || instrument?.ticker || null,
- classCode: position.classCode || instrument?.classCode || null,
- instrumentType: position.instrumentType || instrument?.instrumentType || null,
- name: instrument?.name ?? null,
- quantity,
- blockedLots: mapQuotationToNumber(position.blockedLots),
- currentPrice,
- currentValue,
- averagePositionPrice: mapMoneyValue(position.averagePositionPrice),
- expectedYieldPercent: mapQuotationToNumber(position.expectedYield),
- dailyYield: mapMoneyValue(position.dailyYield),
- };
- });
-
return {
account: input.account,
totals: {
@@ -78,7 +95,36 @@ export function mapBrokerPortfolio(input: MapBrokerPortfolioInput): BrokerPortfo
},
cash: (input.positions.money ?? []).map(mapMoneyValue).filter(isBrokerMoney),
blockedCash: (input.positions.blocked ?? []).map(mapMoneyValue).filter(isBrokerMoney),
- positions: mappedPositions,
+ asOf: new Date().toISOString(),
+ };
+}
+
+export function mapBrokerPositionsPage(input: {
+ accountId: string;
+ portfolio: TBankPortfolioResponse;
+ instruments: Map>;
+ cursor?: string;
+ limit: number;
+}): BrokerPositionsPage {
+ const allPositions = (input.portfolio.positions ?? []).map((position) =>
+ mapBrokerPosition({ position, instruments: input.instruments }),
+ );
+
+ let startIndex = 0;
+ if (input.cursor) {
+ const found = allPositions.findIndex((p) => p.positionUid === input.cursor);
+ startIndex = found >= 0 ? found + 1 : allPositions.length;
+ }
+
+ const pageItems = allPositions.slice(startIndex, startIndex + input.limit);
+ const hasNext = startIndex + input.limit < allPositions.length;
+ const nextCursor = hasNext ? (pageItems[pageItems.length - 1]?.positionUid ?? null) : null;
+
+ return {
+ accountId: input.accountId,
+ items: pageItems,
+ nextCursor,
+ hasNext,
asOf: new Date().toISOString(),
};
}
diff --git a/apps/backend/src/modules/tbank/services/broker-operation-sync.service.spec.ts b/apps/backend/src/modules/tbank/services/broker-operation-sync.service.spec.ts
index 14bc828..70b5892 100644
--- a/apps/backend/src/modules/tbank/services/broker-operation-sync.service.spec.ts
+++ b/apps/backend/src/modules/tbank/services/broker-operation-sync.service.spec.ts
@@ -32,6 +32,7 @@ describe('BrokerOperationSyncService', () => {
category: 'trade',
description: null,
state: 'OPERATION_STATE_EXECUTED',
+ name: null,
instrumentUid: 'uid-1',
figi: null,
ticker: 'SBER',
@@ -98,6 +99,7 @@ describe('BrokerOperationSyncService', () => {
category: 'trade',
description: null,
state: null,
+ name: null,
instrumentUid: null,
figi: null,
ticker: null,
diff --git a/apps/backend/src/modules/tbank/services/broker-portfolio.service.spec.ts b/apps/backend/src/modules/tbank/services/broker-portfolio.service.spec.ts
index 682d12f..458a7cd 100644
--- a/apps/backend/src/modules/tbank/services/broker-portfolio.service.spec.ts
+++ b/apps/backend/src/modules/tbank/services/broker-portfolio.service.spec.ts
@@ -22,7 +22,7 @@ describe('BrokerPortfolioService', () => {
await expect(service.getPortfolio('missing')).rejects.toThrow(NotFoundException);
});
- it('fetches portfolio and positions through cache', async () => {
+ it('fetches portfolio through cache without positions', async () => {
vi.mocked(accounts.findById).mockResolvedValue({
id: 'acc-1',
type: 'brokerage',
@@ -60,6 +60,7 @@ describe('BrokerPortfolioService', () => {
expect(result.data.account.id).toBe('acc-1');
expect(result.data.cash[0].value).toBe(1000);
+ expect('positions' in result.data).toBe(false);
expect(cache.getOrFetch).toHaveBeenCalledWith(
'tbank:portfolio',
['acc-1'],
@@ -68,73 +69,237 @@ describe('BrokerPortfolioService', () => {
);
});
- it('returns portfolio when one instrument enrichment request fails', async () => {
- vi.mocked(accounts.findById).mockResolvedValue({
- id: 'acc-1',
- type: 'brokerage',
- name: 'Broker',
- status: 'ACCOUNT_STATUS_OPEN',
- openedAt: null,
- accessLevel: null,
+ describe('getPositions', () => {
+ function mockAccount() {
+ vi.mocked(accounts.findById).mockResolvedValue({
+ id: 'acc-1',
+ type: 'brokerage',
+ name: 'Broker',
+ status: 'ACCOUNT_STATUS_OPEN',
+ openedAt: null,
+ accessLevel: null,
+ });
+ }
+
+ function mockCache() {
+ vi.mocked(cache.getOrFetch).mockImplementation(
+ async (_prefix: string, _parts: string[], fetchFn: () => Promise) => ({
+ data: await fetchFn(),
+ fromCache: false,
+ cachedAt: null,
+ }),
+ );
+ }
+
+ 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);
});
- vi.mocked(cache.getOrFetch).mockImplementation(
- async (_prefix: string, _parts: string[], fetchFn: () => Promise) => ({
- data: await fetchFn(),
- fromCache: false,
- cachedAt: null,
- }),
- );
- vi.mocked(client.getServiceClient).mockReturnValue({
- getPortfolio: vi.fn(),
- getPositions: vi.fn(),
- } as any);
- vi.mocked(client.callUnary)
- .mockResolvedValueOnce({
+
+ it('returns first page of positions', async () => {
+ mockAccount();
+ mockCache();
+ 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',
- quantity: { units: '1', nano: 0 },
+ positionUid: 'pos-1',
+ quantity: { units: '10', nano: 0 },
},
{
figi: 'figi-2',
instrumentUid: 'uid-2',
- quantity: { units: '2', nano: 0 },
+ positionUid: 'pos-2',
+ quantity: { units: '20', nano: 0 },
},
],
- })
- .mockResolvedValueOnce({
- accountId: 'acc-1',
- money: [],
- blocked: [],
- securities: [],
});
- vi.mocked(instruments.findByInstrumentUid)
- .mockResolvedValueOnce({
- uid: 'uid-1',
- figi: 'figi-1',
- ticker: 'AAA',
- classCode: 'TQBR',
- name: 'First share',
- instrumentType: 'share',
- })
- .mockRejectedValueOnce(new Error('instrument lookup failed'));
- const service = new BrokerPortfolioService(accounts, instruments, client, cache);
- const result = await service.getPortfolio('acc-1');
+ const service = new BrokerPortfolioService(accounts, instruments, client, cache);
+ const result = await service.getPositions('acc-1', undefined, 1);
- expect(result.data.positions).toHaveLength(2);
- expect(result.data.positions[0]).toMatchObject({
- instrumentUid: 'uid-1',
- ticker: 'AAA',
- name: 'First share',
+ 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');
});
- expect(result.data.positions[1]).toMatchObject({
- instrumentUid: 'uid-2',
- ticker: null,
- name: null,
+
+ it('paginates using cursor', async () => {
+ mockAccount();
+ mockCache();
+ 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 () => {
+ mockAccount();
+ mockCache();
+ 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 cursor/limit/type in key and tbankPositionsTtl', async () => {
+ mockAccount();
+ mockCache();
+ 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', 'some-cursor', 5);
+
+ expect(cache.getOrFetch).toHaveBeenCalledWith(
+ 'tbank:positions',
+ ['acc-1', 'some-cursor', '5', ''],
+ expect.any(Function),
+ 'tbankPositionsTtl',
+ );
+ });
+
+ it('filters by instrument type and caches with type in key', async () => {
+ mockAccount();
+ mockCache();
+ vi.mocked(client.getServiceClient).mockReturnValue({
+ getPortfolio: vi.fn(),
+ } as any);
+ vi.mocked(client.callUnary).mockResolvedValueOnce({
+ accountId: 'acc-1',
+ totalAmountPortfolio: { currency: 'rub', units: '5000', nano: 0 },
+ positions: [
+ {
+ figi: 'f1',
+ instrumentUid: 'u1',
+ positionUid: 'p1',
+ instrumentType: 'share',
+ ticker: 'SBER',
+ quantity: { units: '10', nano: 0 },
+ },
+ {
+ figi: 'f2',
+ instrumentUid: 'u2',
+ positionUid: 'p2',
+ instrumentType: 'bond',
+ ticker: 'SU26238RMFS5',
+ quantity: { units: '5', nano: 0 },
+ },
+ {
+ figi: 'f3',
+ instrumentUid: 'u3',
+ positionUid: 'p3',
+ instrumentType: 'share',
+ ticker: 'GAZP',
+ quantity: { units: '3', nano: 0 },
+ },
+ ],
+ });
+
+ const service = new BrokerPortfolioService(accounts, instruments, client, cache);
+ const result = await service.getPositions('acc-1', undefined, 10, 'share');
+
+ expect(result.data.items).toHaveLength(2);
+ expect(result.data.items.map((i) => i.ticker)).toEqual(['SBER', 'GAZP']);
+ expect(cache.getOrFetch).toHaveBeenCalledWith(
+ 'tbank:positions',
+ ['acc-1', '', '10', 'share'],
+ expect.any(Function),
+ 'tbankPositionsTtl',
+ );
+ });
+
+ it('returns empty items when type filter matches nothing', async () => {
+ mockAccount();
+ mockCache();
+ vi.mocked(client.getServiceClient).mockReturnValue({
+ getPortfolio: vi.fn(),
+ } as any);
+ vi.mocked(client.callUnary).mockResolvedValueOnce({
+ accountId: 'acc-1',
+ totalAmountPortfolio: { currency: 'rub', units: '5000', nano: 0 },
+ positions: [
+ {
+ figi: 'f1',
+ instrumentUid: 'u1',
+ positionUid: 'p1',
+ instrumentType: 'share',
+ ticker: 'SBER',
+ quantity: { units: '10', nano: 0 },
+ },
+ ],
+ });
+
+ const service = new BrokerPortfolioService(accounts, instruments, client, cache);
+ const result = await service.getPositions('acc-1', undefined, 10, 'etf');
+
+ expect(result.data.items).toHaveLength(0);
+ expect(result.data.hasNext).toBe(false);
+ expect(result.data.nextCursor).toBeNull();
});
});
});
diff --git a/apps/backend/src/modules/tbank/services/broker-portfolio.service.ts b/apps/backend/src/modules/tbank/services/broker-portfolio.service.ts
index 7ec14a8..0553119 100644
--- a/apps/backend/src/modules/tbank/services/broker-portfolio.service.ts
+++ b/apps/backend/src/modules/tbank/services/broker-portfolio.service.ts
@@ -1,8 +1,8 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { CacheService } from '../../cache/cache.service';
-import { mapBrokerPortfolio } from '../mappers/portfolio.mapper';
+import { mapBrokerPortfolio, mapBrokerPositionsPage } from '../mappers/portfolio.mapper';
import { TBANK_CACHE_KEYS } from '../tbank.config';
-import type { BrokerPortfolio } from '../types/broker.types';
+import type { BrokerPortfolio, BrokerPositionsPage } from '../types/broker.types';
import type {
TBankInstrument,
TBankPortfolioResponse,
@@ -62,6 +62,57 @@ export class BrokerPortfolioService {
};
}
+ async getPositions(
+ accountId: string,
+ cursor?: string,
+ limit = 10,
+ type?: string,
+ ): 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, cursor ?? '', String(limit), type ?? ''],
+ 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 filteredPositions = type
+ ? (portfolio.positions ?? []).filter(
+ (p) => p.instrumentType?.toLowerCase() === type.toLowerCase(),
+ )
+ : portfolio.positions;
+
+ const filteredPortfolio = { ...portfolio, positions: filteredPositions };
+ const instrumentMap = await this.buildInstrumentMap(filteredPortfolio);
+
+ return mapBrokerPositionsPage({
+ accountId,
+ portfolio: filteredPortfolio,
+ instruments: instrumentMap,
+ cursor,
+ limit,
+ });
+ },
+ 'tbankPositionsTtl',
+ );
+
+ return {
+ data: result.data,
+ meta: { fromCache: result.fromCache, cachedAt: result.cachedAt },
+ };
+ }
+
private async buildInstrumentMap(
portfolio: TBankPortfolioResponse,
): Promise
;
+ if (isLoading) {
+ return (
+
+
+
Брокерские счета
+
+
+ {[1, 2, 3].map((i) => (
+
+ ))}
+
+
+ );
+ }
if (error) return Не удалось загрузить счета
;
return (
diff --git a/apps/frontend/src/pages/broker/BrokerOperationsTable.tsx b/apps/frontend/src/pages/broker/BrokerOperationsTable.tsx
index b5f56df..89539a5 100644
--- a/apps/frontend/src/pages/broker/BrokerOperationsTable.tsx
+++ b/apps/frontend/src/pages/broker/BrokerOperationsTable.tsx
@@ -3,10 +3,10 @@ import type { BrokerMoney, BrokerOperation, BrokerOperationsPage } from '../../a
import {
getBrokerInstrumentPath,
getBrokerOperationImpact,
- getBrokerOperationImpactLabel,
getBrokerOperationTypeLabel,
type BrokerOperationImpact,
} from './brokerDisplay';
+import { TableSkeleton } from '../../components/TableSkeleton';
const tableStyle = {
width: '100%',
@@ -27,33 +27,14 @@ const tdStyle = {
verticalAlign: 'top',
} satisfies React.CSSProperties;
-const impactStyles: Record = {
- adds: {
- background: 'rgba(46, 125, 50, 0.1)',
- color: 'var(--color-positive)',
- },
- reduces: {
- background: 'rgba(198, 40, 40, 0.1)',
- color: 'var(--color-negative)',
- },
- neutral: {
- background: 'rgba(25, 118, 210, 0.1)',
- color: 'var(--color-primary)',
- },
- unknown: {
- background: 'rgba(102, 102, 102, 0.12)',
- color: 'var(--color-text-secondary)',
- },
-};
-
function formatMoney(value: BrokerMoney | null | undefined) {
if (!value) return '-';
-
- return new Intl.NumberFormat('ru-RU', {
+ const formatted = new Intl.NumberFormat('ru-RU', {
style: 'currency',
currency: value.currency || 'RUB',
maximumFractionDigits: 2,
}).format(value.value);
+ return value.value > 0 ? `+${formatted}` : formatted;
}
function formatDate(value: string | null) {
@@ -70,43 +51,48 @@ function moneyColor(impact: BrokerOperationImpact): string {
}
function OperationInstrument({ operation }: { operation: BrokerOperation }) {
- const label = operation.ticker || operation.description || '-';
+ 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 || label === '-') {
- return {label};
- }
-
- return {label};
-}
-
-function OperationType({ operation }: { operation: BrokerOperation }) {
- const impact = getBrokerOperationImpact(operation);
+ if (!path && !name) return -;
+ if (!path) return {name};
+ if (!ticker || ticker === '-') return {name};
return (
-
-
{getBrokerOperationTypeLabel(operation)}
-
- {getBrokerOperationImpactLabel(impact)}
-
+
+
+ {ticker}
+
+ {name && name !== ticker && (
+ {name}
+ )}
);
}
+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;
+
export function BrokerOperationsTable({
isLoading,
page,
@@ -139,20 +125,61 @@ export function BrokerOperationsTable({
>
Операции
-
{isLoading ? (
- Загрузка операций...
+
+
+
+
+ |
+ Дата
+ |
+
+ Тип
+ |
+
+ Инструмент
+ |
+
+ Сумма
+ |
+
+
+
+
+
) : operations.length === 0 ? (
Операций за выбранный период нет
) : (
@@ -182,7 +209,7 @@ export function BrokerOperationsTable({
| {formatDate(operation.date)} |
-
+ {getBrokerOperationTypeLabel(operation)}
|
diff --git a/apps/frontend/src/pages/broker/BrokerPages.test.tsx b/apps/frontend/src/pages/broker/BrokerPages.test.tsx
index 7335e59..a5c4c6a 100644
--- a/apps/frontend/src/pages/broker/BrokerPages.test.tsx
+++ b/apps/frontend/src/pages/broker/BrokerPages.test.tsx
@@ -1,5 +1,5 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
-import { render, screen } from '@testing-library/react';
+import { render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { type ReactElement } from 'react';
import { MemoryRouter, Route, Routes } from 'react-router-dom';
@@ -7,6 +7,8 @@ import { describe, expect, it, vi } from 'vitest';
import * as accountHook from '../../hooks/useBrokerAccounts';
import * as operationsHook from '../../hooks/useBrokerOperations';
import * as portfolioHook from '../../hooks/useBrokerPortfolio';
+import * as positionsHook from '../../hooks/useBrokerPositions';
+import type { BrokerPosition } from '../../api/responses';
import { BrokerAccountDetailPage } from './BrokerAccountDetailPage';
import { BrokerAccountsPage } from './BrokerAccountsPage';
@@ -20,6 +22,45 @@ function renderWithClient(ui: ReactElement, initialEntries = ['/broker']) {
);
}
+function createPosition(input: Partial): BrokerPosition {
+ return {
+ figi: null,
+ instrumentUid: null,
+ positionUid: null,
+ ticker: null,
+ classCode: null,
+ instrumentType: null,
+ name: null,
+ quantity: null,
+ blockedLots: null,
+ currentPrice: null,
+ currentValue: null,
+ averagePositionPrice: null,
+ expectedYieldPercent: null,
+ dailyYield: null,
+ ...input,
+ };
+}
+
+/** Spy on useBrokerPositions and return only positions matching query.type . */
+function mockUseBrokerPositions(...positions: BrokerPosition[]) {
+ return vi.spyOn(positionsHook, 'useBrokerPositions').mockImplementation((_accountId, query) => {
+ const type = query.type?.toLowerCase();
+ const filtered = type ? positions.filter((p) => p.instrumentType?.toLowerCase() === type) : [];
+ return {
+ data: {
+ accountId: 'acc-1',
+ items: filtered,
+ nextCursor: null,
+ hasNext: false,
+ asOf: '2026-06-17T00:00:00.000Z',
+ },
+ isLoading: false,
+ error: null,
+ } as any;
+ });
+}
+
describe('Broker pages', () => {
it('renders broker and IIS accounts', () => {
vi.spyOn(accountHook, 'useBrokerAccounts').mockReturnValue({
@@ -66,24 +107,6 @@ describe('Broker pages', () => {
yields: { expectedPercent: 5, daily: null, dailyPercent: null },
cash: [{ currency: 'RUB', units: '100', nano: 0, value: 100 }],
blockedCash: [],
- positions: [
- {
- 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,
- },
- ],
asOf: '2026-06-16T00:00:00.000Z',
},
isLoading: false,
@@ -124,6 +147,17 @@ describe('Broker pages', () => {
isLoading: false,
error: null,
} as any);
+ mockUseBrokerPositions(
+ createPosition({
+ instrumentUid: 'uid-1',
+ ticker: 'SBER',
+ classCode: 'TQBR',
+ instrumentType: 'share',
+ name: 'Sberbank',
+ quantity: 10,
+ currentValue: { currency: 'RUB', units: '1000', nano: 0, value: 1000 },
+ }),
+ );
renderWithClient(
@@ -151,40 +185,6 @@ describe('Broker pages', () => {
yields: { expectedPercent: 5, daily: null, dailyPercent: null },
cash: [],
blockedCash: [],
- positions: [
- {
- 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,
- },
- ],
asOf: '2026-06-17T00:00:00.000Z',
},
isLoading: false,
@@ -201,6 +201,30 @@ describe('Broker pages', () => {
isLoading: false,
error: null,
} as any);
+ mockUseBrokerPositions(
+ createPosition({
+ instrumentUid: 'share-uid',
+ ticker: 'SBER',
+ classCode: 'TQBR',
+ instrumentType: 'share',
+ name: 'Sberbank',
+ quantity: 10,
+ currentPrice: { currency: 'RUB', units: '250', nano: 0, value: 250 },
+ currentValue: { currency: 'RUB', units: '2500', nano: 0, value: 2500 },
+ expectedYieldPercent: 20,
+ }),
+ createPosition({
+ instrumentUid: 'bond-uid',
+ ticker: 'SU26238RMFS5',
+ classCode: 'TQOB',
+ instrumentType: 'bond',
+ name: 'ОФЗ 26238',
+ quantity: 2,
+ currentPrice: { currency: 'RUB', units: '900', nano: 0, value: 900 },
+ currentValue: { currency: 'RUB', units: '1800', nano: 0, value: 1800 },
+ expectedYieldPercent: 10,
+ }),
+ );
renderWithClient(
@@ -222,7 +246,7 @@ describe('Broker pages', () => {
expect(screen.getByText(/900,00/)).toBeInTheDocument();
});
- it('renders broker operations with Russian labels, linked instruments and impact badges', () => {
+ it('renders broker operations with Russian labels, linked instruments and colored amounts', () => {
vi.spyOn(portfolioHook, 'useBrokerPortfolio').mockReturnValue({
data: {
account: {
@@ -237,7 +261,6 @@ describe('Broker pages', () => {
yields: { expectedPercent: null, daily: null, dailyPercent: null },
cash: [],
blockedCash: [],
- positions: [],
asOf: '2026-06-17T00:00:00.000Z',
},
isLoading: false,
@@ -301,6 +324,7 @@ describe('Broker pages', () => {
isLoading: false,
error: null,
} as any);
+ mockUseBrokerPositions();
renderWithClient(
@@ -311,8 +335,7 @@ describe('Broker pages', () => {
expect(screen.getByText('Выплата купона')).toBeInTheDocument();
expect(screen.getByText('Налог')).toBeInTheDocument();
- expect(screen.getByText('Пополняет')).toBeInTheDocument();
- expect(screen.getByText('Списывает')).toBeInTheDocument();
+ expect(screen.getByText(/\+120,00\s*₽/)).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'SU26238RMFS5' })).toHaveAttribute(
'href',
'/bonds/SU26238RMFS5',
@@ -335,7 +358,6 @@ describe('Broker pages', () => {
yields: { expectedPercent: null, daily: null, dailyPercent: null },
cash: [],
blockedCash: [],
- positions: [],
asOf: '2026-06-17T00:00:00.000Z',
},
isLoading: false,
@@ -412,6 +434,7 @@ describe('Broker pages', () => {
error: null,
}) as any,
);
+ mockUseBrokerPositions();
renderWithClient(
@@ -421,19 +444,26 @@ describe('Broker pages', () => {
);
expect(operationsSpy).toHaveBeenLastCalledWith('acc-1', { limit: 10, cursor: undefined });
- expect(screen.getByText('Страница 1')).toBeInTheDocument();
- await user.click(screen.getByRole('button', { name: 'Вперед' }));
+ const operationsSection = screen.getByRole('heading', { name: 'Операции' }).closest('section')!;
+ const withinOperations = within(operationsSection);
+ const nextButton = withinOperations.getByRole('button', { name: '→' });
+ const prevButton = withinOperations.getByRole('button', { name: '←' });
+ expect(prevButton).toBeDisabled();
+ expect(nextButton).not.toBeDisabled();
+
+ await user.click(nextButton);
expect(operationsSpy).toHaveBeenLastCalledWith('acc-1', {
limit: 10,
cursor: 'cursor-page-2',
});
- expect(screen.getByText('Страница 2')).toBeInTheDocument();
- await user.click(screen.getByRole('button', { name: 'Назад' }));
+ expect(withinOperations.getByText('2')).toBeInTheDocument();
+
+ await user.click(prevButton);
expect(operationsSpy).toHaveBeenLastCalledWith('acc-1', { limit: 10, cursor: undefined });
- expect(screen.getByText('Страница 1')).toBeInTheDocument();
+ expect(withinOperations.getByText('1')).toBeInTheDocument();
});
});
diff --git a/apps/frontend/src/pages/broker/BrokerPositionsSection.tsx b/apps/frontend/src/pages/broker/BrokerPositionsSection.tsx
index 5c58ce5..7dd2189 100644
--- a/apps/frontend/src/pages/broker/BrokerPositionsSection.tsx
+++ b/apps/frontend/src/pages/broker/BrokerPositionsSection.tsx
@@ -1,18 +1,25 @@
+import { useState } from 'react';
import { Link } from 'react-router-dom';
import type { BrokerMoney, BrokerPosition } from '../../api/responses';
-import { getBrokerInstrumentPath, getBrokerPositionGroup } from './brokerDisplay';
+import { getBrokerInstrumentPath } from './brokerDisplay';
+import { TableSkeleton } from '../../components/TableSkeleton';
+import { useBrokerPositions } from '../../hooks/useBrokerPositions';
type BrokerPositionGroupConfig = {
- key: 'shares' | 'bonds' | 'other';
+ key: string;
+ type?: string;
title: string;
};
const GROUPS: BrokerPositionGroupConfig[] = [
- { key: 'shares', title: 'Акции' },
- { key: 'bonds', title: 'Облигации' },
- { key: 'other', title: 'Другие инструменты' },
+ { key: 'shares', type: 'share', title: 'Акции' },
+ { key: 'bonds', type: 'bond', title: 'Облигации' },
+ { key: 'etf', type: 'etf', title: 'ETF' },
+ { key: 'fund', type: 'fund', title: 'Фонды' },
];
+const KNOWN_TYPES = new Set(GROUPS.map((g) => g.type).filter(Boolean));
+
const tableStyle = {
width: '100%',
borderCollapse: 'collapse',
@@ -32,9 +39,26 @@ const tdStyle = {
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',
@@ -65,87 +89,199 @@ function PositionTicker({ position }: { position: BrokerPosition }) {
);
}
-function PositionTable({ title, positions }: { title: string; positions: BrokerPosition[] }) {
- return (
-
- {title}
-
-
-
-
- |
- Тикер
- |
-
- Название
- |
-
- Количество
- |
-
- Цена
- |
-
- Стоимость
- |
-
-
-
- {positions.map((position) => (
-
- |
-
- |
-
-
- {position.name || '-'}
-
- |
-
- {formatQuantity(position.quantity)}
- |
-
- {formatMoney(position.currentPrice)}
- |
-
- {formatMoney(position.currentValue)}
- |
-
- ))}
-
-
-
-
- );
-}
+function PositionGroupTable({
+ accountId,
+ group,
+}: {
+ accountId: string;
+ group: BrokerPositionGroupConfig;
+}) {
+ const [cursorStack, setCursorStack] = useState>([]);
+ const [cursor, setCursor] = useState(undefined);
-export function BrokerPositionsSection({ positions }: { positions: BrokerPosition[] }) {
- const grouped = GROUPS.map((group) => ({
- ...group,
- positions: positions.filter((position) => getBrokerPositionGroup(position) === group.key),
- })).filter((group) => group.positions.length > 0);
+ const query = group.type ? { type: group.type, limit: 10, cursor } : { limit: 100, cursor };
+ const { data: page, isLoading } = useBrokerPositions(accountId, query);
- if (grouped.length === 0) {
- return (
-
- Позиции
- В портфеле нет позиций
-
- );
+ const rawPositions = page?.items ?? [];
+ const positions = group.type
+ ? rawPositions
+ : rawPositions.filter(
+ (p) => p.instrumentType && !KNOWN_TYPES.has(p.instrumentType.toLowerCase()),
+ );
+
+ const pageNumber = cursorStack.length + 1;
+ const canGoBack = cursorStack.length > 0;
+ const canGoForward = Boolean(page?.hasNext && page.nextCursor && !!group.type);
+
+ function handleNext() {
+ const nextCursor = page?.nextCursor;
+ if (!nextCursor || !page?.hasNext || !group.type) return;
+ setCursorStack((prev) => [...prev, cursor]);
+ setCursor(nextCursor);
+ }
+
+ function handlePrevious() {
+ if (cursorStack.length === 0) return;
+ const prev = cursorStack[cursorStack.length - 1];
+ setCursorStack((prevStack) => prevStack.slice(0, -1));
+ setCursor(prev);
+ }
+
+ if (!isLoading && positions.length === 0) {
+ return null;
}
return (
- Позиции
-
- {grouped.map((group) => (
-
- ))}
+
+ {group.title}
+ {group.type && (
+
+
+ ←
+
+
+ {pageNumber}
+
+
+ →
+
+
+ )}
+
+ {isLoading && (
+
+
+
+
+ |
+ Тикер
+ |
+
+ Название
+ |
+
+ Количество
+ |
+
+ Цена
+ |
+
+ Стоимость
+ |
+
+
+
+
+
+ )}
+
+ {!isLoading && positions.length > 0 && (
+
+
+
+
+ |
+ Тикер
+ |
+
+ Название
+ |
+
+ Количество
+ |
+
+ Цена
+ |
+
+ Стоимость
+ |
+
+
+
+ {positions.map((position) => (
+
+ |
+
+ |
+
+
+ {position.name || '-'}
+
+ |
+
+ {formatQuantity(position.quantity)}
+ |
+
+ {formatMoney(position.currentPrice)}
+ |
+
+ {formatMoney(position.currentValue)}
+ |
+
+ ))}
+
+
+
+ )}
);
}
+
+type BrokerPositionsSectionProps = {
+ accountId: string;
+};
+
+export function BrokerPositionsSection({ accountId }: BrokerPositionsSectionProps) {
+ return (
+
+ Позиции
+ {GROUPS.map((group) => (
+
+ ))}
+
+ );
+}
diff --git a/apps/frontend/src/pages/broker/brokerDisplay.test.ts b/apps/frontend/src/pages/broker/brokerDisplay.test.ts
index eabb2f0..8713790 100644
--- a/apps/frontend/src/pages/broker/brokerDisplay.test.ts
+++ b/apps/frontend/src/pages/broker/brokerDisplay.test.ts
@@ -3,7 +3,6 @@ import type { BrokerOperation, BrokerPosition } from '../../api/responses';
import {
getBrokerInstrumentPath,
getBrokerOperationImpact,
- getBrokerOperationImpactLabel,
getBrokerOperationTypeLabel,
getBrokerPositionGroup,
} from './brokerDisplay';
@@ -191,11 +190,4 @@ describe('broker display helpers', () => {
),
).toBe('adds');
});
-
- it('provides Russian impact labels', () => {
- expect(getBrokerOperationImpactLabel('adds')).toBe('Пополняет');
- expect(getBrokerOperationImpactLabel('reduces')).toBe('Списывает');
- expect(getBrokerOperationImpactLabel('neutral')).toBe('Перекладка');
- expect(getBrokerOperationImpactLabel('unknown')).toBe('Неясно');
- });
});
diff --git a/apps/frontend/src/pages/broker/brokerDisplay.ts b/apps/frontend/src/pages/broker/brokerDisplay.ts
index 716fc3d..e3191e9 100644
--- a/apps/frontend/src/pages/broker/brokerDisplay.ts
+++ b/apps/frontend/src/pages/broker/brokerDisplay.ts
@@ -161,16 +161,3 @@ export function getBrokerOperationImpact(
return 'unknown';
}
-
-export function getBrokerOperationImpactLabel(impact: BrokerOperationImpact): string {
- switch (impact) {
- case 'adds':
- return 'Пополняет';
- case 'reduces':
- return 'Списывает';
- case 'neutral':
- return 'Перекладка';
- case 'unknown':
- return 'Неясно';
- }
-}
diff --git a/apps/frontend/src/styles.css b/apps/frontend/src/styles.css
index 5db8428..b741b67 100644
--- a/apps/frontend/src/styles.css
+++ b/apps/frontend/src/styles.css
@@ -33,3 +33,20 @@ a {
color: var(--color-primary);
text-decoration: none;
}
+
+@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;
+}
diff --git a/docs/superpowers/specs/2026-06-17-broker-operations-ui-improvements.md b/docs/superpowers/specs/2026-06-17-broker-operations-ui-improvements.md
new file mode 100644
index 0000000..6e381a3
--- /dev/null
+++ b/docs/superpowers/specs/2026-06-17-broker-operations-ui-improvements.md
@@ -0,0 +1,68 @@
+# Улучшение UI операций и пагинации в брокерском портфеле
+
+Дата: 2026-06-17
+Статус: черновик
+
+## Контекст
+
+Страница брокерского счета (`BrokerAccountDetailPage.tsx`) показывает таблицу операций и таблицы позиций.
+Текущая реализация имеет несколько UI-недостатков, описанных ниже.
+
+Изменения затрагивают только frontend. Бэкенд, OpenAPI-контракт, DTO и codegen не меняются.
+
+## Изменения
+
+### 1. Убрать бейджи impact из таблицы "Операции"
+
+**Проблема:** В колонке "Тип" операции показывается label (напр. "Покупка") и под ним цветной бейдж с текстом "Пополняет", "Списывает", "Перекладка" или "Неясно". Это визуальный шум — пользователю достаточно знать тип операции и сумму.
+
+**Решение:** Удалить `OperationType`-компонент, который рендерит бейдж. Вместо него в ячейке "Тип" отображать только `getBrokerOperationTypeLabel(operation)`.
+
+**Удаляемый код:**
+- Компонент `OperationType` (строки 87-108)
+- Объект `impactStyles` (строки 30-47)
+- Импорт `getBrokerOperationImpactLabel` (не используется больше)
+- Импорт `type BrokerOperationImpact` не удаляется — он всё ещё используется в сигнатуре `moneyColor(impact: BrokerOperationImpact)`
+
+**Сохраняется:**
+- `getBrokerOperationImpact()` — всё ещё нужна для `moneyColor()` (цвет суммы)
+- `moneyColor()` и `formatMoney()` — без изменений
+
+### 2. Префикс "+" для положительных сумм
+
+**Проблема:** Отрицательные суммы уже отображаются с минусом ("−11,00 ₽"), а положительные без знака ("90,00 ₽"). Визуально неочевидно, что это приход.
+
+**Решение:** В функции `formatMoney()` в `BrokerOperationsTable.tsx` добавить префикс `'+'` если `value > 0`.
+
+Цвет суммы по-прежнему определяется через `moneyColor(impact)`.
+
+### 3. Пагинация: keepPreviousData и стилизация
+
+**Проблема (скачок):** При нажатии "Вперед" `isLoading` становится `true` → таблица исчезает, показывается "Загрузка операций..." → затем таблица возвращается с новыми данными.
+
+**Решение:** Использовать `placeholderData: keepPreviousData` из TanStack Query v5 в `useBrokerOperations.ts`.
+
+**Проблема (стили кнопок):** Кнопки "Назад" / "Вперед" используют браузерные стили по умолчанию, выглядят неаккуратно. При `disabled` состоянии визуально не отличить от активного.
+
+**Решение:** Добавить inline-стили для кнопок пагинации с padding, border, background, hover, disabled state.
+
+### 4. "Другие инструменты"
+
+Текущая реализация корректна: в эту секцию попадают позиции с `instrumentType !== 'share' && !== 'bond'` (ETF, валюты, фьючерсы и т.д.). Пока таких позиций нет — секция скрыта. Изменений не требуется.
+
+## Файлы для изменения
+
+| Файл | Что меняется |
+|---|---|
+| `apps/frontend/src/pages/broker/BrokerOperationsTable.tsx` | Удалить `OperationType`, `impactStyles`. Модифицировать `formatMoney` с "+". Стилизовать кнопки пагинации |
+| `apps/frontend/src/hooks/useBrokerOperations.ts` | Добавить `placeholderData: keepPreviousData` |
+| `apps/frontend/src/pages/broker/brokerDisplay.ts` | Удалить `getBrokerOperationImpactLabel` (становится dead code). `BrokerOperationImpact` сохраняется — используется в типе возврата `getBrokerOperationImpact` и параметре `moneyColor` |
+| `apps/frontend/src/pages/broker/brokerDisplay.test.ts` | Удалить тест `getBrokerOperationImpactLabel` |
+| `apps/frontend/src/pages/broker/BrokerPages.test.tsx` | Убрать проверки бейджей "Пополняет"/"Списывает". Обновить тест пагинации для новых стилей |
+
+## Тестирование
+
+- `npm run test:frontend` — существующие тесты должны проходить с учётом изменений
+- Проверить, что пагинация не дёргает интерфейс при переключении страниц
+- Проверить, что положительные суммы отображаются с "+"
+- Проверить, что бейджи impact больше не показываются
diff --git a/docs/superpowers/specs/2026-06-17-broker-positions-pagination-and-loading.md b/docs/superpowers/specs/2026-06-17-broker-positions-pagination-and-loading.md
new file mode 100644
index 0000000..826ed08
--- /dev/null
+++ b/docs/superpowers/specs/2026-06-17-broker-positions-pagination-and-loading.md
@@ -0,0 +1,269 @@
+# Пагинация позиций, скелетоны, название инструмента в операциях
+
+Дата: 2026-06-17
+Статус: черновик
+
+## Контекст
+
+Страница брокерского счёта показывает таблицы позиций (Акции, Облигации, Другие инструменты) и
+операций. Сейчас позиции приходят единым списком внутри `GET /portfolio`, что неэффективно при
+большом количестве позиций. Также отсутствуют loading-индикаторы (просто текст "Загрузка...").
+
+## Цель
+
+1. Выделить позиции в отдельный paginated endpoint (10 на страницу)
+2. Заменить текстовые loading-индикаторы на shimmer-скелетоны
+3. Добавить название инструмента в колонку "Инструмент" таблицы операций
+4. Добавить визуальный loading-индикатор при переключении страниц таблиц
+
+## Изменения
+
+### 1. Backend: отдельный endpoint для позиций
+
+**Новый endpoint:** `GET /api/v1/broker/accounts/:accountId/positions`
+
+Query params:
+- `cursor` — positionUid последней позиции на тек. странице (string, опционально)
+- `limit` — размер страницы (number, default 10)
+
+Response:
+```ts
+interface BrokerPositionsPage {
+ accountId: string;
+ items: BrokerPosition[];
+ nextCursor: string | null;
+ hasNext: boolean;
+ asOf: string;
+}
+```
+
+**Логика:**
+- `broker-portfolio.service.ts` уже делает gRPC вызов `GetPortfolio`, который возвращает все позиции
+- Новый метод `getPositions(accountId, cursor?, limit?)` делает тот же gRPC вызов, кэширует полный список,
+ затем возвращает paginated slice
+- Cursor: позиция с `positionUid === cursor` — начало следующей страницы
+- Кэширование: `CACHE_POSITIONS_TTL` (60s) — отдельно от портфеля, т.к. цены меняются быстро
+- Если `cursor` не указан — возвращается первая страница
+
+**Изменение `BrokerPortfolio`:** убрать `positions` из типа/DTO портфеля.
+Фронтенд теперь грузит позиции отдельным запросом.
+
+**Новый файл:** `dto/broker-positions-page-response.dto.ts`
+
+**Изменяемые backend-файлы:**
+| Файл | Изменение |
+|---|---|
+| `types/broker.types.ts` | Добавить `BrokerPositionsPage` тип. Убрать `positions` из `BrokerPortfolio` |
+| `dto/broker-portfolio-response.dto.ts` | Убрать `positions` из `BrokerPortfolioResponseDto` |
+| `dto/broker-position-response.dto.ts` | Создать (перенести `BrokerPositionResponseDto` сюда из portfolio) |
+| `dto/broker-positions-page-response.dto.ts` | Создать |
+| `services/broker-portfolio.service.ts` | Добавить `getPositions()`, убрать positions из `getPortfolio()` |
+| `mappers/portfolio.mapper.ts` | Разделить маппинг: `mapBrokerPortfolio()` без positions, `mapBrokerPosition()` отдельно |
+| `tbank.controller.ts` | Добавить `GET /accounts/:accountId/positions` |
+| `tbank.config.ts` | Добавить `CACHE_POSITIONS_TTL` (60s) |
+| `operation.mapper.ts` | Добавить `name: item.name ?? null` в `mapOperation()` |
+| `types/broker.types.ts` | Добавить `name` в `BrokerOperation` |
+| `dto/broker-operation-response.dto.ts` | Добавить `name` |
+
+### 2. Frontend: новый хук и типы для позиций
+
+**Новый хук:** `apps/frontend/src/hooks/useBrokerPositions.ts`
+```ts
+export function useBrokerPositions(accountId, query = {}) {
+ return useQuery ({
+ queryKey: ['broker', 'positions', accountId, query],
+ enabled: Boolean(accountId),
+ queryFn: () => getBrokerPositions(accountId!, query),
+ placeholderData: keepPreviousData,
+ staleTime: 60_000,
+ retry: 2,
+ refetchOnWindowFocus: false,
+ });
+}
+```
+
+**Новый API-вызов:** `apps/frontend/src/api/broker.ts`
+```ts
+export function getBrokerPositions(accountId, query) { ... }
+```
+
+**Новые типы в `responses.ts`:**
+- `BrokerPositionsPage` — интерфейс с items, nextCursor, hasNext
+- `name: string | null` в `BrokerOperation`
+- Убрать `positions` из `BrokerPortfolio`
+
+### 3. BrokerPositionsSection с пагинацией
+
+Компонент теперь принимает пропсы для пагинации (как BrokerOperationsTable):
+
+```tsx
+interface Props {
+ page: BrokerPositionsPage | undefined;
+ isLoading: boolean;
+ pageNumber: number;
+ canGoBack: boolean;
+ canGoForward: boolean;
+ onPrevious: () => void;
+ onNext: () => void;
+}
+```
+
+**Логика:**
+- `BrokerPositionsSection` рендерит те же группы (Акции / Облигации / Другие инструменты),
+ но только для позиций с текущей страницы
+- Снизу — кнопки пагинации ← N →
+- При `isLoading=true` — показывать 5 shimmer-строк (вместо реальных данных)
+- При `isLoading=true` и отсутствии данных (первая загрузка) — показывать
+ PositionTable skeleton (shimmer-строки для заглушки)
+
+### 4. Shimmer-скелетоны (CSS + компоненты)
+
+**CSS в `styles.css`:**
+```css
+@keyframes shimmer {
+ 0% { background-position: 200% 0; }
+ 100% { background-position: -200% 0; }
+}
+
+.skeleton {
+ background: linear-gradient(
+ 90deg,
+ #eee 25%,
+ #f5f5f5 50%,
+ #eee 75%
+ );
+ background-size: 200% 100%;
+ animation: shimmer 1.5s ease-in-out infinite;
+ border-radius: 4px;
+}
+```
+
+**Компонент `SkeletonBlock`:**
+```tsx
+function SkeletonBlock({ width, height, borderRadius = 4 }: {
+ width?: string | number;
+ height?: string | number;
+ borderRadius?: number;
+}) {
+ return ;
+}
+```
+
+**BrokerAccountsPage:**
+- Вместо `Загрузка... ` — 3 карточки-скелетона в grid
+```tsx
+{isLoading && (
+
+ {[1,2,3].map(i => (
+
+ ))}
+
+)}
+```
+
+**BrokerAccountDetailPage:**
+- Вместо `Загрузка портфеля... ` — shimmer-блоки под header + cash + positions
+- Позиции грузятся отдельно через `useBrokerPositions` — свой skeleton
+
+### 5. Название инструмента в операциях
+
+**Изменение `OperationInstrument`:**
+
+```tsx
+function OperationInstrument({ operation }: { operation: BrokerOperation }) {
+ const ticker = operation.ticker;
+ const path = getBrokerInstrumentPath({ ticker, instrumentType: operation.instrumentType, classCode: operation.classCode });
+ const name = operation.name || operation.description;
+
+ if (!path && !name) return -;
+ if (!path) return {name};
+
+ return (
+
+ {ticker}
+ {name && name !== ticker && (
+ {name}
+ )}
+
+ );
+}
+```
+
+### 6. Loading-индикатор при переключении страниц (shimmer-строки)
+
+**BrokerOperationsTable:**
+- При `isLoading=true` и наличии `page` (уже были данные, но грузится новая страница):
+ показываем 5 shimmer-строк вместо table body
+- При `isLoading=true` и отсутствии `page` (первая загрузка):
+ показываем header таблицы + 5 shimmer-строк
+- Используем `keepPreviousData` в TanStack Query, но визуально не показываем старые данные —
+ показываем shimmer-строки
+
+**BrokerPositionsSection:**
+- Аналогичное поведение при переключении страниц позиций
+
+**Компонент `TableSkeleton`:**
+```tsx
+function TableSkeleton({ rows = 5 }) {
+ return (
+
+ {Array.from({ length: rows }).map((_, i) => (
+
+ |
+ |
+ |
+ |
+ |
+
+ ))}
+
+ );
+}
+```
+
+Количество колонок и их ширина зависит от таблицы (operations vs positions).
+
+## Файлы для изменения
+
+### Backend
+| Файл | Изменение |
+|---|---|
+| `apps/backend/src/modules/tbank/types/broker.types.ts` | Убрать `positions` из `BrokerPortfolio`. Добавить `BrokerPositionsPage`. Добавить `name` в `BrokerOperation` |
+| `apps/backend/src/modules/tbank/dto/broker-portfolio-response.dto.ts` | Убрать `positions` из `BrokerPortfolioResponseDto`. Вынести `BrokerPositionResponseDto` |
+| `apps/backend/src/modules/tbank/dto/broker-position-response.dto.ts` | Создать (из `BrokerPositionResponseDto`) |
+| `apps/backend/src/modules/tbank/dto/broker-positions-page-response.dto.ts` | Создать |
+| `apps/backend/src/modules/tbank/dto/broker-operation-response.dto.ts` | Добавить `name` |
+| `apps/backend/src/modules/tbank/mappers/portfolio.mapper.ts` | Разделить маппинг portfolio/positions |
+| `apps/backend/src/modules/tbank/mappers/operation.mapper.ts` | Добавить `name` в mapOperation |
+| `apps/backend/src/modules/tbank/services/broker-portfolio.service.ts` | Добавить `getPositions()`, убрать positions из portfolio |
+| `apps/backend/src/modules/tbank/tbank.controller.ts` | Добавить GET /positions endpoint |
+| `apps/backend/src/modules/tbank/tbank.config.ts` | Добавить CACHE_POSITIONS_TTL |
+
+### Frontend
+| Файл | Изменение |
+|---|---|
+| `apps/frontend/src/styles.css` | Добавить `@keyframes shimmer` и `.skeleton` |
+| `apps/frontend/src/api/responses.ts` | Убрать `positions` из `BrokerPortfolio`. Добавить `BrokerPositionsPage`, `name` в `BrokerOperation` |
+| `apps/frontend/src/api/broker.ts` | Добавить `getBrokerPositions()` |
+| `apps/frontend/src/hooks/useBrokerPositions.ts` | Создать |
+| `apps/frontend/src/pages/broker/BrokerPositionsSection.tsx` | Пагинация + shimmer-строки |
+| `apps/frontend/src/pages/broker/BrokerOperationsTable.tsx` | Shimmer-строки при loading, обновить OperationInstrument |
+| `apps/frontend/src/pages/broker/BrokerAccountDetailPage.tsx` | Скелетоны, хук позиций |
+| `apps/frontend/src/pages/broker/BrokerAccountsPage.tsx` | Скелетоны |
+| `apps/frontend/src/pages/broker/BrokerPages.test.tsx` | Обновить тесты |
+
+## Тестирование
+
+- Backend: обновить `broker-portfolio.service.spec.ts` — убрать positions из portfolio, покрыть getPositions
+- Backend: обновить `portfolio.mapper.spec.ts`
+- Frontend: `npm run test:frontend` — все тесты должны проходить
+- Проверить, что скелетоны отображаются при загрузке
+- Проверить, что пагинация позиций работает
+- Проверить, что shimmer-строки показываются при переключении страниц
+- Проверить, что название инструмента отображается в операциях
|