1615 lines
50 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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<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:
```typescript
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**
```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<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**
```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>(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**
```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<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**
```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<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**
```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<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**
```typescript
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**
```typescript
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**
```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 (
<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**
```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 (
<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**
```typescript
import { ScreenerPage } from './pages/screener/ScreenerPage';
// Inside <Routes> inside the <Route element={<Layout />}> group:
<Route path="/screener" element={<ScreenerPage />} />
```
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 (
<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):
```typescript
<Link
to="/screener"
style={{
fontSize: 14,
color: 'var(--color-text)',
textDecoration: 'none',
fontWeight: 500,
}}
>
Скринер
</Link>
```
---
### 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"
```