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(); 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 { 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> { 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> { 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 { const positions = await this.prisma.position.findMany({ where: { portfolioId } }); if (positions.length === 0) return []; return this.enrichPositions(positions); } async getAnalytics(userId: number, portfolioId: number): Promise { const portfolio = await this.prisma.portfolio.findUnique({ where: { id: portfolioId } }); if (!portfolio) throw new NotFoundException(`Portfolio ${portfolioId} not found`); if (portfolio.userId !== userId) throw new ForbiddenException(); const enrichedPositions = await this.getPositionsWithPrices(portfolioId); const totalInvested = enrichedPositions.reduce((sum, p) => sum + (p.totalCost ?? 0), 0); const totalValue = enrichedPositions.reduce((sum, p) => sum + (p.currentValue ?? 0), 0); const totalPnl = enrichedPositions.reduce((sum, p) => sum + (p.pnl ?? 0), 0); const totalDividends = enrichedPositions.reduce((sum, p) => sum + (p.dividendIncome ?? 0), 0); const totalReturn = totalPnl + totalDividends; const totalPnlPercent = totalInvested > 0 ? (totalPnl / totalInvested) * 100 : null; const totalReturnPercent = totalInvested > 0 ? (totalReturn / totalInvested) * 100 : null; const positionCount = enrichedPositions.length; const weightedYield = totalInvested > 0 ? enrichedPositions.reduce( (sum, p) => sum + ((p.pnlPercent ?? 0) * (p.totalCost ?? 0)) / totalInvested, 0, ) : null; const summary = { totalInvested, totalValue, totalPnl, totalPnlPercent, totalDividends, totalReturn, totalReturnPercent, positionCount, weightedYield, }; return { positions: enrichedPositions, summary }; } }