moex-vibe/apps/backend/src/modules/securities/screener.service.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

188 lines
6.2 KiB
TypeScript

import { Injectable } from '@nestjs/common';
import { MoexClientService } from '../moex-client/moex-client.service';
import { CacheService } from '../cache/cache.service';
import { ScreenerQueryDto, ScreenerType } from './dto/screener-query.dto';
import { ScreenerItemDto, ScreenerResultDto } from './dto/screener-response.dto';
@Injectable()
export class ScreenerService {
constructor(
private readonly moexClient: MoexClientService,
private readonly cache: CacheService,
) {}
async screen(q: ScreenerQueryDto): Promise<ScreenerResultDto> {
const board = await this.fetchBoard(q.type);
const filtered = board.filter((item) => this.matches(item, q));
const sorted = this.sort(filtered, q.sortBy || 'price', q.sortOrder || 'asc');
const total = sorted.length;
const page = q.page || 1;
const pageSize = q.pageSize || 20;
const totalPages = Math.ceil(total / pageSize);
const start = (page - 1) * pageSize;
const items = sorted.slice(start, start + pageSize);
return {
items,
total,
page,
pageSize,
totalPages,
};
}
private async fetchBoard(type: ScreenerType): Promise<ScreenerItemDto[]> {
const { data } = await this.cache.getOrFetch(
'screener',
[type],
async () => {
if (type === ScreenerType.SHARE) {
const shares = await this.moexClient.getShareMarketDataBatch([]);
return shares.map(
(s): ScreenerItemDto => ({
secid: s.secid,
shortName: s.shortName,
isin: '', // MOEX batch doesn't return ISIN in securities table sometimes, but we can live without it for screener
type: 'share',
price: s.last,
change: s.lastChange,
changePercent: s.lastChangePrcnt,
volume: s.volume,
listLevel: 0,
capitalization: s.issueCapitalization,
yieldToMaturity: null,
duration: null,
couponValue: null,
couponPercent: null,
accruedInt: null,
matDate: null,
bondType: null,
}),
);
} else {
const bonds = await this.moexClient.getBondPositionDataBatch([]);
return bonds.map(
(b): ScreenerItemDto => ({
secid: b.secid,
shortName: b.shortName,
isin: '',
type: 'bond',
price: b.price,
change: null,
changePercent: null,
volume: 0,
listLevel: 0,
capitalization: null,
yieldToMaturity: b.yieldToMaturity,
duration: b.duration,
couponValue: b.couponValue,
couponPercent: b.couponPercent,
accruedInt: b.accruedInt,
matDate: b.matDate,
bondType: b.bondType,
}),
);
}
},
'marketDataTtl',
);
return data;
}
private matches(item: ScreenerItemDto, q: ScreenerQueryDto): boolean {
if (q.priceMin != null && (item.price == null || item.price < q.priceMin)) return false;
if (q.priceMax != null && (item.price == null || item.price > q.priceMax)) return false;
if (q.volumeMin != null && item.volume < q.volumeMin) return false;
if (q.listLevel != null && item.listLevel !== q.listLevel) return false;
if (item.type === 'share') {
if (
q.changePercentMin != null &&
(item.changePercent == null || item.changePercent < q.changePercentMin)
)
return false;
if (
q.changePercentMax != null &&
(item.changePercent == null || item.changePercent > q.changePercentMax)
)
return false;
if (
q.capitalizationMin != null &&
(item.capitalization == null || item.capitalization < q.capitalizationMin)
)
return false;
}
if (item.type === 'bond') {
if (q.yieldMin != null && (item.yieldToMaturity == null || item.yieldToMaturity < q.yieldMin))
return false;
if (q.yieldMax != null && (item.yieldToMaturity == null || item.yieldToMaturity > q.yieldMax))
return false;
if (q.durationMin != null && (item.duration == null || item.duration < q.durationMin))
return false;
if (q.durationMax != null && (item.duration == null || item.duration > q.durationMax))
return false;
if (q.couponMin != null && (item.couponValue == null || item.couponValue < q.couponMin))
return false;
if (q.couponMax != null && (item.couponValue == null || item.couponValue > q.couponMax))
return false;
if (
q.couponPercentMin != null &&
(item.couponPercent == null || item.couponPercent < q.couponPercentMin)
)
return false;
if (
q.couponPercentMax != null &&
(item.couponPercent == null || item.couponPercent > q.couponPercentMax)
)
return false;
if (q.maturityBefore != null && (item.matDate == null || item.matDate > q.maturityBefore))
return false;
if (q.maturityAfter != null && (item.matDate == null || item.matDate < q.maturityAfter))
return false;
if (q.bondType != null && item.bondType !== q.bondType) return false;
}
return true;
}
private sort(
items: ScreenerItemDto[],
sortBy: string,
sortOrder: 'asc' | 'desc',
): ScreenerItemDto[] {
const allowedFields = new Set([
'secid',
'shortName',
'price',
'change',
'changePercent',
'volume',
'listLevel',
'capitalization',
'yieldToMaturity',
'duration',
'couponValue',
'couponPercent',
'accruedInt',
'matDate',
]);
if (!allowedFields.has(sortBy)) {
sortBy = 'price';
}
return [...items].sort((a, b) => {
const aVal = (a as any)[sortBy];
const bVal = (b as any)[sortBy];
if (aVal == null && bVal == null) return 0;
if (aVal == null) return 1;
if (bVal == null) return -1;
if (typeof aVal === 'string') {
return sortOrder === 'asc' ? aVal.localeCompare(bVal) : bVal.localeCompare(aVal);
}
return sortOrder === 'asc' ? aVal - bVal : bVal - aVal;
});
}
}