50 KiB
Raw Blame History

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:

async getShareMarketDataBatch(
  secids: string[],
  boardId = 'TQBR',
): Promise<MoexShareMarketData[]> {
  const params: Record<string, string> = { boards: boardId };
  if (secids.length > 0) {
    params.securities = secids.join(',');
  }
  const data = await this.request<Record<string, unknown>>(
    `/engines/stock/markets/shares/securities`,
    params,
  );
  const securities = this.extractTable(data, 'securities');
  const marketdata = this.extractTable(data, 'marketdata');

  const secidSet = secids.length > 0 ? new Set(secids) : null;
  const filteredSecurities = secidSet
    ? securities.filter((r) => secidSet.has(r.SECID as string))
    : securities;

  const result = await Promise.all(
    filteredSecurities.map(async (sec) => {
      const secid = sec.SECID as string;
      const mkt = marketdata.find(
        (r) => r.SECID === secid && r.BOARDID === boardId,
      ) ?? marketdata.find((r) => r.SECID === secid);
      return {
        secid,
        boardid: boardId,
        shortName: (sec.SHORTNAME as string) || '',
        bid: mkt ? parseFloat((mkt.BID as string) || '') : null,
        offer: mkt ? parseFloat((mkt.OFFER as string) || '') : null,
        open: mkt ? parseFloat((mkt.OPEN as string) || '') : null,
        low: mkt ? parseFloat((mkt.LOW as string) || '') : null,
        high: mkt ? parseFloat((mkt.HIGH as string) || '') : null,
        last: mkt
          ? parseFloat((mkt.LAST as string) || '')
          : parseFloat((sec.PREVPRICE as string) || ''),
        lastChange: mkt ? parseFloat((mkt.LASTCHANGE as string) || '') : null,
        lastChangePrcnt: mkt ? parseFloat((mkt.LASTCHANGEPRCNT as string) || '') : null,
        volume: mkt ? parseInt((mkt.VOLTODAY as string) || '0', 10) : 0,
        value: mkt ? parseFloat((mkt.VALTODAY as string) || '0') : 0,
        waprice: mkt ? parseFloat((mkt.WAPRICE as string) || '') : null,
        numtrades: mkt ? parseInt((mkt.NUMTRADES as string) || '0', 10) : 0,
        issueCapitalization: mkt ? parseFloat((mkt.ISSUECAPITALIZATION as string) || '') : null,
        tradingStatus: (mkt?.TRADINGSTATUS as string) || '',
        updateTime: (mkt?.UPDATETIME as string) || '',
      };
    }),
  );

  return result;
}

Replace the getBondPositionDataBatch method:

async getBondPositionDataBatch(
  secids: string[],
  boardId = 'TQCB',
): Promise<MoexBondPositionData[]> {
  const params: Record<string, string> = { boards: boardId };
  if (secids.length > 0) {
    params.securities = secids.join(',');
  }
  const data = await this.request<Record<string, unknown>>(
    `/engines/stock/markets/bonds/securities`,
    params,
  );
  const securities = this.extractTable(data, 'securities');
  const marketdata = this.extractTable(data, 'marketdata');

  const secidSet = secids.length > 0 ? new Set(secids) : null;
  const filteredBonds = secidSet
    ? securities.filter((r) => secidSet.has(r.SECID as string))
    : securities;

  return filteredBonds.map((bond) => {
    const secid = bond.SECID as string;
    const mkt =
      marketdata.find((r) => r.SECID === secid && r.BOARDID === boardId && r.LAST != null) ??
      marketdata.find((r) => r.LAST != null) ??
      marketdata.find((r) => r.SECID === secid);

    return {
      secid,
      boardid: boardId,
      shortName: (bond.SHORTNAME as string) || '',
      price: mkt?.LAST != null ? parseFloat(mkt.LAST as string) : null,
      yieldToMaturity: mkt?.YIELD != null ? parseFloat(mkt.YIELD as string) : null,
      duration: mkt?.DURATION != null ? parseFloat(mkt.DURATION as string) : null,
      couponValue: bond.COUPONVALUE != null ? parseFloat(bond.COUPONVALUE as string) : null,
      couponPercent: bond.COUPONPERCENT != null ? parseFloat(bond.COUPONPERCENT as string) : null,
      nextCouponDate: (bond.NEXTCOUPON as string) || null,
      matDate: (bond.MATDATE as string) || null,
      accruedInt: bond.ACCRUEDINT != null ? parseFloat(bond.ACCRUEDINT as string) : null,
      faceValue: parseFloat((bond.FACEVALUE as string) || '1000'),
      bid: mkt?.BID != null ? parseFloat(mkt.BID as string) : null,
      offer: mkt?.OFFER != null ? parseFloat(mkt.OFFER as string) : null,
      couponPeriod: parseInt((bond.COUPONPERIOD as string) || '0', 10),
      bondType: (bond.BONDTYPE as string) || null,
      offerDate: (bond.OFFERDATE as string) || null,
    };
  });
}
  • Verify existing portfolio tests still pass
npx vitest run apps/backend/src/modules/portfolio/portfolio.service.spec.ts -w apps/backend

Expected: all existing tests pass (they pass non-empty arrays, behavior unchanged)


Task 2: Backend ScreenerQueryDto

Files:

  • Create: apps/backend/src/modules/securities/dto/screener-query.dto.ts

  • Create ScreenerQueryDto with validation

import { Type, Transform } from 'class-transformer';
import {
  IsString, IsOptional, IsNumber, IsInt, Min, Max,
  IsEnum, IsIn, MinLength,
} from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';

export enum ScreenerType {
  SHARE = 'share',
  BOND = 'bond',
}

export const SORTER_FIELDS = [
  'price', 'changePercent', 'volume', 'listLevel',
  'capitalization', 'yieldToMaturity', 'duration',
  'couponValue', 'couponPercent',
] as const;

export class ScreenerQueryDto {
  @ApiProperty({ enum: ScreenerType })
  @IsEnum(ScreenerType)
  type!: ScreenerType;

  @ApiPropertyOptional()
  @IsNumber()
  @IsOptional()
  @Type(() => Number)
  priceMin?: number;

