moex-vibe/apps/backend/src/modules/portfolio/portfolio.service.spec.ts
Sergey Krylov 96f003852d
Some checks failed
CI / lint (pull_request) Failing after 1m48s
CI / test (pull_request) Successful in 1m47s
CI / build (pull_request) Successful in 1m53s
CI / lint (push) Failing after 1m59s
CI / test (push) Successful in 1m56s
CI / build (push) Successful in 1m50s
feat: implement portfolio analytics, PnL calculation, and security screener
- 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
2026-06-14 15:59:25 +03:00

540 lines
18 KiB
TypeScript

import { Test, TestingModule } from '@nestjs/testing';
import { ConfigModule } from '@nestjs/config';
import { PortfolioService } from './portfolio.service';
import { PrismaService } from '../prisma/prisma.service';
import { MoexClientService } from '../moex-client/moex-client.service';
import { CacheService } from '../cache/cache.service';
import configuration from '../../config/configuration';
import { ForbiddenException, NotFoundException } from '@nestjs/common';
describe('PortfolioService', () => {
let service: PortfolioService;
let prisma: PrismaService;
let moexClient: MoexClientService;
let module: TestingModule;
const mockPortfolio = (overrides: Record<string, unknown> = {}) => ({
id: 1,
userId: 1,
name: 'Test Portfolio',
description: 'A test portfolio',
currency: 'RUB',
targets: null,
createdAt: new Date('2026-01-01'),
updatedAt: new Date('2026-06-14'),
...overrides,
});
const mockPosition = (overrides: Record<string, unknown> = {}) => ({
id: 1,
portfolioId: 1,
secid: 'SBER',
type: 'share',
quantity: 10,
buyPrice: null,
buyDate: null,
notes: null,
tags: null,
createdAt: new Date('2026-01-01'),
updatedAt: new Date('2026-06-14'),
...overrides,
});
beforeAll(async () => {
module = await Test.createTestingModule({
imports: [ConfigModule.forRoot({ load: [configuration] })],
providers: [
PortfolioService,
{
provide: PrismaService,
useValue: {
portfolio: {
findMany: vi.fn(),
findUnique: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
},
position: {
findMany: vi.fn(),
findUnique: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
},
},
},
{
provide: MoexClientService,
useValue: {
getShareMarketDataBatch: vi.fn(),
getBondPositionDataBatch: vi.fn(),
getSecurityDescription: vi.fn(),
},
},
{
provide: CacheService,
useValue: {
getOrFetch: vi.fn(),
},
},
],
}).compile();
service = module.get<PortfolioService>(PortfolioService);
prisma = module.get<PrismaService>(PrismaService);
moexClient = module.get<MoexClientService>(MoexClientService);
});
beforeEach(() => {
vi.clearAllMocks();
});
describe('findAll', () => {
it('should return empty array when user has no portfolios', async () => {
vi.mocked(prisma.portfolio.findMany).mockResolvedValue([]);
const result = await service.findAll(1);
expect(result).toEqual([]);
});
it('should return portfolios with zero aggregates when no positions exist', async () => {
vi.mocked(prisma.portfolio.findMany).mockResolvedValue([
mockPortfolio({ positions: [] }) as any,
]);
const result = await service.findAll(1);
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({
name: 'Test Portfolio',
totalValue: 0,
positionCount: 0,
shareCount: 0,
bondCount: 0,
});
});
it('should enrich portfolios with market data from batch MOEX call and compute PnL', async () => {
const sharePosition = mockPosition({
id: 1,
secid: 'SBER',
type: 'share',
quantity: 10,
buyPrice: 230,
buyDate: new Date('2026-03-01'),
});
const bondPosition = mockPosition({
id: 2,
portfolioId: 1,
secid: 'SU26238RMFS5',
type: 'bond',
quantity: 5,
buyPrice: 950,
buyDate: new Date('2026-03-01'),
});
vi.mocked(prisma.portfolio.findMany).mockResolvedValue([
mockPortfolio({ positions: [sharePosition, bondPosition] }) as any,
]);
vi.mocked(moexClient.getShareMarketDataBatch).mockResolvedValue([
{ secid: 'SBER', shortName: 'Sberbank', last: 250, lastChange: 5, lastChangePrcnt: 2 },
] as any);
vi.mocked(moexClient.getBondPositionDataBatch).mockResolvedValue([
{
secid: 'SU26238RMFS5',
shortName: 'OFZ 26238',
price: 98.5,
faceValue: 1000,
},
] as any);
const cacheMock = module.get(CacheService) as { getOrFetch: ReturnType<typeof vi.fn> };
cacheMock.getOrFetch.mockImplementation(
async (_prefix: string, _key: string[], fetchFn: () => Promise<any>) => ({
data: await fetchFn(),
fromCache: false,
cachedAt: null,
}),
);
const result = await service.findAll(1);
expect(result).toHaveLength(1);
expect(result[0].name).toBe('Test Portfolio');
expect(result[0].positionCount).toBe(2);
expect(result[0].shareCount).toBe(1);
expect(result[0].bondCount).toBe(1);
// SBER: 250 * 10 = 2500, OFZ: (98.5 / 100) * 1000 * 5 = 4925
expect(result[0].totalValue).toBe(7425);
// Verify PnL for SBER share
// totalCost = 230 * 10 = 2300
// currentValue = 250 * 10 = 2500
// pnl = 2500 - 2300 = 200
// pnlPercent = 200 / 2300 * 100 ≈ 8.70
// Verify via findOne which gives enriched positions
});
it('should propagate MOEX errors to the caller', async () => {
vi.mocked(prisma.portfolio.findMany).mockResolvedValue([
mockPortfolio({ positions: [mockPosition()] }) as any,
]);
const cacheMock = module.get(CacheService) as { getOrFetch: ReturnType<typeof vi.fn> };
cacheMock.getOrFetch.mockRejectedValue(new Error('MOEX down'));
await expect(service.findAll(1)).rejects.toThrow('MOEX down');
});
it('should only return portfolios belonging to the requesting user', async () => {
vi.mocked(prisma.portfolio.findMany).mockResolvedValue([]);
await service.findAll(2);
expect(prisma.portfolio.findMany).toHaveBeenCalledWith({
where: { userId: 2 },
include: { positions: true },
orderBy: { updatedAt: 'desc' },
});
});
});
describe('findOne', () => {
it('should throw NotFoundException for non-existent portfolio', async () => {
vi.mocked(prisma.portfolio.findUnique).mockResolvedValue(null);
await expect(service.findOne(1, 999)).rejects.toThrow(NotFoundException);
});
it('should throw ForbiddenException for wrong user', async () => {
vi.mocked(prisma.portfolio.findUnique).mockResolvedValue(mockPortfolio({ userId: 2 }) as any);
await expect(service.findOne(1, 1)).rejects.toThrow(ForbiddenException);
});
it('should return portfolio with enriched positions and analytics summary', async () => {
const sharePosition = mockPosition({
id: 1,
secid: 'SBER',
type: 'share',
quantity: 10,
buyPrice: 200,
});
vi.mocked(prisma.portfolio.findUnique).mockResolvedValue(
mockPortfolio({ positions: [sharePosition] }) as any,
);
vi.mocked(prisma.position.findMany).mockResolvedValue([sharePosition] as any);
const cacheMock = module.get(CacheService) as { getOrFetch: ReturnType<typeof vi.fn> };
cacheMock.getOrFetch.mockImplementation(
async (_prefix: string, _key: string[], fetchFn: () => Promise<any>) => ({
data: await fetchFn(),
fromCache: false,
cachedAt: null,
}),
);
vi.mocked(moexClient.getShareMarketDataBatch).mockResolvedValue([
{ secid: 'SBER', shortName: 'Sberbank', last: 250 },
] as any);
const result = await service.findOne(1, 1);
expect(result.id).toBe(1);
expect(result.positions).toHaveLength(1);
expect(result.positions[0].secid).toBe('SBER');
expect(result.positions[0].weightPercent).toBe(100);
expect(result.totalValue).toBe(2500);
expect(result.analytics).toBeDefined();
expect(result.analytics.totalInvested).toBe(2000);
expect(result.analytics.totalValue).toBe(2500);
expect(result.analytics.totalPnl).toBe(500);
});
});
describe('getPositionsWithPrices', () => {
it('should return empty array when no positions exist', async () => {
vi.mocked(prisma.position.findMany).mockResolvedValue([]);
const result = await service.getPositionsWithPrices(1);
expect(result).toEqual([]);
});
it('should return enriched positions with PnL for shares', async () => {
vi.mocked(prisma.position.findMany).mockResolvedValue([
mockPosition({
id: 1,
secid: 'SBER',
type: 'share',
quantity: 10,
buyPrice: 230,
buyDate: new Date('2026-03-01'),
}),
] as any);
const cacheMock = module.get(CacheService) as { getOrFetch: ReturnType<typeof vi.fn> };
cacheMock.getOrFetch.mockImplementation(
async (_prefix: string, _key: string[], fetchFn: () => Promise<any>) => ({
data: await fetchFn(),
fromCache: false,
cachedAt: null,
}),
);
vi.mocked(moexClient.getShareMarketDataBatch).mockResolvedValue([
{ secid: 'SBER', shortName: 'Sberbank', last: 250, lastChange: 5, lastChangePrcnt: 2 },
] as any);
const result = await service.getPositionsWithPrices(1);
expect(result).toHaveLength(1);
expect(result[0].secid).toBe('SBER');
expect(result[0].buyPrice).toBe(230);
expect(result[0].buyDate).toBe('2026-03-01T00:00:00.000Z');
expect(result[0].totalCost).toBe(2300);
expect(result[0].currentPrice).toBe(250);
expect(result[0].currentValue).toBe(2500);
expect(result[0].pnl).toBe(200);
expect(result[0].pnlPercent).toBeCloseTo(8.6957, 1);
expect(result[0].dividendIncome).toBe(0);
expect(result[0].totalReturn).toBe(200);
expect(result[0].totalReturnPercent).toBeCloseTo(8.6957, 1);
});
it('should return enriched positions with PnL for bonds', async () => {
vi.mocked(prisma.position.findMany).mockResolvedValue([
mockPosition({
id: 2,
portfolioId: 1,
secid: 'SU26238RMFS5',
type: 'bond',
quantity: 5,
buyPrice: 95, // 95% of face value
buyDate: new Date('2026-03-01'),
}),
] as any);
const cacheMock = module.get(CacheService) as { getOrFetch: ReturnType<typeof vi.fn> };
cacheMock.getOrFetch.mockImplementation(
async (_prefix: string, _key: string[], fetchFn: () => Promise<any>) => ({
data: await fetchFn(),
fromCache: false,
cachedAt: null,
}),
);
vi.mocked(moexClient.getBondPositionDataBatch).mockResolvedValue([
{
secid: 'SU26238RMFS5',
shortName: 'OFZ 26238',
price: 98.5,
faceValue: 1000,
},
] as any);
const result = await service.getPositionsWithPrices(1);
expect(result).toHaveLength(1);
expect(result[0].secid).toBe('SU26238RMFS5');
// totalCost = 950 * 5 = 4750
expect(result[0].totalCost).toBe(4750);
// currentValue = (98.5 / 100) * 1000 * 5 = 4925
expect(result[0].currentValue).toBe(4925);
// pnl = 4925 - 4750 = 175
expect(result[0].pnl).toBe(175);
expect(result[0].pnlPercent).toBeCloseTo(3.6842, 1);
expect(result[0].totalReturn).toBe(175);
});
it('should set PnL to null when buyPrice is missing', async () => {
vi.mocked(prisma.position.findMany).mockResolvedValue([
mockPosition({
id: 1,
secid: 'SBER',
type: 'share',
quantity: 10,
buyPrice: null,
buyDate: null,
}),
] as any);
const cacheMock = module.get(CacheService) as { getOrFetch: ReturnType<typeof vi.fn> };
cacheMock.getOrFetch.mockImplementation(
async (_prefix: string, _key: string[], fetchFn: () => Promise<any>) => ({
data: await fetchFn(),
fromCache: false,
cachedAt: null,
}),
);
vi.mocked(moexClient.getShareMarketDataBatch).mockResolvedValue([
{ secid: 'SBER', shortName: 'Sberbank', last: 250 },
] as any);
const result = await service.getPositionsWithPrices(1);
expect(result[0].buyPrice).toBeNull();
expect(result[0].totalCost).toBeNull();
expect(result[0].currentValue).toBe(2500);
expect(result[0].pnl).toBeNull();
expect(result[0].pnlPercent).toBeNull();
expect(result[0].totalReturn).toBeNull();
expect(result[0].totalReturnPercent).toBeNull();
});
it('should set PnL to null when market data is missing', async () => {
vi.mocked(prisma.position.findMany).mockResolvedValue([
mockPosition({
id: 1,
secid: 'UNKNOWN',
type: 'share',
quantity: 10,
buyPrice: 100,
buyDate: new Date('2026-03-01'),
}),
] as any);
const cacheMock = module.get(CacheService) as { getOrFetch: ReturnType<typeof vi.fn> };
cacheMock.getOrFetch.mockImplementation(
async (_prefix: string, _key: string[], fetchFn: () => Promise<any>) => ({
data: await fetchFn(),
fromCache: false,
cachedAt: null,
}),
);
vi.mocked(moexClient.getShareMarketDataBatch).mockResolvedValue([] as any);
const result = await service.getPositionsWithPrices(1);
expect(result[0].currentPrice).toBeNull();
expect(result[0].currentValue).toBeNull();
expect(result[0].pnl).toBeNull();
expect(result[0].totalReturn).toBeNull();
});
});
describe('getAnalytics', () => {
it('should return empty analytics when no positions', async () => {
vi.mocked(prisma.portfolio.findUnique).mockResolvedValue(mockPortfolio() as any);
vi.mocked(prisma.position.findMany).mockResolvedValue([]);
const result = await service.getAnalytics(1, 1);
expect(result.summary.totalInvested).toBe(0);
expect(result.summary.totalValue).toBe(0);
expect(result.summary.totalPnl).toBe(0);
expect(result.summary.positionCount).toBe(0);
expect(result.summary.weightedYield).toBeNull();
});
it('should compute correct summary with share positions', async () => {
vi.mocked(prisma.portfolio.findUnique).mockResolvedValue(mockPortfolio() as any);
vi.mocked(prisma.position.findMany).mockResolvedValue([
mockPosition({
id: 1,
secid: 'SBER',
type: 'share',
quantity: 10,
buyPrice: 230,
buyDate: new Date('2026-03-01'),
}),
mockPosition({
id: 2,
portfolioId: 1,
secid: 'GAZP',
type: 'share',
quantity: 5,
buyPrice: 150,
buyDate: new Date('2026-03-01'),
}),
] as any);
const cacheMock = module.get(CacheService) as { getOrFetch: ReturnType<typeof vi.fn> };
cacheMock.getOrFetch.mockImplementation(
async (_prefix: string, _key: string[], fetchFn: () => Promise<any>) => ({
data: await fetchFn(),
fromCache: false,
cachedAt: null,
}),
);
vi.mocked(moexClient.getShareMarketDataBatch).mockResolvedValue([
{ secid: 'SBER', shortName: 'Sberbank', last: 250, lastChange: 5, lastChangePrcnt: 2 },
{ secid: 'GAZP', shortName: 'Gazprom', last: 160, lastChange: 3, lastChangePrcnt: 1.5 },
] as any);
const result = await service.getAnalytics(1, 1);
// totalInvested: 230*10 + 150*5 = 2300 + 750 = 3050
expect(result.summary.totalInvested).toBe(3050);
// totalValue: 250*10 + 160*5 = 2500 + 800 = 3300
expect(result.summary.totalValue).toBe(3300);
// totalPnl: 200 + 50 = 250
expect(result.summary.totalPnl).toBe(250);
// totalPnlPercent: 250 / 3050 * 100 ≈ 8.20
expect(result.summary.totalPnlPercent).toBeCloseTo(8.1967, 1);
expect(result.summary.positionCount).toBe(2);
expect(result.summary.totalDividends).toBe(0);
expect(result.summary.totalReturn).toBe(250);
expect(result.summary.totalReturnPercent).toBeCloseTo(8.1967, 1);
});
it('should compute weightedYield correctly', async () => {
vi.mocked(prisma.portfolio.findUnique).mockResolvedValue(mockPortfolio() as any);
vi.mocked(prisma.position.findMany).mockResolvedValue([
mockPosition({
id: 1,
secid: 'SBER',
type: 'share',
quantity: 10,
buyPrice: 100,
buyDate: new Date('2026-03-01'),
}),
mockPosition({
id: 2,
portfolioId: 1,
secid: 'GAZP',
type: 'share',
quantity: 10,
buyPrice: 200,
buyDate: new Date('2026-03-01'),
}),
] as any);
const cacheMock = module.get(CacheService) as { getOrFetch: ReturnType<typeof vi.fn> };
cacheMock.getOrFetch.mockImplementation(
async (_prefix: string, _key: string[], fetchFn: () => Promise<any>) => ({
data: await fetchFn(),
fromCache: false,
cachedAt: null,
}),
);
vi.mocked(moexClient.getShareMarketDataBatch).mockResolvedValue([
{ secid: 'SBER', shortName: 'Sberbank', last: 120 },
{ secid: 'GAZP', shortName: 'Gazprom', last: 180 },
] as any);
const result = await service.getAnalytics(1, 1);
// SBER: pnlPercent=20%, cost=1000, weight=1000/3000=1/3
// GAZP: pnlPercent=-10%, cost=2000, weight=2000/3000=2/3
// weightedYield = 20*(1/3) + (-10)*(2/3) = 20/3 - 20/3 = 0
expect(result.summary.weightedYield).toBeCloseTo(0, 1);
});
it('should throw ForbiddenException if portfolio belongs to another user', async () => {
vi.mocked(prisma.portfolio.findUnique).mockResolvedValue(mockPortfolio({ userId: 2 }) as any);
await expect(service.getAnalytics(1, 1)).rejects.toThrow(ForbiddenException);
});
it('should throw NotFoundException if portfolio does not exist', async () => {
vi.mocked(prisma.portfolio.findUnique).mockResolvedValue(null);
await expect(service.getAnalytics(1, 999)).rejects.toThrow(NotFoundException);
});
});
});