This commit is contained in:
lunaticbum 2026-01-06 18:20:31 +09:00
parent 526a7b598f
commit 3d62a51153
10 changed files with 1380 additions and 118 deletions

View File

@ -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()
}
}

View File

@ -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<Map<String, Any>> {
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<String, Any> ?: 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<ResponceResult> {
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("/market")
suspend fun getMarketIndicators(): ResponseEntity<Map<String, Any>> {
// 공용 혹은 세션에 저장된 키를 사용 (세션에 없다면 기본 시스템 키 사용 로직 필요)
val token = "..." // 발급받은 토큰
val appKey = "..."
val appSecret = "..."
@GetMapping("/ws-key")
suspend fun getWebSocketKey(session: HttpSession): ResponseEntity<Map<String, Any>> {
val auth = session.getAttribute("KIS_AUTH") as? KisAuthSession
?: return ResponseEntity.ok(mapOf("resultCode" to 401, "resultMsg" to "인증 정보가 없습니다."))
val kospi = kisMarketService.getDomesticIndex("0001", token, appKey, appSecret).awaitSingle()
val kosdaq = kisMarketService.getDomesticIndex("1001", token, appKey, appSecret).awaitSingle()
return try {
// 접속키 발급 요청 (Config 정보 재구성 필요)
val config = KisConfigRequest(auth.appKey, auth.appSecret, auth.accountNo)
val approvalKey = kisApiService.getWebSocketApprovalKey(config).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"]
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<Map<String, Any>> {
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<Map<String, String>> ?: 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<Map<String, Any>> {
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<String, Map<*, *>, Map<*, *>>
val output = priceRes["output"] as? Map<String, String> ?: emptyMap()
val chartOutput = chartRes["output2"] as? List<Map<String, String>> ?: 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<String, Long> {
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)
)
return ResponseEntity.ok(result) as ResponseEntity<Map<String, Any>>
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<Map<String, Any>> {
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<Map<String, Any>> ?: emptyList()
val output2 = response["output2"] as? List<Map<String, Any>> ?: 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<Map<String, Any>> {
return ResponseEntity.ok(mapOf())
}
}

View File

@ -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
)

View File

@ -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<AutoTradeEntity, String>

View File

@ -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<String> {
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<Map<*, *>> {
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<Map<*, *>> {
// 계좌번호 분리
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<String> {
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<Map<*, *>> {
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<Map<*, *>> {
// 공백 제거 및 유효성 체크
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<Map<*, *>> {
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<Map<*, *>> {
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<Map<*, *>> {
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<Map<*, *>> {

View File

@ -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<AutoTradeEntity>()
// 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<String, String>
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")
}
}

View File

@ -33,6 +33,8 @@
</div>
<script type="module">
import { Api } from '/js/modules/api.js';
import { UI } from '/js/modules/ui.js';
document.getElementById('btn_save_kis').addEventListener('click', async () => {
const data = {
appKey: document.getElementById('kis_app_key').value,
@ -42,7 +44,7 @@
const res = await Api.request('/api/stock/config', 'POST', data);
if (res.resultCode === 0) {
location.href = "/stock/dashboard.bs";
location.href = "/stock/dashboard";
} else {
UI.showAlert("연결 실패", res.resultMsg);
}

View File

@ -18,42 +18,88 @@
</div>
<div class="col-8 col-12-medium">
<div class="table-wrapper">
<table>
<thead>
<table class="alt"> <thead>
<tr>
<th>종목명</th>
<th>보유수량</th>
<th>매입가</th>
<th>현재가</th>
<th>수익률</th>
<th class="text-right">보유수량</th>
<th class="text-right">평가금액</th> <th class="text-right">매입가</th>
<th class="text-right">현재가</th>
<th class="text-right">손익분기</th> <th class="text-right">수익률</th>
</tr>
</thead>
<tbody id="stock_list">
<tr><td colspan="5" class="text-center">데이터를 불러오는 중...</td></tr>
<tr><td colspan="7" class="text-center">데이터를 불러오는 중...</td></tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<script type="module">
// [수정] UI는 ui.js에서, Api는 api.js에서 각각 가져와야 합니다.
import { Api } from '/js/modules/api.js';
import { UI } from '/js/modules/ui.js';
window.addEventListener('DOMContentLoaded', async () => {
try {
const res = await Api.request('/api/stock/balance');
if (res.resultCode === 0) {
renderBalance(res.data);
} else if (res.resultCode === 401) {
alert("API 설정이 필요합니다.");
location.href = "/stock/config";
} else {
location.href = "/stock/config.bs";
alert(res.resultMsg);
}
} catch (e) {
UI.showAlert("오류", "데이터를 가져올 수 없습니다.");
console.error(e);
alert("데이터를 가져올 수 없습니다.");
}
});
function renderBalance(data) {
// 자산 및 종목 렌더링 로직
// 1. 총 자산 및 수익률 표시
document.getElementById('total_asset').innerText = parseInt(data.total_asset).toLocaleString() + ' 원';
const totalRate = parseFloat(data.total_profit_rate);
const rateEl = document.getElementById('total_profit_rate');
rateEl.innerText = totalRate + '%';
rateEl.style.color = totalRate > 0 ? '#e03e2d' : (totalRate < 0 ? '#0e62cf' : '#333');
// 2. 종목 리스트 렌더링
const tbody = document.getElementById('stock_list');
tbody.innerHTML = '';
if (data.stocks && data.stocks.length > 0) {
data.stocks.forEach(stock => {
const tr = document.createElement('tr');
// 수익률 색상 결정 (빨강: 이익, 파랑: 손실)
const profitColor = stock.profit_rate > 0 ? '#e03e2d' : (stock.profit_rate < 0 ? '#0e62cf' : '#333');
// 숫자 포맷팅
const qty = stock.qty.toLocaleString();
const evalAmt = stock.eval_amount.toLocaleString();
const buyPrice = parseInt(stock.buy_price).toLocaleString();
const curPrice = parseInt(stock.current_price).toLocaleString();
const bepPrice = parseInt(stock.break_even_price).toLocaleString(); // 손익분기
tr.innerHTML = `
<td><strong>${stock.name}</strong></td>
<td style="text-align: right;">${qty}</td>
<td style="text-align: right;">${evalAmt}</td>
<td style="text-align: right; color: #666;">${buyPrice}</td>
<td style="text-align: right;"><b>${curPrice}</b></td>
<td style="text-align: right; color: #888;">${bepPrice}</td>
<td style="text-align: right; color: ${profitColor}; font-weight: bold;">
${stock.profit_rate}%
</td>
`;
tbody.appendChild(tr);
});
} else {
tbody.innerHTML = '<tr><td colspan="7" class="text-center">보유 중인 주식이 없습니다.</td></tr>';
}
}
</script>
</section>
</html>

View File

@ -0,0 +1,493 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{layout/default_layout}">
<head>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<style>
/* 주문 박스 스타일 */
.order-box {
background: #f8f9fa;
border: 1px solid #e9ecef;
border-radius: 8px;
padding: 1.25em;
margin-top: 1em;
}
.order-label {
font-size: 0.8em;
color: #666;
margin-bottom: 0.3em;
display: block;
}
/* 자동매매 설정 행 스타일 */
.auto-trade-row {
display: flex;
align-items: center;
gap: 10px;
background: #fff;
padding: 0.5em 0.8em;
border-radius: 5px;
border: 1px solid #e1e1e1;
margin-top: 0.5em;
}
/* 구간별 통계 테이블 스타일 */
.stats-table td {
font-size: 0.8em;
padding: 0.4em 0.5em;
text-align: right;
}
.stats-table th {
font-size: 0.8em;
padding: 0.4em 0.5em;
text-align: center;
background: #f4f4f4;
color: #555;
}
/* 실시간 토글 스위치 스타일 */
.switch {
position: relative;
display: inline-block;
width: 40px;
height: 20px;
vertical-align: middle;
}
.switch input { opacity: 0; width: 0; height: 0; }
.slider {
position: absolute;
cursor: pointer;
top: 0; left: 0; right: 0; bottom: 0;
background-color: #ccc;
transition: .4s;
border-radius: 20px;
}
.slider:before {
position: absolute;
content: "";
height: 14px;
width: 14px;
left: 3px;
bottom: 3px;
background-color: white;
transition: .4s;
border-radius: 50%;
}
input:checked + .slider { background-color: #e03e2d; }
input:checked + .slider:before { transform: translateX(20px); }
/* 텍스트 색상 유틸 */
.text-blue { color: #0e62cf; }
.text-red { color: #e03e2d; }
</style>
</head>
<section layout:fragment="content">
<div class="container">
<header class="major">
<h2>종목 상세 & 주문</h2>
<p>실시간 시세(웹소켓), 분봉 차트, 구간별 거래 추이 분석 및 스마트 주문</p>
</header>
<div class="row" id="detail_container">
<div class="col-12 text-center">
<p>데이터를 불러오는 중입니다...</p>
</div>
</div>
<div class="row">
<div class="col-12">
<a href="/stock/market" class="button">목록으로 돌아가기</a>
</div>
</div>
</div>
<script type="module">
import { Api } from '/js/modules/api.js';
// 전역 변수 관리
let charts = []; // 차트 객체 배열 (재조회 시 기존 차트 파괴용)
let ws = null; // 웹소켓 객체
// 모의투자용 웹소켓 주소 (실전은 ops.koreainvestment.com:21000)
const WS_URL = "ws://ops.koreainvestment.com:31000";
// 1. 초기 로딩 (DOM 로드 시 실행)
window.addEventListener('DOMContentLoaded', async () => {
const params = new URLSearchParams(location.search);
const codes = params.get('codes');
if (!codes) {
alert("선택된 종목이 없습니다.");
location.href = '/stock/market';
return;
}
try {
// 상세 데이터 API 호출
const res = await Api.request(`/api/stock/details?codes=${codes}`);
if (res.resultCode === 0) {
renderDetails(res.data);
} else if (res.resultCode === 401) {
if(confirm("인증 정보가 없습니다. 설정 페이지로 이동하시겠습니까?")) {
location.href = "/stock/config";
}
} else {
alert(res.resultMsg || "데이터 조회 실패");
}
} catch (e) {
console.error(e);
document.getElementById('detail_container').innerHTML =
'<div class="col-12 text-center text-red">데이터를 가져오는 중 오류가 발생했습니다.</div>';
}
});
// 2. 화면 렌더링 함수
function renderDetails(list) {
const container = document.getElementById('detail_container');
container.innerHTML = '';
// 기존 차트 메모리 해제
charts.forEach(c => c.destroy());
charts = [];
if (!list || list.length === 0) {
container.innerHTML = '<div class="col-12 text-center">표시할 데이터가 없습니다.</div>';
return;
}
// 반응형 컬럼 클래스 (1개:꽉참, 2개:반반, 3개:3등분)
const colClass = list.length === 1 ? 'col-12' : (list.length === 2 ? 'col-6 col-12-medium' : 'col-4 col-12-medium');
list.forEach((item, index) => {
const rate = parseFloat(item.change_rate);
const colorStyle = rate > 0 ? 'color: #e03e2d;' : (rate < 0 ? 'color: #0e62cf;' : 'color: #333;');
const icon = rate > 0 ? '▲' : (rate < 0 ? '' : '-');
const canvasId = `chart_${index}`;
const avg = item.averages || {}; // 구간별 평균 데이터
const html = `
<div class="${colClass}" style="margin-bottom: 2em;">
<div class="box" style="padding: 1.5em; height: 100%;">
<div style="margin-bottom: 1em; display: flex; justify-content: space-between; align-items: center;">
<div>
<h3 style="margin-bottom: 0.2em;">${item.name}</h3>
<span style="font-size: 0.8em; color: #888; letter-spacing: 1px;">${item.code}</span>
</div>
<div style="text-align: right;">
<label class="switch">
<input type="checkbox" id="ws_toggle_${index}" onchange="toggleWebSocket('${item.code}', ${index}, this)">
<span class="slider"></span>
</label>
<div style="font-size: 0.7em; margin-top: 3px; color: #666;">실시간</div>
</div>
</div>
<hr />
<div style="display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 1em;">
<h2 style="${colorStyle} font-weight:bold; margin: 0;" id="live_price_${index}">
${parseInt(item.price).toLocaleString()}
</h2>
<span style="${colorStyle} font-weight: bold;" id="live_rate_${index}">
${icon} ${rate}%
</span>
</div>
<div style="height: 220px; width: 100%; position: relative; margin-bottom: 1.5em;">
<canvas id="${canvasId}"></canvas>
</div>
<div style="margin-bottom: 1.5em;">
<h5 style="border-bottom: 1px solid #ddd; padding-bottom: 0.5em; margin-bottom: 0.5em; font-size: 0.9em;">📊 구간별 평균 거래 (분당)</h5>
<div class="table-wrapper">
<table class="alt stats-table" style="margin-bottom: 0;">
<thead>
<tr>
<th>구분</th>
<th>평균 거래량</th>
<th>평균 거래대금</th>
</tr>
</thead>
<tbody>
<tr>
<td style="font-weight:bold; color:#333;">최근 1분</td>
<td>${parseInt(avg.min1?.vol || 0).toLocaleString()}</td>
<td>${formatMoney(avg.min1?.amt || 0)}</td>
</tr>
<tr>
<td>5분</td>
<td>${parseInt(avg.min5?.vol || 0).toLocaleString()}</td>
<td>${formatMoney(avg.min5?.amt || 0)}</td>
</tr>
<tr>
<td>10분</td>
<td>${parseInt(avg.min10?.vol || 0).toLocaleString()}</td>
<td>${formatMoney(avg.min10?.amt || 0)}</td>
</tr>
<tr>
<td>60분</td>
<td>${parseInt(avg.min60?.vol || 0).toLocaleString()}</td>
<td>${formatMoney(avg.min60?.amt || 0)}</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="order-box">
<h5 style="border-bottom: 1px solid #ddd; padding-bottom: 0.5em; margin-bottom: 0.8em;">주문 설정</h5>
<div class="row gtr-50">
<div class="col-6">
<label class="order-label">매수 가격</label>
<input type="number" id="price_${index}" value="${item.price}" style="text-align:right;" onchange="calcQty(${index})">
</div>
<div class="col-6">
<label class="order-label">매수 수량</label>
<input type="number" id="qty_${index}" value="1" min="1" style="text-align:right; font-weight:bold; color:#e03e2d;">
</div>
<div class="col-12">
<label class="order-label">총 투자금액 (원)</label>
<input type="number" id="total_amount_${index}" placeholder="예: 100000" style="text-align:right;" onkeyup="calcQty(${index})">
</div>
<div class="col-12">
<div class="auto-trade-row">
<input type="checkbox" id="auto_trade_${index}" name="auto_trade">
<label for="auto_trade_${index}" style="margin-bottom: 0; font-size: 0.9em; cursor:pointer;">자동매도 실행</label>
<div style="margin-left: auto; display: flex; align-items: center; gap: 5px;">
<span style="font-size: 0.8em;">목표:</span>
<input type="number" id="target_rate_${index}" value="3.0" min="1" max="10" step="0.5"
style="width: 70px; height: 30px; text-align: center; padding: 0 5px; font-size: 0.9em;">
<span style="font-size: 0.8em;">%</span>
</div>
</div>
</div>
<div class="col-6" style="margin-top: 1em;">
<button class="button primary fit small" onclick="orderStock('${item.code}', 'BUY', ${index})">매수</button>
</div>
<div class="col-6" style="margin-top: 1em;">
<button class="button fit small" style="background-color: #555; color: white;" onclick="orderStock('${item.code}', 'SELL', ${index})">매도</button>
</div>
</div>
</div>
</div>
</div>
`;
container.innerHTML += html;
// 차트 그리기는 HTML 렌더링 후 비동기로 실행
setTimeout(() => { drawChart(canvasId, item.chart, rate >= 0); }, 0);
});
}
// --- [기능 1] 웹소켓 실시간 처리 ---
window.toggleWebSocket = async function(code, index, checkbox) {
if (checkbox.checked) {
// 다른 켜진 스위치가 있다면 끄기 (1개만 허용)
document.querySelectorAll('input[id^="ws_toggle_"]').forEach(el => {
if(el !== checkbox && el.checked) el.checked = false;
});
await startWebSocket(code, index);
} else {
closeWebSocket();
}
};
async function startWebSocket(code, index) {
closeWebSocket(); // 기존 연결 종료
try {
// 1. 접속키 발급
const res = await Api.request('/api/stock/ws-key');
if (res.resultCode !== 0) {
alert("접속키 발급 실패: " + res.resultMsg);
document.getElementById(`ws_toggle_${index}`).checked = false;
return;
}
const approvalKey = res.approval_key;
// 2. 소켓 연결
ws = new WebSocket(WS_URL);
ws.onopen = function() {
console.log("[WS] Connected");
// 구독 요청 (주식체결가 H0STCNT0)
const req = {
header: {
approval_key: approvalKey,
custtype: "P",
tr_type: "1",
"content-type": "utf-8"
},
body: {
input: { tr_id: "H0STCNT0", tr_key: code }
}
};
ws.send(JSON.stringify(req));
};
ws.onmessage = function(evt) {
const rawData = evt.data;
if (rawData[0] === '{') return; // 시스템 메시지 무시
const parts = rawData.split('|');
if (parts.length >= 4) {
const fields = parts[3].split('^');
const currentPrice = parseInt(fields[2]); // 현재가
const profitRate = parseFloat(fields[4]); // 등락률
updateUI(index, currentPrice, profitRate);
}
};
ws.onerror = (e) => console.error("[WS] Error", e);
} catch (e) {
console.error(e);
alert("웹소켓 연결 오류");
document.getElementById(`ws_toggle_${index}`).checked = false;
}
}
function closeWebSocket() {
if (ws) {
ws.close();
ws = null;
console.log("[WS] Closed");
}
}
function updateUI(index, price, rate) {
const priceEl = document.getElementById(`live_price_${index}`);
const rateEl = document.getElementById(`live_rate_${index}`);
if (priceEl) {
priceEl.innerText = price.toLocaleString();
const color = rate > 0 ? '#e03e2d' : (rate < 0 ? '#0e62cf' : '#333');
priceEl.style.color = color;
rateEl.style.color = color;
const icon = rate > 0 ? '▲' : (rate < 0 ? '' : '-');
rateEl.innerText = `${icon} ${rate}%`;
// 깜빡임 효과
priceEl.style.transition = "background-color 0.2s";
priceEl.style.backgroundColor = rate > 0 ? "rgba(224, 62, 45, 0.1)" : "rgba(14, 98, 207, 0.1)";
setTimeout(() => { priceEl.style.backgroundColor = "transparent"; }, 300);
}
}
window.addEventListener('beforeunload', () => closeWebSocket());
// --- [기능 2] 주문 및 계산 로직 ---
window.calcQty = function(index) {
const priceVal = document.getElementById(`price_${index}`).value.replace(/,/g, '');
const amountVal = document.getElementById(`total_amount_${index}`).value.replace(/,/g, '');
const price = parseInt(priceVal || 0);
const amount = parseInt(amountVal || 0);
if (price > 0 && amount > 0) {
const qty = Math.floor(amount / price);
document.getElementById(`qty_${index}`).value = qty;
}
};
window.orderStock = async function(code, type, index) {
const price = document.getElementById(`price_${index}`).value;
const qty = document.getElementById(`qty_${index}`).value;
const isAuto = document.getElementById(`auto_trade_${index}`).checked;
const targetRate = document.getElementById(`target_rate_${index}`).value;
if (!price || !qty || parseInt(qty) <= 0) {
alert("가격과 수량을 확인해주세요.");
return;
}
const typeName = type === 'BUY' ? "매수" : "매도";
let msg = `${typeName} 주문을 하시겠습니까?\n\n종목: ${code}\n가격: ${parseInt(price).toLocaleString()}원\n수량: ${qty}주`;
if (type === 'BUY' && isAuto) msg += `\n\n[⚡자동매도 설정]\n목표수익률 ${targetRate}% 도달 시 자동 매도`;
if (!confirm(msg)) return;
try {
const res = await Api.request('/api/stock/order', 'POST', {
code: code, type: type, qty: parseInt(qty), price: parseInt(price),
isAutoTrade: isAuto, targetProfitRate: parseFloat(targetRate)
});
if (res.resultCode === 0) alert(res.resultMsg);
else alert("주문 실패: " + res.resultMsg);
} catch (e) {
console.error(e);
alert("통신 오류 발생");
}
};
// --- [기능 3] 차트 그리기 & 포맷팅 ---
function drawChart(canvasId, chartData, isRising) {
const canvas = document.getElementById(canvasId);
if (!canvas || !chartData || chartData.length === 0) return;
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)');
const chart = 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 }
}
}
});
charts.push(chart);
}
function formatMoney(amount) {
if (amount >= 100000000) return (amount / 100000000).toFixed(1) + '억';
if (amount >= 10000) return (amount / 10000).toFixed(0) + '만';
return parseInt(amount).toLocaleString();
}
</script>
</section>
</html>

View File

@ -1,56 +1,112 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{layout/default_layout}">
<section layout:fragment="content">
<div class="container">
<header class="major">
<h2>글로벌 시장 지표</h2>
</header>
<div class="row">
<div class="col-12">
<ul class="actions fit">
<li><button class="button fit primary" onclick="loadRank('volume', this)">거래량 상위</button></li>
<li><button class="button fit" onclick="loadRank('amount', this)">거래대금 상위</button></li>
<li><button class="button fit" onclick="loadRank('recommend', this)">🔥 단기 추천</button></li>
<li><button class="button fit" onclick="loadRank('rising', this)">급등주</button></li>
<li><button class="button fit" onclick="loadRank('falling', this)">급락주</button></li>
</ul>
</div>
</div>
<script th:inline="javascript">
async function loadMarketData() {
<div class="table-wrapper">
<table class="alt">
<thead>
<tr>
<th width="10%">순위</th>
<th>종목명</th>
<th class="text-right">현재가</th>
<th class="text-right">등락률</th>
<th class="text-right">거래량</th>
<th class="text-right">거래대금</th> </tr>
</thead>
<tbody id="rank_list">
<tr><td colspan="6" class="text-center">데이터를 불러오는 중...</td></tr>
</tbody>
</table>
</div>
<div class="col-12">
<button class="button primary fit" onclick="goToDetail()">선택한 종목 상세 보기 (최대 3개)</button>
</div>
<script type="module">
import { Api } from '/js/modules/api.js';
window.loadRank = async function(type, btn) {
// 버튼 스타일 초기화
document.querySelectorAll('.actions button').forEach(b => b.classList.remove('primary'));
if(btn) btn.classList.add('primary');
const tbody = document.getElementById('rank_list');
tbody.innerHTML = '<tr><td colspan="6" class="text-center">로딩 중...</td></tr>';
try {
// Api 모듈이 window에 등록될 때까지 기다리거나 체크
if (typeof Api === 'undefined') {
console.error("Api 모듈이 아직 로드되지 않았습니다.");
const res = await Api.request(`/api/stock/rank/${type}`);
if (res.resultCode === 0) {
renderList(res.data);
} else if (res.resultCode === 401) {
if(confirm("API 설정이 필요합니다. 이동하시겠습니까?")) location.href = "/stock/config";
} else {
alert(res.resultMsg);
}
} catch (e) {
console.error(e);
tbody.innerHTML = '<tr><td colspan="6" class="text-center">불러오기 실패</td></tr>';
}
};
function renderList(list) {
const tbody = document.getElementById('rank_list');
tbody.innerHTML = '';
if (!list || list.length === 0) {
tbody.innerHTML = '<tr><td colspan="6" class="text-center">조건에 맞는 종목이 없습니다.</td></tr>';
return;
}
console.log("시장 지표 로드 시작");
const data = await Api.request('/api/stock/market', 'GET');
list.forEach(item => {
const tr = document.createElement('tr');
const rate = parseFloat(item.change_rate);
const colorClass = rate > 0 ? 'color: #e03e2d;' : (rate < 0 ? 'color: #0e62cf;' : '');
const kospiEl = document.getElementById('idx_kospi');
if(kospiEl && data.kospi) {
kospiEl.innerText = `${parseFloat(data.kospi).toLocaleString()}`;
renderChange(kospiEl, data.kospi_change);
// 거래대금 포맷팅 (예: 1,500 억)
const amountStr = item.amount ? parseInt(item.amount).toLocaleString() + ' 억' : '-';
tr.innerHTML = `
<td class="text-center">
<input type="checkbox" name="stock_chk" value="${item.code || item.name}" id="chk_${item.rank}">
<label for="chk_${item.rank}"></label>
</td>
<td>${item.rank}</td>
<td><strong>${item.name}</strong><br><span style="font-size:0.8em; color:#888;">${item.code}</span></td>
<td style="text-align: right;">${parseInt(item.price).toLocaleString()}</td>
`;
tbody.appendChild(tr);
});
}
// KOSDAQ 등 나머지 업데이트...
} catch (e) {
console.error("시장 지표 로드 실패", e);
// [추가] 상세 페이지 이동 함수
window.goToDetail = function() {
const checkboxes = document.querySelectorAll('input[name="stock_chk"]:checked');
if (checkboxes.length === 0) {
alert("종목을 선택해주세요.");
return;
}
if (checkboxes.length > 3) {
alert("최대 3개까지만 비교할 수 있습니다.");
return;
}
function renderChange(el, change) {
if (!change) return;
const val = parseFloat(change);
const color = val > 0 ? '#ff4d4d' : (val < 0 ? '#4d4dff' : '#000');
const prefix = val > 0 ? '▲' : (val < 0 ? '' : '');
const span = document.createElement('span');
span.style.color = color;
span.style.fontSize = '0.6em';
span.style.marginLeft = '10px';
span.innerText = `${prefix} ${Math.abs(val)}%`;
el.appendChild(span);
}
const codes = Array.from(checkboxes).map(c => c.value).join(',');
location.href = `/stock/detail?codes=${codes}`;
};
// common.js가 로드된 후 실행되도록 보장
window.addEventListener('load', loadMarketData);
window.addEventListener('DOMContentLoaded', () => {
// 기본값: 단기 추천 탭 먼저 보여주기
const recommendBtn = document.querySelector("button[onclick*='recommend']");
if(recommendBtn) recommendBtn.click();
else document.querySelector('.actions button').click();
});
</script>
</section>
</html>