diff --git a/BE/src/asset/asset.module.ts b/BE/src/asset/asset.module.ts index db1dc8ad..de73a73e 100644 --- a/BE/src/asset/asset.module.ts +++ b/BE/src/asset/asset.module.ts @@ -7,13 +7,13 @@ import { Asset } from './asset.entity'; import { UserStock } from './user-stock.entity'; import { UserStockRepository } from './user-stock.repository'; import { StockDetailModule } from '../stock/detail/stock-detail.module'; -import { StockTradeHistoryModule } from '../stock/trade/history/stock-trade-history.module'; +import { StockSocketModule } from '../stockSocket/stock-socket.module'; @Module({ imports: [ TypeOrmModule.forFeature([Asset, UserStock]), StockDetailModule, - StockTradeHistoryModule, + StockSocketModule, ], controllers: [AssetController], providers: [AssetService, AssetRepository, UserStockRepository], diff --git a/BE/src/asset/asset.service.ts b/BE/src/asset/asset.service.ts index a8008225..bb75e104 100644 --- a/BE/src/asset/asset.service.ts +++ b/BE/src/asset/asset.service.ts @@ -9,8 +9,8 @@ import { StockDetailService } from '../stock/detail/stock-detail.service'; import { UserStock } from './user-stock.entity'; import { Asset } from './asset.entity'; import { InquirePriceResponseDto } from '../stock/detail/dto/stock-detail-response.dto'; -import { StockTradeHistorySocketService } from '../stock/trade/history/stock-trade-history-socket.service'; import { TradeType } from '../stock/order/enum/trade-type'; +import { StockPriceSocketService } from '../stockSocket/stock-price-socket.service'; @Injectable() export class AssetService { @@ -18,7 +18,7 @@ export class AssetService { private readonly userStockRepository: UserStockRepository, private readonly assetRepository: AssetRepository, private readonly stockDetailService: StockDetailService, - private readonly stockTradeHistorySocketService: StockTradeHistorySocketService, + private readonly stockPriceSocketService: StockPriceSocketService, ) {} async getUserStockByCode(userId: number, stockCode: string) { @@ -149,7 +149,7 @@ export class AssetService { await this.userStockRepository.findAllDistinctCode(userId); userStocks.map((userStock) => - this.stockTradeHistorySocketService.subscribeByCode(userStock.stock_code), + this.stockPriceSocketService.subscribeByCode(userStock.stock_code), ); } @@ -158,9 +158,7 @@ export class AssetService { await this.userStockRepository.findAllDistinctCode(userId); userStocks.map((userStock) => - this.stockTradeHistorySocketService.unsubscribeByCode( - userStock.stock_code, - ), + this.stockPriceSocketService.unsubscribeByCode(userStock.stock_code), ); } } diff --git a/BE/src/common/websocket/socket.gateway.ts b/BE/src/common/websocket/socket.gateway.ts index 48668008..d533c3f0 100644 --- a/BE/src/common/websocket/socket.gateway.ts +++ b/BE/src/common/websocket/socket.gateway.ts @@ -20,8 +20,4 @@ export class SocketGateway { this.server.emit(event, stockIndexValue); } - - sendStockTradeHistoryValueToClient(event, historyData) { - this.server.emit(event, historyData); - } } diff --git a/BE/src/stock/index/stock-index-socket.service.ts b/BE/src/stock/index/stock-index-socket.service.ts deleted file mode 100644 index 05ecb307..00000000 --- a/BE/src/stock/index/stock-index-socket.service.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { StockIndexValueElementDto } from './dto/stock-index-value-element.dto'; -import { BaseSocketDomainService } from '../../common/websocket/base-socket.domain-service'; -import { SocketGateway } from '../../common/websocket/socket.gateway'; - -@Injectable() -export class StockIndexSocketService { - private TR_ID = 'H0UPCNT0'; - private STOCK_CODE = { - '0001': 'KOSPI', - '1001': 'KOSDAQ', - '2001': 'KOSPI200', - '3003': 'KSQ150', - }; - - constructor( - private readonly socketGateway: SocketGateway, - private readonly baseSocketDomainService: BaseSocketDomainService, - ) { - baseSocketDomainService.registerSocketOpenHandler(() => { - this.baseSocketDomainService.registerCode(this.TR_ID, '0001'); // 코스피 - this.baseSocketDomainService.registerCode(this.TR_ID, '1001'); // 코스닥 - this.baseSocketDomainService.registerCode(this.TR_ID, '2001'); // 코스피200 - this.baseSocketDomainService.registerCode(this.TR_ID, '3003'); // KSQ150 - }); - - baseSocketDomainService.registerSocketDataHandler( - this.TR_ID, - (data: string[]) => { - this.socketGateway.sendStockIndexValueToClient( - this.STOCK_CODE[data[0]], - new StockIndexValueElementDto( - data[2], // 주가 지수 - data[4], // 전일 대비 등락 - data[9], // 전일 대비 등락률 - data[3], // 부호 - ), - ); - }, - ); - } -} diff --git a/BE/src/stock/index/stock-index.module.ts b/BE/src/stock/index/stock-index.module.ts index dd3d21f4..186a5b05 100644 --- a/BE/src/stock/index/stock-index.module.ts +++ b/BE/src/stock/index/stock-index.module.ts @@ -3,11 +3,10 @@ import { StockIndexController } from './stock-index.controller'; import { StockIndexService } from './stock-index.service'; import { KoreaInvestmentModule } from '../../common/koreaInvestment/korea-investment.module'; import { SocketModule } from '../../common/websocket/socket.module'; -import { StockIndexSocketService } from './stock-index-socket.service'; @Module({ imports: [KoreaInvestmentModule, SocketModule], controllers: [StockIndexController], - providers: [StockIndexService, StockIndexSocketService], + providers: [StockIndexService], }) export class StockIndexModule {} diff --git a/BE/src/stock/order/stock-order-socket.service.ts b/BE/src/stock/order/stock-order-socket.service.ts deleted file mode 100644 index 5b1b5d5a..00000000 --- a/BE/src/stock/order/stock-order-socket.service.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { - Injectable, - InternalServerErrorException, - Logger, -} from '@nestjs/common'; -import { LessThanOrEqual, MoreThanOrEqual } from 'typeorm'; -import { BaseSocketDomainService } from '../../common/websocket/base-socket.domain-service'; -import { SocketGateway } from '../../common/websocket/socket.gateway'; -import { Order } from './stock-order.entity'; -import { TradeType } from './enum/trade-type'; -import { StatusType } from './enum/status-type'; -import { StockOrderRepository } from './stock-order.repository'; - -@Injectable() -export class StockOrderSocketService { - private TR_ID = 'H0STCNT0'; - - private readonly logger = new Logger(); - - constructor( - private readonly socketGateway: SocketGateway, - private readonly baseSocketDomainService: BaseSocketDomainService, - private readonly stockOrderRepository: StockOrderRepository, - ) { - baseSocketDomainService.registerSocketOpenHandler(async () => { - const orders: Order[] = - await this.stockOrderRepository.findAllCodeByStatus(); - orders.forEach((order) => { - baseSocketDomainService.registerCode(this.TR_ID, order.stock_code); - }); - }); - - baseSocketDomainService.registerSocketDataHandler( - this.TR_ID, - (data: string[]) => { - this.checkExecutableOrder( - data[0], // 주식 코드 - data[2], // 주식 체결가 - ).catch((err) => { - throw new InternalServerErrorException(err); - }); - }, - ); - } - - subscribeByCode(trKey: string) { - this.baseSocketDomainService.registerCode(this.TR_ID, trKey); - } - - unsubscribeByCode(trKey: string) { - this.baseSocketDomainService.unregisterCode(this.TR_ID, trKey); - } - - private async checkExecutableOrder(stockCode: string, value) { - const buyOrders = await this.stockOrderRepository.find({ - where: { - stock_code: stockCode, - trade_type: TradeType.BUY, - status: StatusType.PENDING, - price: MoreThanOrEqual(value), - }, - }); - - const sellOrders = await this.stockOrderRepository.find({ - where: { - stock_code: stockCode, - trade_type: TradeType.SELL, - status: StatusType.PENDING, - price: LessThanOrEqual(value), - }, - }); - - await Promise.all(buyOrders.map((buyOrder) => this.executeBuy(buyOrder))); - await Promise.all( - sellOrders.map((sellOrder) => this.executeSell(sellOrder)), - ); - - if ( - !(await this.stockOrderRepository.existsBy({ - stock_code: stockCode, - status: StatusType.PENDING, - })) - ) - this.unsubscribeByCode(stockCode); - } - - private async executeBuy(order) { - this.logger.log(`${order.id}번 매수 예약이 체결되었습니다.`, 'BUY'); - - const totalPrice = order.price * order.amount; - const fee = this.calculateFee(totalPrice); - await this.stockOrderRepository.updateOrderAndAssetAndUserStockWhenBuy( - order, - totalPrice + fee, - ); - } - - private async executeSell(order) { - this.logger.log(`${order.id}번 매도 예약이 체결되었습니다.`, 'SELL'); - - const totalPrice = order.price * order.amount; - const fee = this.calculateFee(totalPrice); - await this.stockOrderRepository.updateOrderAndAssetAndUserStockWhenSell( - order, - totalPrice - fee, - ); - } - - private calculateFee(totalPrice: number) { - if (totalPrice <= 10000000) return Math.floor(totalPrice * 0.16); - if (totalPrice > 10000000 && totalPrice <= 50000000) - return Math.floor(totalPrice * 0.14); - if (totalPrice > 50000000 && totalPrice <= 100000000) - return Math.floor(totalPrice * 0.12); - if (totalPrice > 100000000 && totalPrice <= 300000000) - return Math.floor(totalPrice * 0.1); - return Math.floor(totalPrice * 0.08); - } -} diff --git a/BE/src/stock/order/stock-order.module.ts b/BE/src/stock/order/stock-order.module.ts index 375d4a53..69bda237 100644 --- a/BE/src/stock/order/stock-order.module.ts +++ b/BE/src/stock/order/stock-order.module.ts @@ -6,11 +6,16 @@ import { Order } from './stock-order.entity'; import { StockOrderRepository } from './stock-order.repository'; import { SocketModule } from '../../common/websocket/socket.module'; import { AssetModule } from '../../asset/asset.module'; -import { StockOrderSocketService } from './stock-order-socket.service'; +import { StockSocketModule } from '../../stockSocket/stock-socket.module'; @Module({ - imports: [TypeOrmModule.forFeature([Order]), SocketModule, AssetModule], + imports: [ + TypeOrmModule.forFeature([Order]), + SocketModule, + AssetModule, + StockSocketModule, + ], controllers: [StockOrderController], - providers: [StockOrderService, StockOrderRepository, StockOrderSocketService], + providers: [StockOrderService, StockOrderRepository], }) export class StockOrderModule {} diff --git a/BE/src/stock/order/stock-order.repository.ts b/BE/src/stock/order/stock-order.repository.ts index 92da1ce6..c79abd48 100644 --- a/BE/src/stock/order/stock-order.repository.ts +++ b/BE/src/stock/order/stock-order.repository.ts @@ -1,10 +1,8 @@ import { DataSource, Repository } from 'typeorm'; import { InjectDataSource } from '@nestjs/typeorm'; -import { Injectable, InternalServerErrorException } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { Order } from './stock-order.entity'; import { StatusType } from './enum/status-type'; -import { Asset } from '../../asset/asset.entity'; -import { UserStock } from '../../asset/user-stock.entity'; import { StockOrderRawInterface } from './interface/stock-order-raw.interface'; @Injectable() @@ -20,91 +18,6 @@ export class StockOrderRepository extends Repository { .getRawMany(); } - async updateOrderAndAssetAndUserStockWhenBuy(order, realPrice) { - const queryRunner = this.dataSource.createQueryRunner(); - await queryRunner.startTransaction(); - - try { - await queryRunner.manager.update( - Order, - { id: order.id }, - { status: StatusType.COMPLETE, completed_at: new Date() }, - ); - await queryRunner.manager - .createQueryBuilder() - .update(Asset) - .set({ - cash_balance: () => `cash_balance - :realPrice`, - last_updated: new Date(), - }) - .where({ user_id: order.user_id }) - .setParameters({ realPrice }) - .execute(); - - await queryRunner.query( - `INSERT INTO user_stocks (user_id, stock_code, quantity, avg_price, last_updated) VALUES (?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE avg_price = (avg_price * quantity + ? * ?) / (quantity + ?), quantity = quantity + ?`, - [ - order.user_id, - order.stock_code, - order.amount, - order.price, - new Date(), - order.price, - order.amount, - order.amount, - order.amount, - ], - ); - - await queryRunner.commitTransaction(); - } catch (err) { - await queryRunner.rollbackTransaction(); - throw new InternalServerErrorException(err); - } finally { - await queryRunner.release(); - } - } - - async updateOrderAndAssetAndUserStockWhenSell(order, realPrice) { - const queryRunner = this.dataSource.createQueryRunner(); - await queryRunner.startTransaction(); - - try { - await queryRunner.manager.update( - Order, - { id: order.id }, - { status: StatusType.COMPLETE, completed_at: new Date() }, - ); - await queryRunner.manager - .createQueryBuilder() - .update(Asset) - .set({ - cash_balance: () => `cash_balance + :realPrice`, - last_updated: new Date(), - }) - .where({ user_id: order.user_id }) - .setParameters({ realPrice }) - .execute(); - - await queryRunner.manager - .createQueryBuilder() - .update(UserStock) - .set({ - quantity: () => `quantity - :newQuantity`, - }) - .where({ user_id: order.user_id, stock_code: order.stock_code }) - .setParameters({ newQuantity: order.amount }) - .execute(); - - await queryRunner.commitTransaction(); - } catch (err) { - await queryRunner.rollbackTransaction(); - throw new InternalServerErrorException(); - } finally { - await queryRunner.release(); - } - } - async findAllPendingOrdersByUserId(userId: number) { return this.createQueryBuilder('o') .leftJoinAndSelect('stocks', 's', 's.code = o.stock_code') diff --git a/BE/src/stock/order/stock-order.service.ts b/BE/src/stock/order/stock-order.service.ts index bbc4572f..73879bca 100644 --- a/BE/src/stock/order/stock-order.service.ts +++ b/BE/src/stock/order/stock-order.service.ts @@ -9,17 +9,17 @@ import { StockOrderRequestDto } from './dto/stock-order-request.dto'; import { StockOrderRepository } from './stock-order.repository'; import { TradeType } from './enum/trade-type'; import { StatusType } from './enum/status-type'; -import { StockOrderSocketService } from './stock-order-socket.service'; import { UserStockRepository } from '../../asset/user-stock.repository'; import { AssetRepository } from '../../asset/asset.repository'; import { StockOrderElementResponseDto } from './dto/stock-order-element-response.dto'; import { Order } from './stock-order.entity'; +import { StockPriceSocketService } from '../../stockSocket/stock-price-socket.service'; @Injectable() export class StockOrderService { constructor( private readonly stockOrderRepository: StockOrderRepository, - private readonly stockOrderSocketService: StockOrderSocketService, + private readonly stockPriceSocketService: StockPriceSocketService, private readonly userStockRepository: UserStockRepository, private readonly assetRepository: AssetRepository, ) {} @@ -54,7 +54,7 @@ export class StockOrderService { }); await this.stockOrderRepository.save(order); - this.stockOrderSocketService.subscribeByCode(stockOrderRequest.stock_code); + this.stockPriceSocketService.subscribeByCode(stockOrderRequest.stock_code); } async sell(userId: number, stockOrderRequest: StockOrderRequestDto) { @@ -89,7 +89,7 @@ export class StockOrderService { }); await this.stockOrderRepository.save(order); - this.stockOrderSocketService.subscribeByCode(stockOrderRequest.stock_code); + this.stockPriceSocketService.subscribeByCode(stockOrderRequest.stock_code); } async cancel(userId: number, orderId: number) { @@ -111,7 +111,7 @@ export class StockOrderService { status: StatusType.PENDING, })) ) - this.stockOrderSocketService.unsubscribeByCode(order.stock_code); + this.stockPriceSocketService.unsubscribeByCode(order.stock_code); } async getPendingListByUserId(userId: number) { @@ -137,7 +137,7 @@ export class StockOrderService { await Promise.all( orders.map((order) => - this.stockOrderSocketService.unsubscribeByCode(order.stock_code), + this.stockPriceSocketService.unsubscribeByCode(order.stock_code), ), ); diff --git a/BE/src/stock/trade/history/stock-trade-history-socket.service.ts b/BE/src/stock/trade/history/stock-trade-history-socket.service.ts deleted file mode 100644 index b32f9601..00000000 --- a/BE/src/stock/trade/history/stock-trade-history-socket.service.ts +++ /dev/null @@ -1,162 +0,0 @@ -import { WebSocket } from 'ws'; -import axios from 'axios'; -import { filter, map, Observable, Subject } from 'rxjs'; -import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; -import { SseEvent } from './interface/sse-event'; -import { SocketConnectTokenInterface } from '../../../common/websocket/interface/socket.interface'; -import { getFullTestURL } from '../../../util/get-full-URL'; -import { TodayStockTradeHistoryDataDto } from './dto/today-stock-trade-history-data.dto'; -import { SocketGateway } from '../../../common/websocket/socket.gateway'; -import { StockDetailSocketDataDto } from './dto/stock-detail-socket-data.dto'; - -@Injectable() -export class StockTradeHistorySocketService implements OnModuleInit { - private readonly logger = new Logger(''); - private socket: WebSocket; - private socketConnectionKey: string; - private subscribedStocks = new Set(); - private TR_ID = 'H0STCNT0'; - private eventSubject = new Subject(); - - constructor(private readonly socketGateway: SocketGateway) {} - - async onModuleInit() { - this.socketConnectionKey = await this.getSocketConnectionKey(); - this.socket = new WebSocket(process.env.KOREA_INVESTMENT_TEST_SOCKET_URL); - - this.socket.onopen = () => {}; - - this.socket.onmessage = (event) => { - const data = - typeof event.data === 'string' - ? event.data.split('|') - : JSON.stringify(event.data); - - if (data.length < 2) { - const json = JSON.parse(data[0]); - if (json.body) - this.logger.log( - `한국투자증권 웹소켓 연결: ${json.body.msg1}`, - json.header.tr_id, - ); - if (json.header.tr_id === 'PINGPONG') - this.socket.pong(JSON.stringify(json)); - return; - } - - const dataList = data[3].split('^'); - - const tradeData: TodayStockTradeHistoryDataDto = { - stck_shrn_iscd: dataList[0], - stck_cntg_hour: dataList[1], - stck_prpr: dataList[2], - prdy_vrss_sign: dataList[3], - cntg_vol: dataList[12], - prdy_ctrt: dataList[5], - }; - - const detailData: StockDetailSocketDataDto = { - stck_prpr: dataList[2], - prdy_vrss_sign: dataList[3], - prdy_vrss: dataList[4], - prdy_ctrt: dataList[5], - }; - - this.eventSubject.next({ - data: JSON.stringify({ - tradeData, - }), - }); - - this.socketGateway.sendStockTradeHistoryValueToClient( - `trade-history/${dataList[0]}`, - tradeData, - ); - - this.socketGateway.sendStockIndexValueToClient( - `detail/${dataList[0]}`, - detailData, - ); - }; - - this.socket.onclose = () => { - this.logger.warn(`한국투자증권 소켓 연결 종료`); - }; - } - - getTradeDataStream(targetStockCode: string): Observable { - return this.eventSubject.pipe( - filter((event: SseEvent) => { - const parsed = JSON.parse(event.data); - return parsed.tradeData.stck_shrn_iscd === targetStockCode; - }), - map((event: SseEvent) => event), - ); - } - - subscribeByCode(stockCode: string) { - this.registerCode(this.TR_ID, stockCode); - this.subscribedStocks.add(stockCode); - } - - unsubscribeByCode(stockCode: string) { - this.unregisterCode(this.TR_ID, stockCode); - this.subscribedStocks.delete(stockCode); - } - - registerCode(trId: string, trKey: string) { - this.socket.send( - JSON.stringify({ - header: { - approval_key: this.socketConnectionKey, - custtype: 'P', - tr_type: '1', - 'content-type': 'utf-8', - }, - body: { - input: { - tr_id: trId, - tr_key: trKey, - }, - }, - }), - ); - } - - unregisterCode(trId: string, trKey: string) { - this.socket.send( - JSON.stringify({ - header: { - approval_key: this.socketConnectionKey, - custtype: 'P', - tr_type: '2', - 'content-type': 'utf-8', - }, - body: { - input: { - tr_id: trId, - tr_key: trKey, - }, - }, - }), - ); - } - - async getSocketConnectionKey() { - if (this.socketConnectionKey) { - return this.socketConnectionKey; - } - - const response = await axios.post( - getFullTestURL('/oauth2/Approval'), - { - grant_type: 'client_credentials', - appkey: process.env.KOREA_INVESTMENT_TEST_APP_KEY, - secretkey: process.env.KOREA_INVESTMENT_TEST_APP_SECRET, - }, - ); - - this.socketConnectionKey = response.data.approval_key; - return this.socketConnectionKey; - } -} diff --git a/BE/src/stock/trade/history/stock-trade-history.controller.ts b/BE/src/stock/trade/history/stock-trade-history.controller.ts index b225642d..4d17d6f9 100644 --- a/BE/src/stock/trade/history/stock-trade-history.controller.ts +++ b/BE/src/stock/trade/history/stock-trade-history.controller.ts @@ -2,17 +2,17 @@ import { Observable } from 'rxjs'; import { Controller, Get, Param, Sse } from '@nestjs/common'; import { ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger'; import { StockTradeHistoryService } from './stock-trade-history.service'; -import { StockTradeHistorySocketService } from './stock-trade-history-socket.service'; import { TodayStockTradeHistoryDataDto } from './dto/today-stock-trade-history-data.dto'; import { DailyStockTradeHistoryDataDto } from './dto/daily-stock-trade-history-data.dto'; import { SseEvent } from './interface/sse-event'; +import { StockPriceSocketService } from '../../../stockSocket/stock-price-socket.service'; @ApiTags('주식현재가 체결 조회 API') @Controller('/api/stocks/trade-history') export class StockTradeHistoryController { constructor( private readonly stockTradeHistoryService: StockTradeHistoryService, - private readonly stockTradeHistorySocketService: StockTradeHistorySocketService, + private readonly stockPriceSocketService: StockPriceSocketService, ) {} @Get(':stockCode/today') @@ -30,12 +30,7 @@ export class StockTradeHistoryController { type: TodayStockTradeHistoryDataDto, }) getTodayStockTradeHistory(@Param('stockCode') stockCode: string) { - const data = - this.stockTradeHistoryService.getTodayStockTradeHistory(stockCode); - - this.stockTradeHistorySocketService.subscribeByCode(stockCode); - - return data; + return this.stockTradeHistoryService.getTodayStockTradeHistory(stockCode); } @Get(':stockCode/daily') @@ -72,15 +67,15 @@ export class StockTradeHistoryController { type: TodayStockTradeHistoryDataDto, }) streamTradeHistory(@Param('stockCode') stockCode: string) { - this.stockTradeHistorySocketService.subscribeByCode(stockCode); + this.stockPriceSocketService.subscribeByCode(stockCode); return new Observable((subscriber) => { - const subscription = this.stockTradeHistorySocketService + const subscription = this.stockPriceSocketService .getTradeDataStream(stockCode) .subscribe(subscriber); return () => { - this.stockTradeHistorySocketService.unsubscribeByCode(stockCode); + this.stockPriceSocketService.unsubscribeByCode(stockCode); subscription.unsubscribe(); }; }); @@ -100,6 +95,6 @@ export class StockTradeHistoryController { description: '구독 취소 성공', }) unsubscribeCode(@Param('stockCode') stockCode: string) { - this.stockTradeHistorySocketService.unsubscribeByCode(stockCode); + this.stockPriceSocketService.unsubscribeByCode(stockCode); } } diff --git a/BE/src/stock/trade/history/stock-trade-history.module.ts b/BE/src/stock/trade/history/stock-trade-history.module.ts index bcf067be..a3c55fd1 100644 --- a/BE/src/stock/trade/history/stock-trade-history.module.ts +++ b/BE/src/stock/trade/history/stock-trade-history.module.ts @@ -2,13 +2,12 @@ import { Module } from '@nestjs/common'; import { KoreaInvestmentModule } from '../../../common/koreaInvestment/korea-investment.module'; import { StockTradeHistoryController } from './stock-trade-history.controller'; import { StockTradeHistoryService } from './stock-trade-history.service'; -import { StockTradeHistorySocketService } from './stock-trade-history-socket.service'; import { SocketModule } from '../../../common/websocket/socket.module'; +import { StockSocketModule } from '../../../stockSocket/stock-socket.module'; @Module({ - imports: [KoreaInvestmentModule, SocketModule], + imports: [KoreaInvestmentModule, SocketModule, StockSocketModule], controllers: [StockTradeHistoryController], - providers: [StockTradeHistoryService, StockTradeHistorySocketService], - exports: [StockTradeHistorySocketService], + providers: [StockTradeHistoryService], }) export class StockTradeHistoryModule {} diff --git a/BE/src/stock/trade/history/stock-trade-history.service.ts b/BE/src/stock/trade/history/stock-trade-history.service.ts index 564ba4ca..8ef3b26c 100644 --- a/BE/src/stock/trade/history/stock-trade-history.service.ts +++ b/BE/src/stock/trade/history/stock-trade-history.service.ts @@ -6,11 +6,13 @@ import { TodayStockTradeHistoryDataDto } from './dto/today-stock-trade-history-d import { InquireDailyPriceApiResponse } from './interface/inquire-daily-price.interface'; import { DailyStockTradeHistoryOutputDto } from './dto/daily-stock-trade-history-ouput.dto'; import { DailyStockTradeHistoryDataDto } from './dto/daily-stock-trade-history-data.dto'; +import { StockPriceSocketService } from '../../../stockSocket/stock-price-socket.service'; @Injectable() export class StockTradeHistoryService { constructor( private readonly koreaInvestmentDomainService: KoreaInvestmentDomainService, + private readonly stockPriceSocketService: StockPriceSocketService, ) {} /** @@ -33,6 +35,7 @@ export class StockTradeHistoryService { queryParams, ); + this.stockPriceSocketService.subscribeByCode(stockCode); return this.formatTodayStockTradeHistoryData(response.output); } @@ -107,4 +110,8 @@ export class StockTradeHistoryService { return historyData; }); } + + // unsubscribeCode(stockCode: string) { + // return this.stockPriceSocketService.unsubscribeByCode(stockCode); + // } } diff --git a/BE/src/stockSocket/base-stock-socket.domain-service.ts b/BE/src/stockSocket/base-stock-socket.domain-service.ts new file mode 100644 index 00000000..6bd5b571 --- /dev/null +++ b/BE/src/stockSocket/base-stock-socket.domain-service.ts @@ -0,0 +1,24 @@ +import { Injectable } from '@nestjs/common'; +import { SocketGateway } from '../common/websocket/socket.gateway'; +import { BaseSocketDomainService } from '../common/websocket/base-socket.domain-service'; + +@Injectable() +export abstract class BaseStockSocketDomainService { + protected constructor( + protected readonly socketGateway: SocketGateway, + protected readonly baseSocketDomainService: BaseSocketDomainService, + protected readonly TR_ID: string, + ) { + baseSocketDomainService.registerSocketOpenHandler(() => + this.socketOpenHandler(), + ); + + baseSocketDomainService.registerSocketDataHandler(TR_ID, (data: string[]) => + this.socketDataHandler(data), + ); + } + + abstract socketOpenHandler(): void | Promise; + + abstract socketDataHandler(data: string[]): void; +} diff --git a/BE/src/stockSocket/stock-execute-order.repository.ts b/BE/src/stockSocket/stock-execute-order.repository.ts new file mode 100644 index 00000000..3a0eca92 --- /dev/null +++ b/BE/src/stockSocket/stock-execute-order.repository.ts @@ -0,0 +1,105 @@ +import { DataSource, Repository } from 'typeorm'; +import { InjectDataSource } from '@nestjs/typeorm'; +import { InternalServerErrorException } from '@nestjs/common'; +import { Order } from '../stock/order/stock-order.entity'; +import { StatusType } from '../stock/order/enum/status-type'; +import { Asset } from '../asset/asset.entity'; +import { UserStock } from '../asset/user-stock.entity'; + +export class StockExecuteOrderRepository extends Repository { + constructor(@InjectDataSource() private dataSource: DataSource) { + super(Order, dataSource.createEntityManager()); + } + + async findAllCodeByStatus() { + return this.createQueryBuilder('orders') + .select('DISTINCT orders.stock_code') + .where({ status: StatusType.PENDING }) + .getRawMany(); + } + + async updateOrderAndAssetAndUserStockWhenBuy(order, realPrice) { + const queryRunner = this.dataSource.createQueryRunner(); + await queryRunner.startTransaction(); + + try { + await queryRunner.manager.update( + Order, + { id: order.id }, + { status: StatusType.COMPLETE, completed_at: new Date() }, + ); + await queryRunner.manager + .createQueryBuilder() + .update(Asset) + .set({ + cash_balance: () => `cash_balance - :realPrice`, + last_updated: new Date(), + }) + .where({ user_id: order.user_id }) + .setParameters({ realPrice }) + .execute(); + + await queryRunner.query( + `INSERT INTO user_stocks (user_id, stock_code, quantity, avg_price, last_updated) VALUES (?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE avg_price = (avg_price * quantity + ? * ?) / (quantity + ?), quantity = quantity + ?`, + [ + order.user_id, + order.stock_code, + order.amount, + order.price, + new Date(), + order.price, + order.amount, + order.amount, + order.amount, + ], + ); + + await queryRunner.commitTransaction(); + } catch (err) { + await queryRunner.rollbackTransaction(); + throw new InternalServerErrorException(err); + } finally { + await queryRunner.release(); + } + } + + async updateOrderAndAssetAndUserStockWhenSell(order, realPrice) { + const queryRunner = this.dataSource.createQueryRunner(); + await queryRunner.startTransaction(); + + try { + await queryRunner.manager.update( + Order, + { id: order.id }, + { status: StatusType.COMPLETE, completed_at: new Date() }, + ); + await queryRunner.manager + .createQueryBuilder() + .update(Asset) + .set({ + cash_balance: () => `cash_balance + :realPrice`, + last_updated: new Date(), + }) + .where({ user_id: order.user_id }) + .setParameters({ realPrice }) + .execute(); + + await queryRunner.manager + .createQueryBuilder() + .update(UserStock) + .set({ + quantity: () => `quantity - :newQuantity`, + }) + .where({ user_id: order.user_id, stock_code: order.stock_code }) + .setParameters({ newQuantity: order.amount }) + .execute(); + + await queryRunner.commitTransaction(); + } catch (err) { + await queryRunner.rollbackTransaction(); + throw new InternalServerErrorException(); + } finally { + await queryRunner.release(); + } + } +} diff --git a/BE/src/stockSocket/stock-index-socket.service.ts b/BE/src/stockSocket/stock-index-socket.service.ts new file mode 100644 index 00000000..208fc71f --- /dev/null +++ b/BE/src/stockSocket/stock-index-socket.service.ts @@ -0,0 +1,41 @@ +import { Injectable } from '@nestjs/common'; +import { StockIndexValueElementDto } from '../stock/index/dto/stock-index-value-element.dto'; +import { BaseStockSocketDomainService } from './base-stock-socket.domain-service'; +import { SocketGateway } from '../common/websocket/socket.gateway'; +import { BaseSocketDomainService } from '../common/websocket/base-socket.domain-service'; + +@Injectable() +export class StockIndexSocketService extends BaseStockSocketDomainService { + private STOCK_CODE = { + '0001': 'KOSPI', + '1001': 'KOSDAQ', + '2001': 'KOSPI200', + '3003': 'KSQ150', + }; + + constructor( + protected readonly socketGateway: SocketGateway, + protected readonly baseSocketDomainService: BaseSocketDomainService, + ) { + super(socketGateway, baseSocketDomainService, 'H0UPCNT0'); + } + + socketOpenHandler(): void { + this.baseSocketDomainService.registerCode(this.TR_ID, '0001'); // 코스피 + this.baseSocketDomainService.registerCode(this.TR_ID, '1001'); // 코스닥 + this.baseSocketDomainService.registerCode(this.TR_ID, '2001'); // 코스피200 + this.baseSocketDomainService.registerCode(this.TR_ID, '3003'); // KSQ150 + } + + socketDataHandler(data: string[]): void { + this.socketGateway.sendStockIndexValueToClient( + this.STOCK_CODE[data[0]], + new StockIndexValueElementDto( + data[2], // 주가 지수 + data[4], // 전일 대비 등락 + data[9], // 전일 대비 등락률 + data[3], // 부호 + ), + ); + } +} diff --git a/BE/src/stockSocket/stock-price-socket.service.ts b/BE/src/stockSocket/stock-price-socket.service.ts new file mode 100644 index 00000000..6744d9f3 --- /dev/null +++ b/BE/src/stockSocket/stock-price-socket.service.ts @@ -0,0 +1,178 @@ +import { + Injectable, + InternalServerErrorException, + Logger, +} from '@nestjs/common'; +import { LessThanOrEqual, MoreThanOrEqual } from 'typeorm'; +import { filter, map, Observable, Subject } from 'rxjs'; +import { BaseSocketDomainService } from '../common/websocket/base-socket.domain-service'; +import { SocketGateway } from '../common/websocket/socket.gateway'; +import { BaseStockSocketDomainService } from './base-stock-socket.domain-service'; +import { Order } from '../stock/order/stock-order.entity'; +import { TradeType } from '../stock/order/enum/trade-type'; +import { StatusType } from '../stock/order/enum/status-type'; +import { TodayStockTradeHistoryDataDto } from '../stock/trade/history/dto/today-stock-trade-history-data.dto'; +import { StockDetailSocketDataDto } from '../stock/trade/history/dto/stock-detail-socket-data.dto'; +import { StockExecuteOrderRepository } from './stock-execute-order.repository'; +import { SseEvent } from '../stock/trade/history/interface/sse-event'; + +@Injectable() +export class StockPriceSocketService extends BaseStockSocketDomainService { + private readonly logger = new Logger(); + private connection: { [key: string]: number } = {}; + private eventSubject = new Subject(); + + constructor( + protected readonly socketGateway: SocketGateway, + protected readonly baseSocketDomainService: BaseSocketDomainService, + private readonly stockExecuteOrderRepository: StockExecuteOrderRepository, + ) { + super(socketGateway, baseSocketDomainService, 'H0STCNT0'); + } + + async socketOpenHandler(): Promise { + const orders: Order[] = + await this.stockExecuteOrderRepository.findAllCodeByStatus(); + orders.forEach((order) => { + this.baseSocketDomainService.registerCode(this.TR_ID, order.stock_code); + }); + } + + socketDataHandler(data: string[]) { + this.checkExecutableOrder( + data[0], // 주식 코드 + data[2], // 주식 체결가 + ).catch((err) => { + throw new InternalServerErrorException(err); + }); + + const tradeData: TodayStockTradeHistoryDataDto = { + stck_shrn_iscd: data[0], + stck_cntg_hour: data[1], + stck_prpr: data[2], + prdy_vrss_sign: data[3], + cntg_vol: data[12], + prdy_ctrt: data[5], + }; + + const detailData: StockDetailSocketDataDto = { + stck_prpr: data[2], + prdy_vrss_sign: data[3], + prdy_vrss: data[4], + prdy_ctrt: data[5], + }; + + this.eventSubject.next({ + data: JSON.stringify({ + tradeData, + }), + }); + + this.socketGateway.sendStockIndexValueToClient( + `trade-history/${data[0]}`, + tradeData, + ); + + this.socketGateway.sendStockIndexValueToClient( + `detail/${data[0]}`, + detailData, + ); + } + + getTradeDataStream(targetStockCode: string): Observable { + return this.eventSubject.pipe( + filter((event: SseEvent) => { + const parsed = JSON.parse(event.data); + return parsed.tradeData.stck_shrn_iscd === targetStockCode; + }), + map((event: SseEvent) => event), + ); + } + + subscribeByCode(trKey: string) { + this.baseSocketDomainService.registerCode(this.TR_ID, trKey); + + if (this.connection[trKey]) { + this.connection[trKey] += 1; + return; + } + this.connection[trKey] = 1; + } + + unsubscribeByCode(trKey: string) { + if (!this.connection[trKey]) return; + if (this.connection[trKey] > 1) { + this.connection[trKey] -= 1; + return; + } + delete this.connection[trKey]; + this.baseSocketDomainService.unregisterCode(this.TR_ID, trKey); + } + + private async checkExecutableOrder(stockCode: string, value) { + const buyOrders = await this.stockExecuteOrderRepository.find({ + where: { + stock_code: stockCode, + trade_type: TradeType.BUY, + status: StatusType.PENDING, + price: MoreThanOrEqual(value), + }, + }); + + const sellOrders = await this.stockExecuteOrderRepository.find({ + where: { + stock_code: stockCode, + trade_type: TradeType.SELL, + status: StatusType.PENDING, + price: LessThanOrEqual(value), + }, + }); + + await Promise.all(buyOrders.map((buyOrder) => this.executeBuy(buyOrder))); + await Promise.all( + sellOrders.map((sellOrder) => this.executeSell(sellOrder)), + ); + + if ( + buyOrders.length + sellOrders.length > 0 && + !(await this.stockExecuteOrderRepository.existsBy({ + stock_code: stockCode, + status: StatusType.PENDING, + })) + ) + this.unsubscribeByCode(stockCode); + } + + private async executeBuy(order) { + this.logger.log(`${order.id}번 매수 예약이 체결되었습니다.`, 'BUY'); + + const totalPrice = order.price * order.amount; + const fee = this.calculateFee(totalPrice); + await this.stockExecuteOrderRepository.updateOrderAndAssetAndUserStockWhenBuy( + order, + totalPrice + fee, + ); + } + + private async executeSell(order) { + this.logger.log(`${order.id}번 매도 예약이 체결되었습니다.`, 'SELL'); + + const totalPrice = order.price * order.amount; + const fee = this.calculateFee(totalPrice); + await this.stockExecuteOrderRepository.updateOrderAndAssetAndUserStockWhenSell( + order, + totalPrice - fee, + ); + } + + private calculateFee(totalPrice: number) { + if (totalPrice <= 10000000) return Math.floor(totalPrice * 0.16); + if (totalPrice > 10000000 && totalPrice <= 50000000) + return Math.floor(totalPrice * 0.14); + if (totalPrice > 50000000 && totalPrice <= 100000000) + return Math.floor(totalPrice * 0.12); + if (totalPrice > 100000000 && totalPrice <= 300000000) + return Math.floor(totalPrice * 0.1); + return Math.floor(totalPrice * 0.08); + } +} diff --git a/BE/src/stockSocket/stock-socket.module.ts b/BE/src/stockSocket/stock-socket.module.ts new file mode 100644 index 00000000..fd2dc094 --- /dev/null +++ b/BE/src/stockSocket/stock-socket.module.ts @@ -0,0 +1,16 @@ +import { Module } from '@nestjs/common'; +import { StockIndexSocketService } from './stock-index-socket.service'; +import { StockPriceSocketService } from './stock-price-socket.service'; +import { SocketModule } from '../common/websocket/socket.module'; +import { StockExecuteOrderRepository } from './stock-execute-order.repository'; + +@Module({ + imports: [SocketModule], + providers: [ + StockIndexSocketService, + StockPriceSocketService, + StockExecuteOrderRepository, + ], + exports: [StockIndexSocketService, StockPriceSocketService], +}) +export class StockSocketModule {}