1615 lines
50 KiB
Markdown
1615 lines
50 KiB
Markdown
# 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"
|
||
```
|