# 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.ts` - `apps/backend/src/modules/securities/dto/screener-response.dto.ts` - `apps/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 fetch - `apps/backend/src/modules/securities/securities.controller.ts` — add `GET /securities/screener` - `apps/backend/src/modules/securities/securities.module.ts` — add ScreenerService ### Frontend (new files) - `apps/frontend/src/api/screener.ts` - `apps/frontend/src/hooks/useScreener.ts` - `apps/frontend/src/pages/screener/ScreenerPage.tsx` - `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` ### Frontend (modified files) - `apps/frontend/src/api/responses.ts` — add ScreenerItem, ScreenerResult types - `apps/frontend/src/routes.tsx` — add /screener route - `apps/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: ```typescript async getShareMarketDataBatch( secids: string[], boardId = 'TQBR', ): Promise { const params: Record = { boards: boardId }; if (secids.length > 0) { params.securities = secids.join(','); } const data = await this.request>( `/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: ```typescript async getBondPositionDataBatch( secids: string[], boardId = 'TQCB', ): Promise { const params: Record = { boards: boardId }; if (secids.length > 0) { params.securities = secids.join(','); } const data = await this.request>( `/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** ```bash 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** ```typescript 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** ```typescript 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** ```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(query: ScreenerQueryDto): Promise { 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 { 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** ```typescript 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** ```typescript 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** ```bash 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** ```typescript 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); moexClient = module.get(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 }; cacheMock.getOrFetch.mockImplementation( async (_prefix: string, _key: string[], fetchFn: () => Promise) => ({ 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 }; cacheMock.getOrFetch.mockImplementation( async (_prefix: string, _key: string[], fetchFn: () => Promise) => ({ 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 }; cacheMock.getOrFetch.mockImplementation( async (_prefix: string, _key: string[], fetchFn: () => Promise) => ({ 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 }; 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 }; cacheMock.getOrFetch.mockImplementation( async (_prefix: string, _key: string[], fetchFn: () => Promise) => ({ 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** ```bash 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** ```typescript 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** ```typescript 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(`/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** ```typescript 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) { 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** ```typescript 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) => void; onReset: () => void; } export function FilterPanel({ params, onApply, onReset }: Props) { const [type, setType] = useState<'share' | 'bond'>(params.type); const [local, setLocal] = useState>({}); function handleApply() { const filters: Partial = { 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 (
{type === 'share' ? ( ) : ( )}
); } ``` - [ ] **Create FilterPanelShare component** ```typescript interface Props { params: Record; local: Record; 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 (
{fields.map(({ key, label }) => (
updateField(key, e.target.value)} style={{ width: '100%', padding: '6px 10px', border: '1px solid #e0e0e0', borderRadius: 'var(--border-radius)', fontSize: 13, boxSizing: 'border-box', }} />
))}
); } ``` - [ ] **Create FilterPanelBond component** ```typescript interface Props { params: Record; local: Record; 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 (
{fields.map(({ key, label }) => (
updateField(key, e.target.value)} style={{ width: '100%', padding: '6px 10px', border: '1px solid #e0e0e0', borderRadius: 'var(--border-radius)', fontSize: 13, boxSizing: 'border-box', }} />
))}
); } ``` --- ### Task 11: Frontend ScreenerTable component **Files:** - Create: `apps/frontend/src/components/screener/ScreenerTable.tsx` - [ ] **Create ScreenerTable with sortable columns** ```typescript 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 ( 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' ? '▲' : '▼') : ''} ); } const isShare = result.items[0]?.type === 'share'; return (
Найдено: {result.totalCount} бумаг
ЦенаИзм.Объём {isShare ? ( Капитализация ) : ( <> YTMДюрацияКупонКуп. % )} {result.items.map((item) => { const change = formatChange(item.changePercent); const link = isShare ? `/stocks/${item.secid}` : `/bonds/${item.secid}`; return ( {isShare ? ( ) : ( <> )} ); })}
Тикер Название
{item.secid} {item.shortName} {formatNum(item.price)} {change.text} {item.volume.toLocaleString('ru-RU')} {item.capitalization != null ? item.capitalization.toLocaleString('ru-RU') : '—'} {formatNum(item.yieldToMaturity)} {item.duration != null ? `${item.duration.toFixed(2)}г` : '—'} {formatNum(item.couponValue)} {formatNum(item.couponPercent)}
{result.totalPages > 1 && (
{Array.from({ length: Math.min(result.totalPages, 10) }, (_, i) => i + 1).map((p) => ( ))}
)}
); } ``` --- ### Task 12: Frontend ScreenerPage **Files:** - Create: `apps/frontend/src/pages/screener/ScreenerPage.tsx` - [ ] **Create ScreenerPage** ```typescript 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 (

Скринер

{isLoading && (
Загрузка...
)} {error && (
Ошибка загрузки данных. Попробуйте позже.
)} {!isLoading && !error && result && result.items.length === 0 && (
Ничего не найдено. Попробуйте смягчить фильтры.
)} {!isLoading && !error && result && result.items.length > 0 && ( )}
); } ``` --- ### 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** ```typescript import { ScreenerPage } from './pages/screener/ScreenerPage'; // Inside inside the }> group: } /> ``` Full updated routes.tsx: ```typescript 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 ( }> } /> } /> } /> } /> } /> } /> } /> } /> } /> ); } ``` - [ ] **Add Скринер nav link to Layout.tsx** Add after the Портфели link (around line 47): ```typescript Скринер ``` --- ### Task 14: Verify everything works - [ ] **Run all backend tests** ```bash npx vitest run -w apps/backend ``` Expected: all tests pass - [ ] **Build backend** ```bash npm run build:backend ``` Expected: no errors - [ ] **Build frontend** ```bash npm run build:frontend ``` Expected: no errors - [ ] **Run lint** ```bash npm run lint ``` Expected: no errors - [ ] **Run frontend tests** ```bash npx vitest run -w apps/frontend ``` Expected: all tests pass - [ ] **Commit** ```bash 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" ```