feat: implement portfolio analytics, PnL calculation, and security screener
Some checks failed
CI / lint (pull_request) Failing after 1m48s
CI / test (pull_request) Successful in 1m47s
CI / build (pull_request) Successful in 1m53s
CI / lint (push) Failing after 1m59s
CI / test (push) Successful in 1m56s
CI / build (push) Successful in 1m50s

- Add buyPrice and buyDate to positions for PnL tracking
- Implement backend analytics service for real-time portfolio performance
- Add server-side security screener with filtering, sorting, and pagination
- Update frontend UI with analytics summaries and sortable screener table
- Optimize MOEX API calls with batch fetching and portfolio-specific caching
- Add unit tests for analytics and screener services
This commit is contained in:
Sergey Krylov 2026-06-14 15:59:25 +03:00
parent 1017fd4f08
commit 96f003852d
44 changed files with 6050 additions and 67 deletions

View File

@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "Position" ADD COLUMN "buyDate" DATETIME;
ALTER TABLE "Position" ADD COLUMN "buyPrice" REAL;

View File

@ -28,6 +28,8 @@ model Position {
secid String secid String
type String @default("share") type String @default("share")
quantity Int quantity Int
buyPrice Float?
buyDate DateTime?
notes String? notes String?
tags String? tags String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())

View File

