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'; export interface EnrichedPosition { id: number; secid: string; shortName: string | null; type: string; quantity: number; notes: string | null; tags: string[] | null; currentPrice: number | null; currentValue: number | null; weightPercent: number; 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) { return this.prisma.portfolio.findMany({ where: { userId }, orderBy: { updatedAt: 'desc' }, }); } 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); 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, }; }); 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, }; } 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, 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.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; notes: string | null; tags: string | null; }[], ): 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), this.fetchBondBatch(bondSecids), ]); const enriched: EnrichedPosition[] = []; for (const pos of positions) { const base = { id: pos.id, secid: pos.secid, shortName: null as string | null, type: pos.type, quantity: pos.quantity, notes: pos.notes, tags: pos.tags ? JSON.parse(pos.tags) : null, weightPercent: 0, currentPrice: null as number | null, currentValue: 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[]): Promise> { if (secids.length === 0) return new Map(); const cacheKey = 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[]): Promise> { if (secids.length === 0) return new Map(); const cacheKey = 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 }, base: EnrichedPosition, data: MoexShareMarketData | undefined, ): EnrichedPosition { if (!data) return { ...base, currentPrice: null, currentValue: null }; return { ...base, shortName: data.shortName, currentPrice: data.last, change: data.lastChange, changePercent: data.lastChangePrcnt, currentValue: data.last !== null ? data.last * pos.quantity : null, }; } private buildBondPosition( pos: { id: number; secid: string; quantity: number }, base: EnrichedPosition, data: MoexBondPositionData | undefined, ): EnrichedPosition { if (!data) return { ...base, currentPrice: null, currentValue: null }; const currentValue = data.price !== null ? (data.price / 100) * data.faceValue * pos.quantity : null; return { ...base, shortName: data.shortName, currentPrice: data.price, 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, currentValue, }; } }