diff --git a/BE/src/app.module.ts b/BE/src/app.module.ts index db5337d5..efa0165f 100644 --- a/BE/src/app.module.ts +++ b/BE/src/app.module.ts @@ -3,6 +3,7 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { ConfigModule } from '@nestjs/config'; import { AppController } from './app.controller'; import { AppService } from './app.service'; +import { TopfiveModule } from './stocks/topfive/topfive.module'; @Module({ imports: [ @@ -17,6 +18,7 @@ import { AppService } from './app.service'; entities: [], synchronize: true, }), + TopfiveModule, ], controllers: [AppController], providers: [AppService], diff --git a/BE/src/stocks/topfive/dto/stock-ranking-data.dto.ts b/BE/src/stocks/topfive/dto/stock-ranking-data.dto.ts new file mode 100644 index 00000000..5f1a3026 --- /dev/null +++ b/BE/src/stocks/topfive/dto/stock-ranking-data.dto.ts @@ -0,0 +1,21 @@ +import { ApiProperty } from '@nestjs/swagger'; + +/** + * 등락률 API 요청 후 받은 응답값 정제용 DTO + */ +export class StockRankingDataDto { + @ApiProperty({ description: 'HTS 한글 종목명' }) + hts_kor_isnm: string; + + @ApiProperty({ description: '주식 현재가' }) + stck_prpr: string; + + @ApiProperty({ description: '전일 대비' }) + prdy_vrss: string; + + @ApiProperty({ description: '전일 대비 부호' }) + prdy_vrss_sign: string; + + @ApiProperty({ description: '전일 대비율' }) + prdy_ctrt: string; +} diff --git a/BE/src/stocks/topfive/dto/stock-ranking-request.dto.ts b/BE/src/stocks/topfive/dto/stock-ranking-request.dto.ts new file mode 100644 index 00000000..200c244d --- /dev/null +++ b/BE/src/stocks/topfive/dto/stock-ranking-request.dto.ts @@ -0,0 +1,23 @@ +/** + * 등락률 API를 사용할 때 쿼리 파라미터로 사용할 요청값 DTO + */ +export class StockRankigRequestDto { + /** + * 조건 시장 분류 코드 + * 'J' 주식 + */ + fid_cond_mrkt_div_code: string; + + /** + * 입력 종목 코드 + * '0000' 전체 / '0001' 코스피 + * '1001' 코스닥 / '2001' 코스피200 + */ + fid_input_iscd: string; + + /** + * 순위 정렬 구분 코드 + * '0' 상승률 / '1' 하락률 + */ + fid_rank_sort_cls_code: string; +} diff --git a/BE/src/stocks/topfive/dto/stock-ranking-response.dto.ts b/BE/src/stocks/topfive/dto/stock-ranking-response.dto.ts new file mode 100644 index 00000000..79a4954b --- /dev/null +++ b/BE/src/stocks/topfive/dto/stock-ranking-response.dto.ts @@ -0,0 +1,13 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { StockRankingDataDto } from './stock-ranking-data.dto'; + +/** + * 순위 정렬 후 FE에 보낼 DTO + */ +export class StockRankingResponseDto { + @ApiProperty({ type: [StockRankingDataDto], description: '상승률 순위' }) + high: StockRankingDataDto[]; + + @ApiProperty({ type: [StockRankingDataDto], description: '하락률 순위' }) + low: StockRankingDataDto[]; +} diff --git a/BE/src/stocks/topfive/topfive.controller.ts b/BE/src/stocks/topfive/topfive.controller.ts new file mode 100644 index 00000000..9dcf2715 --- /dev/null +++ b/BE/src/stocks/topfive/topfive.controller.ts @@ -0,0 +1,28 @@ +import { ApiOperation, ApiQuery, ApiResponse } from '@nestjs/swagger'; +import { Controller, Get, Query } from '@nestjs/common'; +import { TopFiveService, MarketType } from './topfive.service'; +import { StockRankingResponseDto } from './dto/stock-ranking-response.dto'; + +@Controller('/api/stocks') +export class TopfiveController { + constructor(private readonly topFiveService: TopFiveService) {} + + @Get('topfive') + @ApiOperation({ summary: '오늘의 상/하위 종목 조회 API' }) + @ApiQuery({ + name: 'market', + enum: MarketType, + required: true, + description: + '주식 시장 구분\n' + + 'ALL: 전체, KOSPI: 코스피, KOSDAQ: 코스닥, KOSPI200: 코스피200', + }) + @ApiResponse({ + status: 200, + description: '주식 시장별 순위 조회 성공', + type: StockRankingResponseDto, + }) + async getTopFive(@Query('market') market: MarketType) { + return this.topFiveService.getMarketRanking(market); + } +} diff --git a/BE/src/stocks/topfive/topfive.module.ts b/BE/src/stocks/topfive/topfive.module.ts new file mode 100644 index 00000000..70f1d6b9 --- /dev/null +++ b/BE/src/stocks/topfive/topfive.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import { TopfiveController } from './topfive.controller'; +import { TopFiveService } from './topfive.service'; + +@Module({ + imports: [ConfigModule], + controllers: [TopfiveController], + providers: [TopFiveService], +}) +export class TopfiveModule {} diff --git a/BE/src/stocks/topfive/topfive.service.ts b/BE/src/stocks/topfive/topfive.service.ts new file mode 100644 index 00000000..f2197c58 --- /dev/null +++ b/BE/src/stocks/topfive/topfive.service.ts @@ -0,0 +1,197 @@ +import axios from 'axios'; +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { StockRankigRequestDto } from './dto/stock-ranking-request.dto'; +import { StockRankingResponseDto } from './dto/stock-ranking-response.dto'; +import { StockRankingDataDto } from './dto/stock-ranking-data.dto'; + +export enum MarketType { + ALL = 'ALL', + KOSPI = 'KOSPI', + KOSDAQ = 'KOSDAQ', + KOSPI200 = 'KOSPI200', +} + +interface StockApiOutputData { + stck_shrn_iscd: string; + data_rank: string; + hts_kor_isnm: string; + stck_prpr: string; + prdy_vrss: string; + prdy_vrss_sign: string; + prdy_ctrt: string; + acml_vol: string; + stck_hgpr: string; + hgpr_hour: string; + acml_hgpr_data: string; + stck_lwpr: string; + lwpr_hour: string; + acml_lwpr_date: string; + lwpr_vrss_prpr_rate: string; + dsgt_date_clpr_vrss_prpr_rate: string; + cnnt_ascn_dynu: string; + hgpr_vrss_prpr_rate: string; + cnnt_down_dynu: string; + oprc_vrss_prpr_sign: string; + oprc_vrss_prpr: string; + oprc_vrss_prpr_rate: string; + prd_rsfl: string; + prd_rsfl_rate: string; +} + +interface StockApiResponse { + output: StockApiOutputData[]; + rt_cd: string; + msg_cd: string; + msg1: string; +} + +@Injectable() +export class TopFiveService { + private accessToken: string; + private tokenExpireTime: Date; + private readonly koreaInvestmentConfig: { + appKey: string; + appSecret: string; + baseUrl: string; + }; + + private readonly logger = new Logger(); + + constructor(private readonly config: ConfigService) { + this.koreaInvestmentConfig = { + appKey: this.config.get('KOREA_INVESTMENT_APP_KEY'), + appSecret: this.config.get('KOREA_INVESTMENT_APP_SECRET'), + baseUrl: this.config.get('KOREA_INVESTMENT_BASE_URL'), + }; + } + + private async getAccessToken() { + // accessToken이 유효한 경우 + if (this.accessToken && this.tokenExpireTime > new Date()) { + return this.accessToken; + } + + const response = await axios.post( + `${this.koreaInvestmentConfig.baseUrl}/oauth2/tokenP`, + { + grant_type: 'client_credentials', + appkey: this.koreaInvestmentConfig.appKey, + appsecret: this.koreaInvestmentConfig.appSecret, + }, + ); + + this.accessToken = response.data.access_token; + this.tokenExpireTime = new Date(Date.now() + +response.data.expires_in); + + return this.accessToken; + } + + private async requestApi(params: StockRankigRequestDto) { + try { + const token = await this.getAccessToken(); + + const response = await axios.get( + `${this.koreaInvestmentConfig.baseUrl}/uapi/domestic-stock/v1/ranking/fluctuation`, + { + headers: { + 'content-type': 'application/json; charset=utf-8', + authorization: `Bearer ${token}`, + appkey: this.koreaInvestmentConfig.appKey, + appsecret: this.koreaInvestmentConfig.appSecret, + tr_id: 'FHPST01700000', + custtype: 'P', + }, + params: { + fid_rsfl_rate2: '', + fid_cond_mrkt_div_code: params.fid_cond_mrkt_div_code, + fid_cond_scr_div_code: '20170', + fid_input_iscd: params.fid_input_iscd, + fid_rank_sort_cls_code: params.fid_rank_sort_cls_code, + fid_input_cnt_1: '0', + fid_prc_cls_code: '1', + fid_input_price_1: '', + fid_input_price_2: '', + fid_vol_cnt: '', + fid_trgt_cls_code: '0', + fid_trgt_exls_cls_code: '0', + fid_div_cls_code: '0', + fid_rsfl_rate1: '', + }, + }, + ); + return response.data; + } catch (error) { + this.logger.error('API Error Details:', { + status: error.response?.status, + statusText: error.response?.statusText, + data: error.response?.data, + headers: error.response?.config?.headers, + message: error.message, + }); + throw error; + } + } + + async getMarketRanking(marketType: MarketType) { + try { + const params = new StockRankigRequestDto(); + params.fid_cond_mrkt_div_code = 'J'; + + switch (marketType) { + case MarketType.ALL: + params.fid_input_iscd = '0000'; + break; + case MarketType.KOSPI: + params.fid_input_iscd = '0001'; + break; + case MarketType.KOSDAQ: + params.fid_input_iscd = '1001'; + break; + case MarketType.KOSPI200: + params.fid_input_iscd = '2001'; + break; + default: + break; + } + + const highResponse = await this.requestApi({ + ...params, + fid_rank_sort_cls_code: '0', + }); + + const lowResponse = await this.requestApi({ + ...params, + fid_rank_sort_cls_code: '1', + }); + + const response = new StockRankingResponseDto(); + response.high = this.formatStockData(highResponse.output); + response.low = this.formatStockData(lowResponse.output); + + return response; + } catch (error) { + this.logger.error('API Error Details:', { + status: error.response?.status, + statusText: error.response?.statusText, + data: error.response?.data, + headers: error.response?.config?.headers, // 실제 요청 헤더 + message: error.message, + }); + throw error; + } + } + + private formatStockData(stocks: StockApiOutputData[]) { + return stocks.slice(0, 5).map((stock) => { + const stockData = new StockRankingDataDto(); + stockData.hts_kor_isnm = stock.hts_kor_isnm; + stockData.stck_prpr = stock.stck_prpr; + stockData.prdy_vrss = stock.prdy_vrss; + stockData.prdy_vrss_sign = stock.prdy_vrss_sign; + stockData.prdy_ctrt = stock.prdy_ctrt; + + return stockData; + }); + } +}