moex-vibe/apps/backend/src/modules/portfolio/portfolio.service.ts
Sergey Krylov 96f003852d
Some checks failed
CI / lint (pull_request) Failing after 1m48s
CI / test (pull_request) Successful in 1m47s
CI / build (pull_request) Successful in 1m53s
CI / lint (push) Failing after 1m59s
CI / test (push) Successful in 1m56s
CI / build (push) Successful in 1m50s
feat: implement portfolio analytics, PnL calculation, and security screener
- 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
2026-06-14 15:59:25 +03:00

512 lines
17 KiB
TypeScript

import {
Injectable,
NotFoundException,
BadRequestException,
ForbiddenException,
} from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { MoexClientService } from '../moex-client/moex-client.service';
import { CacheService } from '../cache/cache.service';
import type { MoexShareMarketData, MoexBondPositionData } from '../moex-client/moex-client.types';
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;
duration?: number | null;
couponValue?: number | null;
couponPercent?: number | null;
nextCouponDate?: string | null;
matDate?: string | null;
accruedInt?: number | null;
bid?: number | null;
offer?: number | null;
couponPeriod?: number | null;
bondType?: string | null;
offerDate?: string | null;
}
@Injectable()
export class PortfolioService {
constructor(
private readonly prisma: PrismaService,
private readonly moexClient: MoexClientService,
private readonly cache: CacheService,
) {}
async create(userId: number, dto: CreatePortfolioDto) {
return this.prisma.portfolio.create({
data: {
userId,
name: dto.name,
description: dto.description ?? null,
currency: dto.currency ?? 'RUB',
},
});
}
async findAll(userId: number) {
const portfolios = await this.prisma.portfolio.findMany({
where: { userId },
include: { positions: true },
orderBy: { updatedAt: 'desc' },
});
const allPositions = portfolios.flatMap((p) => p.positions);
if (allPositions.length === 0) {
return portfolios.map((p) => ({
id: p.id,
name: p.name,
description: p.description,
currency: p.currency,
createdAt: p.createdAt.toISOString(),
updatedAt: p.updatedAt.toISOString(),
totalValue: 0,
positionCount: 0,
shareCount: 0,
bondCount: 0,
}));
}
const enrichedPositions = await this.enrichPositions(allPositions);
const posByPortfolioId = new Map<number, (typeof enrichedPositions)[number][]>();
for (let i = 0; i < enrichedPositions.length; i++) {
const pfId = allPositions[i].portfolioId;
if (!posByPortfolioId.has(pfId)) {
posByPortfolioId.set(pfId, []);
}
posByPortfolioId.get(pfId)!.push(enrichedPositions[i]);
}
return portfolios.map((p) => {
const positions = posByPortfolioId.get(p.id) ?? [];
const totalValue = positions.reduce((sum, pos) => sum + (pos.currentValue ?? 0), 0);
return {
id: p.id,
name: p.name,
description: p.description,
currency: p.currency,
createdAt: p.createdAt.toISOString(),
updatedAt: p.updatedAt.toISOString(),
totalValue: Math.round(totalValue * 100) / 100,
positionCount: positions.length,
shareCount: positions.filter((pos) => pos.type === 'share').length,
bondCount: positions.filter((pos) => pos.type === 'bond').length,
};
});
}
async findOne(userId: number, id: number) {
const portfolio = await this.prisma.portfolio.findUnique({
where: { id },
include: { positions: true },
});
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, id);
const totalValue = positionsWithPrices.reduce((sum, p) => sum + (p.currentValue ?? 0), 0);
const positionsWithWeights: EnrichedPosition[] = positionsWithPrices.map((p) => {
const weightPercent = totalValue > 0 ? ((p.currentValue ?? 0) / totalValue) * 100 : 0;
return {
...p,
weightPercent: Math.round(weightPercent * 2) / 2,
};
});
const analytics = await this.getAnalytics(userId, id);
return {
id: portfolio.id,
name: portfolio.name,
description: portfolio.description,
currency: portfolio.currency,
createdAt: portfolio.createdAt.toISOString(),
updatedAt: portfolio.updatedAt.toISOString(),
positions: positionsWithWeights,
totalValue: Math.round(totalValue * 100) / 100,
analytics: analytics.summary,
};
}
async update(userId: number, id: number, dto: UpdatePortfolioDto) {
const portfolio = await this.prisma.portfolio.findUnique({ where: { id } });
if (!portfolio) throw new NotFoundException(`Portfolio ${id} not found`);
if (portfolio.userId !== userId) throw new ForbiddenException('Access denied');
return this.prisma.portfolio.update({
where: { id },
data: {
...(dto.name !== undefined && { name: dto.name }),
...(dto.description !== undefined && { description: dto.description }),
...(dto.currency !== undefined && { currency: dto.currency }),
},
});
}
async remove(userId: number, id: number) {
const portfolio = await this.prisma.portfolio.findUnique({ where: { id } });
if (!portfolio) throw new NotFoundException(`Portfolio ${id} not found`);
if (portfolio.userId !== userId) throw new ForbiddenException('Access denied');
await this.prisma.portfolio.delete({ where: { id } });
}
async addPosition(userId: number, portfolioId: number, dto: AddPositionDto) {
const portfolio = await this.prisma.portfolio.findUnique({
where: { id: portfolioId },
include: { positions: true },
});
if (!portfolio) throw new NotFoundException(`Portfolio ${portfolioId} not found`);
if (portfolio.userId !== userId) throw new ForbiddenException('Access denied');
const exists = portfolio.positions.find((p) => p.secid === dto.secid);
if (exists)
throw new BadRequestException(`Position ${dto.secid} already exists in this portfolio`);
if (dto.quantity === 0) throw new BadRequestException('Quantity must be greater than 0');
const desc = await this.moexClient.getSecurityDescription(dto.secid);
if (!desc) throw new BadRequestException(`Security ${dto.secid} not found in MOEX`);
const type = desc.group === 'stock_bonds' ? 'bond' : 'share';
return this.prisma.position.create({
data: {
portfolioId,
secid: dto.secid,
type,
quantity: dto.quantity,
buyPrice: dto.buyPrice ?? null,
buyDate: dto.buyDate ? new Date(dto.buyDate) : null,
notes: dto.notes ?? null,
tags: dto.tags ? JSON.stringify(dto.tags) : null,
},
});
}
async updatePosition(
userId: number,
portfolioId: number,
positionId: number,
dto: UpdatePositionDto,
) {
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('Access denied');
const position = await this.prisma.position.findUnique({ where: { id: positionId } });
if (!position || position.portfolioId !== portfolioId) {
throw new NotFoundException(`Position ${positionId} not found`);
}
return this.prisma.position.update({
where: { id: positionId },
data: {
...(dto.quantity !== undefined && { quantity: dto.quantity }),
...(dto.buyPrice !== undefined && { buyPrice: dto.buyPrice }),
...(dto.buyDate !== undefined && { buyDate: new Date(dto.buyDate) }),
...(dto.notes !== undefined && { notes: dto.notes }),
...(dto.tags !== undefined && { tags: dto.tags ? JSON.stringify(dto.tags) : null }),
},
});
}
async removePosition(userId: number, portfolioId: number, positionId: number) {
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('Access denied');
const position = await this.prisma.position.findUnique({ where: { id: positionId } });
if (!position || position.portfolioId !== portfolioId) {
throw new NotFoundException(`Position ${positionId} not found`);
}
await this.prisma.position.delete({ where: { id: positionId } });
}
private async enrichPositions(
positions: {
id: number;
portfolioId: number;
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');
const shareSecids = [...new Set(sharePositions.map((p) => p.secid))].sort();
const bondSecids = [...new Set(bondPositions.map((p) => p.secid))].sort();
const [shareDataBySecid, bondDataBySecid] = await Promise.all([
this.fetchShareBatch(shareSecids, portfolioId),
this.fetchBondBatch(bondSecids, portfolioId),
]);
const enriched: EnrichedPosition[] = [];
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') {
enriched.push(this.buildBondPosition(pos, base, bondDataBySecid.get(pos.secid)));
} else {
enriched.push(this.buildSharePosition(pos, base, shareDataBySecid.get(pos.secid)));
}
}
return enriched;
}
private async fetchShareBatch(
secids: string[],
portfolioId?: number,
): Promise<Map<string, MoexShareMarketData>> {
if (secids.length === 0) return new Map();
const cacheKey = portfolioId ? `pf:${portfolioId}:${secids.join(',')}` : secids.join(',');
const { data } = await this.cache.getOrFetch(
'batchdata',
['shares', cacheKey],
() => this.moexClient.getShareMarketDataBatch(secids),
'marketDataTtl',
);
return new Map(data.map((d) => [d.secid, d]));
}
private async fetchBondBatch(
secids: string[],
portfolioId?: number,
): Promise<Map<string, MoexBondPositionData>> {
if (secids.length === 0) return new Map();
const cacheKey = portfolioId ? `pf:${portfolioId}:${secids.join(',')}` : secids.join(',');
const { data } = await this.cache.getOrFetch(
'batchdata',
['bonds', cacheKey],
() => this.moexClient.getBondPositionDataBatch(secids),
'marketDataTtl',
);
return new Map(data.map((d) => [d.secid, d]));
}
private buildSharePosition(
pos: {
id: number;
secid: string;
quantity: number;
buyPrice: number | null;
buyDate: Date | null;
},
base: EnrichedPosition,
data: MoexShareMarketData | undefined,
): EnrichedPosition {
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,
change: data.lastChange,
changePercent: data.lastChangePrcnt,
totalCost,
currentValue,
pnl,
pnlPercent,
dividendIncome,
totalReturn,
totalReturnPercent,
};
}
private buildBondPosition(
pos: {
id: number;
secid: string;
quantity: number;
buyPrice: number | null;
buyDate: Date | null;
},
base: EnrichedPosition,
data: MoexBondPositionData | undefined,
): EnrichedPosition {
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,
yieldToMaturity: data.yieldToMaturity,
duration: data.duration,
couponValue: data.couponValue,
couponPercent: data.couponPercent,
nextCouponDate: data.nextCouponDate,
matDate: data.matDate,
accruedInt: data.accruedInt,
bid: data.bid,
offer: data.offer,
couponPeriod: data.couponPeriod,
bondType: data.bondType,
offerDate: data.offerDate,
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 };
}
}