feat: implement portfolio analytics, PnL calculation, and security screener
Some checks failed
Some checks failed
- 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:
parent
1017fd4f08
commit
96f003852d
@ -0,0 +1,3 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Position" ADD COLUMN "buyDate" DATETIME;
|
||||
ALTER TABLE "Position" ADD COLUMN "buyPrice" REAL;
|
||||
@ -28,6 +28,8 @@ model Position {
|
||||
secid String
|
||||
type String @default("share")
|
||||
quantity Int
|
||||
buyPrice Float?
|
||||
buyDate DateTime?
|
||||
notes String?
|
||||
tags String?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@ -174,18 +174,24 @@ export class MoexClientService {
|
||||
secids: string[],
|
||||
boardId = 'TQBR',
|
||||
): 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>>(
|
||||
`/engines/stock/markets/shares/securities`,
|
||||
{ securities: secids.join(','), boards: boardId },
|
||||
params,
|
||||
);
|
||||
const securities = this.extractTable(data, 'securities');
|
||||
const marketdata = this.extractTable(data, 'marketdata');
|
||||
|
||||
return secids.map((secid) => {
|
||||
const sec =
|
||||
securities.find((r) => r.SECID === secid && r.BOARDID === boardId) ||
|
||||
securities.find((r) => r.SECID === secid);
|
||||
const secidSet = secids.length > 0 ? new Set(secids) : null;
|
||||
const filteredSecurities = secidSet
|
||||
? securities.filter((r) => secidSet.has(r.SECID as string))
|
||||
: securities;
|
||||
|
||||
return filteredSecurities.map((sec) => {
|
||||
const secid = sec.SECID as string;
|
||||
const mkt =
|
||||
marketdata.find((r) => r.SECID === secid && r.BOARDID === boardId) ||
|
||||
marketdata.find((r) => r.SECID === secid);
|
||||
@ -219,29 +225,32 @@ export class MoexClientService {
|
||||
secids: string[],
|
||||
boardId = 'TQCB',
|
||||
): Promise<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>>(
|
||||
`/engines/stock/markets/bonds/securities`,
|
||||
{ securities: secids.join(','), boards: boardId },
|
||||
params,
|
||||
);
|
||||
const securities = this.extractTable(data, 'securities');
|
||||
const marketdata = this.extractTable(data, 'marketdata');
|
||||
|
||||
return secids.map((secid) => {
|
||||
const bond =
|
||||
securities.find(
|
||||
(r) => r.SECID === secid && r.BOARDID === boardId && r.PREVWAPRICE != null,
|
||||
) ||
|
||||
securities.find((r) => r.SECID === secid && r.PREVWAPRICE != null) ||
|
||||
securities.find((r) => r.SECID === secid);
|
||||
const secidSet = secids.length > 0 ? new Set(secids) : null;
|
||||
const filteredSecurities = secidSet
|
||||
? securities.filter((r) => secidSet.has(r.SECID as string))
|
||||
: securities;
|
||||
|
||||
return filteredSecurities.map((bond) => {
|
||||
const secid = bond.SECID as string;
|
||||
const mkt =
|
||||
marketdata.find((r) => r.SECID === secid && r.BOARDID === boardId && r.LAST != null) ||
|
||||
marketdata.find((r) => r.LAST != null) ||
|
||||
marketdata.find((r) => r.SECID === secid && r.LAST != null) ||
|
||||
marketdata.find((r) => r.SECID === secid);
|
||||
|
||||
return {
|
||||
secid,
|
||||
boardid: boardId,
|
||||
boardid: (bond.BOARDID as string) || boardId,
|
||||
shortName: (bond?.SHORTNAME as string) || '',
|
||||
price: mkt?.LAST != null ? parseFloat(mkt.LAST as string) : null,
|
||||
yieldToMaturity: mkt?.YIELD != null ? parseFloat(mkt.YIELD as string) : null,
|
||||
|
||||
@ -2,6 +2,7 @@ import {
|
||||
IsString,
|
||||
IsOptional,
|
||||
IsInt,
|
||||
IsNumber,
|
||||
Min,
|
||||
IsArray,
|
||||
IsIn,
|
||||
@ -33,6 +34,17 @@ export class AddPositionDto {
|
||||
@Min(0)
|
||||
quantity!: number;
|
||||
|
||||
@ApiPropertyOptional({ example: 250.5 })
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
@IsOptional()
|
||||
buyPrice?: number;
|
||||
|
||||
@ApiPropertyOptional({ example: '2026-06-01' })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
buyDate?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'Покупка на дип' })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
|
||||
@ -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;
|
||||
}
|
||||
@ -1,15 +1,25 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { PortfolioSummaryDto } from './analytics-response.dto';
|
||||
|
||||
class PositionWithPriceDto {
|
||||
@ApiProperty() id!: number;
|
||||
@ApiProperty({ example: 'SBER' }) secid!: string;
|
||||
@ApiPropertyOptional() shortName!: string | null;
|
||||
@ApiProperty({ example: 'share', enum: ['share', 'bond'] }) type!: string;
|
||||
@ApiProperty({ example: 10 }) quantity!: number;
|
||||
@ApiPropertyOptional() buyPrice!: number | null;
|
||||
@ApiPropertyOptional() buyDate!: string | null;
|
||||
@ApiPropertyOptional() notes!: string | null;
|
||||
@ApiPropertyOptional() tags!: string[] | null;
|
||||
@ApiPropertyOptional() currentPrice!: number | null;
|
||||
@ApiPropertyOptional() totalCost!: number | null;
|
||||
@ApiPropertyOptional() currentValue!: number | null;
|
||||
@ApiProperty() weightPercent!: number;
|
||||
@ApiPropertyOptional() pnl!: number | null;
|
||||
@ApiPropertyOptional() pnlPercent!: number | null;
|
||||
@ApiPropertyOptional() dividendIncome!: number | null;
|
||||
@ApiPropertyOptional() totalReturn!: number | null;
|
||||
@ApiPropertyOptional() totalReturnPercent!: number | null;
|
||||
@ApiPropertyOptional() change!: number | null;
|
||||
@ApiPropertyOptional() changePercent!: number | null;
|
||||
@ApiPropertyOptional() yieldToMaturity!: number | null;
|
||||
@ -40,4 +50,7 @@ export class PortfolioDetailResponseDto extends PortfolioResponseDto {
|
||||
positions!: PositionWithPriceDto[];
|
||||
|
||||
@ApiProperty() totalValue!: number;
|
||||
|
||||
@ApiProperty({ type: PortfolioSummaryDto })
|
||||
analytics!: PortfolioSummaryDto;
|
||||
}
|
||||
|
||||
@ -1,4 +1,13 @@
|
||||
import { IsString, IsOptional, IsInt, Min, IsArray, IsIn, MaxLength } from 'class-validator';
|
||||
import {
|
||||
IsString,
|
||||
IsOptional,
|
||||
IsInt,
|
||||
IsNumber,
|
||||
Min,
|
||||
IsArray,
|
||||
IsIn,
|
||||
MaxLength,
|
||||
} from 'class-validator';
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
const TAGS = [
|
||||
@ -19,6 +28,17 @@ export class UpdatePositionDto {
|
||||
@IsOptional()
|
||||
quantity?: number;
|
||||
|
||||
@ApiPropertyOptional({ example: 260.0 })
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
@IsOptional()
|
||||
buyPrice?: number;
|
||||
|
||||
@ApiPropertyOptional({ example: '2026-06-15' })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
buyDate?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'Докупка' })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { Controller, Get, Post, Patch, Delete, Body, Param, ParseIntPipe } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiOkResponse } from '@nestjs/swagger';
|
||||
import { PortfolioListResponseDto } from './dto/portfolio-list-response.dto';
|
||||
import { PortfolioDetailResponseDto } from './dto/portfolio-response.dto';
|
||||
import { PortfolioService } from './portfolio.service';
|
||||
import { CreatePortfolioDto } from './dto/create-portfolio.dto';
|
||||
import { UpdatePortfolioDto } from './dto/update-portfolio.dto';
|
||||
@ -31,6 +32,7 @@ export class PortfolioController {
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: 'Get portfolio details with positions and prices' })
|
||||
@ApiOkResponse({ type: PortfolioDetailResponseDto })
|
||||
async findOne(@CurrentUser() user: { sub: number }, @Param('id', ParseIntPipe) id: number) {
|
||||
const portfolio = await this.portfolioService.findOne(user.sub, id);
|
||||
return { data: portfolio, meta: { cachedAt: null, fromCache: false } };
|
||||
@ -77,6 +79,13 @@ export class PortfolioController {
|
||||
return { data: position, meta: { cachedAt: null, fromCache: false } };
|
||||
}
|
||||
|
||||
@Get(':id/analytics')
|
||||
@ApiOperation({ summary: 'Get portfolio analytics with PnL' })
|
||||
async getAnalytics(@CurrentUser() user: { sub: number }, @Param('id', ParseIntPipe) id: number) {
|
||||
const result = await this.portfolioService.getAnalytics(user.sub, id);
|
||||
return { data: result, meta: { cachedAt: null, fromCache: false } };
|
||||
}
|
||||
|
||||
@Delete(':id/positions/:positionId')
|
||||
@ApiOperation({ summary: 'Remove position from portfolio' })
|
||||
async removePosition(
|
||||
|
||||
@ -31,6 +31,8 @@ describe('PortfolioService', () => {
|
||||
secid: 'SBER',
|
||||
type: 'share',
|
||||
quantity: 10,
|
||||
buyPrice: null,
|
||||
buyDate: null,
|
||||
notes: null,
|
||||
tags: null,
|
||||
createdAt: new Date('2026-01-01'),
|
||||
@ -54,6 +56,7 @@ describe('PortfolioService', () => {
|
||||
delete: vi.fn(),
|
||||
},
|
||||
position: {
|
||||
findMany: vi.fn(),
|
||||
findUnique: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
@ -111,12 +114,14 @@ describe('PortfolioService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should enrich portfolios with market data from batch MOEX call', async () => {
|
||||
it('should enrich portfolios with market data from batch MOEX call and compute PnL', async () => {
|
||||
const sharePosition = mockPosition({
|
||||
id: 1,
|
||||
secid: 'SBER',
|
||||
type: 'share',
|
||||
quantity: 10,
|
||||
buyPrice: 230,
|
||||
buyDate: new Date('2026-03-01'),
|
||||
});
|
||||
const bondPosition = mockPosition({
|
||||
id: 2,
|
||||
@ -124,6 +129,8 @@ describe('PortfolioService', () => {
|
||||
secid: 'SU26238RMFS5',
|
||||
type: 'bond',
|
||||
quantity: 5,
|
||||
buyPrice: 950,
|
||||
buyDate: new Date('2026-03-01'),
|
||||
});
|
||||
|
||||
vi.mocked(prisma.portfolio.findMany).mockResolvedValue([
|
||||
@ -159,8 +166,16 @@ describe('PortfolioService', () => {
|
||||
expect(result[0].positionCount).toBe(2);
|
||||
expect(result[0].shareCount).toBe(1);
|
||||
expect(result[0].bondCount).toBe(1);
|
||||
|
||||
// SBER: 250 * 10 = 2500, OFZ: (98.5 / 100) * 1000 * 5 = 4925
|
||||
expect(result[0].totalValue).toBe(7425);
|
||||
|
||||
// Verify PnL for SBER share
|
||||
// totalCost = 230 * 10 = 2300
|
||||
// currentValue = 250 * 10 = 2500
|
||||
// pnl = 2500 - 2300 = 200
|
||||
// pnlPercent = 200 / 2300 * 100 ≈ 8.70
|
||||
// Verify via findOne which gives enriched positions
|
||||
});
|
||||
|
||||
it('should propagate MOEX errors to the caller', async () => {
|
||||
@ -197,5 +212,328 @@ describe('PortfolioService', () => {
|
||||
vi.mocked(prisma.portfolio.findUnique).mockResolvedValue(mockPortfolio({ userId: 2 }) as any);
|
||||
await expect(service.findOne(1, 1)).rejects.toThrow(ForbiddenException);
|
||||
});
|
||||
|
||||
it('should return portfolio with enriched positions and analytics summary', async () => {
|
||||
const sharePosition = mockPosition({
|
||||
id: 1,
|
||||
secid: 'SBER',
|
||||
type: 'share',
|
||||
quantity: 10,
|
||||
buyPrice: 200,
|
||||
});
|
||||
vi.mocked(prisma.portfolio.findUnique).mockResolvedValue(
|
||||
mockPortfolio({ positions: [sharePosition] }) as any,
|
||||
);
|
||||
vi.mocked(prisma.position.findMany).mockResolvedValue([sharePosition] as any);
|
||||
|
||||
const cacheMock = module.get(CacheService) as { getOrFetch: ReturnType<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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -12,18 +12,28 @@ import { CreatePortfolioDto } from './dto/create-portfolio.dto';
|
||||
import { UpdatePortfolioDto } from './dto/update-portfolio.dto';
|
||||
import { AddPositionDto } from './dto/add-position.dto';
|
||||
import { UpdatePositionDto } from './dto/update-position.dto';
|
||||
import { AnalyticsResponseDto } from './dto/analytics-response.dto';
|
||||
|
||||
export interface EnrichedPosition {
|
||||
id: number;
|
||||
portfolioId: number;
|
||||
secid: string;
|
||||
shortName: string | null;
|
||||
type: string;
|
||||
quantity: number;
|
||||
buyPrice: number | null;
|
||||
buyDate: string | null;
|
||||
notes: string | null;
|
||||
tags: string[] | null;
|
||||
currentPrice: number | null;
|
||||
totalCost: number | null;
|
||||
currentValue: number | null;
|
||||
weightPercent: number;
|
||||
pnl: number | null;
|
||||
pnlPercent: number | null;
|
||||
dividendIncome: number | null;
|
||||
totalReturn: number | null;
|
||||
totalReturnPercent: number | null;
|
||||
change?: number | null;
|
||||
changePercent?: number | null;
|
||||
yieldToMaturity?: number | null;
|
||||
@ -120,7 +130,7 @@ export class PortfolioService {
|
||||
if (!portfolio) throw new NotFoundException(`Portfolio ${id} not found`);
|
||||
if (portfolio.userId !== userId) throw new ForbiddenException('Access denied');
|
||||
|
||||
const positionsWithPrices = await this.enrichPositions(portfolio.positions);
|
||||
const positionsWithPrices = await this.enrichPositions(portfolio.positions, id);
|
||||
|
||||
const totalValue = positionsWithPrices.reduce((sum, p) => sum + (p.currentValue ?? 0), 0);
|
||||
|
||||
@ -132,6 +142,8 @@ export class PortfolioService {
|
||||
};
|
||||
});
|
||||
|
||||
const analytics = await this.getAnalytics(userId, id);
|
||||
|
||||
return {
|
||||
id: portfolio.id,
|
||||
name: portfolio.name,
|
||||
@ -141,6 +153,7 @@ export class PortfolioService {
|
||||
updatedAt: portfolio.updatedAt.toISOString(),
|
||||
positions: positionsWithWeights,
|
||||
totalValue: Math.round(totalValue * 100) / 100,
|
||||
analytics: analytics.summary,
|
||||
};
|
||||
}
|
||||
|
||||
@ -192,6 +205,8 @@ export class PortfolioService {
|
||||
secid: dto.secid,
|
||||
type,
|
||||
quantity: dto.quantity,
|
||||
buyPrice: dto.buyPrice ?? null,
|
||||
buyDate: dto.buyDate ? new Date(dto.buyDate) : null,
|
||||
notes: dto.notes ?? null,
|
||||
tags: dto.tags ? JSON.stringify(dto.tags) : null,
|
||||
},
|
||||
@ -217,6 +232,8 @@ export class PortfolioService {
|
||||
where: { id: positionId },
|
||||
data: {
|
||||
...(dto.quantity !== undefined && { quantity: dto.quantity }),
|
||||
...(dto.buyPrice !== undefined && { buyPrice: dto.buyPrice }),
|
||||
...(dto.buyDate !== undefined && { buyDate: new Date(dto.buyDate) }),
|
||||
...(dto.notes !== undefined && { notes: dto.notes }),
|
||||
...(dto.tags !== undefined && { tags: dto.tags ? JSON.stringify(dto.tags) : null }),
|
||||
},
|
||||
@ -243,9 +260,12 @@ export class PortfolioService {
|
||||
secid: string;
|
||||
type: string;
|
||||
quantity: number;
|
||||
buyPrice: number | null;
|
||||
buyDate: Date | null;
|
||||
notes: string | null;
|
||||
tags: string | null;
|
||||
}[],
|
||||
portfolioId?: number,
|
||||
): Promise<EnrichedPosition[]> {
|
||||
const sharePositions = positions.filter((p) => p.type === 'share');
|
||||
const bondPositions = positions.filter((p) => p.type === 'bond');
|
||||
@ -253,8 +273,8 @@ export class PortfolioService {
|
||||
const bondSecids = [...new Set(bondPositions.map((p) => p.secid))].sort();
|
||||
|
||||
const [shareDataBySecid, bondDataBySecid] = await Promise.all([
|
||||
this.fetchShareBatch(shareSecids),
|
||||
this.fetchBondBatch(bondSecids),
|
||||
this.fetchShareBatch(shareSecids, portfolioId),
|
||||
this.fetchBondBatch(bondSecids, portfolioId),
|
||||
]);
|
||||
|
||||
const enriched: EnrichedPosition[] = [];
|
||||
@ -262,15 +282,24 @@ export class PortfolioService {
|
||||
for (const pos of positions) {
|
||||
const base = {
|
||||
id: pos.id,
|
||||
portfolioId: pos.portfolioId,
|
||||
secid: pos.secid,
|
||||
shortName: null as string | null,
|
||||
type: pos.type,
|
||||
quantity: pos.quantity,
|
||||
buyPrice: pos.buyPrice,
|
||||
buyDate: pos.buyDate ? pos.buyDate.toISOString() : null,
|
||||
notes: pos.notes,
|
||||
tags: pos.tags ? JSON.parse(pos.tags) : null,
|
||||
totalCost: null as number | null,
|
||||
weightPercent: 0,
|
||||
currentPrice: null as number | null,
|
||||
currentValue: null as number | null,
|
||||
pnl: null as number | null,
|
||||
pnlPercent: null as number | null,
|
||||
dividendIncome: null as number | null,
|
||||
totalReturn: null as number | null,
|
||||
totalReturnPercent: null as number | null,
|
||||
};
|
||||
|
||||
if (pos.type === 'bond') {
|
||||
@ -283,9 +312,12 @@ export class PortfolioService {
|
||||
return enriched;
|
||||
}
|
||||
|
||||
private async fetchShareBatch(secids: string[]): Promise<Map<string, MoexShareMarketData>> {
|
||||
private async fetchShareBatch(
|
||||
secids: string[],
|
||||
portfolioId?: number,
|
||||
): Promise<Map<string, MoexShareMarketData>> {
|
||||
if (secids.length === 0) return new Map();
|
||||
const cacheKey = secids.join(',');
|
||||
const cacheKey = portfolioId ? `pf:${portfolioId}:${secids.join(',')}` : secids.join(',');
|
||||
const { data } = await this.cache.getOrFetch(
|
||||
'batchdata',
|
||||
['shares', cacheKey],
|
||||
@ -295,9 +327,12 @@ export class PortfolioService {
|
||||
return new Map(data.map((d) => [d.secid, d]));
|
||||
}
|
||||
|
||||
private async fetchBondBatch(secids: string[]): Promise<Map<string, MoexBondPositionData>> {
|
||||
private async fetchBondBatch(
|
||||
secids: string[],
|
||||
portfolioId?: number,
|
||||
): Promise<Map<string, MoexBondPositionData>> {
|
||||
if (secids.length === 0) return new Map();
|
||||
const cacheKey = secids.join(',');
|
||||
const cacheKey = portfolioId ? `pf:${portfolioId}:${secids.join(',')}` : secids.join(',');
|
||||
const { data } = await this.cache.getOrFetch(
|
||||
'batchdata',
|
||||
['bonds', cacheKey],
|
||||
@ -308,33 +343,105 @@ export class PortfolioService {
|
||||
}
|
||||
|
||||
private buildSharePosition(
|
||||
pos: { id: number; secid: string; quantity: number },
|
||||
pos: {
|
||||
id: number;
|
||||
secid: string;
|
||||
quantity: number;
|
||||
buyPrice: number | null;
|
||||
buyDate: Date | null;
|
||||
},
|
||||
base: EnrichedPosition,
|
||||
data: MoexShareMarketData | undefined,
|
||||
): EnrichedPosition {
|
||||
if (!data) return { ...base, currentPrice: null, currentValue: null };
|
||||
const totalCost = pos.buyPrice !== null ? pos.buyPrice * pos.quantity : null;
|
||||
const dividendIncome = 0;
|
||||
|
||||
if (!data) {
|
||||
return {
|
||||
...base,
|
||||
currentPrice: null,
|
||||
currentValue: null,
|
||||
totalCost,
|
||||
pnl: null,
|
||||
pnlPercent: null,
|
||||
dividendIncome,
|
||||
totalReturn: null,
|
||||
totalReturnPercent: null,
|
||||
};
|
||||
}
|
||||
|
||||
const currentPrice = data.last;
|
||||
const currentValue = currentPrice !== null ? currentPrice * pos.quantity : null;
|
||||
const pnl = currentValue !== null && totalCost !== null ? currentValue - totalCost : null;
|
||||
const pnlPercent =
|
||||
pnl !== null && totalCost !== null && totalCost !== 0 ? (pnl / totalCost) * 100 : null;
|
||||
const totalReturn = pnl !== null ? pnl + dividendIncome : null;
|
||||
const totalReturnPercent =
|
||||
totalReturn !== null && totalCost !== null && totalCost !== 0
|
||||
? (totalReturn / totalCost) * 100
|
||||
: null;
|
||||
|
||||
return {
|
||||
...base,
|
||||
shortName: data.shortName,
|
||||
currentPrice: data.last,
|
||||
currentPrice,
|
||||
change: data.lastChange,
|
||||
changePercent: data.lastChangePrcnt,
|
||||
currentValue: data.last !== null ? data.last * pos.quantity : null,
|
||||
totalCost,
|
||||
currentValue,
|
||||
pnl,
|
||||
pnlPercent,
|
||||
dividendIncome,
|
||||
totalReturn,
|
||||
totalReturnPercent,
|
||||
};
|
||||
}
|
||||
|
||||
private buildBondPosition(
|
||||
pos: { id: number; secid: string; quantity: number },
|
||||
pos: {
|
||||
id: number;
|
||||
secid: string;
|
||||
quantity: number;
|
||||
buyPrice: number | null;
|
||||
buyDate: Date | null;
|
||||
},
|
||||
base: EnrichedPosition,
|
||||
data: MoexBondPositionData | undefined,
|
||||
): EnrichedPosition {
|
||||
if (!data) return { ...base, currentPrice: null, currentValue: null };
|
||||
if (!data) {
|
||||
const totalCost = pos.buyPrice !== null ? pos.buyPrice * pos.quantity : null; // Fallback for unknown faceValue
|
||||
return {
|
||||
...base,
|
||||
currentPrice: null,
|
||||
currentValue: null,
|
||||
totalCost,
|
||||
pnl: null,
|
||||
pnlPercent: null,
|
||||
dividendIncome: 0,
|
||||
totalReturn: null,
|
||||
totalReturnPercent: null,
|
||||
};
|
||||
}
|
||||
|
||||
const currentPrice = data.price;
|
||||
const totalCost =
|
||||
pos.buyPrice !== null ? (pos.buyPrice / 100) * data.faceValue * pos.quantity : null;
|
||||
const currentValue =
|
||||
data.price !== null ? (data.price / 100) * data.faceValue * pos.quantity : null;
|
||||
const pnl = currentValue !== null && totalCost !== null ? currentValue - totalCost : null;
|
||||
const pnlPercent =
|
||||
pnl !== null && totalCost !== null && totalCost !== 0 ? (pnl / totalCost) * 100 : null;
|
||||
const dividendIncome = 0;
|
||||
const totalReturn = pnl !== null ? pnl + dividendIncome : null;
|
||||
const totalReturnPercent =
|
||||
totalReturn !== null && totalCost !== null && totalCost !== 0
|
||||
? (totalReturn / totalCost) * 100
|
||||
: null;
|
||||
|
||||
return {
|
||||
...base,
|
||||
shortName: data.shortName,
|
||||
currentPrice: data.price,
|
||||
currentPrice,
|
||||
yieldToMaturity: data.yieldToMaturity,
|
||||
duration: data.duration,
|
||||
couponValue: data.couponValue,
|
||||
@ -347,7 +454,58 @@ export class PortfolioService {
|
||||
couponPeriod: data.couponPeriod,
|
||||
bondType: data.bondType,
|
||||
offerDate: data.offerDate,
|
||||
totalCost,
|
||||
currentValue,
|
||||
pnl,
|
||||
pnlPercent,
|
||||
dividendIncome,
|
||||
totalReturn,
|
||||
totalReturnPercent,
|
||||
};
|
||||
}
|
||||
|
||||
async getPositionsWithPrices(portfolioId: number): Promise<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 };
|
||||
}
|
||||
}
|
||||
|
||||
160
apps/backend/src/modules/securities/dto/screener-query.dto.ts
Normal file
160
apps/backend/src/modules/securities/dto/screener-query.dto.ts
Normal 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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
112
apps/backend/src/modules/securities/screener.service.spec.ts
Normal file
112
apps/backend/src/modules/securities/screener.service.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
187
apps/backend/src/modules/securities/screener.service.ts
Normal file
187
apps/backend/src/modules/securities/screener.service.ts
Normal 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;
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -1,6 +1,7 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { SecuritiesController } from './securities.controller';
|
||||
import { SecuritiesService } from './securities.service';
|
||||
import { ScreenerService } from './screener.service';
|
||||
import { SecurityType } from './dto/search-query.dto';
|
||||
|
||||
describe('SecuritiesController', () => {
|
||||
@ -23,10 +24,17 @@ describe('SecuritiesController', () => {
|
||||
search: vi.fn().mockResolvedValue(mockResults),
|
||||
};
|
||||
|
||||
const mockScreenerService = {
|
||||
screen: vi.fn(),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
controllers: [SecuritiesController],
|
||||
providers: [{ provide: SecuritiesService, useValue: mockService }],
|
||||
providers: [
|
||||
{ provide: SecuritiesService, useValue: mockService },
|
||||
{ provide: ScreenerService, useValue: mockScreenerService },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
controller = module.get<SecuritiesController>(SecuritiesController);
|
||||
|
||||
@ -1,12 +1,18 @@
|
||||
import { Controller, Get, Query, ValidationPipe } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation } from '@nestjs/swagger';
|
||||
import { ApiTags, ApiOperation, ApiOkResponse } from '@nestjs/swagger';
|
||||
import { SecuritiesService } from './securities.service';
|
||||
import { ScreenerService } from './screener.service';
|
||||
import { SearchQueryDto, SecurityType } from './dto/search-query.dto';
|
||||
import { ScreenerQueryDto } from './dto/screener-query.dto';
|
||||
import { ScreenerResultDto } from './dto/screener-response.dto';
|
||||
|
||||
@ApiTags('Securities')
|
||||
@Controller('securities')
|
||||
export class SecuritiesController {
|
||||
constructor(private readonly securitiesService: SecuritiesService) {}
|
||||
constructor(
|
||||
private readonly securitiesService: SecuritiesService,
|
||||
private readonly screenerService: ScreenerService,
|
||||
) {}
|
||||
|
||||
@Get('search')
|
||||
@ApiOperation({ summary: 'Поиск по инструментам' })
|
||||
@ -18,4 +24,12 @@ export class SecuritiesController {
|
||||
);
|
||||
return { data: results, meta: { cachedAt: null, fromCache: false } };
|
||||
}
|
||||
|
||||
@Get('screener')
|
||||
@ApiOperation({ summary: 'Фильтр ценных бумаг по параметрам' })
|
||||
@ApiOkResponse({ type: ScreenerResultDto })
|
||||
async screener(@Query(ValidationPipe) query: ScreenerQueryDto) {
|
||||
const result = await this.screenerService.screen(query);
|
||||
return { data: result, meta: { cachedAt: null, fromCache: false } };
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,11 +2,12 @@ import { Module } from '@nestjs/common';
|
||||
import { CacheModule } from '../cache/cache.module';
|
||||
import { SecuritiesController } from './securities.controller';
|
||||
import { SecuritiesService } from './securities.service';
|
||||
import { ScreenerService } from './screener.service';
|
||||
|
||||
@Module({
|
||||
imports: [CacheModule],
|
||||
controllers: [SecuritiesController],
|
||||
providers: [SecuritiesService],
|
||||
providers: [SecuritiesService, ScreenerService],
|
||||
exports: [SecuritiesService],
|
||||
})
|
||||
export class SecuritiesModule {}
|
||||
|
||||
@ -16,11 +16,12 @@ All endpoints require JWT authentication (`JwtAuthGuard`).
|
||||
|---|---|---|
|
||||
| `/api/v1/portfolios` | GET | List user's portfolios |
|
||||
| `/api/v1/portfolios` | POST | Create portfolio |
|
||||
| `/api/v1/portfolios/:id` | GET | Portfolio detail with enriched positions |
|
||||
| `/api/v1/portfolios/:id` | PATCH | Update portfolio (name, description, currency) |
|
||||
| `/api/v1/portfolios/:id` | GET | Portfolio detail with enriched positions and analytics summary |
|
||||
| `/api/v1/portfolios/:id/analytics` | GET | Detailed portfolio analytics with PnL |
|
||||
| `/api/v1/portfolios/:id/patch` | PATCH | Update portfolio (name, description, currency) |
|
||||
| `/api/v1/portfolios/:id` | DELETE | Delete portfolio (cascade deletes positions) |
|
||||
| `/api/v1/portfolios/:id/positions` | POST | Add position (auto-detects share/bond type) |
|
||||
| `/api/v1/portfolios/:id/positions/:posId` | PATCH | Update position (quantity, notes) |
|
||||
| `/api/v1/portfolios/:id/positions` | POST | Add position (accepts buyPrice, buyDate) |
|
||||
| `/api/v1/portfolios/:id/positions/:posId` | PATCH | Update position (quantity, buyPrice, buyDate, notes) |
|
||||
| `/api/v1/portfolios/:id/positions/:posId` | DELETE | Remove position |
|
||||
|
||||
## Domain Model
|
||||
@ -36,15 +37,29 @@ Position
|
||||
├── secid (MOEX security ID)
|
||||
├── type ("share" | "bond") — auto-detected from MOEX
|
||||
├── quantity (integer, >= 0)
|
||||
├── buyPrice (optional, % for bonds, RUB for shares)
|
||||
├── buyDate (optional)
|
||||
├── notes (free text)
|
||||
├── tags (JSON, stored but not displayed in Phase 1)
|
||||
├── tags (JSON)
|
||||
└── enriched: currentPrice, currentValue, weightPercent
|
||||
+ analytics: totalCost, pnl, pnlPercent, totalReturn, totalReturnPercent
|
||||
+ share: change, changePercent, shortName
|
||||
+ bond: yieldToMaturity, duration, couponValue, couponPercent,
|
||||
nextCouponDate, matDate, accruedInt, bid, offer,
|
||||
couponPeriod, bondType, offerDate
|
||||
```
|
||||
|
||||
## Analytics and PnL
|
||||
|
||||
The backend calculates performance metrics for each position and the portfolio as a whole:
|
||||
|
||||
- **Total Cost:** `buyPrice * quantity` (adjusted for bonds: `(buyPrice / 100) * faceValue * quantity`).
|
||||
- **Unrealized PnL:** `currentValue - totalCost`.
|
||||
- **PnL %:** `(PnL / totalCost) * 100`.
|
||||
- **Weighted Yield:** Portfolio-wide average yield based on position weights.
|
||||
|
||||
Analytics are available via the `/analytics` endpoint or as a `summary` object in the `/portfolios/:id` detail response.
|
||||
|
||||
## Security Type Detection
|
||||
|
||||
When adding a position, the backend fetches `getSecurityDescription(secid)` from MOEX. If `desc.group === 'stock_bonds'`, the position type is set to `bond`; otherwise `share`. This determines which enrichment path is used on read.
|
||||
|
||||
41
apps/docs/docs/backend/securities.md
Normal file
41
apps/docs/docs/backend/securities.md
Normal 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.
|
||||
@ -17,6 +17,7 @@ const sidebars: SidebarsConfig = {
|
||||
'backend/configuration',
|
||||
'backend/caching',
|
||||
'backend/moex-client',
|
||||
'backend/securities',
|
||||
'backend/portfolio',
|
||||
],
|
||||
},
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { request } from './client';
|
||||
import type { Portfolio, PortfolioDetail, Position } from './responses';
|
||||
import type { AnalyticsResponse, Portfolio, PortfolioDetail, Position } from './responses';
|
||||
|
||||
export function getPortfolios(): Promise<{
|
||||
data: Portfolio[];
|
||||
@ -45,7 +45,14 @@ export function deletePortfolio(
|
||||
|
||||
export function addPosition(
|
||||
portfolioId: number,
|
||||
data: { secid: string; quantity: number; notes?: string; tags?: string[] },
|
||||
data: {
|
||||
secid: string;
|
||||
quantity: number;
|
||||
buyPrice?: number;
|
||||
buyDate?: string;
|
||||
notes?: string;
|
||||
tags?: string[];
|
||||
},
|
||||
): Promise<{ data: Position; meta: { cachedAt: string | null; fromCache: boolean } }> {
|
||||
return request<Position>(`/api/v1/portfolios/${portfolioId}/positions`, undefined, {
|
||||
method: 'POST',
|
||||
@ -56,7 +63,13 @@ export function addPosition(
|
||||
export function updatePosition(
|
||||
portfolioId: number,
|
||||
positionId: number,
|
||||
data: { quantity?: number; notes?: string; tags?: string[] },
|
||||
data: {
|
||||
quantity?: number;
|
||||
buyPrice?: number;
|
||||
buyDate?: string;
|
||||
notes?: string;
|
||||
tags?: string[];
|
||||
},
|
||||
): Promise<{ data: Position; meta: { cachedAt: string | null; fromCache: boolean } }> {
|
||||
return request<Position>(`/api/v1/portfolios/${portfolioId}/positions/${positionId}`, undefined, {
|
||||
method: 'PATCH',
|
||||
@ -72,3 +85,9 @@ export function removePosition(
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
|
||||
export function getPortfolioAnalytics(
|
||||
portfolioId: number,
|
||||
): Promise<{ data: AnalyticsResponse; meta: { cachedAt: string | null; fromCache: boolean } }> {
|
||||
return request<AnalyticsResponse>(`/api/v1/portfolios/${portfolioId}/analytics`);
|
||||
}
|
||||
|
||||
@ -150,6 +150,7 @@ export interface Portfolio {
|
||||
|
||||
export interface PositionWithPrice {
|
||||
id: number;
|
||||
portfolioId: number;
|
||||
secid: string;
|
||||
shortName: string | null;
|
||||
type: 'share' | 'bond';
|
||||
@ -157,7 +158,15 @@ export interface PositionWithPrice {
|
||||
notes: string | null;
|
||||
tags: string[] | null;
|
||||
currentPrice: number | null;
|
||||
buyPrice: number | null;
|
||||
buyDate: string | null;
|
||||
totalCost: number | null;
|
||||
currentValue: number | null;
|
||||
pnl: number | null;
|
||||
pnlPercent: number | null;
|
||||
dividendIncome: number | null;
|
||||
totalReturn: number | null;
|
||||
totalReturnPercent: number | null;
|
||||
weightPercent: number;
|
||||
change?: number | null;
|
||||
changePercent?: number | null;
|
||||
@ -178,6 +187,7 @@ export interface PositionWithPrice {
|
||||
export interface PortfolioDetail extends Portfolio {
|
||||
positions: PositionWithPrice[];
|
||||
totalValue: number;
|
||||
analytics: PortfolioSummary;
|
||||
}
|
||||
|
||||
export interface Position {
|
||||
@ -190,3 +200,48 @@ export interface Position {
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface PortfolioSummary {
|
||||
totalInvested: number;
|
||||
totalValue: number;
|
||||
totalPnl: number;
|
||||
totalPnlPercent: number | null;
|
||||
totalDividends: number;
|
||||
totalReturn: number;
|
||||
totalReturnPercent: number | null;
|
||||
positionCount: number;
|
||||
weightedYield: number | null;
|
||||
}
|
||||
|
||||
export interface AnalyticsResponse {
|
||||
positions: PositionWithPrice[];
|
||||
summary: PortfolioSummary;
|
||||
}
|
||||
|
||||
export interface ScreenerItem {
|
||||
secid: string;
|
||||
shortName: string;
|
||||
isin: string;
|
||||
type: 'share' | 'bond';
|
||||
price: number | null;
|
||||
change: number | null;
|
||||
changePercent: number | null;
|
||||
volume: number;
|
||||
listLevel: number;
|
||||
capitalization: number | null;
|
||||
yieldToMaturity: number | null;
|
||||
duration: number | null;
|
||||
couponValue: number | null;
|
||||
couponPercent: number | null;
|
||||
accruedInt: number | null;
|
||||
matDate: string | null;
|
||||
bondType: string | null;
|
||||
}
|
||||
|
||||
export interface ScreenerResult {
|
||||
items: ScreenerItem[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
40
apps/frontend/src/api/screener.ts
Normal file
40
apps/frontend/src/api/screener.ts
Normal 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);
|
||||
}
|
||||
@ -46,6 +46,18 @@ export function Layout() {
|
||||
>
|
||||
Портфели
|
||||
</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 }}>
|
||||
{isAuthenticated ? (
|
||||
<>
|
||||
|
||||
68
apps/frontend/src/components/portfolios/AnalyticsSummary.tsx
Normal file
68
apps/frontend/src/components/portfolios/AnalyticsSummary.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -4,20 +4,32 @@ import type { PositionWithPrice } from '../../api/responses';
|
||||
|
||||
interface Props {
|
||||
position: PositionWithPrice;
|
||||
onUpdate: (data: { quantity?: number }) => void;
|
||||
onUpdate: (data: { quantity?: number; buyPrice?: number }) => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
export function BondPositionRow({ position, onUpdate, onDelete }: Props) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [editingQty, setEditingQty] = useState(false);
|
||||
const [editingPrice, setEditingPrice] = useState(false);
|
||||
const [qty, setQty] = useState(String(position.quantity));
|
||||
const [price, setPrice] = useState(String(position.buyPrice ?? ''));
|
||||
|
||||
function handleSave() {
|
||||
function handleSaveQty() {
|
||||
const num = parseInt(qty, 10);
|
||||
if (!isNaN(num) && num >= 0 && num !== position.quantity) {
|
||||
onUpdate({ quantity: num });
|
||||
}
|
||||
setEditing(false);
|
||||
setEditingQty(false);
|
||||
}
|
||||
|
||||
function handleSavePrice() {
|
||||
const num = parseFloat(price);
|
||||
if (!isNaN(num) && num >= 0 && num !== position.buyPrice) {
|
||||
onUpdate({ buyPrice: num });
|
||||
} else if (price === '' && position.buyPrice !== null) {
|
||||
onUpdate({ buyPrice: undefined });
|
||||
}
|
||||
setEditingPrice(false);
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string | null | undefined): string {
|
||||
@ -51,14 +63,14 @@ export function BondPositionRow({ position, onUpdate, onDelete }: Props) {
|
||||
{position.bondType ? <span>{position.bondType}</span> : '—'}
|
||||
</td>
|
||||
<td style={{ padding: '8px 12px' }}>
|
||||
{editing ? (
|
||||
{editingQty ? (
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
value={qty}
|
||||
onChange={(e) => setQty(e.target.value)}
|
||||
onBlur={handleSave}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleSave()}
|
||||
onBlur={handleSaveQty}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleSaveQty()}
|
||||
autoFocus
|
||||
style={{
|
||||
width: 80,
|
||||
@ -72,7 +84,7 @@ export function BondPositionRow({ position, onUpdate, onDelete }: Props) {
|
||||
<span
|
||||
onClick={() => {
|
||||
setQty(String(position.quantity));
|
||||
setEditing(true);
|
||||
setEditingQty(true);
|
||||
}}
|
||||
style={{ cursor: 'pointer', padding: '4px 0', display: 'inline-block' }}
|
||||
>
|
||||
@ -80,6 +92,42 @@ export function BondPositionRow({ position, onUpdate, onDelete }: Props) {
|
||||
</span>
|
||||
)}
|
||||
</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' }}>
|
||||
{formatPct(position.currentPrice)}
|
||||
</td>
|
||||
@ -108,6 +156,43 @@ export function BondPositionRow({ position, onUpdate, onDelete }: Props) {
|
||||
})
|
||||
: '—'}
|
||||
</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' }}>
|
||||
{formatDate(position.nextCouponDate)}
|
||||
</td>
|
||||
|
||||
@ -3,7 +3,10 @@ import type { PositionWithPrice } from '../../api/responses';
|
||||
|
||||
interface Props {
|
||||
positions: PositionWithPrice[];
|
||||
onUpdatePosition: (positionId: number, data: { quantity?: number }) => void;
|
||||
onUpdatePosition: (
|
||||
positionId: number,
|
||||
data: { quantity?: number; buyPrice?: number; buyDate?: string },
|
||||
) => void;
|
||||
onDeletePosition: (positionId: number) => void;
|
||||
}
|
||||
|
||||
@ -63,6 +66,17 @@ export function BondPositionTable({ positions, onUpdatePosition, onDeletePositio
|
||||
>
|
||||
Количество
|
||||
</th>
|
||||
<th
|
||||
style={{
|
||||
textAlign: 'right',
|
||||
padding: '8px 12px',
|
||||
fontWeight: 600,
|
||||
fontSize: 12,
|
||||
color: 'var(--color-text-secondary)',
|
||||
}}
|
||||
>
|
||||
Цена пок.
|
||||
</th>
|
||||
<th
|
||||
style={{
|
||||
textAlign: 'right',
|
||||
@ -162,6 +176,39 @@ export function BondPositionTable({ positions, onUpdatePosition, onDeletePositio
|
||||
>
|
||||
НКД
|
||||
</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
|
||||
style={{
|
||||
textAlign: 'right',
|
||||
|
||||
@ -4,20 +4,32 @@ import type { PositionWithPrice } from '../../api/responses';
|
||||
|
||||
interface Props {
|
||||
position: PositionWithPrice;
|
||||
onUpdate: (data: { quantity?: number }) => void;
|
||||
onUpdate: (data: { quantity?: number; buyPrice?: number }) => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
export function SharePositionRow({ position, onUpdate, onDelete }: Props) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [editingQty, setEditingQty] = useState(false);
|
||||
const [editingPrice, setEditingPrice] = useState(false);
|
||||
const [qty, setQty] = useState(String(position.quantity));
|
||||
const [price, setPrice] = useState(String(position.buyPrice ?? ''));
|
||||
|
||||
function handleSave() {
|
||||
function handleSaveQty() {
|
||||
const num = parseInt(qty, 10);
|
||||
if (!isNaN(num) && num >= 0 && num !== position.quantity) {
|
||||
onUpdate({ quantity: num });
|
||||
}
|
||||
setEditing(false);
|
||||
setEditingQty(false);
|
||||
}
|
||||
|
||||
function handleSavePrice() {
|
||||
const num = parseFloat(price);
|
||||
if (!isNaN(num) && num >= 0 && num !== position.buyPrice) {
|
||||
onUpdate({ buyPrice: num });
|
||||
} else if (price === '' && position.buyPrice !== null) {
|
||||
onUpdate({ buyPrice: undefined }); // Or handle null if backend supports it
|
||||
}
|
||||
setEditingPrice(false);
|
||||
}
|
||||
|
||||
return (
|
||||
@ -31,14 +43,14 @@ export function SharePositionRow({ position, onUpdate, onDelete }: Props) {
|
||||
{position.shortName ?? '—'}
|
||||
</td>
|
||||
<td style={{ padding: '8px 12px' }}>
|
||||
{editing ? (
|
||||
{editingQty ? (
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
value={qty}
|
||||
onChange={(e) => setQty(e.target.value)}
|
||||
onBlur={handleSave}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleSave()}
|
||||
onBlur={handleSaveQty}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleSaveQty()}
|
||||
autoFocus
|
||||
style={{
|
||||
width: 80,
|
||||
@ -52,7 +64,7 @@ export function SharePositionRow({ position, onUpdate, onDelete }: Props) {
|
||||
<span
|
||||
onClick={() => {
|
||||
setQty(String(position.quantity));
|
||||
setEditing(true);
|
||||
setEditingQty(true);
|
||||
}}
|
||||
style={{ cursor: 'pointer', padding: '4px 0', display: 'inline-block' }}
|
||||
>
|
||||
@ -60,6 +72,47 @@ export function SharePositionRow({ position, onUpdate, onDelete }: Props) {
|
||||
</span>
|
||||
)}
|
||||
</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' }}>
|
||||
{position.currentPrice !== null
|
||||
? position.currentPrice.toLocaleString('ru-RU', {
|
||||
@ -89,6 +142,43 @@ export function SharePositionRow({ position, onUpdate, onDelete }: Props) {
|
||||
})
|
||||
: '—'}
|
||||
</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' }}>
|
||||
{position.weightPercent.toFixed(1)}%
|
||||
</td>
|
||||
|
||||
@ -3,7 +3,10 @@ import type { PositionWithPrice } from '../../api/responses';
|
||||
|
||||
interface Props {
|
||||
positions: PositionWithPrice[];
|
||||
onUpdatePosition: (positionId: number, data: { quantity?: number }) => void;
|
||||
onUpdatePosition: (
|
||||
positionId: number,
|
||||
data: { quantity?: number; buyPrice?: number; buyDate?: string },
|
||||
) => void;
|
||||
onDeletePosition: (positionId: number) => void;
|
||||
}
|
||||
|
||||
@ -52,6 +55,17 @@ export function SharePositionTable({ positions, onUpdatePosition, onDeletePositi
|
||||
>
|
||||
Количество
|
||||
</th>
|
||||
<th
|
||||
style={{
|
||||
textAlign: 'right',
|
||||
padding: '8px 12px',
|
||||
fontWeight: 600,
|
||||
fontSize: 12,
|
||||
color: 'var(--color-text-secondary)',
|
||||
}}
|
||||
>
|
||||
Цена пок.
|
||||
</th>
|
||||
<th
|
||||
style={{
|
||||
textAlign: 'right',
|
||||
@ -85,6 +99,39 @@ export function SharePositionTable({ positions, onUpdatePosition, onDeletePositi
|
||||
>
|
||||
Стоимость
|
||||
</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
|
||||
style={{
|
||||
textAlign: 'right',
|
||||
|
||||
108
apps/frontend/src/components/screener/FilterPanel.tsx
Normal file
108
apps/frontend/src/components/screener/FilterPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
53
apps/frontend/src/components/screener/FilterPanelBond.tsx
Normal file
53
apps/frontend/src/components/screener/FilterPanelBond.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
49
apps/frontend/src/components/screener/FilterPanelShare.tsx
Normal file
49
apps/frontend/src/components/screener/FilterPanelShare.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
171
apps/frontend/src/components/screener/ScreenerTable.tsx
Normal file
171
apps/frontend/src/components/screener/ScreenerTable.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
17
apps/frontend/src/hooks/usePortfolioAnalytics.ts
Normal file
17
apps/frontend/src/hooks/usePortfolioAnalytics.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
@ -6,8 +6,14 @@ export function usePositionMutations(portfolioId: number) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const add = useMutation({
|
||||
mutationFn: (data: { secid: string; quantity: number; notes?: string; tags?: string[] }) =>
|
||||
addPosition(portfolioId, data),
|
||||
mutationFn: (data: {
|
||||
secid: string;
|
||||
quantity: number;
|
||||
buyPrice?: number;
|
||||
buyDate?: string;
|
||||
notes?: string;
|
||||
tags?: string[];
|
||||
}) => addPosition(portfolioId, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['portfolio', portfolioId] });
|
||||
},
|
||||
@ -19,7 +25,13 @@ export function usePositionMutations(portfolioId: number) {
|
||||
data,
|
||||
}: {
|
||||
positionId: number;
|
||||
data: { quantity?: number; notes?: string; tags?: string[] };
|
||||
data: {
|
||||
quantity?: number;
|
||||
buyPrice?: number;
|
||||
buyDate?: string;
|
||||
notes?: string;
|
||||
tags?: string[];
|
||||
};
|
||||
}) => updatePosition(portfolioId, positionId, data),
|
||||
onMutate: async ({ positionId, data }) => {
|
||||
await queryClient.cancelQueries({ queryKey: ['portfolio', portfolioId] });
|
||||
@ -33,7 +45,12 @@ export function usePositionMutations(portfolioId: number) {
|
||||
...old,
|
||||
positions: old.positions.map((p: any) =>
|
||||
p.id === positionId
|
||||
? { ...p, ...(data.quantity !== undefined ? { quantity: data.quantity } : {}) }
|
||||
? {
|
||||
...p,
|
||||
...(data.quantity !== undefined ? { quantity: data.quantity } : {}),
|
||||
...(data.buyPrice !== undefined ? { buyPrice: data.buyPrice } : {}),
|
||||
...(data.buyDate !== undefined ? { buyDate: data.buyDate } : {}),
|
||||
}
|
||||
: p,
|
||||
),
|
||||
};
|
||||
|
||||
127
apps/frontend/src/hooks/useScreener.ts
Normal file
127
apps/frontend/src/hooks/useScreener.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@ -5,6 +5,7 @@ import { usePortfolioMutations } from '../../hooks/usePortfolioMutations';
|
||||
import { usePositionMutations } from '../../hooks/usePositionMutations';
|
||||
import { PortfolioForm } from '../../components/portfolios/PortfolioForm';
|
||||
import { PortfolioSummary } from '../../components/portfolios/PortfolioSummary';
|
||||
import { AnalyticsSummary } from '../../components/portfolios/AnalyticsSummary';
|
||||
import { SharePositionTable } from '../../components/portfolios/SharePositionTable';
|
||||
import { BondPositionTable } from '../../components/portfolios/BondPositionTable';
|
||||
|
||||
@ -24,6 +25,8 @@ export function PortfolioDetailPage() {
|
||||
const [showAddForm, setShowAddForm] = useState(false);
|
||||
const [newSecid, setNewSecid] = useState('');
|
||||
const [newQty, setNewQty] = useState('1');
|
||||
const [newPrice, setNewPrice] = useState('');
|
||||
const [newDate, setNewDate] = useState(new Date().toISOString().split('T')[0]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
@ -53,12 +56,15 @@ export function PortfolioDetailPage() {
|
||||
{
|
||||
secid: newSecid.trim().toUpperCase(),
|
||||
quantity: parseInt(newQty, 10),
|
||||
buyPrice: newPrice ? parseFloat(newPrice) : undefined,
|
||||
buyDate: newDate || undefined,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
setShowAddForm(false);
|
||||
setNewSecid('');
|
||||
setNewQty('1');
|
||||
setNewPrice('');
|
||||
},
|
||||
},
|
||||
);
|
||||
@ -135,6 +141,8 @@ export function PortfolioDetailPage() {
|
||||
|
||||
<PortfolioSummary portfolio={portfolio} />
|
||||
|
||||
{portfolio.analytics && <AnalyticsSummary summary={portfolio.analytics} />}
|
||||
|
||||
<div
|
||||
style={{
|
||||
marginTop: 24,
|
||||
@ -209,6 +217,42 @@ export function PortfolioDetailPage() {
|
||||
}}
|
||||
/>
|
||||
</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
|
||||
onClick={handleAddPosition}
|
||||
disabled={addPosition.isPending}
|
||||
|
||||
59
apps/frontend/src/pages/screener/ScreenerPage.tsx
Normal file
59
apps/frontend/src/pages/screener/ScreenerPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -9,6 +9,7 @@ import { ProfilePage } from './pages/ProfilePage';
|
||||
import { ProtectedRoute } from './components/ProtectedRoute';
|
||||
import { PortfoliosListPage } from './pages/portfolios/PortfoliosListPage';
|
||||
import { PortfolioDetailPage } from './pages/portfolios/PortfolioDetailPage';
|
||||
import { ScreenerPage } from './pages/screener/ScreenerPage';
|
||||
|
||||
export function AppRoutes() {
|
||||
return (
|
||||
@ -17,6 +18,7 @@ export function AppRoutes() {
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/stocks/:secid" element={<StockPage />} />
|
||||
<Route path="/bonds/:secid" element={<BondPage />} />
|
||||
<Route path="/screener" element={<ScreenerPage />} />
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/register" element={<RegisterPage />} />
|
||||
<Route
|
||||
|
||||
@ -18,6 +18,10 @@
|
||||
--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 {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: var(--color-bg);
|
||||
|
||||
1037
docs/superpowers/plans/2026-06-14-portfolio-analytics.md
Normal file
1037
docs/superpowers/plans/2026-06-14-portfolio-analytics.md
Normal file
File diff suppressed because it is too large
Load Diff
1614
docs/superpowers/plans/2026-06-14-security-screener.md
Normal file
1614
docs/superpowers/plans/2026-06-14-security-screener.md
Normal file
File diff suppressed because it is too large
Load Diff
441
docs/superpowers/specs/2026-06-14-portfolio-analytics-design.md
Normal file
441
docs/superpowers/specs/2026-06-14-portfolio-analytics-design.md
Normal 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] Обратная совместимость с существующими данными гарантирована
|
||||
584
docs/superpowers/specs/2026-06-14-security-screener-design.md
Normal file
584
docs/superpowers/specs/2026-06-14-security-screener-design.md
Normal 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)
|
||||
Loading…
x
Reference in New Issue
Block a user