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 { 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 { 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; }); } }