moex-vibe/apps/backend/src/modules/portfolio/portfolio.service.ts
Sergey Krylov 60e456fbb5 perf: reduce portfolio enrichment from 298 to 2 MOEX API calls (-99.3%)
Three optimizations:
1. Merge getBondData + getBondMarketData into single batch call
   (same endpoint, parse both tables)
2. Remove redundant getSecurityDescription for shortName
   (shortName already in market data responses)
3. Batch by market: 1 call for all shares, 1 call for all bonds
   (instead of N individual calls)

Before: 298 API calls for 104 positions -> ~29.8s
After:  2 API calls for 104 positions -> ~0.3s
2026-06-14 13:19:22 +03:00

309 lines
10 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';
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<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),
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<Map<string, MoexShareMarketData>> {
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<Map<string, MoexBondPositionData>> {
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,
};
}
}