  @ApiPropertyOptional()
  @IsNumber()
  @IsOptional()
  @Type(() => Number)
  priceMax?: number;

  @ApiPropertyOptional()
  @IsInt()
  @Min(0)
  @IsOptional()
  @Type(() => Number)
  volumeMin?: number;

  @ApiPropertyOptional()
  @IsInt()
  @Min(1)
  @Max(3)
  @IsOptional()
  @Type(() => Number)
  listLevel?: number;

  @ApiPropertyOptional()
  @IsNumber()
  @IsOptional()
  @Type(() => Number)
  changePercentMin?: number;

  @ApiPropertyOptional()
  @IsNumber()
  @IsOptional()
  @Type(() => Number)
  changePercentMax?: number;

  @ApiPropertyOptional()
  @IsNumber()
  @IsOptional()
  @Type(() => Number)
  capitalizationMin?: number;

  @ApiPropertyOptional()
  @IsNumber()
  @IsOptional()
  @Type(() => Number)
  yieldMin?: number;

  @ApiPropertyOptional()
  @IsNumber()
  @IsOptional()
  @Type(() => Number)
  yieldMax?: number;

  @ApiPropertyOptional()
  @IsNumber()
  @IsOptional()
  @Type(() => Number)
  durationMin?: number;

  @ApiPropertyOptional()
  @IsNumber()
  @IsOptional()
  @Type(() => Number)
  durationMax?: number;

  @ApiPropertyOptional()
  @IsNumber()
  @IsOptional()
  @Type(() => Number)
  couponMin?: number;

  @ApiPropertyOptional()
  @IsNumber()
  @IsOptional()
  @Type(() => Number)
  couponMax?: number;

  @ApiPropertyOptional()
  @IsNumber()
  @IsOptional()
  @Type(() => Number)
  couponPercentMin?: number;

  @ApiPropertyOptional()
  @IsNumber()
  @IsOptional()
  @Type(() => Number)
  couponPercentMax?: number;

  @ApiPropertyOptional()
  @IsString()
  @IsOptional()
  maturityBefore?: string;

  @ApiPropertyOptional()
  @IsString()
  @IsOptional()
  maturityAfter?: string;

  @ApiPropertyOptional()
  @IsString()
  @IsOptional()
  bondType?: string;

  @ApiPropertyOptional({ default: 'price' })
  @IsString()
  @IsOptional()
  sortBy?: string;

  @ApiPropertyOptional({ default: 'asc' })
  @IsString()
  @IsIn(['asc', 'desc'])
  @IsOptional()
  sortOrder?: 'asc' | 'desc';

  @ApiPropertyOptional({ default: 1 })
  @IsInt()
  @Min(1)
  @IsOptional()
  @Type(() => Number)
  page?: number;

  @ApiPropertyOptional({ default: 20 })
  @IsInt()
  @Min(1)
  @Max(100)
  @IsOptional()
  @Type(() => Number)
  pageSize?: number;
}

Task 3: Backend ScreenerResponseDto

Files:

  • Create: apps/backend/src/modules/securities/dto/screener-response.dto.ts

  • Create ScreenerItemDto and ScreenerResultDto

import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';

export class ScreenerItemDto {
  @ApiProperty({ example: 'SBER' })
  secid: string;

  @ApiProperty({ example: 'Сбербанк' })
  shortName: string;

  @ApiProperty({ example: 'RU0009029540' })
  isin: string;

  @ApiProperty({ enum: ['share', 'bond'] })
  type: 'share' | 'bond';

  @ApiPropertyOptional({ example: 322.35 })
  price: number | null;

  @ApiPropertyOptional({ example: 1.15 })
  change: number | null;

  @ApiPropertyOptional({ example: 0.36 })
  changePercent: number | null;

  @ApiProperty({ example: 1925163 })
  volume: number;

  @ApiProperty({ example: 1 })
  listLevel: number;

  @ApiPropertyOptional({ example: 6958336818320 })
  capitalization: number | null;

  @ApiPropertyOptional({ example: 12.71 })
  yieldToMaturity: number | null;

  @ApiPropertyOptional({ example: 4.5 })
  duration: number | null;

  @ApiPropertyOptional({ example: 40.64 })
  couponValue: number | null;

  @ApiPropertyOptional({ example: 8.15 })
  couponPercent: number | null;

  @ApiPropertyOptional({ example: 29.48 })
  accruedInt: number | null;

  @ApiPropertyOptional({ example: '2027-02-03' })
  matDate: string | null;

  @ApiPropertyOptional({ example: 'ОФЗ-ПД' })
  bondType: string | null;
}

export class ScreenerResultDto {
  @ApiProperty()
  totalCount: number;

  @ApiProperty()
  page: number;

  @ApiProperty()
  pageSize: number;

  @ApiProperty()
  totalPages: number;

  @ApiProperty({ type: [ScreenerItemDto] })
  items: ScreenerItemDto[];
}

Task 4: Backend ScreenerService

Files:

  • Create: apps/backend/src/modules/securities/screener.service.ts

  • Create ScreenerService with filtering, sorting, pagination

import { Injectable } from '@nestjs/common';
import { MoexClientService } from '../moex-client/moex-client.service';
import { CacheService } from '../cache/cache.service';
import { ScreenerQueryDto, ScreenerType } from './dto/screener-query.dto';
import { ScreenerItemDto, ScreenerResultDto } from './dto/screener-response.dto';

@Injectable()
export class ScreenerService {
  constructor(
    private readonly moexClient: MoexClientService,
    private readonly cache: CacheService,
  ) {}

  async screen(query: ScreenerQueryDto): Promise<ScreenerResultDto> {
    const board = await this.fetchBoard(query.type);

    const filtered = board.filter((item) => this.matches(item, query));
    const sorted = this.sort(filtered, query.sortBy ?? 'price', query.sortOrder ?? 'asc');
    const page = query.page ?? 1;
    const pageSize = query.pageSize ?? 20;
    const totalCount = sorted.length;
    const totalPages = Math.ceil(totalCount / pageSize);
    const start = (page - 1) * pageSize;
    const items = sorted.slice(start, start + pageSize);

    return {
      totalCount,
      page,
      pageSize,
      totalPages,
      items,
    };
  }

