feat: add stock screener
Backend verify / commits (pull_request) Successful in 28s
Backend verify / security (pull_request) Successful in 11s
Backend verify / verify (pull_request) Successful in 4m45s

This commit is contained in:
2026-10-11 11:18:21 +03:00
parent bc631e72de
commit 7d10ef8adf
15 changed files with 901 additions and 1 deletions
@@ -1,15 +1,21 @@
import type { Request } from 'express';
import {
BadRequestException,
Body,
Controller,
Get,
HttpCode,
HttpStatus,
Param,
ParseUUIDPipe,
Post,
Query,
Req,
UseGuards,
ValidationPipe,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger';
import { ApiBearerAuth, ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger';
import { ApiProblemResponses } from '../../../../../common/decorators/api-safe-error-response.decorator';
import { AccessPayload, JwtAuthGuard } from '../../../../auth/auth.api';
@@ -18,6 +24,7 @@ import { GetStockCompanyUseCase } from '../application/get-stock-company.use-cas
import { StockCatalogService } from '../application/stock-catalog.service';
import { StockIncomeService } from '../application/stock-income.service';
import { StockMarketService } from '../application/stock-market.service';
import { StockScreenerService } from '../application/stock-screener.service';
import {
StockCandlesQueryDto,
StockCandlesResourceDto,
@@ -26,6 +33,9 @@ import {
StockPositionResourceDto,
StockQuoteResourceDto,
StockResourceDto,
StockScreenerCriteriaDto,
StockScreenerPageDto,
StockScreenerPageQueryDto,
StockSearchQueryDto,
StockSearchResourceDto,
StockTickerParamsDto,
@@ -42,6 +52,7 @@ export class StockController {
private readonly market: StockMarketService,
private readonly income: StockIncomeService,
private readonly companyInfo: GetStockCompanyUseCase,
private readonly screener: StockScreenerService,
) {}
@Get()
@@ -71,6 +82,33 @@ export class StockController {
);
}
@Post('screener/search')
@HttpCode(HttpStatus.OK)
@ApiOperation({ operationId: 'createStockScreenerSearch' })
@ApiOkResponse({ type: StockScreenerPageDto })
searchScreener(
@Req() request: Request & { user: AccessPayload },
@Body(new ValidationPipe({ forbidNonWhitelisted: true, forbidUnknownValues: true, whitelist: true }))
criteria: StockScreenerCriteriaDto,
) {
const allowed = new Set(['countryOfRisk', 'currency', 'maxLot', 'query', 'realExchange', 'sector', 'shareType']);
if (Object.keys(request.body as object).some((key) => !allowed.has(key))) {
throw new BadRequestException();
}
return this.screener.search(request.user.sub, criteria);
}
@Get('screener/search/:searchId')
@ApiOperation({ operationId: 'readStockScreenerPage' })
@ApiOkResponse({ type: StockScreenerPageDto })
pageScreener(
@Req() request: Request & { user: AccessPayload },
@Param('searchId') searchId: string,
@Query() query: StockScreenerPageQueryDto,
) {
return this.screener.page(request.user.sub, searchId, query.page);
}
@Get(':uid')
@ApiOkResponse({ type: StockResourceDto })
get(
@@ -2,10 +2,12 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import {
IsIn,
IsInt,
IsOptional,
IsString,
Matches,
MaxLength,
Min,
MinLength,
} from 'class-validator';
@@ -21,6 +23,86 @@ import {
import { PortfolioPositionDto } from '../../../portfolio/portfolio.api';
const MIN_TICKER_LENGTH = 1;
const MAX_SCREENER_QUERY_LENGTH = 80;
const MIN_PAGE = 1;
export class StockScreenerCriteriaDto {
@ApiPropertyOptional({ maxLength: MAX_SCREENER_QUERY_LENGTH })
@Transform(({ value }: { value: unknown }) =>
value === '' ? undefined : value,
)
@IsOptional()
@IsString()
@MinLength(MIN_TICKER_LENGTH)
@MaxLength(MAX_SCREENER_QUERY_LENGTH)
query?: string;
@ApiPropertyOptional({ minLength: MIN_TICKER_LENGTH })
@IsOptional()
@IsString()
@MinLength(MIN_TICKER_LENGTH)
sector?: string;
@ApiPropertyOptional({ minLength: MIN_TICKER_LENGTH })
@IsOptional()
@IsString()
@MinLength(MIN_TICKER_LENGTH)
shareType?: string;
@ApiPropertyOptional({ minLength: MIN_TICKER_LENGTH })
@IsOptional()
@IsString()
@MinLength(MIN_TICKER_LENGTH)
currency?: string;
@ApiPropertyOptional({ minLength: MIN_TICKER_LENGTH })
@IsOptional()
@IsString()
@MinLength(MIN_TICKER_LENGTH)
realExchange?: string;
@ApiPropertyOptional({ minLength: MIN_TICKER_LENGTH })
@IsOptional()
@IsString()
@MinLength(MIN_TICKER_LENGTH)
countryOfRisk?: string;
@ApiPropertyOptional({ minimum: MIN_TICKER_LENGTH, type: 'integer' })
@IsOptional()
@IsInt()
@Min(MIN_TICKER_LENGTH)
maxLot?: number;
}
export class StockScreenerPageQueryDto {
@ApiProperty({ minimum: MIN_PAGE, type: 'integer' })
@Transform(({ value }: { value: unknown }) => Number(value))
@IsInt()
@Min(MIN_PAGE)
declare page: number;
}
export class StockScreenerItemDto {
@ApiProperty({ format: 'uuid' }) declare instrumentUid: string;
@ApiProperty() declare name: string;
@ApiProperty({ nullable: true, type: String }) declare ticker: null | string;
@ApiProperty({ nullable: true, type: String }) declare isin: null | string;
@ApiProperty({ nullable: true, type: String }) declare logoName: null | string;
@ApiProperty({ nullable: true, type: String }) declare shareType: null | string;
@ApiProperty({ nullable: true, type: String }) declare sector: null | string;
@ApiProperty({ nullable: true, type: String }) declare currency: null | string;
@ApiProperty({ nullable: true, type: String }) declare realExchange: null | string;
@ApiProperty({ nullable: true, type: String }) declare countryOfRisk: null | string;
@ApiProperty({ nullable: true, type: 'integer' }) declare lot: null | number;
}
export class StockScreenerPageDto {
@ApiProperty() declare searchId: string;
@ApiProperty({ format: 'date-time' }) declare asOf: string;
@ApiProperty({ minimum: MIN_PAGE, type: 'integer' }) declare page: number;
@ApiProperty() declare hasNext: boolean;
@ApiProperty({ type: StockScreenerItemDto, isArray: true, maxItems: 30 }) declare items: StockScreenerItemDto[];
}
export class StockSearchQueryDto extends InvestmentReadQueryDto {
@ApiProperty({ minLength: MIN_INSTRUMENT_QUERY_LENGTH, maxLength: MAX_INSTRUMENT_QUERY_LENGTH })