diff --git a/packages/backend/src/auth/google/googleAuth.controller.ts b/packages/backend/src/auth/google/googleAuth.controller.ts index 1d460748..73f39449 100644 --- a/packages/backend/src/auth/google/googleAuth.controller.ts +++ b/packages/backend/src/auth/google/googleAuth.controller.ts @@ -21,7 +21,7 @@ export class GoogleAuthController { @Get('/redirect') @UseGuards(GoogleAuthGuard) async handleRedirect(@Res() response: Response) { - response.redirect('/'); + response.redirect('http://localhost:5173'); } @ApiOperation({ diff --git a/packages/backend/src/scraper/openapi/api/openapiFluctuationData.api.ts b/packages/backend/src/scraper/openapi/api/openapiFluctuationData.api.ts new file mode 100644 index 00000000..39626588 --- /dev/null +++ b/packages/backend/src/scraper/openapi/api/openapiFluctuationData.api.ts @@ -0,0 +1,95 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { Cron } from '@nestjs/schedule'; +import { DataSource, EntityManager } from 'typeorm'; +import { Logger } from 'winston'; +import { OpenapiTokenApi } from '@/scraper/openapi/api/openapiToken.api'; +import { + DECREASE_STOCK_QUERY, + INCREASE_STOCK_QUERY, +} from '@/scraper/openapi/constants/query'; +import { TR_IDS } from '@/scraper/openapi/type/openapiUtil.type'; +import { getOpenApi } from '@/scraper/openapi/util/openapiUtil.api'; +import { FluctuationRankStock } from '@/stock/domain/FluctuationRankStock.entity'; + +@Injectable() +export class OpenapiFluctuationData { + private readonly fluctuationUrl: string = + '/uapi/domestic-stock/v1/ranking/fluctuation'; + constructor( + private readonly openApiToken: OpenapiTokenApi, + private readonly datasource: DataSource, + @Inject('winston') private readonly logger: Logger, + ) { + setTimeout(() => this.getFluctuationRankStocks(), 1000); + } + + @Cron('*/1 9-16 * * 1-5') + async getFluctuationRankStocks() { + await this.getDecreaseRankStocks(); + await this.getIncreaseRankStocks(); + } + + async getDecreaseRankStocks(count = 5) { + try { + if (count === 0) return; + await this.datasource.transaction(async (manager) => { + const result = await this.getFluctuationRankApiStocks(false); + await this.datasource.manager.delete(FluctuationRankStock, { + isRising: false, + }); + await this.saveFluctuationRankStocks(result, manager); + this.logger.info('decrease rank stocks updated'); + }); + } catch (error) { + this.logger.warn(error); + this.getDecreaseRankStocks(--count); + } + } + + async getIncreaseRankStocks(count = 5) { + try { + if (count === 0) return; + await this.datasource.transaction(async (manager) => { + const result = await this.getFluctuationRankApiStocks(true); + await this.datasource.manager.delete(FluctuationRankStock, { + isRising: true, + }); + await this.saveFluctuationRankStocks(result, manager); + this.logger.info('increase rank stocks updated'); + }); + } catch (error) { + this.logger.warn(error); + this.getIncreaseRankStocks(--count); + } + } + + private async saveFluctuationRankStocks( + result: FluctuationRankStock[], + manager: EntityManager, + ) { + await manager + .getRepository(FluctuationRankStock) + .createQueryBuilder() + .insert() + .into(FluctuationRankStock) + .values(result) + .execute(); + } + + private async getFluctuationRankApiStocks(isRising: boolean) { + const query = isRising ? INCREASE_STOCK_QUERY : DECREASE_STOCK_QUERY; + const result = await getOpenApi( + this.fluctuationUrl, + (await this.openApiToken.configs())[0], + query, + TR_IDS.FLUCTUATION_DATA, + ); + + return result.output.map((result: Record) => ({ + rank: result.data_rank, + fluctuationRate: result.prdy_ctrt, + stock: { id: result.stck_shrn_iscd }, + isRising, + })); + } +} diff --git a/packages/backend/src/scraper/openapi/constants/query.ts b/packages/backend/src/scraper/openapi/constants/query.ts new file mode 100644 index 00000000..7e0aa0e6 --- /dev/null +++ b/packages/backend/src/scraper/openapi/constants/query.ts @@ -0,0 +1,26 @@ +const BASE_QUERY = { + fid_cond_mrkt_div_code: 'J', + fid_cond_scr_div_code: '20170', + fid_input_iscd: '0000', + fid_input_cnt_1: '0', + 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: '', + fid_rsfl_rate2: '', +}; + +export const DECREASE_STOCK_QUERY = { + ...BASE_QUERY, + fid_rank_sort_cls_code: '1', + fid_prc_cls_code: '1', +}; + +export const INCREASE_STOCK_QUERY = { + ...BASE_QUERY, + fid_rank_sort_cls_code: '0', + fid_prc_cls_code: '1', +}; diff --git a/packages/backend/src/scraper/openapi/openapi-scraper.module.ts b/packages/backend/src/scraper/openapi/openapi-scraper.module.ts index 4afbf154..cb13a87b 100644 --- a/packages/backend/src/scraper/openapi/openapi-scraper.module.ts +++ b/packages/backend/src/scraper/openapi/openapi-scraper.module.ts @@ -15,6 +15,8 @@ import { } from '@/stock/domain/stockData.entity'; import { StockDetail } from '@/stock/domain/stockDetail.entity'; import { StockLiveData } from '@/stock/domain/stockLiveData.entity'; +import { OpenapiFluctuationData } from '@/scraper/openapi/api/openapiFluctuationData.api'; +import { FluctuationRankStock } from '@/stock/domain/FluctuationRankStock.entity'; @Module({ imports: [ @@ -27,6 +29,7 @@ import { StockLiveData } from '@/stock/domain/stockLiveData.entity'; StockYearly, StockLiveData, StockDetail, + FluctuationRankStock, ]), ], controllers: [], @@ -36,7 +39,7 @@ import { StockLiveData } from '@/stock/domain/stockLiveData.entity'; OpenapiMinuteData, OpenapiDetailData, OpenapiScraperService, - OpenapiTokenApi, + OpenapiFluctuationData, ], }) export class OpenapiScraperModule {} diff --git a/packages/backend/src/scraper/openapi/type/openapiUtil.type.ts b/packages/backend/src/scraper/openapi/type/openapiUtil.type.ts index e9f0869d..20b7de26 100644 --- a/packages/backend/src/scraper/openapi/type/openapiUtil.type.ts +++ b/packages/backend/src/scraper/openapi/type/openapiUtil.type.ts @@ -3,6 +3,7 @@ export type TR_ID = | 'FHKST03010200' | 'FHKST66430300' | 'HHKDB669107C0' + | 'FHPST01700000' | 'FHKST01010100' | 'CTPF1002R'; @@ -12,4 +13,5 @@ export const TR_IDS: Record = { FINANCIAL_DATA: 'FHKST66430300', PRODUCTION_DETAIL: 'CTPF1002R', LIVE_DATA: 'FHKST01010100', + FLUCTUATION_DATA : 'FHPST01700000', }; diff --git a/packages/backend/src/stock/domain/FluctuationRankStock.entity.ts b/packages/backend/src/stock/domain/FluctuationRankStock.entity.ts new file mode 100644 index 00000000..5ece2107 --- /dev/null +++ b/packages/backend/src/stock/domain/FluctuationRankStock.entity.ts @@ -0,0 +1,31 @@ +import { + Column, + CreateDateColumn, + Entity, + JoinColumn, + ManyToOne, + PrimaryGeneratedColumn, +} from 'typeorm'; +import { Stock } from '@/stock/domain/stock.entity'; + +@Entity() +export class FluctuationRankStock { + @PrimaryGeneratedColumn() + id: number; + + @ManyToOne(() => Stock, (stock) => stock.id) + @JoinColumn({ name: 'stock_id' }) + stock: Stock; + + @Column({ name: 'fluctuation_rate', type: 'decimal', precision: 5, scale: 2 }) + fluctuationRate: string; + + @Column() + isRising: boolean; + + @Column() + rank: number; + + @CreateDateColumn({ name: 'created_at', type: 'timestamp' }) + createdAt: Date; +} diff --git a/packages/backend/src/stock/domain/stock.entity.ts b/packages/backend/src/stock/domain/stock.entity.ts index 994a6de1..eaa38aa7 100644 --- a/packages/backend/src/stock/domain/stock.entity.ts +++ b/packages/backend/src/stock/domain/stock.entity.ts @@ -10,6 +10,7 @@ import { import { StockLiveData } from './stockLiveData.entity'; import { Like } from '@/chat/domain/like.entity'; import { DateEmbedded } from '@/common/dateEmbedded.entity'; +import { FluctuationRankStock } from '@/stock/domain/FluctuationRankStock.entity'; import { UserStock } from '@/stock/domain/userStock.entity'; @Entity() @@ -58,4 +59,10 @@ export class Stock { @OneToOne(() => KospiStock, (kospiStock) => kospiStock.stock) kospiStock?: KospiStock; + + @OneToMany( + () => FluctuationRankStock, + (fluctuationRankStock) => fluctuationRankStock.stock, + ) + fluctuationRankStocks?: FluctuationRankStock[]; } diff --git a/packages/backend/src/stock/dto/stock.response.ts b/packages/backend/src/stock/dto/stock.response.ts index 6ac76f31..0d00104e 100644 --- a/packages/backend/src/stock/dto/stock.response.ts +++ b/packages/backend/src/stock/dto/stock.response.ts @@ -101,3 +101,66 @@ export class StockSearchResponse { })); } } + +export class StockRankResponse { + @ApiProperty({ + description: '주식 종목 코드', + example: 'A005930', + }) + id: string; + + @ApiProperty({ + description: '주식 종목 이름', + example: '삼성전자', + }) + name: string; + + @ApiProperty({ + description: '주식 현재가', + example: 100000.0, + }) + @Transform(({ value }) => parseFloat(value)) + currentPrice: number; + + @ApiProperty({ + description: '주식 변동률', + example: 2.5, + }) + @Transform(({ value }) => parseFloat(value)) + changeRate: number; + + @ApiProperty({ + description: '주식 거래량', + example: 500000, + }) + @Transform(({ value }) => parseInt(value)) + volume: number; + + @ApiProperty({ + description: '주식 시가 총액', + example: '500000000000.00', + }) + marketCap: string; + + @ApiProperty({ + description: '랭킹', + example: 1, + }) + rank: number; +} + +export class StockRankResponses { + result: StockRankResponse[]; + + constructor(stocks: Record[]) { + this.result = stocks.map((stock) => ({ + id: stock.id, + name: stock.name, + currentPrice: parseFloat(stock.currentPrice), + volume: parseInt(stock.volume), + marketCap: stock.marketCap, + changeRate: parseFloat(stock.fluctuationRate), + rank: parseInt(stock.stockRank), + })); + } +} diff --git a/packages/backend/src/stock/stock.service.ts b/packages/backend/src/stock/stock.service.ts index c0e50a9c..c72fb091 100644 --- a/packages/backend/src/stock/stock.service.ts +++ b/packages/backend/src/stock/stock.service.ts @@ -3,7 +3,11 @@ import { plainToInstance } from 'class-transformer'; import { DataSource, EntityManager } from 'typeorm'; import { Logger } from 'winston'; import { Stock } from './domain/stock.entity'; -import { StockSearchResponse, StocksResponse } from './dto/stock.response'; +import { + StockRankResponses, + StockSearchResponse, + StocksResponse, +} from './dto/stock.response'; import { UserStock } from '@/stock/domain/userStock.entity'; @Injectable() @@ -94,7 +98,7 @@ export class StockService { } async getTopStocksByViews(limit: number) { - const rawData = await this.StocksQuery() + const rawData = await this.getStocksQuery() .orderBy('stock.views', 'DESC') .limit(limit) .getRawMany(); @@ -103,21 +107,17 @@ export class StockService { } async getTopStocksByGainers(limit: number) { - const rawData = await this.StocksQuery() - .orderBy('stockLiveData.changeRate', 'DESC') - .limit(limit) - .getRawMany(); + const rawData = await this.getStockRankQuery(true).take(limit).getRawMany(); - return plainToInstance(StocksResponse, rawData); + return new StockRankResponses(rawData); } async getTopStocksByLosers(limit: number) { - const rawData = await this.StocksQuery() - .orderBy('stockLiveData.changeRate', 'ASC') - .limit(limit) + const rawData = await this.getStockRankQuery(false) + .take(limit) .getRawMany(); - return plainToInstance(StocksResponse, rawData); + return new StockRankResponses(rawData); } private async validateStockExists(stockId: string, manager: EntityManager) { @@ -153,7 +153,7 @@ export class StockService { return await manager.exists(Stock, { where: { id: stockId } }); } - private StocksQuery() { + private getStocksQuery() { return this.datasource .getRepository(Stock) .createQueryBuilder('stock') @@ -176,4 +176,15 @@ export class StockService { 'stockDetail.marketCap AS marketCap', ]); } + + private getStockRankQuery(isRising: boolean) { + return this.getStocksQuery() + .innerJoinAndSelect('stock.fluctuationRankStocks', 'FluctuationRankStock') + .addSelect([ + 'fluctuationRankStock.rank AS stockRank', + 'fluctuationRankStock.isRising AS isRising', + 'fluctuationRankStock.fluctuation_rate AS fluctuationRate', + ]) + .where('FluctuationRankStock.isRising = :isRising', { isRising }); + } }