  private async fetchBoard(type: ScreenerType): Promise<ScreenerItemDto[]> {
    const { data } = await this.cache.getOrFetch(
      'screener',
      [type],
      async () => {
        if (type === ScreenerType.SHARE) {
          const shares = await this.moexClient.getShareMarketDataBatch([]);
          return shares.map((s): ScreenerItemDto => ({
            secid: s.secid,
            shortName: s.shortName,
            isin: '',
            type: 'share',
            price: s.last,
            change: s.lastChange,
            changePercent: s.lastChangePrcnt,
            volume: s.volume,
            listLevel: 0,
            capitalization: s.issueCapitalization,
            yieldToMaturity: null,
            duration: null,
            couponValue: null,
            couponPercent: null,
            accruedInt: null,
            matDate: null,
            bondType: null,
          }));
        } else {
          const bonds = await this.moexClient.getBondPositionDataBatch([]);
          return bonds.map((b): ScreenerItemDto => ({
            secid: b.secid,
            shortName: b.shortName,
            isin: '',
            type: 'bond',
            price: b.price,
            change: null,
            changePercent: null,
            volume: 0,
            listLevel: 0,
            capitalization: null,
            yieldToMaturity: b.yieldToMaturity,
            duration: b.duration,
            couponValue: b.couponValue,
            couponPercent: b.couponPercent,
            accruedInt: b.accruedInt,
            matDate: b.matDate,
            bondType: b.bondType,
          }));
        }
      },
      'marketDataTtl',
    );

    return data;
  }

  private matches(item: ScreenerItemDto, q: ScreenerQueryDto): boolean {
    if (q.priceMin != null && (item.price == null || item.price < q.priceMin)) return false;
    if (q.priceMax != null && (item.price == null || item.price > q.priceMax)) return false;
    if (q.volumeMin != null && item.volume < q.volumeMin) return false;
    if (q.listLevel != null && item.listLevel !== q.listLevel) return false;

    if (item.type === 'share') {
      if (q.changePercentMin != null && (item.changePercent == null || item.changePercent < q.changePercentMin)) return false;
      if (q.changePercentMax != null && (item.changePercent == null || item.changePercent > q.changePercentMax)) return false;
      if (q.capitalizationMin != null && (item.capitalization == null || item.capitalization < q.capitalizationMin)) return false;
    }

    if (item.type === 'bond') {
      if (q.yieldMin != null && (item.yieldToMaturity == null || item.yieldToMaturity < q.yieldMin)) return false;
      if (q.yieldMax != null && (item.yieldToMaturity == null || item.yieldToMaturity > q.yieldMax)) return false;
      if (q.durationMin != null && (item.duration == null || item.duration < q.durationMin)) return false;
      if (q.durationMax != null && (item.duration == null || item.duration > q.durationMax)) return false;
      if (q.couponMin != null && (item.couponValue == null || item.couponValue < q.couponMin)) return false;
      if (q.couponMax != null && (item.couponValue == null || item.couponValue > q.couponMax)) return false;
      if (q.couponPercentMin != null && (item.couponPercent == null || item.couponPercent < q.couponPercentMin)) return false;
      if (q.couponPercentMax != null && (item.couponPercent == null || item.couponPercent > q.couponPercentMax)) return false;
      if (q.maturityBefore != null && (item.matDate == null || item.matDate > q.maturityBefore)) return false;
      if (q.maturityAfter != null && (item.matDate == null || item.matDate < q.maturityAfter)) return false;
      if (q.bondType != null && item.bondType !== q.bondType) return false;
    }

    return true;
  }

  private sort(items: ScreenerItemDto[], sortBy: string, sortOrder: 'asc' | 'desc'): ScreenerItemDto[] {
    const allowedFields = new Set([
      'secid', 'shortName', 'price', 'change', 'changePercent',
      'volume', 'listLevel', 'capitalization', 'yieldToMaturity',
      'duration', 'couponValue', 'couponPercent', 'accruedInt', 'matDate',
    ]);
    if (!allowedFields.has(sortBy)) {
      sortBy = 'price';
    }
    return [...items].sort((a, b) => {
      const aVal = (a as any)[sortBy];
      const bVal = (b as any)[sortBy];
      if (aVal == null && bVal == null) return 0;
      if (aVal == null) return 1;
      if (bVal == null) return -1;
      if (typeof aVal === 'string') {
        return sortOrder === 'asc' ? aVal.localeCompare(bVal) : bVal.localeCompare(aVal);
      }
      return sortOrder === 'asc' ? aVal - bVal : bVal - aVal;
    });
  }
}

Task 5: Backend controller + module update

Files:

  • Modify: apps/backend/src/modules/securities/securities.controller.ts

  • Modify: apps/backend/src/modules/securities/securities.module.ts

  • Add screener endpoint to SecuritiesController

import { Controller, Get, Query, ValidationPipe } from '@nestjs/common';
import { ApiTags, ApiOperation } from '@nestjs/swagger';
import { SecuritiesService } from './securities.service';
import { ScreenerService } from './screener.service';
import { SearchQueryDto, SecurityType } from './dto/search-query.dto';
import { ScreenerQueryDto } from './dto/screener-query.dto';

@ApiTags('Securities')
@Controller('securities')
export class SecuritiesController {
  constructor(
    private readonly securitiesService: SecuritiesService,
    private readonly screenerService: ScreenerService,
  ) {}

  @Get('search')
  @ApiOperation({ summary: 'Поиск по инструментам' })
  async search(@Query(ValidationPipe) query: SearchQueryDto) {
    const results = await this.securitiesService.search(
      query.q,
      query.type || SecurityType.ALL,
      query.limit || 20,
    );
    return { data: results, meta: { cachedAt: null, fromCache: false } };
  }

  @Get('screener')
  @ApiOperation({ summary: 'Фильтр ценных бумаг по параметрам' })
  async screener(@Query(ValidationPipe) query: ScreenerQueryDto) {
    const result = await this.screenerService.screen(query);
    return { data: result, meta: { cachedAt: null, fromCache: false } };
  }
}
  • Update SecuritiesModule