@ -174,18 +174,24 @@ export class MoexClientService {
secids: string[], secids: string[],
boardId = 'TQBR', boardId = 'TQBR',
): Promise<MoexShareMarketData[]> { ): Promise<MoexShareMarketData[]> {
if (secids.length === 0) return []; const params: Record<string, string> = { boards: boardId };
if (secids.length > 0) {
params.securities = secids.join(',');
}
const data = await this.request<Record<string, unknown>>( const data = await this.request<Record<string, unknown>>(
`/engines/stock/markets/shares/securities`, `/engines/stock/markets/shares/securities`,
{ securities: secids.join(','), boards: boardId }, params,
); );
const securities = this.extractTable(data, 'securities'); const securities = this.extractTable(data, 'securities');
const marketdata = this.extractTable(data, 'marketdata'); const marketdata = this.extractTable(data, 'marketdata');
return secids.map((secid) => { const secidSet = secids.length > 0 ? new Set(secids) : null;
const sec = const filteredSecurities = secidSet
securities.find((r) => r.SECID === secid && r.BOARDID === boardId) || ? securities.filter((r) => secidSet.has(r.SECID as string))
securities.find((r) => r.SECID === secid); : securities;
return filteredSecurities.map((sec) => {
const secid = sec.SECID as string;
const mkt = const mkt =
marketdata.find((r) => r.SECID === secid && r.BOARDID === boardId) || marketdata.find((r) => r.SECID === secid && r.BOARDID === boardId) ||
marketdata.find((r) => r.SECID === secid); marketdata.find((r) => r.SECID === secid);
@ -219,29 +225,32 @@ export class MoexClientService {
secids: string[], secids: string[],
boardId = 'TQCB', boardId = 'TQCB',
): Promise<MoexBondPositionData[]> { ): Promise<MoexBondPositionData[]> {
if (secids.length === 0) return []; const params: Record<string, string> = { boards: boardId };
if (secids.length > 0) {
params.securities = secids.join(',');
}
const data = await this.request<Record<string, unknown>>( const data = await this.request<Record<string, unknown>>(
`/engines/stock/markets/bonds/securities`, `/engines/stock/markets/bonds/securities`,
{ securities: secids.join(','), boards: boardId }, params,
); );
const securities = this.extractTable(data, 'securities'); const securities = this.extractTable(data, 'securities');
const marketdata = this.extractTable(data, 'marketdata'); const marketdata = this.extractTable(data, 'marketdata');
return secids.map((secid) => { const secidSet = secids.length > 0 ? new Set(secids) : null;
const bond = const filteredSecurities = secidSet
securities.find( ? securities.filter((r) => secidSet.has(r.SECID as string))
(r) => r.SECID === secid && r.BOARDID === boardId && r.PREVWAPRICE != null, : securities;
) ||
securities.find((r) => r.SECID === secid && r.PREVWAPRICE != null) || return filteredSecurities.map((bond) => {
securities.find((r) => r.SECID === secid); const secid = bond.SECID as string;
const mkt = const mkt =
marketdata.find((r) => r.SECID === secid && r.BOARDID === boardId && r.LAST != null) || 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); marketdata.find((r) => r.SECID === secid);
return { return {
secid, secid,
boardid: boardId, boardid: (bond.BOARDID as string) || boardId,
shortName: (bond?.SHORTNAME as string) || '', shortName: (bond?.SHORTNAME as string) || '',
price: mkt?.LAST != null ? parseFloat(mkt.LAST as string) : null, price: mkt?.LAST != null ? parseFloat(mkt.LAST as string) : null,
yieldToMaturity: mkt?.YIELD != null ? parseFloat(mkt.YIELD as string) : null, yieldToMaturity: mkt?.YIELD != null ? parseFloat(mkt.YIELD as string) : null,

View File

@ -2,6 +2,7 @@ import {
IsString, IsString,
IsOptional, IsOptional,
IsInt, IsInt,
IsNumber,
Min, Min,
IsArray, IsArray,
IsIn, IsIn,
@ -33,6 +34,17 @@ export class AddPositionDto {
@Min(0) @Min(0)
quantity!: number; quantity!: number;
@ApiPropertyOptional({ example: 250.5 })
@IsNumber()
@Min(0)
@IsOptional()
buyPrice?: number;
@ApiPropertyOptional({ example: '2026-06-01' })
@IsString()
@IsOptional()
buyDate?: string;
@ApiPropertyOptional({ example: 'Покупка на дип' }) @ApiPropertyOptional({ example: 'Покупка на дип' })
@IsString() @IsString()
@IsOptional() @IsOptional()

View File

@ -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;
}

View File

@ -1,15 +1,25 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { PortfolioSummaryDto } from './analytics-response.dto';
class PositionWithPriceDto { class PositionWithPriceDto {
@ApiProperty() id!: number; @ApiProperty() id!: number;
@ApiProperty({ example: 'SBER' }) secid!: string; @ApiProperty({ example: 'SBER' }) secid!: string;
@ApiPropertyOptional() shortName!: string | null;
@ApiProperty({ example: 'share', enum: ['share', 'bond'] }) type!: string; @ApiProperty({ example: 'share', enum: ['share', 'bond'] }) type!: string;
@ApiProperty({ example: 10 }) quantity!: number; @ApiProperty({ example: 10 }) quantity!: number;
@ApiPropertyOptional() buyPrice!: number | null;
@ApiPropertyOptional() buyDate!: string | null;
@ApiPropertyOptional() notes!: string | null; @ApiPropertyOptional() notes!: string | null;
@ApiPropertyOptional() tags!: string[] | null; @ApiPropertyOptional() tags!: string[] | null;
@ApiPropertyOptional() currentPrice!: number | null; @ApiPropertyOptional() currentPrice!: number | null;
@ApiPropertyOptional() totalCost!: number | null;
@ApiPropertyOptional() currentValue!: number | null; @ApiPropertyOptional() currentValue!: number | null;
@ApiProperty() weightPercent!: number; @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() change!: number | null;
@ApiPropertyOptional() changePercent!: number | null; @ApiPropertyOptional() changePercent!: number | null;
@ApiPropertyOptional() yieldToMaturity!: number | null; @ApiPropertyOptional() yieldToMaturity!: number | null;
@ -40,4 +50,7 @@ export class PortfolioDetailResponseDto extends PortfolioResponseDto {
positions!: PositionWithPriceDto[]; positions!: PositionWithPriceDto[];
@ApiProperty() totalValue!: number; @ApiProperty() totalValue!: number;
@ApiProperty({ type: PortfolioSummaryDto })
analytics!: PortfolioSummaryDto;
} }

View File

@ -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'; import { ApiPropertyOptional } from '@nestjs/swagger';
const TAGS = [ const TAGS = [
@ -19,6 +28,17 @@ export class UpdatePositionDto {
@IsOptional() @IsOptional()
quantity?: number; quantity?: number;
@ApiPropertyOptional({ example: 260.0 })
@IsNumber()
@Min(0)
@IsOptional()
buyPrice?: number;
@ApiPropertyOptional({ example: '2026-06-15' })
@IsString()
@IsOptional()
buyDate?: string;
@ApiPropertyOptional({ example: 'Докупка' }) @ApiPropertyOptional({ example: 'Докупка' })
@IsString() @IsString()
@IsOptional() @IsOptional()

View File

@ -1,6 +1,7 @@
import { Controller, Get, Post, Patch, Delete, Body, Param, ParseIntPipe } from '@nestjs/common'; import { Controller, Get, Post, Patch, Delete, Body, Param, ParseIntPipe } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiOkResponse } from '@nestjs/swagger'; import { ApiTags, ApiOperation, ApiBearerAuth, ApiOkResponse } from '@nestjs/swagger';
import { PortfolioListResponseDto } from './dto/portfolio-list-response.dto'; import { PortfolioListResponseDto } from './dto/portfolio-list-response.dto';
import { PortfolioDetailResponseDto } from './dto/portfolio-response.dto';
import { PortfolioService } from './portfolio.service'; import { PortfolioService } from './portfolio.service';
import { CreatePortfolioDto } from './dto/create-portfolio.dto'; import { CreatePortfolioDto } from './dto/create-portfolio.dto';
import { UpdatePortfolioDto } from './dto/update-portfolio.dto'; import { UpdatePortfolioDto } from './dto/update-portfolio.dto';
@ -31,6 +32,7 @@ export class PortfolioController {
@Get(':id') @Get(':id')
@ApiOperation({ summary: 'Get portfolio details with positions and prices' }) @ApiOperation({ summary: 'Get portfolio details with positions and prices' })
@ApiOkResponse({ type: PortfolioDetailResponseDto })
async findOne(@CurrentUser() user: { sub: number }, @Param('id', ParseIntPipe) id: number) { async findOne(@CurrentUser() user: { sub: number }, @Param('id', ParseIntPipe) id: number) {
const portfolio = await this.portfolioService.findOne(user.sub, id); const portfolio = await this.portfolioService.findOne(user.sub, id);
return { data: portfolio, meta: { cachedAt: null, fromCache: false } }; return { data: portfolio, meta: { cachedAt: null, fromCache: false } };
@ -77,6 +79,13 @@ export class PortfolioController {
return { data: position, meta: { cachedAt: null, fromCache: false } }; 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') @Delete(':id/positions/:positionId')
@ApiOperation({ summary: 'Remove position from portfolio' }) @ApiOperation({ summary: 'Remove position from portfolio' })
async removePosition( async removePosition(

View File

@ -31,6 +31,8 @@ describe('PortfolioService', () => {
secid: 'SBER', secid: 'SBER',
type: 'share', type: 'share',
quantity: 10, quantity: 10,
buyPrice: null,
buyDate: null,
notes: null, notes: null,
tags: null, tags: null,
createdAt: new Date('2026-01-01'), createdAt: new Date('2026-01-01'),
@ -54,6 +56,7 @@ describe('PortfolioService', () => {
delete: vi.fn(), delete: vi.fn(),
}, },
position: { position: {
findMany: vi.fn(),
findUnique: vi.fn(), findUnique: vi.fn(),
create: vi.fn(), create: vi.fn(),
update: 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({ const sharePosition = mockPosition({
id: 1, id: 1,
secid: 'SBER', secid: 'SBER',
type: 'share', type: 'share',
quantity: 10, quantity: 10,
buyPrice: 230,
buyDate: new Date('2026-03-01'),
}); });
const bondPosition = mockPosition({ const bondPosition = mockPosition({
id: 2, id: 2,
@ -124,6 +129,8 @@ describe('PortfolioService', () => {
secid: 'SU26238RMFS5', secid: 'SU26238RMFS5',
type: 'bond', type: 'bond',
quantity: 5, quantity: 5,
buyPrice: 950,
buyDate: new Date('2026-03-01'),
}); });
vi.mocked(prisma.portfolio.findMany).mockResolvedValue([ vi.mocked(prisma.portfolio.findMany).mockResolvedValue([
@ -159,8 +166,16 @@ describe('PortfolioService', () => {
expect(result[0].positionCount).toBe(2); expect(result[0].positionCount).toBe(2);
expect(result[0].shareCount).toBe(1); expect(result[0].shareCount).toBe(1);
expect(result[0].bondCount).toBe(1); expect(result[0].bondCount).toBe(1);
// SBER: 250 * 10 = 2500, OFZ: (98.5 / 100) * 1000 * 5 = 4925 // SBER: 250 * 10 = 2500, OFZ: (98.5 / 100) * 1000 * 5 = 4925
expect(result[0].totalValue).toBe(7425); 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 () => { 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); vi.mocked(prisma.portfolio.findUnique).mockResolvedValue(mockPortfolio({ userId: 2 }) as any);
await expect(service.findOne(1, 1)).rejects.toThrow(ForbiddenException); 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<typeof vi.fn> };
cacheMock.getOrFetch.mockImplementation(
async (_prefix: string, _key: string[], fetchFn: () => Promise<any>) => ({
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<typeof vi.fn> };
cacheMock.getOrFetch.mockImplementation(
async (_prefix: string, _key: string[], fetchFn: () => Promise<any>) => ({
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<typeof vi.fn> };
cacheMock.getOrFetch.mockImplementation(
async (_prefix: string, _key: string[], fetchFn: () => Promise<any>) => ({
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<typeof vi.fn> };
cacheMock.getOrFetch.mockImplementation(
async (_prefix: string, _key: string[], fetchFn: () => Promise<any>) => ({
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<typeof vi.fn> };
cacheMock.getOrFetch.mockImplementation(
async (_prefix: string, _key: string[], fetchFn: () => Promise<any>) => ({
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<typeof vi.fn> };
cacheMock.getOrFetch.mockImplementation(
async (_prefix: string, _key: string[], fetchFn: () => Promise<any>) => ({
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<typeof vi.fn> };
cacheMock.getOrFetch.mockImplementation(
async (_prefix: string, _key: string[], fetchFn: () => Promise<any>) => ({
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);
});
}); });
}); });

View File

@ -12,18 +12,28 @@ import { CreatePortfolioDto } from './dto/create-portfolio.dto';
import { UpdatePortfolioDto } from './dto/update-portfolio.dto'; import { UpdatePortfolioDto } from './dto/update-portfolio.dto';
import { AddPositionDto } from './dto/add-position.dto'; import { AddPositionDto } from './dto/add-position.dto';
import { UpdatePositionDto } from './dto/update-position.dto'; import { UpdatePositionDto } from './dto/update-position.dto';
import { AnalyticsResponseDto } from './dto/analytics-response.dto';
export interface EnrichedPosition { export interface EnrichedPosition {
id: number; id: number;
portfolioId: number;
secid: string; secid: string;
shortName: string | null; shortName: string | null;
type: string; type: string;
quantity: number; quantity: number;
buyPrice: number | null;
buyDate: string | null;
notes: string | null; notes: string | null;
tags: string[] | null; tags: string[] | null;
currentPrice: number | null; currentPrice: number | null;
totalCost: number | null;
currentValue: number | null; currentValue: number | null;
weightPercent: number; weightPercent: number;
pnl: number | null;
pnlPercent: number | null;
dividendIncome: number | null;
totalReturn: number | null;
totalReturnPercent: number | null;
change?: number | null; change?: number | null;
changePercent?: number | null; changePercent?: number | null;
yieldToMaturity?: number | null; yieldToMaturity?: number | null;
@ -120,7 +130,7 @@ export class PortfolioService {
if (!portfolio) throw new NotFoundException(`Portfolio ${id} not found`); if (!portfolio) throw new NotFoundException(`Portfolio ${id} not found`);
if (portfolio.userId !== userId) throw new ForbiddenException('Access denied'); 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); 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 { return {
id: portfolio.id, id: portfolio.id,
name: portfolio.name, name: portfolio.name,
@ -141,6 +153,7 @@ export class PortfolioService {
updatedAt: portfolio.updatedAt.toISOString(), updatedAt: portfolio.updatedAt.toISOString(),
positions: positionsWithWeights, positions: positionsWithWeights,
totalValue: Math.round(totalValue * 100) / 100, totalValue: Math.round(totalValue * 100) / 100,
analytics: analytics.summary,
}; };
} }
@ -192,6 +205,8 @@ export class PortfolioService {
secid: dto.secid, secid: dto.secid,
type, type,
quantity: dto.quantity, quantity: dto.quantity,
buyPrice: dto.buyPrice ?? null,
buyDate: dto.buyDate ? new Date(dto.buyDate) : null,
notes: dto.notes ?? null, notes: dto.notes ?? null,
tags: dto.tags ? JSON.stringify(dto.tags) : null, tags: dto.tags ? JSON.stringify(dto.tags) : null,
}, },
@ -217,6 +232,8 @@ export class PortfolioService {
where: { id: positionId }, where: { id: positionId },
data: { data: {
...(dto.quantity !== undefined && { quantity: dto.quantity }), ...(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.notes !== undefined && { notes: dto.notes }),
...(dto.tags !== undefined && { tags: dto.tags ? JSON.stringify(dto.tags) : null }), ...(dto.tags !== undefined && { tags: dto.tags ? JSON.stringify(dto.tags) : null }),
}, },
@ -243,9 +260,12 @@ export class PortfolioService {
secid: string; secid: string;
type: string; type: string;
quantity: number; quantity: number;
buyPrice: number | null;
buyDate: Date | null;
notes: string | null; notes: string | null;
tags: string | null; tags: string | null;
}[], }[],
portfolioId?: number,
): Promise<EnrichedPosition[]> { ): Promise<EnrichedPosition[]> {
const sharePositions = positions.filter((p) => p.type === 'share'); const sharePositions = positions.filter((p) => p.type === 'share');
const bondPositions = positions.filter((p) => p.type === 'bond'); 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 bondSecids = [...new Set(bondPositions.map((p) => p.secid))].sort();
const [shareDataBySecid, bondDataBySecid] = await Promise.all([ const [shareDataBySecid, bondDataBySecid] = await Promise.all([
this.fetchShareBatch(shareSecids), this.fetchShareBatch(shareSecids, portfolioId),
this.fetchBondBatch(bondSecids), this.fetchBondBatch(bondSecids, portfolioId),
]); ]);
const enriched: EnrichedPosition[] = []; const enriched: EnrichedPosition[] = [];
@ -262,15 +282,24 @@ export class PortfolioService {
for (const pos of positions) { for (const pos of positions) {
const base = { const base = {
id: pos.id, id: pos.id,
portfolioId: pos.portfolioId,
secid: pos.secid, secid: pos.secid,
shortName: null as string | null, shortName: null as string | null,
type: pos.type, type: pos.type,
quantity: pos.quantity, quantity: pos.quantity,
buyPrice: pos.buyPrice,
buyDate: pos.buyDate ? pos.buyDate.toISOString() : null,
notes: pos.notes, notes: pos.notes,
tags: pos.tags ? JSON.parse(pos.tags) : null, tags: pos.tags ? JSON.parse(pos.tags) : null,
totalCost: null as number | null,
weightPercent: 0, weightPercent: 0,
currentPrice: null as number | null, currentPrice: null as number | null,
currentValue: 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') { if (pos.type === 'bond') {
@ -283,9 +312,12 @@ export class PortfolioService {
return enriched; return enriched;
} }
private async fetchShareBatch(secids: string[]): Promise<Map<string, MoexShareMarketData>> { private async fetchShareBatch(
secids: string[],
portfolioId?: number,
): Promise<Map<string, MoexShareMarketData>> {
if (secids.length === 0) return new Map(); 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( const { data } = await this.cache.getOrFetch(
'batchdata', 'batchdata',
['shares', cacheKey], ['shares', cacheKey],
@ -295,9 +327,12 @@ export class PortfolioService {
return new Map(data.map((d) => [d.secid, d])); return new Map(data.map((d) => [d.secid, d]));
} }
private async fetchBondBatch(secids: string[]): Promise<Map<string, MoexBondPositionData>> { private async fetchBondBatch(
secids: string[],
portfolioId?: number,
): Promise<Map<string, MoexBondPositionData>> {
if (secids.length === 0) return new Map(); 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( const { data } = await this.cache.getOrFetch(
'batchdata', 'batchdata',
['bonds', cacheKey], ['bonds', cacheKey],
@ -308,33 +343,105 @@ export class PortfolioService {
} }
private buildSharePosition( private buildSharePosition(
pos: { id: number; secid: string; quantity: number }, pos: {
id: number;
secid: string;
quantity: number;
buyPrice: number | null;
buyDate: Date | null;
},
base: EnrichedPosition, base: EnrichedPosition,
data: MoexShareMarketData | undefined, data: MoexShareMarketData | undefined,
): EnrichedPosition { ): 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 { return {
...base, ...base,
shortName: data.shortName, shortName: data.shortName,
currentPrice: data.last, currentPrice,
change: data.lastChange, change: data.lastChange,
changePercent: data.lastChangePrcnt, changePercent: data.lastChangePrcnt,
currentValue: data.last !== null ? data.last * pos.quantity : null, totalCost,
currentValue,
pnl,
pnlPercent,
dividendIncome,
totalReturn,
totalReturnPercent,
}; };
} }
private buildBondPosition( private buildBondPosition(
pos: { id: number; secid: string; quantity: number }, pos: {
id: number;
secid: string;
quantity: number;
buyPrice: number | null;
buyDate: Date | null;
},
base: EnrichedPosition, base: EnrichedPosition,
data: MoexBondPositionData | undefined, data: MoexBondPositionData | undefined,
): EnrichedPosition { ): 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 = const currentValue =
data.price !== null ? (data.price / 100) * data.faceValue * pos.quantity : null; 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 { return {
...base, ...base,
shortName: data.shortName, shortName: data.shortName,
currentPrice: data.price, currentPrice,
yieldToMaturity: data.yieldToMaturity, yieldToMaturity: data.yieldToMaturity,
duration: data.duration, duration: data.duration,
couponValue: data.couponValue, couponValue: data.couponValue,
@ -347,7 +454,58 @@ export class PortfolioService {
couponPeriod: data.couponPeriod, couponPeriod: data.couponPeriod,
bondType: data.bondType, bondType: data.bondType,
offerDate: data.offerDate, offerDate: data.offerDate,
totalCost,
currentValue, currentValue,
pnl,
pnlPercent,
dividendIncome,
totalReturn,
totalReturnPercent,
}; };
} }
async getPositionsWithPrices(portfolioId: number): Promise<EnrichedPosition[]> {
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<AnalyticsResponseDto> {
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 };
}
} }

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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>(ScreenerService);
moexClient = module.get<MoexClientService>(MoexClientService);
cache = module.get<CacheService>(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);
});
});
});

View File

@ -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<ScreenerResultDto> {
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<ScreenerItemDto[]> {
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;
});
}
}

View File

@ -1,6 +1,7 @@
import { Test, TestingModule } from '@nestjs/testing'; import { Test, TestingModule } from '@nestjs/testing';
import { SecuritiesController } from './securities.controller'; import { SecuritiesController } from './securities.controller';
import { SecuritiesService } from './securities.service'; import { SecuritiesService } from './securities.service';
import { ScreenerService } from './screener.service';
import { SecurityType } from './dto/search-query.dto'; import { SecurityType } from './dto/search-query.dto';
describe('SecuritiesController', () => { describe('SecuritiesController', () => {
@ -23,10 +24,17 @@ describe('SecuritiesController', () => {
search: vi.fn().mockResolvedValue(mockResults), search: vi.fn().mockResolvedValue(mockResults),
}; };
const mockScreenerService = {
screen: vi.fn(),
};
beforeEach(async () => { beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({ const module: TestingModule = await Test.createTestingModule({
controllers: [SecuritiesController], controllers: [SecuritiesController],
providers: [{ provide: SecuritiesService, useValue: mockService }], providers: [
{ provide: SecuritiesService, useValue: mockService },
{ provide: ScreenerService, useValue: mockScreenerService },
],
}).compile(); }).compile();
controller = module.get<SecuritiesController>(SecuritiesController); controller = module.get<SecuritiesController>(SecuritiesController);

View File

@ -1,12 +1,18 @@
import { Controller, Get, Query, ValidationPipe } from '@nestjs/common'; 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 { SecuritiesService } from './securities.service';
import { ScreenerService } from './screener.service';
import { SearchQueryDto, SecurityType } from './dto/search-query.dto'; import { SearchQueryDto, SecurityType } from './dto/search-query.dto';
import { ScreenerQueryDto } from './dto/screener-query.dto';
import { ScreenerResultDto } from './dto/screener-response.dto';
@ApiTags('Securities') @ApiTags('Securities')
@Controller('securities') @Controller('securities')
export class SecuritiesController { export class SecuritiesController {
constructor(private readonly securitiesService: SecuritiesService) {} constructor(
private readonly securitiesService: SecuritiesService,
private readonly screenerService: ScreenerService,
) {}
@Get('search') @Get('search')
@ApiOperation({ summary: 'Поиск по инструментам' }) @ApiOperation({ summary: 'Поиск по инструментам' })
@ -18,4 +24,12 @@ export class SecuritiesController {
); );
return { data: results, meta: { cachedAt: null, fromCache: false } }; 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 } };
}
} }

View File

@ -2,11 +2,12 @@ import { Module } from '@nestjs/common';
import { CacheModule } from '../cache/cache.module'; import { CacheModule } from '../cache/cache.module';
import { SecuritiesController } from './securities.controller'; import { SecuritiesController } from './securities.controller';
import { SecuritiesService } from './securities.service'; import { SecuritiesService } from './securities.service';
import { ScreenerService } from './screener.service';
@Module({ @Module({
imports: [CacheModule], imports: [CacheModule],
controllers: [SecuritiesController], controllers: [SecuritiesController],
providers: [SecuritiesService], providers: [SecuritiesService, ScreenerService],
exports: [SecuritiesService], exports: [SecuritiesService],
}) })
export class SecuritiesModule {} export class SecuritiesModule {}

View File

@ -16,11 +16,12 @@ All endpoints require JWT authentication (`JwtAuthGuard`).
|---|---|---| |---|---|---|
| `/api/v1/portfolios` | GET | List user's portfolios | | `/api/v1/portfolios` | GET | List user's portfolios |
| `/api/v1/portfolios` | POST | Create portfolio | | `/api/v1/portfolios` | POST | Create portfolio |
| `/api/v1/portfolios/:id` | GET | Portfolio detail with enriched positions | | `/api/v1/portfolios/:id` | GET | Portfolio detail with enriched positions and analytics summary |
| `/api/v1/portfolios/:id` | PATCH | Update portfolio (name, description, currency) | | `/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` | DELETE | Delete portfolio (cascade deletes positions) |
| `/api/v1/portfolios/:id/positions` | POST | Add position (auto-detects share/bond type) | | `/api/v1/portfolios/:id/positions` | POST | Add position (accepts buyPrice, buyDate) |
| `/api/v1/portfolios/:id/positions/:posId` | PATCH | Update position (quantity, notes) | | `/api/v1/portfolios/:id/positions/:posId` | PATCH | Update position (quantity, buyPrice, buyDate, notes) |
| `/api/v1/portfolios/:id/positions/:posId` | DELETE | Remove position | | `/api/v1/portfolios/:id/positions/:posId` | DELETE | Remove position |
## Domain Model ## Domain Model
@ -36,15 +37,29 @@ Position
├── secid (MOEX security ID) ├── secid (MOEX security ID)
├── type ("share" | "bond") — auto-detected from MOEX ├── type ("share" | "bond") — auto-detected from MOEX
├── quantity (integer, >= 0) ├── quantity (integer, >= 0)
├── buyPrice (optional, % for bonds, RUB for shares)
├── buyDate (optional)
├── notes (free text) ├── notes (free text)
├── tags (JSON, stored but not displayed in Phase 1) ├── tags (JSON)
└── enriched: currentPrice, currentValue, weightPercent └── enriched: currentPrice, currentValue, weightPercent
+ analytics: totalCost, pnl, pnlPercent, totalReturn, totalReturnPercent
+ share: change, changePercent, shortName + share: change, changePercent, shortName
+ bond: yieldToMaturity, duration, couponValue, couponPercent, + bond: yieldToMaturity, duration, couponValue, couponPercent,
nextCouponDate, matDate, accruedInt, bid, offer, nextCouponDate, matDate, accruedInt, bid, offer,
couponPeriod, bondType, offerDate 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 ## 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. 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.

View File

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

View File

@ -17,6 +17,7 @@ const sidebars: SidebarsConfig = {
'backend/configuration', 'backend/configuration',
'backend/caching', 'backend/caching',
'backend/moex-client', 'backend/moex-client',
'backend/securities',
'backend/portfolio', 'backend/portfolio',
], ],
}, },

View File

@ -1,5 +1,5 @@
import { request } from './client'; import { request } from './client';
import type { Portfolio, PortfolioDetail, Position } from './responses'; import type { AnalyticsResponse, Portfolio, PortfolioDetail, Position } from './responses';
export function getPortfolios(): Promise<{ export function getPortfolios(): Promise<{
data: Portfolio[]; data: Portfolio[];
@ -45,7 +45,14 @@ export function deletePortfolio(
export function addPosition( export function addPosition(
portfolioId: number, 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 } }> { ): Promise<{ data: Position; meta: { cachedAt: string | null; fromCache: boolean } }> {
return request<Position>(`/api/v1/portfolios/${portfolioId}/positions`, undefined, { return request<Position>(`/api/v1/portfolios/${portfolioId}/positions`, undefined, {
method: 'POST', method: 'POST',
@ -56,7 +63,13 @@ export function addPosition(
export function updatePosition( export function updatePosition(
portfolioId: number, portfolioId: number,
positionId: 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 } }> { ): Promise<{ data: Position; meta: { cachedAt: string | null; fromCache: boolean } }> {
return request<Position>(`/api/v1/portfolios/${portfolioId}/positions/${positionId}`, undefined, { return request<Position>(`/api/v1/portfolios/${portfolioId}/positions/${positionId}`, undefined, {
method: 'PATCH', method: 'PATCH',
@ -72,3 +85,9 @@ export function removePosition(
method: 'DELETE', method: 'DELETE',
}); });
} }
export function getPortfolioAnalytics(
portfolioId: number,
): Promise<{ data: AnalyticsResponse; meta: { cachedAt: string | null; fromCache: boolean } }> {
return request<AnalyticsResponse>(`/api/v1/portfolios/${portfolioId}/analytics`);
}

View File

@ -150,6 +150,7 @@ export interface Portfolio {
export interface PositionWithPrice { export interface PositionWithPrice {
id: number; id: number;
portfolioId: number;
secid: string; secid: string;
shortName: string | null; shortName: string | null;
type: 'share' | 'bond'; type: 'share' | 'bond';
@ -157,7 +158,15 @@ export interface PositionWithPrice {
notes: string | null; notes: string | null;
tags: string[] | null; tags: string[] | null;
currentPrice: number | null; currentPrice: number | null;
buyPrice: number | null;
buyDate: string | null;
totalCost: number | null;
currentValue: number | null; currentValue: number | null;
pnl: number | null;
pnlPercent: number | null;
dividendIncome: number | null;
totalReturn: number | null;
totalReturnPercent: number | null;
weightPercent: number; weightPercent: number;
change?: number | null; change?: number | null;
changePercent?: number | null; changePercent?: number | null;
@ -178,6 +187,7 @@ export interface PositionWithPrice {
export interface PortfolioDetail extends Portfolio { export interface PortfolioDetail extends Portfolio {
positions: PositionWithPrice[]; positions: PositionWithPrice[];
totalValue: number; totalValue: number;
analytics: PortfolioSummary;
} }
export interface Position { export interface Position {
@ -190,3 +200,48 @@ export interface Position {
createdAt: string; createdAt: string;
updatedAt: 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;
}

View File

@ -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<string, string> = {};
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== null) {
query[key] = String(value);
}
});
return request<ScreenerResult>('/api/v1/securities/screener', query);
}

View File

@ -46,6 +46,18 @@ export function Layout() {
> >
Портфели Портфели
</Link> </Link>
<Link
to="/screener"
style={{
fontSize: 14,
color: 'var(--color-text)',
textDecoration: 'none',
fontWeight: 500,
}}
>
Скринер
</Link>
<div style={{ marginLeft: 'auto', display: 'flex', alignItems: 'center', gap: 12 }}> <div style={{ marginLeft: 'auto', display: 'flex', alignItems: 'center', gap: 12 }}>
{isAuthenticated ? ( {isAuthenticated ? (
<> <>

View File

@ -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 (
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(140px, 1fr))',
gap: 24,
padding: 20,
background: 'var(--color-surface)',
border: '1px solid #e0e0e0',
borderRadius: 'var(--border-radius)',
marginTop: 16,
}}
>
<div>
<div style={{ fontSize: 12, color: 'var(--color-text-secondary)', marginBottom: 4 }}>
Инвестировано
</div>
<div style={{ fontSize: 18, fontWeight: 700 }}>{formatRub(summary.totalInvested)}</div>
</div>
<div>
<div style={{ fontSize: 12, color: 'var(--color-text-secondary)', marginBottom: 4 }}>
Текущая стоимость
</div>
<div style={{ fontSize: 18, fontWeight: 700 }}>{formatRub(summary.totalValue)}</div>
</div>
<div>
<div style={{ fontSize: 12, color: 'var(--color-text-secondary)', marginBottom: 4 }}>
Прибыль/Убыток
</div>
<div style={{ fontSize: 18, fontWeight: 700, color: pnlColor }}>
{summary.totalPnl > 0 ? '+' : ''}
{formatRub(summary.totalPnl)}
<span style={{ fontSize: 13, fontWeight: 500, marginLeft: 6 }}>
({formatPct(summary.totalPnlPercent)})
</span>
</div>
</div>
<div>
<div style={{ fontSize: 12, color: 'var(--color-text-secondary)', marginBottom: 4 }}>
Доходность (weighted)
</div>
<div style={{ fontSize: 18, fontWeight: 700, color: pnlColor }}>
{formatPct(summary.weightedYield)}
</div>
</div>
</div>
);
}

View File

@ -4,20 +4,32 @@ import type { PositionWithPrice } from '../../api/responses';
interface Props { interface Props {
position: PositionWithPrice; position: PositionWithPrice;
onUpdate: (data: { quantity?: number }) => void; onUpdate: (data: { quantity?: number; buyPrice?: number }) => void;
onDelete: () => void; onDelete: () => void;
} }
export function BondPositionRow({ position, onUpdate, onDelete }: Props) { 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 [qty, setQty] = useState(String(position.quantity));
const [price, setPrice] = useState(String(position.buyPrice ?? ''));
function handleSave() { function handleSaveQty() {
const num = parseInt(qty, 10); const num = parseInt(qty, 10);
if (!isNaN(num) && num >= 0 && num !== position.quantity) { if (!isNaN(num) && num >= 0 && num !== position.quantity) {
onUpdate({ quantity: num }); 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 { function formatDate(dateStr: string | null | undefined): string {
@ -51,14 +63,14 @@ export function BondPositionRow({ position, onUpdate, onDelete }: Props) {
{position.bondType ? <span>{position.bondType}</span> : '—'} {position.bondType ? <span>{position.bondType}</span> : '—'}
</td> </td>
<td style={{ padding: '8px 12px' }}> <td style={{ padding: '8px 12px' }}>
{editing ? ( {editingQty ? (
<input <input
type="number" type="number"
min={0} min={0}
value={qty} value={qty}
onChange={(e) => setQty(e.target.value)} onChange={(e) => setQty(e.target.value)}
onBlur={handleSave} onBlur={handleSaveQty}
onKeyDown={(e) => e.key === 'Enter' && handleSave()} onKeyDown={(e) => e.key === 'Enter' && handleSaveQty()}
autoFocus autoFocus
style={{ style={{
width: 80, width: 80,
@ -72,7 +84,7 @@ export function BondPositionRow({ position, onUpdate, onDelete }: Props) {
<span <span
onClick={() => { onClick={() => {
setQty(String(position.quantity)); setQty(String(position.quantity));
setEditing(true); setEditingQty(true);
}} }}
style={{ cursor: 'pointer', padding: '4px 0', display: 'inline-block' }} style={{ cursor: 'pointer', padding: '4px 0', display: 'inline-block' }}
> >
@ -80,6 +92,42 @@ export function BondPositionRow({ position, onUpdate, onDelete }: Props) {
</span> </span>
)} )}
</td> </td>
<td style={{ padding: '8px 12px', textAlign: 'right' }}>
{editingPrice ? (
<input
type="number"
step="0.01"
value={price}
onChange={(e) => 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',
}}
/>
) : (
<span
onClick={() => {
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)}
</span>
)}
</td>
<td style={{ padding: '8px 12px', textAlign: 'right' }}> <td style={{ padding: '8px 12px', textAlign: 'right' }}>
{formatPct(position.currentPrice)} {formatPct(position.currentPrice)}
</td> </td>
@ -108,6 +156,43 @@ export function BondPositionRow({ position, onUpdate, onDelete }: Props) {
}) })
: '—'} : '—'}
</td> </td>
<td style={{ padding: '8px 12px', textAlign: 'right' }}>
{position.totalCost !== null
? position.totalCost.toLocaleString('ru-RU', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})
: '—'}
</td>
<td style={{ padding: '8px 12px', textAlign: 'right' }}>
{position.pnl !== null ? (
<span
style={{ color: position.pnl >= 0 ? 'var(--color-positive)' : 'var(--color-negative)' }}
>
{position.pnl >= 0 ? '+' : ''}
{position.pnl.toLocaleString('ru-RU', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}
</span>
) : (
'—'
)}
</td>
<td style={{ padding: '8px 12px', textAlign: 'right' }}>
{position.pnlPercent !== null ? (
<span
style={{
color: position.pnlPercent >= 0 ? 'var(--color-positive)' : 'var(--color-negative)',
}}
>
{position.pnlPercent >= 0 ? '+' : ''}
{position.pnlPercent.toFixed(2)}%
</span>
) : (
'—'
)}
</td>
<td style={{ padding: '8px 12px', textAlign: 'right' }}> <td style={{ padding: '8px 12px', textAlign: 'right' }}>
{formatDate(position.nextCouponDate)} {formatDate(position.nextCouponDate)}
</td> </td>

View File

@ -3,7 +3,10 @@ import type { PositionWithPrice } from '../../api/responses';
interface Props { interface Props {
positions: PositionWithPrice[]; positions: PositionWithPrice[];
onUpdatePosition: (positionId: number, data: { quantity?: number }) => void; onUpdatePosition: (
positionId: number,
data: { quantity?: number; buyPrice?: number; buyDate?: string },
) => void;
onDeletePosition: (positionId: number) => void; onDeletePosition: (positionId: number) => void;
} }
@ -63,6 +66,17 @@ export function BondPositionTable({ positions, onUpdatePosition, onDeletePositio
> >
Количество Количество
</th> </th>
<th
style={{
textAlign: 'right',
padding: '8px 12px',
fontWeight: 600,
fontSize: 12,
color: 'var(--color-text-secondary)',
}}
>
Цена пок.
</th>
<th <th
style={{ style={{
textAlign: 'right', textAlign: 'right',
@ -162,6 +176,39 @@ export function BondPositionTable({ positions, onUpdatePosition, onDeletePositio
> >
НКД НКД
</th> </th>
<th
style={{
textAlign: 'right',
padding: '8px 12px',
fontWeight: 600,
fontSize: 12,
color: 'var(--color-text-secondary)',
}}
>
Затраты
</th>
<th
style={{
textAlign: 'right',
padding: '8px 12px',
fontWeight: 600,
fontSize: 12,
color: 'var(--color-text-secondary)',
}}
>
P&L
</th>
<th
style={{
textAlign: 'right',
padding: '8px 12px',
fontWeight: 600,
fontSize: 12,
color: 'var(--color-text-secondary)',
}}
>
P&L %
</th>
<th <th
style={{ style={{
textAlign: 'right', textAlign: 'right',

View File

@ -4,20 +4,32 @@ import type { PositionWithPrice } from '../../api/responses';
interface Props { interface Props {
position: PositionWithPrice; position: PositionWithPrice;
onUpdate: (data: { quantity?: number }) => void; onUpdate: (data: { quantity?: number; buyPrice?: number }) => void;
onDelete: () => void; onDelete: () => void;
} }
export function SharePositionRow({ position, onUpdate, onDelete }: Props) { 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 [qty, setQty] = useState(String(position.quantity));
const [price, setPrice] = useState(String(position.buyPrice ?? ''));
function handleSave() { function handleSaveQty() {
const num = parseInt(qty, 10); const num = parseInt(qty, 10);
if (!isNaN(num) && num >= 0 && num !== position.quantity) { if (!isNaN(num) && num >= 0 && num !== position.quantity) {
onUpdate({ quantity: num }); 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 ( return (
@ -31,14 +43,14 @@ export function SharePositionRow({ position, onUpdate, onDelete }: Props) {
{position.shortName ?? '—'} {position.shortName ?? '—'}
</td> </td>
<td style={{ padding: '8px 12px' }}> <td style={{ padding: '8px 12px' }}>
{editing ? ( {editingQty ? (
<input <input
type="number" type="number"
min={0} min={0}
value={qty} value={qty}
onChange={(e) => setQty(e.target.value)} onChange={(e) => setQty(e.target.value)}
onBlur={handleSave} onBlur={handleSaveQty}
onKeyDown={(e) => e.key === 'Enter' && handleSave()} onKeyDown={(e) => e.key === 'Enter' && handleSaveQty()}
autoFocus autoFocus
style={{ style={{
width: 80, width: 80,
@ -52,7 +64,7 @@ export function SharePositionRow({ position, onUpdate, onDelete }: Props) {
<span <span
onClick={() => { onClick={() => {
setQty(String(position.quantity)); setQty(String(position.quantity));
setEditing(true); setEditingQty(true);
}} }}
style={{ cursor: 'pointer', padding: '4px 0', display: 'inline-block' }} style={{ cursor: 'pointer', padding: '4px 0', display: 'inline-block' }}
> >
@ -60,6 +72,47 @@ export function SharePositionRow({ position, onUpdate, onDelete }: Props) {
</span> </span>
)} )}
</td> </td>
<td style={{ padding: '8px 12px', textAlign: 'right' }}>
{editingPrice ? (
<input
type="number"
step="0.01"
value={price}
onChange={(e) => 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',
}}
/>
) : (
<span
onClick={() => {
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,
})
: '—'}
</span>
)}
</td>
<td style={{ padding: '8px 12px', textAlign: 'right' }}> <td style={{ padding: '8px 12px', textAlign: 'right' }}>
{position.currentPrice !== null {position.currentPrice !== null
? position.currentPrice.toLocaleString('ru-RU', { ? position.currentPrice.toLocaleString('ru-RU', {
@ -89,6 +142,43 @@ export function SharePositionRow({ position, onUpdate, onDelete }: Props) {
}) })
: '—'} : '—'}
</td> </td>
<td style={{ padding: '8px 12px', textAlign: 'right' }}>
{position.totalCost !== null
? position.totalCost.toLocaleString('ru-RU', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})
: '—'}
</td>
<td style={{ padding: '8px 12px', textAlign: 'right' }}>
{position.pnl !== null ? (
<span
style={{ color: position.pnl >= 0 ? 'var(--color-positive)' : 'var(--color-negative)' }}
>
{position.pnl >= 0 ? '+' : ''}
{position.pnl.toLocaleString('ru-RU', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}
</span>
) : (
'—'
)}
</td>
<td style={{ padding: '8px 12px', textAlign: 'right' }}>
{position.pnlPercent !== null ? (
<span
style={{
color: position.pnlPercent >= 0 ? 'var(--color-positive)' : 'var(--color-negative)',
}}
>
{position.pnlPercent >= 0 ? '+' : ''}
{position.pnlPercent.toFixed(2)}%
</span>
) : (
'—'
)}
</td>
<td style={{ padding: '8px 12px', textAlign: 'right' }}> <td style={{ padding: '8px 12px', textAlign: 'right' }}>
{position.weightPercent.toFixed(1)}% {position.weightPercent.toFixed(1)}%
</td> </td>

View File

@ -3,7 +3,10 @@ import type { PositionWithPrice } from '../../api/responses';
interface Props { interface Props {
positions: PositionWithPrice[]; positions: PositionWithPrice[];
onUpdatePosition: (positionId: number, data: { quantity?: number }) => void; onUpdatePosition: (
positionId: number,
data: { quantity?: number; buyPrice?: number; buyDate?: string },
) => void;
onDeletePosition: (positionId: number) => void; onDeletePosition: (positionId: number) => void;
} }
@ -52,6 +55,17 @@ export function SharePositionTable({ positions, onUpdatePosition, onDeletePositi
> >
Количество Количество
</th> </th>
<th
style={{
textAlign: 'right',
padding: '8px 12px',
fontWeight: 600,
fontSize: 12,
color: 'var(--color-text-secondary)',
}}
>
Цена пок.
</th>
<th <th
style={{ style={{
textAlign: 'right', textAlign: 'right',
@ -85,6 +99,39 @@ export function SharePositionTable({ positions, onUpdatePosition, onDeletePositi
> >
Стоимость Стоимость
</th> </th>
<th
style={{
textAlign: 'right',
padding: '8px 12px',
fontWeight: 600,
fontSize: 12,
color: 'var(--color-text-secondary)',
}}
>
Затраты
</th>
<th
style={{
textAlign: 'right',
padding: '8px 12px',
fontWeight: 600,
fontSize: 12,
color: 'var(--color-text-secondary)',
}}
>
P&L
</th>
<th
style={{
textAlign: 'right',
padding: '8px 12px',
fontWeight: 600,
fontSize: 12,
color: 'var(--color-text-secondary)',
}}
>
P&L %
</th>
<th <th
style={{ style={{
textAlign: 'right', textAlign: 'right',

View File

@ -0,0 +1,108 @@
import { useState } from 'react';
import type { ScreenerQuery } from '../../api/screener';
import { FilterPanelShare } from './FilterPanelShare';
import { FilterPanelBond } from './FilterPanelBond';
interface Props {
params: ScreenerQuery;
onApply: (filters: Partial<ScreenerQuery>) => void;
onReset: () => void;
}
export function FilterPanel({ params, onApply, onReset }: Props) {
const [type, setType] = useState<'share' | 'bond'>(params.type);
const [local, setLocal] = useState<Record<string, string>>({});
function handleApply() {
const filters: Partial<ScreenerQuery> = { 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 (
<div
style={{
width: 260,
padding: 16,
background: 'var(--color-surface)',
border: '1px solid #e0e0e0',
borderRadius: 'var(--border-radius)',
display: 'flex',
flexDirection: 'column',
gap: 12,
}}
>
<div>
<label style={{ display: 'block', fontSize: 12, fontWeight: 600, marginBottom: 4 }}>
Тип бумаги
</label>
<select
value={type}
onChange={(e) => setType(e.target.value as 'share' | 'bond')}
style={{
width: '100%',
padding: '6px 10px',
border: '1px solid #e0e0e0',
borderRadius: 'var(--border-radius)',
fontSize: 13,
}}
>
<option value="share">Акции</option>
<option value="bond">Облигации</option>
</select>
</div>
{type === 'share' ? (
<FilterPanelShare params={params} local={local} updateField={updateField} />
) : (
<FilterPanelBond params={params} local={local} updateField={updateField} />
)}
<div style={{ display: 'flex', gap: 8 }}>
<button
onClick={handleApply}
style={{
flex: 1,
padding: '8px 16px',
background: 'var(--color-primary)',
color: '#fff',
border: 'none',
borderRadius: 'var(--border-radius)',
fontSize: 13,
fontWeight: 600,
cursor: 'pointer',
}}
>
Применить
</button>
<button
onClick={handleReset}
style={{
padding: '8px 16px',
background: 'transparent',
border: '1px solid #e0e0e0',
borderRadius: 'var(--border-radius)',
fontSize: 13,
cursor: 'pointer',
}}
>
Сбросить
</button>
</div>
</div>
);
}

View File

@ -0,0 +1,53 @@
interface Props {
params: Record<string, any>;
local: Record<string, string>;
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 (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{fields.map(({ key, label }) => (
<div key={key}>
<label
style={{
display: 'block',
fontSize: 11,
fontWeight: 600,
marginBottom: 2,
color: 'var(--color-text-secondary)',
}}
>
{label}
</label>
<input
type="number"
value={local[key] ?? ''}
onChange={(e) => updateField(key, e.target.value)}
style={{
width: '100%',
padding: '6px 10px',
border: '1px solid #e0e0e0',
borderRadius: 'var(--border-radius)',
fontSize: 13,
boxSizing: 'border-box',
}}
/>
</div>
))}
</div>
);
}

View File

@ -0,0 +1,49 @@
interface Props {
params: Record<string, any>;
local: Record<string, string>;
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 (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{fields.map(({ key, label }) => (
<div key={key}>
<label
style={{
display: 'block',
fontSize: 11,
fontWeight: 600,
marginBottom: 2,
color: 'var(--color-text-secondary)',
}}
>
{label}
</label>
<input
type="number"
value={local[key] ?? ''}
onChange={(e) => updateField(key, e.target.value)}
style={{
width: '100%',
padding: '6px 10px',
border: '1px solid #e0e0e0',
borderRadius: 'var(--border-radius)',
fontSize: 13,
boxSizing: 'border-box',
}}
/>
</div>
))}
</div>
);
}

View File

@ -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 (
<th
onClick={() => 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' ? '▲' : '▼') : ''}
</th>
);
}
const isShare = result.items[0]?.type === 'share';
return (
<div style={{ flex: 1 }}>
<div style={{ fontSize: 13, color: 'var(--color-text-secondary)', marginBottom: 8 }}>
Найдено: {result.total} бумаг
</div>
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead>
<tr style={{ borderBottom: '2px solid #e0e0e0' }}>
<th
style={{
textAlign: 'left',
padding: '8px 12px',
fontWeight: 600,
fontSize: 12,
color: 'var(--color-text-secondary)',
}}
>
Тикер
</th>
<th
style={{
textAlign: 'left',
padding: '8px 12px',
fontWeight: 600,
fontSize: 12,
color: 'var(--color-text-secondary)',
}}
>
Название
</th>
<SortHeader field="price">Цена</SortHeader>
<SortHeader field="changePercent">Изм.</SortHeader>
<SortHeader field="volume">Объём</SortHeader>
{isShare ? (
<SortHeader field="capitalization">Капитализация</SortHeader>
) : (
<>
<SortHeader field="yieldToMaturity">YTM</SortHeader>
<SortHeader field="duration">Дюрация</SortHeader>
<SortHeader field="couponValue">Купон</SortHeader>
<SortHeader field="couponPercent">Куп. %</SortHeader>
</>
)}
</tr>
</thead>
<tbody>
{result.items.map((item) => {
const change = formatChange(item.changePercent);
const link = isShare ? `/stocks/${item.secid}` : `/bonds/${item.secid}`;
return (
<tr key={item.secid} style={{ borderBottom: '1px solid #f0f0f0' }}>
<td style={{ padding: '8px 12px', fontWeight: 600, fontFamily: 'monospace' }}>
<Link to={link} style={{ color: 'inherit', textDecoration: 'none' }}>
{item.secid}
</Link>
</td>
<td style={{ padding: '8px 12px', color: 'var(--color-text-secondary)' }}>
{item.shortName}
</td>
<td style={{ padding: '8px 12px', textAlign: 'right' }}>
{formatNum(item.price)}
</td>
<td style={{ padding: '8px 12px', textAlign: 'right', color: change.color }}>
{change.text}
</td>
<td style={{ padding: '8px 12px', textAlign: 'right' }}>
{item.volume.toLocaleString('ru-RU')}
</td>
{isShare ? (
<td style={{ padding: '8px 12px', textAlign: 'right' }}>
{item.capitalization != null
? item.capitalization.toLocaleString('ru-RU')
: '—'}
</td>
) : (
<>
<td style={{ padding: '8px 12px', textAlign: 'right' }}>
{formatNum(item.yieldToMaturity)}
</td>
<td style={{ padding: '8px 12px', textAlign: 'right' }}>
{item.duration != null ? `${item.duration.toFixed(2)}г` : '—'}
</td>
<td style={{ padding: '8px 12px', textAlign: 'right' }}>
{formatNum(item.couponValue)}
</td>
<td style={{ padding: '8px 12px', textAlign: 'right' }}>
{formatNum(item.couponPercent)}
</td>
</>
)}
</tr>
);
})}
</tbody>
</table>
</div>
{result.totalPages > 1 && (
<div style={{ display: 'flex', justifyContent: 'center', gap: 8, marginTop: 16 }}>
{Array.from({ length: Math.min(result.totalPages, 10) }, (_, i) => i + 1).map((p) => (
<button
key={p}
onClick={() => onPageChange(p)}
style={{
padding: '4px 10px',
background: p === result.page ? 'var(--color-primary)' : 'transparent',
color: p === result.page ? '#fff' : 'var(--color-text)',
border: '1px solid #e0e0e0',
borderRadius: 'var(--border-radius)',
fontSize: 13,
cursor: 'pointer',
}}
>
{p}
</button>
))}
</div>
)}
</div>
);
}

View File

@ -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<AnalyticsResponse>({
queryKey: ['portfolio', portfolioId, 'analytics'],
queryFn: async () => {
const res = await getPortfolioAnalytics(portfolioId);
return res.data;
},
staleTime: 900_000,
retry: 2,
refetchOnWindowFocus: false,
enabled: !!portfolioId,
});
}

View File

@ -6,8 +6,14 @@ export function usePositionMutations(portfolioId: number) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const add = useMutation({ const add = useMutation({
mutationFn: (data: { secid: string; quantity: number; notes?: string; tags?: string[] }) => mutationFn: (data: {
addPosition(portfolioId, data), secid: string;
quantity: number;
buyPrice?: number;
buyDate?: string;
notes?: string;
tags?: string[];
}) => addPosition(portfolioId, data),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['portfolio', portfolioId] }); queryClient.invalidateQueries({ queryKey: ['portfolio', portfolioId] });
}, },
@ -19,7 +25,13 @@ export function usePositionMutations(portfolioId: number) {
data, data,
}: { }: {
positionId: number; positionId: number;
data: { quantity?: number; notes?: string; tags?: string[] }; data: {
quantity?: number;
buyPrice?: number;
buyDate?: string;
notes?: string;
tags?: string[];
};
}) => updatePosition(portfolioId, positionId, data), }) => updatePosition(portfolioId, positionId, data),
onMutate: async ({ positionId, data }) => { onMutate: async ({ positionId, data }) => {
await queryClient.cancelQueries({ queryKey: ['portfolio', portfolioId] }); await queryClient.cancelQueries({ queryKey: ['portfolio', portfolioId] });
@ -33,7 +45,12 @@ export function usePositionMutations(portfolioId: number) {
...old, ...old,
positions: old.positions.map((p: any) => positions: old.positions.map((p: any) =>
p.id === positionId 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, : p,
), ),
}; };

View File

@ -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<ScreenerQuery>) {
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,
};
}

View File

@ -5,6 +5,7 @@ import { usePortfolioMutations } from '../../hooks/usePortfolioMutations';
import { usePositionMutations } from '../../hooks/usePositionMutations'; import { usePositionMutations } from '../../hooks/usePositionMutations';
import { PortfolioForm } from '../../components/portfolios/PortfolioForm'; import { PortfolioForm } from '../../components/portfolios/PortfolioForm';
import { PortfolioSummary } from '../../components/portfolios/PortfolioSummary'; import { PortfolioSummary } from '../../components/portfolios/PortfolioSummary';
import { AnalyticsSummary } from '../../components/portfolios/AnalyticsSummary';
import { SharePositionTable } from '../../components/portfolios/SharePositionTable'; import { SharePositionTable } from '../../components/portfolios/SharePositionTable';
import { BondPositionTable } from '../../components/portfolios/BondPositionTable'; import { BondPositionTable } from '../../components/portfolios/BondPositionTable';
@ -24,6 +25,8 @@ export function PortfolioDetailPage() {
const [showAddForm, setShowAddForm] = useState(false); const [showAddForm, setShowAddForm] = useState(false);
const [newSecid, setNewSecid] = useState(''); const [newSecid, setNewSecid] = useState('');
const [newQty, setNewQty] = useState('1'); const [newQty, setNewQty] = useState('1');
const [newPrice, setNewPrice] = useState('');
const [newDate, setNewDate] = useState(new Date().toISOString().split('T')[0]);
if (isLoading) { if (isLoading) {
return ( return (
@ -53,12 +56,15 @@ export function PortfolioDetailPage() {
{ {
secid: newSecid.trim().toUpperCase(), secid: newSecid.trim().toUpperCase(),
quantity: parseInt(newQty, 10), quantity: parseInt(newQty, 10),
buyPrice: newPrice ? parseFloat(newPrice) : undefined,
buyDate: newDate || undefined,
}, },
{ {
onSuccess: () => { onSuccess: () => {
setShowAddForm(false); setShowAddForm(false);
setNewSecid(''); setNewSecid('');
setNewQty('1'); setNewQty('1');
setNewPrice('');
}, },
}, },
); );
@ -135,6 +141,8 @@ export function PortfolioDetailPage() {
<PortfolioSummary portfolio={portfolio} /> <PortfolioSummary portfolio={portfolio} />
{portfolio.analytics && <AnalyticsSummary summary={portfolio.analytics} />}
<div <div
style={{ style={{
marginTop: 24, marginTop: 24,
@ -209,6 +217,42 @@ export function PortfolioDetailPage() {
}} }}
/> />
</div> </div>
<div>
<label style={{ display: 'block', fontSize: 12, fontWeight: 600, marginBottom: 4 }}>
Цена покупки
</label>
<input
type="number"
step="0.01"
value={newPrice}
onChange={(e) => setNewPrice(e.target.value)}
placeholder="0.00"
style={{
padding: '8px 12px',
border: '1px solid #e0e0e0',
borderRadius: 'var(--border-radius)',
fontSize: 14,
width: 120,
}}
/>
</div>
<div>
<label style={{ display: 'block', fontSize: 12, fontWeight: 600, marginBottom: 4 }}>
Дата покупки
</label>
<input
type="date"
value={newDate}
onChange={(e) => setNewDate(e.target.value)}
style={{
padding: '8px 12px',
border: '1px solid #e0e0e0',
borderRadius: 'var(--border-radius)',
fontSize: 14,
width: 150,
}}
/>
</div>
<button <button
onClick={handleAddPosition} onClick={handleAddPosition}
disabled={addPosition.isPending} disabled={addPosition.isPending}

View File

@ -0,0 +1,59 @@
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 (
<div>
<h1 style={{ margin: '0 0 20px', fontSize: 24, fontWeight: 700 }}>Скринер</h1>
<div style={{ display: 'flex', gap: 20, alignItems: 'flex-start' }}>
<FilterPanel params={params} onApply={setFilters} onReset={resetFilters} />
{isLoading && (
<div
style={{
flex: 1,
textAlign: 'center',
padding: 40,
color: 'var(--color-text-secondary)',
}}
>
Загрузка...
</div>
)}
{error && (
<div style={{ flex: 1, textAlign: 'center', padding: 40, color: '#e53935' }}>
Ошибка загрузки данных. Попробуйте позже.
</div>
)}
{!isLoading && !error && result && result.items.length === 0 && (
<div
style={{
flex: 1,
textAlign: 'center',
padding: 40,
color: 'var(--color-text-secondary)',
}}
>
Ничего не найдено. Попробуйте смягчить фильтры.
</div>
)}
{!isLoading && !error && result && result.items.length > 0 && (
<ScreenerTable
result={result}
sortBy={params.sortBy || 'price'}
sortOrder={params.sortOrder || 'asc'}
onSort={setSort}
onPageChange={setPage}
/>
)}
</div>
</div>
);
}

View File

@ -9,6 +9,7 @@ import { ProfilePage } from './pages/ProfilePage';
import { ProtectedRoute } from './components/ProtectedRoute'; import { ProtectedRoute } from './components/ProtectedRoute';
import { PortfoliosListPage } from './pages/portfolios/PortfoliosListPage'; import { PortfoliosListPage } from './pages/portfolios/PortfoliosListPage';
import { PortfolioDetailPage } from './pages/portfolios/PortfolioDetailPage'; import { PortfolioDetailPage } from './pages/portfolios/PortfolioDetailPage';
import { ScreenerPage } from './pages/screener/ScreenerPage';
export function AppRoutes() { export function AppRoutes() {
return ( return (
@ -17,6 +18,7 @@ export function AppRoutes() {
<Route path="/" element={<HomePage />} /> <Route path="/" element={<HomePage />} />
<Route path="/stocks/:secid" element={<StockPage />} /> <Route path="/stocks/:secid" element={<StockPage />} />
<Route path="/bonds/:secid" element={<BondPage />} /> <Route path="/bonds/:secid" element={<BondPage />} />
<Route path="/screener" element={<ScreenerPage />} />
<Route path="/login" element={<LoginPage />} /> <Route path="/login" element={<LoginPage />} />
<Route path="/register" element={<RegisterPage />} /> <Route path="/register" element={<RegisterPage />} />
<Route <Route

View File

@ -18,6 +18,10 @@
--shadow: 0 1px 3px rgba(0, 0, 0, 0.12); --shadow: 0 1px 3px rgba(0, 0, 0, 0.12);
} }
.pnl-cell { text-align: right; }
.positive { color: var(--color-positive); }
.negative { color: var(--color-negative); }
body { body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--color-bg); background: var(--color-bg);

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -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] Обратная совместимость с существующими данными гарантирована

View File

@ -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
<Route path="/screener" element={<ScreenerPage />} />
// Без 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<ScreenerResult>`
- Получение доски через `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)