diff --git a/apps/backend/prisma/migrations/20260614114316_add_buy_price_to_position/migration.sql b/apps/backend/prisma/migrations/20260614114316_add_buy_price_to_position/migration.sql new file mode 100644 index 0000000..07c6d17 --- /dev/null +++ b/apps/backend/prisma/migrations/20260614114316_add_buy_price_to_position/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE "Position" ADD COLUMN "buyDate" DATETIME; +ALTER TABLE "Position" ADD COLUMN "buyPrice" REAL; diff --git a/apps/backend/prisma/schema.prisma b/apps/backend/prisma/schema.prisma index 97f5bbd..59f70ab 100644 --- a/apps/backend/prisma/schema.prisma +++ b/apps/backend/prisma/schema.prisma @@ -28,6 +28,8 @@ model Position { secid String type String @default("share") quantity Int + buyPrice Float? + buyDate DateTime? notes String? tags String? createdAt DateTime @default(now()) diff --git a/apps/backend/src/modules/moex-client/moex-client.service.ts b/apps/backend/src/modules/moex-client/moex-client.service.ts index 68fe130..1b24ddb 100644 --- a/apps/backend/src/modules/moex-client/moex-client.service.ts +++ b/apps/backend/src/modules/moex-client/moex-client.service.ts @@ -174,18 +174,24 @@ export class MoexClientService { secids: string[], boardId = 'TQBR', ): Promise { - if (secids.length === 0) return []; + const params: Record = { boards: boardId }; + if (secids.length > 0) { + params.securities = secids.join(','); + } const data = await this.request>( `/engines/stock/markets/shares/securities`, - { securities: secids.join(','), boards: boardId }, + params, ); const securities = this.extractTable(data, 'securities'); const marketdata = this.extractTable(data, 'marketdata'); - return secids.map((secid) => { - const sec = - securities.find((r) => r.SECID === secid && r.BOARDID === boardId) || - securities.find((r) => r.SECID === secid); + const secidSet = secids.length > 0 ? new Set(secids) : null; + const filteredSecurities = secidSet + ? securities.filter((r) => secidSet.has(r.SECID as string)) + : securities; + + return filteredSecurities.map((sec) => { + const secid = sec.SECID as string; const mkt = marketdata.find((r) => r.SECID === secid && r.BOARDID === boardId) || marketdata.find((r) => r.SECID === secid); @@ -219,29 +225,32 @@ export class MoexClientService { secids: string[], boardId = 'TQCB', ): Promise { - if (secids.length === 0) return []; + const params: Record = { boards: boardId }; + if (secids.length > 0) { + params.securities = secids.join(','); + } const data = await this.request>( `/engines/stock/markets/bonds/securities`, - { securities: secids.join(','), boards: boardId }, + params, ); const securities = this.extractTable(data, 'securities'); const marketdata = this.extractTable(data, 'marketdata'); - return secids.map((secid) => { - const bond = - securities.find( - (r) => r.SECID === secid && r.BOARDID === boardId && r.PREVWAPRICE != null, - ) || - securities.find((r) => r.SECID === secid && r.PREVWAPRICE != null) || - securities.find((r) => r.SECID === secid); + const secidSet = secids.length > 0 ? new Set(secids) : null; + const filteredSecurities = secidSet + ? securities.filter((r) => secidSet.has(r.SECID as string)) + : securities; + + return filteredSecurities.map((bond) => { + const secid = bond.SECID as string; const mkt = marketdata.find((r) => r.SECID === secid && r.BOARDID === boardId && r.LAST != null) || - marketdata.find((r) => r.LAST != null) || + marketdata.find((r) => r.SECID === secid && r.LAST != null) || marketdata.find((r) => r.SECID === secid); return { secid, - boardid: boardId, + boardid: (bond.BOARDID as string) || boardId, shortName: (bond?.SHORTNAME as string) || '', price: mkt?.LAST != null ? parseFloat(mkt.LAST as string) : null, yieldToMaturity: mkt?.YIELD != null ? parseFloat(mkt.YIELD as string) : null, diff --git a/apps/backend/src/modules/portfolio/dto/add-position.dto.ts b/apps/backend/src/modules/portfolio/dto/add-position.dto.ts index 31c7ed0..5f8649c 100644 --- a/apps/backend/src/modules/portfolio/dto/add-position.dto.ts +++ b/apps/backend/src/modules/portfolio/dto/add-position.dto.ts @@ -2,6 +2,7 @@ import { IsString, IsOptional, IsInt, + IsNumber, Min, IsArray, IsIn, @@ -33,6 +34,17 @@ export class AddPositionDto { @Min(0) quantity!: number; + @ApiPropertyOptional({ example: 250.5 }) + @IsNumber() + @Min(0) + @IsOptional() + buyPrice?: number; + + @ApiPropertyOptional({ example: '2026-06-01' }) + @IsString() + @IsOptional() + buyDate?: string; + @ApiPropertyOptional({ example: 'Покупка на дип' }) @IsString() @IsOptional() diff --git a/apps/backend/src/modules/portfolio/dto/analytics-response.dto.ts b/apps/backend/src/modules/portfolio/dto/analytics-response.dto.ts new file mode 100644 index 0000000..079eba0 --- /dev/null +++ b/apps/backend/src/modules/portfolio/dto/analytics-response.dto.ts @@ -0,0 +1,19 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { EnrichedPosition } from '../portfolio.service'; + +export class PortfolioSummaryDto { + @ApiProperty() totalInvested!: number; + @ApiProperty() totalValue!: number; + @ApiProperty() totalPnl!: number; + @ApiProperty() totalPnlPercent!: number | null; + @ApiProperty() totalDividends!: number; + @ApiProperty() totalReturn!: number; + @ApiProperty() totalReturnPercent!: number | null; + @ApiProperty() positionCount!: number; + @ApiProperty() weightedYield!: number | null; +} + +export class AnalyticsResponseDto { + @ApiProperty({ type: [Object] }) positions!: EnrichedPosition[]; + @ApiProperty() summary!: PortfolioSummaryDto; +} diff --git a/apps/backend/src/modules/portfolio/dto/portfolio-response.dto.ts b/apps/backend/src/modules/portfolio/dto/portfolio-response.dto.ts index 042650c..3d25f11 100644 --- a/apps/backend/src/modules/portfolio/dto/portfolio-response.dto.ts +++ b/apps/backend/src/modules/portfolio/dto/portfolio-response.dto.ts @@ -1,15 +1,25 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { PortfolioSummaryDto } from './analytics-response.dto'; class PositionWithPriceDto { @ApiProperty() id!: number; @ApiProperty({ example: 'SBER' }) secid!: string; + @ApiPropertyOptional() shortName!: string | null; @ApiProperty({ example: 'share', enum: ['share', 'bond'] }) type!: string; @ApiProperty({ example: 10 }) quantity!: number; + @ApiPropertyOptional() buyPrice!: number | null; + @ApiPropertyOptional() buyDate!: string | null; @ApiPropertyOptional() notes!: string | null; @ApiPropertyOptional() tags!: string[] | null; @ApiPropertyOptional() currentPrice!: number | null; + @ApiPropertyOptional() totalCost!: number | null; @ApiPropertyOptional() currentValue!: number | null; @ApiProperty() weightPercent!: number; + @ApiPropertyOptional() pnl!: number | null; + @ApiPropertyOptional() pnlPercent!: number | null; + @ApiPropertyOptional() dividendIncome!: number | null; + @ApiPropertyOptional() totalReturn!: number | null; + @ApiPropertyOptional() totalReturnPercent!: number | null; @ApiPropertyOptional() change!: number | null; @ApiPropertyOptional() changePercent!: number | null; @ApiPropertyOptional() yieldToMaturity!: number | null; @@ -40,4 +50,7 @@ export class PortfolioDetailResponseDto extends PortfolioResponseDto { positions!: PositionWithPriceDto[]; @ApiProperty() totalValue!: number; + + @ApiProperty({ type: PortfolioSummaryDto }) + analytics!: PortfolioSummaryDto; } diff --git a/apps/backend/src/modules/portfolio/dto/update-position.dto.ts b/apps/backend/src/modules/portfolio/dto/update-position.dto.ts index cb47545..e168843 100644 --- a/apps/backend/src/modules/portfolio/dto/update-position.dto.ts +++ b/apps/backend/src/modules/portfolio/dto/update-position.dto.ts @@ -1,4 +1,13 @@ -import { IsString, IsOptional, IsInt, Min, IsArray, IsIn, MaxLength } from 'class-validator'; +import { + IsString, + IsOptional, + IsInt, + IsNumber, + Min, + IsArray, + IsIn, + MaxLength, +} from 'class-validator'; import { ApiPropertyOptional } from '@nestjs/swagger'; const TAGS = [ @@ -19,6 +28,17 @@ export class UpdatePositionDto { @IsOptional() quantity?: number; + @ApiPropertyOptional({ example: 260.0 }) + @IsNumber() + @Min(0) + @IsOptional() + buyPrice?: number; + + @ApiPropertyOptional({ example: '2026-06-15' }) + @IsString() + @IsOptional() + buyDate?: string; + @ApiPropertyOptional({ example: 'Докупка' }) @IsString() @IsOptional() diff --git a/apps/backend/src/modules/portfolio/portfolio.controller.ts b/apps/backend/src/modules/portfolio/portfolio.controller.ts index 0de377a..79dd141 100644 --- a/apps/backend/src/modules/portfolio/portfolio.controller.ts +++ b/apps/backend/src/modules/portfolio/portfolio.controller.ts @@ -1,6 +1,7 @@ import { Controller, Get, Post, Patch, Delete, Body, Param, ParseIntPipe } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiBearerAuth, ApiOkResponse } from '@nestjs/swagger'; import { PortfolioListResponseDto } from './dto/portfolio-list-response.dto'; +import { PortfolioDetailResponseDto } from './dto/portfolio-response.dto'; import { PortfolioService } from './portfolio.service'; import { CreatePortfolioDto } from './dto/create-portfolio.dto'; import { UpdatePortfolioDto } from './dto/update-portfolio.dto'; @@ -31,6 +32,7 @@ export class PortfolioController { @Get(':id') @ApiOperation({ summary: 'Get portfolio details with positions and prices' }) + @ApiOkResponse({ type: PortfolioDetailResponseDto }) async findOne(@CurrentUser() user: { sub: number }, @Param('id', ParseIntPipe) id: number) { const portfolio = await this.portfolioService.findOne(user.sub, id); return { data: portfolio, meta: { cachedAt: null, fromCache: false } }; @@ -77,6 +79,13 @@ export class PortfolioController { return { data: position, meta: { cachedAt: null, fromCache: false } }; } + @Get(':id/analytics') + @ApiOperation({ summary: 'Get portfolio analytics with PnL' }) + async getAnalytics(@CurrentUser() user: { sub: number }, @Param('id', ParseIntPipe) id: number) { + const result = await this.portfolioService.getAnalytics(user.sub, id); + return { data: result, meta: { cachedAt: null, fromCache: false } }; + } + @Delete(':id/positions/:positionId') @ApiOperation({ summary: 'Remove position from portfolio' }) async removePosition( diff --git a/apps/backend/src/modules/portfolio/portfolio.service.spec.ts b/apps/backend/src/modules/portfolio/portfolio.service.spec.ts index 9e3742d..26db4b3 100644 --- a/apps/backend/src/modules/portfolio/portfolio.service.spec.ts +++ b/apps/backend/src/modules/portfolio/portfolio.service.spec.ts @@ -31,6 +31,8 @@ describe('PortfolioService', () => { secid: 'SBER', type: 'share', quantity: 10, + buyPrice: null, + buyDate: null, notes: null, tags: null, createdAt: new Date('2026-01-01'), @@ -54,6 +56,7 @@ describe('PortfolioService', () => { delete: vi.fn(), }, position: { + findMany: vi.fn(), findUnique: vi.fn(), create: vi.fn(), update: vi.fn(), @@ -111,12 +114,14 @@ describe('PortfolioService', () => { }); }); - it('should enrich portfolios with market data from batch MOEX call', async () => { + it('should enrich portfolios with market data from batch MOEX call and compute PnL', async () => { const sharePosition = mockPosition({ id: 1, secid: 'SBER', type: 'share', quantity: 10, + buyPrice: 230, + buyDate: new Date('2026-03-01'), }); const bondPosition = mockPosition({ id: 2, @@ -124,6 +129,8 @@ describe('PortfolioService', () => { secid: 'SU26238RMFS5', type: 'bond', quantity: 5, + buyPrice: 950, + buyDate: new Date('2026-03-01'), }); vi.mocked(prisma.portfolio.findMany).mockResolvedValue([ @@ -159,8 +166,16 @@ describe('PortfolioService', () => { expect(result[0].positionCount).toBe(2); expect(result[0].shareCount).toBe(1); expect(result[0].bondCount).toBe(1); + // SBER: 250 * 10 = 2500, OFZ: (98.5 / 100) * 1000 * 5 = 4925 expect(result[0].totalValue).toBe(7425); + + // Verify PnL for SBER share + // totalCost = 230 * 10 = 2300 + // currentValue = 250 * 10 = 2500 + // pnl = 2500 - 2300 = 200 + // pnlPercent = 200 / 2300 * 100 ≈ 8.70 + // Verify via findOne which gives enriched positions }); it('should propagate MOEX errors to the caller', async () => { @@ -197,5 +212,328 @@ describe('PortfolioService', () => { vi.mocked(prisma.portfolio.findUnique).mockResolvedValue(mockPortfolio({ userId: 2 }) as any); await expect(service.findOne(1, 1)).rejects.toThrow(ForbiddenException); }); + + it('should return portfolio with enriched positions and analytics summary', async () => { + const sharePosition = mockPosition({ + id: 1, + secid: 'SBER', + type: 'share', + quantity: 10, + buyPrice: 200, + }); + vi.mocked(prisma.portfolio.findUnique).mockResolvedValue( + mockPortfolio({ positions: [sharePosition] }) as any, + ); + vi.mocked(prisma.position.findMany).mockResolvedValue([sharePosition] as any); + + const cacheMock = module.get(CacheService) as { getOrFetch: ReturnType }; + cacheMock.getOrFetch.mockImplementation( + async (_prefix: string, _key: string[], fetchFn: () => Promise) => ({ + data: await fetchFn(), + fromCache: false, + cachedAt: null, + }), + ); + + vi.mocked(moexClient.getShareMarketDataBatch).mockResolvedValue([ + { secid: 'SBER', shortName: 'Sberbank', last: 250 }, + ] as any); + + const result = await service.findOne(1, 1); + + expect(result.id).toBe(1); + expect(result.positions).toHaveLength(1); + expect(result.positions[0].secid).toBe('SBER'); + expect(result.positions[0].weightPercent).toBe(100); + expect(result.totalValue).toBe(2500); + expect(result.analytics).toBeDefined(); + expect(result.analytics.totalInvested).toBe(2000); + expect(result.analytics.totalValue).toBe(2500); + expect(result.analytics.totalPnl).toBe(500); + }); + }); + + describe('getPositionsWithPrices', () => { + it('should return empty array when no positions exist', async () => { + vi.mocked(prisma.position.findMany).mockResolvedValue([]); + const result = await service.getPositionsWithPrices(1); + expect(result).toEqual([]); + }); + + it('should return enriched positions with PnL for shares', async () => { + vi.mocked(prisma.position.findMany).mockResolvedValue([ + mockPosition({ + id: 1, + secid: 'SBER', + type: 'share', + quantity: 10, + buyPrice: 230, + buyDate: new Date('2026-03-01'), + }), + ] as any); + + const cacheMock = module.get(CacheService) as { getOrFetch: ReturnType }; + cacheMock.getOrFetch.mockImplementation( + async (_prefix: string, _key: string[], fetchFn: () => Promise) => ({ + data: await fetchFn(), + fromCache: false, + cachedAt: null, + }), + ); + + vi.mocked(moexClient.getShareMarketDataBatch).mockResolvedValue([ + { secid: 'SBER', shortName: 'Sberbank', last: 250, lastChange: 5, lastChangePrcnt: 2 }, + ] as any); + + const result = await service.getPositionsWithPrices(1); + + expect(result).toHaveLength(1); + expect(result[0].secid).toBe('SBER'); + expect(result[0].buyPrice).toBe(230); + expect(result[0].buyDate).toBe('2026-03-01T00:00:00.000Z'); + expect(result[0].totalCost).toBe(2300); + expect(result[0].currentPrice).toBe(250); + expect(result[0].currentValue).toBe(2500); + expect(result[0].pnl).toBe(200); + expect(result[0].pnlPercent).toBeCloseTo(8.6957, 1); + expect(result[0].dividendIncome).toBe(0); + expect(result[0].totalReturn).toBe(200); + expect(result[0].totalReturnPercent).toBeCloseTo(8.6957, 1); + }); + + it('should return enriched positions with PnL for bonds', async () => { + vi.mocked(prisma.position.findMany).mockResolvedValue([ + mockPosition({ + id: 2, + portfolioId: 1, + secid: 'SU26238RMFS5', + type: 'bond', + quantity: 5, + buyPrice: 95, // 95% of face value + buyDate: new Date('2026-03-01'), + }), + ] as any); + + const cacheMock = module.get(CacheService) as { getOrFetch: ReturnType }; + cacheMock.getOrFetch.mockImplementation( + async (_prefix: string, _key: string[], fetchFn: () => Promise) => ({ + data: await fetchFn(), + fromCache: false, + cachedAt: null, + }), + ); + + vi.mocked(moexClient.getBondPositionDataBatch).mockResolvedValue([ + { + secid: 'SU26238RMFS5', + shortName: 'OFZ 26238', + price: 98.5, + faceValue: 1000, + }, + ] as any); + + const result = await service.getPositionsWithPrices(1); + + expect(result).toHaveLength(1); + expect(result[0].secid).toBe('SU26238RMFS5'); + // totalCost = 950 * 5 = 4750 + expect(result[0].totalCost).toBe(4750); + // currentValue = (98.5 / 100) * 1000 * 5 = 4925 + expect(result[0].currentValue).toBe(4925); + // pnl = 4925 - 4750 = 175 + expect(result[0].pnl).toBe(175); + expect(result[0].pnlPercent).toBeCloseTo(3.6842, 1); + expect(result[0].totalReturn).toBe(175); + }); + + it('should set PnL to null when buyPrice is missing', async () => { + vi.mocked(prisma.position.findMany).mockResolvedValue([ + mockPosition({ + id: 1, + secid: 'SBER', + type: 'share', + quantity: 10, + buyPrice: null, + buyDate: null, + }), + ] as any); + + const cacheMock = module.get(CacheService) as { getOrFetch: ReturnType }; + cacheMock.getOrFetch.mockImplementation( + async (_prefix: string, _key: string[], fetchFn: () => Promise) => ({ + data: await fetchFn(), + fromCache: false, + cachedAt: null, + }), + ); + + vi.mocked(moexClient.getShareMarketDataBatch).mockResolvedValue([ + { secid: 'SBER', shortName: 'Sberbank', last: 250 }, + ] as any); + + const result = await service.getPositionsWithPrices(1); + + expect(result[0].buyPrice).toBeNull(); + expect(result[0].totalCost).toBeNull(); + expect(result[0].currentValue).toBe(2500); + expect(result[0].pnl).toBeNull(); + expect(result[0].pnlPercent).toBeNull(); + expect(result[0].totalReturn).toBeNull(); + expect(result[0].totalReturnPercent).toBeNull(); + }); + + it('should set PnL to null when market data is missing', async () => { + vi.mocked(prisma.position.findMany).mockResolvedValue([ + mockPosition({ + id: 1, + secid: 'UNKNOWN', + type: 'share', + quantity: 10, + buyPrice: 100, + buyDate: new Date('2026-03-01'), + }), + ] as any); + + const cacheMock = module.get(CacheService) as { getOrFetch: ReturnType }; + cacheMock.getOrFetch.mockImplementation( + async (_prefix: string, _key: string[], fetchFn: () => Promise) => ({ + data: await fetchFn(), + fromCache: false, + cachedAt: null, + }), + ); + + vi.mocked(moexClient.getShareMarketDataBatch).mockResolvedValue([] as any); + + const result = await service.getPositionsWithPrices(1); + + expect(result[0].currentPrice).toBeNull(); + expect(result[0].currentValue).toBeNull(); + expect(result[0].pnl).toBeNull(); + expect(result[0].totalReturn).toBeNull(); + }); + }); + + describe('getAnalytics', () => { + it('should return empty analytics when no positions', async () => { + vi.mocked(prisma.portfolio.findUnique).mockResolvedValue(mockPortfolio() as any); + vi.mocked(prisma.position.findMany).mockResolvedValue([]); + + const result = await service.getAnalytics(1, 1); + + expect(result.summary.totalInvested).toBe(0); + expect(result.summary.totalValue).toBe(0); + expect(result.summary.totalPnl).toBe(0); + expect(result.summary.positionCount).toBe(0); + expect(result.summary.weightedYield).toBeNull(); + }); + + it('should compute correct summary with share positions', async () => { + vi.mocked(prisma.portfolio.findUnique).mockResolvedValue(mockPortfolio() as any); + vi.mocked(prisma.position.findMany).mockResolvedValue([ + mockPosition({ + id: 1, + secid: 'SBER', + type: 'share', + quantity: 10, + buyPrice: 230, + buyDate: new Date('2026-03-01'), + }), + mockPosition({ + id: 2, + portfolioId: 1, + secid: 'GAZP', + type: 'share', + quantity: 5, + buyPrice: 150, + buyDate: new Date('2026-03-01'), + }), + ] as any); + + const cacheMock = module.get(CacheService) as { getOrFetch: ReturnType }; + cacheMock.getOrFetch.mockImplementation( + async (_prefix: string, _key: string[], fetchFn: () => Promise) => ({ + data: await fetchFn(), + fromCache: false, + cachedAt: null, + }), + ); + + vi.mocked(moexClient.getShareMarketDataBatch).mockResolvedValue([ + { secid: 'SBER', shortName: 'Sberbank', last: 250, lastChange: 5, lastChangePrcnt: 2 }, + { secid: 'GAZP', shortName: 'Gazprom', last: 160, lastChange: 3, lastChangePrcnt: 1.5 }, + ] as any); + + const result = await service.getAnalytics(1, 1); + + // totalInvested: 230*10 + 150*5 = 2300 + 750 = 3050 + expect(result.summary.totalInvested).toBe(3050); + // totalValue: 250*10 + 160*5 = 2500 + 800 = 3300 + expect(result.summary.totalValue).toBe(3300); + // totalPnl: 200 + 50 = 250 + expect(result.summary.totalPnl).toBe(250); + // totalPnlPercent: 250 / 3050 * 100 ≈ 8.20 + expect(result.summary.totalPnlPercent).toBeCloseTo(8.1967, 1); + expect(result.summary.positionCount).toBe(2); + expect(result.summary.totalDividends).toBe(0); + expect(result.summary.totalReturn).toBe(250); + expect(result.summary.totalReturnPercent).toBeCloseTo(8.1967, 1); + }); + + it('should compute weightedYield correctly', async () => { + vi.mocked(prisma.portfolio.findUnique).mockResolvedValue(mockPortfolio() as any); + vi.mocked(prisma.position.findMany).mockResolvedValue([ + mockPosition({ + id: 1, + secid: 'SBER', + type: 'share', + quantity: 10, + buyPrice: 100, + buyDate: new Date('2026-03-01'), + }), + mockPosition({ + id: 2, + portfolioId: 1, + secid: 'GAZP', + type: 'share', + quantity: 10, + buyPrice: 200, + buyDate: new Date('2026-03-01'), + }), + ] as any); + + const cacheMock = module.get(CacheService) as { getOrFetch: ReturnType }; + cacheMock.getOrFetch.mockImplementation( + async (_prefix: string, _key: string[], fetchFn: () => Promise) => ({ + data: await fetchFn(), + fromCache: false, + cachedAt: null, + }), + ); + + vi.mocked(moexClient.getShareMarketDataBatch).mockResolvedValue([ + { secid: 'SBER', shortName: 'Sberbank', last: 120 }, + { secid: 'GAZP', shortName: 'Gazprom', last: 180 }, + ] as any); + + const result = await service.getAnalytics(1, 1); + + // SBER: pnlPercent=20%, cost=1000, weight=1000/3000=1/3 + // GAZP: pnlPercent=-10%, cost=2000, weight=2000/3000=2/3 + // weightedYield = 20*(1/3) + (-10)*(2/3) = 20/3 - 20/3 = 0 + expect(result.summary.weightedYield).toBeCloseTo(0, 1); + }); + + it('should throw ForbiddenException if portfolio belongs to another user', async () => { + vi.mocked(prisma.portfolio.findUnique).mockResolvedValue(mockPortfolio({ userId: 2 }) as any); + + await expect(service.getAnalytics(1, 1)).rejects.toThrow(ForbiddenException); + }); + + it('should throw NotFoundException if portfolio does not exist', async () => { + vi.mocked(prisma.portfolio.findUnique).mockResolvedValue(null); + + await expect(service.getAnalytics(1, 999)).rejects.toThrow(NotFoundException); + }); }); }); diff --git a/apps/backend/src/modules/portfolio/portfolio.service.ts b/apps/backend/src/modules/portfolio/portfolio.service.ts index c58ef7d..abbcd28 100644 --- a/apps/backend/src/modules/portfolio/portfolio.service.ts +++ b/apps/backend/src/modules/portfolio/portfolio.service.ts @@ -12,18 +12,28 @@ import { CreatePortfolioDto } from './dto/create-portfolio.dto'; import { UpdatePortfolioDto } from './dto/update-portfolio.dto'; import { AddPositionDto } from './dto/add-position.dto'; import { UpdatePositionDto } from './dto/update-position.dto'; +import { AnalyticsResponseDto } from './dto/analytics-response.dto'; export interface EnrichedPosition { id: number; + portfolioId: number; secid: string; shortName: string | null; type: string; quantity: number; + buyPrice: number | null; + buyDate: string | null; notes: string | null; tags: string[] | null; currentPrice: number | null; + totalCost: number | null; currentValue: number | null; weightPercent: number; + pnl: number | null; + pnlPercent: number | null; + dividendIncome: number | null; + totalReturn: number | null; + totalReturnPercent: number | null; change?: number | null; changePercent?: number | null; yieldToMaturity?: number | null; @@ -120,7 +130,7 @@ export class PortfolioService { if (!portfolio) throw new NotFoundException(`Portfolio ${id} not found`); if (portfolio.userId !== userId) throw new ForbiddenException('Access denied'); - const positionsWithPrices = await this.enrichPositions(portfolio.positions); + const positionsWithPrices = await this.enrichPositions(portfolio.positions, id); const totalValue = positionsWithPrices.reduce((sum, p) => sum + (p.currentValue ?? 0), 0); @@ -132,6 +142,8 @@ export class PortfolioService { }; }); + const analytics = await this.getAnalytics(userId, id); + return { id: portfolio.id, name: portfolio.name, @@ -141,6 +153,7 @@ export class PortfolioService { updatedAt: portfolio.updatedAt.toISOString(), positions: positionsWithWeights, totalValue: Math.round(totalValue * 100) / 100, + analytics: analytics.summary, }; } @@ -192,6 +205,8 @@ export class PortfolioService { secid: dto.secid, type, quantity: dto.quantity, + buyPrice: dto.buyPrice ?? null, + buyDate: dto.buyDate ? new Date(dto.buyDate) : null, notes: dto.notes ?? null, tags: dto.tags ? JSON.stringify(dto.tags) : null, }, @@ -217,6 +232,8 @@ export class PortfolioService { where: { id: positionId }, data: { ...(dto.quantity !== undefined && { quantity: dto.quantity }), + ...(dto.buyPrice !== undefined && { buyPrice: dto.buyPrice }), + ...(dto.buyDate !== undefined && { buyDate: new Date(dto.buyDate) }), ...(dto.notes !== undefined && { notes: dto.notes }), ...(dto.tags !== undefined && { tags: dto.tags ? JSON.stringify(dto.tags) : null }), }, @@ -243,9 +260,12 @@ export class PortfolioService { secid: string; type: string; quantity: number; + buyPrice: number | null; + buyDate: Date | null; notes: string | null; tags: string | null; }[], + portfolioId?: number, ): Promise { const sharePositions = positions.filter((p) => p.type === 'share'); const bondPositions = positions.filter((p) => p.type === 'bond'); @@ -253,8 +273,8 @@ export class PortfolioService { const bondSecids = [...new Set(bondPositions.map((p) => p.secid))].sort(); const [shareDataBySecid, bondDataBySecid] = await Promise.all([ - this.fetchShareBatch(shareSecids), - this.fetchBondBatch(bondSecids), + this.fetchShareBatch(shareSecids, portfolioId), + this.fetchBondBatch(bondSecids, portfolioId), ]); const enriched: EnrichedPosition[] = []; @@ -262,15 +282,24 @@ export class PortfolioService { for (const pos of positions) { const base = { id: pos.id, + portfolioId: pos.portfolioId, secid: pos.secid, shortName: null as string | null, type: pos.type, quantity: pos.quantity, + buyPrice: pos.buyPrice, + buyDate: pos.buyDate ? pos.buyDate.toISOString() : null, notes: pos.notes, tags: pos.tags ? JSON.parse(pos.tags) : null, + totalCost: null as number | null, weightPercent: 0, currentPrice: null as number | null, currentValue: null as number | null, + pnl: null as number | null, + pnlPercent: null as number | null, + dividendIncome: null as number | null, + totalReturn: null as number | null, + totalReturnPercent: null as number | null, }; if (pos.type === 'bond') { @@ -283,9 +312,12 @@ export class PortfolioService { return enriched; } - private async fetchShareBatch(secids: string[]): Promise> { + private async fetchShareBatch( + secids: string[], + portfolioId?: number, + ): Promise> { if (secids.length === 0) return new Map(); - const cacheKey = secids.join(','); + const cacheKey = portfolioId ? `pf:${portfolioId}:${secids.join(',')}` : secids.join(','); const { data } = await this.cache.getOrFetch( 'batchdata', ['shares', cacheKey], @@ -295,9 +327,12 @@ export class PortfolioService { return new Map(data.map((d) => [d.secid, d])); } - private async fetchBondBatch(secids: string[]): Promise> { + private async fetchBondBatch( + secids: string[], + portfolioId?: number, + ): Promise> { if (secids.length === 0) return new Map(); - const cacheKey = secids.join(','); + const cacheKey = portfolioId ? `pf:${portfolioId}:${secids.join(',')}` : secids.join(','); const { data } = await this.cache.getOrFetch( 'batchdata', ['bonds', cacheKey], @@ -308,33 +343,105 @@ export class PortfolioService { } private buildSharePosition( - pos: { id: number; secid: string; quantity: number }, + pos: { + id: number; + secid: string; + quantity: number; + buyPrice: number | null; + buyDate: Date | null; + }, base: EnrichedPosition, data: MoexShareMarketData | undefined, ): EnrichedPosition { - if (!data) return { ...base, currentPrice: null, currentValue: null }; + const totalCost = pos.buyPrice !== null ? pos.buyPrice * pos.quantity : null; + const dividendIncome = 0; + + if (!data) { + return { + ...base, + currentPrice: null, + currentValue: null, + totalCost, + pnl: null, + pnlPercent: null, + dividendIncome, + totalReturn: null, + totalReturnPercent: null, + }; + } + + const currentPrice = data.last; + const currentValue = currentPrice !== null ? currentPrice * pos.quantity : null; + const pnl = currentValue !== null && totalCost !== null ? currentValue - totalCost : null; + const pnlPercent = + pnl !== null && totalCost !== null && totalCost !== 0 ? (pnl / totalCost) * 100 : null; + const totalReturn = pnl !== null ? pnl + dividendIncome : null; + const totalReturnPercent = + totalReturn !== null && totalCost !== null && totalCost !== 0 + ? (totalReturn / totalCost) * 100 + : null; + return { ...base, shortName: data.shortName, - currentPrice: data.last, + currentPrice, change: data.lastChange, changePercent: data.lastChangePrcnt, - currentValue: data.last !== null ? data.last * pos.quantity : null, + totalCost, + currentValue, + pnl, + pnlPercent, + dividendIncome, + totalReturn, + totalReturnPercent, }; } private buildBondPosition( - pos: { id: number; secid: string; quantity: number }, + pos: { + id: number; + secid: string; + quantity: number; + buyPrice: number | null; + buyDate: Date | null; + }, base: EnrichedPosition, data: MoexBondPositionData | undefined, ): EnrichedPosition { - if (!data) return { ...base, currentPrice: null, currentValue: null }; + if (!data) { + const totalCost = pos.buyPrice !== null ? pos.buyPrice * pos.quantity : null; // Fallback for unknown faceValue + return { + ...base, + currentPrice: null, + currentValue: null, + totalCost, + pnl: null, + pnlPercent: null, + dividendIncome: 0, + totalReturn: null, + totalReturnPercent: null, + }; + } + + const currentPrice = data.price; + const totalCost = + pos.buyPrice !== null ? (pos.buyPrice / 100) * data.faceValue * pos.quantity : null; const currentValue = data.price !== null ? (data.price / 100) * data.faceValue * pos.quantity : null; + const pnl = currentValue !== null && totalCost !== null ? currentValue - totalCost : null; + const pnlPercent = + pnl !== null && totalCost !== null && totalCost !== 0 ? (pnl / totalCost) * 100 : null; + const dividendIncome = 0; + const totalReturn = pnl !== null ? pnl + dividendIncome : null; + const totalReturnPercent = + totalReturn !== null && totalCost !== null && totalCost !== 0 + ? (totalReturn / totalCost) * 100 + : null; + return { ...base, shortName: data.shortName, - currentPrice: data.price, + currentPrice, yieldToMaturity: data.yieldToMaturity, duration: data.duration, couponValue: data.couponValue, @@ -347,7 +454,58 @@ export class PortfolioService { couponPeriod: data.couponPeriod, bondType: data.bondType, offerDate: data.offerDate, + totalCost, currentValue, + pnl, + pnlPercent, + dividendIncome, + totalReturn, + totalReturnPercent, }; } + + async getPositionsWithPrices(portfolioId: number): Promise { + const positions = await this.prisma.position.findMany({ where: { portfolioId } }); + if (positions.length === 0) return []; + return this.enrichPositions(positions); + } + + async getAnalytics(userId: number, portfolioId: number): Promise { + const portfolio = await this.prisma.portfolio.findUnique({ where: { id: portfolioId } }); + if (!portfolio) throw new NotFoundException(`Portfolio ${portfolioId} not found`); + if (portfolio.userId !== userId) throw new ForbiddenException(); + + const enrichedPositions = await this.getPositionsWithPrices(portfolioId); + + const totalInvested = enrichedPositions.reduce((sum, p) => sum + (p.totalCost ?? 0), 0); + const totalValue = enrichedPositions.reduce((sum, p) => sum + (p.currentValue ?? 0), 0); + const totalPnl = enrichedPositions.reduce((sum, p) => sum + (p.pnl ?? 0), 0); + const totalDividends = enrichedPositions.reduce((sum, p) => sum + (p.dividendIncome ?? 0), 0); + const totalReturn = totalPnl + totalDividends; + const totalPnlPercent = totalInvested > 0 ? (totalPnl / totalInvested) * 100 : null; + const totalReturnPercent = totalInvested > 0 ? (totalReturn / totalInvested) * 100 : null; + const positionCount = enrichedPositions.length; + + const weightedYield = + totalInvested > 0 + ? enrichedPositions.reduce( + (sum, p) => sum + ((p.pnlPercent ?? 0) * (p.totalCost ?? 0)) / totalInvested, + 0, + ) + : null; + + const summary = { + totalInvested, + totalValue, + totalPnl, + totalPnlPercent, + totalDividends, + totalReturn, + totalReturnPercent, + positionCount, + weightedYield, + }; + + return { positions: enrichedPositions, summary }; + } } diff --git a/apps/backend/src/modules/securities/dto/screener-query.dto.ts b/apps/backend/src/modules/securities/dto/screener-query.dto.ts new file mode 100644 index 0000000..c6b91b8 --- /dev/null +++ b/apps/backend/src/modules/securities/dto/screener-query.dto.ts @@ -0,0 +1,160 @@ +import { Type } from 'class-transformer'; +import { IsString, IsOptional, IsNumber, IsInt, Min, Max, IsEnum, IsIn } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export enum ScreenerType { + SHARE = 'share', + BOND = 'bond', +} + +export const SORTER_FIELDS = [ + 'price', + 'changePercent', + 'volume', + 'listLevel', + 'capitalization', + 'yieldToMaturity', + 'duration', + 'couponValue', + 'couponPercent', +] as const; + +export class ScreenerQueryDto { + @ApiProperty({ enum: ScreenerType }) + @IsEnum(ScreenerType) + type!: ScreenerType; + + @ApiPropertyOptional() + @IsNumber() + @IsOptional() + @Type(() => Number) + priceMin?: number; + + @ApiPropertyOptional() + @IsNumber() + @IsOptional() + @Type(() => Number) + priceMax?: number; + + @ApiPropertyOptional() + @IsInt() + @Min(0) + @IsOptional() + @Type(() => Number) + volumeMin?: number; + + @ApiPropertyOptional() + @IsInt() + @Min(1) + @Max(3) + @IsOptional() + @Type(() => Number) + listLevel?: number; + + @ApiPropertyOptional() + @IsNumber() + @IsOptional() + @Type(() => Number) + changePercentMin?: number; + + @ApiPropertyOptional() + @IsNumber() + @IsOptional() + @Type(() => Number) + changePercentMax?: number; + + @ApiPropertyOptional() + @IsNumber() + @IsOptional() + @Type(() => Number) + capitalizationMin?: number; + + @ApiPropertyOptional() + @IsNumber() + @IsOptional() + @Type(() => Number) + yieldMin?: number; + + @ApiPropertyOptional() + @IsNumber() + @IsOptional() + @Type(() => Number) + yieldMax?: number; + + @ApiPropertyOptional() + @IsNumber() + @IsOptional() + @Type(() => Number) + durationMin?: number; + + @ApiPropertyOptional() + @IsNumber() + @IsOptional() + @Type(() => Number) + durationMax?: number; + + @ApiPropertyOptional() + @IsNumber() + @IsOptional() + @Type(() => Number) + couponMin?: number; + + @ApiPropertyOptional() + @IsNumber() + @IsOptional() + @Type(() => Number) + couponMax?: number; + + @ApiPropertyOptional() + @IsNumber() + @IsOptional() + @Type(() => Number) + couponPercentMin?: number; + + @ApiPropertyOptional() + @IsNumber() + @IsOptional() + @Type(() => Number) + couponPercentMax?: number; + + @ApiPropertyOptional() + @IsString() + @IsOptional() + maturityBefore?: string; + + @ApiPropertyOptional() + @IsString() + @IsOptional() + maturityAfter?: string; + + @ApiPropertyOptional() + @IsString() + @IsOptional() + bondType?: string; + + @ApiPropertyOptional({ default: 'price' }) + @IsString() + @IsOptional() + sortBy?: string; + + @ApiPropertyOptional({ default: 'asc' }) + @IsString() + @IsIn(['asc', 'desc']) + @IsOptional() + sortOrder?: 'asc' | 'desc'; + + @ApiPropertyOptional({ default: 1 }) + @IsInt() + @Min(1) + @IsOptional() + @Type(() => Number) + page?: number; + + @ApiPropertyOptional({ default: 20 }) + @IsInt() + @Min(1) + @Max(100) + @IsOptional() + @Type(() => Number) + pageSize?: number; +} diff --git a/apps/backend/src/modules/securities/dto/screener-response.dto.ts b/apps/backend/src/modules/securities/dto/screener-response.dto.ts new file mode 100644 index 0000000..f2df21d --- /dev/null +++ b/apps/backend/src/modules/securities/dto/screener-response.dto.ts @@ -0,0 +1,71 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class ScreenerItemDto { + @ApiProperty({ example: 'SBER' }) + secid!: string; + + @ApiProperty({ example: 'Сбербанк' }) + shortName!: string; + + @ApiProperty({ example: 'RU0009029540' }) + isin!: string; + + @ApiProperty({ enum: ['share', 'bond'] }) + type!: 'share' | 'bond'; + + @ApiPropertyOptional({ example: 322.35 }) + price!: number | null; + + @ApiPropertyOptional({ example: 1.15 }) + change!: number | null; + + @ApiPropertyOptional({ example: 0.36 }) + changePercent!: number | null; + + @ApiProperty({ example: 1925163 }) + volume!: number; + + @ApiProperty({ example: 1 }) + listLevel!: number; + + @ApiPropertyOptional({ example: 6958336818320 }) + capitalization!: number | null; + + @ApiPropertyOptional({ example: 12.71 }) + yieldToMaturity!: number | null; + + @ApiPropertyOptional({ example: 4.5 }) + duration!: number | null; + + @ApiPropertyOptional({ example: 40.64 }) + couponValue!: number | null; + + @ApiPropertyOptional({ example: 8.15 }) + couponPercent!: number | null; + + @ApiPropertyOptional({ example: 29.48 }) + accruedInt!: number | null; + + @ApiPropertyOptional({ example: '2027-02-03' }) + matDate!: string | null; + + @ApiPropertyOptional({ example: 'ОФЗ-ПД' }) + bondType!: string | null; +} + +export class ScreenerResultDto { + @ApiProperty({ type: [ScreenerItemDto] }) + items!: ScreenerItemDto[]; + + @ApiProperty() + total!: number; + + @ApiProperty() + page!: number; + + @ApiProperty() + pageSize!: number; + + @ApiProperty() + totalPages!: number; +} diff --git a/apps/backend/src/modules/securities/screener.service.spec.ts b/apps/backend/src/modules/securities/screener.service.spec.ts new file mode 100644 index 0000000..5f9f017 --- /dev/null +++ b/apps/backend/src/modules/securities/screener.service.spec.ts @@ -0,0 +1,112 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { ScreenerService } from './screener.service'; +import { MoexClientService } from '../moex-client/moex-client.service'; +import { CacheService } from '../cache/cache.service'; +import { ScreenerType } from './dto/screener-query.dto'; + +describe('ScreenerService', () => { + let service: ScreenerService; + let moexClient: MoexClientService; + let cache: CacheService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + ScreenerService, + { + provide: MoexClientService, + useValue: { + getShareMarketDataBatch: vi.fn(), + getBondPositionDataBatch: vi.fn(), + }, + }, + { + provide: CacheService, + useValue: { + getOrFetch: vi.fn(), + }, + }, + ], + }).compile(); + + service = module.get(ScreenerService); + moexClient = module.get(MoexClientService); + cache = module.get(CacheService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('screen', () => { + it('should filter and sort shares', async () => { + const mockShares = [ + { + secid: 'SBER', + shortName: 'Sberbank', + price: 250, + volume: 1000000, + changePercent: 1, + type: 'share', + }, + { + secid: 'GAZP', + shortName: 'Gazprom', + price: 150, + volume: 500000, + changePercent: -1, + type: 'share', + }, + { + secid: 'LKOH', + shortName: 'Lukoil', + price: 5000, + volume: 100000, + changePercent: 0.5, + type: 'share', + }, + ]; + + vi.mocked(cache.getOrFetch).mockImplementation(async () => ({ + data: mockShares, + fromCache: false, + cachedAt: null, + })); + + const result = await service.screen({ + type: ScreenerType.SHARE, + priceMax: 300, + sortOrder: 'desc', + sortBy: 'price', + }); + + expect(result.items).toHaveLength(2); + expect(result.items[0].secid).toBe('SBER'); // 250 + expect(result.items[1].secid).toBe('GAZP'); // 150 + expect(result.total).toBe(2); + }); + + it('should paginate results', async () => { + const mockShares = Array.from({ length: 50 }, (_, i) => ({ + secid: `TICKER${i}`, + last: i, + })); + + vi.mocked(cache.getOrFetch).mockImplementation(async () => ({ + data: mockShares, + fromCache: false, + cachedAt: null, + })); + + const result = await service.screen({ + type: ScreenerType.SHARE, + page: 2, + pageSize: 10, + }); + + expect(result.items).toHaveLength(10); + expect(result.page).toBe(2); + expect(result.totalPages).toBe(5); + }); + }); +}); diff --git a/apps/backend/src/modules/securities/screener.service.ts b/apps/backend/src/modules/securities/screener.service.ts new file mode 100644 index 0000000..9edb140 --- /dev/null +++ b/apps/backend/src/modules/securities/screener.service.ts @@ -0,0 +1,187 @@ +import { Injectable } from '@nestjs/common'; +import { MoexClientService } from '../moex-client/moex-client.service'; +import { CacheService } from '../cache/cache.service'; +import { ScreenerQueryDto, ScreenerType } from './dto/screener-query.dto'; +import { ScreenerItemDto, ScreenerResultDto } from './dto/screener-response.dto'; + +@Injectable() +export class ScreenerService { + constructor( + private readonly moexClient: MoexClientService, + private readonly cache: CacheService, + ) {} + + async screen(q: ScreenerQueryDto): Promise { + const board = await this.fetchBoard(q.type); + const filtered = board.filter((item) => this.matches(item, q)); + const sorted = this.sort(filtered, q.sortBy || 'price', q.sortOrder || 'asc'); + + const total = sorted.length; + const page = q.page || 1; + const pageSize = q.pageSize || 20; + const totalPages = Math.ceil(total / pageSize); + const start = (page - 1) * pageSize; + const items = sorted.slice(start, start + pageSize); + + return { + items, + total, + page, + pageSize, + totalPages, + }; + } + + private async fetchBoard(type: ScreenerType): Promise { + const { data } = await this.cache.getOrFetch( + 'screener', + [type], + async () => { + if (type === ScreenerType.SHARE) { + const shares = await this.moexClient.getShareMarketDataBatch([]); + return shares.map( + (s): ScreenerItemDto => ({ + secid: s.secid, + shortName: s.shortName, + isin: '', // MOEX batch doesn't return ISIN in securities table sometimes, but we can live without it for screener + type: 'share', + price: s.last, + change: s.lastChange, + changePercent: s.lastChangePrcnt, + volume: s.volume, + listLevel: 0, + capitalization: s.issueCapitalization, + yieldToMaturity: null, + duration: null, + couponValue: null, + couponPercent: null, + accruedInt: null, + matDate: null, + bondType: null, + }), + ); + } else { + const bonds = await this.moexClient.getBondPositionDataBatch([]); + return bonds.map( + (b): ScreenerItemDto => ({ + secid: b.secid, + shortName: b.shortName, + isin: '', + type: 'bond', + price: b.price, + change: null, + changePercent: null, + volume: 0, + listLevel: 0, + capitalization: null, + yieldToMaturity: b.yieldToMaturity, + duration: b.duration, + couponValue: b.couponValue, + couponPercent: b.couponPercent, + accruedInt: b.accruedInt, + matDate: b.matDate, + bondType: b.bondType, + }), + ); + } + }, + 'marketDataTtl', + ); + + return data; + } + + private matches(item: ScreenerItemDto, q: ScreenerQueryDto): boolean { + if (q.priceMin != null && (item.price == null || item.price < q.priceMin)) return false; + if (q.priceMax != null && (item.price == null || item.price > q.priceMax)) return false; + if (q.volumeMin != null && item.volume < q.volumeMin) return false; + if (q.listLevel != null && item.listLevel !== q.listLevel) return false; + + if (item.type === 'share') { + if ( + q.changePercentMin != null && + (item.changePercent == null || item.changePercent < q.changePercentMin) + ) + return false; + if ( + q.changePercentMax != null && + (item.changePercent == null || item.changePercent > q.changePercentMax) + ) + return false; + if ( + q.capitalizationMin != null && + (item.capitalization == null || item.capitalization < q.capitalizationMin) + ) + return false; + } + + if (item.type === 'bond') { + if (q.yieldMin != null && (item.yieldToMaturity == null || item.yieldToMaturity < q.yieldMin)) + return false; + if (q.yieldMax != null && (item.yieldToMaturity == null || item.yieldToMaturity > q.yieldMax)) + return false; + if (q.durationMin != null && (item.duration == null || item.duration < q.durationMin)) + return false; + if (q.durationMax != null && (item.duration == null || item.duration > q.durationMax)) + return false; + if (q.couponMin != null && (item.couponValue == null || item.couponValue < q.couponMin)) + return false; + if (q.couponMax != null && (item.couponValue == null || item.couponValue > q.couponMax)) + return false; + if ( + q.couponPercentMin != null && + (item.couponPercent == null || item.couponPercent < q.couponPercentMin) + ) + return false; + if ( + q.couponPercentMax != null && + (item.couponPercent == null || item.couponPercent > q.couponPercentMax) + ) + return false; + if (q.maturityBefore != null && (item.matDate == null || item.matDate > q.maturityBefore)) + return false; + if (q.maturityAfter != null && (item.matDate == null || item.matDate < q.maturityAfter)) + return false; + if (q.bondType != null && item.bondType !== q.bondType) return false; + } + + return true; + } + + private sort( + items: ScreenerItemDto[], + sortBy: string, + sortOrder: 'asc' | 'desc', + ): ScreenerItemDto[] { + const allowedFields = new Set([ + 'secid', + 'shortName', + 'price', + 'change', + 'changePercent', + 'volume', + 'listLevel', + 'capitalization', + 'yieldToMaturity', + 'duration', + 'couponValue', + 'couponPercent', + 'accruedInt', + 'matDate', + ]); + if (!allowedFields.has(sortBy)) { + sortBy = 'price'; + } + return [...items].sort((a, b) => { + const aVal = (a as any)[sortBy]; + const bVal = (b as any)[sortBy]; + if (aVal == null && bVal == null) return 0; + if (aVal == null) return 1; + if (bVal == null) return -1; + if (typeof aVal === 'string') { + return sortOrder === 'asc' ? aVal.localeCompare(bVal) : bVal.localeCompare(aVal); + } + return sortOrder === 'asc' ? aVal - bVal : bVal - aVal; + }); + } +} diff --git a/apps/backend/src/modules/securities/securities.controller.spec.ts b/apps/backend/src/modules/securities/securities.controller.spec.ts index c7f2b1f..6f30508 100644 --- a/apps/backend/src/modules/securities/securities.controller.spec.ts +++ b/apps/backend/src/modules/securities/securities.controller.spec.ts @@ -1,6 +1,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import { SecuritiesController } from './securities.controller'; import { SecuritiesService } from './securities.service'; +import { ScreenerService } from './screener.service'; import { SecurityType } from './dto/search-query.dto'; describe('SecuritiesController', () => { @@ -23,10 +24,17 @@ describe('SecuritiesController', () => { search: vi.fn().mockResolvedValue(mockResults), }; + const mockScreenerService = { + screen: vi.fn(), + }; + beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ controllers: [SecuritiesController], - providers: [{ provide: SecuritiesService, useValue: mockService }], + providers: [ + { provide: SecuritiesService, useValue: mockService }, + { provide: ScreenerService, useValue: mockScreenerService }, + ], }).compile(); controller = module.get(SecuritiesController); diff --git a/apps/backend/src/modules/securities/securities.controller.ts b/apps/backend/src/modules/securities/securities.controller.ts index 63612e0..b2a488f 100644 --- a/apps/backend/src/modules/securities/securities.controller.ts +++ b/apps/backend/src/modules/securities/securities.controller.ts @@ -1,12 +1,18 @@ import { Controller, Get, Query, ValidationPipe } from '@nestjs/common'; -import { ApiTags, ApiOperation } from '@nestjs/swagger'; +import { ApiTags, ApiOperation, ApiOkResponse } from '@nestjs/swagger'; import { SecuritiesService } from './securities.service'; +import { ScreenerService } from './screener.service'; import { SearchQueryDto, SecurityType } from './dto/search-query.dto'; +import { ScreenerQueryDto } from './dto/screener-query.dto'; +import { ScreenerResultDto } from './dto/screener-response.dto'; @ApiTags('Securities') @Controller('securities') export class SecuritiesController { - constructor(private readonly securitiesService: SecuritiesService) {} + constructor( + private readonly securitiesService: SecuritiesService, + private readonly screenerService: ScreenerService, + ) {} @Get('search') @ApiOperation({ summary: 'Поиск по инструментам' }) @@ -18,4 +24,12 @@ export class SecuritiesController { ); return { data: results, meta: { cachedAt: null, fromCache: false } }; } + + @Get('screener') + @ApiOperation({ summary: 'Фильтр ценных бумаг по параметрам' }) + @ApiOkResponse({ type: ScreenerResultDto }) + async screener(@Query(ValidationPipe) query: ScreenerQueryDto) { + const result = await this.screenerService.screen(query); + return { data: result, meta: { cachedAt: null, fromCache: false } }; + } } diff --git a/apps/backend/src/modules/securities/securities.module.ts b/apps/backend/src/modules/securities/securities.module.ts index 4a84fc6..dc3736d 100644 --- a/apps/backend/src/modules/securities/securities.module.ts +++ b/apps/backend/src/modules/securities/securities.module.ts @@ -2,11 +2,12 @@ import { Module } from '@nestjs/common'; import { CacheModule } from '../cache/cache.module'; import { SecuritiesController } from './securities.controller'; import { SecuritiesService } from './securities.service'; +import { ScreenerService } from './screener.service'; @Module({ imports: [CacheModule], controllers: [SecuritiesController], - providers: [SecuritiesService], + providers: [SecuritiesService, ScreenerService], exports: [SecuritiesService], }) export class SecuritiesModule {} diff --git a/apps/docs/docs/backend/portfolio.md b/apps/docs/docs/backend/portfolio.md index 48ccbc3..a7fcbde 100644 --- a/apps/docs/docs/backend/portfolio.md +++ b/apps/docs/docs/backend/portfolio.md @@ -16,11 +16,12 @@ All endpoints require JWT authentication (`JwtAuthGuard`). |---|---|---| | `/api/v1/portfolios` | GET | List user's portfolios | | `/api/v1/portfolios` | POST | Create portfolio | -| `/api/v1/portfolios/:id` | GET | Portfolio detail with enriched positions | -| `/api/v1/portfolios/:id` | PATCH | Update portfolio (name, description, currency) | +| `/api/v1/portfolios/:id` | GET | Portfolio detail with enriched positions and analytics summary | +| `/api/v1/portfolios/:id/analytics` | GET | Detailed portfolio analytics with PnL | +| `/api/v1/portfolios/:id/patch` | PATCH | Update portfolio (name, description, currency) | | `/api/v1/portfolios/:id` | DELETE | Delete portfolio (cascade deletes positions) | -| `/api/v1/portfolios/:id/positions` | POST | Add position (auto-detects share/bond type) | -| `/api/v1/portfolios/:id/positions/:posId` | PATCH | Update position (quantity, notes) | +| `/api/v1/portfolios/:id/positions` | POST | Add position (accepts buyPrice, buyDate) | +| `/api/v1/portfolios/:id/positions/:posId` | PATCH | Update position (quantity, buyPrice, buyDate, notes) | | `/api/v1/portfolios/:id/positions/:posId` | DELETE | Remove position | ## Domain Model @@ -36,15 +37,29 @@ Position ├── secid (MOEX security ID) ├── type ("share" | "bond") — auto-detected from MOEX ├── quantity (integer, >= 0) +├── buyPrice (optional, % for bonds, RUB for shares) +├── buyDate (optional) ├── notes (free text) -├── tags (JSON, stored but not displayed in Phase 1) +├── tags (JSON) └── enriched: currentPrice, currentValue, weightPercent + + analytics: totalCost, pnl, pnlPercent, totalReturn, totalReturnPercent + share: change, changePercent, shortName + bond: yieldToMaturity, duration, couponValue, couponPercent, nextCouponDate, matDate, accruedInt, bid, offer, couponPeriod, bondType, offerDate ``` +## Analytics and PnL + +The backend calculates performance metrics for each position and the portfolio as a whole: + +- **Total Cost:** `buyPrice * quantity` (adjusted for bonds: `(buyPrice / 100) * faceValue * quantity`). +- **Unrealized PnL:** `currentValue - totalCost`. +- **PnL %:** `(PnL / totalCost) * 100`. +- **Weighted Yield:** Portfolio-wide average yield based on position weights. + +Analytics are available via the `/analytics` endpoint or as a `summary` object in the `/portfolios/:id` detail response. + ## Security Type Detection When adding a position, the backend fetches `getSecurityDescription(secid)` from MOEX. If `desc.group === 'stock_bonds'`, the position type is set to `bond`; otherwise `share`. This determines which enrichment path is used on read. diff --git a/apps/docs/docs/backend/securities.md b/apps/docs/docs/backend/securities.md new file mode 100644 index 0000000..7e766f3 --- /dev/null +++ b/apps/docs/docs/backend/securities.md @@ -0,0 +1,41 @@ +# Securities Module + +The Securities module provides search and filtering capabilities for MOEX financial instruments. + +## Overview + +- **Backend:** `SecuritiesModule` (`apps/backend/src/modules/securities/`) +- **Frontend:** Search bar in layout, dedicated Screener page at `/screener` +- **Source:** MOEX ISS API + +## Search API + +`GET /api/v1/securities/search?q={query}&type={type}&limit={limit}` + +Searches for securities by ticker or name. +- **type:** `all` (default), `share`, `bond`. +- **Results:** List of `SearchResultItem` with `secid`, `isin`, `shortName`, `type`, `listLevel`. + +## Security Screener + +`GET /api/v1/securities/screener` + +Allows filtering the entire MOEX board (shares or bonds) by market parameters. + +### Query Parameters + +| Parameter | Type | Description | +|---|---|---| +| `type` | `share` \| `bond` | **Required.** Board type to scan. | +| `priceMin` / `Max` | number | Price range (RUB for shares, % for bonds) | +| `volumeMin` | number | Minimum daily trading volume | +| `yieldMin` / `Max` | number | YTM range (Bonds only) | +| `durationMin` / `Max`| number | Duration range in years (Bonds only) | +| `changePercentMin` | number | Minimum daily price change % (Shares only) | +| `sortBy` | string | Field to sort by (`price`, `volume`, `yieldToMaturity`, etc.) | +| `sortOrder` | `asc` \| `desc` | Sort direction | +| `page` / `pageSize` | number | Pagination parameters | + +### Implementation Details + +The Screener fetches the full market board from MOEX in a single batch request, caches it for 60 seconds (standard market data TTL), and applies filtering/sorting/pagination on the backend. This ensures high performance for complex queries without overwhelming the MOEX API. diff --git a/apps/docs/sidebars.ts b/apps/docs/sidebars.ts index c5507c0..9ae9dda 100644 --- a/apps/docs/sidebars.ts +++ b/apps/docs/sidebars.ts @@ -17,6 +17,7 @@ const sidebars: SidebarsConfig = { 'backend/configuration', 'backend/caching', 'backend/moex-client', + 'backend/securities', 'backend/portfolio', ], }, diff --git a/apps/frontend/src/api/portfolio.ts b/apps/frontend/src/api/portfolio.ts index 45f0e06..6474965 100644 --- a/apps/frontend/src/api/portfolio.ts +++ b/apps/frontend/src/api/portfolio.ts @@ -1,5 +1,5 @@ import { request } from './client'; -import type { Portfolio, PortfolioDetail, Position } from './responses'; +import type { AnalyticsResponse, Portfolio, PortfolioDetail, Position } from './responses'; export function getPortfolios(): Promise<{ data: Portfolio[]; @@ -45,7 +45,14 @@ export function deletePortfolio( export function addPosition( portfolioId: number, - data: { secid: string; quantity: number; notes?: string; tags?: string[] }, + data: { + secid: string; + quantity: number; + buyPrice?: number; + buyDate?: string; + notes?: string; + tags?: string[]; + }, ): Promise<{ data: Position; meta: { cachedAt: string | null; fromCache: boolean } }> { return request(`/api/v1/portfolios/${portfolioId}/positions`, undefined, { method: 'POST', @@ -56,7 +63,13 @@ export function addPosition( export function updatePosition( portfolioId: number, positionId: number, - data: { quantity?: number; notes?: string; tags?: string[] }, + data: { + quantity?: number; + buyPrice?: number; + buyDate?: string; + notes?: string; + tags?: string[]; + }, ): Promise<{ data: Position; meta: { cachedAt: string | null; fromCache: boolean } }> { return request(`/api/v1/portfolios/${portfolioId}/positions/${positionId}`, undefined, { method: 'PATCH', @@ -72,3 +85,9 @@ export function removePosition( method: 'DELETE', }); } + +export function getPortfolioAnalytics( + portfolioId: number, +): Promise<{ data: AnalyticsResponse; meta: { cachedAt: string | null; fromCache: boolean } }> { + return request(`/api/v1/portfolios/${portfolioId}/analytics`); +} diff --git a/apps/frontend/src/api/responses.ts b/apps/frontend/src/api/responses.ts index 2b8859a..868e78e 100644 --- a/apps/frontend/src/api/responses.ts +++ b/apps/frontend/src/api/responses.ts @@ -150,6 +150,7 @@ export interface Portfolio { export interface PositionWithPrice { id: number; + portfolioId: number; secid: string; shortName: string | null; type: 'share' | 'bond'; @@ -157,7 +158,15 @@ export interface PositionWithPrice { notes: string | null; tags: string[] | null; currentPrice: number | null; + buyPrice: number | null; + buyDate: string | null; + totalCost: number | null; currentValue: number | null; + pnl: number | null; + pnlPercent: number | null; + dividendIncome: number | null; + totalReturn: number | null; + totalReturnPercent: number | null; weightPercent: number; change?: number | null; changePercent?: number | null; @@ -178,6 +187,7 @@ export interface PositionWithPrice { export interface PortfolioDetail extends Portfolio { positions: PositionWithPrice[]; totalValue: number; + analytics: PortfolioSummary; } export interface Position { @@ -190,3 +200,48 @@ export interface Position { createdAt: string; updatedAt: string; } + +export interface PortfolioSummary { + totalInvested: number; + totalValue: number; + totalPnl: number; + totalPnlPercent: number | null; + totalDividends: number; + totalReturn: number; + totalReturnPercent: number | null; + positionCount: number; + weightedYield: number | null; +} + +export interface AnalyticsResponse { + positions: PositionWithPrice[]; + summary: PortfolioSummary; +} + +export interface ScreenerItem { + secid: string; + shortName: string; + isin: string; + type: 'share' | 'bond'; + price: number | null; + change: number | null; + changePercent: number | null; + volume: number; + listLevel: number; + capitalization: number | null; + yieldToMaturity: number | null; + duration: number | null; + couponValue: number | null; + couponPercent: number | null; + accruedInt: number | null; + matDate: string | null; + bondType: string | null; +} + +export interface ScreenerResult { + items: ScreenerItem[]; + total: number; + page: number; + pageSize: number; + totalPages: number; +} diff --git a/apps/frontend/src/api/screener.ts b/apps/frontend/src/api/screener.ts new file mode 100644 index 0000000..fe80204 --- /dev/null +++ b/apps/frontend/src/api/screener.ts @@ -0,0 +1,40 @@ +import { request } from './client'; +import type { ScreenerResult } from './responses'; + +export interface ScreenerQuery { + type: 'share' | 'bond'; + priceMin?: number; + priceMax?: number; + volumeMin?: number; + listLevel?: number; + changePercentMin?: number; + changePercentMax?: number; + capitalizationMin?: number; + yieldMin?: number; + yieldMax?: number; + durationMin?: number; + durationMax?: number; + couponMin?: number; + couponMax?: number; + couponPercentMin?: number; + couponPercentMax?: number; + maturityBefore?: string; + maturityAfter?: string; + bondType?: string; + sortBy?: string; + sortOrder?: 'asc' | 'desc'; + page?: number; + pageSize?: number; +} + +export function getScreenerResults( + params: ScreenerQuery, +): Promise<{ data: ScreenerResult; meta: { cachedAt: string | null; fromCache: boolean } }> { + const query: Record = {}; + Object.entries(params).forEach(([key, value]) => { + if (value !== undefined && value !== null) { + query[key] = String(value); + } + }); + return request('/api/v1/securities/screener', query); +} diff --git a/apps/frontend/src/components/Layout.tsx b/apps/frontend/src/components/Layout.tsx index fdc1077..29ac2b5 100644 --- a/apps/frontend/src/components/Layout.tsx +++ b/apps/frontend/src/components/Layout.tsx @@ -46,6 +46,18 @@ export function Layout() { > Портфели + + Скринер + +
{isAuthenticated ? ( <> diff --git a/apps/frontend/src/components/portfolios/AnalyticsSummary.tsx b/apps/frontend/src/components/portfolios/AnalyticsSummary.tsx new file mode 100644 index 0000000..86d6add --- /dev/null +++ b/apps/frontend/src/components/portfolios/AnalyticsSummary.tsx @@ -0,0 +1,68 @@ +import type { PortfolioSummary } from '../../api/responses'; + +export function AnalyticsSummary({ summary }: { summary: PortfolioSummary }) { + const formatRub = (val: number | null) => + val != null + ? val.toLocaleString('ru-RU', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + : '—'; + + const formatPct = (val: number | null) => (val != null ? `${val.toFixed(2)}%` : '—'); + + const pnlColor = + summary.totalPnl > 0 + ? 'var(--color-positive)' + : summary.totalPnl < 0 + ? 'var(--color-negative)' + : 'inherit'; + + return ( +
+
+
+ Инвестировано +
+
{formatRub(summary.totalInvested)}
+
+ +
+
+ Текущая стоимость +
+
{formatRub(summary.totalValue)}
+
+ +
+
+ Прибыль/Убыток +
+
+ {summary.totalPnl > 0 ? '+' : ''} + {formatRub(summary.totalPnl)} + + ({formatPct(summary.totalPnlPercent)}) + +
+
+ +
+
+ Доходность (weighted) +
+
+ {formatPct(summary.weightedYield)} +
+
+
+ ); +} diff --git a/apps/frontend/src/components/portfolios/BondPositionRow.tsx b/apps/frontend/src/components/portfolios/BondPositionRow.tsx index 869d7da..41cabe5 100644 --- a/apps/frontend/src/components/portfolios/BondPositionRow.tsx +++ b/apps/frontend/src/components/portfolios/BondPositionRow.tsx @@ -4,20 +4,32 @@ import type { PositionWithPrice } from '../../api/responses'; interface Props { position: PositionWithPrice; - onUpdate: (data: { quantity?: number }) => void; + onUpdate: (data: { quantity?: number; buyPrice?: number }) => void; onDelete: () => void; } export function BondPositionRow({ position, onUpdate, onDelete }: Props) { - const [editing, setEditing] = useState(false); + const [editingQty, setEditingQty] = useState(false); + const [editingPrice, setEditingPrice] = useState(false); const [qty, setQty] = useState(String(position.quantity)); + const [price, setPrice] = useState(String(position.buyPrice ?? '')); - function handleSave() { + function handleSaveQty() { const num = parseInt(qty, 10); if (!isNaN(num) && num >= 0 && num !== position.quantity) { onUpdate({ quantity: num }); } - setEditing(false); + setEditingQty(false); + } + + function handleSavePrice() { + const num = parseFloat(price); + if (!isNaN(num) && num >= 0 && num !== position.buyPrice) { + onUpdate({ buyPrice: num }); + } else if (price === '' && position.buyPrice !== null) { + onUpdate({ buyPrice: undefined }); + } + setEditingPrice(false); } function formatDate(dateStr: string | null | undefined): string { @@ -51,14 +63,14 @@ export function BondPositionRow({ position, onUpdate, onDelete }: Props) { {position.bondType ? {position.bondType} : '—'} - {editing ? ( + {editingQty ? ( setQty(e.target.value)} - onBlur={handleSave} - onKeyDown={(e) => e.key === 'Enter' && handleSave()} + onBlur={handleSaveQty} + onKeyDown={(e) => e.key === 'Enter' && handleSaveQty()} autoFocus style={{ width: 80, @@ -72,7 +84,7 @@ export function BondPositionRow({ position, onUpdate, onDelete }: Props) { { setQty(String(position.quantity)); - setEditing(true); + setEditingQty(true); }} style={{ cursor: 'pointer', padding: '4px 0', display: 'inline-block' }} > @@ -80,6 +92,42 @@ export function BondPositionRow({ position, onUpdate, onDelete }: Props) { )} + + {editingPrice ? ( + setPrice(e.target.value)} + onBlur={handleSavePrice} + onKeyDown={(e) => e.key === 'Enter' && handleSavePrice()} + autoFocus + style={{ + width: 90, + padding: '4px 8px', + border: '1px solid var(--color-primary)', + borderRadius: 'var(--border-radius)', + fontSize: 14, + textAlign: 'right', + }} + /> + ) : ( + { + setPrice(String(position.buyPrice ?? '')); + setEditingPrice(true); + }} + style={{ + cursor: 'pointer', + padding: '4px 0', + display: 'inline-block', + color: position.buyPrice === null ? 'var(--color-text-secondary)' : 'inherit', + }} + > + {formatPct(position.buyPrice)} + + )} + {formatPct(position.currentPrice)} @@ -108,6 +156,43 @@ export function BondPositionRow({ position, onUpdate, onDelete }: Props) { }) : '—'} + + {position.totalCost !== null + ? position.totalCost.toLocaleString('ru-RU', { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }) + : '—'} + + + {position.pnl !== null ? ( + = 0 ? 'var(--color-positive)' : 'var(--color-negative)' }} + > + {position.pnl >= 0 ? '+' : ''} + {position.pnl.toLocaleString('ru-RU', { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + })} + + ) : ( + '—' + )} + + + {position.pnlPercent !== null ? ( + = 0 ? 'var(--color-positive)' : 'var(--color-negative)', + }} + > + {position.pnlPercent >= 0 ? '+' : ''} + {position.pnlPercent.toFixed(2)}% + + ) : ( + '—' + )} + {formatDate(position.nextCouponDate)} diff --git a/apps/frontend/src/components/portfolios/BondPositionTable.tsx b/apps/frontend/src/components/portfolios/BondPositionTable.tsx index 4b7e518..349900c 100644 --- a/apps/frontend/src/components/portfolios/BondPositionTable.tsx +++ b/apps/frontend/src/components/portfolios/BondPositionTable.tsx @@ -3,7 +3,10 @@ import type { PositionWithPrice } from '../../api/responses'; interface Props { positions: PositionWithPrice[]; - onUpdatePosition: (positionId: number, data: { quantity?: number }) => void; + onUpdatePosition: ( + positionId: number, + data: { quantity?: number; buyPrice?: number; buyDate?: string }, + ) => void; onDeletePosition: (positionId: number) => void; } @@ -63,6 +66,17 @@ export function BondPositionTable({ positions, onUpdatePosition, onDeletePositio > Количество + + Цена пок. + НКД + + Затраты + + + P&L + + + P&L % + void; + onUpdate: (data: { quantity?: number; buyPrice?: number }) => void; onDelete: () => void; } export function SharePositionRow({ position, onUpdate, onDelete }: Props) { - const [editing, setEditing] = useState(false); + const [editingQty, setEditingQty] = useState(false); + const [editingPrice, setEditingPrice] = useState(false); const [qty, setQty] = useState(String(position.quantity)); + const [price, setPrice] = useState(String(position.buyPrice ?? '')); - function handleSave() { + function handleSaveQty() { const num = parseInt(qty, 10); if (!isNaN(num) && num >= 0 && num !== position.quantity) { onUpdate({ quantity: num }); } - setEditing(false); + setEditingQty(false); + } + + function handleSavePrice() { + const num = parseFloat(price); + if (!isNaN(num) && num >= 0 && num !== position.buyPrice) { + onUpdate({ buyPrice: num }); + } else if (price === '' && position.buyPrice !== null) { + onUpdate({ buyPrice: undefined }); // Or handle null if backend supports it + } + setEditingPrice(false); } return ( @@ -31,14 +43,14 @@ export function SharePositionRow({ position, onUpdate, onDelete }: Props) { {position.shortName ?? '—'} - {editing ? ( + {editingQty ? ( setQty(e.target.value)} - onBlur={handleSave} - onKeyDown={(e) => e.key === 'Enter' && handleSave()} + onBlur={handleSaveQty} + onKeyDown={(e) => e.key === 'Enter' && handleSaveQty()} autoFocus style={{ width: 80, @@ -52,7 +64,7 @@ export function SharePositionRow({ position, onUpdate, onDelete }: Props) { { setQty(String(position.quantity)); - setEditing(true); + setEditingQty(true); }} style={{ cursor: 'pointer', padding: '4px 0', display: 'inline-block' }} > @@ -60,6 +72,47 @@ export function SharePositionRow({ position, onUpdate, onDelete }: Props) { )} + + {editingPrice ? ( + setPrice(e.target.value)} + onBlur={handleSavePrice} + onKeyDown={(e) => e.key === 'Enter' && handleSavePrice()} + autoFocus + style={{ + width: 100, + padding: '4px 8px', + border: '1px solid var(--color-primary)', + borderRadius: 'var(--border-radius)', + fontSize: 14, + textAlign: 'right', + }} + /> + ) : ( + { + setPrice(String(position.buyPrice ?? '')); + setEditingPrice(true); + }} + style={{ + cursor: 'pointer', + padding: '4px 0', + display: 'inline-block', + color: position.buyPrice === null ? 'var(--color-text-secondary)' : 'inherit', + }} + > + {position.buyPrice !== null + ? position.buyPrice.toLocaleString('ru-RU', { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }) + : '—'} + + )} + {position.currentPrice !== null ? position.currentPrice.toLocaleString('ru-RU', { @@ -89,6 +142,43 @@ export function SharePositionRow({ position, onUpdate, onDelete }: Props) { }) : '—'} + + {position.totalCost !== null + ? position.totalCost.toLocaleString('ru-RU', { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }) + : '—'} + + + {position.pnl !== null ? ( + = 0 ? 'var(--color-positive)' : 'var(--color-negative)' }} + > + {position.pnl >= 0 ? '+' : ''} + {position.pnl.toLocaleString('ru-RU', { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + })} + + ) : ( + '—' + )} + + + {position.pnlPercent !== null ? ( + = 0 ? 'var(--color-positive)' : 'var(--color-negative)', + }} + > + {position.pnlPercent >= 0 ? '+' : ''} + {position.pnlPercent.toFixed(2)}% + + ) : ( + '—' + )} + {position.weightPercent.toFixed(1)}% diff --git a/apps/frontend/src/components/portfolios/SharePositionTable.tsx b/apps/frontend/src/components/portfolios/SharePositionTable.tsx index 5dcd28f..05e8841 100644 --- a/apps/frontend/src/components/portfolios/SharePositionTable.tsx +++ b/apps/frontend/src/components/portfolios/SharePositionTable.tsx @@ -3,7 +3,10 @@ import type { PositionWithPrice } from '../../api/responses'; interface Props { positions: PositionWithPrice[]; - onUpdatePosition: (positionId: number, data: { quantity?: number }) => void; + onUpdatePosition: ( + positionId: number, + data: { quantity?: number; buyPrice?: number; buyDate?: string }, + ) => void; onDeletePosition: (positionId: number) => void; } @@ -52,6 +55,17 @@ export function SharePositionTable({ positions, onUpdatePosition, onDeletePositi > Количество + + Цена пок. + Стоимость + + Затраты + + + P&L + + + P&L % + ) => void; + onReset: () => void; +} + +export function FilterPanel({ params, onApply, onReset }: Props) { + const [type, setType] = useState<'share' | 'bond'>(params.type); + const [local, setLocal] = useState>({}); + + function handleApply() { + const filters: Partial = { type }; + Object.entries(local).forEach(([key, value]) => { + if (value !== '') { + const num = Number(value); + filters[key as keyof ScreenerQuery] = isNaN(num) ? (value as any) : (num as any); + } + }); + onApply(filters); + } + + function handleReset() { + setLocal({}); + onReset(); + } + + function updateField(key: string, value: string) { + setLocal((prev) => ({ ...prev, [key]: value })); + } + + return ( +
+
+ + +
+ + {type === 'share' ? ( + + ) : ( + + )} + +
+ + +
+
+ ); +} diff --git a/apps/frontend/src/components/screener/FilterPanelBond.tsx b/apps/frontend/src/components/screener/FilterPanelBond.tsx new file mode 100644 index 0000000..c8382e9 --- /dev/null +++ b/apps/frontend/src/components/screener/FilterPanelBond.tsx @@ -0,0 +1,53 @@ +interface Props { + params: Record; + local: Record; + updateField: (key: string, value: string) => void; +} + +export function FilterPanelBond({ local, updateField }: Props) { + const fields = [ + { key: 'priceMin', label: 'Цена (% от номинала) от' }, + { key: 'priceMax', label: 'Цена (% от номинала) до' }, + { key: 'yieldMin', label: 'YTM % от' }, + { key: 'yieldMax', label: 'YTM % до' }, + { key: 'durationMin', label: 'Дюрация (лет) от' }, + { key: 'durationMax', label: 'Дюрация (лет) до' }, + { key: 'couponMin', label: 'Купон (₽) от' }, + { key: 'couponMax', label: 'Купон (₽) до' }, + { key: 'couponPercentMin', label: 'Купон % от' }, + { key: 'couponPercentMax', label: 'Купон % до' }, + ]; + + return ( +
+ {fields.map(({ key, label }) => ( +
+ + updateField(key, e.target.value)} + style={{ + width: '100%', + padding: '6px 10px', + border: '1px solid #e0e0e0', + borderRadius: 'var(--border-radius)', + fontSize: 13, + boxSizing: 'border-box', + }} + /> +
+ ))} +
+ ); +} diff --git a/apps/frontend/src/components/screener/FilterPanelShare.tsx b/apps/frontend/src/components/screener/FilterPanelShare.tsx new file mode 100644 index 0000000..4af167b --- /dev/null +++ b/apps/frontend/src/components/screener/FilterPanelShare.tsx @@ -0,0 +1,49 @@ +interface Props { + params: Record; + local: Record; + updateField: (key: string, value: string) => void; +} + +export function FilterPanelShare({ local, updateField }: Props) { + const fields = [ + { key: 'priceMin', label: 'Цена от' }, + { key: 'priceMax', label: 'Цена до' }, + { key: 'changePercentMin', label: 'Изм. % от' }, + { key: 'changePercentMax', label: 'Изм. % до' }, + { key: 'volumeMin', label: 'Объём от' }, + { key: 'capitalizationMin', label: 'Капитализация от' }, + ]; + + return ( +
+ {fields.map(({ key, label }) => ( +
+ + updateField(key, e.target.value)} + style={{ + width: '100%', + padding: '6px 10px', + border: '1px solid #e0e0e0', + borderRadius: 'var(--border-radius)', + fontSize: 13, + boxSizing: 'border-box', + }} + /> +
+ ))} +
+ ); +} diff --git a/apps/frontend/src/components/screener/ScreenerTable.tsx b/apps/frontend/src/components/screener/ScreenerTable.tsx new file mode 100644 index 0000000..7288f7e --- /dev/null +++ b/apps/frontend/src/components/screener/ScreenerTable.tsx @@ -0,0 +1,171 @@ +import { Link } from 'react-router-dom'; +import type { ScreenerResult } from '../../api/responses'; + +interface Props { + result: ScreenerResult; + sortBy: string; + sortOrder: 'asc' | 'desc'; + onSort: (field: string) => void; + onPageChange: (page: number) => void; +} + +function formatNum(value: number | null | undefined, digits = 2): string { + if (value == null) return '—'; + return value.toLocaleString('ru-RU', { + minimumFractionDigits: digits, + maximumFractionDigits: digits, + }); +} + +function formatChange(value: number | null | undefined): { text: string; color: string } { + if (value == null) return { text: '—', color: 'inherit' }; + const color = value > 0 ? '#43a047' : value < 0 ? '#e53935' : 'inherit'; + return { text: `${value > 0 ? '+' : ''}${value.toFixed(2)}%`, color }; +} + +export function ScreenerTable({ result, sortBy, sortOrder, onSort, onPageChange }: Props) { + function SortHeader({ field, children }: { field: string; children: string }) { + const isActive = sortBy === field; + return ( + onSort(field)} + style={{ + textAlign: 'right', + padding: '8px 12px', + fontWeight: 600, + fontSize: 12, + color: 'var(--color-text-secondary)', + cursor: 'pointer', + userSelect: 'none', + whiteSpace: 'nowrap', + }} + > + {children} {isActive ? (sortOrder === 'asc' ? '▲' : '▼') : ''} + + ); + } + + const isShare = result.items[0]?.type === 'share'; + + return ( +
+
+ Найдено: {result.total} бумаг +
+
+ + + + + + Цена + Изм. + Объём + {isShare ? ( + Капитализация + ) : ( + <> + YTM + Дюрация + Купон + Куп. % + + )} + + + + {result.items.map((item) => { + const change = formatChange(item.changePercent); + const link = isShare ? `/stocks/${item.secid}` : `/bonds/${item.secid}`; + return ( + + + + + + + {isShare ? ( + + ) : ( + <> + + + + + + )} + + ); + })} + +
+ Тикер + + Название +
+ + {item.secid} + + + {item.shortName} + + {formatNum(item.price)} + + {change.text} + + {item.volume.toLocaleString('ru-RU')} + + {item.capitalization != null + ? item.capitalization.toLocaleString('ru-RU') + : '—'} + + {formatNum(item.yieldToMaturity)} + + {item.duration != null ? `${item.duration.toFixed(2)}г` : '—'} + + {formatNum(item.couponValue)} + + {formatNum(item.couponPercent)} +
+
+ + {result.totalPages > 1 && ( +
+ {Array.from({ length: Math.min(result.totalPages, 10) }, (_, i) => i + 1).map((p) => ( + + ))} +
+ )} +
+ ); +} diff --git a/apps/frontend/src/hooks/usePortfolioAnalytics.ts b/apps/frontend/src/hooks/usePortfolioAnalytics.ts new file mode 100644 index 0000000..c6745a8 --- /dev/null +++ b/apps/frontend/src/hooks/usePortfolioAnalytics.ts @@ -0,0 +1,17 @@ +import { useQuery } from '@tanstack/react-query'; +import { getPortfolioAnalytics } from '../api/portfolio'; +import type { AnalyticsResponse } from '../api/responses'; + +export function usePortfolioAnalytics(portfolioId: number) { + return useQuery({ + queryKey: ['portfolio', portfolioId, 'analytics'], + queryFn: async () => { + const res = await getPortfolioAnalytics(portfolioId); + return res.data; + }, + staleTime: 900_000, + retry: 2, + refetchOnWindowFocus: false, + enabled: !!portfolioId, + }); +} diff --git a/apps/frontend/src/hooks/usePositionMutations.ts b/apps/frontend/src/hooks/usePositionMutations.ts index eaa742d..1947f54 100644 --- a/apps/frontend/src/hooks/usePositionMutations.ts +++ b/apps/frontend/src/hooks/usePositionMutations.ts @@ -6,8 +6,14 @@ export function usePositionMutations(portfolioId: number) { const queryClient = useQueryClient(); const add = useMutation({ - mutationFn: (data: { secid: string; quantity: number; notes?: string; tags?: string[] }) => - addPosition(portfolioId, data), + mutationFn: (data: { + secid: string; + quantity: number; + buyPrice?: number; + buyDate?: string; + notes?: string; + tags?: string[]; + }) => addPosition(portfolioId, data), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['portfolio', portfolioId] }); }, @@ -19,7 +25,13 @@ export function usePositionMutations(portfolioId: number) { data, }: { positionId: number; - data: { quantity?: number; notes?: string; tags?: string[] }; + data: { + quantity?: number; + buyPrice?: number; + buyDate?: string; + notes?: string; + tags?: string[]; + }; }) => updatePosition(portfolioId, positionId, data), onMutate: async ({ positionId, data }) => { await queryClient.cancelQueries({ queryKey: ['portfolio', portfolioId] }); @@ -33,7 +45,12 @@ export function usePositionMutations(portfolioId: number) { ...old, positions: old.positions.map((p: any) => p.id === positionId - ? { ...p, ...(data.quantity !== undefined ? { quantity: data.quantity } : {}) } + ? { + ...p, + ...(data.quantity !== undefined ? { quantity: data.quantity } : {}), + ...(data.buyPrice !== undefined ? { buyPrice: data.buyPrice } : {}), + ...(data.buyDate !== undefined ? { buyDate: data.buyDate } : {}), + } : p, ), }; diff --git a/apps/frontend/src/hooks/useScreener.ts b/apps/frontend/src/hooks/useScreener.ts new file mode 100644 index 0000000..457a47e --- /dev/null +++ b/apps/frontend/src/hooks/useScreener.ts @@ -0,0 +1,127 @@ +import { useSearchParams } from 'react-router-dom'; +import { useQuery } from '@tanstack/react-query'; +import { getScreenerResults } from '../api/screener'; +import type { ScreenerQuery } from '../api/screener'; + +export function useScreener() { + const [searchParams, setSearchParams] = useSearchParams(); + + const params: ScreenerQuery = { + type: (searchParams.get('type') as 'share' | 'bond') || 'share', + priceMin: searchParams.get('priceMin') ? Number(searchParams.get('priceMin')) : undefined, + priceMax: searchParams.get('priceMax') ? Number(searchParams.get('priceMax')) : undefined, + volumeMin: searchParams.get('volumeMin') ? Number(searchParams.get('volumeMin')) : undefined, + listLevel: searchParams.get('listLevel') ? Number(searchParams.get('listLevel')) : undefined, + changePercentMin: searchParams.get('changePercentMin') + ? Number(searchParams.get('changePercentMin')) + : undefined, + changePercentMax: searchParams.get('changePercentMax') + ? Number(searchParams.get('changePercentMax')) + : undefined, + capitalizationMin: searchParams.get('capitalizationMin') + ? Number(searchParams.get('capitalizationMin')) + : undefined, + yieldMin: searchParams.get('yieldMin') ? Number(searchParams.get('yieldMin')) : undefined, + yieldMax: searchParams.get('yieldMax') ? Number(searchParams.get('yieldMax')) : undefined, + durationMin: searchParams.get('durationMin') + ? Number(searchParams.get('durationMin')) + : undefined, + durationMax: searchParams.get('durationMax') + ? Number(searchParams.get('durationMax')) + : undefined, + couponMin: searchParams.get('couponMin') ? Number(searchParams.get('couponMin')) : undefined, + couponMax: searchParams.get('couponMax') ? Number(searchParams.get('couponMax')) : undefined, + couponPercentMin: searchParams.get('couponPercentMin') + ? Number(searchParams.get('couponPercentMin')) + : undefined, + couponPercentMax: searchParams.get('couponPercentMax') + ? Number(searchParams.get('couponPercentMax')) + : undefined, + maturityBefore: searchParams.get('maturityBefore') || undefined, + maturityAfter: searchParams.get('maturityAfter') || undefined, + bondType: searchParams.get('bondType') || undefined, + sortBy: searchParams.get('sortBy') || undefined, + sortOrder: (searchParams.get('sortOrder') as 'asc' | 'desc') || undefined, + page: searchParams.get('page') ? Number(searchParams.get('page')) : undefined, + pageSize: searchParams.get('pageSize') ? Number(searchParams.get('pageSize')) : undefined, + }; + + const queryKey = ['screener', params]; + + const query = useQuery({ + queryKey, + queryFn: () => getScreenerResults(params), + staleTime: 60_000, + retry: 2, + refetchOnWindowFocus: false, + placeholderData: (previousData) => previousData, + }); + + function setParam(key: string, value: string | undefined) { + setSearchParams((prev) => { + const next = new URLSearchParams(prev); + if (value === undefined || value === '') { + next.delete(key); + } else { + next.set(key, value); + } + next.set('page', '1'); + return next; + }); + } + + function setFilters(filters: Partial) { + setSearchParams((prev) => { + const next = new URLSearchParams(prev); + Object.entries(filters).forEach(([key, value]) => { + if (value === undefined || value === null || value === '') { + next.delete(key); + } else { + next.set(key, String(value)); + } + }); + next.set('page', '1'); + return next; + }); + } + + function setPage(page: number) { + setSearchParams((prev) => { + const next = new URLSearchParams(prev); + next.set('page', String(page)); + return next; + }); + } + + function setSort(sortBy: string) { + setSearchParams((prev) => { + const next = new URLSearchParams(prev); + const current = next.get('sortBy'); + const currentOrder = next.get('sortOrder') || 'asc'; + if (current === sortBy) { + next.set('sortOrder', currentOrder === 'asc' ? 'desc' : 'asc'); + } else { + next.set('sortBy', sortBy); + next.set('sortOrder', 'asc'); + } + next.set('page', '1'); + return next; + }); + } + + function resetFilters() { + setSearchParams(new URLSearchParams({ type: params.type })); + } + + return { + params, + result: query.data?.data ?? null, + isLoading: query.isLoading, + error: query.error, + setFilters, + setPage, + setSort, + setParam, + resetFilters, + }; +} diff --git a/apps/frontend/src/pages/portfolios/PortfolioDetailPage.tsx b/apps/frontend/src/pages/portfolios/PortfolioDetailPage.tsx index 9557be6..cfcfe22 100644 --- a/apps/frontend/src/pages/portfolios/PortfolioDetailPage.tsx +++ b/apps/frontend/src/pages/portfolios/PortfolioDetailPage.tsx @@ -5,6 +5,7 @@ import { usePortfolioMutations } from '../../hooks/usePortfolioMutations'; import { usePositionMutations } from '../../hooks/usePositionMutations'; import { PortfolioForm } from '../../components/portfolios/PortfolioForm'; import { PortfolioSummary } from '../../components/portfolios/PortfolioSummary'; +import { AnalyticsSummary } from '../../components/portfolios/AnalyticsSummary'; import { SharePositionTable } from '../../components/portfolios/SharePositionTable'; import { BondPositionTable } from '../../components/portfolios/BondPositionTable'; @@ -24,6 +25,8 @@ export function PortfolioDetailPage() { const [showAddForm, setShowAddForm] = useState(false); const [newSecid, setNewSecid] = useState(''); const [newQty, setNewQty] = useState('1'); + const [newPrice, setNewPrice] = useState(''); + const [newDate, setNewDate] = useState(new Date().toISOString().split('T')[0]); if (isLoading) { return ( @@ -53,12 +56,15 @@ export function PortfolioDetailPage() { { secid: newSecid.trim().toUpperCase(), quantity: parseInt(newQty, 10), + buyPrice: newPrice ? parseFloat(newPrice) : undefined, + buyDate: newDate || undefined, }, { onSuccess: () => { setShowAddForm(false); setNewSecid(''); setNewQty('1'); + setNewPrice(''); }, }, ); @@ -135,6 +141,8 @@ export function PortfolioDetailPage() { + {portfolio.analytics && } +
+
+ + setNewPrice(e.target.value)} + placeholder="0.00" + style={{ + padding: '8px 12px', + border: '1px solid #e0e0e0', + borderRadius: 'var(--border-radius)', + fontSize: 14, + width: 120, + }} + /> +
+
+ + setNewDate(e.target.value)} + style={{ + padding: '8px 12px', + border: '1px solid #e0e0e0', + borderRadius: 'var(--border-radius)', + fontSize: 14, + width: 150, + }} + /> +
+ ); +} diff --git a/apps/frontend/src/routes.tsx b/apps/frontend/src/routes.tsx index d17fb54..c285c4a 100644 --- a/apps/frontend/src/routes.tsx +++ b/apps/frontend/src/routes.tsx @@ -9,6 +9,7 @@ import { ProfilePage } from './pages/ProfilePage'; import { ProtectedRoute } from './components/ProtectedRoute'; import { PortfoliosListPage } from './pages/portfolios/PortfoliosListPage'; import { PortfolioDetailPage } from './pages/portfolios/PortfolioDetailPage'; +import { ScreenerPage } from './pages/screener/ScreenerPage'; export function AppRoutes() { return ( @@ -17,6 +18,7 @@ export function AppRoutes() { } /> } /> } /> + } /> } /> } /> **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 (`- [ ]`) syntax for tracking. + +**Goal:** Add cost basis tracking (buyPrice/buyDate) to positions, calculate unrealized PnL at position and portfolio level, display PnL in UI. + +**Architecture:** Extend existing Prisma Position model with buyPrice/buyDate. PnL calculated on backend during enrichment. New PnL columns in position tables. New AnalyticsSummary component. + +**Tech Stack:** NestJS, Prisma + SQLite, TanStack Query v5, React 18 + +--- + +## File Structure + +### Backend (modified files) +- `apps/backend/prisma/schema.prisma` — add `buyPrice` (Float?), `buyDate` (DateTime?) to Position +- `apps/backend/src/modules/portfolio/dto/add-position.dto.ts` — add `buyPrice`, `buyDate` +- `apps/backend/src/modules/portfolio/dto/update-position.dto.ts` — add `buyPrice`, `buyDate` +- `apps/backend/src/modules/portfolio/portfolio.service.ts` — add PnL fields to EnrichedPosition + calculateAnalytics() + +### Backend (new files) +- `apps/backend/src/modules/portfolio/dto/analytics-response.dto.ts` — PortfolioAnalyticsDto + +### Frontend (modified files) +- `apps/frontend/src/api/responses.ts` — add PnL fields to PositionWithPrice, add PortfolioAnalytics type +- `apps/frontend/src/api/portfolio.ts` — add buyPrice/buyDate to add/update position types +- `apps/frontend/src/hooks/usePositionMutations.ts` — pass buyPrice/buyDate +- `apps/frontend/src/components/portfolios/SharePositionRow.tsx` — add buyPrice edit + PnL columns +- `apps/frontend/src/components/portfolios/BondPositionRow.tsx` — add buyPrice edit + PnL columns +- `apps/frontend/src/components/portfolios/PortfolioSummary.tsx` — add analytics section +- `apps/frontend/src/pages/portfolios/PortfolioDetailPage.tsx` — add buyPrice to add position form + +### Frontend (new files) +- `apps/frontend/src/components/portfolios/AnalyticsSummary.tsx` — portfolio-level analytics card + +--- + +### Task 1: Prisma schema — add buyPrice and buyDate to Position + +**Files:** +- Modify: `apps/backend/prisma/schema.prisma` +- Run: `npx prisma migrate dev` + +- [ ] **Add buyPrice and buyDate fields to Position model** + +```prisma +model Position { + id Int @id @default(autoincrement()) + portfolioId Int + secid String + type String @default("share") + quantity Int + buyPrice Float? // NEW + buyDate DateTime? // NEW + notes String? + tags String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + portfolio Portfolio @relation(fields: [portfolioId], references: [id], onDelete: Cascade) + + @@unique([portfolioId, secid]) +} +``` + +- [ ] **Run Prisma migration** + +```bash +npx prisma migrate dev --name add-buy-price-to-position -w apps/backend +``` + +- [ ] **Generate Prisma client** + +```bash +npx prisma generate -w apps/backend +``` + +--- + +### Task 2: Backend DTO updates — add-position and update-position + +**Files:** +- Modify: `apps/backend/src/modules/portfolio/dto/add-position.dto.ts` +- Modify: `apps/backend/src/modules/portfolio/dto/update-position.dto.ts` + +- [ ] **Add buyPrice and buyDate to AddPositionDto** + +```typescript +import { + IsString, IsOptional, IsInt, Min, IsArray, IsIn, + MaxLength, MinLength, IsNumber, +} from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +const TAGS = [ + 'DIVIDEND', 'GROWTH', 'DEFENSIVE', 'SPECULATIVE', + 'BOND', 'ETF', 'GOVERNMENT', 'CASH', +] as const; + +export class AddPositionDto { + @ApiProperty({ example: 'SBER' }) + @IsString() + @MinLength(1) + @MaxLength(50) + secid!: string; + + @ApiProperty({ example: 10 }) + @IsInt() + @Min(0) + quantity!: number; + + @ApiPropertyOptional({ example: 250.5 }) + @IsNumber() + @Min(0) + @IsOptional() + buyPrice?: number; + + @ApiPropertyOptional({ example: '2026-06-01' }) + @IsString() + @IsOptional() + buyDate?: string; + + @ApiPropertyOptional({ example: 'Покупка на дип' }) + @IsString() + @IsOptional() + @MaxLength(500) + notes?: string; + + @ApiPropertyOptional({ example: ['DIVIDEND', 'GROWTH'], enum: TAGS }) + @IsArray() + @IsIn(TAGS, { each: true }) + @IsOptional() + tags?: string[]; +} +``` + +- [ ] **Add buyPrice and buyDate to UpdatePositionDto** + +```typescript +import { IsString, IsOptional, IsInt, Min, IsArray, IsIn, MaxLength, IsNumber } from 'class-validator'; +import { ApiPropertyOptional } from '@nestjs/swagger'; + +const TAGS = [ + 'DIVIDEND', 'GROWTH', 'DEFENSIVE', 'SPECULATIVE', + 'BOND', 'ETF', 'GOVERNMENT', 'CASH', +] as const; + +export class UpdatePositionDto { + @ApiPropertyOptional({ example: 15 }) + @IsInt() + @Min(0) + @IsOptional() + quantity?: number; + + @ApiPropertyOptional({ example: 260.0 }) + @IsNumber() + @Min(0) + @IsOptional() + buyPrice?: number; + + @ApiPropertyOptional({ example: '2026-06-15' }) + @IsString() + @IsOptional() + buyDate?: string; + + @ApiPropertyOptional({ example: 'Докупка' }) + @IsString() + @IsOptional() + @MaxLength(500) + notes?: string; + + @ApiPropertyOptional({ example: ['DIVIDEND'], enum: TAGS }) + @IsArray() + @IsIn(TAGS, { each: true }) + @IsOptional() + tags?: string[]; +} +``` + +--- + +### Task 3: Backend PortfolioService — PnL enrichment + +**Files:** +- Modify: `apps/backend/src/modules/portfolio/portfolio.service.ts` + +- [ ] **Add PnL fields to EnrichedPosition interface and implement calculateAnalytics** + +Replace the `EnrichedPosition` interface and methods in `portfolio.service.ts`: + +```typescript +export interface EnrichedPosition { + id: number; + secid: string; + shortName: string | null; + type: string; + quantity: number; + notes: string | null; + tags: string[] | null; + buyPrice: number | null; // NEW + buyDate: string | null; // NEW + currentPrice: number | null; + currentValue: number | null; + totalCost: number | null; // NEW: buyPrice * quantity + unrealizedPnl: number | null; // NEW: currentValue - totalCost + unrealizedPnlPercent: number | null; // NEW: (currentPrice - buyPrice) / buyPrice * 100 + weightPercent: number; + change?: number | null; + changePercent?: number | null; + yieldToMaturity?: number | null; + duration?: number | null; + couponValue?: number | null; + couponPercent?: number | null; + nextCouponDate?: string | null; + matDate?: string | null; + accruedInt?: number | null; + bid?: number | null; + offer?: number | null; + couponPeriod?: number | null; + bondType?: string | null; + offerDate?: string | null; +} + +export interface PortfolioAnalytics { + totalCost: number | null; + totalValue: number; + totalPnl: number | null; + totalPnlPercent: number | null; + totalDividendIncome: number; + totalReturn: number | null; +} +``` + +- [ ] **Update enrichPositions to pass buyPrice/buyDate through enrichment** + +In the `enrichPositions` method, update the base object constructor: + +```typescript +const base = { + id: pos.id, + secid: pos.secid, + shortName: null as string | null, + type: pos.type, + quantity: pos.quantity, + notes: pos.notes, + tags: pos.tags ? JSON.parse(pos.tags) : null, + buyPrice: (pos as any).buyPrice ?? null, // NEW + buyDate: (pos as any).buyDate // NEW + ? ((pos as any).buyDate as Date).toISOString().split('T')[0] + : null as string | null, + weightPercent: 0, + currentPrice: null as number | null, + currentValue: null as number | null, + totalCost: null as number | null, // NEW + unrealizedPnl: null as number | null, // NEW + unrealizedPnlPercent: null as number | null, // NEW +}; +``` + +- [ ] **Update buildSharePosition to calculate PnL** + +```typescript +private buildSharePosition( + pos: { id: number; secid: string; quantity: number; buyPrice?: number | null }, + base: EnrichedPosition, + data: MoexShareMarketData | undefined, +): EnrichedPosition { + if (!data) return { ...base, currentPrice: null, currentValue: null, totalCost: null, unrealizedPnl: null, unrealizedPnlPercent: null }; + const currentPrice = data.last; + const currentValue = currentPrice !== null ? currentPrice * pos.quantity : null; + const totalCost = pos.buyPrice != null ? pos.buyPrice * pos.quantity : null; + const unrealizedPnl = totalCost != null && currentValue != null ? currentValue - totalCost : null; + const unrealizedPnlPercent = pos.buyPrice != null && currentPrice != null + ? ((currentPrice - pos.buyPrice) / pos.buyPrice) * 100 + : null; + return { + ...base, + shortName: data.shortName, + currentPrice, + change: data.lastChange, + changePercent: data.lastChangePrcnt, + currentValue, + totalCost, + unrealizedPnl, + unrealizedPnlPercent, + }; +} +``` + +- [ ] **Update buildBondPosition to calculate PnL** + +```typescript +private buildBondPosition( + pos: { id: number; secid: string; quantity: number; buyPrice?: number | null }, + base: EnrichedPosition, + data: MoexBondPositionData | undefined, +): EnrichedPosition { + if (!data) return { ...base, currentPrice: null, currentValue: null, totalCost: null, unrealizedPnl: null, unrealizedPnlPercent: null }; + const currentPrice = data.price; + const currentValue = data.price !== null ? (data.price / 100) * data.faceValue * pos.quantity : null; + const totalCost = pos.buyPrice != null ? pos.buyPrice * pos.quantity : null; + const unrealizedPnl = totalCost != null && currentValue != null ? currentValue - totalCost : null; + const unrealizedPnlPercent = pos.buyPrice != null && currentPrice != null + ? ((currentPrice - pos.buyPrice) / pos.buyPrice) * 100 + : null; + return { + ...base, + shortName: data.shortName, + currentPrice, + yieldToMaturity: data.yieldToMaturity, + duration: data.duration, + couponValue: data.couponValue, + couponPercent: data.couponPercent, + nextCouponDate: data.nextCouponDate, + matDate: data.matDate, + accruedInt: data.accruedInt, + bid: data.bid, + offer: data.offer, + couponPeriod: data.couponPeriod, + bondType: data.bondType, + offerDate: data.offerDate, + currentValue, + totalCost, + unrealizedPnl, + unrealizedPnlPercent, + }; +} +``` + +- [ ] **Update findOne to calculate and return analytics** + +Replace the final return block in `findOne`: + +```typescript +const positionsWithWeights: EnrichedPosition[] = positionsWithPrices.map((p) => { + const weightPercent = totalValue > 0 ? ((p.currentValue ?? 0) / totalValue) * 100 : 0; + return { + ...p, + weightPercent: Math.round(weightPercent * 2) / 2, + }; +}); + +const analytics = this.calculateAnalytics(positionsWithWeights); + +return { + id: portfolio.id, + name: portfolio.name, + description: portfolio.description, + currency: portfolio.currency, + createdAt: portfolio.createdAt.toISOString(), + updatedAt: portfolio.updatedAt.toISOString(), + positions: positionsWithWeights, + totalValue: Math.round(totalValue * 100) / 100, + analytics, +}; +``` + +- [ ] **Add calculateAnalytics private method** + +```typescript +private calculateAnalytics(positions: EnrichedPosition[]): PortfolioAnalytics { + const totalCost = positions.reduce( + (sum, p) => sum + (p.totalCost ?? 0), + 0, + ); + const totalValue = positions.reduce( + (sum, p) => sum + (p.currentValue ?? 0), + 0, + ); + const totalPnl = positions.reduce( + (sum, p) => sum + (p.unrealizedPnl ?? 0), + 0, + ); + const totalPnlPercent = totalCost > 0 ? (totalPnl / totalCost) * 100 : null; + + return { + totalCost: totalCost > 0 ? Math.round(totalCost * 100) / 100 : null, + totalValue: Math.round(totalValue * 100) / 100, + totalPnl: totalPnl !== 0 ? Math.round(totalPnl * 100) / 100 : null, + totalPnlPercent: totalPnlPercent != null ? Math.round(totalPnlPercent * 100) / 100 : null, + totalDividendIncome: 0, + totalReturn: totalPnlPercent, + }; +} +``` + +- [ ] **Update addPosition to accept buyPrice/buyDate** + +Replace the `data` block in the `create` call inside `addPosition`: + +```typescript +return this.prisma.position.create({ + data: { + portfolioId, + secid: dto.secid, + type, + quantity: dto.quantity, + buyPrice: dto.buyPrice ?? null, + buyDate: dto.buyDate ? new Date(dto.buyDate) : null, + notes: dto.notes ?? null, + tags: dto.tags ? JSON.stringify(dto.tags) : null, + }, +}); +``` + +- [ ] **Update updatePosition to accept buyPrice/buyDate** + +Replace the `data` block in the `update` call inside `updatePosition`: + +```typescript +return this.prisma.position.update({ + where: { id: positionId }, + data: { + ...(dto.quantity !== undefined && { quantity: dto.quantity }), + ...(dto.buyPrice !== undefined && { buyPrice: dto.buyPrice }), + ...(dto.buyDate !== undefined && { buyDate: new Date(dto.buyDate) }), + ...(dto.notes !== undefined && { notes: dto.notes }), + ...(dto.tags !== undefined && { tags: dto.tags ? JSON.stringify(dto.tags) : null }), + }, +}); +``` + +--- + +### Task 4: Backend AnalyticsResponseDto + +**Files:** +- Create: `apps/backend/src/modules/portfolio/dto/analytics-response.dto.ts` + +- [ ] **Create AnalyticsResponseDto** + +```typescript +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class AnalyticsResponseDto { + @ApiPropertyOptional() + totalCost: number | null; + + @ApiProperty() + totalValue: number; + + @ApiPropertyOptional() + totalPnl: number | null; + + @ApiPropertyOptional() + totalPnlPercent: number | null; + + @ApiProperty() + totalDividendIncome: number; + + @ApiPropertyOptional() + totalReturn: number | null; +} +``` + +--- + +### Task 5: Backend tests — PnL calculation + +**Files:** +- Modify: `apps/backend/src/modules/portfolio/portfolio.service.spec.ts` + +- [ ] **Add test: PnL calculation for share position** + +Add inside `describe('findOne')` block: + +```typescript +it('should calculate PnL for share position with buyPrice', async () => { + const sharePosition = mockPosition({ + id: 1, + secid: 'SBER', + type: 'share', + quantity: 10, + buyPrice: 200, + buyDate: new Date('2026-06-01'), + }); + + vi.mocked(prisma.portfolio.findUnique).mockResolvedValue( + mockPortfolio({ positions: [sharePosition] }) as any, + ); + + vi.mocked(moexClient.getShareMarketDataBatch).mockResolvedValue([ + { secid: 'SBER', shortName: 'Sberbank', last: 250, lastChange: 5, lastChangePrcnt: 2 }, + ] as any); + + vi.mocked(moexClient.getBondPositionDataBatch).mockResolvedValue([]); + + const cacheMock = module.get(CacheService) as { getOrFetch: ReturnType }; + cacheMock.getOrFetch.mockImplementation( + async (_prefix: string, _key: string[], fetchFn: () => Promise) => ({ + data: await fetchFn(), + fromCache: false, + cachedAt: null, + }), + ); + + const result = await service.findOne(1, 1); + + expect(result.positions).toHaveLength(1); + expect(result.positions[0].buyPrice).toBe(200); + expect(result.positions[0].totalCost).toBe(2000); // 200 * 10 + expect(result.positions[0].unrealizedPnl).toBe(500); // 2500 - 2000 + expect(result.positions[0].unrealizedPnlPercent).toBe(25); // (250 - 200) / 200 * 100 + expect(result.analytics.totalCost).toBe(2000); + expect(result.analytics.totalPnl).toBe(500); + expect(result.analytics.totalPnlPercent).toBe(25); +}); + +it('should return null PnL when buyPrice is not set', async () => { + const sharePosition = mockPosition({ + id: 1, + secid: 'SBER', + type: 'share', + quantity: 10, + }); + + vi.mocked(prisma.portfolio.findUnique).mockResolvedValue( + mockPortfolio({ positions: [sharePosition] }) as any, + ); + + vi.mocked(moexClient.getShareMarketDataBatch).mockResolvedValue([ + { secid: 'SBER', shortName: 'Sberbank', last: 250, lastChange: 5, lastChangePrcnt: 2 }, + ] as any); + + vi.mocked(moexClient.getBondPositionDataBatch).mockResolvedValue([]); + + const cacheMock = module.get(CacheService) as { getOrFetch: ReturnType }; + cacheMock.getOrFetch.mockImplementation( + async (_prefix: string, _key: string[], fetchFn: () => Promise) => ({ + data: await fetchFn(), + fromCache: false, + cachedAt: null, + }), + ); + + const result = await service.findOne(1, 1); + + expect(result.positions[0].totalCost).toBeNull(); + expect(result.positions[0].unrealizedPnl).toBeNull(); + expect(result.positions[0].unrealizedPnlPercent).toBeNull(); +}); +``` + +- [ ] **Run tests to verify** + +```bash +npx vitest run apps/backend/src/modules/portfolio/portfolio.service.spec.ts -w apps/backend +``` + +Expected: all tests pass (including existing ones + 2 new ones) + +--- + +### Task 6: Frontend types — add PnL fields to responses.ts + +**Files:** +- Modify: `apps/frontend/src/api/responses.ts` + +- [ ] **Add PnL fields to PositionWithPrice and add PortfolioAnalytics type** + +Add new fields to `PositionWithPrice`: +```typescript +export interface PositionWithPrice { + // ... existing fields + buyPrice?: number | null; + buyDate?: string | null; + totalCost?: number | null; + unrealizedPnl?: number | null; + unrealizedPnlPercent?: number | null; +} +``` + +Add new types: +```typescript +export interface PortfolioAnalytics { + totalCost: number | null; + totalValue: number; + totalPnl: number | null; + totalPnlPercent: number | null; + totalDividendIncome: number; + totalReturn: number | null; +} +``` + +Update `PortfolioDetail` to include analytics: +```typescript +export interface PortfolioDetail extends Portfolio { + positions: PositionWithPrice[]; + totalValue: number; + analytics: PortfolioAnalytics; // NEW +} +``` + +--- + +### Task 7: Frontend API client + hooks — pass buyPrice/buyDate + +**Files:** +- Modify: `apps/frontend/src/api/portfolio.ts` +- Modify: `apps/frontend/src/hooks/usePositionMutations.ts` + +- [ ] **Update addPosition and updatePosition types in api/portfolio.ts** + +```typescript +export function addPosition( + portfolioId: number, + data: { secid: string; quantity: number; buyPrice?: number; buyDate?: string; notes?: string; tags?: string[] }, +): Promise<{ data: Position; meta: { cachedAt: string | null; fromCache: boolean } }> { + return request(`/api/v1/portfolios/${portfolioId}/positions`, undefined, { + method: 'POST', + body: data, + }); +} + +export function updatePosition( + portfolioId: number, + positionId: number, + data: { quantity?: number; buyPrice?: number; buyDate?: string; notes?: string; tags?: string[] }, +): Promise<{ data: Position; meta: { cachedAt: string | null; fromCache: boolean } }> { + return request(`/api/v1/portfolios/${portfolioId}/positions/${positionId}`, undefined, { + method: 'PATCH', + body: data, + }); +} +``` + +- [ ] **Update usePositionMutations to accept buyPrice/buyDate** + +Update the `add` mutation function type: +```typescript +const add = useMutation({ + mutationFn: (data: { + secid: string; + quantity: number; + buyPrice?: number; + buyDate?: string; + notes?: string; + tags?: string[]; + }) => addPosition(portfolioId, data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['portfolio', portfolioId] }); + }, +}); +``` + +Update the `update` mutation function type: +```typescript +const update = useMutation({ + mutationFn: ({ + positionId, + data, + }: { + positionId: number; + data: { quantity?: number; buyPrice?: number; buyDate?: string; notes?: string; tags?: string[] }; + }) => updatePosition(portfolioId, positionId, data), + // ... rest unchanged +}); +``` + +Update the optimistic update to handle buyPrice: +```typescript +queryClient.setQueryData(['portfolio', portfolioId], (old: any) => { + if (!old) return old; + return { + ...old, + positions: old.positions.map((p: any) => + p.id === positionId + ? { + ...p, + ...(data.quantity !== undefined ? { quantity: data.quantity } : {}), + ...(data.buyPrice !== undefined ? { buyPrice: data.buyPrice } : {}), + } + : p, + ), + }; +}); +``` + +--- + +### Task 8: Frontend SharePositionRow — add PnL columns + +**Files:** +- Modify: `apps/frontend/src/components/portfolios/SharePositionRow.tsx` + +- [ ] **Add buyPrice inline editing and PnL columns** + +Replace the `` content with additional cells between колонка «Стоимость» and «Доля»: + +```typescript +// After currentValue column (index 6), before weightPercent column: +{/* Цена покупки */} + + {position.buyPrice != null + ? position.buyPrice.toLocaleString('ru-RU', { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }) + : '—'} + + +{/* PnL */} + + {position.unrealizedPnl != null ? ( + = 0 ? '#43a047' : '#e53935' }}> + {position.unrealizedPnl >= 0 ? '+' : ''} + {position.unrealizedPnl.toLocaleString('ru-RU', { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + })} + + ) : '—'} + + +{/* PnL% */} + + {position.unrealizedPnlPercent != null ? ( + = 0 ? '#43a047' : '#e53935' }}> + {position.unrealizedPnlPercent >= 0 ? '+' : ''} + {position.unrealizedPnlPercent.toFixed(2)}% + + ) : '—'} + +``` + +Also update `onUpdate` props interface to accept `buyPrice`: +```typescript +interface Props { + position: PositionWithPrice; + onUpdate: (data: { quantity?: number; buyPrice?: number }) => void; + onDelete: () => void; +} +``` + +--- + +### Task 9: Frontend BondPositionRow — add PnL columns + +**Files:** +- Modify: `apps/frontend/src/components/portfolios/BondPositionRow.tsx` + +- [ ] **Add same PnL columns after НКД column (index 13), same logic as SharePositionRow** + +Insert after the totalAccrued cell: + +```typescript +{/* Цена покупки */} + + {position.buyPrice != null + ? position.buyPrice.toLocaleString('ru-RU', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + : '—'} + + +{/* PnL */} + + {position.unrealizedPnl != null ? ( + = 0 ? '#43a047' : '#e53935' }}> + {position.unrealizedPnl >= 0 ? '+' : ''} + {position.unrealizedPnl.toLocaleString('ru-RU', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} + + ) : '—'} + + +{/* PnL% */} + + {position.unrealizedPnlPercent != null ? ( + = 0 ? '#43a047' : '#e53935' }}> + {position.unrealizedPnlPercent >= 0 ? '+' : ''} + {position.unrealizedPnlPercent.toFixed(2)}% + + ) : '—'} + +``` + +Also update `onUpdate` props: +```typescript +interface Props { + position: PositionWithPrice; + onUpdate: (data: { quantity?: number; buyPrice?: number }) => void; + onDelete: () => void; +} +``` + +Update the SharePositionTable and BondPositionTable `` headers to include the new columns ("Цена покупки", "PnL", "PnL%"). + +--- + +### Task 10: Frontend AnalyticsSummary + PortfolioSummary update + +**Files:** +- Create: `apps/frontend/src/components/portfolios/AnalyticsSummary.tsx` +- Modify: `apps/frontend/src/components/portfolios/PortfolioSummary.tsx` + +- [ ] **Create AnalyticsSummary component** + +```typescript +import type { PortfolioAnalytics } from '../../api/responses'; + +interface Props { + analytics: PortfolioAnalytics; + currency: string; +} + +export function AnalyticsSummary({ analytics, currency }: Props) { + return ( +
+
+
+ Общая стоимость +
+
+ {analytics.totalValue.toLocaleString('ru-RU', { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + })} + + {currency} + +
+
+ + {analytics.totalCost != null && ( + <> +
+
+ Вложено +
+
+ {analytics.totalCost.toLocaleString('ru-RU', { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + })} +
+
+ +
+
+ PnL +
+
= 0 ? '#43a047' : '#e53935', + }} + > + {analytics.totalPnl != null + ? `${analytics.totalPnl >= 0 ? '+' : ''}${analytics.totalPnl.toLocaleString('ru-RU', { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + })}` + : '—'} +
+
+ +
+
+ Доходность +
+
= 0 ? '#43a047' : '#e53935', + }} + > + {analytics.totalPnlPercent != null + ? `${analytics.totalPnlPercent >= 0 ? '+' : ''}${analytics.totalPnlPercent.toFixed(2)}%` + : '—'} +
+
+ + )} +
+ ); +} +``` + +- [ ] **Update PortfolioSummary to include AnalyticsSummary** + +```typescript +import { AllocationChart } from './AllocationChart'; +import { AnalyticsSummary } from './AnalyticsSummary'; +import type { PortfolioDetail } from '../../api/responses'; + +export function PortfolioSummary({ portfolio }: { portfolio: PortfolioDetail }) { + return ( +
+
+ +
+
+ Позиций +
+
{portfolio.positions.length}
+
+
+ +
+ ); +} +``` + +--- + +### Task 11: Frontend PortfolioDetailPage — add buyPrice to add position form + +**Files:** +- Modify: `apps/frontend/src/pages/portfolios/PortfolioDetailPage.tsx` + +- [ ] **Add buyPrice input field to the add position form** + +Add state variable: +```typescript +const [newBuyPrice, setNewBuyPrice] = useState(''); +``` + +Add the input field after the quantity input in the add form: +```typescript +
+ + setNewBuyPrice(e.target.value)} + placeholder="250.50" + style={{ + padding: '8px 12px', + border: '1px solid #e0e0e0', + borderRadius: 'var(--border-radius)', + fontSize: 14, + width: 100, + }} + /> +
+``` + +Update `handleAddPosition`: +```typescript +function handleAddPosition() { + if (!newSecid.trim() || !parseInt(newQty, 10)) return; + addPosition.mutate( + { + secid: newSecid.trim().toUpperCase(), + quantity: parseInt(newQty, 10), + buyPrice: newBuyPrice ? parseFloat(newBuyPrice) : undefined, + }, + { + onSuccess: () => { + setShowAddForm(false); + setNewSecid(''); + setNewQty('1'); + setNewBuyPrice(''); + }, + }, + ); +} +``` + +- [ ] **Verify frontend builds** + +```bash +npm run build:frontend +``` + +Expected: no TypeScript errors + +--- + +### Task 12: Verify everything works + +- [ ] **Run all backend tests** + +```bash +npx vitest run -w apps/backend +``` + +Expected: all tests pass + +- [ ] **Run frontend tests** + +```bash +npx vitest run -w apps/frontend +``` + +Expected: all tests pass + +- [ ] **Run lint** + +```bash +npm run lint +``` + +Expected: no errors + +- [ ] **Commit** + +```bash +git add apps/backend/prisma/schema.prisma \ + apps/backend/src/modules/portfolio/dto/add-position.dto.ts \ + apps/backend/src/modules/portfolio/dto/update-position.dto.ts \ + apps/backend/src/modules/portfolio/dto/analytics-response.dto.ts \ + apps/backend/src/modules/portfolio/portfolio.service.ts \ + apps/backend/src/modules/portfolio/portfolio.service.spec.ts \ + apps/frontend/src/api/responses.ts \ + apps/frontend/src/api/portfolio.ts \ + apps/frontend/src/hooks/usePositionMutations.ts \ + apps/frontend/src/components/portfolios/SharePositionRow.tsx \ + apps/frontend/src/components/portfolios/BondPositionRow.tsx \ + apps/frontend/src/components/portfolios/PortfolioSummary.tsx \ + apps/frontend/src/components/portfolios/AnalyticsSummary.tsx \ + apps/frontend/src/pages/portfolios/PortfolioDetailPage.tsx \ + apps/backend/prisma/migrations +git commit -m "feat: add portfolio analytics with PnL and cost basis tracking" +``` diff --git a/docs/superpowers/plans/2026-06-14-security-screener.md b/docs/superpowers/plans/2026-06-14-security-screener.md new file mode 100644 index 0000000..4ed9825 --- /dev/null +++ b/docs/superpowers/plans/2026-06-14-security-screener.md @@ -0,0 +1,1614 @@ +# Security Screener — 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 (`- [ ]`) syntax for tracking. + +**Goal:** Add security screener endpoint on backend and screener page on frontend for filtering MOEX shares/bonds by market data parameters. + +**Architecture:** New ScreenerService on backend that fetches full MOEX board (all shares or bonds) in one batch call, applies server-side filtering/sorting/pagination. New `/screener` page on frontend with filter panel and results table. Cache full board for 60s. + +**Tech Stack:** NestJS, MoexClientService (batch methods), TanStack Query v5, React 18, react-router-dom v6 + +--- + +## File Structure + +### Backend (new files) +- `apps/backend/src/modules/securities/dto/screener-query.dto.ts` +- `apps/backend/src/modules/securities/dto/screener-response.dto.ts` +- `apps/backend/src/modules/securities/screener.service.ts` + +### Backend (modified files) +- `apps/backend/src/modules/moex-client/moex-client.service.ts` — allow empty secids for full board fetch +- `apps/backend/src/modules/securities/securities.controller.ts` — add `GET /securities/screener` +- `apps/backend/src/modules/securities/securities.module.ts` — add ScreenerService + +### Frontend (new files) +- `apps/frontend/src/api/screener.ts` +- `apps/frontend/src/hooks/useScreener.ts` +- `apps/frontend/src/pages/screener/ScreenerPage.tsx` +- `apps/frontend/src/components/screener/FilterPanel.tsx` +- `apps/frontend/src/components/screener/FilterPanelShare.tsx` +- `apps/frontend/src/components/screener/FilterPanelBond.tsx` +- `apps/frontend/src/components/screener/ScreenerTable.tsx` + +### Frontend (modified files) +- `apps/frontend/src/api/responses.ts` — add ScreenerItem, ScreenerResult types +- `apps/frontend/src/routes.tsx` — add /screener route +- `apps/frontend/src/components/Layout.tsx` — add nav link + +--- + +### Task 1: Backend MoexClientService — allow full board fetch with empty secids + +**Files:** +- Modify: `apps/backend/src/modules/moex-client/moex-client.service.ts` + +- [ ] **Remove early return guard in batch methods to support empty secids = fetch all** + +Replace the `getShareMarketDataBatch` method: + +```typescript +async getShareMarketDataBatch( + secids: string[], + boardId = 'TQBR', +): Promise { + const params: Record = { boards: boardId }; + if (secids.length > 0) { + params.securities = secids.join(','); + } + const data = await this.request>( + `/engines/stock/markets/shares/securities`, + params, + ); + const securities = this.extractTable(data, 'securities'); + const marketdata = this.extractTable(data, 'marketdata'); + + const secidSet = secids.length > 0 ? new Set(secids) : null; + const filteredSecurities = secidSet + ? securities.filter((r) => secidSet.has(r.SECID as string)) + : securities; + + const result = await Promise.all( + filteredSecurities.map(async (sec) => { + const secid = sec.SECID as string; + const mkt = marketdata.find( + (r) => r.SECID === secid && r.BOARDID === boardId, + ) ?? marketdata.find((r) => r.SECID === secid); + return { + secid, + boardid: boardId, + shortName: (sec.SHORTNAME as string) || '', + bid: mkt ? parseFloat((mkt.BID as string) || '') : null, + offer: mkt ? parseFloat((mkt.OFFER as string) || '') : null, + open: mkt ? parseFloat((mkt.OPEN as string) || '') : null, + low: mkt ? parseFloat((mkt.LOW as string) || '') : null, + high: mkt ? parseFloat((mkt.HIGH as string) || '') : null, + last: mkt + ? parseFloat((mkt.LAST as string) || '') + : parseFloat((sec.PREVPRICE as string) || ''), + lastChange: mkt ? parseFloat((mkt.LASTCHANGE as string) || '') : null, + lastChangePrcnt: mkt ? parseFloat((mkt.LASTCHANGEPRCNT as string) || '') : null, + volume: mkt ? parseInt((mkt.VOLTODAY as string) || '0', 10) : 0, + value: mkt ? parseFloat((mkt.VALTODAY as string) || '0') : 0, + waprice: mkt ? parseFloat((mkt.WAPRICE as string) || '') : null, + numtrades: mkt ? parseInt((mkt.NUMTRADES as string) || '0', 10) : 0, + issueCapitalization: mkt ? parseFloat((mkt.ISSUECAPITALIZATION as string) || '') : null, + tradingStatus: (mkt?.TRADINGSTATUS as string) || '', + updateTime: (mkt?.UPDATETIME as string) || '', + }; + }), + ); + + return result; +} +``` + +Replace the `getBondPositionDataBatch` method: + +```typescript +async getBondPositionDataBatch( + secids: string[], + boardId = 'TQCB', +): Promise { + const params: Record = { boards: boardId }; + if (secids.length > 0) { + params.securities = secids.join(','); + } + const data = await this.request>( + `/engines/stock/markets/bonds/securities`, + params, + ); + const securities = this.extractTable(data, 'securities'); + const marketdata = this.extractTable(data, 'marketdata'); + + const secidSet = secids.length > 0 ? new Set(secids) : null; + const filteredBonds = secidSet + ? securities.filter((r) => secidSet.has(r.SECID as string)) + : securities; + + return filteredBonds.map((bond) => { + const secid = bond.SECID as string; + const mkt = + marketdata.find((r) => r.SECID === secid && r.BOARDID === boardId && r.LAST != null) ?? + marketdata.find((r) => r.LAST != null) ?? + marketdata.find((r) => r.SECID === secid); + + return { + secid, + boardid: boardId, + shortName: (bond.SHORTNAME as string) || '', + price: mkt?.LAST != null ? parseFloat(mkt.LAST as string) : null, + yieldToMaturity: mkt?.YIELD != null ? parseFloat(mkt.YIELD as string) : null, + duration: mkt?.DURATION != null ? parseFloat(mkt.DURATION as string) : null, + couponValue: bond.COUPONVALUE != null ? parseFloat(bond.COUPONVALUE as string) : null, + couponPercent: bond.COUPONPERCENT != null ? parseFloat(bond.COUPONPERCENT as string) : null, + nextCouponDate: (bond.NEXTCOUPON as string) || null, + matDate: (bond.MATDATE as string) || null, + accruedInt: bond.ACCRUEDINT != null ? parseFloat(bond.ACCRUEDINT as string) : null, + faceValue: parseFloat((bond.FACEVALUE as string) || '1000'), + bid: mkt?.BID != null ? parseFloat(mkt.BID as string) : null, + offer: mkt?.OFFER != null ? parseFloat(mkt.OFFER as string) : null, + couponPeriod: parseInt((bond.COUPONPERIOD as string) || '0', 10), + bondType: (bond.BONDTYPE as string) || null, + offerDate: (bond.OFFERDATE as string) || null, + }; + }); +} +``` + +- [ ] **Verify existing portfolio tests still pass** + +```bash +npx vitest run apps/backend/src/modules/portfolio/portfolio.service.spec.ts -w apps/backend +``` + +Expected: all existing tests pass (they pass non-empty arrays, behavior unchanged) + +--- + +### Task 2: Backend ScreenerQueryDto + +**Files:** +- Create: `apps/backend/src/modules/securities/dto/screener-query.dto.ts` + +- [ ] **Create ScreenerQueryDto with validation** + +```typescript +import { Type, Transform } from 'class-transformer'; +import { + IsString, IsOptional, IsNumber, IsInt, Min, Max, + IsEnum, IsIn, MinLength, +} from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export enum ScreenerType { + SHARE = 'share', + BOND = 'bond', +} + +export const SORTER_FIELDS = [ + 'price', 'changePercent', 'volume', 'listLevel', + 'capitalization', 'yieldToMaturity', 'duration', + 'couponValue', 'couponPercent', +] as const; + +export class ScreenerQueryDto { + @ApiProperty({ enum: ScreenerType }) + @IsEnum(ScreenerType) + type!: ScreenerType; + + @ApiPropertyOptional() + @IsNumber() + @IsOptional() + @Type(() => Number) + priceMin?: number; + + @ApiPropertyOptional() + @IsNumber() + @IsOptional() + @Type(() => Number) + priceMax?: number; + + @ApiPropertyOptional() + @IsInt() + @Min(0) + @IsOptional() + @Type(() => Number) + volumeMin?: number; + + @ApiPropertyOptional() + @IsInt() + @Min(1) + @Max(3) + @IsOptional() + @Type(() => Number) + listLevel?: number; + + @ApiPropertyOptional() + @IsNumber() + @IsOptional() + @Type(() => Number) + changePercentMin?: number; + + @ApiPropertyOptional() + @IsNumber() + @IsOptional() + @Type(() => Number) + changePercentMax?: number; + + @ApiPropertyOptional() + @IsNumber() + @IsOptional() + @Type(() => Number) + capitalizationMin?: number; + + @ApiPropertyOptional() + @IsNumber() + @IsOptional() + @Type(() => Number) + yieldMin?: number; + + @ApiPropertyOptional() + @IsNumber() + @IsOptional() + @Type(() => Number) + yieldMax?: number; + + @ApiPropertyOptional() + @IsNumber() + @IsOptional() + @Type(() => Number) + durationMin?: number; + + @ApiPropertyOptional() + @IsNumber() + @IsOptional() + @Type(() => Number) + durationMax?: number; + + @ApiPropertyOptional() + @IsNumber() + @IsOptional() + @Type(() => Number) + couponMin?: number; + + @ApiPropertyOptional() + @IsNumber() + @IsOptional() + @Type(() => Number) + couponMax?: number; + + @ApiPropertyOptional() + @IsNumber() + @IsOptional() + @Type(() => Number) + couponPercentMin?: number; + + @ApiPropertyOptional() + @IsNumber() + @IsOptional() + @Type(() => Number) + couponPercentMax?: number; + + @ApiPropertyOptional() + @IsString() + @IsOptional() + maturityBefore?: string; + + @ApiPropertyOptional() + @IsString() + @IsOptional() + maturityAfter?: string; + + @ApiPropertyOptional() + @IsString() + @IsOptional() + bondType?: string; + + @ApiPropertyOptional({ default: 'price' }) + @IsString() + @IsOptional() + sortBy?: string; + + @ApiPropertyOptional({ default: 'asc' }) + @IsString() + @IsIn(['asc', 'desc']) + @IsOptional() + sortOrder?: 'asc' | 'desc'; + + @ApiPropertyOptional({ default: 1 }) + @IsInt() + @Min(1) + @IsOptional() + @Type(() => Number) + page?: number; + + @ApiPropertyOptional({ default: 20 }) + @IsInt() + @Min(1) + @Max(100) + @IsOptional() + @Type(() => Number) + pageSize?: number; +} +``` + +--- + +### Task 3: Backend ScreenerResponseDto + +**Files:** +- Create: `apps/backend/src/modules/securities/dto/screener-response.dto.ts` + +- [ ] **Create ScreenerItemDto and ScreenerResultDto** + +```typescript +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class ScreenerItemDto { + @ApiProperty({ example: 'SBER' }) + secid: string; + + @ApiProperty({ example: 'Сбербанк' }) + shortName: string; + + @ApiProperty({ example: 'RU0009029540' }) + isin: string; + + @ApiProperty({ enum: ['share', 'bond'] }) + type: 'share' | 'bond'; + + @ApiPropertyOptional({ example: 322.35 }) + price: number | null; + + @ApiPropertyOptional({ example: 1.15 }) + change: number | null; + + @ApiPropertyOptional({ example: 0.36 }) + changePercent: number | null; + + @ApiProperty({ example: 1925163 }) + volume: number; + + @ApiProperty({ example: 1 }) + listLevel: number; + + @ApiPropertyOptional({ example: 6958336818320 }) + capitalization: number | null; + + @ApiPropertyOptional({ example: 12.71 }) + yieldToMaturity: number | null; + + @ApiPropertyOptional({ example: 4.5 }) + duration: number | null; + + @ApiPropertyOptional({ example: 40.64 }) + couponValue: number | null; + + @ApiPropertyOptional({ example: 8.15 }) + couponPercent: number | null; + + @ApiPropertyOptional({ example: 29.48 }) + accruedInt: number | null; + + @ApiPropertyOptional({ example: '2027-02-03' }) + matDate: string | null; + + @ApiPropertyOptional({ example: 'ОФЗ-ПД' }) + bondType: string | null; +} + +export class ScreenerResultDto { + @ApiProperty() + totalCount: number; + + @ApiProperty() + page: number; + + @ApiProperty() + pageSize: number; + + @ApiProperty() + totalPages: number; + + @ApiProperty({ type: [ScreenerItemDto] }) + items: ScreenerItemDto[]; +} +``` + +--- + +### Task 4: Backend ScreenerService + +**Files:** +- Create: `apps/backend/src/modules/securities/screener.service.ts` + +- [ ] **Create ScreenerService with filtering, sorting, pagination** + +```typescript +import { Injectable } from '@nestjs/common'; +import { MoexClientService } from '../moex-client/moex-client.service'; +import { CacheService } from '../cache/cache.service'; +import { ScreenerQueryDto, ScreenerType } from './dto/screener-query.dto'; +import { ScreenerItemDto, ScreenerResultDto } from './dto/screener-response.dto'; + +@Injectable() +export class ScreenerService { + constructor( + private readonly moexClient: MoexClientService, + private readonly cache: CacheService, + ) {} + + async screen(query: ScreenerQueryDto): Promise { + const board = await this.fetchBoard(query.type); + + const filtered = board.filter((item) => this.matches(item, query)); + const sorted = this.sort(filtered, query.sortBy ?? 'price', query.sortOrder ?? 'asc'); + const page = query.page ?? 1; + const pageSize = query.pageSize ?? 20; + const totalCount = sorted.length; + const totalPages = Math.ceil(totalCount / pageSize); + const start = (page - 1) * pageSize; + const items = sorted.slice(start, start + pageSize); + + return { + totalCount, + page, + pageSize, + totalPages, + items, + }; + } + + private async fetchBoard(type: ScreenerType): Promise { + const { data } = await this.cache.getOrFetch( + 'screener', + [type], + async () => { + if (type === ScreenerType.SHARE) { + const shares = await this.moexClient.getShareMarketDataBatch([]); + return shares.map((s): ScreenerItemDto => ({ + secid: s.secid, + shortName: s.shortName, + isin: '', + type: 'share', + price: s.last, + change: s.lastChange, + changePercent: s.lastChangePrcnt, + volume: s.volume, + listLevel: 0, + capitalization: s.issueCapitalization, + yieldToMaturity: null, + duration: null, + couponValue: null, + couponPercent: null, + accruedInt: null, + matDate: null, + bondType: null, + })); + } else { + const bonds = await this.moexClient.getBondPositionDataBatch([]); + return bonds.map((b): ScreenerItemDto => ({ + secid: b.secid, + shortName: b.shortName, + isin: '', + type: 'bond', + price: b.price, + change: null, + changePercent: null, + volume: 0, + listLevel: 0, + capitalization: null, + yieldToMaturity: b.yieldToMaturity, + duration: b.duration, + couponValue: b.couponValue, + couponPercent: b.couponPercent, + accruedInt: b.accruedInt, + matDate: b.matDate, + bondType: b.bondType, + })); + } + }, + 'marketDataTtl', + ); + + return data; + } + + private matches(item: ScreenerItemDto, q: ScreenerQueryDto): boolean { + if (q.priceMin != null && (item.price == null || item.price < q.priceMin)) return false; + if (q.priceMax != null && (item.price == null || item.price > q.priceMax)) return false; + if (q.volumeMin != null && item.volume < q.volumeMin) return false; + if (q.listLevel != null && item.listLevel !== q.listLevel) return false; + + if (item.type === 'share') { + if (q.changePercentMin != null && (item.changePercent == null || item.changePercent < q.changePercentMin)) return false; + if (q.changePercentMax != null && (item.changePercent == null || item.changePercent > q.changePercentMax)) return false; + if (q.capitalizationMin != null && (item.capitalization == null || item.capitalization < q.capitalizationMin)) return false; + } + + if (item.type === 'bond') { + if (q.yieldMin != null && (item.yieldToMaturity == null || item.yieldToMaturity < q.yieldMin)) return false; + if (q.yieldMax != null && (item.yieldToMaturity == null || item.yieldToMaturity > q.yieldMax)) return false; + if (q.durationMin != null && (item.duration == null || item.duration < q.durationMin)) return false; + if (q.durationMax != null && (item.duration == null || item.duration > q.durationMax)) return false; + if (q.couponMin != null && (item.couponValue == null || item.couponValue < q.couponMin)) return false; + if (q.couponMax != null && (item.couponValue == null || item.couponValue > q.couponMax)) return false; + if (q.couponPercentMin != null && (item.couponPercent == null || item.couponPercent < q.couponPercentMin)) return false; + if (q.couponPercentMax != null && (item.couponPercent == null || item.couponPercent > q.couponPercentMax)) return false; + if (q.maturityBefore != null && (item.matDate == null || item.matDate > q.maturityBefore)) return false; + if (q.maturityAfter != null && (item.matDate == null || item.matDate < q.maturityAfter)) return false; + if (q.bondType != null && item.bondType !== q.bondType) return false; + } + + return true; + } + + private sort(items: ScreenerItemDto[], sortBy: string, sortOrder: 'asc' | 'desc'): ScreenerItemDto[] { + const allowedFields = new Set([ + 'secid', 'shortName', 'price', 'change', 'changePercent', + 'volume', 'listLevel', 'capitalization', 'yieldToMaturity', + 'duration', 'couponValue', 'couponPercent', 'accruedInt', 'matDate', + ]); + if (!allowedFields.has(sortBy)) { + sortBy = 'price'; + } + return [...items].sort((a, b) => { + const aVal = (a as any)[sortBy]; + const bVal = (b as any)[sortBy]; + if (aVal == null && bVal == null) return 0; + if (aVal == null) return 1; + if (bVal == null) return -1; + if (typeof aVal === 'string') { + return sortOrder === 'asc' ? aVal.localeCompare(bVal) : bVal.localeCompare(aVal); + } + return sortOrder === 'asc' ? aVal - bVal : bVal - aVal; + }); + } +} +``` + +--- + +### Task 5: Backend controller + module update + +**Files:** +- Modify: `apps/backend/src/modules/securities/securities.controller.ts` +- Modify: `apps/backend/src/modules/securities/securities.module.ts` + +- [ ] **Add screener endpoint to SecuritiesController** + +```typescript +import { Controller, Get, Query, ValidationPipe } from '@nestjs/common'; +import { ApiTags, ApiOperation } from '@nestjs/swagger'; +import { SecuritiesService } from './securities.service'; +import { ScreenerService } from './screener.service'; +import { SearchQueryDto, SecurityType } from './dto/search-query.dto'; +import { ScreenerQueryDto } from './dto/screener-query.dto'; + +@ApiTags('Securities') +@Controller('securities') +export class SecuritiesController { + constructor( + private readonly securitiesService: SecuritiesService, + private readonly screenerService: ScreenerService, + ) {} + + @Get('search') + @ApiOperation({ summary: 'Поиск по инструментам' }) + async search(@Query(ValidationPipe) query: SearchQueryDto) { + const results = await this.securitiesService.search( + query.q, + query.type || SecurityType.ALL, + query.limit || 20, + ); + return { data: results, meta: { cachedAt: null, fromCache: false } }; + } + + @Get('screener') + @ApiOperation({ summary: 'Фильтр ценных бумаг по параметрам' }) + async screener(@Query(ValidationPipe) query: ScreenerQueryDto) { + const result = await this.screenerService.screen(query); + return { data: result, meta: { cachedAt: null, fromCache: false } }; + } +} +``` + +- [ ] **Update SecuritiesModule** + +```typescript +import { Module } from '@nestjs/common'; +import { CacheModule } from '../cache/cache.module'; +import { SecuritiesController } from './securities.controller'; +import { SecuritiesService } from './securities.service'; +import { ScreenerService } from './screener.service'; + +@Module({ + imports: [CacheModule], + controllers: [SecuritiesController], + providers: [SecuritiesService, ScreenerService], + exports: [SecuritiesService], +}) +export class SecuritiesModule {} +``` + +- [ ] **Run backend build to verify** + +```bash +npm run build:backend +``` + +Expected: no TypeScript errors + +--- + +### Task 6: Backend tests — ScreenerService + +**Files:** +- Create: `apps/backend/src/modules/securities/screener.service.spec.ts` + +- [ ] **Create ScreenerService test** + +```typescript +import { Test, TestingModule } from '@nestjs/testing'; +import { ConfigModule } from '@nestjs/config'; +import { ScreenerService } from './screener.service'; +import { MoexClientService } from '../moex-client/moex-client.service'; +import { CacheService } from '../cache/cache.service'; +import configuration from '../../config/configuration'; +import { ScreenerQueryDto, ScreenerType } from './dto/screener-query.dto'; + +describe('ScreenerService', () => { + let service: ScreenerService; + let moexClient: MoexClientService; + let module: TestingModule; + + const mockShares = [ + { secid: 'SBER', shortName: 'Sberbank', last: 300, lastChange: 5, lastChangePrcnt: 1.5, volume: 1000000, issueCapitalization: 5000000000000 }, + { secid: 'GAZP', shortName: 'Gazprom', last: 200, lastChange: -2, lastChangePrcnt: -1.0, volume: 500000, issueCapitalization: 3000000000000 }, + { secid: 'VTBR', shortName: 'VTB', last: 0.05, lastChange: 0.001, lastChangePrcnt: 2.0, volume: 10000000, issueCapitalization: 100000000000 }, + ] as any[]; + + beforeAll(async () => { + module = await Test.createTestingModule({ + imports: [ConfigModule.forRoot({ load: [configuration] })], + providers: [ + ScreenerService, + { + provide: MoexClientService, + useValue: { + getShareMarketDataBatch: vi.fn(), + getBondPositionDataBatch: vi.fn(), + }, + }, + { + provide: CacheService, + useValue: { + getOrFetch: vi.fn(), + }, + }, + ], + }).compile(); + + service = module.get(ScreenerService); + moexClient = module.get(MoexClientService); + }); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should return all shares when no filters applied', async () => { + vi.mocked(moexClient.getShareMarketDataBatch).mockResolvedValue(mockShares); + + const cacheMock = module.get(CacheService) as { getOrFetch: ReturnType }; + cacheMock.getOrFetch.mockImplementation( + async (_prefix: string, _key: string[], fetchFn: () => Promise) => ({ + data: await fetchFn(), + fromCache: false, + cachedAt: null, + }), + ); + + const query = Object.assign(new ScreenerQueryDto(), { type: ScreenerType.SHARE }); + const result = await service.screen(query); + + expect(result.totalCount).toBe(3); + expect(result.items).toHaveLength(3); + expect(result.page).toBe(1); + expect(result.totalPages).toBe(1); + }); + + it('should filter by price range', async () => { + vi.mocked(moexClient.getShareMarketDataBatch).mockResolvedValue(mockShares); + + const cacheMock = module.get(CacheService) as { getOrFetch: ReturnType }; + cacheMock.getOrFetch.mockImplementation( + async (_prefix: string, _key: string[], fetchFn: () => Promise) => ({ + data: await fetchFn(), + fromCache: false, + cachedAt: null, + }), + ); + + const query = Object.assign(new ScreenerQueryDto(), { + type: ScreenerType.SHARE, + priceMin: 100, + priceMax: 250, + }); + const result = await service.screen(query); + + expect(result.totalCount).toBe(1); + expect(result.items[0].secid).toBe('GAZP'); + }); + + it('should apply pagination', async () => { + vi.mocked(moexClient.getShareMarketDataBatch).mockResolvedValue(mockShares); + + const cacheMock = module.get(CacheService) as { getOrFetch: ReturnType }; + cacheMock.getOrFetch.mockImplementation( + async (_prefix: string, _key: string[], fetchFn: () => Promise) => ({ + data: await fetchFn(), + fromCache: false, + cachedAt: null, + }), + ); + + const query = Object.assign(new ScreenerQueryDto(), { + type: ScreenerType.SHARE, + page: 1, + pageSize: 2, + }); + const result = await service.screen(query); + + expect(result.items).toHaveLength(2); + expect(result.totalCount).toBe(3); + expect(result.totalPages).toBe(2); + }); + + it('should use cache for board data', async () => { + const cacheMock = module.get(CacheService) as { getOrFetch: ReturnType }; + cacheMock.getOrFetch.mockResolvedValue({ + data: mockShares, + fromCache: true, + cachedAt: new Date().toISOString(), + }); + + const query = Object.assign(new ScreenerQueryDto(), { type: ScreenerType.SHARE }); + await service.screen(query); + + expect(cacheMock.getOrFetch).toHaveBeenCalledWith( + 'screener', + ['share'], + expect.any(Function), + 'marketDataTtl', + ); + }); + + it('should return empty result when no items match filters', async () => { + vi.mocked(moexClient.getShareMarketDataBatch).mockResolvedValue(mockShares); + + const cacheMock = module.get(CacheService) as { getOrFetch: ReturnType }; + cacheMock.getOrFetch.mockImplementation( + async (_prefix: string, _key: string[], fetchFn: () => Promise) => ({ + data: await fetchFn(), + fromCache: false, + cachedAt: null, + }), + ); + + const query = Object.assign(new ScreenerQueryDto(), { + type: ScreenerType.SHARE, + priceMin: 1000, + }); + const result = await service.screen(query); + + expect(result.totalCount).toBe(0); + expect(result.items).toHaveLength(0); + }); +}); +``` + +- [ ] **Run tests** + +```bash +npx vitest run apps/backend/src/modules/securities/screener.service.spec.ts -w apps/backend +``` + +Expected: all tests pass + +--- + +### Task 7: Frontend types — add screener types + +**Files:** +- Modify: `apps/frontend/src/api/responses.ts` + +- [ ] **Add ScreenerItem and ScreenerResult types** + +```typescript +export interface ScreenerItem { + secid: string; + shortName: string; + isin: string; + type: 'share' | 'bond'; + price: number | null; + change: number | null; + changePercent: number | null; + volume: number; + listLevel: number; + capitalization: number | null; + yieldToMaturity: number | null; + duration: number | null; + couponValue: number | null; + couponPercent: number | null; + accruedInt: number | null; + matDate: string | null; + bondType: string | null; +} + +export interface ScreenerResult { + totalCount: number; + page: number; + pageSize: number; + totalPages: number; + items: ScreenerItem[]; +} +``` + +--- + +### Task 8: Frontend API client for screener + +**Files:** +- Create: `apps/frontend/src/api/screener.ts` + +- [ ] **Create screener API client** + +```typescript +import { request } from './client'; +import type { ScreenerResult } from './responses'; + +export interface ScreenerParams { + type: 'share' | 'bond'; + priceMin?: number; + priceMax?: number; + volumeMin?: number; + listLevel?: number; + changePercentMin?: number; + changePercentMax?: number; + capitalizationMin?: number; + yieldMin?: number; + yieldMax?: number; + durationMin?: number; + durationMax?: number; + couponMin?: number; + couponMax?: number; + couponPercentMin?: number; + couponPercentMax?: number; + maturityBefore?: string; + maturityAfter?: string; + bondType?: string; + sortBy?: string; + sortOrder?: 'asc' | 'desc'; + page?: number; + pageSize?: number; +} + +export function getScreenerResults( + params: ScreenerParams, +): Promise<{ data: ScreenerResult; meta: { cachedAt: string | null; fromCache: boolean } }> { + const searchParams = new URLSearchParams(); + Object.entries(params).forEach(([key, value]) => { + if (value !== undefined && value !== null && value !== '') { + searchParams.set(key, String(value)); + } + }); + const qs = searchParams.toString(); + return request(`/api/v1/securities/screener${qs ? `?${qs}` : ''}`); +} +``` + +--- + +### Task 9: Frontend useScreener hook + +**Files:** +- Create: `apps/frontend/src/hooks/useScreener.ts` + +- [ ] **Create useScreener hook with URL search params sync** + +```typescript +import { useSearchParams } from 'react-router-dom'; +import { useQuery } from '@tanstack/react-query'; +import { getScreenerResults } from '../api/screener'; +import type { ScreenerParams } from '../api/screener'; + +export function useScreener() { + const [searchParams, setSearchParams] = useSearchParams(); + + const params: ScreenerParams = { + type: (searchParams.get('type') as 'share' | 'bond') || 'share', + priceMin: searchParams.get('priceMin') ? Number(searchParams.get('priceMin')) : undefined, + priceMax: searchParams.get('priceMax') ? Number(searchParams.get('priceMax')) : undefined, + volumeMin: searchParams.get('volumeMin') ? Number(searchParams.get('volumeMin')) : undefined, + listLevel: searchParams.get('listLevel') ? Number(searchParams.get('listLevel')) : undefined, + changePercentMin: searchParams.get('changePercentMin') ? Number(searchParams.get('changePercentMin')) : undefined, + changePercentMax: searchParams.get('changePercentMax') ? Number(searchParams.get('changePercentMax')) : undefined, + capitalizationMin: searchParams.get('capitalizationMin') ? Number(searchParams.get('capitalizationMin')) : undefined, + yieldMin: searchParams.get('yieldMin') ? Number(searchParams.get('yieldMin')) : undefined, + yieldMax: searchParams.get('yieldMax') ? Number(searchParams.get('yieldMax')) : undefined, + durationMin: searchParams.get('durationMin') ? Number(searchParams.get('durationMin')) : undefined, + durationMax: searchParams.get('durationMax') ? Number(searchParams.get('durationMax')) : undefined, + couponMin: searchParams.get('couponMin') ? Number(searchParams.get('couponMin')) : undefined, + couponMax: searchParams.get('couponMax') ? Number(searchParams.get('couponMax')) : undefined, + couponPercentMin: searchParams.get('couponPercentMin') ? Number(searchParams.get('couponPercentMin')) : undefined, + couponPercentMax: searchParams.get('couponPercentMax') ? Number(searchParams.get('couponPercentMax')) : undefined, + maturityBefore: searchParams.get('maturityBefore') || undefined, + maturityAfter: searchParams.get('maturityAfter') || undefined, + bondType: searchParams.get('bondType') || undefined, + sortBy: searchParams.get('sortBy') || undefined, + sortOrder: (searchParams.get('sortOrder') as 'asc' | 'desc') || undefined, + page: searchParams.get('page') ? Number(searchParams.get('page')) : undefined, + pageSize: searchParams.get('pageSize') ? Number(searchParams.get('pageSize')) : undefined, + }; + + const queryKey = ['screener', params]; + + const query = useQuery({ + queryKey, + queryFn: () => getScreenerResults(params), + staleTime: 60_000, + retry: 2, + refetchOnWindowFocus: false, + }); + + function setParam(key: string, value: string | undefined) { + setSearchParams((prev) => { + const next = new URLSearchParams(prev); + if (value === undefined || value === '') { + next.delete(key); + } else { + next.set(key, value); + } + next.set('page', '1'); // reset page on filter change + return next; + }); + } + + function setFilters(filters: Partial) { + setSearchParams((prev) => { + const next = new URLSearchParams(prev); + Object.entries(filters).forEach(([key, value]) => { + if (value === undefined || value === null || value === '') { + next.delete(key); + } else { + next.set(key, String(value)); + } + }); + next.set('page', '1'); + return next; + }); + } + + function setPage(page: number) { + setSearchParams((prev) => { + const next = new URLSearchParams(prev); + next.set('page', String(page)); + return next; + }); + } + + function setSort(sortBy: string) { + setSearchParams((prev) => { + const next = new URLSearchParams(prev); + const current = next.get('sortBy'); + const currentOrder = next.get('sortOrder') || 'asc'; + if (current === sortBy) { + next.set('sortOrder', currentOrder === 'asc' ? 'desc' : 'asc'); + } else { + next.set('sortBy', sortBy); + next.set('sortOrder', 'asc'); + } + next.set('page', '1'); + return next; + }); + } + + function resetFilters() { + setSearchParams(new URLSearchParams({ type: params.type })); + } + + return { + params, + result: query.data?.data ?? null, + isLoading: query.isLoading, + error: query.error, + setFilters, + setPage, + setSort, + setParam, + resetFilters, + }; +} +``` + +--- + +### Task 10: Frontend FilterPanel components + +**Files:** +- Create: `apps/frontend/src/components/screener/FilterPanel.tsx` +- Create: `apps/frontend/src/components/screener/FilterPanelShare.tsx` +- Create: `apps/frontend/src/components/screener/FilterPanelBond.tsx` + +- [ ] **Create FilterPanel component** + +```typescript +import { useState } from 'react'; +import type { ScreenerParams } from '../../api/screener'; +import { FilterPanelShare } from './FilterPanelShare'; +import { FilterPanelBond } from './FilterPanelBond'; + +interface Props { + params: ScreenerParams; + onApply: (filters: Partial) => void; + onReset: () => void; +} + +export function FilterPanel({ params, onApply, onReset }: Props) { + const [type, setType] = useState<'share' | 'bond'>(params.type); + const [local, setLocal] = useState>({}); + + function handleApply() { + const filters: Partial = { type }; + Object.entries(local).forEach(([key, value]) => { + if (value !== '') { + const num = Number(value); + filters[key as keyof ScreenerParams] = isNaN(num) ? (value as any) : (num as any); + } + }); + onApply(filters); + } + + function handleReset() { + setLocal({}); + onReset(); + } + + function updateField(key: string, value: string) { + setLocal((prev) => ({ ...prev, [key]: value })); + } + + return ( +
+
+ + +
+ + {type === 'share' ? ( + + ) : ( + + )} + +
+ + +
+
+ ); +} +``` + +- [ ] **Create FilterPanelShare component** + +```typescript +interface Props { + params: Record; + local: Record; + updateField: (key: string, value: string) => void; +} + +export function FilterPanelShare({ local, updateField }: Props) { + const fields = [ + { key: 'priceMin', label: 'Цена от' }, + { key: 'priceMax', label: 'Цена до' }, + { key: 'changePercentMin', label: 'Изм. % от' }, + { key: 'changePercentMax', label: 'Изм. % до' }, + { key: 'volumeMin', label: 'Объём от' }, + { key: 'capitalizationMin', label: 'Капитализация от' }, + ]; + + return ( +
+ {fields.map(({ key, label }) => ( +
+ + updateField(key, e.target.value)} + style={{ + width: '100%', + padding: '6px 10px', + border: '1px solid #e0e0e0', + borderRadius: 'var(--border-radius)', + fontSize: 13, + boxSizing: 'border-box', + }} + /> +
+ ))} +
+ ); +} +``` + +- [ ] **Create FilterPanelBond component** + +```typescript +interface Props { + params: Record; + local: Record; + updateField: (key: string, value: string) => void; +} + +export function FilterPanelBond({ local, updateField }: Props) { + const fields = [ + { key: 'priceMin', label: 'Цена (% от номинала) от' }, + { key: 'priceMax', label: 'Цена (% от номинала) до' }, + { key: 'yieldMin', label: 'YTM % от' }, + { key: 'yieldMax', label: 'YTM % до' }, + { key: 'durationMin', label: 'Дюрация (лет) от' }, + { key: 'durationMax', label: 'Дюрация (лет) до' }, + { key: 'couponMin', label: 'Купон (₽) от' }, + { key: 'couponMax', label: 'Купон (₽) до' }, + { key: 'couponPercentMin', label: 'Купон % от' }, + { key: 'couponPercentMax', label: 'Купон % до' }, + ]; + + return ( +
+ {fields.map(({ key, label }) => ( +
+ + updateField(key, e.target.value)} + style={{ + width: '100%', + padding: '6px 10px', + border: '1px solid #e0e0e0', + borderRadius: 'var(--border-radius)', + fontSize: 13, + boxSizing: 'border-box', + }} + /> +
+ ))} +
+ ); +} +``` + +--- + +### Task 11: Frontend ScreenerTable component + +**Files:** +- Create: `apps/frontend/src/components/screener/ScreenerTable.tsx` + +- [ ] **Create ScreenerTable with sortable columns** + +```typescript +import { Link } from 'react-router-dom'; +import type { ScreenerItem, ScreenerResult } from '../../api/responses'; + +interface Props { + result: ScreenerResult; + sortBy: string; + sortOrder: 'asc' | 'desc'; + onSort: (field: string) => void; + onPageChange: (page: number) => void; +} + +function formatNum(value: number | null | undefined, digits = 2): string { + if (value == null) return '—'; + return value.toLocaleString('ru-RU', { minimumFractionDigits: digits, maximumFractionDigits: digits }); +} + +function formatChange(value: number | null | undefined): { text: string; color: string } { + if (value == null) return { text: '—', color: 'inherit' }; + const color = value > 0 ? '#43a047' : value < 0 ? '#e53935' : 'inherit'; + return { text: `${value > 0 ? '+' : ''}${value.toFixed(2)}%`, color }; +} + +export function ScreenerTable({ result, sortBy, sortOrder, onSort, onPageChange }: Props) { + function SortHeader({ field, children }: { field: string; children: string }) { + const isActive = sortBy === field; + return ( + onSort(field)} + style={{ + textAlign: 'right', + padding: '8px 12px', + fontWeight: 600, + fontSize: 12, + color: 'var(--color-text-secondary)', + cursor: 'pointer', + userSelect: 'none', + whiteSpace: 'nowrap', + }} + > + {children} {isActive ? (sortOrder === 'asc' ? '▲' : '▼') : ''} + + ); + } + + const isShare = result.items[0]?.type === 'share'; + + return ( +
+
+ Найдено: {result.totalCount} бумаг +
+
+ + + + + + Цена + Изм. + Объём + {isShare ? ( + Капитализация + ) : ( + <> + YTM + Дюрация + Купон + Куп. % + + )} + + + + {result.items.map((item) => { + const change = formatChange(item.changePercent); + const link = isShare ? `/stocks/${item.secid}` : `/bonds/${item.secid}`; + return ( + + + + + + + {isShare ? ( + + ) : ( + <> + + + + + + )} + + ); + })} + +
+ Тикер + + Название +
+ + {item.secid} + + + {item.shortName} + + {formatNum(item.price)} + + {change.text} + + {item.volume.toLocaleString('ru-RU')} + + {item.capitalization != null ? item.capitalization.toLocaleString('ru-RU') : '—'} + + {formatNum(item.yieldToMaturity)} + + {item.duration != null ? `${item.duration.toFixed(2)}г` : '—'} + + {formatNum(item.couponValue)} + + {formatNum(item.couponPercent)} +
+
+ + {result.totalPages > 1 && ( +
+ {Array.from({ length: Math.min(result.totalPages, 10) }, (_, i) => i + 1).map((p) => ( + + ))} +
+ )} +
+ ); +} +``` + +--- + +### Task 12: Frontend ScreenerPage + +**Files:** +- Create: `apps/frontend/src/pages/screener/ScreenerPage.tsx` + +- [ ] **Create ScreenerPage** + +```typescript +import { useScreener } from '../../hooks/useScreener'; +import { FilterPanel } from '../../components/screener/FilterPanel'; +import { ScreenerTable } from '../../components/screener/ScreenerTable'; + +export function ScreenerPage() { + const { + params, result, isLoading, error, + setFilters, setPage, setSort, resetFilters, + } = useScreener(); + + return ( +
+

Скринер

+
+ + + {isLoading && ( +
+ Загрузка... +
+ )} + + {error && ( +
+ Ошибка загрузки данных. Попробуйте позже. +
+ )} + + {!isLoading && !error && result && result.items.length === 0 && ( +
+ Ничего не найдено. Попробуйте смягчить фильтры. +
+ )} + + {!isLoading && !error && result && result.items.length > 0 && ( + + )} +
+
+ ); +} +``` + +--- + +### Task 13: Frontend routes + nav link + +**Files:** +- Modify: `apps/frontend/src/routes.tsx` +- Modify: `apps/frontend/src/components/Layout.tsx` + +- [ ] **Add /screener route to routes.tsx** + +```typescript +import { ScreenerPage } from './pages/screener/ScreenerPage'; + +// Inside inside the }> group: +} /> +``` + +Full updated routes.tsx: + +```typescript +import { Routes, Route } from 'react-router-dom'; +import { Layout } from './components/Layout'; +import { HomePage } from './pages/HomePage'; +import { StockPage } from './pages/StockPage'; +import { BondPage } from './pages/BondPage'; +import { LoginPage } from './pages/LoginPage'; +import { RegisterPage } from './pages/RegisterPage'; +import { ProfilePage } from './pages/ProfilePage'; +import { ProtectedRoute } from './components/ProtectedRoute'; +import { PortfoliosListPage } from './pages/portfolios/PortfoliosListPage'; +import { PortfolioDetailPage } from './pages/portfolios/PortfolioDetailPage'; +import { ScreenerPage } from './pages/screener/ScreenerPage'; + +export function AppRoutes() { + return ( + + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + ); +} +``` + +- [ ] **Add Скринер nav link to Layout.tsx** + +Add after the Портфели link (around line 47): + +```typescript + + Скринер + +``` + +--- + +### Task 14: Verify everything works + +- [ ] **Run all backend tests** + +```bash +npx vitest run -w apps/backend +``` + +Expected: all tests pass + +- [ ] **Build backend** + +```bash +npm run build:backend +``` + +Expected: no errors + +- [ ] **Build frontend** + +```bash +npm run build:frontend +``` + +Expected: no errors + +- [ ] **Run lint** + +```bash +npm run lint +``` + +Expected: no errors + +- [ ] **Run frontend tests** + +```bash +npx vitest run -w apps/frontend +``` + +Expected: all tests pass + +- [ ] **Commit** + +```bash +git add apps/backend/src/modules/moex-client/moex-client.service.ts \ + apps/backend/src/modules/securities/dto/screener-query.dto.ts \ + apps/backend/src/modules/securities/dto/screener-response.dto.ts \ + apps/backend/src/modules/securities/screener.service.ts \ + apps/backend/src/modules/securities/screener.service.spec.ts \ + apps/backend/src/modules/securities/securities.controller.ts \ + apps/backend/src/modules/securities/securities.module.ts \ + apps/frontend/src/api/responses.ts \ + apps/frontend/src/api/screener.ts \ + apps/frontend/src/hooks/useScreener.ts \ + apps/frontend/src/components/screener/FilterPanel.tsx \ + apps/frontend/src/components/screener/FilterPanelShare.tsx \ + apps/frontend/src/components/screener/FilterPanelBond.tsx \ + apps/frontend/src/components/screener/ScreenerTable.tsx \ + apps/frontend/src/pages/screener/ScreenerPage.tsx \ + apps/frontend/src/routes.tsx \ + apps/frontend/src/components/Layout.tsx +git commit -m "feat: add security screener with filtering and sorting" +``` diff --git a/docs/superpowers/specs/2026-06-14-portfolio-analytics-design.md b/docs/superpowers/specs/2026-06-14-portfolio-analytics-design.md new file mode 100644 index 0000000..c753da5 --- /dev/null +++ b/docs/superpowers/specs/2026-06-14-portfolio-analytics-design.md @@ -0,0 +1,441 @@ +# Portfolio Analytics — Design Specification (SDD) + +**Date:** 2026-06-14 +**Status:** Draft +**Author:** AI Assistant + +--- + +## 1. Product Requirements Document (PRD) + +### 1.1 Product Vision + +Добавить в MoexVibe аналитику доходности портфелей: расчёт PnL (прибыль/убыток) по каждой позиции и по портфелю в целом, учёт дивидендного дохода, сравнение фактического распределения с целевым. Пользователь может указать цену покупки для каждой позиции и видеть реальную доходность своих инвестиций. + +### 1.2 Target Audience + +Текущие пользователи MoexVibe — частные инвесторы, которые уже используют портфели для отслеживания состава вложений. Аналитика превращает портфель из «учёта» в инструмент оценки эффективности инвестиций. + +### 1.3 Scope + +| In Scope | Out of Scope (Future Phases) | +|---|---| +| Цена покупки (buyPrice) на позицию | История транзакций (buy/sell log) | +| Дата покупки (buyDate) на позицию | XIRR / time-weighted return | +| Unrealized PnL (абсолютный и %) | График стоимости портфеля во времени | +| Dividend income по позиции | Налоговая отчётность | +| Portfolio-level PnL (total + %) | Multi-currency конверсия | +| Target allocation comparison (факт vs цель) | Scheduled snapshots / history | +| Цветовая индикация PnL (зелёный/красный) | Экспорт отчётов | + +### 1.4 User Stories + +- US-AN-001: Пользователь указывает цену покупки при добавлении позиции в портфель +- US-AN-002: Пользователь редактирует цену покупки существующей позиции +- US-AN-003: Пользователь видит unrealized PnL (в валюте) по каждой позиции +- US-AN-004: Пользователь видит unrealized PnL (%) по каждой позиции +- US-AN-005: Пользователь видит суммарный PnL по всему портфелю +- US-AN-006: Пользователь видит общую доходность портфеля в процентах +- US-AN-007: Пользователь видит дивидендный доход по позиции (если buyPrice указан) +- US-AN-008: Пользователь задаёт целевое распределение (shares/bonds %) +- US-AN-009: Пользователь видит отклонение факта от цели + +### 1.5 Non-Functional Requirements + +- PnL рассчитывается на backend при enrichment (не на frontend) +- Дивиденды кешируются с TTL 86400s (как сейчас) +- PnL для облигаций учитывает faceValue и НКД +- При отсутствии buyPrice колонки PnL показывают `—` + +--- + +## 2. Domain Model + +``` +Position (расширение существующей модели) { + // ... существующие поля + buyPrice: number | null // цена покупки за единицу (в валюте портфеля) + buyDate: string | null // дата покупки (ISO date, опционально) + + // Computed at read time (EnrichedPosition): + currentPrice: number | null + currentValue: number | null // quantity * currentPrice + totalCost: number | null // buyPrice * quantity + unrealizedPnl: number | null // currentValue - totalCost + unrealizedPnlPercent: number | null // (currentPrice - buyPrice) / buyPrice * 100 + dividendIncome: number | null // сумма дивидендов (если buyDate указан) + totalReturn: number | null // (unrealizedPnl + dividendIncome) / totalCost * 100 + weightPercent: number +} + +PortfolioAnalytics { + totalCost: number | null + totalValue: number + totalPnl: number | null + totalPnlPercent: number | null + totalDividendIncome: number + totalReturn: number | null + targetSharesPercent: number | null + actualSharesPercent: number + targetBondsPercent: number | null + actualBondsPercent: number + sharesDeviation: number | null + bondsDeviation: number | null +} + +DividendSummary { + secid: string + registryCloseDate: string + value: number + currency: string +} +``` + +### Расчёт PnL для облигаций + +Для облигаций цена указывается в % от номинала. Формула: + +``` +currentValue = (currentPrice / 100) * faceValue * quantity +totalCost = buyPrice * quantity // buyPrice указывается пользователем +unrealizedPnl = currentValue - totalCost + accruedInt +``` + +### Расчёт дивидендного дохода + +``` +dividendIncome = SUM(dividend.value) + WHERE dividend.registryCloseDate >= position.buyDate + AND position.type = 'share' +``` + +--- + +## 3. Architecture + +### 3.1 Backend — изменения в существующем PortfolioModule + +``` +PortfolioModule (изменения) +├── PortfolioService — расширенная логика enrichment +│ ├── enrichPositions() — новый расчёт PnL полей +│ ├── calculateDividendIncome() — новый метод +│ └── calculateAnalytics() — новый метод для портфеля в целом +├── dto/ +│ ├── add-position.dto.ts — новое поле buyPrice (optional), buyDate (optional) +│ ├── update-position.dto.ts — новое поле buyPrice (optional), buyDate (optional) +│ ├── position-response.dto.ts — новые PnL поля +│ └── analytics-response.dto.ts — НОВЫЙ: PortfolioAnalyticsDto +└── portfolio.controller.ts — новый эндпоинт GET /:id/analytics (опционально) +``` + +**Новые/изменяемые зависимости:** +- `PortfolioService` использует `MoexClientService.getDividends(secid)` для dividend income +- Кеш дивидендов существует (`cache.dividendsTtl`, 86400s) + +### 3.2 Frontend + +``` +src/components/portfolios/ +├── SharePositionRow.tsx — изменён: новые колонки PnL / PnL% / Дox.% +├── BondPositionRow.tsx — изменён: новые колонки PnL / PnL% / Дox.% +├── PortfolioSummary.tsx — изменён: добавлен PnL, доходность +└── AnalyticsSummary.tsx — НОВЫЙ: карточка аналитики портфеля + +src/hooks/ +├── usePortfolio.ts — изменён: новые поля в типе +└── usePositionMutations.ts — изменён: buyPrice передаётся в мутацию + +src/api/ +├── portfolio.ts — без изменений (те же эндпоинты) +└── responses.ts — новые поля типах +``` + +### 3.3 Роутинг + +Без изменений — аналитика интегрируется в существующую страницу `/portfolios/:id`. + +--- + +## 4. API Contracts + +### 4.1 Изменения в существующих эндпоинтах + +**POST /api/v1/portfolios/:id/positions** +```typescript +// Добавлены опциональные поля: +Body: { + secid: string; // required + quantity: number; // required + buyPrice?: number; // NEW: optional, цена покупки + buyDate?: string; // NEW: optional, ISO date + notes?: string; + tags?: string[]; +} +``` + +**PATCH /api/v1/portfolios/:id/positions/:posId** +```typescript +// Добавлены опциональные поля: +Body: { + quantity?: number; + buyPrice?: number; // NEW + buyDate?: string; // NEW + notes?: string; + tags?: string[]; +} +``` + +**GET /api/v1/portfolios/:id — расширенный ответ** +```typescript +Response: { + data: { + id: number; + name: string; + description: string | null; + currency: string; + createdAt: string; + updatedAt: string; + totalValue: number; + positions: EnrichedPosition[]; // с новыми PnL полями + analytics: PortfolioAnalytics; // NEW: агрегированная аналитика + }, + meta: { fromCache, cachedAt } +} +``` + +### 4.2 Response Types + +```typescript +class EnrichedPosition { + // ... существующие поля + totalCost: number | null; + unrealizedPnl: number | null; + unrealizedPnlPercent: number | null; + dividendIncome: number | null; + totalReturn: number | null; +} + +class PortfolioAnalytics { + totalCost: number | null; + totalValue: number; + totalPnl: number | null; + totalPnlPercent: number | null; + totalDividendIncome: number; + totalReturn: number | null; + targetSharesPercent: number | null; + actualSharesPercent: number; + targetBondsPercent: number | null; + actualBondsPercent: number; + sharesDeviation: number | null; + bondsDeviation: number | null; +} +``` + +### 4.3 Error Codes + +| HTTP | Code | Когда | +|---|---|---| +| 400 | `VALIDATION_ERROR` | buyPrice ≤ 0, buyDate в будущем | +| 422 | `NO_DIVIDEND_DATA` | Дивиденды недоступны (не share) | +| 404 | `NOT_FOUND` | Позиция не найдена | + +--- + +## 5. Database Schema (Prisma) + +```prisma +model Position { + id Int @id @default(autoincrement()) + portfolioId Int + secid String + type String @default("share") // "share" | "bond" + quantity Int + buyPrice Float? // NEW: цена покупки за единицу + buyDate DateTime? // NEW: дата покупки (опционально) + notes String? + tags String? // JSON: ["DIVIDEND", "GROWTH"] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + portfolio Portfolio @relation(fields: [portfolioId], references: [id], onDelete: Cascade) + + @@unique([portfolioId, secid]) +} +``` + +**Миграция:** `npx prisma migrate dev --name add-buy-price-to-position` + +**Обратная совместимость:** Все существующие Position получают `buyPrice = null`, `buyDate = null`. PnL для них не отображается. + +--- + +## 6. Business Rules + +| Rule | Описание | +|---|---| +| BR-AN-001 | `buyPrice > 0` если указан — цена покупки должна быть положительной | +| BR-AN-002 | `buyDate` не может быть в будущем | +| BR-AN-003 | Если `buyPrice = null`, PnL поля не вычисляются (возвращаются как null) | +| BR-AN-004 | `dividendIncome` вычисляется только для `type = 'share'` и только если указан `buyDate` | +| BR-AN-005 | `totalReturn` = `(unrealizedPnl + dividendIncome) / totalCost * 100` | +| BR-AN-006 | Для `type = 'bond'` `currentValue` рассчитывается через `(currentPrice / 100) * faceValue * quantity` | +| BR-AN-007 | `targetSharesPercent + targetBondsPercent` должна быть 100 (если оба указаны) | +| BR-AN-008 | `totalPnlPercent` = `sum(unrealizedPnl) / sum(totalCost) * 100` (weighted average) | + +--- + +## 7. Events (Domain Events) + +| Событие | Payload | Когда происходит | Будущее использование | +|---|---|---|---| +| `PositionBuyPriceSet` | `{ positionId, portfolioId, secid, buyPrice, buyDate }` | POST/PATCH position | Snapshot для графика стоимости | +| `DividendsCalculated` | `{ positionId, secid, totalDividends }` | GET portfolio | Аудит, кеш инвалидация | + +--- + +## 8. Risks and Edge Cases + +| Risk | Impact | Mitigation | +|---|---|---| +| **buyPrice не указан** | PnL недоступен | Показывать `—`, не блокировать остальные функции | +| **Корпоративное действие (сплит)** | Количество изменилось, buyPrice неактуален | Сейчас не обрабатываем. Будущая фаза: CorporateActions | +| **Докупка бумаги** | Средняя цена входа меняется | Нет механизма дозаписей. Работаем с «одной сделкой». Будущая фаза: transactions | +| **Дивиденды за период > 1 года** | Много запросов к MOEX | Кеш 86400s, batch запрос | +| **Bond buyPrice в % vs в рублях** | Путаница при вводе | buyPrice всегда в валюте портфеля (RUB). Бэкенд не конвертирует | +| **Отрицательный PnL на 99%** | UI переполнение | Ограничить отображение 2 знаками после запятой | +| **Облигация с НКД** | Стоимость покупки ≠ текущая стоимость | currentValue = рыночная цена (без НКД). UnrealizedPnl включает накопленный доход | + +--- + +## 9. Implementation Phases + +### Phase 1: Cost Basis + PnL Core + +**Backend:** +- Prisma: добавить `buyPrice` (Float?) и `buyDate` (DateTime?) в модель Position +- Создать миграцию +- Обновить DTO: `AddPositionDto`, `UpdatePositionDto` — добавить `buyPrice`, `buyDate` +- Обновить `PortfolioService.enrichPositions()`: + - Расчёт `totalCost` = `buyPrice * quantity` + - Расчёт `unrealizedPnl` = `currentValue - totalCost` + - Расчёт `unrealizedPnlPercent` = `(currentPrice - buyPrice) / buyPrice * 100` + - Для bonds: `currentValue = (currentPrice / 100) * faceValue * quantity` +- Создать `PortfolioAnalytics` — агрегация на уровне портфеля +- Вернуть analytics в `findOne()` +- Написать тесты (см. Phase 4) + +**Frontend:** +- Обновить `PositionWithPrice` в `responses.ts` — новые PnL поля +- Обновить `AddPositionDto` / `UpdatePositionDto` — buyPrice, buyDate +- Обновить `usePositionMutations.ts` — передавать buyPrice +- `PositionRow` (share + bond): добавить колонки: + - Цена покупки (edit inline) + - PnL (валюта, зелёный/красный) + - PnL% +- `PortfolioSummary` / новая карточка `AnalyticsSummary`: total PnL, total return % + +### Phase 2: Dividend Income + +**Backend:** +- В `PortfolioService`: метод `calculateDividendIncome(position)`: + - Если `position.type !== 'share'` → return 0 + - Если `buyDate === null` → return 0 + - Вызвать `moexClient.getDividends(secid)` + - Отфильтровать `registryCloseDate >= buyDate` + - Суммировать `value` +- Добавить `dividendIncome` в `EnrichedPosition` +- Добавить `totalDividendIncome` в `PortfolioAnalytics` +- Кешировать результат на 86400s + +**Frontend:** +- `AnalyticsSummary`: добавить строку «Дивидендный доход» +- `SharePositionRow`: добавить колонку «Дивиденды» + +### Phase 3: Target Allocation Comparison + +**Backend:** +- Реализовать чтение `Portfolio.targets` (JSON поле уже существует в схеме) +- Парсить `targets` как `{ sharesPercent: number, bondsPercent: number }` +- Вернуть в `analytics`: `targetSharesPercent`, `targetBondsPercent`, `sharesDeviation`, `bondsDeviation` +- Валидация при PATCH portfolio: `sharesPercent + bondsPercent === 100` + +**Frontend:** +- `PortfolioForm`: добавить поля `Цель: акции %` и `Цель: облигации %` +- `AnalyticsSummary`: отображать факт vs цель, отклонение цветом + +--- + +## 10. OpenAPI Specification + +Дополнения к существующему `docs/openapi/openapi.yaml`: + +- Обновить схему `PositionResponse` — добавить `buyPrice`, `buyDate` +- Создать схему `PortfolioAnalytics` со всеми полями +- Обновить `PortfolioDetailResponse` — добавить `analytics` + +--- + +## 11. ADR + +### ADR-011: PnL Calculation on Backend + +**Context:** Где рассчитывать PnL — на backend или frontend? + +**Decision:** На backend, в `PortfolioService.enrichPositions()`. PnL поля — часть `EnrichedPosition`. + +**Rationale:** +- Единый источник истины (DRY: frontend и API клиенты получают одинаковые данные) +- Можно кешировать результат enrichment +- Сложная логика (особенно dividend income) проще тестируется на backend +- Соответствует существующей архитектуре (currentPrice, currentValue уже на backend) + +**Consequences:** +- Backend делает доп.запросы к MOEX за дивидендами (кешируются) +- `GET /portfolios/:id` может быть медленнее (но enrichment уже делает batch-запросы) + +### ADR-012: BuyPrice как Float (не Decimal) + +**Context:** Какой тип данных использовать для `buyPrice` в SQLite/Prisma? + +**Decision:** `Float` (Prisma) / REAL (SQLite). + +**Rationale:** +- SQLite не имеет нативного decimal типа +- Цены акций MOEX имеют 2 знака после запятой (копейки) — Float достаточен +- `class-validator` с `@IsNumber` обрабатывает Float корректно +- При миграции на PostgreSQL можно перейти на `Decimal` + +**Consequences:** +- Возможны ошибки округления при very large quantities (>1M) +- На фронтенде форматировать через `toFixed(2)` + +### ADR-013: Dividend Income Calculation + +**Context:** Как определять, какие дивиденды относятся к позиции? + +**Decision:** Суммировать все дивиденды MOEX по secid, где `registryCloseDate >= position.buyDate`. + +**Rationale:** +- Простейшая имплементация без истории сделок +- MOEX возвращает полную историю дивидендов по secid +- Если buyDate не указан — дивиденды не считаются (0) + +**Consequences:** +- Если пользователь докупал бумагу, дивиденды засчитываются полностью (не пропорционально) +- Фикса: нужна модель Transaction, что в out of scope + +--- + +## 12. Self-Review Checklist + +- [x] Нет placeholder'ов (TBD, TODO) +- [x] Все разделы заполнены +- [x] PRD покрывает ключевые user stories +- [x] Domain model однозначна (Position с buyPrice/buyDate) +- [x] API контракты расширены обратно-совместимо (все новые поля optional) +- [x] Business rules полны и непротиворечивы +- [x] Риски документированы с mitigation +- [x] Scope Phase 1 чётко отделён от Phase 2/3 +- [x] ADR документируют ключевые решения +- [x] Обратная совместимость с существующими данными гарантирована diff --git a/docs/superpowers/specs/2026-06-14-security-screener-design.md b/docs/superpowers/specs/2026-06-14-security-screener-design.md new file mode 100644 index 0000000..921cf96 --- /dev/null +++ b/docs/superpowers/specs/2026-06-14-security-screener-design.md @@ -0,0 +1,584 @@ +# Security Screener — Design Specification (SDD) + +**Date:** 2026-06-14 +**Status:** Draft +**Author:** AI Assistant + +--- + +## 1. Product Requirements Document (PRD) + +### 1.1 Product Vision + +Добавить в MoexVibe функциональность скринера (фильтра) ценных бумаг — инструмент для поиска инвестиционных идей. Пользователь может задавать фильтры по ключевым параметрам акций и облигаций MOEX: цена, доходность, объём, дюрация, дивидендная доходность и т.д. — и получать таблицу бумаг, удовлетворяющих критериям. + +### 1.2 Target Audience + +Частные инвесторы, которые ищут бумаги для инвестиций по заданным критериям. Скринер — стандартный инструмент брокерских платформ (Tinkoff, BCS, QUIK), отсутствие которого в MoexVibe снижает ценность продукта для активных инвесторов. + +### 1.3 Scope + +| In Scope | Out of Scope (Future Phases) | +|---|---| +| Фильтр по типу бумаги (акции / облигации) | Фундаментальные мультипликаторы (P/E, P/B, EV/EBITDA) | +| Фильтр по цене (диапазон) | Технические индикаторы (RSI, SMA) | +| Фильтр по изменению цены (%) | Сравнение бумаг (side-by-side) | +| Фильтр по объёму торгов | Сохранение скринера (шаблоны фильтров) | +| Фильтр по капитализации | Экспорт результатов | +| Фильтр по дивидендной доходности (акции) | Уведомления по результатам скринера | +| Фильтр по YTM / YTP (облигации) | | +| Фильтр по дюрации (облигации) | | +| Фильтр по купонной ставке (облигации) | | +| Фильтр по дате погашения (облигации) | | +| Фильтр по типу облигации | | +| Фильтр по уровню листинга | | +| Сортировка по любому столбцу | | +| Пагинация (20 элементов) | | + +### 1.4 User Stories + +- US-SC-001: Пользователь открывает страницу скринера и видит форму фильтров +- US-SC-002: Пользователь выбирает тип бумаги (акции/облигации) — форма фильтров меняется +- US-SC-003: Пользователь задаёт диапазон цены и нажимает «Применить» — видит результаты +- US-SC-004: Пользователь сортирует результаты по любому столбцу (возрастание/убывание) +- US-SC-005: Пользователь кликает на бумагу — переходит на её страницу +- US-SC-006: Пользователь видит количество найденных бумаг и пагинацию +- US-SC-007: Пользователь очищает фильтры кнопкой «Сбросить» + +### 1.5 Non-Functional Requirements + +- Данные загружаются одним batch-запросом к MOEX (вся доска), фильтрация на backend +- Кеш всей доски: TTL = 60s (данные меняются в реальном времени) +- Ответ должен приходить за <500ms при закешированных данных +- Фильтрация и сортировка на backend (не на frontend) +- Пагинация: 20 элементов на страницу (default) + +--- + +## 2. Domain Model + +``` +ScreenerQuery { + type: 'share' | 'bond' // required — определяет набор фильтров + // Общие фильтры: + priceMin?: number // минимальная цена (для shares: RUB, bonds: % от номинала) + priceMax?: number // максимальная цена + volumeMin?: number // минимальный объём торгов + listLevel?: number // уровень листинга (1, 2, 3) + // Фильтры для акций: + changePercentMin?: number // минимальное изменение цены (%) + changePercentMax?: number // максимальное изменение цены (%) + capitalizationMin?: number // минимальная капитализация + dividendYieldMin?: number // минимальная дивидендная доходность (%) + // Фильтры для облигаций: + yieldMin?: number // минимальная YTM (%) + yieldMax?: number // максимальная YTM (%) + durationMin?: number // минимальная дюрация (лет) + durationMax?: number // максимальная дюрация (лет) + couponMin?: number // минимальный купон (RUB) + couponMax?: number // максимальный купон (RUB) + couponPercentMin?: number // минимальная купонная ставка (%) + couponPercentMax?: number // максимальная купонная ставка (%) + maturityBefore?: string // погашение до даты (ISO date) + maturityAfter?: string // погашение после даты (ISO date) + bondType?: string // тип облигации (ОФЗ, Корпоративная, Субфедеральная, и т.д.) + // Сортировка и пагинация: + sortBy?: string // поле для сортировки (default: 'price') + sortOrder?: 'asc' | 'desc' // default: 'asc' + page?: number // default: 1 + pageSize?: number // default: 20, max: 100 +} + +ScreenerResult { + totalCount: number // всего найдено (до пагинации) + page: number + pageSize: number + totalPages: number + items: ScreenerItem[] +} + +ScreenerItem { + // Общие поля: + secid: string + shortName: string + isin: string + type: 'share' | 'bond' + price: number | null + change: number | null // изменение цены за сегодня + changePercent: number | null + volume: number + listLevel: number + // Для акций: + capitalization: number | null + dividendYield: number | null // расчётная дивидендная доходность + // Для облигаций: + yieldToMaturity: number | null + duration: number | null + couponValue: number | null + couponPercent: number | null + accruedInt: number | null + matDate: string | null + bondType: string | null +} +``` + +### Фильтрация на backend + +``` +function filterShares(items: ShareMarketData[], query: ScreenerQuery): ScreenerItem[] + return items.filter(item => + priceMin <= item.price <= priceMax && + volume >= volumeMin && + changePercentMin <= item.changePercent <= changePercentMax && + capitalization >= capitalizationMin && + dividendYield >= dividendYieldMin && + listLevel == query.listLevel (если указан) + ) + +function filterBonds(items: BondPositionData[], query: ScreenerQuery): ScreenerItem[] + return items.filter(item => + priceMin <= item.price <= priceMax && + yieldMin <= item.ytm <= yieldMax && + durationMin <= item.duration <= durationMax && + couponMin <= item.coupon <= couponMax && + maturityBefore >= item.matDate >= maturityAfter && + bondType == query.bondType (если указан) + ) +``` + +--- + +## 3. Architecture + +### 3.1 Backend + +``` +SecuritiesModule (расширение) +├── SecuritiesController — изменён: новый эндпоинт GET /screener +├── SecuritiesService — изменён: новый метод search() +├── ScreenerService — НОВЫЙ: логика фильтрации и пагинации +├── dto/ +│ └── screener-query.dto.ts — НОВЫЙ: DTO для query параметров +├── securities.module.ts — изменён: ScreenerService в providers +└── MoexClientService (глобальный) — изменён: метод getFullShareBoard(), getFullBondBoard() +``` + +**Поток данных:** + +``` +GET /securities/screener?type=share&priceMin=100&priceMax=500&page=1&pageSize=20 + ↓ +SecuritiesController.screener(query) + ↓ +ScreenerService.screen(query) + ↓ +CacheService.getOrFetch('screener:board', [type], fetchFn, 'marketDataTtl') + | где fetchFn = type === 'share' + | ? moexClient.getShareMarketDataBatch([]) — все акции + | : moexClient.getBondPositionDataBatch([]) — все облигации + ↓ +filter(board, query) — применяем фильтры + ↓ +sort(board, sortBy, sortOrder) — сортируем + ↓ +paginate(board, page, pageSize) — пагинация + ↓ +Response: { data: ScreenerResult, meta: { fromCache, cachedAt } } +``` + +### 3.2 Frontend + +``` +src/pages/screener/ +└── ScreenerPage.tsx — НОВЫЙ: страница скринера + +src/hooks/ +└── useScreener.ts — НОВЫЙ: хук для запроса скринера + +src/api/ +├── screener.ts — НОВЫЙ: API client +└── responses.ts — новые типы + +src/components/screener/ +├── FilterPanel.tsx — НОВЫЙ: панель фильтров +├── FilterPanelShare.tsx — НОВЫЙ: фильтры для акций +├── FilterPanelBond.tsx — НОВЫЙ: фильтры для облигаций +├── ScreenerTable.tsx — НОВЫЙ: таблица результатов +└── ScreenerTableRow.tsx — НОВЫЙ: строка таблицы +``` + +**Структура страницы:** + +``` +┌─────────────────────────────────────────────┐ +│ Header с навигацией (Layout) │ +├──────────────┬──────────────────────────────┤ +│ FilterPanel │ ScreenerTable │ +│ │ ┌────┬─────┬──────┬───┐ │ +│ Тип: ◉ акции│ │Тикер│Цена │Изм.% │ ↗│ │ +│ ○ облиг. │ ├────┼─────┼──────┼───┤ │ +│ │ │SBER│322.3│+0.36%│ ↗ │ │ +│ Цена от [__]│ │GAZP│155.2│-0.52%│ ↗ │ │ +│ Цена до [__]│ │... │ │ │ │ │ +│ │ └────┴─────┴──────┴───┘ │ +│ Объём от[__]│ [< 1 2 3 ... 10 >] │ +│ │ │ +│ [Применить] │ Найдено: 145 бумаг │ +│ [Сбросить] │ │ +└──────────────┴──────────────────────────────┘ +``` + +### 3.3 Роутинг + +```tsx +} /> +// Без ProtectedRoute — скринер доступен всем (как search) +``` + +### 3.4 State Management + +- URL query params — источник истины для фильтров (`/screener?type=share&priceMin=100`) +- `useScreener()` читает URL params, делает запрос +- При изменении фильтра → debounce 300ms → update URL → refetch +- Кнопка «Применить» для ручного запуска + +--- + +## 4. API Contracts + +### 4.1 Screener Endpoint + +``` +GET /api/v1/securities/screener + +Query Parameters: + type: 'share' | 'bond' (required) + priceMin: number (optional) + priceMax: number (optional) + volumeMin: number (optional) + listLevel: number (optional, 1-3) + // shares: + changePercentMin: number (optional) + changePercentMax: number (optional) + capitalizationMin: number (optional) + dividendYieldMin: number (optional) + // bonds: + yieldMin: number (optional) + yieldMax: number (optional) + durationMin: number (optional) + durationMax: number (optional) + couponMin: number (optional) + couponMax: number (optional) + couponPercentMin: number (optional) + couponPercentMax: number (optional) + maturityBefore: string (optional, ISO date) + maturityAfter: string (optional, ISO date) + bondType: string (optional) + // sort & pagination: + sortBy: string (optional, default: 'price') + sortOrder: 'asc' | 'desc' (optional, default: 'asc') + page: number (optional, default: 1) + pageSize: number (optional, default: 20) + +Response: { + data: { + totalCount: number, + page: number, + pageSize: number, + totalPages: number, + items: ScreenerItem[] + }, + meta: { fromCache, cachedAt } +} +``` + +### 4.2 Response Types + +```typescript +class ScreenerItem { + secid: string; + shortName: string; + isin: string; + type: 'share' | 'bond'; + price: number | null; + change: number | null; + changePercent: number | null; + volume: number; + listLevel: number; + capitalization?: number | null; // shares only + dividendYield?: number | null; // shares only + yieldToMaturity?: number | null; // bonds only + duration?: number | null; // bonds only + couponValue?: number | null; // bonds only + couponPercent?: number | null; // bonds only + accruedInt?: number | null; // bonds only + matDate?: string | null; // bonds only + bondType?: string | null; // bonds only +} + +class ScreenerResult { + totalCount: number; + page: number; + pageSize: number; + totalPages: number; + items: ScreenerItem[]; +} +``` + +### 4.3 Error Codes + +| HTTP | Code | Когда | +|---|---|---| +| 400 | `VALIDATION_ERROR` | type не указан, page < 1, priceMax < priceMin | +| 422 | `MOEX_UNAVAILABLE` | Доска MOEX не загрузилась (circuit breaker open) | + +--- + +## 5. Database Schema + +Изменений в схеме БД не требуется. Все данные получаются из MOEX ISS API и кешируются in-memory. + +--- + +## 6. Business Rules + +| Rule | Описание | +|---|---| +| BR-SC-001 | `type` обязателен — скринер работает или по акциям, или по облигациям | +| BR-SC-002 | `priceMin <= priceMax` если оба указаны | +| BR-SC-003 | `page >= 1`, `pageSize` от 1 до 100 | +| BR-SC-004 | `dividendYieldMin` применяется только для `type = 'share'` | +| BR-SC-005 | `yieldMin/yieldMax/durationMin/durationMax` применяются только для `type = 'bond'` | +| BR-SC-006 | Вся доска кешируется на 60 секунд (TTL = `marketDataTtl` через config) | +| BR-SC-007 | `sortBy` может быть любым полем `ScreenerItem` | +| BR-SC-008 | Неизвестные/неподдерживаемые параметры игнорируются (не ошибка) | +| BR-SC-009 | `listLevel` — список через запятую (1,2,3) — бумаги любого из указанных уровней | + +--- + +## 7. Events (Domain Events) + +Phase 1 не требует событий. Для будущих фаз: + +| Событие | Payload | Когда | Будущее использование | +|---|---|---|---| +| `ScreenerUsed` | `{ type, filterCount, resultCount }` | GET screener | Аналитика популярных фильтров | +| `ScreenerBoardRefreshed` | `{ type, count, cachedAt }` | Обновление кеша доски | Мониторинг | + +--- + +## 8. Risks and Edge Cases + +| Risk | Impact | Mitigation | +|---|---|---| +| **MOEX board endpoint медленный** | Время ожидания 2-5s при первом запросе | Кеш 60s; показать loading state; prefetch при наведении на скринер | +| **Большая доска (300+ акций)** | Размер ответа ~100KB | Ok для JSON. При необходимости — сжатие через accept-encoding | +| **Empty result** | Нет бумаг под фильтры | Показать «Ничего не найдено», предложить смягчить фильтры | +| **Все параметры пустые** | Возврат всей доски | Ok — пользователь видит весь рынок с сортировкой | +| **Dividend yield по всем акциям** | N запросов к MOEX | Сейчас НЕ включаем dividend yield в первый batch. Расчёт при отдельном запросе | +| **Некорректный sortBy** | Ошибка 400 | Валидация на backend: список разрешённых полей сортировки | +| **Одновременные запросы** | N запросов к MOEX | Rate limiter (p-queue, 10/с) + circuit breaker уже есть | + +--- + +## 9. Implementation Phases + +### Phase 1: Core Screener (Shares + Bonds) + +**Backend:** + +1. Создать `ScreenerService`: + - `screen(query: ScreenerQuery): Promise` + - Получение доски через `moexClient.getShareMarketDataBatch([])` или `getBondPositionDataBatch([])` + - Валидация фильтров + - Фильтрация массива + - Сортировка + - Пагинация + +2. Создать `ScreenerQueryDto` с валидацией (`@IsOptional`, `@IsNumber`, `@Min`, `@Max`, etc.) + +3. Добавить эндпоинт в `SecuritiesController`: + - `GET /securities/screener` → `ScreenerService.screen(query)` + +4. Зарегистрировать `ScreenerService` в `SecuritiesModule` + +5. Создать `ScreenerResponseDto` со Swagger-декораторами + +6. Написать тесты (см. Phase 4) + +**Frontend:** + +1. Создать `api/screener.ts`: + - `getScreenerResults(params) → ScreenerResult` + +2. Создать `hooks/useScreener.ts`: + - Читает URL search params как source of truth + - TanStack Query с key `['screener', params]` + - `staleTime: 60000` (1 min — соответствует cache TTL) + - Debounce на изменение фильтров (300ms) + +3. Создать компоненты: + - `FilterPanel.tsx` — обёртка, переключатель share/bond + - `FilterPanelShare.tsx` — фильтры для акций + - `FilterPanelBond.tsx` — фильтры для облигаций + - `ScreenerTable.tsx` — таблица с сортировкой по клику на header + - `ScreenerTableRow.tsx` — строка с ссылкой на страницу бумаги + +4. Создать `ScreenerPage.tsx`: + - `/screener` route + - Layout: filter panel (left) + results table (right) + - Loading / error / empty states + - Pagination controls + +5. Добавить ссылку «Скринер» в навигацию (Layout) + +### Phase 2: Dividend Yield (Shares) + +**Backend:** +- Для акций с `dividendYieldMin > 0`: + - После фильтрации — запросить дивиденды для отфильтрованных secid + - Рассчитать `lastYearDividends / currentPrice * 100` + - Отфильтровать по `dividendYieldMin` + (batch-запрос дивидендов по набору secid) + +**Frontend:** +- `FilterPanelShare`: добавить поле «Див.доходность от, %» +- `ScreenerTable`: добавить колонку «Див.дох.» + +### Phase 3: Saved Screener Templates + +- Сохранение набора фильтров как «шаблон» (в localStorage или на backend) +- Быстрый доступ к избранным скринерам +- Shared link (кодировать params в URL) + +--- + +## 10. OpenAPI Specification + +Дополнение к существующему `docs/openapi/openapi.yaml`: + +```yaml +paths: + /securities/screener: + get: + summary: Screen securities by filters + parameters: + - name: type + in: query + required: true + schema: + type: string + enum: [share, bond] + - name: priceMin + in: query + schema: { type: number } + - name: priceMax + in: query + schema: { type: number } + # ... остальные параметры + responses: + 200: + description: Screener results + content: + application/json: + schema: + $ref: '#/components/schemas/ScreenerResponse' + +components: + schemas: + ScreenerItem: + type: object + properties: + secid: { type: string } + shortName: { type: string } + price: { type: number, nullable: true } + changePercent: { type: number, nullable: true } + # ... остальные поля + ScreenerResult: + type: object + properties: + totalCount: { type: integer } + page: { type: integer } + pageSize: { type: integer } + totalPages: { type: integer } + items: + type: array + items: + $ref: '#/components/schemas/ScreenerItem' + ScreenerResponse: + type: object + properties: + data: + $ref: '#/components/schemas/ScreenerResult' + meta: + $ref: '#/components/schemas/ApiMeta' +``` + +--- + +## 11. ADR + +### ADR-014: Full Board Fetch vs Incremental + +**Context:** Скринер должен фильтровать по всем бумагам рынка. Как получать данные — инкрементально (каждый запрос — свой набор MOEX эндпоинтов) или одним batch-запросом всей доски? + +**Decision:** Один batch-запрос всей доски MOEX (`/engines/stock/markets/{shares|bonds}/securities.json`) с кешированием на 60s. + +**Rationale:** +- MOEX отдаёт всю доску одним запросом (300-500 shares, 100-200 bonds) +- Batch-методы уже реализованы в `MoexClientService` (используются для portfolio enrichment) +- Один запрос = 1 http call vs N http calls при инкрементальном подходе +- Кеш на 60s — разумный компромисс между свежестью и производительностью + +**Consequences:** +- При первом запросе после TTL — задержка 2-5s (ожидание MOEX) +- Нельзя фильтровать по данным, которых нет в board response (например, фундаментальные мультипликаторы) +- Dividend yield требует отдельного прохода (Phase 2) + +### ADR-015: URL Search Params as Source of Truth + +**Context:** Как хранить состояние фильтров на фронтенде — React state, URL params, или Redux/Zustand? + +**Decision:** URL search params (`/screener?type=share&priceMin=100`). + +**Rationale:** +- Shareable URL:用户可以 отправить ссылку с фильтрами +- Back/forward навигация работает нативно +- Нет лишних зависимостей (Redux и т.д.) +- TanStack Query key = URL params — автоматическая интеграция + +**Consequences:** +- URL может стать длинным (но это нормально для query params) +- Нужно синхронизировать URL ←→ FilterPanel (useSearchParams) +- Придётся обрабатывать частичную загрузку страницы с params + +### ADR-016: Sorting on Backend + +**Context:** Сортировать результаты на backend или frontend? + +**Decision:** На backend. + +**Rationale:** +- При пагинации сортировка должна быть на сервере (иначе данные первой страницы не соответствуют порядку) +- Единый источник истины +- Можно добавить сортировку по полям, которые не отображаются в таблице + +**Consequences:** +- Нужно передавать sortBy/sortOrder при каждом запросе +- Backend должен валидировать sortBy (только существующие поля) + +--- + +## 12. Self-Review Checklist + +- [x] Нет placeholder'ов (TBD, TODO) +- [x] Все разделы заполнены +- [x] PRD покрывает ключевые user stories для MVP +- [x] Domain model описывает все поля и их типы +- [x] API контракты полны (все query params, response shape, error codes) +- [x] Business rules однозначны +- [x] Скринер доступен без авторизации (как search) +- [x] Scope Phase 1 отделён от Phase 2/3 +- [x] ADR документируют ключевые архитектурные решения +- [x] Производительность учтена (batch fetch + cache 60s + пагинация) +- [x] Edge cases обработаны (empty result, ошибка MOEX, некорректные params)