import { Module } from '@nestjs/common';
import { CacheModule } from '../cache/cache.module';
import { SecuritiesController } from './securities.controller';
import { SecuritiesService } from './securities.service';
import { ScreenerService } from './screener.service';

@Module({
  imports: [CacheModule],
  controllers: [SecuritiesController],
  providers: [SecuritiesService, ScreenerService],
  exports: [SecuritiesService],
})
export class SecuritiesModule {}
  • Run backend build to verify
npm run build:backend

Expected: no TypeScript errors


Task 6: Backend tests — ScreenerService

Files:

  • Create: apps/backend/src/modules/securities/screener.service.spec.ts

  • Create ScreenerService test

import { Test, TestingModule } from '@nestjs/testing';
import { ConfigModule } from '@nestjs/config';
import { ScreenerService } from './screener.service';
import { MoexClientService } from '../moex-client/moex-client.service';
import { CacheService } from '../cache/cache.service';
import configuration from '../../config/configuration';
import { ScreenerQueryDto, ScreenerType } from './dto/screener-query.dto';

describe('ScreenerService', () => {
  let service: ScreenerService;
  let moexClient: MoexClientService;
  let module: TestingModule;

  const mockShares = [
    { secid: 'SBER', shortName: 'Sberbank', last: 300, lastChange: 5, lastChangePrcnt: 1.5, volume: 1000000, issueCapitalization: 5000000000000 },
    { secid: 'GAZP', shortName: 'Gazprom', last: 200, lastChange: -2, lastChangePrcnt: -1.0, volume: 500000, issueCapitalization: 3000000000000 },
    { secid: 'VTBR', shortName: 'VTB', last: 0.05, lastChange: 0.001, lastChangePrcnt: 2.0, volume: 10000000, issueCapitalization: 100000000000 },
  ] as any[];

  beforeAll(async () => {
    module = await Test.createTestingModule({
      imports: [ConfigModule.forRoot({ load: [configuration] })],
      providers: [
        ScreenerService,
        {
          provide: MoexClientService,
          useValue: {
            getShareMarketDataBatch: vi.fn(),
            getBondPositionDataBatch: vi.fn(),
          },
        },
        {
          provide: CacheService,
          useValue: {
            getOrFetch: vi.fn(),
          },
        },
      ],
    }).compile();

    service = module.get<ScreenerService>(ScreenerService);
    moexClient = module.get<MoexClientService>(MoexClientService);
  });

  beforeEach(() => {
    vi.clearAllMocks();
  });

  it('should return all shares when no filters applied', async () => {
    vi.mocked(moexClient.getShareMarketDataBatch).mockResolvedValue(mockShares);

    const cacheMock = module.get(CacheService) as { getOrFetch: ReturnType<typeof vi.fn> };
    cacheMock.getOrFetch.mockImplementation(
      async (_prefix: string, _key: string[], fetchFn: () => Promise<any>) => ({
        data: await fetchFn(),
        fromCache: false,
        cachedAt: null,
      }),
    );

    const query = Object.assign(new ScreenerQueryDto(), { type: ScreenerType.SHARE });
    const result = await service.screen(query);

    expect(result.totalCount).toBe(3);
    expect(result.items).toHaveLength(3);
    expect(result.page).toBe(1);
    expect(result.totalPages).toBe(1);
  });

  it('should filter by price range', async () => {
    vi.mocked(moexClient.getShareMarketDataBatch).mockResolvedValue(mockShares);

    const cacheMock = module.get(CacheService) as { getOrFetch: ReturnType<typeof vi.fn> };
    cacheMock.getOrFetch.mockImplementation(
      async (_prefix: string, _key: string[], fetchFn: () => Promise<any>) => ({
        data: await fetchFn(),
        fromCache: false,
        cachedAt: null,
      }),
    );

    const query = Object.assign(new ScreenerQueryDto(), {
      type: ScreenerType.SHARE,
      priceMin: 100,
      priceMax: 250,
    });
    const result = await service.screen(query);

    expect(result.totalCount).toBe(1);
    expect(result.items[0].secid).toBe('GAZP');
  });

  it('should apply pagination', async () => {
    vi.mocked(moexClient.getShareMarketDataBatch).mockResolvedValue(mockShares);

    const cacheMock = module.get(CacheService) as { getOrFetch: ReturnType<typeof vi.fn> };
    cacheMock.getOrFetch.mockImplementation(
      async (_prefix: string, _key: string[], fetchFn: () => Promise<any>) => ({
        data: await fetchFn(),
        fromCache: false,
        cachedAt: null,
      }),
    );

    const query = Object.assign(new ScreenerQueryDto(), {
      type: ScreenerType.SHARE,
      page: 1,
      pageSize: 2,
    });
    const result = await service.screen(query);

    expect(result.items).toHaveLength(2);
    expect(result.totalCount).toBe(3);
    expect(result.totalPages).toBe(2);
  });

  it('should use cache for board data', async () => {
    const cacheMock = module.get(CacheService) as { getOrFetch: ReturnType<typeof vi.fn> };
    cacheMock.getOrFetch.mockResolvedValue({
      data: mockShares,
      fromCache: true,
      cachedAt: new Date().toISOString(),
    });

    const query = Object.assign(new ScreenerQueryDto(), { type: ScreenerType.SHARE });
    await service.screen(query);

    expect(cacheMock.getOrFetch).toHaveBeenCalledWith(
      'screener',
      ['share'],
      expect.any(Function),
      'marketDataTtl',
    );
  });

  it('should return empty result when no items match filters', async () => {
    vi.mocked(moexClient.getShareMarketDataBatch).mockResolvedValue(mockShares);

    const cacheMock = module.get(CacheService) as { getOrFetch: ReturnType<typeof vi.fn> };
    cacheMock.getOrFetch.mockImplementation(
      async (_prefix: string, _key: string[], fetchFn: () => Promise<any>) => ({
        data: await fetchFn(),
        fromCache: false,
        cachedAt: null,
      }),
    );

    const query = Object.assign(new ScreenerQueryDto(), {
      type: ScreenerType.SHARE,
      priceMin: 1000,
    });
    const result = await service.screen(query);

    expect(result.totalCount).toBe(0);
    expect(result.items).toHaveLength(0);
  });
});
  • Run tests
