From df50c9a2da02a8692934760db52f799d0e997761 Mon Sep 17 00:00:00 2001 From: lunaticbum Date: Wed, 7 Jan 2026 16:42:06 +0900 Subject: [PATCH] ... --- .../lun/configs/security/SecurityConfig.kt | 20 +- .../back/lun/controllers/StockController.kt | 330 ++++++++--- .../lun/controllers/StockLinkController.kt | 68 +++ .../back/lun/model/AutoTradeEntity.kt | 13 +- .../back/lun/model/DirectLoginToken.kt | 25 + .../back/lun/model/TradeHistoryEntity.kt | 21 + .../kr/lunaticbum/back/lun/model/User.kt | 2 +- .../lun/repository/DirectLoginRepository.kt | 9 + .../lun/repository/TradeHistoryRepository.kt | 13 + .../back/lun/service/DirectLoginService.kt | 58 ++ .../back/lun/service/KisApiService.kt | 76 ++- .../back/lun/service/StockMonitorService.kt | 168 +++++- .../back/lun/utils/MarketTimeManager.kt | 48 ++ src/main/resources/static/js/modules/api.js | 2 +- src/main/resources/static/js/modules/stock.js | 284 +++++++++ .../templates/content/stock/auto_trade.html | 108 ++++ .../templates/content/stock/config.html | 117 +++- .../templates/content/stock/dashboard.html | 12 +- .../templates/content/stock/detail.html | 554 +++++------------- .../templates/content/stock/history.html | 82 +++ .../templates/content/stock/market.html | 224 ++++--- .../resources/templates/fragments/header.html | 2 + 22 files changed, 1580 insertions(+), 656 deletions(-) create mode 100644 src/main/kotlin/kr/lunaticbum/back/lun/controllers/StockLinkController.kt create mode 100644 src/main/kotlin/kr/lunaticbum/back/lun/model/DirectLoginToken.kt create mode 100644 src/main/kotlin/kr/lunaticbum/back/lun/model/TradeHistoryEntity.kt create mode 100644 src/main/kotlin/kr/lunaticbum/back/lun/repository/DirectLoginRepository.kt create mode 100644 src/main/kotlin/kr/lunaticbum/back/lun/repository/TradeHistoryRepository.kt create mode 100644 src/main/kotlin/kr/lunaticbum/back/lun/service/DirectLoginService.kt create mode 100644 src/main/kotlin/kr/lunaticbum/back/lun/utils/MarketTimeManager.kt create mode 100644 src/main/resources/static/js/modules/stock.js create mode 100644 src/main/resources/templates/content/stock/auto_trade.html create mode 100644 src/main/resources/templates/content/stock/history.html diff --git a/src/main/kotlin/kr/lunaticbum/back/lun/configs/security/SecurityConfig.kt b/src/main/kotlin/kr/lunaticbum/back/lun/configs/security/SecurityConfig.kt index 50ff46d..b8d2823 100644 --- a/src/main/kotlin/kr/lunaticbum/back/lun/configs/security/SecurityConfig.kt +++ b/src/main/kotlin/kr/lunaticbum/back/lun/configs/security/SecurityConfig.kt @@ -48,6 +48,7 @@ import org.springframework.security.web.context.RequestAttributeSecurityContextR import org.springframework.security.web.context.SecurityContextRepository import org.springframework.security.web.util.matcher.AntPathRequestMatcher import org.springframework.security.web.util.matcher.NegatedRequestMatcher +import org.springframework.security.web.util.matcher.RequestMatcher import org.springframework.stereotype.Component import org.springframework.web.filter.OncePerRequestFilter import java.security.SignatureException @@ -106,7 +107,10 @@ class SecurityConfig( http.securityContext { context -> context.securityContextRepository(securityContextRepository()) } - .securityMatcher("/api/**") // 이 설정은 /api/ 경로에만 적용됨 + .securityMatcher { request -> + val path = request.servletPath + path.startsWith("/api/") && !path.startsWith("/api/stock/") + } .csrf { it.disable() } .cors { it.configurationSource(corsConfigurationSource()) } .sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) } // API는 세션을 사용하지 않음 @@ -171,8 +175,11 @@ class SecurityConfig( @Bean @Order(2) // 웹 페이지 보안 설정 fun webFilterChain(http: HttpSecurity): SecurityFilterChain { - http.securityMatcher(NegatedRequestMatcher(AntPathRequestMatcher("/api/**"))) - +// http.securityMatcher(NegatedRequestMatcher(AntPathRequestMatcher("/api/**"))) + http.securityMatcher(NegatedRequestMatcher { request -> + val path = request.servletPath + path.startsWith("/api/") && !path.startsWith("/api/stock/") + }) http.cors { } .csrf { csrf -> csrf.ignoringRequestMatchers( @@ -397,8 +404,11 @@ class CustomAccessDeniedHandler : AccessDeniedHandler { class ApiAndWebSecurityContextRepository : SecurityContextRepository { // API 요청은 /api/** 패턴에 매칭됩니다. - private val apiRequestMatcher = AntPathRequestMatcher("/api/**") - +// private val apiRequestMatcher = AntPathRequestMatcher("/api/**") + private val apiRequestMatcher = RequestMatcher { request -> + val path = request.servletPath + path.startsWith("/api/") && !path.startsWith("/api/stock/") + } // API 요청에 대해서는 세션을 전혀 사용하지 않고, 오직 요청 기간 동안만 SecurityContext를 저장합니다. (완벽한 STATELESS) private val apiContextRepository = RequestAttributeSecurityContextRepository() 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 ab79aa2..382f59f 100644 --- a/src/main/kotlin/kr/lunaticbum/back/lun/controllers/StockController.kt +++ b/src/main/kotlin/kr/lunaticbum/back/lun/controllers/StockController.kt @@ -1,22 +1,36 @@ package kr.lunaticbum.back.lun.controllers +import jakarta.servlet.http.HttpServletRequest import jakarta.servlet.http.HttpSession +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay import kotlinx.coroutines.reactor.awaitSingle +import kotlinx.coroutines.withContext +import kr.lunaticbum.back.lun.configs.core.GlobalEnvironment import kr.lunaticbum.back.lun.model.KisAuthSession import kr.lunaticbum.back.lun.model.KisConfigRequest import kr.lunaticbum.back.lun.model.ResponceResult import kr.lunaticbum.back.lun.model.ResultMV import kr.lunaticbum.back.lun.model.UserManager +import kr.lunaticbum.back.lun.repository.TradeHistoryRepository +import kr.lunaticbum.back.lun.service.DirectLoginService 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.services.TelegramBotService import kr.lunaticbum.back.lun.utils.LogService +import kr.lunaticbum.back.lun.utils.MarketTimeManager import org.springframework.http.ResponseEntity +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken +import org.springframework.security.core.context.SecurityContextHolder +import org.springframework.security.core.userdetails.UserDetailsService import org.springframework.stereotype.Controller 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 reactor.kotlin.core.util.function.component1 +import reactor.kotlin.core.util.function.component2 import java.time.Duration // [추가] data class StockOrderRequest( @@ -24,14 +38,26 @@ data class StockOrderRequest( val type: String, val qty: Int, val price: Int, - // [추가] 자동매매 옵션 val isAutoTrade: Boolean = false, - val targetProfitRate: Double = 0.0 + val targetProfitRate: Double = 0.0, + val stopLossRate: Double = 0.0, + val isSellAll: Boolean = false ) - @Controller @RequestMapping("/stock") -class StockViewController { +class StockViewController( + private val kisApiService: KisApiService, + private val kisMarketService: KisMarketService, + private val directLoginService: DirectLoginService, + private val userDetailsService: UserDetailsService +) { + + @GetMapping("/auto-trade") // [추가] 자동매매 리스트 화면 + fun autoTradePage(): ResultMV = ResultMV("content/stock/auto_trade").apply { setTitle("자동매매 관리") } + + @GetMapping("/history") // [추가] 거래내역 화면 + fun historyPage(): ResultMV = ResultMV("content/stock/history").apply { setTitle("거래 내역") } + @GetMapping("/detail") fun detailPage(): ResultMV = ResultMV("content/stock/detail").apply { setTitle("종목 상세 분석") } @@ -43,6 +69,60 @@ class StockViewController { @GetMapping("/market") fun margketPage(): ResultMV = ResultMV("content/stock/market").apply { setTitle("Stock API 설정") } + + @GetMapping("/direct-login") + suspend fun directLogin( + @RequestParam token: String, + session: HttpSession, + request: HttpServletRequest + ): String { + return try { + val ip = request.getHeader("X-Forwarded-For") ?: request.remoteAddr + val ua = request.getHeader("User-Agent") ?: "" + val deviceId = request.cookies?.find { it.name == "LUN_DEVICE_ID" }?.value + + // 1. 토큰 검증 (비동기) + val info = directLoginService.validateAndGet(token, ip, ua, deviceId) + + // 2. 앱 로그인 처리 (블로킹 구간 격리) + // UserDetailsService는 블로킹 DB 호출을 포함할 수 있으므로 IO 스레드에서 실행 + if (request.userPrincipal == null || request.userPrincipal.name != info.username) { + withContext(Dispatchers.IO) { + val userDetails = userDetailsService.loadUserByUsername(info.username) + val auth = UsernamePasswordAuthenticationToken(userDetails, null, userDetails.authorities) + SecurityContextHolder.getContext().authentication = auth + session.setAttribute("SPRING_SECURITY_CONTEXT", SecurityContextHolder.getContext()) + } + } + + // 3. 주식 API 연결 (비동기) + val config = KisConfigRequest(info.appKey, info.appSecret, info.accountNo) + val accessToken = kisApiService.verifyAndGetToken(config).awaitSingle() + session.setAttribute("KIS_AUTH", KisAuthSession(info.appKey, info.appSecret, info.accountNo, accessToken)) + + "redirect:/stock/dashboard" + + } catch (e: Exception) { + e.printStackTrace() + // 에러 메시지 한글 깨짐 방지 처리 + val errorMsg = if (e.message?.contains("Timeout") == true) { + "로그인 시간 초과 (DB 응답 지연)" + } else { + e.message ?: "접속 실패" + } + val encodedMsg = java.net.URLEncoder.encode(errorMsg, "UTF-8") + "redirect:/stock/config?error=$encodedMsg" + } + } + + // IP 추출 헬퍼 함수 + private fun getClientIp(request: HttpServletRequest): String { + var ip = request.getHeader("X-Forwarded-For") + if (ip.isNullOrEmpty() || "unknown".equals(ip, ignoreCase = true)) { + ip = request.remoteAddr + } + return ip ?: "" + } } @RestController @@ -53,9 +133,56 @@ class StockApiController( private val kisApiService: KisApiService, private val kisMarketService: KisMarketService, private val logService: LogService, - private val stockMonitorService: StockMonitorService // [추가] 注入 + private val stockMonitorService: StockMonitorService, + private val tradeHistoryRepository: TradeHistoryRepository, + private val telegramBotService: TelegramBotService, // [추가] + private val directLoginService: DirectLoginService, + private val globalEvv: GlobalEnvironment // [추가] ) { + @GetMapping("/status") + fun getMarketStatus(): ResponseEntity> { + val status = MarketTimeManager.getCurrentStatus() + + return ResponseEntity.ok(mapOf( + "resultCode" to 0, + "status_code" to status.code, + "status_name" to status.label, + "is_tradeable" to MarketTimeManager.isTradeable() // 정규장 거래 가능 여부 + )) + } + + @PostMapping("/auth") + suspend fun authenticate( + @RequestBody body: Map, + session: HttpSession + ): ResponseEntity> { + val appKey = body["appKey"] ?: "" + val appSecret = body["appSecret"] ?: "" + val accountNo = body["accountNo"] ?: "" + + if (appKey.isBlank() || appSecret.isBlank() || accountNo.isBlank()) { + return ResponseEntity.ok(mapOf("resultCode" to 400, "resultMsg" to "모든 정보를 입력해주세요.")) + } + + return try { + // 1. 토큰 발급 시도 (유효성 검증) + val config = KisConfigRequest(appKey, appSecret, accountNo) + val token = kisApiService.verifyAndGetToken(config).awaitSingle() + + // 2. 세션에 저장 (이게 있어야 대시보드 접근 가능) + val authInfo = KisAuthSession(appKey, appSecret, accountNo, token) + session.setAttribute("KIS_AUTH", authInfo) + + ResponseEntity.ok(mapOf("resultCode" to 0, "resultMsg" to "인증되었습니다.")) + } catch (e: Exception) { + e.printStackTrace() + ResponseEntity.ok(mapOf("resultCode" to 500, "resultMsg" to "인증 실패: ${e.message}")) + } + } + + + @PostMapping("/order") suspend fun placeOrder( @RequestBody req: StockOrderRequest, @@ -64,51 +191,115 @@ class StockApiController( 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 "수량을 확인해주세요.")) - } + // 종목명 조회 (기존 로직 유지) + var stockName = req.code + try { + val priceRes = kisMarketService.getCurrentPrice(req.code, auth).awaitSingle() + val output = priceRes["output"] as? Map + stockName = output?.get("rprs_mrkt_kor_name") ?: req.code + } catch (e: Exception) {} return try { - // 1. 주문 실행 - val response = kisApiService.orderStock( - auth, - req.type, - req.code, - req.qty.toString(), - req.price.toString() - ).awaitSingle() + // [핵심 변경] 전량 매도(isSellAll)일 경우 서버에서 잔고 조회 후 수량 결정 + var finalQty = req.qty + var finalPrice = req.price.toString() + if (req.isSellAll) { + println(">>> [SellAll Debug] 잔고 조회 시작 (Code: ${req.code})") + + // 1. 내 잔고 조회 + val balanceRes = kisApiService.getAccountBalance(auth).awaitSingle() + val stocks = balanceRes["output1"] as? List> ?: emptyList() + + // 2. 해당 종목 보유 수량 찾기 + // API마다 필드명이 다를 수 있으므로 확인 필요 (pdno: 상품번호) + val targetStock = stocks.find { it["pdno"] == req.code } + finalQty = targetStock?.get("hldg_qty")?.toString()?.toIntOrNull() ?: 0 + + println(">>> [SellAll Debug] 조회된 보유수량: $finalQty (Raw: $targetStock)") + + // 3. 가격은 시장가("0")로 강제 설정 + finalPrice = "0" + + if (finalQty <= 0) { + println(">>> [SellAll Debug] 보유 수량 0이라 중단") + return ResponseEntity.ok(mapOf("resultCode" to 400, "resultMsg" to "보유 수량이 없습니다.")) + } + delay(800) + } + + // 4. 결정된 수량과 가격으로 주문 실행 + val response = kisApiService.orderStock(auth, req.type, req.code, finalQty.toString(), finalPrice).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 저장됨)" - } + // 히스토리 저장 등 후처리 (기존 코드 유지) + val msgType = if(req.isSellAll) "🔥시장가 전량매도" else (if(req.type == "BUY") "매수" else "매도") + stockMonitorService.saveHistory(req.code, stockName, req.type, 0.0, finalQty, orderNo, false, "주문완료") - ResponseEntity.ok(mapOf("resultCode" to 0, "resultMsg" to msg)) + telegramBotService.sendTelegramMessage( + globalEvv.telegramMyId, + "[$msgType] 주문 성공\n종목: $stockName\n수량: ${finalQty}주" + ) + + ResponseEntity.ok(mapOf("resultCode" to 0, "resultMsg" to "주문 전송 완료 (주문번호: $orderNo)")) } 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}")) + ResponseEntity.ok(mapOf("resultCode" to 500, "resultMsg" to "오류: ${e.message}")) } } + @PostMapping("/auto-trade/update-stoploss") + suspend fun updateStopLoss(@RequestBody body: Map): ResponseEntity> { + val id = body["id"] as? String ?: return ResponseEntity.ok(mapOf("resultCode" to 400)) + val rate = body["stopLossRate"].toString().toDoubleOrNull() ?: return ResponseEntity.ok(mapOf("resultCode" to 400)) + + val success = stockMonitorService.updateStopLossRate(id, rate) + return if(success) ResponseEntity.ok(mapOf("resultCode" to 0, "resultMsg" to "수정되었습니다.")) + else ResponseEntity.ok(mapOf("resultCode" to 500, "resultMsg" to "실패")) + } + + // [신규] 자동매매 리스트 조회 API + @GetMapping("/auto-trade/list") + fun getAutoTradeList(): ResponseEntity> { + val list = stockMonitorService.getAllTasks() + return ResponseEntity.ok(mapOf("resultCode" to 0, "data" to list)) + } + + // [신규] 자동매매 취소 API + @PostMapping("/auto-trade/cancel") + suspend fun cancelAutoTrade(@RequestBody body: Map): ResponseEntity> { + val id = body["id"] ?: return ResponseEntity.ok(mapOf("resultCode" to 400, "resultMsg" to "ID 없음")) + val success = stockMonitorService.cancelMonitoring(id) + return if(success) ResponseEntity.ok(mapOf("resultCode" to 0, "resultMsg" to "취소되었습니다.")) + else ResponseEntity.ok(mapOf("resultCode" to 500, "resultMsg" to "찾을 수 없습니다.")) + } + + // [신규] 목표 수익률 수정 API + @PostMapping("/auto-trade/update") + suspend fun updateAutoTrade(@RequestBody body: Map): ResponseEntity> { + val id = body["id"] as? String ?: return ResponseEntity.ok(mapOf("resultCode" to 400)) + val rate = body["targetRate"].toString().toDoubleOrNull() ?: return ResponseEntity.ok(mapOf("resultCode" to 400)) + + val success = stockMonitorService.updateTargetRate(id, rate) + return if(success) ResponseEntity.ok(mapOf("resultCode" to 0, "resultMsg" to "수정되었습니다.")) + else ResponseEntity.ok(mapOf("resultCode" to 500, "resultMsg" to "실패")) + } + + // [신규] 거래 내역 조회 API + @GetMapping("/history/list") + suspend fun getHistoryList(): ResponseEntity> { + val list = tradeHistoryRepository.findAllByOrderByTimeDesc().collectList().awaitSingle() + return ResponseEntity.ok(mapOf("resultCode" to 0, "data" to list)) + } + @PostMapping("/config") suspend fun saveConfig( @RequestBody config: KisConfigRequest, @@ -206,7 +397,7 @@ class StockApiController( } } - + val delayTime = 600L @GetMapping("/details") suspend fun getStockDetails( @@ -217,12 +408,15 @@ class StockApiController( ?: 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 + if (codeList.isEmpty()) return ResponseEntity.ok(mapOf("resultCode" to 400)) + return try { - val results = Flux.fromIterable(codeList) + // 1. [병렬 호출] 내 잔고 조회 & 자동매매 목록 조회 + val balanceMono = kisApiService.getAccountBalance(auth) + val tasks = stockMonitorService.getAllTasks() // 현재 진행중인 자동매매 목록 + + // 2. 종목별 시세/차트 조회 (기존 로직) + val detailsFlux = Flux.fromIterable(codeList) .concatMap { code -> kisMarketService.getCurrentPrice(code, auth) .delayElement(Duration.ofMillis(delayTime)) @@ -233,15 +427,19 @@ class StockApiController( .delayElement(Duration.ofMillis(delayTime)) } .collectList() - .awaitSingle() - val dataList = results.map { item -> - val (code, priceRes, chartRes) = item as Triple, Map<*, *>> + // 3. 데이터 합치기 (잔고 + 상세정보) + val (balanceRes, details) = Mono.zip(balanceMono, detailsFlux).awaitSingle() + // 잔고 데이터 파싱 (보유 종목 찾기용) + val myStocks = (balanceRes["output1"] as? List>) ?: emptyList() + + val dataList = details.map { item -> + val (code, priceRes, chartRes) = item 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) ?: ""), @@ -250,45 +448,32 @@ class StockApiController( ) } - // [추가] 구간별 평균 거래량/거래대금 계산 함수 - // chartOutput은 최신순(내림차순)으로 들어옵니다. (0번 인덱스가 가장 최근 1분) - fun calcAvg(minutes: Int): Map { - val subset = chartOutput.take(minutes) - if (subset.isEmpty()) return mapOf("vol" to 0L, "amt" to 0L) + // [추가] 내 보유 정보 찾기 + // API마다 종목코드 필드명이 다를 수 있으므로 pdno(잔고)와 stck_shrn_iscd(현재가) 비교 + val myStock = myStocks.find { it["pdno"] == code } + val myQty = myStock?.get("hldg_qty")?.toString()?.toIntOrNull() ?: 0 + val myAvgPrice = myStock?.get("pchs_avg_pric")?.toString()?.toDoubleOrNull() ?: 0.0 + val myProfitRate = myStock?.get("evlu_pfls_rt")?.toString()?.toDoubleOrNull() ?: 0.0 - 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) - ) + // [추가] 자동매매 진행 여부 확인 + val autoTradeTask = tasks.find { it.stockCode == code } + val isAutoActive = autoTradeTask != null + val targetRate = autoTradeTask?.targetProfitRate ?: 0.0 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 // [추가] 평균 데이터 전달 + // 내 보유 정보 + "my_qty" to myQty, + "my_price" to myAvgPrice, + "my_profit_rate" to myProfitRate, + // 자동매매 정보 + "is_auto_active" to isAutoActive, + "auto_target_rate" to targetRate ) } @@ -296,7 +481,7 @@ class StockApiController( } catch (e: Exception) { e.printStackTrace() - ResponseEntity.ok(mapOf("resultCode" to 500, "resultMsg" to "상세 조회 실패: ${e.message}")) + ResponseEntity.ok(mapOf("resultCode" to 500, "resultMsg" to "조회 실패: ${e.message}")) } } @@ -327,6 +512,7 @@ class StockApiController( val evalAmount = stock["evlu_amt"]?.toString()?.toLongOrNull() ?: (currentPrice * qty).toLong() mapOf( + "code" to (stock["pdno"]?.toString() ?: ""), "name" to (stock["prdt_name"]?.toString() ?: ""), "qty" to qty, "buy_price" to buyPrice, diff --git a/src/main/kotlin/kr/lunaticbum/back/lun/controllers/StockLinkController.kt b/src/main/kotlin/kr/lunaticbum/back/lun/controllers/StockLinkController.kt new file mode 100644 index 0000000..75c8515 --- /dev/null +++ b/src/main/kotlin/kr/lunaticbum/back/lun/controllers/StockLinkController.kt @@ -0,0 +1,68 @@ +package kr.lunaticbum.back.lun.controllers + +import jakarta.servlet.http.Cookie +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse +import kr.lunaticbum.back.lun.service.DirectLoginService +import org.springframework.http.ResponseEntity +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 java.util.UUID + +@RestController +@RequestMapping("/api/stock") +class StockLinkController( + private val directLoginService: DirectLoginService +) { + @PostMapping("/generate-link") + suspend fun generateLink( + @RequestBody body: Map, + request: HttpServletRequest, + response: HttpServletResponse, // [추가] 쿠키 설정을 위해 필요 + principal: java.security.Principal? + ): ResponseEntity> { + return try { + if (principal == null) return ResponseEntity.ok(mapOf("resultCode" to "401", "resultMsg" to "로그인 필요")) + + val ip = request.getHeader("X-Forwarded-For") ?: request.remoteAddr + val ua = request.getHeader("User-Agent") ?: "" + + // 1. 기존 쿠키 확인 또는 새 Device ID 생성 + var deviceId = request.cookies?.find { it.name == "LUN_DEVICE_ID" }?.value + if (deviceId.isNullOrBlank()) { + deviceId = UUID.randomUUID().toString() + + // 2. 쿠키 설정 (30일 유지, HttpOnly 아님-JS접근가능해야 편함 or HttpOnly 권장) + val cookie = Cookie("LUN_DEVICE_ID", deviceId) + cookie.path = "/" + cookie.maxAge = 60 * 60 * 24 * 30 // 30일 + cookie.isHttpOnly = true // 보안 강화 (JS 탈취 방지) + response.addCookie(cookie) + } + + // 3. DB에 토큰 + DeviceID 저장 + val token = directLoginService.createToken( + key = body["key"] ?: "", + secret = body["secret"] ?: "", + acc = body["acc"] ?: "", + username = principal.name, + ip = ip, + ua = ua, + deviceId = deviceId + ) + + // ... URL 생성 로직 ... + val scheme = request.scheme + val serverName = request.serverName + val serverPort = request.serverPort + val portPart = if ((scheme == "http" && serverPort == 80) || (scheme == "https" && serverPort == 443)) "" else ":$serverPort" + val fullUrl = "$scheme://$serverName$portPart/stock/direct-login?token=$token" + + ResponseEntity.ok(mapOf("resultCode" to "0", "url" to fullUrl)) + } catch (e: Exception) { + ResponseEntity.ok(mapOf("resultCode" to "500", "resultMsg" to e.message.toString())) + } + } +} \ 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 index 3b7be9d..6c6fb54 100644 --- a/src/main/kotlin/kr/lunaticbum/back/lun/model/AutoTradeEntity.kt +++ b/src/main/kotlin/kr/lunaticbum/back/lun/model/AutoTradeEntity.kt @@ -1,26 +1,23 @@ -// 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 id: String? = null, val stockCode: String, + val stockName: String, val buyPrice: Double, val quantity: Int, - val targetProfitRate: Double, - // 인증 정보 + var targetProfitRate: Double, // 익절 수익률 (예: 5.0) + var stopLossRate: Double, // [추가] 손절 수익률 (예: -3.0) + 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/model/DirectLoginToken.kt b/src/main/kotlin/kr/lunaticbum/back/lun/model/DirectLoginToken.kt new file mode 100644 index 0000000..6bbf439 --- /dev/null +++ b/src/main/kotlin/kr/lunaticbum/back/lun/model/DirectLoginToken.kt @@ -0,0 +1,25 @@ +// src/main/kotlin/kr/lunaticbum/back/lun/model/DirectLoginToken.kt +package kr.lunaticbum.back.lun.model + +import org.springframework.data.annotation.Id +import org.springframework.data.mongodb.core.mapping.Document +import java.time.LocalDateTime + +@Document(collection = "direct_login_tokens") +data class DirectLoginToken( + @Id + val token: String, + + val appKey: String, + val appSecret: String, + val accountNo: String, + val username: String, + + // [보안 강화] + val deviceId: String, // 브라우저에 심어둔 쿠키 값 + val clientIp: String, // 생성 당시 IP + val userAgent: String, // 생성 당시 기기 정보 + + val createdAt: LocalDateTime = LocalDateTime.now(), + val expiresAt: LocalDateTime = LocalDateTime.now().plusDays(30) // 30일 유효 +) \ No newline at end of file diff --git a/src/main/kotlin/kr/lunaticbum/back/lun/model/TradeHistoryEntity.kt b/src/main/kotlin/kr/lunaticbum/back/lun/model/TradeHistoryEntity.kt new file mode 100644 index 0000000..7bc06eb --- /dev/null +++ b/src/main/kotlin/kr/lunaticbum/back/lun/model/TradeHistoryEntity.kt @@ -0,0 +1,21 @@ +// src/main/kotlin/kr/lunaticbum/back/lun/model/TradeHistoryEntity.kt +package kr.lunaticbum.back.lun.model + +import org.springframework.data.annotation.Id +import org.springframework.data.mongodb.core.mapping.Document +import java.time.LocalDateTime + +@Document(collection = "trade_history") +data class TradeHistoryEntity( + @Id + val id: String? = null, + val time: LocalDateTime = LocalDateTime.now(), // 거래 시간 + val stockCode: String, + val stockName: String, + val orderType: String, // "BUY" or "SELL" + val price: Double, // 주문 가격 (또는 체결가) + val quantity: Int, + val orderNo: String, // 주문 번호 + val isAutoTrade: Boolean, // 자동매매 여부 + val resultMsg: String // 결과 메시지 +) \ No newline at end of file diff --git a/src/main/kotlin/kr/lunaticbum/back/lun/model/User.kt b/src/main/kotlin/kr/lunaticbum/back/lun/model/User.kt index 0760f05..2c1b1da 100644 --- a/src/main/kotlin/kr/lunaticbum/back/lun/model/User.kt +++ b/src/main/kotlin/kr/lunaticbum/back/lun/model/User.kt @@ -187,7 +187,7 @@ class UserManager( } // 사용자를 찾지 못하면 예외를 던지도록 수정 val user = findById(username) - .blockOptional(Duration.ofMillis(5000L)) + .blockOptional(Duration.ofMillis(15000L)) .orElseThrow { UsernameNotFoundException("User not found: $username") } val userRole = user.getRole().name // "READ", "WRITE", 또는 "ADMIN" diff --git a/src/main/kotlin/kr/lunaticbum/back/lun/repository/DirectLoginRepository.kt b/src/main/kotlin/kr/lunaticbum/back/lun/repository/DirectLoginRepository.kt new file mode 100644 index 0000000..321c59c --- /dev/null +++ b/src/main/kotlin/kr/lunaticbum/back/lun/repository/DirectLoginRepository.kt @@ -0,0 +1,9 @@ +// src/main/kotlin/kr/lunaticbum/back/lun/repository/DirectLoginRepository.kt +package kr.lunaticbum.back.lun.repository + +import kr.lunaticbum.back.lun.model.DirectLoginToken +import org.springframework.data.mongodb.repository.ReactiveMongoRepository +import org.springframework.stereotype.Repository + +@Repository +interface DirectLoginRepository : ReactiveMongoRepository \ No newline at end of file diff --git a/src/main/kotlin/kr/lunaticbum/back/lun/repository/TradeHistoryRepository.kt b/src/main/kotlin/kr/lunaticbum/back/lun/repository/TradeHistoryRepository.kt new file mode 100644 index 0000000..8201f69 --- /dev/null +++ b/src/main/kotlin/kr/lunaticbum/back/lun/repository/TradeHistoryRepository.kt @@ -0,0 +1,13 @@ +// src/main/kotlin/kr/lunaticbum/back/lun/repository/TradeHistoryRepository.kt +package kr.lunaticbum.back.lun.repository + +import kr.lunaticbum.back.lun.model.TradeHistoryEntity +import org.springframework.data.mongodb.repository.ReactiveMongoRepository +import org.springframework.stereotype.Repository +import reactor.core.publisher.Flux + +@Repository +interface TradeHistoryRepository : ReactiveMongoRepository { + // 최신순 조회 + fun findAllByOrderByTimeDesc(): Flux +} \ No newline at end of file diff --git a/src/main/kotlin/kr/lunaticbum/back/lun/service/DirectLoginService.kt b/src/main/kotlin/kr/lunaticbum/back/lun/service/DirectLoginService.kt new file mode 100644 index 0000000..d8f5db0 --- /dev/null +++ b/src/main/kotlin/kr/lunaticbum/back/lun/service/DirectLoginService.kt @@ -0,0 +1,58 @@ +package kr.lunaticbum.back.lun.service + +import kotlinx.coroutines.reactor.awaitSingle +import kotlinx.coroutines.reactor.awaitSingleOrNull +import kr.lunaticbum.back.lun.model.DirectLoginToken +import kr.lunaticbum.back.lun.repository.DirectLoginRepository +import org.springframework.stereotype.Service +import java.time.LocalDateTime +import java.util.UUID + +@Service +class DirectLoginService( + private val directLoginRepository: DirectLoginRepository +) { + // 1. 토큰 생성 (deviceId 추가) + suspend fun createToken( + key: String, secret: String, acc: String, username: String, + ip: String, ua: String, deviceId: String // [추가] + ): String { + val token = UUID.randomUUID().toString().replace("-", "") + + val entity = DirectLoginToken( + token = token, + appKey = key, + appSecret = secret, + accountNo = acc, + username = username, + clientIp = ip, + userAgent = ua, + deviceId = deviceId // 저장 + ) + + directLoginRepository.save(entity).awaitSingle() + return token + } + + // 2. 토큰 검증 (쿠키 값 비교 추가) + suspend fun validateAndGet(token: String, currentIp: String, currentUa: String, cookieDeviceId: String?): DirectLoginToken { + val info = directLoginRepository.findById(token).awaitSingleOrNull() + ?: throw Exception("존재하지 않는 링크입니다.") + + // 1. 만료일 체크 + if (info.expiresAt.isBefore(LocalDateTime.now())) { + throw Exception("만료된 링크입니다. 다시 생성해주세요.") + } + + // 2. 쿠키(Device ID) 검증 [핵심 보안] + // 링크가 유출되어도 해커 PC에는 이 쿠키가 없으므로 절대 접속 불가 + if (info.deviceId != cookieDeviceId) { + throw Exception("등록된 기기가 아닙니다. (링크를 생성한 브라우저에서만 접속 가능합니다)") + } + + // 3. IP 검증 (선택: 모바일이라 IP가 자주 바뀌면 이 부분은 주석 처리하세요) + // if (info.clientIp != currentIp) throw Exception("IP가 변경되었습니다.") + + return info + } +} \ 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 3c6dc03..840e8e2 100644 --- a/src/main/kotlin/kr/lunaticbum/back/lun/service/KisApiService.kt +++ b/src/main/kotlin/kr/lunaticbum/back/lun/service/KisApiService.kt @@ -6,6 +6,8 @@ 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 +import java.time.LocalTime +import java.time.format.DateTimeFormatter private val REAL_URL = "https://openapi.koreainvestment.com:9443" private val MOCK_URL = "https://openapivts.koreainvestment.com:29443" @@ -78,33 +80,40 @@ class KisApiService() { fun orderStock( auth: KisAuthSession, - orderType: String, // "BUY" or "SELL" + orderType: String, 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" + // 1. 계좌번호 포맷팅 (하이픈 제거) + val cleanAccount = auth.accountNo.replace("-", "").trim() + val cano = if (cleanAccount.length >= 8) cleanAccount.substring(0, 8) else cleanAccount + val prdt = if (cleanAccount.length >= 10) cleanAccount.substring(8, 10) else "01" - // TR_ID 결정 (중요!) - // 실전: 매수(TTTC0802U), 매도(TTTC0801U) - // 모의: 매수(VTTC0802U), 매도(VTTC0801U) + // 2. TR_ID 결정 val trId = if (isRealTrading) { if (orderType == "BUY") "TTTC0802U" else "TTTC0801U" } else { if (orderType == "BUY") "VTTC0802U" else "VTTC0801U" } + // 3. 주문 구분 (시장가: 01, 지정가: 00) + val ordDvsn = if (price == "0") "01" else "00" + + // 4. 요청 바디 구성 val requestBody = mapOf( "CANO" to cano, "ACNT_PRDT_CD" to prdt, "PDNO" to stockCode, - "ORD_DVSN" to "00", // 00: 지정가 (가격을 직접 입력) + "ORD_DVSN" to ordDvsn, "ORD_QTY" to qty, - "ORD_UNPR" to price // 0원이면 시장가로 하려면 ORD_DVSN을 01로 바꿔야 함 + "ORD_UNPR" to price ) + // [디버깅 로그 1] 내가 보내는 데이터 확인 + println(">>> [KIS Order Debug] TR_ID: $trId") + println(">>> [KIS Order Debug] Request Body: $requestBody") + return webClientBuilder.baseUrl(getBaseUrl()).build() .post() .uri("/uapi/domestic-stock/v1/trading/order-cash") @@ -115,6 +124,15 @@ class KisApiService() { .bodyValue(requestBody) .retrieve() .bodyToMono(Map::class.java) + // [디버깅 로그 2] 에러 발생 시 상세 응답 확인 + .onErrorResume(WebClientResponseException::class.java) { ex -> + val errorBody = ex.responseBodyAsString + println(">>> [KIS API Error] Status: ${ex.statusCode}") + println(">>> [KIS API Error] Body: $errorBody") + + // 에러 내용을 포함해서 상위로 던짐 + Mono.error(Exception("KIS Error: $errorBody")) + } } // [추가] 웹소켓 접속키 발급 (1회 발급 후 계속 사용 가능하지만, 여기선 호출 시마다 받도록 구현) @@ -140,22 +158,48 @@ class KisApiService() { @Service class KisMarketService() { + fun checkHoliday(auth: KisAuthSession, date: String): Mono> { + return webClient.get() + .uri { it.path("/uapi/domestic-stock/v1/quotations/chk-holiday") + .queryParam("BASS_DT", date) // 기준일자 (YYYYMMDD) + .queryParam("CTX_AREA_NK", "") + .queryParam("CTX_AREA_FK", "") + .build() + } + .header("authorization", "Bearer ${auth.accessToken}") + .header("appkey", auth.appKey) + .header("appsecret", auth.appSecret) + .header("tr_id", "CTCA0903R") // 휴장일 조회 TR ID + .header("custtype", "P") + .retrieve() + .bodyToMono(Map::class.java) + } + + fun getMinuteChart(symbol: String, auth: KisAuthSession): Mono> { + // [핵심] 현재 시간을 HHmmss 포맷으로 구해서 파라미터로 넘겨야 합니다. + val now = LocalTime.now().format(DateTimeFormatter.ofPattern("HHmmss")) + 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_INPUT_HOUR_1", now) // [수정] 빈 값("") -> 현재시간(now) .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 (실전/모의 동일) + .header("tr_id", "FHKST03010200") // 주식 분봉 조회 TR .retrieve() .bodyToMono(Map::class.java) + .onErrorResume(WebClientResponseException::class.java) { ex -> + // 에러 디버깅을 위해 로그 출력 추가 + println(">>> 차트 조회 실패: ${ex.responseBodyAsString}") + Mono.error(ex) + } } // 3. 주식 현재가 시세 조회 (에러 디버깅 추가) @@ -235,11 +279,17 @@ class KisMarketService() { .queryParam("FID_COND_SCR_DIV_CODE", "20170") .queryParam("FID_INPUT_ISCD", "0000") // 0000: 전체 .queryParam("FID_RANK_SORT_CLS_CODE", type) // 0: 상승, 1: 하락 + + // [▼▼▼ 필수 파라미터 추가 ▼▼▼] + .queryParam("FID_ORG_ADJ_PRC", "0") // 수정주가 반영 여부 (0:반영안함, 1:반영) + .queryParam("FID_LS_DIV_CLS_CODE", "00") // 순위 관리 구분 코드 (00: 기본) + // [▲▲▲ 추가 완료 ▲▲▲] + .queryParam("FID_INPUT_CNT_1", "0") // 입력 수 - .queryParam("FID_PRC_CLS_CODE", "1") // [수정] 0:관련없음 -> 1:보통 (장 종료후에는 1이 더 안정적일 수 있음) + .queryParam("FID_PRC_CLS_CODE", "1") // 1: 보통 .queryParam("FID_INPUT_PRICE_1", "") .queryParam("FID_INPUT_PRICE_2", "") - .queryParam("FID_VOL_CNT", "") // 거래량 조건 없애기 (비워두면 전체) + .queryParam("FID_VOL_CNT", "") // 거래량 조건 .queryParam("FID_TRGT_CLS_CODE", "11111111") .queryParam("FID_TRGT_EXLS_CLS_CODE", "000000") .build() diff --git a/src/main/kotlin/kr/lunaticbum/back/lun/service/StockMonitorService.kt b/src/main/kotlin/kr/lunaticbum/back/lun/service/StockMonitorService.kt index dd5b4cf..78eb2c0 100644 --- a/src/main/kotlin/kr/lunaticbum/back/lun/service/StockMonitorService.kt +++ b/src/main/kotlin/kr/lunaticbum/back/lun/service/StockMonitorService.kt @@ -5,10 +5,16 @@ package kr.lunaticbum.back.lun.service import jakarta.annotation.PostConstruct import kotlinx.coroutines.reactor.awaitSingle import kotlinx.coroutines.runBlocking +import kr.lunaticbum.back.lun.configs.core.GlobalEnvironment 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.model.TradeHistoryEntity import kr.lunaticbum.back.lun.repository.AutoTradeRepository +import kr.lunaticbum.back.lun.repository.TradeHistoryRepository +import kr.lunaticbum.back.lun.services.TelegramBotService +import kr.lunaticbum.back.lun.utils.MarketTimeManager +import org.springframework.scheduling.annotation.Scheduled import org.springframework.stereotype.Service import java.util.concurrent.CopyOnWriteArrayList @@ -16,11 +22,45 @@ import java.util.concurrent.CopyOnWriteArrayList class StockMonitorService( private val kisMarketService: KisMarketService, private val kisApiService: KisApiService, - private val autoTradeRepository: AutoTradeRepository + private val autoTradeRepository: AutoTradeRepository, + private val tradeHistoryRepository: TradeHistoryRepository, + private val telegramBotService: TelegramBotService, + private val globalEvv: GlobalEnvironment ) { // 메모리 캐시 (실시간 조회를 위해 사용) private val monitoringList = CopyOnWriteArrayList() + private var lastStatus: MarketTimeManager.MarketStatus? = null + + // [신규] 1분마다 시장 상태 변경 체크 및 알림 발송 + // (매분 0초에 실행) + @Scheduled(cron = "0 * * * * *") + fun checkMarketStatusAndNotify() { + // 1. 현재 상태 조회 + val currentStatus = MarketTimeManager.getCurrentStatus() + + // 2. 상태가 변했는지 확인 (초기 실행이 아니고, 상태가 달라졌을 때만) + if (lastStatus != null && lastStatus != currentStatus) { + val msg = when (currentStatus) { + MarketTimeManager.MarketStatus.PRE_OPEN -> "🌅 [장전] 동시호가가 시작되었습니다. (08:30)" + MarketTimeManager.MarketStatus.OPEN -> "🔔 [개장] 정규장이 시작되었습니다! 성투하세요. (09:00)" + MarketTimeManager.MarketStatus.CLOSE_5M -> "📢 [마감임박] 장마감 동시호가 시간입니다. (15:20)" + MarketTimeManager.MarketStatus.AFTER_HOURS -> "🌙 [마감] 정규장이 종료되었습니다. (15:30)" + MarketTimeManager.MarketStatus.SINGLE_PRICE -> "🕓 [시간외] 시간외 단일가 매매가 시작되었습니다. (16:00)" + MarketTimeManager.MarketStatus.CLOSED -> "💤 [종료] 금일 장이 완전히 마감되었습니다. (18:00)" + else -> null + } + + // 메시지가 정의된 상태라면 텔레그램 발송 + if (msg != null) { + sendAlert(msg) + } + } + + // 3. 현재 상태를 '이전 상태'로 저장 + lastStatus = currentStatus + } + // 1. 서버 시작 시 MongoDB에서 데이터 복구 @PostConstruct fun init() { @@ -34,30 +74,93 @@ class StockMonitorService( // 2. 감시 대상 등록 (MongoDB 저장 + 메모리 추가) // Controller에서 호출하므로 suspend 함수로 변경 - suspend fun addMonitoring(auth: KisAuthSession, code: String, buyPrice: Double, qty: Int, targetRate: Double) { - - auth.accessToken?.let{accessToken -> + suspend fun addMonitoring( + auth: KisAuthSession, code: String, name: String, + buyPrice: Double, qty: Int, targetRate: Double, stopLossRate: Double + ) { + auth.accessToken?.let { accessToken -> val entity = AutoTradeEntity( stockCode = code, + stockName = name, buyPrice = buyPrice, quantity = qty, targetProfitRate = targetRate, + stopLossRate = stopLossRate, // [저장] 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 저장 완료") + // [알림] 기존 봇 서비스를 사용하여 내 ID로 메시지 전송 + sendAlert("📡 [자동매매 등록]\n$name ($code)\n수량: ${qty}주\n목표: +$targetRate% / 손절: $stopLossRate%") } + } + // [신규] 손절 수익률 수정 기능 + suspend fun updateStopLossRate(id: String, newRate: Double): Boolean { + val task = monitoringList.find { it.id == id } ?: return false + task.stopLossRate = newRate + autoTradeRepository.save(task).awaitSingle() + return true + } + + // [신규] 거래 히스토리 저장 + suspend fun saveHistory(code: String, name: String, type: String, price: Double, qty: Int, orderNo: String, isAuto: Boolean, msg: String) { + val history = TradeHistoryEntity( + stockCode = code, + stockName = name, + orderType = type, + price = price, + quantity = qty, + orderNo = orderNo, + isAutoTrade = isAuto, + resultMsg = msg + ) + tradeHistoryRepository.save(history).awaitSingle() + } + + // [신규] 감시 작업 취소 (삭제) + suspend fun cancelMonitoring(id: String): Boolean { + val task = monitoringList.find { it.id == id } + if (task != null) { + monitoringList.remove(task) + autoTradeRepository.deleteById(id).awaitSingle() + return true + } + return false + } + + // [신규] 목표 수익률 수정 + suspend fun updateTargetRate(id: String, newRate: Double): Boolean { + val task = monitoringList.find { it.id == id } + if (task != null) { + // 메모리 업데이트 + task.targetProfitRate = newRate + // DB 업데이트 + autoTradeRepository.save(task).awaitSingle() + return true + } + return false + } + + // [신규] 전체 감시 목록 조회 (화면 표시용) + fun getAllTasks(): List { + return monitoringList.toList() + } + + // 3. 주기적 실행 (Scheduler에서 호출) fun checkAndExecuteAutoSell() { + if (!MarketTimeManager.isTradeable()) { + // (선택사항) 장 마감 중에는 로그를 남기지 않거나, 디버깅용으로만 남김 + // println(">>> 장 마감 시간입니다. 자동매매 스킵") + return + } + if (monitoringList.isEmpty()) return // 스케줄러는 동기식이므로 runBlocking 블록 내에서 비동기 작업을 수행합니다. @@ -84,7 +187,6 @@ class StockMonitorService( // 시세 확인 및 매도 로직 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 @@ -92,18 +194,48 @@ class StockMonitorService( if (currentPrice > 0) { val currentRate = ((currentPrice - task.buyPrice) / task.buyPrice) * 100 + // 1. 익절 조건 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} 작업 삭제됨") + executeSell(task, tempAuth, currentPrice, currentRate, "💰 자동익절") } + // 2. [추가] 손절 조건 (예: -5.0 <= -3.0 이면 매도) + else if (currentRate <= task.stopLossRate) { + executeSell(task, tempAuth, currentPrice, currentRate, "💧 자동손절") + } + } + } + + private suspend fun executeSell(task: AutoTradeEntity, auth: KisAuthSession, price: Double, rate: Double, typeMsg: String) { + // 매도 주문 + val res = kisApiService.orderStock(auth, "SELL", task.stockCode, task.quantity.toString(), "0").awaitSingle() + val output = res["output"] as? Map ?: emptyMap() + val orderNo = output["ODNO"] as? String ?: "Unknown" + + val msg = "$typeMsg (수익률 ${String.format("%.2f", rate)}%)" + + // 히스토리 저장 + saveHistory(task.stockCode, task.stockName, "SELL", price, task.quantity, orderNo, true, msg) + + // [알림] 텔레그램 전송 + sendAlert( + "$typeMsg 실행 완료!\n" + + "종목: ${task.stockName}\n" + + "수익률: ${String.format("%.2f", rate)}%\n" + + "체결가: ${price.toInt()}원" + ) + + // 목록 제거 + monitoringList.remove(task) + autoTradeRepository.delete(task).awaitSingle() + } + + // [도구] 알림 전송 헬퍼 + private fun sendAlert(msg: String) { + try { + // globalEvv.telegramMyId를 사용하여 나에게 메시지 전송 + telegramBotService.sendTelegramMessage(globalEvv.telegramMyId, msg) + } catch (e: Exception) { + println("텔레그램 전송 실패: ${e.message}") } } diff --git a/src/main/kotlin/kr/lunaticbum/back/lun/utils/MarketTimeManager.kt b/src/main/kotlin/kr/lunaticbum/back/lun/utils/MarketTimeManager.kt new file mode 100644 index 0000000..51b036a --- /dev/null +++ b/src/main/kotlin/kr/lunaticbum/back/lun/utils/MarketTimeManager.kt @@ -0,0 +1,48 @@ +package kr.lunaticbum.back.lun.utils + +import java.time.DayOfWeek +import java.time.LocalDateTime +import java.time.LocalTime +import java.time.ZoneId + +object MarketTimeManager { + // 한국 시간대 + private val KST = ZoneId.of("Asia/Seoul") + + enum class MarketStatus(val label: String, val code: String) { + PRE_OPEN("장전 동시호가", "PRE"), // 08:30 ~ 09:00 + OPEN("정규장", "OPEN"), // 09:00 ~ 15:20 + CLOSE_5M("장마감 동시호가", "CLOSE_READY"), // 15:20 ~ 15:30 + AFTER_HOURS("시간외 종가", "AFTER"), // 15:30 ~ 16:00 + SINGLE_PRICE("시간외 단일가", "SINGLE"), // 16:00 ~ 18:00 + CLOSED("장 마감", "CLOSED"), // 그 외 시간 + HOLIDAY("휴장일", "HOLIDAY") // 주말/공휴일 + } + + fun getCurrentStatus(): MarketStatus { + val now = LocalDateTime.now(KST) + val time = now.toLocalTime() + val day = now.dayOfWeek + + // 1. 주말 체크 (토, 일) + if (day == DayOfWeek.SATURDAY || day == DayOfWeek.SUNDAY) { + return MarketStatus.HOLIDAY + } + + // 2. 시간대별 상태 체크 + return when { + time.isBefore(LocalTime.of(8, 30)) -> MarketStatus.CLOSED + time.isBefore(LocalTime.of(9, 0)) -> MarketStatus.PRE_OPEN + time.isBefore(LocalTime.of(15, 20)) -> MarketStatus.OPEN + time.isBefore(LocalTime.of(15, 30)) -> MarketStatus.CLOSE_5M + time.isBefore(LocalTime.of(16, 0)) -> MarketStatus.AFTER_HOURS + time.isBefore(LocalTime.of(18, 0)) -> MarketStatus.SINGLE_PRICE + else -> MarketStatus.CLOSED + } + } + + // 현재 정규장 거래가 가능한지 여부 + fun isTradeable(): Boolean { + return getCurrentStatus() == MarketStatus.OPEN + } +} \ No newline at end of file diff --git a/src/main/resources/static/js/modules/api.js b/src/main/resources/static/js/modules/api.js index 3b167ef..397d7b1 100644 --- a/src/main/resources/static/js/modules/api.js +++ b/src/main/resources/static/js/modules/api.js @@ -19,7 +19,7 @@ export let Api = { const targetUrl = url.startsWith('/') ? url : '/' + url; const defaultHeaders = { 'X-CSRF-TOKEN': this.getCsrfToken() }; - const config = { method, headers: { ...defaultHeaders, ...headers } }; + const config = { method, headers: { ...defaultHeaders, ...headers },credentials: 'include' }; if (body) { if (body instanceof FormData) config.body = body; diff --git a/src/main/resources/static/js/modules/stock.js b/src/main/resources/static/js/modules/stock.js new file mode 100644 index 0000000..b536b18 --- /dev/null +++ b/src/main/resources/static/js/modules/stock.js @@ -0,0 +1,284 @@ +import { Api } from '/js/modules/api.js'; + +export const StockUtils = { + // [설정] 웹소켓 URL (모의투자용) + WS_URL: "ws://ops.koreainvestment.com:31000", + ws: null, + + // 1. 숫자/금액 포맷팅 + format: { + number: (num) => parseInt(num || 0).toLocaleString(), + money: (num) => { + if (num >= 100000000) return (num / 100000000).toFixed(1) + '억'; + if (num >= 10000) return (num / 10000).toFixed(0) + '만'; + return parseInt(num).toLocaleString(); + }, + color: (val) => val > 0 ? '#e03e2d' : (val < 0 ? '#0e62cf' : '#333'), + icon: (val) => val > 0 ? '▲' : (val < 0 ? '▼' : '-') + }, + + // 2. 차트 그리기 (Chart.js 래퍼) + drawChart: (canvasId, chartData, isRising) => { + const canvas = document.getElementById(canvasId); + if (!canvas || !chartData || chartData.length === 0) return; + + // 기존 차트 파괴 (메모리 누수 방지) + const existingChart = Chart.getChart(canvasId); + if (existingChart) existingChart.destroy(); + + const ctx = canvas.getContext('2d'); + const labels = chartData.map(d => d.time.substring(0, 2) + ":" + d.time.substring(2, 4)); + const prices = chartData.map(d => parseInt(d.price)); + const volumes = chartData.map(d => parseInt(d.volume)); + + const mainColor = isRising ? '#e03e2d' : '#0e62cf'; + const gradient = ctx.createLinearGradient(0, 0, 0, 200); + gradient.addColorStop(0, isRising ? 'rgba(224, 62, 45, 0.2)' : 'rgba(14, 98, 207, 0.2)'); + gradient.addColorStop(1, 'rgba(255, 255, 255, 0)'); + + new Chart(ctx, { + type: 'line', + data: { + labels: labels, + datasets: [ + { + label: '주가', data: prices, borderColor: mainColor, backgroundColor: gradient, + borderWidth: 2, pointRadius: 0, pointHoverRadius: 4, fill: true, yAxisID: 'y', tension: 0.1 + }, + { + label: '거래량', data: volumes, type: 'bar', backgroundColor: 'rgba(200, 200, 200, 0.3)', + yAxisID: 'y1', barPercentage: 0.6 + } + ] + }, + options: { + responsive: true, maintainAspectRatio: false, + interaction: { mode: 'index', intersect: false }, + plugins: { legend: { display: false } }, + scales: { + x: { display: false }, + y: { type: 'linear', display: true, position: 'right', grid: { color: '#f0f0f0' } }, + y1: { type: 'linear', display: false, position: 'left', min: 0, max: Math.max(...volumes) * 5 } + } + } + }); + }, + + // 3. 웹소켓 관리 + wsManager: { + socket: null, + async connect(code, onMessage) { + this.disconnect(); + try { + // 접속키 발급 + const res = await Api.request('/api/stock/ws-key'); + if (res.resultCode !== 0) throw new Error(res.resultMsg); + + this.socket = new WebSocket(StockUtils.WS_URL); + this.socket.onopen = () => { + console.log("[WS] Connected"); + this.socket.send(JSON.stringify({ + header: { approval_key: res.approval_key, custtype: "P", tr_type: "1", "content-type": "utf-8" }, + body: { input: { tr_id: "H0STCNT0", tr_key: code } } + })); + }; + this.socket.onmessage = (evt) => { + if (evt.data[0] !== '{') { // 시스템 메시지 제외 + const parts = evt.data.split('|'); + if (parts.length >= 4) { + const fields = parts[3].split('^'); + onMessage({ price: parseInt(fields[2]), rate: parseFloat(fields[4]) }); + } + } + }; + } catch (e) { + console.error(e); + alert("실시간 연결 실패"); + } + }, + disconnect() { + if (this.socket) { + this.socket.close(); + this.socket = null; + } + } + }, + + // 4. 주문 API 호출 + async placeOrder(params) { + // params: { code, type, qty, price, isAuto, targetRate, stopLoss } + if (!params.price || !params.qty || parseInt(params.qty) <= 0) { + alert("가격과 수량을 확인해주세요."); + return; + } + + const typeName = params.type === 'BUY' ? "매수" : "매도"; + let msg = `${typeName} 주문을 전송하시겠습니까?\n\n종목: ${params.code}\n수량: ${params.qty}주\n가격: ${params.price == 0 ? '시장가' : StockUtils.format.number(params.price) + '원'}`; + + if (params.type === 'BUY' && params.isAuto) { + msg += `\n\n[⚡자동매매]\n익절: +${params.targetRate}%\n손절: ${params.stopLoss}%`; + } + + if (!confirm(msg)) return; + + try { + const res = await Api.request('/api/stock/order', 'POST', { + code: params.code, + type: params.type, + qty: parseInt(params.qty), + price: parseInt(params.price), + isAutoTrade: params.isAuto || false, + targetProfitRate: parseFloat(params.targetRate || 0), + stopLossRate: parseFloat(params.stopLoss || 0) + }); + alert(res.resultCode === 0 ? res.resultMsg : "주문 실패: " + res.resultMsg); + if(res.resultCode === 0 && params.price == 0) location.reload(); // 시장가 주문 시 새로고침 + } catch (e) { + console.error(e); + alert("통신 오류"); + } + }, + + // 5. 수량 자동 계산기 + calcQty(amountInputId, priceInputId, targetQtyId) { + const amount = parseInt(document.getElementById(amountInputId).value.replace(/,/g, '') || 0); + const price = parseInt(document.getElementById(priceInputId).value.replace(/,/g, '') || 0); + if (price > 0 && amount > 0) { + document.getElementById(targetQtyId).value = Math.floor(amount / price); + } + }, + + security: { + // 보안 바로가기 링크 생성 + async generateLink(inputs) { + // inputs: { key, secret, acc } -> id, pw 불필요 + const { key, secret, acc } = inputs; + + if (!key || !secret || !acc) { + alert("API 키와 계좌번호는 필수입니다."); + return null; + } + + try { + // 서버에 요청하면 서버가 알아서 현재 로그인된 ID를 사용함 + const res = await Api.request('/api/stock/generate-link', 'POST', { + key, secret, acc + }); + + if (res.resultCode === "0") { + return res.url; + } else if (res.resultCode === "401") { + alert("로그인이 필요합니다."); + return null; + } else { + alert("생성 실패: " + res.resultMsg); + return null; + } + } catch (e) { + console.error(e); + alert("통신 오류가 발생했습니다."); + return null; + } + }, + + // 클립보드 복사 + copyToClipboard(elementId) { + const el = document.getElementById(elementId); + if (!el || !el.value) { + alert("복사할 내용이 없습니다."); + return; + } + el.select(); + // 최신 브라우저 지원 시 clipboard API 사용, 아니면 execCommand + if (navigator.clipboard) { + navigator.clipboard.writeText(el.value) + .then(() => alert("복사되었습니다!")) + .catch(() => document.execCommand("copy")); + } else { + document.execCommand("copy"); + alert("복사되었습니다!"); + } + } + }, + async connect(inputs) { + const { key, secret, acc } = inputs; + if (!key || !secret || !acc) { + alert("App Key, Secret Key, 계좌번호를 모두 입력해주세요."); + return; + } + + try { + // 1. 인증 요청 + const res = await Api.request('/api/stock/auth', 'POST', { + appKey: key, + appSecret: secret, + accountNo: acc + }); + + if (res.resultCode === 0) { + // 2. 인증 성공 시 사용자에게 선택권 부여 + const wantLink = confirm( + "✅ 인증에 성공했습니다!\n\n" + + "매번 로그인할 필요 없는 '원터치 접속 링크'를 생성하고 복사하시겠습니까?\n\n" + + "[확인] 링크 생성 및 복사 후 대시보드로 이동\n" + + "[취소] 바로 대시보드로 이동" + ); + + if (wantLink) { + // 링크 생성 시도 + const url = await StockUtils.security.generateLink(inputs); + + if (url) { + try { + // 클립보드에 자동 복사 + await navigator.clipboard.writeText(url); + alert( + "📋 링크가 클립보드에 복사되었습니다!\n\n" + + "브라우저 즐겨찾기나 메모장에 붙여넣기(Ctrl+V) 하세요.\n\n" + + "이제 대시보드로 이동합니다." + ); + } catch (err) { + // 자동 복사 실패 시 (보안상 막힌 경우) 직접 복사 유도 + prompt("자동 복사에 실패했습니다. 아래 링크를 직접 복사하세요.", url); + } + } + } + + // 3. 대시보드로 이동 + location.href = "/stock/dashboard"; + + } else { + alert("인증 실패: " + res.resultMsg); + } + } catch (e) { + console.error(e); + alert("서버 통신 오류가 발생했습니다."); + } + }, + async sellAll(code) { + if (!confirm(`🚨 해당 종목(${code})을 현재 시장가로 전량 매도하시겠습니까?\n\n서버 잔고 기준으로 모든 수량을 즉시 처분합니다.`)) { + return; + } + + try { + // 가격/수량은 0으로 보내고 isSellAll 플래그만 true로 설정 + const res = await Api.request('/api/stock/order', 'POST', { + code: code, + type: 'SELL', + isSellAll: true, // [핵심] + qty: 0, + price: 0 + }); + + if (res.resultCode === 0) { + alert("✅ 전량 매도 주문이 접수되었습니다.\n" + res.resultMsg); + location.reload(); // 잔고 갱신을 위해 새로고침 + } else { + alert("주문 실패: " + res.resultMsg); + } + } catch (e) { + console.error(e); + alert("통신 오류가 발생했습니다."); + } + }, +}; \ No newline at end of file diff --git a/src/main/resources/templates/content/stock/auto_trade.html b/src/main/resources/templates/content/stock/auto_trade.html new file mode 100644 index 0000000..9eae435 --- /dev/null +++ b/src/main/resources/templates/content/stock/auto_trade.html @@ -0,0 +1,108 @@ + + + +
+
+
+

자동매매 관리

+

익절/손절 감시 목록 수정 및 취소

+
+ +
+ + + + + + + + + + + + + +
종목매수가익절 %손절 %관리
로딩 중...
+
+ +
+ + +
+ \ 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 42ca16b..10d10be 100644 --- a/src/main/resources/templates/content/stock/config.html +++ b/src/main/resources/templates/content/stock/config.html @@ -2,53 +2,106 @@ +
-
+
-

한국투자증권 API 설정

-

보안을 위해 입력하신 키는 DB에 저장되지 않으며, 세션 종료 시 즉시 파기됩니다.

+

API 설정

+

한국투자증권 API 접속 정보 및 바로가기 설정

-
+
+

🔑 API 인증 정보 입력

+

발급받은 App Key와 Secret Key를 입력하세요.

+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ +
+ +
+

🔗 원터치 보안 링크 생성

+

+ 현재 로그인된 계정(User)과 연동된 바로가기 링크를 생성합니다.
+ 이 링크는 현재 기기와 IP에서만 작동하며, 비밀번호 입력 없이 즉시 접속됩니다. +

+
- - -
-
- - -
-
- - -
-
-
    -
  • -
+
+ + + +
- +
+ +
\ No newline at end of file diff --git a/src/main/resources/templates/content/stock/dashboard.html b/src/main/resources/templates/content/stock/dashboard.html index 03ae4af..43e23cc 100644 --- a/src/main/resources/templates/content/stock/dashboard.html +++ b/src/main/resources/templates/content/stock/dashboard.html @@ -82,13 +82,19 @@ const curPrice = parseInt(stock.current_price).toLocaleString(); const bepPrice = parseInt(stock.break_even_price).toLocaleString(); // 손익분기 + const detailLink = `/stock/detail?codes=${stock.code}`; + tr.innerHTML = ` - ${stock.name} + + + ${stock.name} + +
+ ${stock.code} + ${qty} - ${evalAmt} ${buyPrice} ${curPrice} - ${bepPrice} ${stock.profit_rate}% diff --git a/src/main/resources/templates/content/stock/detail.html b/src/main/resources/templates/content/stock/detail.html index c257d91..59b44b6 100644 --- a/src/main/resources/templates/content/stock/detail.html +++ b/src/main/resources/templates/content/stock/detail.html @@ -6,488 +6,226 @@
-

종목 상세 & 주문

-

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

+

종목 상세

+

실시간 분석 및 스마트 주문

-
-
-

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

-
+
+
로딩 중...
\ No newline at end of file diff --git a/src/main/resources/templates/content/stock/history.html b/src/main/resources/templates/content/stock/history.html new file mode 100644 index 0000000..25e0e43 --- /dev/null +++ b/src/main/resources/templates/content/stock/history.html @@ -0,0 +1,82 @@ + + +
+
+
+

거래 내역

+

나의 모든 매매 기록입니다.

+
+ +
+ + + + + + + + + + + + + + + +
일시종목구분단가수량총액비고
불러오는 중...
+
+ +
+ + +
+ \ 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 0e9abd1..f13495d 100644 --- a/src/main/resources/templates/content/stock/market.html +++ b/src/main/resources/templates/content/stock/market.html @@ -1,112 +1,146 @@ -
-
-
    -
  • -
  • -
  • + + -
  • -
  • -
+
+
+
+

주식 시장 순위

+

실시간 거래량, 등락률 상위 종목을 확인하세요.

+
+ +
+
+
    +
  • +
  • +
  • +
  • +
  • +
+
+
+ +
+ + + + + + + + + + + + + + + +
선택순위종목명현재가등락률거래량거래대금
데이터를 불러오는 중...
+
+ +
+ +
-
-
- - - - - - - - - - - - - -
순위종목명현재가등락률거래량거래대금
데이터를 불러오는 중...
-
-
- -
- \ No newline at end of file + + + \ No newline at end of file diff --git a/src/main/resources/templates/fragments/header.html b/src/main/resources/templates/fragments/header.html index 33489f1..a644f53 100644 --- a/src/main/resources/templates/fragments/header.html +++ b/src/main/resources/templates/fragments/header.html @@ -21,6 +21,8 @@