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
309 lines
10 KiB
TypeScript
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,
|
|
};
|
|
}
|
|
}
|