npx vitest run apps/backend/src/modules/securities/screener.service.spec.ts -w apps/backend

Expected: all tests pass


Task 7: Frontend types — add screener types

Files:

  • Modify: apps/frontend/src/api/responses.ts

  • Add ScreenerItem and ScreenerResult types

export interface ScreenerItem {
  secid: string;
  shortName: string;
  isin: string;
  type: 'share' | 'bond';
  price: number | null;
  change: number | null;
  changePercent: number | null;
  volume: number;
  listLevel: number;
  capitalization: number | null;
  yieldToMaturity: number | null;
  duration: number | null;
  couponValue: number | null;
  couponPercent: number | null;
  accruedInt: number | null;
  matDate: string | null;
  bondType: string | null;
}

export interface ScreenerResult {
  totalCount: number;
  page: number;
  pageSize: number;
  totalPages: number;
  items: ScreenerItem[];
}

Task 8: Frontend API client for screener

Files:

  • Create: apps/frontend/src/api/screener.ts

  • Create screener API client

import { request } from './client';
import type { ScreenerResult } from './responses';

export interface ScreenerParams {
  type: 'share' | 'bond';
  priceMin?: number;
  priceMax?: number;
  volumeMin?: number;
  listLevel?: number;
  changePercentMin?: number;
  changePercentMax?: number;
  capitalizationMin?: number;
  yieldMin?: number;
  yieldMax?: number;
  durationMin?: number;
  durationMax?: number;
  couponMin?: number;
  couponMax?: number;
  couponPercentMin?: number;
  couponPercentMax?: number;
  maturityBefore?: string;
  maturityAfter?: string;
  bondType?: string;
  sortBy?: string;
  sortOrder?: 'asc' | 'desc';
  page?: number;
  pageSize?: number;
}

export function getScreenerResults(
  params: ScreenerParams,
): Promise<{ data: ScreenerResult; meta: { cachedAt: string | null; fromCache: boolean } }> {
  const searchParams = new URLSearchParams();
  Object.entries(params).forEach(([key, value]) => {
    if (value !== undefined && value !== null && value !== '') {
      searchParams.set(key, String(value));
    }
  });
  const qs = searchParams.toString();
  return request<ScreenerResult>(`/api/v1/securities/screener${qs ? `?${qs}` : ''}`);
}

Task 9: Frontend useScreener hook

Files:

  • Create: apps/frontend/src/hooks/useScreener.ts

  • Create useScreener hook with URL search params sync

import { useSearchParams } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { getScreenerResults } from '../api/screener';
import type { ScreenerParams } from '../api/screener';

export function useScreener() {
  const [searchParams, setSearchParams] = useSearchParams();

  const params: ScreenerParams = {
    type: (searchParams.get('type') as 'share' | 'bond') || 'share',
    priceMin: searchParams.get('priceMin') ? Number(searchParams.get('priceMin')) : undefined,
    priceMax: searchParams.get('priceMax') ? Number(searchParams.get('priceMax')) : undefined,
    volumeMin: searchParams.get('volumeMin') ? Number(searchParams.get('volumeMin')) : undefined,
    listLevel: searchParams.get('listLevel') ? Number(searchParams.get('listLevel')) : undefined,
    changePercentMin: searchParams.get('changePercentMin') ? Number(searchParams.get('changePercentMin')) : undefined,
    changePercentMax: searchParams.get('changePercentMax') ? Number(searchParams.get('changePercentMax')) : undefined,
    capitalizationMin: searchParams.get('capitalizationMin') ? Number(searchParams.get('capitalizationMin')) : undefined,
    yieldMin: searchParams.get('yieldMin') ? Number(searchParams.get('yieldMin')) : undefined,
    yieldMax: searchParams.get('yieldMax') ? Number(searchParams.get('yieldMax')) : undefined,
    durationMin: searchParams.get('durationMin') ? Number(searchParams.get('durationMin')) : undefined,
    durationMax: searchParams.get('durationMax') ? Number(searchParams.get('durationMax')) : undefined,
    couponMin: searchParams.get('couponMin') ? Number(searchParams.get('couponMin')) : undefined,
    couponMax: searchParams.get('couponMax') ? Number(searchParams.get('couponMax')) : undefined,
    couponPercentMin: searchParams.get('couponPercentMin') ? Number(searchParams.get('couponPercentMin')) : undefined,
    couponPercentMax: searchParams.get('couponPercentMax') ? Number(searchParams.get('couponPercentMax')) : undefined,
    maturityBefore: searchParams.get('maturityBefore') || undefined,
    maturityAfter: searchParams.get('maturityAfter') || undefined,
    bondType: searchParams.get('bondType') || undefined,
    sortBy: searchParams.get('sortBy') || undefined,
    sortOrder: (searchParams.get('sortOrder') as 'asc' | 'desc') || undefined,
    page: searchParams.get('page') ? Number(searchParams.get('page')) : undefined,
    pageSize: searchParams.get('pageSize') ? Number(searchParams.get('pageSize')) : undefined,
  };

  const queryKey = ['screener', params];

  const query = useQuery({
    queryKey,
    queryFn: () => getScreenerResults(params),
    staleTime: 60_000,
    retry: 2,
    refetchOnWindowFocus: false,
  });

  function setParam(key: string, value: string | undefined) {
    setSearchParams((prev) => {
      const next = new URLSearchParams(prev);
      if (value === undefined || value === '') {
        next.delete(key);
      } else {
        next.set(key, value);
      }
      next.set('page', '1'); // reset page on filter change
      return next;
    });
  }

  function setFilters(filters: Partial<ScreenerParams>) {
    setSearchParams((prev) => {
      const next = new URLSearchParams(prev);
      Object.entries(filters).forEach(([key, value]) => {
        if (value === undefined || value === null || value === '') {
          next.delete(key);
        } else {
          next.set(key, String(value));
        }
      });
      next.set('page', '1');
      return next;
    });
  }

  function setPage(page: number) {
    setSearchParams((prev) => {
      const next = new URLSearchParams(prev);
      next.set('page', String(page));
      return next;
    });
  }

  function setSort(sortBy: string) {
    setSearchParams((prev) => {
      const next = new URLSearchParams(prev);
      const current = next.get('sortBy');
      const currentOrder = next.get('sortOrder') || 'asc';
      if (current === sortBy) {
        next.set('sortOrder', currentOrder === 'asc' ? 'desc' : 'asc');
      } else {
        next.set('sortBy', sortBy);
        next.set('sortOrder', 'asc');
      }
      next.set('page', '1');
      return next;
    });
  }

  function resetFilters() {
    setSearchParams(new URLSearchParams({ type: params.type }));
  }

  return {
    params,
    result: query.data?.data ?? null,
    isLoading: query.isLoading,
    error: query.error,
    setFilters,
    setPage,
    setSort,
    setParam,
    resetFilters,
  };
}

