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
512 lines
17 KiB
TypeScript
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 };
|
|
}
|
|
}
|