50 KiB
Security Screener — Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Add security screener endpoint on backend and screener page on frontend for filtering MOEX shares/bonds by market data parameters.
Architecture: New ScreenerService on backend that fetches full MOEX board (all shares or bonds) in one batch call, applies server-side filtering/sorting/pagination. New /screener page on frontend with filter panel and results table. Cache full board for 60s.
Tech Stack: NestJS, MoexClientService (batch methods), TanStack Query v5, React 18, react-router-dom v6
File Structure
Backend (new files)
apps/backend/src/modules/securities/dto/screener-query.dto.tsapps/backend/src/modules/securities/dto/screener-response.dto.tsapps/backend/src/modules/securities/screener.service.ts
Backend (modified files)
apps/backend/src/modules/moex-client/moex-client.service.ts— allow empty secids for full board fetchapps/backend/src/modules/securities/securities.controller.ts— addGET /securities/screenerapps/backend/src/modules/securities/securities.module.ts— add ScreenerService
Frontend (new files)
apps/frontend/src/api/screener.tsapps/frontend/src/hooks/useScreener.tsapps/frontend/src/pages/screener/ScreenerPage.tsxapps/frontend/src/components/screener/FilterPanel.tsxapps/frontend/src/components/screener/FilterPanelShare.tsxapps/frontend/src/components/screener/FilterPanelBond.tsxapps/frontend/src/components/screener/ScreenerTable.tsx
Frontend (modified files)
apps/frontend/src/api/responses.ts— add ScreenerItem, ScreenerResult typesapps/frontend/src/routes.tsx— add /screener routeapps/frontend/src/components/Layout.tsx— add nav link
Task 1: Backend MoexClientService — allow full board fetch with empty secids
Files:
-
Modify:
apps/backend/src/modules/moex-client/moex-client.service.ts -
Remove early return guard in batch methods to support empty secids = fetch all
Replace the getShareMarketDataBatch method:
async getShareMarketDataBatch(
secids: string[],
boardId = 'TQBR',
): Promise<MoexShareMarketData[]> {
const params: Record<string, string> = { boards: boardId };
if (secids.length > 0) {
params.securities = secids.join(',');
}
const data = await this.request<Record<string, unknown>>(
`/engines/stock/markets/shares/securities`,
params,
);
const securities = this.extractTable(data, 'securities');
const marketdata = this.extractTable(data, 'marketdata');
const secidSet = secids.length > 0 ? new Set(secids) : null;
const filteredSecurities = secidSet
? securities.filter((r) => secidSet.has(r.SECID as string))
: securities;
const result = await Promise.all(
filteredSecurities.map(async (sec) => {
const secid = sec.SECID as string;
const mkt = marketdata.find(
(r) => r.SECID === secid && r.BOARDID === boardId,
) ?? marketdata.find((r) => r.SECID === secid);
return {
secid,
boardid: boardId,
shortName: (sec.SHORTNAME as string) || '',
bid: mkt ? parseFloat((mkt.BID as string) || '') : null,
offer: mkt ? parseFloat((mkt.OFFER as string) || '') : null,
open: mkt ? parseFloat((mkt.OPEN as string) || '') : null,
low: mkt ? parseFloat((mkt.LOW as string) || '') : null,
high: mkt ? parseFloat((mkt.HIGH as string) || '') : null,
last: mkt
? parseFloat((mkt.LAST as string) || '')
: parseFloat((sec.PREVPRICE as string) || ''),
lastChange: mkt ? parseFloat((mkt.LASTCHANGE as string) || '') : null,
lastChangePrcnt: mkt ? parseFloat((mkt.LASTCHANGEPRCNT as string) || '') : null,
volume: mkt ? parseInt((mkt.VOLTODAY as string) || '0', 10) : 0,
value: mkt ? parseFloat((mkt.VALTODAY as string) || '0') : 0,
waprice: mkt ? parseFloat((mkt.WAPRICE as string) || '') : null,
numtrades: mkt ? parseInt((mkt.NUMTRADES as string) || '0', 10) : 0,
issueCapitalization: mkt ? parseFloat((mkt.ISSUECAPITALIZATION as string) || '') : null,
tradingStatus: (mkt?.TRADINGSTATUS as string) || '',
updateTime: (mkt?.UPDATETIME as string) || '',
};
}),
);
return result;
}
Replace the getBondPositionDataBatch method:
async getBondPositionDataBatch(
secids: string[],
boardId = 'TQCB',
): Promise<MoexBondPositionData[]> {
const params: Record<string, string> = { boards: boardId };
if (secids.length > 0) {
params.securities = secids.join(',');
}
const data = await this.request<Record<string, unknown>>(
`/engines/stock/markets/bonds/securities`,
params,
);
const securities = this.extractTable(data, 'securities');
const marketdata = this.extractTable(data, 'marketdata');
const secidSet = secids.length > 0 ? new Set(secids) : null;
const filteredBonds = secidSet
? securities.filter((r) => secidSet.has(r.SECID as string))
: securities;
return filteredBonds.map((bond) => {
const secid = bond.SECID as string;
const mkt =
marketdata.find((r) => r.SECID === secid && r.BOARDID === boardId && r.LAST != null) ??
marketdata.find((r) => r.LAST != null) ??
marketdata.find((r) => r.SECID === secid);
return {
secid,
boardid: boardId,
shortName: (bond.SHORTNAME as string) || '',
price: mkt?.LAST != null ? parseFloat(mkt.LAST as string) : null,
yieldToMaturity: mkt?.YIELD != null ? parseFloat(mkt.YIELD as string) : null,
duration: mkt?.DURATION != null ? parseFloat(mkt.DURATION as string) : null,
couponValue: bond.COUPONVALUE != null ? parseFloat(bond.COUPONVALUE as string) : null,
couponPercent: bond.COUPONPERCENT != null ? parseFloat(bond.COUPONPERCENT as string) : null,
nextCouponDate: (bond.NEXTCOUPON as string) || null,
matDate: (bond.MATDATE as string) || null,
accruedInt: bond.ACCRUEDINT != null ? parseFloat(bond.ACCRUEDINT as string) : null,
faceValue: parseFloat((bond.FACEVALUE as string) || '1000'),
bid: mkt?.BID != null ? parseFloat(mkt.BID as string) : null,
offer: mkt?.OFFER != null ? parseFloat(mkt.OFFER as string) : null,
couponPeriod: parseInt((bond.COUPONPERIOD as string) || '0', 10),
bondType: (bond.BONDTYPE as string) || null,
offerDate: (bond.OFFERDATE as string) || null,
};
});
}
- Verify existing portfolio tests still pass
npx vitest run apps/backend/src/modules/portfolio/portfolio.service.spec.ts -w apps/backend
Expected: all existing tests pass (they pass non-empty arrays, behavior unchanged)
Task 2: Backend ScreenerQueryDto
Files:
-
Create:
apps/backend/src/modules/securities/dto/screener-query.dto.ts -
Create ScreenerQueryDto with validation
import { Type, Transform } from 'class-transformer';
import {
IsString, IsOptional, IsNumber, IsInt, Min, Max,
IsEnum, IsIn, MinLength,
} from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export enum ScreenerType {
SHARE = 'share',
BOND = 'bond',
}
export const SORTER_FIELDS = [
'price', 'changePercent', 'volume', 'listLevel',
'capitalization', 'yieldToMaturity', 'duration',
'couponValue', 'couponPercent',
] as const;
export class ScreenerQueryDto {
@ApiProperty({ enum: ScreenerType })
@IsEnum(ScreenerType)
type!: ScreenerType;
@ApiPropertyOptional()
@IsNumber()
@IsOptional()
@Type(() => Number)
priceMin?: number;
@ApiPropertyOptional()
@IsNumber()
@IsOptional()
@Type(() => Number)
priceMax?: number;
@ApiPropertyOptional()
@IsInt()
@Min(0)
@IsOptional()
@Type(() => Number)
volumeMin?: number;
@ApiPropertyOptional()
@IsInt()
@Min(1)
@Max(3)
@IsOptional()
@Type(() => Number)
listLevel?: number;
@ApiPropertyOptional()
@IsNumber()
@IsOptional()
@Type(() => Number)
changePercentMin?: number;
@ApiPropertyOptional()
@IsNumber()
@IsOptional()
@Type(() => Number)
changePercentMax?: number;
@ApiPropertyOptional()
@IsNumber()
@IsOptional()
@Type(() => Number)
capitalizationMin?: number;
@ApiPropertyOptional()
@IsNumber()
@IsOptional()
@Type(() => Number)
yieldMin?: number;
@ApiPropertyOptional()
@IsNumber()
@IsOptional()
@Type(() => Number)
yieldMax?: number;
@ApiPropertyOptional()
@IsNumber()
@IsOptional()
@Type(() => Number)
durationMin?: number;
@ApiPropertyOptional()
@IsNumber()
@IsOptional()
@Type(() => Number)
durationMax?: number;
@ApiPropertyOptional()
@IsNumber()
@IsOptional()
@Type(() => Number)
couponMin?: number;
@ApiPropertyOptional()
@IsNumber()
@IsOptional()
@Type(() => Number)
couponMax?: number;
@ApiPropertyOptional()
@IsNumber()
@IsOptional()
@Type(() => Number)
couponPercentMin?: number;
@ApiPropertyOptional()
@IsNumber()
@IsOptional()
@Type(() => Number)
couponPercentMax?: number;
@ApiPropertyOptional()
@IsString()
@IsOptional()
maturityBefore?: string;
@ApiPropertyOptional()
@IsString()
@IsOptional()
maturityAfter?: string;
@ApiPropertyOptional()
@IsString()
@IsOptional()
bondType?: string;
@ApiPropertyOptional({ default: 'price' })
@IsString()
@IsOptional()
sortBy?: string;
@ApiPropertyOptional({ default: 'asc' })
@IsString()
@IsIn(['asc', 'desc'])
@IsOptional()
sortOrder?: 'asc' | 'desc';
@ApiPropertyOptional({ default: 1 })
@IsInt()
@Min(1)
@IsOptional()
@Type(() => Number)
page?: number;
@ApiPropertyOptional({ default: 20 })
@IsInt()
@Min(1)
@Max(100)
@IsOptional()
@Type(() => Number)
pageSize?: number;
}
Task 3: Backend ScreenerResponseDto
Files:
-
Create:
apps/backend/src/modules/securities/dto/screener-response.dto.ts -
Create ScreenerItemDto and ScreenerResultDto
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class ScreenerItemDto {
@ApiProperty({ example: 'SBER' })
secid: string;
@ApiProperty({ example: 'Сбербанк' })
shortName: string;
@ApiProperty({ example: 'RU0009029540' })
isin: string;
@ApiProperty({ enum: ['share', 'bond'] })
type: 'share' | 'bond';
@ApiPropertyOptional({ example: 322.35 })
price: number | null;
@ApiPropertyOptional({ example: 1.15 })
change: number | null;
@ApiPropertyOptional({ example: 0.36 })
changePercent: number | null;
@ApiProperty({ example: 1925163 })
volume: number;
@ApiProperty({ example: 1 })
listLevel: number;
@ApiPropertyOptional({ example: 6958336818320 })
capitalization: number | null;
@ApiPropertyOptional({ example: 12.71 })
yieldToMaturity: number | null;
@ApiPropertyOptional({ example: 4.5 })
duration: number | null;
@ApiPropertyOptional({ example: 40.64 })
couponValue: number | null;
@ApiPropertyOptional({ example: 8.15 })
couponPercent: number | null;
@ApiPropertyOptional({ example: 29.48 })
accruedInt: number | null;
@ApiPropertyOptional({ example: '2027-02-03' })
matDate: string | null;
@ApiPropertyOptional({ example: 'ОФЗ-ПД' })
bondType: string | null;
}
export class ScreenerResultDto {
@ApiProperty()
totalCount: number;
@ApiProperty()
page: number;
@ApiProperty()
pageSize: number;
@ApiProperty()
totalPages: number;
@ApiProperty({ type: [ScreenerItemDto] })
items: ScreenerItemDto[];
}
Task 4: Backend ScreenerService
Files:
-
Create:
apps/backend/src/modules/securities/screener.service.ts -
Create ScreenerService with filtering, sorting, pagination
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(query: ScreenerQueryDto): Promise<ScreenerResultDto> {
const board = await this.fetchBoard(query.type);
const filtered = board.filter((item) => this.matches(item, query));
const sorted = this.sort(filtered, query.sortBy ?? 'price', query.sortOrder ?? 'asc');
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 20;
const totalCount = sorted.length;
const totalPages = Math.ceil(totalCount / pageSize);
const start = (page - 1) * pageSize;
const items = sorted.slice(start, start + pageSize);
return {
totalCount,
page,
pageSize,
totalPages,
items,
};
}
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: '',
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;
});
}
}
Task 5: Backend controller + module update
Files:
-
Modify:
apps/backend/src/modules/securities/securities.controller.ts -
Modify:
apps/backend/src/modules/securities/securities.module.ts -
Add screener endpoint to SecuritiesController
import { Controller, Get, Query, ValidationPipe } from '@nestjs/common';
import { ApiTags, ApiOperation } from '@nestjs/swagger';
import { SecuritiesService } from './securities.service';
import { ScreenerService } from './screener.service';
import { SearchQueryDto, SecurityType } from './dto/search-query.dto';
import { ScreenerQueryDto } from './dto/screener-query.dto';
@ApiTags('Securities')
@Controller('securities')
export class SecuritiesController {
constructor(
private readonly securitiesService: SecuritiesService,
private readonly screenerService: ScreenerService,
) {}
@Get('search')
@ApiOperation({ summary: 'Поиск по инструментам' })
async search(@Query(ValidationPipe) query: SearchQueryDto) {
const results = await this.securitiesService.search(
query.q,
query.type || SecurityType.ALL,
query.limit || 20,
);
return { data: results, meta: { cachedAt: null, fromCache: false } };
}
@Get('screener')
@ApiOperation({ summary: 'Фильтр ценных бумаг по параметрам' })
async screener(@Query(ValidationPipe) query: ScreenerQueryDto) {
const result = await this.screenerService.screen(query);
return { data: result, meta: { cachedAt: null, fromCache: false } };
}
}
- Update SecuritiesModule
import { Module } from '@nestjs/common';
import { CacheModule } from '../cache/cache.module';
import { SecuritiesController } from './securities.controller';
import { SecuritiesService } from './securities.service';
import { ScreenerService } from './screener.service';
@Module({
imports: [CacheModule],
controllers: [SecuritiesController],
providers: [SecuritiesService, ScreenerService],
exports: [SecuritiesService],
})
export class SecuritiesModule {}
- Run backend build to verify
npm run build:backend
Expected: no TypeScript errors
Task 6: Backend tests — ScreenerService
Files:
-
Create:
apps/backend/src/modules/securities/screener.service.spec.ts -
Create ScreenerService test
import { Test, TestingModule } from '@nestjs/testing';
import { ConfigModule } from '@nestjs/config';
import { ScreenerService } from './screener.service';
import { MoexClientService } from '../moex-client/moex-client.service';
import { CacheService } from '../cache/cache.service';
import configuration from '../../config/configuration';
import { ScreenerQueryDto, ScreenerType } from './dto/screener-query.dto';
describe('ScreenerService', () => {
let service: ScreenerService;
let moexClient: MoexClientService;
let module: TestingModule;
const mockShares = [
{ secid: 'SBER', shortName: 'Sberbank', last: 300, lastChange: 5, lastChangePrcnt: 1.5, volume: 1000000, issueCapitalization: 5000000000000 },
{ secid: 'GAZP', shortName: 'Gazprom', last: 200, lastChange: -2, lastChangePrcnt: -1.0, volume: 500000, issueCapitalization: 3000000000000 },
{ secid: 'VTBR', shortName: 'VTB', last: 0.05, lastChange: 0.001, lastChangePrcnt: 2.0, volume: 10000000, issueCapitalization: 100000000000 },
] as any[];
beforeAll(async () => {
module = await Test.createTestingModule({
imports: [ConfigModule.forRoot({ load: [configuration] })],
providers: [
ScreenerService,
{
provide: MoexClientService,
useValue: {
getShareMarketDataBatch: vi.fn(),
getBondPositionDataBatch: vi.fn(),
},
},
{
provide: CacheService,
useValue: {
getOrFetch: vi.fn(),
},
},
],
}).compile();
service = module.get<ScreenerService>(ScreenerService);
moexClient = module.get<MoexClientService>(MoexClientService);
});
beforeEach(() => {
vi.clearAllMocks();
});
it('should return all shares when no filters applied', async () => {
vi.mocked(moexClient.getShareMarketDataBatch).mockResolvedValue(mockShares);
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 query = Object.assign(new ScreenerQueryDto(), { type: ScreenerType.SHARE });
const result = await service.screen(query);
expect(result.totalCount).toBe(3);
expect(result.items).toHaveLength(3);
expect(result.page).toBe(1);
expect(result.totalPages).toBe(1);
});
it('should filter by price range', async () => {
vi.mocked(moexClient.getShareMarketDataBatch).mockResolvedValue(mockShares);
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 query = Object.assign(new ScreenerQueryDto(), {
type: ScreenerType.SHARE,
priceMin: 100,
priceMax: 250,
});
const result = await service.screen(query);
expect(result.totalCount).toBe(1);
expect(result.items[0].secid).toBe('GAZP');
});
it('should apply pagination', async () => {
vi.mocked(moexClient.getShareMarketDataBatch).mockResolvedValue(mockShares);
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 query = Object.assign(new ScreenerQueryDto(), {
type: ScreenerType.SHARE,
page: 1,
pageSize: 2,
});
const result = await service.screen(query);
expect(result.items).toHaveLength(2);
expect(result.totalCount).toBe(3);
expect(result.totalPages).toBe(2);
});
it('should use cache for board data', async () => {
const cacheMock = module.get(CacheService) as { getOrFetch: ReturnType<typeof vi.fn> };
cacheMock.getOrFetch.mockResolvedValue({
data: mockShares,
fromCache: true,
cachedAt: new Date().toISOString(),
});
const query = Object.assign(new ScreenerQueryDto(), { type: ScreenerType.SHARE });
await service.screen(query);
expect(cacheMock.getOrFetch).toHaveBeenCalledWith(
'screener',
['share'],
expect.any(Function),
'marketDataTtl',
);
});
it('should return empty result when no items match filters', async () => {
vi.mocked(moexClient.getShareMarketDataBatch).mockResolvedValue(mockShares);
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 query = Object.assign(new ScreenerQueryDto(), {
type: ScreenerType.SHARE,
priceMin: 1000,
});
const result = await service.screen(query);
expect(result.totalCount).toBe(0);
expect(result.items).toHaveLength(0);
});
});
- Run tests
npx vitest run apps/backend/src/modules/securities/screener.service.spec.ts -w apps/backend
Expected: all tests pass
Task 7: Frontend types — add screener types
Files:
-
Modify:
apps/frontend/src/api/responses.ts -
Add ScreenerItem and ScreenerResult types
export interface ScreenerItem {
secid: string;
shortName: string;
isin: string;
type: 'share' | 'bond';
price: number | null;
change: number | null;
changePercent: number | null;
volume: number;
listLevel: number;
capitalization: number | null;
yieldToMaturity: number | null;
duration: number | null;
couponValue: number | null;
couponPercent: number | null;
accruedInt: number | null;
matDate: string | null;
bondType: string | null;
}
export interface ScreenerResult {
totalCount: number;
page: number;
pageSize: number;
totalPages: number;
items: ScreenerItem[];
}
Task 8: Frontend API client for screener
Files:
-
Create:
apps/frontend/src/api/screener.ts -
Create screener API client
import { request } from './client';
import type { ScreenerResult } from './responses';
export interface ScreenerParams {
type: 'share' | 'bond';
priceMin?: number;
priceMax?: number;
volumeMin?: number;
listLevel?: number;
changePercentMin?: number;
changePercentMax?: number;
capitalizationMin?: number;
yieldMin?: number;
yieldMax?: number;
durationMin?: number;
durationMax?: number;
couponMin?: number;
couponMax?: number;
couponPercentMin?: number;
couponPercentMax?: number;
maturityBefore?: string;
maturityAfter?: string;
bondType?: string;
sortBy?: string;
sortOrder?: 'asc' | 'desc';
page?: number;
pageSize?: number;
}
export function getScreenerResults(
params: ScreenerParams,
): Promise<{ data: ScreenerResult; meta: { cachedAt: string | null; fromCache: boolean } }> {
const searchParams = new URLSearchParams();
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== null && value !== '') {
searchParams.set(key, String(value));
}
});
const qs = searchParams.toString();
return request<ScreenerResult>(`/api/v1/securities/screener${qs ? `?${qs}` : ''}`);
}
Task 9: Frontend useScreener hook
Files:
-
Create:
apps/frontend/src/hooks/useScreener.ts -
Create useScreener hook with URL search params sync
import { useSearchParams } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { getScreenerResults } from '../api/screener';
import type { ScreenerParams } from '../api/screener';
export function useScreener() {
const [searchParams, setSearchParams] = useSearchParams();
const params: ScreenerParams = {
type: (searchParams.get('type') as 'share' | 'bond') || 'share',
priceMin: searchParams.get('priceMin') ? Number(searchParams.get('priceMin')) : undefined,
priceMax: searchParams.get('priceMax') ? Number(searchParams.get('priceMax')) : undefined,
volumeMin: searchParams.get('volumeMin') ? Number(searchParams.get('volumeMin')) : undefined,
listLevel: searchParams.get('listLevel') ? Number(searchParams.get('listLevel')) : undefined,
changePercentMin: searchParams.get('changePercentMin') ? Number(searchParams.get('changePercentMin')) : undefined,
changePercentMax: searchParams.get('changePercentMax') ? Number(searchParams.get('changePercentMax')) : undefined,
capitalizationMin: searchParams.get('capitalizationMin') ? Number(searchParams.get('capitalizationMin')) : undefined,
yieldMin: searchParams.get('yieldMin') ? Number(searchParams.get('yieldMin')) : undefined,
yieldMax: searchParams.get('yieldMax') ? Number(searchParams.get('yieldMax')) : undefined,
durationMin: searchParams.get('durationMin') ? Number(searchParams.get('durationMin')) : undefined,
durationMax: searchParams.get('durationMax') ? Number(searchParams.get('durationMax')) : undefined,
couponMin: searchParams.get('couponMin') ? Number(searchParams.get('couponMin')) : undefined,
couponMax: searchParams.get('couponMax') ? Number(searchParams.get('couponMax')) : undefined,
couponPercentMin: searchParams.get('couponPercentMin') ? Number(searchParams.get('couponPercentMin')) : undefined,
couponPercentMax: searchParams.get('couponPercentMax') ? Number(searchParams.get('couponPercentMax')) : undefined,
maturityBefore: searchParams.get('maturityBefore') || undefined,
maturityAfter: searchParams.get('maturityAfter') || undefined,
bondType: searchParams.get('bondType') || undefined,
sortBy: searchParams.get('sortBy') || undefined,
sortOrder: (searchParams.get('sortOrder') as 'asc' | 'desc') || undefined,
page: searchParams.get('page') ? Number(searchParams.get('page')) : undefined,
pageSize: searchParams.get('pageSize') ? Number(searchParams.get('pageSize')) : undefined,
};
const queryKey = ['screener', params];
const query = useQuery({
queryKey,
queryFn: () => getScreenerResults(params),
staleTime: 60_000,
retry: 2,
refetchOnWindowFocus: false,
});
function setParam(key: string, value: string | undefined) {
setSearchParams((prev) => {
const next = new URLSearchParams(prev);
if (value === undefined || value === '') {
next.delete(key);
} else {
next.set(key, value);
}
next.set('page', '1'); // reset page on filter change
return next;
});
}
function setFilters(filters: Partial<ScreenerParams>) {
setSearchParams((prev) => {
const next = new URLSearchParams(prev);
Object.entries(filters).forEach(([key, value]) => {
if (value === undefined || value === null || value === '') {
next.delete(key);
} else {
next.set(key, String(value));
}
});
next.set('page', '1');
return next;
});
}
function setPage(page: number) {
setSearchParams((prev) => {
const next = new URLSearchParams(prev);
next.set('page', String(page));
return next;
});
}
function setSort(sortBy: string) {
setSearchParams((prev) => {
const next = new URLSearchParams(prev);
const current = next.get('sortBy');
const currentOrder = next.get('sortOrder') || 'asc';
if (current === sortBy) {
next.set('sortOrder', currentOrder === 'asc' ? 'desc' : 'asc');
} else {
next.set('sortBy', sortBy);
next.set('sortOrder', 'asc');
}
next.set('page', '1');
return next;
});
}
function resetFilters() {
setSearchParams(new URLSearchParams({ type: params.type }));
}
return {
params,
result: query.data?.data ?? null,
isLoading: query.isLoading,
error: query.error,
setFilters,
setPage,
setSort,
setParam,
resetFilters,
};
}
Task 10: Frontend FilterPanel components
Files:
-
Create:
apps/frontend/src/components/screener/FilterPanel.tsx -
Create:
apps/frontend/src/components/screener/FilterPanelShare.tsx -
Create:
apps/frontend/src/components/screener/FilterPanelBond.tsx -
Create FilterPanel component
import { useState } from 'react';
import type { ScreenerParams } from '../../api/screener';
import { FilterPanelShare } from './FilterPanelShare';
import { FilterPanelBond } from './FilterPanelBond';
interface Props {
params: ScreenerParams;
onApply: (filters: Partial<ScreenerParams>) => void;
onReset: () => void;
}
export function FilterPanel({ params, onApply, onReset }: Props) {
const [type, setType] = useState<'share' | 'bond'>(params.type);
const [local, setLocal] = useState<Record<string, string>>({});
function handleApply() {
const filters: Partial<ScreenerParams> = { type };
Object.entries(local).forEach(([key, value]) => {
if (value !== '') {
const num = Number(value);
filters[key as keyof ScreenerParams] = isNaN(num) ? (value as any) : (num as any);
}
});
onApply(filters);
}
function handleReset() {
setLocal({});
onReset();
}
function updateField(key: string, value: string) {
setLocal((prev) => ({ ...prev, [key]: value }));
}
return (
<div
style={{
width: 260,
padding: 16,
background: 'var(--color-surface)',
border: '1px solid #e0e0e0',
borderRadius: 'var(--border-radius)',
display: 'flex',
flexDirection: 'column',
gap: 12,
}}
>
<div>
<label style={{ display: 'block', fontSize: 12, fontWeight: 600, marginBottom: 4 }}>
Тип бумаги
</label>
<select
value={type}
onChange={(e) => setType(e.target.value as 'share' | 'bond')}
style={{
width: '100%',
padding: '6px 10px',
border: '1px solid #e0e0e0',
borderRadius: 'var(--border-radius)',
fontSize: 13,
}}
>
<option value="share">Акции</option>
<option value="bond">Облигации</option>
</select>
</div>
{type === 'share' ? (
<FilterPanelShare params={params} local={local} updateField={updateField} />
) : (
<FilterPanelBond params={params} local={local} updateField={updateField} />
)}
<div style={{ display: 'flex', gap: 8 }}>
<button
onClick={handleApply}
style={{
flex: 1,
padding: '8px 16px',
background: 'var(--color-primary)',
color: '#fff',
border: 'none',
borderRadius: 'var(--border-radius)',
fontSize: 13,
fontWeight: 600,
cursor: 'pointer',
}}
>
Применить
</button>
<button
onClick={handleReset}
style={{
padding: '8px 16px',
background: 'transparent',
border: '1px solid #e0e0e0',
borderRadius: 'var(--border-radius)',
fontSize: 13,
cursor: 'pointer',
}}
>
Сбросить
</button>
</div>
</div>
);
}
- Create FilterPanelShare component
interface Props {
params: Record<string, any>;
local: Record<string, string>;
updateField: (key: string, value: string) => void;
}
export function FilterPanelShare({ local, updateField }: Props) {
const fields = [
{ key: 'priceMin', label: 'Цена от' },
{ key: 'priceMax', label: 'Цена до' },
{ key: 'changePercentMin', label: 'Изм. % от' },
{ key: 'changePercentMax', label: 'Изм. % до' },
{ key: 'volumeMin', label: 'Объём от' },
{ key: 'capitalizationMin', label: 'Капитализация от' },
];
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{fields.map(({ key, label }) => (
<div key={key}>
<label style={{ display: 'block', fontSize: 11, fontWeight: 600, marginBottom: 2, color: 'var(--color-text-secondary)' }}>
{label}
</label>
<input
type="number"
value={local[key] ?? ''}
onChange={(e) => updateField(key, e.target.value)}
style={{
width: '100%',
padding: '6px 10px',
border: '1px solid #e0e0e0',
borderRadius: 'var(--border-radius)',
fontSize: 13,
boxSizing: 'border-box',
}}
/>
</div>
))}
</div>
);
}
- Create FilterPanelBond component
interface Props {
params: Record<string, any>;
local: Record<string, string>;
updateField: (key: string, value: string) => void;
}
export function FilterPanelBond({ local, updateField }: Props) {
const fields = [
{ key: 'priceMin', label: 'Цена (% от номинала) от' },
{ key: 'priceMax', label: 'Цена (% от номинала) до' },
{ key: 'yieldMin', label: 'YTM % от' },
{ key: 'yieldMax', label: 'YTM % до' },
{ key: 'durationMin', label: 'Дюрация (лет) от' },
{ key: 'durationMax', label: 'Дюрация (лет) до' },
{ key: 'couponMin', label: 'Купон (₽) от' },
{ key: 'couponMax', label: 'Купон (₽) до' },
{ key: 'couponPercentMin', label: 'Купон % от' },
{ key: 'couponPercentMax', label: 'Купон % до' },
];
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{fields.map(({ key, label }) => (
<div key={key}>
<label style={{ display: 'block', fontSize: 11, fontWeight: 600, marginBottom: 2, color: 'var(--color-text-secondary)' }}>
{label}
</label>
<input
type="number"
value={local[key] ?? ''}
onChange={(e) => updateField(key, e.target.value)}
style={{
width: '100%',
padding: '6px 10px',
border: '1px solid #e0e0e0',
borderRadius: 'var(--border-radius)',
fontSize: 13,
boxSizing: 'border-box',
}}
/>
</div>
))}
</div>
);
}
Task 11: Frontend ScreenerTable component
Files:
-
Create:
apps/frontend/src/components/screener/ScreenerTable.tsx -
Create ScreenerTable with sortable columns
import { Link } from 'react-router-dom';
import type { ScreenerItem, ScreenerResult } from '../../api/responses';
interface Props {
result: ScreenerResult;
sortBy: string;
sortOrder: 'asc' | 'desc';
onSort: (field: string) => void;
onPageChange: (page: number) => void;
}
function formatNum(value: number | null | undefined, digits = 2): string {
if (value == null) return '—';
return value.toLocaleString('ru-RU', { minimumFractionDigits: digits, maximumFractionDigits: digits });
}
function formatChange(value: number | null | undefined): { text: string; color: string } {
if (value == null) return { text: '—', color: 'inherit' };
const color = value > 0 ? '#43a047' : value < 0 ? '#e53935' : 'inherit';
return { text: `${value > 0 ? '+' : ''}${value.toFixed(2)}%`, color };
}
export function ScreenerTable({ result, sortBy, sortOrder, onSort, onPageChange }: Props) {
function SortHeader({ field, children }: { field: string; children: string }) {
const isActive = sortBy === field;
return (
<th
onClick={() => onSort(field)}
style={{
textAlign: 'right',
padding: '8px 12px',
fontWeight: 600,
fontSize: 12,
color: 'var(--color-text-secondary)',
cursor: 'pointer',
userSelect: 'none',
whiteSpace: 'nowrap',
}}
>
{children} {isActive ? (sortOrder === 'asc' ? '▲' : '▼') : ''}
</th>
);
}
const isShare = result.items[0]?.type === 'share';
return (
<div style={{ flex: 1 }}>
<div style={{ fontSize: 13, color: 'var(--color-text-secondary)', marginBottom: 8 }}>
Найдено: {result.totalCount} бумаг
</div>
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead>
<tr style={{ borderBottom: '2px solid #e0e0e0' }}>
<th style={{ textAlign: 'left', padding: '8px 12px', fontWeight: 600, fontSize: 12, color: 'var(--color-text-secondary)' }}>
Тикер
</th>
<th style={{ textAlign: 'left', padding: '8px 12px', fontWeight: 600, fontSize: 12, color: 'var(--color-text-secondary)' }}>
Название
</th>
<SortHeader field="price">Цена</SortHeader>
<SortHeader field="changePercent">Изм.</SortHeader>
<SortHeader field="volume">Объём</SortHeader>
{isShare ? (
<SortHeader field="capitalization">Капитализация</SortHeader>
) : (
<>
<SortHeader field="yieldToMaturity">YTM</SortHeader>
<SortHeader field="duration">Дюрация</SortHeader>
<SortHeader field="couponValue">Купон</SortHeader>
<SortHeader field="couponPercent">Куп. %</SortHeader>
</>
)}
</tr>
</thead>
<tbody>
{result.items.map((item) => {
const change = formatChange(item.changePercent);
const link = isShare ? `/stocks/${item.secid}` : `/bonds/${item.secid}`;
return (
<tr key={item.secid} style={{ borderBottom: '1px solid #f0f0f0' }}>
<td style={{ padding: '8px 12px', fontWeight: 600, fontFamily: 'monospace' }}>
<Link to={link} style={{ color: 'inherit', textDecoration: 'none' }}>
{item.secid}
</Link>
</td>
<td style={{ padding: '8px 12px', color: 'var(--color-text-secondary)' }}>
{item.shortName}
</td>
<td style={{ padding: '8px 12px', textAlign: 'right' }}>
{formatNum(item.price)}
</td>
<td style={{ padding: '8px 12px', textAlign: 'right', color: change.color }}>
{change.text}
</td>
<td style={{ padding: '8px 12px', textAlign: 'right' }}>
{item.volume.toLocaleString('ru-RU')}
</td>
{isShare ? (
<td style={{ padding: '8px 12px', textAlign: 'right' }}>
{item.capitalization != null ? item.capitalization.toLocaleString('ru-RU') : '—'}
</td>
) : (
<>
<td style={{ padding: '8px 12px', textAlign: 'right' }}>
{formatNum(item.yieldToMaturity)}
</td>
<td style={{ padding: '8px 12px', textAlign: 'right' }}>
{item.duration != null ? `${item.duration.toFixed(2)}г` : '—'}
</td>
<td style={{ padding: '8px 12px', textAlign: 'right' }}>
{formatNum(item.couponValue)}
</td>
<td style={{ padding: '8px 12px', textAlign: 'right' }}>
{formatNum(item.couponPercent)}
</td>
</>
)}
</tr>
);
})}
</tbody>
</table>
</div>
{result.totalPages > 1 && (
<div style={{ display: 'flex', justifyContent: 'center', gap: 8, marginTop: 16 }}>
{Array.from({ length: Math.min(result.totalPages, 10) }, (_, i) => i + 1).map((p) => (
<button
key={p}
onClick={() => onPageChange(p)}
style={{
padding: '4px 10px',
background: p === result.page ? 'var(--color-primary)' : 'transparent',
color: p === result.page ? '#fff' : 'var(--color-text)',
border: '1px solid #e0e0e0',
borderRadius: 'var(--border-radius)',
fontSize: 13,
cursor: 'pointer',
}}
>
{p}
</button>
))}
</div>
)}
</div>
);
}
Task 12: Frontend ScreenerPage
Files:
-
Create:
apps/frontend/src/pages/screener/ScreenerPage.tsx -
Create ScreenerPage
import { useScreener } from '../../hooks/useScreener';
import { FilterPanel } from '../../components/screener/FilterPanel';
import { ScreenerTable } from '../../components/screener/ScreenerTable';
export function ScreenerPage() {
const {
params, result, isLoading, error,
setFilters, setPage, setSort, resetFilters,
} = useScreener();
return (
<div>
<h1 style={{ margin: '0 0 20px', fontSize: 24, fontWeight: 700 }}>Скринер</h1>
<div style={{ display: 'flex', gap: 20, alignItems: 'flex-start' }}>
<FilterPanel
params={params}
onApply={setFilters}
onReset={resetFilters}
/>
{isLoading && (
<div style={{ flex: 1, textAlign: 'center', padding: 40, color: 'var(--color-text-secondary)' }}>
Загрузка...
</div>
)}
{error && (
<div style={{ flex: 1, textAlign: 'center', padding: 40, color: '#e53935' }}>
Ошибка загрузки данных. Попробуйте позже.
</div>
)}
{!isLoading && !error && result && result.items.length === 0 && (
<div style={{ flex: 1, textAlign: 'center', padding: 40, color: 'var(--color-text-secondary)' }}>
Ничего не найдено. Попробуйте смягчить фильтры.
</div>
)}
{!isLoading && !error && result && result.items.length > 0 && (
<ScreenerTable
result={result}
sortBy={params.sortBy || 'price'}
sortOrder={params.sortOrder || 'asc'}
onSort={setSort}
onPageChange={setPage}
/>
)}
</div>
</div>
);
}
Task 13: Frontend routes + nav link
Files:
-
Modify:
apps/frontend/src/routes.tsx -
Modify:
apps/frontend/src/components/Layout.tsx -
Add /screener route to routes.tsx
import { ScreenerPage } from './pages/screener/ScreenerPage';
// Inside <Routes> inside the <Route element={<Layout />}> group:
<Route path="/screener" element={<ScreenerPage />} />
Full updated routes.tsx:
import { Routes, Route } from 'react-router-dom';
import { Layout } from './components/Layout';
import { HomePage } from './pages/HomePage';
import { StockPage } from './pages/StockPage';
import { BondPage } from './pages/BondPage';
import { LoginPage } from './pages/LoginPage';
import { RegisterPage } from './pages/RegisterPage';
import { ProfilePage } from './pages/ProfilePage';
import { ProtectedRoute } from './components/ProtectedRoute';
import { PortfoliosListPage } from './pages/portfolios/PortfoliosListPage';
import { PortfolioDetailPage } from './pages/portfolios/PortfolioDetailPage';
import { ScreenerPage } from './pages/screener/ScreenerPage';
export function AppRoutes() {
return (
<Routes>
<Route element={<Layout />}>
<Route path="/" element={<HomePage />} />
<Route path="/stocks/:secid" element={<StockPage />} />
<Route path="/bonds/:secid" element={<BondPage />} />
<Route path="/screener" element={<ScreenerPage />} />
<Route path="/login" element={<LoginPage />} />
<Route path="/register" element={<RegisterPage />} />
<Route path="/profile" element={<ProtectedRoute><ProfilePage /></ProtectedRoute>} />
<Route path="/portfolios" element={<ProtectedRoute><PortfoliosListPage /></ProtectedRoute>} />
<Route path="/portfolios/:id" element={<ProtectedRoute><PortfolioDetailPage /></ProtectedRoute>} />
</Route>
</Routes>
);
}
- Add Скринер nav link to Layout.tsx
Add after the Портфели link (around line 47):
<Link
to="/screener"
style={{
fontSize: 14,
color: 'var(--color-text)',
textDecoration: 'none',
fontWeight: 500,
}}
>
Скринер
</Link>
Task 14: Verify everything works
- Run all backend tests
npx vitest run -w apps/backend
Expected: all tests pass
- Build backend
npm run build:backend
Expected: no errors
- Build frontend
npm run build:frontend
Expected: no errors
- Run lint
npm run lint
Expected: no errors
- Run frontend tests
npx vitest run -w apps/frontend
Expected: all tests pass
- Commit
git add apps/backend/src/modules/moex-client/moex-client.service.ts \
apps/backend/src/modules/securities/dto/screener-query.dto.ts \
apps/backend/src/modules/securities/dto/screener-response.dto.ts \
apps/backend/src/modules/securities/screener.service.ts \
apps/backend/src/modules/securities/screener.service.spec.ts \
apps/backend/src/modules/securities/securities.controller.ts \
apps/backend/src/modules/securities/securities.module.ts \
apps/frontend/src/api/responses.ts \
apps/frontend/src/api/screener.ts \
apps/frontend/src/hooks/useScreener.ts \
apps/frontend/src/components/screener/FilterPanel.tsx \
apps/frontend/src/components/screener/FilterPanelShare.tsx \
apps/frontend/src/components/screener/FilterPanelBond.tsx \
apps/frontend/src/components/screener/ScreenerTable.tsx \
apps/frontend/src/pages/screener/ScreenerPage.tsx \
apps/frontend/src/routes.tsx \
apps/frontend/src/components/Layout.tsx
git commit -m "feat: add security screener with filtering and sorting"