Task 10: Frontend FilterPanel components

Files:

  • Create: apps/frontend/src/components/screener/FilterPanel.tsx

  • Create: apps/frontend/src/components/screener/FilterPanelShare.tsx

  • Create: apps/frontend/src/components/screener/FilterPanelBond.tsx

  • Create FilterPanel component

import { useState } from 'react';
import type { ScreenerParams } from '../../api/screener';
import { FilterPanelShare } from './FilterPanelShare';
import { FilterPanelBond } from './FilterPanelBond';

interface Props {
  params: ScreenerParams;
  onApply: (filters: Partial<ScreenerParams>) => void;
  onReset: () => void;
}

export function FilterPanel({ params, onApply, onReset }: Props) {
  const [type, setType] = useState<'share' | 'bond'>(params.type);
  const [local, setLocal] = useState<Record<string, string>>({});

  function handleApply() {
    const filters: Partial<ScreenerParams> = { type };
    Object.entries(local).forEach(([key, value]) => {
      if (value !== '') {
        const num = Number(value);
        filters[key as keyof ScreenerParams] = isNaN(num) ? (value as any) : (num as any);
      }
    });
    onApply(filters);
  }

  function handleReset() {
    setLocal({});
    onReset();
  }

  function updateField(key: string, value: string) {
    setLocal((prev) => ({ ...prev, [key]: value }));
  }

  return (
    <div
      style={{
        width: 260,
        padding: 16,
        background: 'var(--color-surface)',
        border: '1px solid #e0e0e0',
        borderRadius: 'var(--border-radius)',
        display: 'flex',
        flexDirection: 'column',
        gap: 12,
      }}
    >
      <div>
        <label style={{ display: 'block', fontSize: 12, fontWeight: 600, marginBottom: 4 }}>
          Тип бумаги
        </label>
        <select
          value={type}
          onChange={(e) => setType(e.target.value as 'share' | 'bond')}
          style={{
            width: '100%',
            padding: '6px 10px',
            border: '1px solid #e0e0e0',
            borderRadius: 'var(--border-radius)',
            fontSize: 13,
          }}
        >
          <option value="share">Акции</option>
          <option value="bond">Облигации</option>
        </select>
      </div>

      {type === 'share' ? (
        <FilterPanelShare params={params} local={local} updateField={updateField} />
      ) : (
        <FilterPanelBond params={params} local={local} updateField={updateField} />
      )}

      <div style={{ display: 'flex', gap: 8 }}>
        <button
          onClick={handleApply}
          style={{
            flex: 1,
            padding: '8px 16px',
            background: 'var(--color-primary)',
            color: '#fff',
            border: 'none',
            borderRadius: 'var(--border-radius)',
            fontSize: 13,
            fontWeight: 600,
            cursor: 'pointer',
          }}
        >
          Применить
        </button>
        <button
          onClick={handleReset}
          style={{
            padding: '8px 16px',
            background: 'transparent',
            border: '1px solid #e0e0e0',
            borderRadius: 'var(--border-radius)',
            fontSize: 13,
            cursor: 'pointer',
          }}
        >
          Сбросить
        </button>
      </div>
    </div>
  );
}
  • Create FilterPanelShare component
interface Props {
  params: Record<string, any>;
  local: Record<string, string>;
  updateField: (key: string, value: string) => void;
}

export function FilterPanelShare({ local, updateField }: Props) {
  const fields = [
    { key: 'priceMin', label: 'Цена от' },
    { key: 'priceMax', label: 'Цена до' },
    { key: 'changePercentMin', label: 'Изм. % от' },
    { key: 'changePercentMax', label: 'Изм. % до' },
    { key: 'volumeMin', label: 'Объём от' },
    { key: 'capitalizationMin', label: 'Капитализация от' },
  ];

  return (
    <div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
      {fields.map(({ key, label }) => (
        <div key={key}>
          <label style={{ display: 'block', fontSize: 11, fontWeight: 600, marginBottom: 2, color: 'var(--color-text-secondary)' }}>
            {label}
          </label>
          <input
            type="number"
            value={local[key] ?? ''}
            onChange={(e) => updateField(key, e.target.value)}
            style={{
              width: '100%',
              padding: '6px 10px',
              border: '1px solid #e0e0e0',
              borderRadius: 'var(--border-radius)',
              fontSize: 13,
              boxSizing: 'border-box',
            }}
          />
        </div>
      ))}
    </div>
  );
}
  • Create FilterPanelBond component
interface Props {
  params: Record<string, any>;
  local: Record<string, string>;
  updateField: (key: string, value: string) => void;
}

