Some checks failed
- 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
188 lines
6.2 KiB
TypeScript
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;
|
|
});
|
|
}
|
|
}
|