From 3d62a51153de827b3135985d1bed6577774f6663 Mon Sep 17 00:00:00 2001 From: lunaticbum Date: Tue, 6 Jan 2026 18:20:31 +0900 Subject: [PATCH] .... --- .../back/lun/batch/BatchScheduler.kt | 23 + .../back/lun/controllers/StockController.kt | 335 ++++++++++-- .../back/lun/model/AutoTradeEntity.kt | 26 + .../lun/repository/ReactiveMongoRepository.kt | 10 + .../back/lun/service/KisApiService.kt | 224 +++++++- .../back/lun/service/StockMonitorService.kt | 133 +++++ .../templates/content/stock/config.html | 4 +- .../templates/content/stock/dashboard.html | 104 ++-- .../templates/content/stock/detail.html | 493 ++++++++++++++++++ .../templates/content/stock/market.html | 146 ++++-- 10 files changed, 1380 insertions(+), 118 deletions(-) create mode 100644 src/main/kotlin/kr/lunaticbum/back/lun/model/AutoTradeEntity.kt create mode 100644 src/main/kotlin/kr/lunaticbum/back/lun/repository/ReactiveMongoRepository.kt create mode 100644 src/main/kotlin/kr/lunaticbum/back/lun/service/StockMonitorService.kt create mode 100644 src/main/resources/templates/content/stock/detail.html diff --git a/src/main/kotlin/kr/lunaticbum/back/lun/batch/BatchScheduler.kt b/src/main/kotlin/kr/lunaticbum/back/lun/batch/BatchScheduler.kt index e941989..b700f63 100644 --- a/src/main/kotlin/kr/lunaticbum/back/lun/batch/BatchScheduler.kt +++ b/src/main/kotlin/kr/lunaticbum/back/lun/batch/BatchScheduler.kt @@ -16,3 +16,26 @@ //import org.springframework.stereotype.Component //import java.time.LocalDateTime // +import kr.lunaticbum.back.lun.service.FeedService +import kr.lunaticbum.back.lun.service.PostManager +import kr.lunaticbum.back.lun.service.StockMonitorService // 추가 +import org.springframework.scheduling.annotation.EnableScheduling +import org.springframework.scheduling.annotation.Scheduled +import org.springframework.stereotype.Component + +@Component +@EnableScheduling +class BatchScheduler( + private val postManager: PostManager, + private val feedService: FeedService, + private val stockMonitorService: StockMonitorService // [추가] 주입 +) { + + // ... 기존 메서드들 ... + + // [추가] 자동매매 모니터링 (예: 10초마다 실행) + @Scheduled(fixedDelay = 10000) + fun runAutoTrading() { + stockMonitorService.checkAndExecuteAutoSell() + } +} \ No newline at end of file diff --git a/src/main/kotlin/kr/lunaticbum/back/lun/controllers/StockController.kt b/src/main/kotlin/kr/lunaticbum/back/lun/controllers/StockController.kt index 8ef8f91..ab79aa2 100644 --- a/src/main/kotlin/kr/lunaticbum/back/lun/controllers/StockController.kt +++ b/src/main/kotlin/kr/lunaticbum/back/lun/controllers/StockController.kt @@ -1,6 +1,5 @@ package kr.lunaticbum.back.lun.controllers -import jakarta.servlet.http.HttpServletRequest import jakarta.servlet.http.HttpSession import kotlinx.coroutines.reactor.awaitSingle import kr.lunaticbum.back.lun.model.KisAuthSession @@ -10,51 +9,113 @@ import kr.lunaticbum.back.lun.model.ResultMV import kr.lunaticbum.back.lun.model.UserManager import kr.lunaticbum.back.lun.service.KisApiService import kr.lunaticbum.back.lun.service.KisMarketService +import kr.lunaticbum.back.lun.service.StockMonitorService import kr.lunaticbum.back.lun.utils.LogService import org.springframework.http.ResponseEntity -import org.springframework.security.core.annotation.AuthenticationPrincipal -import org.springframework.security.core.userdetails.UserDetails import org.springframework.stereotype.Controller -import org.springframework.web.bind.annotation.GetMapping -import org.springframework.web.bind.annotation.PostMapping -import org.springframework.web.bind.annotation.RequestBody -import org.springframework.web.bind.annotation.RequestMapping -import org.springframework.web.bind.annotation.RestController +import org.springframework.web.bind.annotation.* import org.springframework.web.reactive.function.client.WebClient +import reactor.core.publisher.Flux // [추가] +import reactor.core.publisher.Mono +import java.time.Duration // [추가] -// src/main/kotlin/kr/lunaticbum/back/lun/controllers/StockController.kt (예시) +data class StockOrderRequest( + val code: String, + val type: String, + val qty: Int, + val price: Int, + // [추가] 자동매매 옵션 + val isAutoTrade: Boolean = false, + val targetProfitRate: Double = 0.0 +) @Controller @RequestMapping("/stock") class StockViewController { + @GetMapping("/detail") + fun detailPage(): ResultMV = ResultMV("content/stock/detail").apply { setTitle("종목 상세 분석") } + + @GetMapping("/dashboard", "/dashboard.bs") + fun dashboardPage(): ResultMV = ResultMV("content/stock/dashboard").apply { setTitle("나의 투자 대시보드") } + @GetMapping("/config") fun configPage(): ResultMV = ResultMV("content/stock/config").apply { setTitle("Stock API 설정") } - @GetMapping("/market") fun margketPage(): ResultMV = ResultMV("content/stock/market").apply { setTitle("Stock API 설정") } - } @RestController @RequestMapping("/api/stock") class StockApiController( - private val webClient: WebClient, // WebClientConfig.kt 활용 + private val webClient: WebClient, private val userManager: UserManager, private val kisApiService: KisApiService, private val kisMarketService: KisMarketService, - private val logService: LogService + private val logService: LogService, + private val stockMonitorService: StockMonitorService // [추가] 注入 ) { + + @PostMapping("/order") + suspend fun placeOrder( + @RequestBody req: StockOrderRequest, + session: HttpSession + ): ResponseEntity> { + val auth = session.getAttribute("KIS_AUTH") as? KisAuthSession + ?: return ResponseEntity.ok(mapOf("resultCode" to 401, "resultMsg" to "인증 정보가 없습니다.")) + + if (req.qty <= 0) { + return ResponseEntity.ok(mapOf("resultCode" to 400, "resultMsg" to "수량을 확인해주세요.")) + } + + return try { + // 1. 주문 실행 + val response = kisApiService.orderStock( + auth, + req.type, + req.code, + req.qty.toString(), + req.price.toString() + ).awaitSingle() + + val rtCd = response["rt_cd"] as? String ?: "" + + if (rtCd == "0") { + val output = response["output"] as? Map ?: emptyMap() + val orderNo = output["ODNO"] as? String ?: "번호없음" + var msg = "주문 전송 완료 (주문번호: $orderNo)" + + // 2. [추가] 자동매매 등록 (매수 주문이고, 자동매매 체크 시) + if (req.type == "BUY" && req.isAutoTrade && req.targetProfitRate > 0) { + // suspend 함수 호출 + stockMonitorService.addMonitoring( + auth = auth, + code = req.code, + buyPrice = req.price.toDouble(), + qty = req.qty, + targetRate = req.targetProfitRate + ) + msg += "\n[⚡자동매도 등록] 목표수익률: ${req.targetProfitRate}% (DB 저장됨)" + } + + ResponseEntity.ok(mapOf("resultCode" to 0, "resultMsg" to msg)) + } else { + val msg = response["msg1"] as? String ?: "주문 실패" + ResponseEntity.ok(mapOf("resultCode" to 500, "resultMsg" to msg)) + } + } catch (e: Exception) { + e.printStackTrace() + ResponseEntity.ok(mapOf("resultCode" to 500, "resultMsg" to "주문 중 오류 발생: ${e.message}")) + } + } + @PostMapping("/config") suspend fun saveConfig( @RequestBody config: KisConfigRequest, - session: HttpSession // 세션 주입 + session: HttpSession ): ResponseEntity { return try { - // 1. KIS 서버로 토큰 발급 테스트 (유효성 검사) val token = kisApiService.verifyAndGetToken(config).awaitSingle() - - // 2. DB 저장 없이 세션에만 저장 (브라우저 종료 시 삭제) val authInfo = KisAuthSession( appKey = config.appKey, appSecret = config.appSecret, @@ -62,31 +123,235 @@ class StockApiController( accessToken = token ) session.setAttribute("KIS_AUTH", authInfo) - ResponseEntity.ok(ResponceResult().apply { resultCode = 0; resultMsg = "연결 성공" }) } catch (e: Exception) { ResponseEntity.ok(ResponceResult().apply { resultCode = 7001; resultMsg = "연결 실패: ${e.message}" }) } } + @GetMapping("/ws-key") + suspend fun getWebSocketKey(session: HttpSession): ResponseEntity> { + val auth = session.getAttribute("KIS_AUTH") as? KisAuthSession + ?: return ResponseEntity.ok(mapOf("resultCode" to 401, "resultMsg" to "인증 정보가 없습니다.")) + + return try { + // 접속키 발급 요청 (Config 정보 재구성 필요) + val config = KisConfigRequest(auth.appKey, auth.appSecret, auth.accountNo) + val approvalKey = kisApiService.getWebSocketApprovalKey(config).awaitSingle() + + ResponseEntity.ok(mapOf( + "resultCode" to 0, + "approval_key" to approvalKey + )) + } catch (e: Exception) { + e.printStackTrace() + ResponseEntity.ok(mapOf("resultCode" to 500, "resultMsg" to "접속키 발급 실패: ${e.message}")) + } + } + + @GetMapping("/rank/{type}") + suspend fun getRank( + @PathVariable type: String, + session: HttpSession + ): ResponseEntity> { + val auth = session.getAttribute("KIS_AUTH") as? KisAuthSession + ?: return ResponseEntity.ok(mapOf("resultCode" to 401, "resultMsg" to "인증 정보가 없습니다.")) + + return try { + val responseMono = when (type) { + "volume", "recommend", "amount" -> kisMarketService.getVolumeRank(auth) + "rising" -> kisMarketService.getFluctuationRank(auth, "0") + "falling" -> kisMarketService.getFluctuationRank(auth, "1") + else -> throw IllegalArgumentException("잘못된 요청입니다.") + } + + val response = responseMono.awaitSingle() + var output = response["output"] as? List> ?: emptyList() + + if (type == "recommend") { + output = output.filter { + val rate = it["prdy_ctrt"]?.toDoubleOrNull() ?: 0.0 + val price = it["stck_prpr"]?.toIntOrNull() ?: 0 + rate > 0.0 && rate < 25.0 && price >= 1000 + } + } else if (type == "amount") { + output = output.sortedByDescending { + val price = it["stck_prpr"]?.toLongOrNull() ?: 0L + val vol = it["acml_vol"]?.toLongOrNull() ?: 0L + price * vol + } + } + + val list = output.take(15).mapIndexed { index, item -> + val code = item["mksc_shrn_iscd"] ?: item["stck_shrn_iscd"] ?: item["iscd_stat_cls_code"] ?: "" + val price = item["stck_prpr"]?.toLongOrNull() ?: 0L + val vol = item["acml_vol"]?.toLongOrNull() ?: 0L + val amount = (price * vol) / 100000000 + + mapOf( + "rank" to (index + 1), + "code" to code, + "name" to (item["hts_kor_isnm"] ?: item["prdt_name"] ?: ""), + "price" to (item["stck_prpr"] ?: "0"), + "change_rate" to (item["prdy_ctrt"] ?: "0.0"), + "volume" to (item["acml_vol"] ?: "0"), + "amount" to amount + ) + } + + ResponseEntity.ok(mapOf("resultCode" to 0, "data" to list)) + } catch (e: Exception) { + e.printStackTrace() + ResponseEntity.ok(mapOf("resultCode" to 500, "resultMsg" to "조회 실패: ${e.message}")) + } + } + + + + @GetMapping("/details") + suspend fun getStockDetails( + @RequestParam codes: String, + session: HttpSession + ): ResponseEntity> { + val auth = session.getAttribute("KIS_AUTH") as? KisAuthSession + ?: return ResponseEntity.ok(mapOf("resultCode" to 401, "resultMsg" to "인증 정보가 없습니다.")) + + val codeList = codes.split(",").map { it.trim() }.filter { it.isNotEmpty() }.take(3) + if (codeList.isEmpty()) { + return ResponseEntity.ok(mapOf("resultCode" to 400, "resultMsg" to "유효한 종목 코드가 없습니다.")) + } + val delayTime = 500L + return try { + val results = Flux.fromIterable(codeList) + .concatMap { code -> + kisMarketService.getCurrentPrice(code, auth) + .delayElement(Duration.ofMillis(delayTime)) + .flatMap { priceData -> + kisMarketService.getMinuteChart(code, auth) + .map { chartData -> Triple(code, priceData, chartData) } + } + .delayElement(Duration.ofMillis(delayTime)) + } + .collectList() + .awaitSingle() + + val dataList = results.map { item -> + val (code, priceRes, chartRes) = item as Triple, Map<*, *>> + + val output = priceRes["output"] as? Map ?: emptyMap() + val chartOutput = chartRes["output2"] as? List> ?: emptyList() + + // 차트 데이터 (시간순 정렬) + val chartList = chartOutput.take(60).reversed().map { tick -> + mapOf( + "time" to (tick["stck_cntg_hour"]?.substring(0, 4) ?: ""), + "price" to (tick["stck_prpr"] ?: "0"), + "volume" to (tick["cntg_vol"] ?: "0") + ) + } + + // [추가] 구간별 평균 거래량/거래대금 계산 함수 + // chartOutput은 최신순(내림차순)으로 들어옵니다. (0번 인덱스가 가장 최근 1분) + fun calcAvg(minutes: Int): Map { + val subset = chartOutput.take(minutes) + if (subset.isEmpty()) return mapOf("vol" to 0L, "amt" to 0L) + + val sumVol = subset.sumOf { (it["cntg_vol"]?.toLongOrNull() ?: 0L) } + // 분당 거래대금 추정 = 체결가 * 거래량 + val sumAmt = subset.sumOf { + val p = it["stck_prpr"]?.toLongOrNull() ?: 0L + val v = it["cntg_vol"]?.toLongOrNull() ?: 0L + p * v + } + + return mapOf( + "vol" to (sumVol / subset.size), + "amt" to (sumAmt / subset.size) + ) + } + + val averages = mapOf( + "min1" to calcAvg(1), + "min5" to calcAvg(5), + "min10" to calcAvg(10), + "min60" to calcAvg(60) + ) + + mapOf( + "code" to code, + "name" to (output["rprs_mrkt_kor_name"] ?: ""), + "price" to (output["stck_prpr"] ?: "0"), + "change" to (output["prdy_vrss"] ?: "0"), + "change_rate" to (output["prdy_ctrt"] ?: "0.0"), + "high" to (output["stck_hgpr"] ?: "0"), + "low" to (output["stck_lwpr"] ?: "0"), + "open" to (output["stck_oprc"] ?: "0"), + "volume" to (output["acml_vol"] ?: "0"), + "chart" to chartList, + "averages" to averages // [추가] 평균 데이터 전달 + ) + } + + ResponseEntity.ok(mapOf("resultCode" to 0, "data" to dataList)) + + } catch (e: Exception) { + e.printStackTrace() + ResponseEntity.ok(mapOf("resultCode" to 500, "resultMsg" to "상세 조회 실패: ${e.message}")) + } + } + + @GetMapping("/balance") + suspend fun getBalance(session: HttpSession): ResponseEntity> { + val auth = session.getAttribute("KIS_AUTH") as? KisAuthSession + ?: return ResponseEntity.ok(mapOf("resultCode" to 401, "resultMsg" to "인증 정보가 없습니다.")) + + return try { + val response = kisApiService.getAccountBalance(auth).awaitSingle() + val rtCd = response["rt_cd"] as? String ?: "" + if (rtCd != "0") { + val msg = response["msg1"] as? String ?: "알 수 없는 오류" + return ResponseEntity.ok(mapOf("resultCode" to 500, "resultMsg" to "KIS 오류: $msg ($rtCd)")) + } + + val output1 = response["output1"] as? List> ?: emptyList() + val output2 = response["output2"] as? List> ?: emptyList() + val summary = if (output2.isNotEmpty()) output2[0] else emptyMap() + + val taxFeeRate = 1.0025 + + val stocks = output1.map { stock -> + val buyPrice = stock["pchs_avg_pric"]?.toString()?.toDoubleOrNull() ?: 0.0 + val currentPrice = stock["prpr"]?.toString()?.toDoubleOrNull() ?: 0.0 + val qty = stock["hldg_qty"]?.toString()?.toIntOrNull() ?: 0 + val profitRate = stock["evlu_pfls_rt"]?.toString()?.toDoubleOrNull() ?: 0.0 + val evalAmount = stock["evlu_amt"]?.toString()?.toLongOrNull() ?: (currentPrice * qty).toLong() + + mapOf( + "name" to (stock["prdt_name"]?.toString() ?: ""), + "qty" to qty, + "buy_price" to buyPrice, + "current_price" to currentPrice, + "eval_amount" to evalAmount, + "profit_rate" to profitRate, + "break_even_price" to buyPrice * taxFeeRate + ) + } + + val resultData = mapOf( + "total_asset" to (summary["tot_evlu_amt"]?.toString() ?: "0"), + "total_profit_rate" to (summary["evlu_pfls_rt"]?.toString() ?: "0.0"), + "stocks" to stocks + ) + + ResponseEntity.ok(mapOf("resultCode" to 0, "data" to resultData)) + } catch (e: Exception) { + e.printStackTrace() + ResponseEntity.ok(mapOf("resultCode" to 500, "resultMsg" to "처리 실패: ${e.message}")) + } + } + @GetMapping("/market") suspend fun getMarketIndicators(): ResponseEntity> { - // 공용 혹은 세션에 저장된 키를 사용 (세션에 없다면 기본 시스템 키 사용 로직 필요) - val token = "..." // 발급받은 토큰 - val appKey = "..." - val appSecret = "..." - - val kospi = kisMarketService.getDomesticIndex("0001", token, appKey, appSecret).awaitSingle() - val kosdaq = kisMarketService.getDomesticIndex("1001", token, appKey, appSecret).awaitSingle() - - // 응답 데이터 정리 - val result = mapOf( - "kospi" to (kospi["output"] as Map<*, *>)["bstp_nmix_prpr"], // 현재가 - "kospi_change" to (kospi["output"] as Map<*, *>)["bstp_nmix_prdy_ctrt"], // 등락률 - "kosdaq" to (kosdaq["output"] as Map<*, *>)["bstp_nmix_prpr"], - "kosdaq_change" to (kosdaq["output"] as Map<*, *>)["bstp_nmix_prdy_ctrt"] - ) - - return ResponseEntity.ok(result) as ResponseEntity> + return ResponseEntity.ok(mapOf()) } } \ No newline at end of file diff --git a/src/main/kotlin/kr/lunaticbum/back/lun/model/AutoTradeEntity.kt b/src/main/kotlin/kr/lunaticbum/back/lun/model/AutoTradeEntity.kt new file mode 100644 index 0000000..3b7be9d --- /dev/null +++ b/src/main/kotlin/kr/lunaticbum/back/lun/model/AutoTradeEntity.kt @@ -0,0 +1,26 @@ +// src/main/kotlin/kr/lunaticbum/back/lun/model/AutoTradeEntity.kt + +package kr.lunaticbum.back.lun.model + +import org.springframework.data.annotation.Id +import org.springframework.data.mongodb.core.mapping.Document + +// [수정] JPA @Entity 대신 MongoDB @Document 사용 +@Document(collection = "auto_trade_tasks") +data class AutoTradeEntity( + @Id + val id: String? = null, // MongoDB는 ID가 보통 String입니다. + + val stockCode: String, + val buyPrice: Double, + val quantity: Int, + val targetProfitRate: Double, + + // 인증 정보 + val appKey: String, + val appSecret: String, + val accountNo: String, + + // 가변 변수 (토큰 갱신용) + var accessToken: String +) \ No newline at end of file diff --git a/src/main/kotlin/kr/lunaticbum/back/lun/repository/ReactiveMongoRepository.kt b/src/main/kotlin/kr/lunaticbum/back/lun/repository/ReactiveMongoRepository.kt new file mode 100644 index 0000000..a680b95 --- /dev/null +++ b/src/main/kotlin/kr/lunaticbum/back/lun/repository/ReactiveMongoRepository.kt @@ -0,0 +1,10 @@ +// src/main/kotlin/kr/lunaticbum/back/lun/repository/AutoTradeRepository.kt + +package kr.lunaticbum.back.lun.repository + +import kr.lunaticbum.back.lun.model.AutoTradeEntity +import org.springframework.data.mongodb.repository.ReactiveMongoRepository +import org.springframework.stereotype.Repository + +@Repository +interface AutoTradeRepository : ReactiveMongoRepository \ No newline at end of file diff --git a/src/main/kotlin/kr/lunaticbum/back/lun/service/KisApiService.kt b/src/main/kotlin/kr/lunaticbum/back/lun/service/KisApiService.kt index ed19ce6..3c6dc03 100644 --- a/src/main/kotlin/kr/lunaticbum/back/lun/service/KisApiService.kt +++ b/src/main/kotlin/kr/lunaticbum/back/lun/service/KisApiService.kt @@ -1,14 +1,31 @@ package kr.lunaticbum.back.lun.service +import kr.lunaticbum.back.lun.model.KisAuthSession import kr.lunaticbum.back.lun.model.KisConfigRequest import org.springframework.stereotype.Service import org.springframework.web.reactive.function.client.WebClient +import org.springframework.web.reactive.function.client.WebClientResponseException import reactor.core.publisher.Mono +private val REAL_URL = "https://openapi.koreainvestment.com:9443" +private val MOCK_URL = "https://openapivts.koreainvestment.com:29443" + +private val isRealTrading = false + +private fun getBaseUrl(): String { + return if (isRealTrading) REAL_URL else MOCK_URL +} + @Service class KisApiService() { - private val baseUrl = "https://openapi.koreainvestment.com:9443" // 실전투자용 - private val webClient: WebClient = WebClient.create(baseUrl) + // 두 환경의 URL을 상수로 정의 + + // [설정] 기본적으로 실전투자를 사용하려면 true, 모의투자는 false로 설정하세요. + // 혹은 설정 화면에서 체크박스를 받아오는 방식으로 구조를 변경할 수도 있습니다. + + + // WebClient를 매번 생성하거나(단순함), baseUrl 변경이 필요하므로 요청 시 build + private val webClientBuilder: WebClient.Builder = WebClient.builder() fun verifyAndGetToken(config: KisConfigRequest): Mono { val body = mapOf( @@ -17,22 +34,160 @@ class KisApiService() { "appsecret" to config.appSecret ) - return webClient.post() - .uri("$baseUrl/oauth2/tokenP") + return webClientBuilder.baseUrl(getBaseUrl()).build() + .post() + .uri("/oauth2/tokenP") .bodyValue(body) .retrieve() .bodyToMono(Map::class.java) .map { it["access_token"]?.toString() ?: throw Exception("토큰 발급 실패") } } + + fun getAccountBalance(auth: KisAuthSession): Mono> { + val cano = if (auth.accountNo.length >= 8) auth.accountNo.substring(0, 8) else auth.accountNo + val prdt = if (auth.accountNo.length >= 10) auth.accountNo.substring(8, 10) else "01" + + // 실전/모의투자에 따라 TR_ID가 다릅니다. + // 주식잔고조회: 실전(TTTC8434R) / 모의(VTTC8434R) + val trId = if (isRealTrading) "TTTC8434R" else "VTTC8434R" + + return webClientBuilder.baseUrl(getBaseUrl()).build() + .get() + .uri { it.path("/uapi/domestic-stock/v1/trading/inquire-balance") + .queryParam("CANO", cano) + .queryParam("ACNT_PRDT_CD", prdt) + .queryParam("AFHR_FLPR_YN", "N") + .queryParam("OFL_YN", "N") + .queryParam("INQR_DVSN", "02") + .queryParam("UNPR_DVSN", "01") + .queryParam("FUND_STTL_ICLD_YN", "N") + .queryParam("FNCG_AMT_AUTO_RDPT_YN", "N") + .queryParam("PRCS_DVSN", "00") + .queryParam("CTX_AREA_FK100", "") + .queryParam("CTX_AREA_NK100", "") + .build() + } + .header("authorization", "Bearer ${auth.accessToken}") + .header("appkey", auth.appKey) + .header("appsecret", auth.appSecret) + .header("tr_id", trId) // [중요] 환경에 맞는 TR_ID 사용 + .retrieve() + .bodyToMono(Map::class.java) + } + + + fun orderStock( + auth: KisAuthSession, + orderType: String, // "BUY" or "SELL" + stockCode: String, + qty: String, + price: String + ): Mono> { + // 계좌번호 분리 + val cano = if (auth.accountNo.length >= 8) auth.accountNo.substring(0, 8) else auth.accountNo + val prdt = if (auth.accountNo.length >= 10) auth.accountNo.substring(8, 10) else "01" + + // TR_ID 결정 (중요!) + // 실전: 매수(TTTC0802U), 매도(TTTC0801U) + // 모의: 매수(VTTC0802U), 매도(VTTC0801U) + val trId = if (isRealTrading) { + if (orderType == "BUY") "TTTC0802U" else "TTTC0801U" + } else { + if (orderType == "BUY") "VTTC0802U" else "VTTC0801U" + } + + val requestBody = mapOf( + "CANO" to cano, + "ACNT_PRDT_CD" to prdt, + "PDNO" to stockCode, + "ORD_DVSN" to "00", // 00: 지정가 (가격을 직접 입력) + "ORD_QTY" to qty, + "ORD_UNPR" to price // 0원이면 시장가로 하려면 ORD_DVSN을 01로 바꿔야 함 + ) + + return webClientBuilder.baseUrl(getBaseUrl()).build() + .post() + .uri("/uapi/domestic-stock/v1/trading/order-cash") + .header("authorization", "Bearer ${auth.accessToken}") + .header("appkey", auth.appKey) + .header("appsecret", auth.appSecret) + .header("tr_id", trId) + .bodyValue(requestBody) + .retrieve() + .bodyToMono(Map::class.java) + } + + // [추가] 웹소켓 접속키 발급 (1회 발급 후 계속 사용 가능하지만, 여기선 호출 시마다 받도록 구현) + fun getWebSocketApprovalKey(config: KisConfigRequest): Mono { + val body = mapOf( + "grant_type" to "client_credentials", + "appkey" to config.appKey, + "secretkey" to config.appSecret // 주의: 여기선 appsecret이 아니라 secretkey라는 키 이름을 씁니다. + ) + + return webClientBuilder.baseUrl(getBaseUrl()).build() + .post() + .uri("/oauth2/Approval") + .bodyValue(body) + .retrieve() + .bodyToMono(Map::class.java) + .map { it["approval_key"]?.toString() ?: throw Exception("접속키 발급 실패") } + } + } + @Service class KisMarketService() { - private val baseUrl = "https://openapi.koreainvestment.com:9443" - private val webClient: WebClient = WebClient.create(baseUrl) + + fun getMinuteChart(symbol: String, auth: KisAuthSession): Mono> { + return webClient.get() + .uri { it.path("/uapi/domestic-stock/v1/quotations/inquire-time-itemchartprice") + .queryParam("FID_COND_MRKT_DIV_CODE", "J") + .queryParam("FID_INPUT_ISCD", symbol) + .queryParam("FID_ETC_CLS_CODE", "") + .queryParam("FID_INPUT_HOUR_1", "") // 비워두면 최신 데이터 + .queryParam("FID_PW_DATA_INCU_YN", "N") + .build() + } + .header("authorization", "Bearer ${auth.accessToken}") + .header("appkey", auth.appKey) + .header("appsecret", auth.appSecret) + .header("tr_id", "FHKST03010200") // 주식 분봉 조회 TR (실전/모의 동일) + .retrieve() + .bodyToMono(Map::class.java) + } + + // 3. 주식 현재가 시세 조회 (에러 디버깅 추가) + fun getCurrentPrice(symbol: String, auth: KisAuthSession): Mono> { + // 공백 제거 및 유효성 체크 + val cleanSymbol = symbol.trim() + if (cleanSymbol.isEmpty()) return Mono.error(IllegalArgumentException("종목 코드가 비어있습니다.")) + + return webClient.get() + .uri { it.path("/uapi/domestic-stock/v1/quotations/inquire-price") + .queryParam("FID_COND_MRKT_DIV_CODE", "J") + .queryParam("FID_INPUT_ISCD", cleanSymbol) + .build() + } + .header("authorization", "Bearer ${auth.accessToken}") + .header("appkey", auth.appKey) + .header("appsecret", auth.appSecret) + .header("tr_id", "FHKST01010100") + .retrieve() + .bodyToMono(Map::class.java) + .onErrorResume(WebClientResponseException::class.java) { ex -> + // 에러 발생 시 상세 내용을 로그로 출력 + val errorBody = ex.responseBodyAsString + println(">>> KIS API Error [CurrentPrice]: $errorBody") + Mono.error(Exception("KIS Error: $errorBody")) + } + } + + private val webClient: WebClient = WebClient.create(getBaseUrl()) // 1. 국내 지수 조회 (KOSPI: "0001", KOSDAQ: "1001") fun getDomesticIndex(indexCode: String, token: String, appKey: String, appSecret: String): Mono> { - return WebClient.create(baseUrl).get() + return WebClient.create(getBaseUrl()).get() .uri { it.path("/uapi/domestic-stock/v1/quotations/inquire-index-price") .queryParam("FID_COND_MRKT_DIV_CODE", "U") // 업종 .queryParam("FID_INPUT_ISCD", indexCode) @@ -46,6 +201,59 @@ class KisMarketService() { .bodyToMono(Map::class.java) } + // 1. 거래량 순위 조회 + fun getVolumeRank(auth: KisAuthSession): Mono> { + return webClient.get() + .uri { it.path("/uapi/domestic-stock/v1/quotations/volume-rank") + .queryParam("FID_COND_MRKT_DIV_CODE", "J") // J: 전체, P: 코스피, Q: 코스닥 + .queryParam("FID_COND_SCR_DIV_CODE", "20171") + .queryParam("FID_INPUT_ISCD", "0000") // 0000: 전체 + .queryParam("FID_DIV_CLS_CODE", "0") // 0: 전체 + .queryParam("FID_BLNG_CLS_CODE", "0") // 0: 평균거래량 + .queryParam("FID_TRGT_CLS_CODE", "11111111") + .queryParam("FID_TRGT_EXLS_CLS_CODE", "000000") + .queryParam("FID_INPUT_PRICE_1", "") + .queryParam("FID_INPUT_PRICE_2", "") + .queryParam("FID_VOL_CNT", "") + .queryParam("FID_INPUT_DATE_1", "") + .build() + } + .header("authorization", "Bearer ${auth.accessToken}") + .header("appkey", auth.appKey) + .header("appsecret", auth.appSecret) + .header("tr_id", "FHPST01710000") // 거래량 순위 TR ID + .header("custtype", "P") + .retrieve() + .bodyToMono(Map::class.java) + } + + // 2. 등락률 순위 조회 (0: 상승순, 1: 하락순) + fun getFluctuationRank(auth: KisAuthSession, type: String = "0"): Mono> { + return webClient.get() + .uri { it.path("/uapi/domestic-stock/v1/ranking/fluctuation") + .queryParam("FID_COND_MRKT_DIV_CODE", "J") // J: 전체 + .queryParam("FID_COND_SCR_DIV_CODE", "20170") + .queryParam("FID_INPUT_ISCD", "0000") // 0000: 전체 + .queryParam("FID_RANK_SORT_CLS_CODE", type) // 0: 상승, 1: 하락 + .queryParam("FID_INPUT_CNT_1", "0") // 입력 수 + .queryParam("FID_PRC_CLS_CODE", "1") // [수정] 0:관련없음 -> 1:보통 (장 종료후에는 1이 더 안정적일 수 있음) + .queryParam("FID_INPUT_PRICE_1", "") + .queryParam("FID_INPUT_PRICE_2", "") + .queryParam("FID_VOL_CNT", "") // 거래량 조건 없애기 (비워두면 전체) + .queryParam("FID_TRGT_CLS_CODE", "11111111") + .queryParam("FID_TRGT_EXLS_CLS_CODE", "000000") + .build() + } + .header("authorization", "Bearer ${auth.accessToken}") + .header("appkey", auth.appKey) + .header("appsecret", auth.appSecret) + .header("tr_id", "FHPST01700000") // 등락률 순위 TR ID + .header("custtype", "P") + .retrieve() + .bodyToMono(Map::class.java) + } + + // 2. 환율 및 해외 지수 조회 (환율: "FX@KRW", 나스닥: "NAS@IXIC") // ※ 해외 지수는 '해외주식 현재가 상세' API 등을 활용합니다. fun getMarketIndicator(symbol: String, token: String, appKey: String, appSecret: String): Mono> { @@ -63,4 +271,4 @@ class KisMarketService() { .retrieve() .bodyToMono(Map::class.java) } -} \ No newline at end of file +} diff --git a/src/main/kotlin/kr/lunaticbum/back/lun/service/StockMonitorService.kt b/src/main/kotlin/kr/lunaticbum/back/lun/service/StockMonitorService.kt new file mode 100644 index 0000000..dd5b4cf --- /dev/null +++ b/src/main/kotlin/kr/lunaticbum/back/lun/service/StockMonitorService.kt @@ -0,0 +1,133 @@ +// src/main/kotlin/kr/lunaticbum/back/lun/service/StockMonitorService.kt + +package kr.lunaticbum.back.lun.service + +import jakarta.annotation.PostConstruct +import kotlinx.coroutines.reactor.awaitSingle +import kotlinx.coroutines.runBlocking +import kr.lunaticbum.back.lun.model.AutoTradeEntity +import kr.lunaticbum.back.lun.model.KisAuthSession +import kr.lunaticbum.back.lun.model.KisConfigRequest +import kr.lunaticbum.back.lun.repository.AutoTradeRepository +import org.springframework.stereotype.Service +import java.util.concurrent.CopyOnWriteArrayList + +@Service +class StockMonitorService( + private val kisMarketService: KisMarketService, + private val kisApiService: KisApiService, + private val autoTradeRepository: AutoTradeRepository +) { + // 메모리 캐시 (실시간 조회를 위해 사용) + private val monitoringList = CopyOnWriteArrayList() + + // 1. 서버 시작 시 MongoDB에서 데이터 복구 + @PostConstruct + fun init() { + // Reactive 리포지토리이므로 runBlocking으로 데이터를 가져옵니다. + val savedTasks = runBlocking { + autoTradeRepository.findAll().collectList().awaitSingle() + } + monitoringList.addAll(savedTasks) + println(">>> [StockMonitor] MongoDB에서 ${savedTasks.size}개의 자동매매 작업을 복구했습니다.") + } + + // 2. 감시 대상 등록 (MongoDB 저장 + 메모리 추가) + // Controller에서 호출하므로 suspend 함수로 변경 + suspend fun addMonitoring(auth: KisAuthSession, code: String, buyPrice: Double, qty: Int, targetRate: Double) { + + auth.accessToken?.let{accessToken -> + val entity = AutoTradeEntity( + stockCode = code, + buyPrice = buyPrice, + quantity = qty, + targetProfitRate = targetRate, + appKey = auth.appKey, + appSecret = auth.appSecret, + accountNo = auth.accountNo, + accessToken = accessToken + ) + + // MongoDB 저장 (awaitSingle 사용) + val saved = autoTradeRepository.save(entity).awaitSingle() + monitoringList.add(saved) + println(">>> [자동매매 등록] $code / 목표: ${targetRate}% / DB 저장 완료") + + } + } + + // 3. 주기적 실행 (Scheduler에서 호출) + fun checkAndExecuteAutoSell() { + if (monitoringList.isEmpty()) return + + // 스케줄러는 동기식이므로 runBlocking 블록 내에서 비동기 작업을 수행합니다. + runBlocking { + monitoringList.forEach { task -> + try { + checkPriceAndTrade(task) + } catch (e: Exception) { + // 토큰 만료 에러 감지 시 + if (isTokenExpiredError(e)) { + println(">>> [토큰 만료 감지] ${task.stockCode} 작업의 토큰을 갱신합니다.") + if (refreshToken(task)) { + // 갱신 성공 시 재시도 + try { checkPriceAndTrade(task) } catch (e2: Exception) { e2.printStackTrace() } + } + } else { + println(">>> [자동매매 오류] ${task.stockCode}: ${e.message}") + } + } + } + } + } + + // 시세 확인 및 매도 로직 + private suspend fun checkPriceAndTrade(task: AutoTradeEntity) { + val tempAuth = KisAuthSession(task.appKey, task.appSecret, task.accountNo, task.accessToken) + + val response = kisMarketService.getCurrentPrice(task.stockCode, tempAuth).awaitSingle() + val output = response["output"] as? Map + val currentPrice = output?.get("stck_prpr")?.toDoubleOrNull() ?: 0.0 + + if (currentPrice > 0) { + val currentRate = ((currentPrice - task.buyPrice) / task.buyPrice) * 100 + + if (currentRate >= task.targetProfitRate) { + println(">>> [조건 달성] ${task.stockCode} 수익률 ${String.format("%.2f", currentRate)}% -> 매도 실행") + + kisApiService.orderStock( + tempAuth, "SELL", task.stockCode, task.quantity.toString(), "0" + ).awaitSingle() + + // 매도 성공 시 목록 및 DB에서 제거 + monitoringList.remove(task) + autoTradeRepository.delete(task).awaitSingle() // Reactive delete + println(">>> [자동매매 완료] ${task.stockCode} 작업 삭제됨") + } + } + } + + // 토큰 갱신 로직 + private suspend fun refreshToken(task: AutoTradeEntity): Boolean { + return try { + val config = KisConfigRequest(task.appKey, task.appSecret, task.accountNo) + val newToken = kisApiService.verifyAndGetToken(config).awaitSingle() + + // 메모리 업데이트 + task.accessToken = newToken + // DB 업데이트 (save는 덮어쓰기 수행) + autoTradeRepository.save(task).awaitSingle() + + println(">>> [토큰 갱신 성공] ${task.stockCode}") + true + } catch (e: Exception) { + println(">>> [토큰 갱신 실패] ${e.message}") + false + } + } + + private fun isTokenExpiredError(e: Exception): Boolean { + val msg = e.message?.lowercase() ?: "" + return msg.contains("401") || msg.contains("token") || msg.contains("expired") || msg.contains("authorization") + } +} \ No newline at end of file diff --git a/src/main/resources/templates/content/stock/config.html b/src/main/resources/templates/content/stock/config.html index e1b55d7..42ca16b 100644 --- a/src/main/resources/templates/content/stock/config.html +++ b/src/main/resources/templates/content/stock/config.html @@ -33,6 +33,8 @@ + \ No newline at end of file diff --git a/src/main/resources/templates/content/stock/detail.html b/src/main/resources/templates/content/stock/detail.html new file mode 100644 index 0000000..c257d91 --- /dev/null +++ b/src/main/resources/templates/content/stock/detail.html @@ -0,0 +1,493 @@ + + + + + + + + +
+
+
+

종목 상세 & 주문

+

실시간 시세(웹소켓), 분봉 차트, 구간별 거래 추이 분석 및 스마트 주문

+
+ +
+
+

데이터를 불러오는 중입니다...

+
+
+ + +
+ + +
+ \ No newline at end of file diff --git a/src/main/resources/templates/content/stock/market.html b/src/main/resources/templates/content/stock/market.html index 4a09adf..0e9abd1 100644 --- a/src/main/resources/templates/content/stock/market.html +++ b/src/main/resources/templates/content/stock/market.html @@ -1,56 +1,112 @@ - - +
+
+
    +
  • +
  • +
  • -
    -
    -
    -

    글로벌 시장 지표

    -
    -
    -
    +
  • +
  • +
+
- - - \ No newline at end of file + const codes = Array.from(checkboxes).map(c => c.value).join(','); + location.href = `/stock/detail?codes=${codes}`; + }; + + + window.addEventListener('DOMContentLoaded', () => { + // 기본값: 단기 추천 탭 먼저 보여주기 + const recommendBtn = document.querySelector("button[onclick*='recommend']"); + if(recommendBtn) recommendBtn.click(); + else document.querySelector('.actions button').click(); + }); + \ No newline at end of file