export function FilterPanelBond({ local, updateField }: Props) {
  const fields = [
    { key: 'priceMin', label: 'Цена (% от номинала) от' },
    { key: 'priceMax', label: 'Цена (% от номинала) до' },
    { key: 'yieldMin', label: 'YTM % от' },
    { key: 'yieldMax', label: 'YTM % до' },
    { key: 'durationMin', label: 'Дюрация (лет) от' },
    { key: 'durationMax', label: 'Дюрация (лет) до' },
    { key: 'couponMin', label: 'Купон (₽) от' },
    { key: 'couponMax', label: 'Купон (₽) до' },
    { key: 'couponPercentMin', label: 'Купон % от' },
    { key: 'couponPercentMax', label: 'Купон % до' },
  ];

  return (
    <div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
      {fields.map(({ key, label }) => (
        <div key={key}>
          <label style={{ display: 'block', fontSize: 11, fontWeight: 600, marginBottom: 2, color: 'var(--color-text-secondary)' }}>
            {label}
          </label>
          <input
            type="number"
            value={local[key] ?? ''}
            onChange={(e) => updateField(key, e.target.value)}
            style={{
              width: '100%',
              padding: '6px 10px',
              border: '1px solid #e0e0e0',
              borderRadius: 'var(--border-radius)',
              fontSize: 13,
              boxSizing: 'border-box',
            }}
          />
        </div>
      ))}
    </div>
  );
}

Task 11: Frontend ScreenerTable component

Files:

  • Create: apps/frontend/src/components/screener/ScreenerTable.tsx

  • Create ScreenerTable with sortable columns

import { Link } from 'react-router-dom';
import type { ScreenerItem, ScreenerResult } from '../../api/responses';

interface Props {
  result: ScreenerResult;
  sortBy: string;
  sortOrder: 'asc' | 'desc';
  onSort: (field: string) => void;
  onPageChange: (page: number) => void;
}

function formatNum(value: number | null | undefined, digits = 2): string {
  if (value == null) return '—';
  return value.toLocaleString('ru-RU', { minimumFractionDigits: digits, maximumFractionDigits: digits });
}

function formatChange(value: number | null | undefined): { text: string; color: string } {
  if (value == null) return { text: '—', color: 'inherit' };
  const color = value > 0 ? '#43a047' : value < 0 ? '#e53935' : 'inherit';
  return { text: `${value > 0 ? '+' : ''}${value.toFixed(2)}%`, color };
}

export function ScreenerTable({ result, sortBy, sortOrder, onSort, onPageChange }: Props) {
  function SortHeader({ field, children }: { field: string; children: string }) {
    const isActive = sortBy === field;
    return (
      <th
        onClick={() => onSort(field)}
        style={{
          textAlign: 'right',
          padding: '8px 12px',
          fontWeight: 600,
          fontSize: 12,
          color: 'var(--color-text-secondary)',
          cursor: 'pointer',
          userSelect: 'none',
          whiteSpace: 'nowrap',
        }}
      >
        {children} {isActive ? (sortOrder === 'asc' ? '▲' : '▼') : ''}
      </th>
    );
  }

  const isShare = result.items[0]?.type === 'share';

  return (
    <div style={{ flex: 1 }}>
      <div style={{ fontSize: 13, color: 'var(--color-text-secondary)', marginBottom: 8 }}>
        Найдено: {result.totalCount} бумаг
      </div>
      <div style={{ overflowX: 'auto' }}>
        <table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
          <thead>
            <tr style={{ borderBottom: '2px solid #e0e0e0' }}>
              <th style={{ textAlign: 'left', padding: '8px 12px', fontWeight: 600, fontSize: 12, color: 'var(--color-text-secondary)' }}>
                Тикер
              </th>
              <th style={{ textAlign: 'left', padding: '8px 12px', fontWeight: 600, fontSize: 12, color: 'var(--color-text-secondary)' }}>
                Название
              </th>
              <SortHeader field="price">Цена</SortHeader>
              <SortHeader field="changePercent">Изм.</SortHeader>
              <SortHeader field="volume">Объём</SortHeader>
              {isShare ? (
                <SortHeader field="capitalization">Капитализация</SortHeader>
              ) : (
                <>
                  <SortHeader field="yieldToMaturity">YTM</SortHeader>
                  <SortHeader field="duration">Дюрация</SortHeader>
                  <SortHeader field="couponValue">Купон</SortHeader>
                  <SortHeader field="couponPercent">Куп. %</SortHeader>
                </>
              )}
            </tr>
          </thead>
          <tbody>
            {result.items.map((item) => {
              const change = formatChange(item.changePercent);
              const link = isShare ? `/stocks/${item.secid}` : `/bonds/${item.secid}`;
              return (
                <tr key={item.secid} style={{ borderBottom: '1px solid #f0f0f0' }}>
                  <td style={{ padding: '8px 12px', fontWeight: 600, fontFamily: 'monospace' }}>
                    <Link to={link} style={{ color: 'inherit', textDecoration: 'none' }}>
                      {item.secid}
                    </Link>
                  </td>
                  <td style={{ padding: '8px 12px', color: 'var(--color-text-secondary)' }}>
                    {item.shortName}
                  </td>
                  <td style={{ padding: '8px 12px', textAlign: 'right' }}>
                    {formatNum(item.price)}
                  </td>
                  <td style={{ padding: '8px 12px', textAlign: 'right', color: change.color }}>
                    {change.text}
                  </td>
                  <td style={{ padding: '8px 12px', textAlign: 'right' }}>
                    {item.volume.toLocaleString('ru-RU')}
                  </td>
                  {isShare ? (
                    <td style={{ padding: '8px 12px', textAlign: 'right' }}>
                      {item.capitalization != null ? item.capitalization.toLocaleString('ru-RU') : '—'}
                    </td>
                  ) : (
                    <>
                      <td style={{ padding: '8px 12px', textAlign: 'right' }}>
                        {formatNum(item.yieldToMaturity)}
                      </td>
                      <td style={{ padding: '8px 12px', textAlign: 'right' }}>
                        {item.duration != null ? `${item.duration.toFixed(2)}г` : '—'}
                      </td>
                      <td style={{ padding: '8px 12px', textAlign: 'right' }}>
                        {formatNum(item.couponValue)}
                      </td>
                      <td style={{ padding: '8px 12px', textAlign: 'right' }}>
                        {formatNum(item.couponPercent)}
                      </td>
                    </>
                  )}
                </tr>
              );
            })}
          </tbody>
        </table>
      </div>

      {result.totalPages > 1 && (
        <div style={{ display: 'flex', justifyContent: 'center', gap: 8, marginTop: 16 }}>
          {Array.from({ length: Math.min(result.totalPages, 10) }, (_, i) => i + 1).map((p) => (
            <button
              key={p}
              onClick={() => onPageChange(p)}
              style={{
                padding: '4px 10px',
                background: p === result.page ? 'var(--color-primary)' : 'transparent',
                color: p === result.page ? '#fff' : 'var(--color-text)',
                border: '1px solid #e0e0e0',
                borderRadius: 'var(--border-radius)',
                fontSize: 13,
                cursor: 'pointer',
              }}
            >
              {p}
            </button>
          ))}
        </div>
      )}
    </div>
  );
}

Task 12: Frontend ScreenerPage

Files:

  • Create: apps/frontend/src/pages/screener/ScreenerPage.tsx

  • Create ScreenerPage

import { useScreener } from '../../hooks/useScreener';
import { FilterPanel } from '../../components/screener/FilterPanel';
import { ScreenerTable } from '../../components/screener/ScreenerTable';

export function ScreenerPage() {
  const {
    params, result, isLoading, error,
    setFilters, setPage, setSort, resetFilters,
  } = useScreener();

  return (
    <div>
      <h1 style={{ margin: '0 0 20px', fontSize: 24, fontWeight: 700 }}>Скринер</h1>
      <div style={{ display: 'flex', gap: 20, alignItems: 'flex-start' }}>
        <FilterPanel
          params={params}
          onApply={setFilters}
          onReset={resetFilters}
        />

        {isLoading && (
          <div style={{ flex: 1, textAlign: 'center', padding: 40, color: 'var(--color-text-secondary)' }}>
            Загрузка...
          </div>
        )}

        {error && (
          <div style={{ flex: 1, textAlign: 'center', padding: 40, color: '#e53935' }}>
            Ошибка загрузки данных. Попробуйте позже.
          </div>
        )}

        {!isLoading && !error && result && result.items.length === 0 && (
          <div style={{ flex: 1, textAlign: 'center', padding: 40, color: 'var(--color-text-secondary)' }}>
            Ничего не найдено. Попробуйте смягчить фильтры.
          </div>
        )}

        {!isLoading && !error && result && result.items.length > 0 && (
          <ScreenerTable
            result={result}
            sortBy={params.sortBy || 'price'}
            sortOrder={params.sortOrder || 'asc'}
            onSort={setSort}
            onPageChange={setPage}
          />
        )}
      </div>
    </div>
  );
}

Files:

  • Modify: apps/frontend/src/routes.tsx

  • Modify: apps/frontend/src/components/Layout.tsx

  • Add /screener route to routes.tsx

import { ScreenerPage } from './pages/screener/ScreenerPage';

// Inside <Routes> inside the <Route element={<Layout />}> group:
<Route path="/screener" element={<ScreenerPage />} />

Full updated routes.tsx:

import { Routes, Route } from 'react-router-dom';
import { Layout } from './components/Layout';
import { HomePage } from './pages/HomePage';
import { StockPage } from './pages/StockPage';
import { BondPage } from './pages/BondPage';
import { LoginPage } from './pages/LoginPage';
import { RegisterPage } from './pages/RegisterPage';
import { ProfilePage } from './pages/ProfilePage';
import { ProtectedRoute } from './components/ProtectedRoute';
import { PortfoliosListPage } from './pages/portfolios/PortfoliosListPage';
import { PortfolioDetailPage } from './pages/portfolios/PortfolioDetailPage';
import { ScreenerPage } from './pages/screener/ScreenerPage';

export function AppRoutes() {
  return (
    <Routes>
      <Route element={<Layout />}>
        <Route path="/" element={<HomePage />} />
        <Route path="/stocks/:secid" element={<StockPage />} />
        <Route path="/bonds/:secid" element={<BondPage />} />
        <Route path="/screener" element={<ScreenerPage />} />
        <Route path="/login" element={<LoginPage />} />
        <Route path="/register" element={<RegisterPage />} />
        <Route path="/profile" element={<ProtectedRoute><ProfilePage /></ProtectedRoute>} />
        <Route path="/portfolios" element={<ProtectedRoute><PortfoliosListPage /></ProtectedRoute>} />
        <Route path="/portfolios/:id" element={<ProtectedRoute><PortfolioDetailPage /></ProtectedRoute>} />
      </Route>
    </Routes>
  );
}
  • Add Скринер nav link to Layout.tsx

Add after the Портфели link (around line 47):

<Link
  to="/screener"
  style={{
    fontSize: 14,
    color: 'var(--color-text)',
    textDecoration: 'none',
    fontWeight: 500,
  }}
>
  Скринер
</Link>

Task 14: Verify everything works

  • Run all backend tests
npx vitest run -w apps/backend

Expected: all tests pass

  • Build backend
npm run build:backend

Expected: no errors

  • Build frontend
npm run build:frontend

Expected: no errors

  • Run lint
npm run lint

Expected: no errors

  • Run frontend tests
npx vitest run -w apps/frontend

Expected: all tests pass

  • Commit
git add apps/backend/src/modules/moex-client/moex-client.service.ts \
  apps/backend/src/modules/securities/dto/screener-query.dto.ts \
  apps/backend/src/modules/securities/dto/screener-response.dto.ts \
  apps/backend/src/modules/securities/screener.service.ts \
  apps/backend/src/modules/securities/screener.service.spec.ts \
  apps/backend/src/modules/securities/securities.controller.ts \
  apps/backend/src/modules/securities/securities.module.ts \
  apps/frontend/src/api/responses.ts \
  apps/frontend/src/api/screener.ts \
  apps/frontend/src/hooks/useScreener.ts \
  apps/frontend/src/components/screener/FilterPanel.tsx \
  apps/frontend/src/components/screener/FilterPanelShare.tsx \
  apps/frontend/src/components/screener/FilterPanelBond.tsx \
  apps/frontend/src/components/screener/ScreenerTable.tsx \
  apps/frontend/src/pages/screener/ScreenerPage.tsx \
  apps/frontend/src/routes.tsx \
  apps/frontend/src/components/Layout.tsx
git commit -m "feat: add security screener with filtering and sorting"