This commit is contained in:
lunaticbum 2026-01-07 16:42:06 +09:00
parent 3d62a51153
commit df50c9a2da
22 changed files with 1580 additions and 656 deletions

View File

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

View File

@ -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<Map<String, Any>> {
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<String, String>,
session: HttpSession
): ResponseEntity<Map<String, Any>> {
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<String, String>
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<Map<String, Any>> ?: 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<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 저장됨)"
}
// 히스토리 저장 등 후처리 (기존 코드 유지)
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<String, Any>): ResponseEntity<Map<String, Any>> {
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<Map<String, Any>> {
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<String, String>): ResponseEntity<Map<String, Any>> {
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<String, Any>): ResponseEntity<Map<String, Any>> {
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<Map<String, Any>> {
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<String, Map<*, *>, Map<*, *>>
// 3. 데이터 합치기 (잔고 + 상세정보)
val (balanceRes, details) = Mono.zip(balanceMono, detailsFlux).awaitSingle()
// 잔고 데이터 파싱 (보유 종목 찾기용)
val myStocks = (balanceRes["output1"] as? List<Map<String, Any>>) ?: emptyList()
val dataList = details.map { item ->
val (code, priceRes, chartRes) = item
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) ?: ""),
@ -250,45 +448,32 @@ class StockApiController(
)
}
// [추가] 구간별 평균 거래량/거래대금 계산 함수
// 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)
// [추가] 내 보유 정보 찾기
// 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,

View File

@ -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<String, String>,
request: HttpServletRequest,
response: HttpServletResponse, // [추가] 쿠키 설정을 위해 필요
principal: java.security.Principal?
): ResponseEntity<Map<String, String>> {
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()))
}
}
}

View File

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

View File

@ -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일 유효
)

View File

@ -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 // 결과 메시지
)

View File

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

View File

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

View File

@ -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<TradeHistoryEntity, String> {
// 최신순 조회
fun findAllByOrderByTimeDesc(): Flux<TradeHistoryEntity>
}

View File

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

View File

@ -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<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"
// 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<Map<*, *>> {
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<Map<*, *>> {
// [핵심] 현재 시간을 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()

View File

@ -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<AutoTradeEntity>()
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<AutoTradeEntity> {
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<String, String>
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<String, Any> ?: 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}")
}
}

View File

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

View File

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

View File

@ -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("통신 오류가 발생했습니다.");
}
},
};

View File

@ -0,0 +1,108 @@
<!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>
<p>익절/손절 감시 목록 수정 및 취소</p>
</header>
<div class="table-wrapper">
<table class="alt">
<thead>
<tr>
<th>종목</th>
<th class="text-right">매수가</th>
<th class="text-center">익절 %</th>
<th class="text-center">손절 %</th>
<th class="text-center">관리</th>
</tr>
</thead>
<tbody id="task_list">
<tr><td colspan="5" class="text-center">로딩 중...</td></tr>
</tbody>
</table>
</div>
<div class="col-12"><a href="/stock/dashboard" class="button">대시보드</a></div>
</div>
<script type="module">
import { Api } from '/js/modules/api.js';
window.addEventListener('DOMContentLoaded', loadTasks);
// 목록 조회
async function loadTasks() {
try {
const res = await Api.request('/api/stock/auto-trade/list');
const tbody = document.getElementById('task_list');
tbody.innerHTML = '';
if (!res.data || res.data.length === 0) {
tbody.innerHTML = '<tr><td colspan="5" class="text-center">진행 중인 작업이 없습니다.</td></tr>';
return;
}
res.data.forEach(task => {
const html = `
<tr>
<td><strong>${task.stockName}</strong><br><span style="font-size:0.8em; color:#888;">${task.stockCode}</span></td>
<td class="text-right">${parseInt(task.buyPrice).toLocaleString()}</td>
<td class="text-center">
<input type="number" value="${task.targetProfitRate}" step="0.1" style="width:60px; text-align:center; display:inline;" class="inp-rate" data-id="${task.id}" data-type="profit">
</td>
<td class="text-center">
<input type="number" value="${task.stopLossRate || -3.0}" step="0.1" style="width:60px; text-align:center; color:#0e62cf; display:inline;" class="inp-rate" data-id="${task.id}" data-type="loss">
</td>
<td class="text-center">
<button class="button small primary btn-cancel" data-id="${task.id}">중단</button>
</td>
</tr>`;
tbody.insertAdjacentHTML('beforeend', html);
});
bindEvents();
} catch (e) { console.error(e); }
}
function bindEvents() {
// 수정 이벤트 (Enter키 혹은 포커스 아웃 시 저장)
document.querySelectorAll('.inp-rate').forEach(inp => {
inp.addEventListener('change', async (e) => {
const id = e.target.dataset.id;
const type = e.target.dataset.type;
const val = e.target.value;
const url = type === 'profit' ? '/api/stock/auto-trade/update' : '/api/stock/auto-trade/update-stoploss';
const payload = type === 'profit' ? { id: id, targetRate: val } : { id: id, stopLossRate: val };
if(confirm(`${type === 'profit' ? '익절' : '손절'} 목표를 ${val}%로 변경하시겠습니까?`)) {
const res = await Api.request(url, 'POST', payload);
alert(res.resultMsg);
} else {
// 취소 시 새로고침 (값 원복)
loadTasks();
}
});
});
// 취소 버튼
document.querySelectorAll('.btn-cancel').forEach(btn => {
btn.addEventListener('click', async (e) => {
if(confirm("정말로 자동매매를 중단하시겠습니까?")) {
const res = await Api.request('/api/stock/auto-trade/cancel', 'POST', { id: e.target.dataset.id });
alert(res.resultMsg);
loadTasks();
}
});
});
}
</script>
</section>
</html>

View File

@ -2,53 +2,106 @@
<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" style="max-width: 800px; margin: 50px auto;">
<div class="container">
<header class="major">
<h2>한국투자증권 API 설정</h2>
<p>보안을 위해 입력하신 키는 DB에 저장되지 않으며, 세션 종료 시 즉시 파기됩니다.</p>
<h2>API 설정</h2>
<p>한국투자증권 API 접속 정보 및 바로가기 설정</p>
</header>
<form id="kisConfigForm" class="box">
<div class="box">
<h3>🔑 API 인증 정보 입력</h3>
<p style="font-size: 0.9em; color:#666;">발급받은 App Key와 Secret Key를 입력하세요.</p>
<form method="post" action="#" onsubmit="return false;">
<div class="row gtr-uniform">
<div class="col-12">
<label for="appKey">App Key</label>
<input type="text" name="appKey" id="appKey" placeholder="App Key 입력" />
</div>
<div class="col-12">
<label for="appSecret">App Secret</label>
<input type="password" name="appSecret" id="appSecret" placeholder="Secret Key 입력" />
</div>
<div class="col-12">
<label for="accountNo">계좌번호 (8자리+2자리)</label>
<input type="text" name="accountNo" id="accountNo" placeholder="12345678-01" />
</div>
<div class="col-12" style="margin-top: 1.5em;">
<button class="button primary fit" id="btn_connect">API 연결 및 저장 (세션 생성)</button>
</div>
</div>
</form>
</div>
<div class="box" style="margin-top: 2em; border-top: 3px solid #e03e2d;">
<h3>🔗 원터치 보안 링크 생성</h3>
<p style="font-size: 0.8em; color: #666;">
현재 로그인된 계정(<strong><span th:text="${#authentication.name}">User</span></strong>)과 연동된 바로가기 링크를 생성합니다.<br>
이 링크는 현재 기기와 IP에서만 작동하며, <strong>비밀번호 입력 없이</strong> 즉시 접속됩니다.
</p>
<div class="row gtr-uniform">
<div class="col-12">
<label for="kis_app_key">App Key (실전투자)</label>
<input type="text" id="kis_app_key" placeholder="App Key를 입력하세요" required />
</div>
<div class="col-12">
<label for="kis_app_secret">App Secret</label>
<input type="password" id="kis_app_secret" placeholder="App Secret을 입력하세요" required />
</div>
<div class="col-12">
<label for="kis_account_no">계좌번호</label>
<input type="text" id="kis_account_no" placeholder="'-' 제외 10자리 (예: 1234567801)" maxlength="10" required />
</div>
<div class="col-12">
<ul class="actions fit">
<li><button type="button" id="btn_save_kis" class="button primary fit">연결 및 시작하기</button></li>
</ul>
<div style="display: flex; gap: 10px; align-items: center;">
<input type="text" id="generated_link" readonly placeholder="[링크 생성] 버튼을 눌러주세요."
style="background: #f1f1f1; cursor: pointer;" onclick="this.select()">
<button class="button primary small" id="btn_generate" style="white-space: nowrap;">링크 생성</button>
<button class="button small" id="btn_copy" style="white-space: nowrap;">복사</button>
</div>
</div>
</div>
</form>
</div>
<div class="col-12" style="margin-top:1em;">
<a href="/stock/dashboard" class="button fit">대시보드로 이동</a>
</div>
</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,
appSecret: document.getElementById('kis_app_secret').value,
accountNo: document.getElementById('kis_account_no').value
import { StockUtils } from '/js/modules/stock.js';
// URL 파라미터에 에러가 있으면 알림 표시
window.addEventListener('DOMContentLoaded', () => {
const params = new URLSearchParams(location.search);
if (params.has('error')) {
alert("오류 발생: " + params.get('error'));
}
});
// 링크 생성 버튼 이벤트
document.getElementById('btn_generate').addEventListener('click', async () => {
// 입력된 AppKey 등만 가져옴 (ID/PW 불필요)
const inputs = {
key: document.getElementById('appKey').value.trim(),
secret: document.getElementById('appSecret').value.trim(),
acc: document.getElementById('accountNo').value.trim()
};
const res = await Api.request('/api/stock/config', 'POST', data);
if (res.resultCode === 0) {
location.href = "/stock/dashboard";
} else {
UI.showAlert("연결 실패", res.resultMsg);
const url = await StockUtils.security.generateLink(inputs);
if (url) {
const inputEl = document.getElementById('generated_link');
inputEl.value = url;
inputEl.style.backgroundColor = "#e6fffa";
inputEl.style.borderColor = "#38b2ac";
alert("✅ 나만의 바로가기 링크가 생성되었습니다!");
}
});
document.getElementById('btn_connect').addEventListener('click', () => {
const inputs = {
key: document.getElementById('appKey').value.trim(),
secret: document.getElementById('appSecret').value.trim(),
acc: document.getElementById('accountNo').value.trim()
};
StockUtils.connect(inputs);
});
// 복사 버튼 이벤트
document.getElementById('btn_copy').addEventListener('click', () => {
StockUtils.security.copyToClipboard('generated_link');
});
</script>
</section>
</html>

View File

@ -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 = `
<td><strong>${stock.name}</strong></td>
<td>
<a href="${detailLink}" style="font-weight: bold; text-decoration: none; color: inherit;">
${stock.name}
</a>
<br/>
<span style="font-size: 0.8em; color: #888;">${stock.code}</span>
</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>

View File

@ -6,488 +6,226 @@
<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;
}
/* UI 스타일 정의 */
.order-box { background: #f8f9fa; border: 1px solid #e9ecef; border-radius: 8px; padding: 1.25em; margin-top: 1em; }
.my-stock-info { background-color: #fffbe6; border: 1px solid #ffe58f; padding: 0.8em; border-radius: 5px; margin-bottom: 1em; font-size: 0.9em; }
.badge-auto { background-color: #52c41a; color: white; padding: 2px 6px; border-radius: 4px; font-size: 0.7em; vertical-align: middle; margin-left: 5px; }
.stats-table th { background: #f4f4f4; text-align: center; font-size: 0.8em; padding: 0.4em; }
.stats-table td { text-align: right; font-size: 0.8em; padding: 0.4em; }
.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%;
}
.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>
<h2>종목 상세</h2>
<p>실시간 분석 및 스마트 주문</p>
</header>
<div class="row" id="detail_container">
<div class="col-12 text-center">
<p>데이터를 불러오는 중입니다...</p>
</div>
<div id="detail_container" class="row">
<div class="col-12 text-center">로딩 중...</div>
</div>
<div class="row">
<div class="col-12">
<a href="/stock/market" class="button">목록으로 돌아가기</a>
</div>
<div class="col-12"><a href="/stock/market" class="button">목록으로</a></div>
</div>
</div>
<script type="module">
import { Api } from '/js/modules/api.js';
import { StockUtils } from '/js/modules/stock.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;
}
const codes = new URLSearchParams(location.search).get('codes');
if (!codes) return alert("종목이 없습니다.") || (location.href = '/stock/market');
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 || "데이터 조회 실패");
}
if (res.resultCode === 0) render(res.data);
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) {
// 렌더링 로직 (HTML 생성)
function render(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) => {
list.forEach((item, idx) => {
// 데이터 준비
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 color = StockUtils.format.color(rate);
const canvasId = `chart_${idx}`;
// HTML 템플릿
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 style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1em;">
<div>
<h3 style="margin-bottom: 0.2em;">${item.name}</h3>
<span style="font-size: 0.8em; color: #888; letter-spacing: 1px;">${item.code}</span>
<h3 style="margin:0;">${item.name} ${item.is_auto_active ? '<span class="badge-auto">⚡AUTO</span>' : ''}</h3>
<span style="font-size: 0.8em; color: #888;">${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)">
<input type="checkbox" class="ws-toggle" data-code="${item.code}" data-idx="${idx}">
<span class="slider"></span>
</label>
<div style="font-size: 0.7em; margin-top: 3px; color: #666;">실시간</div>
<div style="font-size: 0.7em; color: #666;">실시간</div>
</div>
</div>
<hr />
${item.my_qty > 0 ? `
<div class="my-stock-info">
<div style="display: flex; justify-content: space-between;">
<span>📦 보유: <b>${item.my_qty}주</b></span>
<span>평단: <b>${StockUtils.format.number(item.my_price)}</b></span>
</div>
<div style="text-align: right; margin-top:5px; color:${StockUtils.format.color(item.my_profit_rate)}">
<b>${item.my_profit_rate}%</b>
</div>
</div>` : ''
}
<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>
<h2 style="${color}; font-weight:bold; margin: 0;" id="price_display_${idx}">${StockUtils.format.number(item.price)}</h2>
<span style="${color}; font-weight: bold;" id="rate_display_${idx}">${StockUtils.format.icon(rate)} ${rate}%</span>
</div>
<div style="height: 220px; width: 100%; position: relative; margin-bottom: 1.5em;">
<canvas id="${canvasId}"></canvas>
</div>
<div style="height: 200px; width: 100%; position: relative; margin-bottom: 1em;"><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>
${renderStatsTable(item.averages)}
<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})">
<label style="font-size:0.8em;">가격 (0=시장가)</label>
<input type="number" id="inp_price_${idx}" value="${item.price}" style="text-align:right;">
</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;">
<label style="font-size:0.8em;">수량</label>
<input type="number" id="inp_qty_${idx}" 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})">
<input type="number" id="inp_amt_${idx}" placeholder="투자금액 입력 (수량 자동계산)" style="text-align:right; font-size: 0.9em;">
</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 class="col-12" style="margin-top: 0.5em; background:#fff; padding:5px; border-radius:5px;">
<div style="display:flex; align-items:center; flex-wrap:wrap; gap:10px;">
<label><input type="checkbox" id="chk_auto_${idx}"> 자동매매</label>
<span style="font-size:0.8em; color:#e03e2d;">익절 <input type="number" id="inp_profit_${idx}" value="3.0" step="0.5" style="width:50px; text-align:center;">%</span>
<span style="font-size:0.8em; color:#0e62cf;">손절 <input type="number" id="inp_loss_${idx}" value="-3.0" max="-0.1" step="0.5" style="width:55px; text-align:center;">%</span>
</div>
</div>
<div class="col-6" style="margin-top: 1em;">
<button class="button primary fit small" onclick="orderStock('${item.code}', 'BUY', ${index})">매수</button>
<button class="button primary fit small btn-order" data-type="BUY" data-idx="${idx}" data-code="${item.code}">매수</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>
<button class="button fit small btn-order" data-type="SELL" data-idx="${idx}" data-code="${item.code}">지정가 매도</button>
</div>
${item.my_qty > 0 ? `
<div class="col-12" style="margin-top: 0.5em;">
<button class="button fit small btn-sell-all"
style="background-color:#d63031; color:white; border:none;"
data-code="${item.code}">
🔥 시장가 전량 매도 (보유분 전체)
</button>
</div>
` : ''}
</div>
</div>
</div>
</div>
`;
container.innerHTML += html;
</div>`;
// 차트 그리기는 HTML 렌더링 후 비동기로 실행
setTimeout(() => { drawChart(canvasId, item.chart, rate >= 0); }, 0);
container.insertAdjacentHTML('beforeend', html);
// 차트 그리기
setTimeout(() => StockUtils.drawChart(canvasId, item.chart, rate >= 0), 0);
// 이벤트 바인딩 (계산기)
const amtInp = document.getElementById(`inp_amt_${idx}`);
const priceInp = document.getElementById(`inp_price_${idx}`);
amtInp.addEventListener('keyup', () => StockUtils.calcQty(`inp_amt_${idx}`, `inp_price_${idx}`, `inp_qty_${idx}`));
priceInp.addEventListener('change', () => StockUtils.calcQty(`inp_amt_${idx}`, `inp_price_${idx}`, `inp_qty_${idx}`));
});
bindGlobalEvents(); // 버튼 이벤트 연결
}
// 통계 테이블 HTML 생성
function renderStatsTable(avg) {
if(!avg) return '';
const row = (label, data) => `<tr><td>${label}</td><td>${StockUtils.format.number(data?.vol)}</td><td>${StockUtils.format.money(data?.amt)}</td></tr>`;
return `<div class="table-wrapper" style="margin-bottom:1.5em;"><table class="alt stats-table" style="margin:0;"><thead><tr><th>구분</th><th>거래량</th><th>거래대금</th></tr></thead><tbody>
${row('최근 1분', avg.min1)}${row('5분', avg.min5)}${row('60분', avg.min60)}
</tbody></table></div>`;
}
// 이벤트 리스너 통합 관리
function bindGlobalEvents() {
// 주문 버튼
document.querySelectorAll('.btn-order').forEach(btn => {
btn.addEventListener('click', () => {
const idx = btn.dataset.idx;
StockUtils.placeOrder({
code: btn.dataset.code,
type: btn.dataset.type,
qty: document.getElementById(`inp_qty_${idx}`).value,
price: document.getElementById(`inp_price_${idx}`).value,
isAuto: document.getElementById(`chk_auto_${idx}`).checked,
targetRate: document.getElementById(`inp_profit_${idx}`).value,
stopLoss: document.getElementById(`inp_loss_${idx}`).value
});
});
});
// 시장가 매도 버튼
document.querySelectorAll('.btn-sell-all').forEach(btn => {
btn.addEventListener('click', () => {
StockUtils.sellAll(btn.dataset.code);
});
});
// 웹소켓 토글
document.querySelectorAll('.ws-toggle').forEach(toggle => {
toggle.addEventListener('change', (e) => {
// 다른 토글 끄기 (하나만 허용)
if(e.target.checked) document.querySelectorAll('.ws-toggle').forEach(t => t !== e.target && (t.checked = false));
if (e.target.checked) {
const idx = e.target.dataset.idx;
StockUtils.wsManager.connect(e.target.dataset.code, (data) => {
// UI 업데이트 콜백
const pEl = document.getElementById(`price_display_${idx}`);
const rEl = document.getElementById(`rate_display_${idx}`);
if(pEl) {
pEl.innerText = StockUtils.format.number(data.price);
pEl.style.color = StockUtils.format.color(data.rate);
rEl.innerText = `${StockUtils.format.icon(data.rate)} ${data.rate}%`;
rEl.style.color = StockUtils.format.color(data.rate);
}
});
} else {
StockUtils.wsManager.disconnect();
}
});
});
}
// --- [기능 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();
}
// 페이지 이탈 시 소켓 종료
window.addEventListener('beforeunload', () => StockUtils.wsManager.disconnect());
</script>
</section>
</html>

View File

@ -0,0 +1,82 @@
<!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>
<p>나의 모든 매매 기록입니다.</p>
</header>
<div class="table-wrapper">
<table class="alt">
<thead>
<tr>
<th>일시</th>
<th>종목</th>
<th class="text-center">구분</th>
<th class="text-right">단가</th>
<th class="text-right">수량</th>
<th class="text-right">총액</th>
<th>비고</th>
</tr>
</thead>
<tbody id="history_list">
<tr><td colspan="7" class="text-center">불러오는 중...</td></tr>
</tbody>
</table>
</div>
<div class="col-12">
<a href="/stock/dashboard" class="button">대시보드로 이동</a>
</div>
</div>
<script type="module">
import { Api } from '/js/modules/api.js';
window.addEventListener('DOMContentLoaded', async () => {
try {
const res = await Api.request('/api/stock/history/list');
const tbody = document.getElementById('history_list');
tbody.innerHTML = '';
if (!res.data || res.data.length === 0) {
tbody.innerHTML = '<tr><td colspan="7" class="text-center">거래 내역이 없습니다.</td></tr>';
return;
}
res.data.forEach(item => {
const tr = document.createElement('tr');
const isBuy = item.orderType === 'BUY';
const typeBadge = isBuy
? '<span style="color:#e03e2d; font-weight:bold;">매수</span>'
: '<span style="color:#0e62cf; font-weight:bold;">매도</span>';
const total = parseInt(item.price) * parseInt(item.quantity);
const autoBadge = item.isAutoTrade ? '<span style="font-size:0.8em; background:#eee; padding:2px 5px; border-radius:3px;">AUTO</span>' : '';
// 날짜 포맷팅 (YYYY-MM-DD HH:mm:ss)
const date = new Date(item.time).toLocaleString('ko-KR');
tr.innerHTML = `
<td style="font-size:0.9em;">${date}</td>
<td>
<strong>${item.stockName}</strong><br>
<span style="font-size:0.8em; color:#888;">${item.stockCode}</span>
</td>
<td class="text-center">${typeBadge}</td>
<td class="text-right">${parseInt(item.price).toLocaleString()}</td>
<td class="text-right">${item.quantity}</td>
<td class="text-right">${total.toLocaleString()}</td>
<td style="font-size:0.8em;">${autoBadge} ${item.resultMsg}</td>
`;
tbody.appendChild(tr);
});
} catch (e) {
console.error(e);
}
});
</script>
</section>
</html>

View File

@ -1,112 +1,146 @@
<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>
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{layout/default_layout}">
<li><button class="button fit" onclick="loadRank('rising', this)">급등주</button></li>
<li><button class="button fit" onclick="loadRank('falling', this)">급락주</button></li>
</ul>
<section layout:fragment="content">
<div class="container">
<header class="major">
<h2>주식 시장 순위</h2>
<p>실시간 거래량, 등락률 상위 종목을 확인하세요.</p>
</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>
<div class="table-wrapper">
<table class="alt">
<thead>
<tr>
<th width="5%" class="text-center">선택</th>
<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="7" class="text-center">데이터를 불러오는 중...</td></tr>
</tbody>
</table>
</div>
<div class="col-12">
<button class="button primary fit" onclick="goToDetail()">선택한 종목 상세 보기 (최대 3개)</button>
</div>
</div>
</div>
<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';
<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 {
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);
// 1. 랭킹 로드 함수
window.loadRank = async function(type, btn) {
// 버튼 스타일 초기화
if(btn) {
document.querySelectorAll('.actions button').forEach(b => b.classList.remove('primary'));
btn.classList.add('primary');
}
} 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 = '';
const tbody = document.getElementById('rank_list');
tbody.innerHTML = '<tr><td colspan="7" class="text-center">로딩 중...</td></tr>';
if (!list || list.length === 0) {
tbody.innerHTML = '<tr><td colspan="6" class="text-center">조건에 맞는 종목이 없습니다.</td></tr>';
return;
}
try {
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);
tbody.innerHTML = '<tr><td colspan="7" class="text-center">데이터 없음</td></tr>';
}
} catch (e) {
console.error(e);
tbody.innerHTML = '<tr><td colspan="7" class="text-center">불러오기 실패</td></tr>';
}
};
list.forEach(item => {
const tr = document.createElement('tr');
const rate = parseFloat(item.change_rate);
const colorClass = rate > 0 ? 'color: #e03e2d;' : (rate < 0 ? 'color: #0e62cf;' : '');
// 2. 리스트 렌더링 함수
function renderList(list) {
const tbody = document.getElementById('rank_list');
tbody.innerHTML = '';
// 거래대금 포맷팅 (예: 1,500 억)
const amountStr = item.amount ? parseInt(item.amount).toLocaleString() + ' 억' : '-';
if (!list || list.length === 0) {
tbody.innerHTML = '<tr><td colspan="7" class="text-center">조건에 맞는 종목이 없습니다.</td></tr>';
return;
}
tr.innerHTML = `
list.forEach(item => {
const tr = document.createElement('tr');
const rate = parseFloat(item.change_rate);
// 등락률 색상 (상승: 빨강, 하락: 파랑)
const colorStyle = rate > 0 ? 'color: #e03e2d;' : (rate < 0 ? 'color: #0e62cf;' : '');
// 거래대금 포맷팅
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>
<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);
<td style="text-align: right; ${colorStyle} font-weight:bold;">${rate}%</td>
<td style="text-align: right;">${parseInt(item.volume).toLocaleString()}</td>
<td style="text-align: right; color:#666;">${amountStr}</td>
`;
tbody.appendChild(tr);
});
}
// 3. 상세 페이지 이동 함수
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;
}
const codes = Array.from(checkboxes).map(c => c.value).join(',');
location.href = `/stock/detail?codes=${codes}`;
};
// 초기 로딩
window.addEventListener('DOMContentLoaded', () => {
// 기본값: 단기 추천 탭 먼저 클릭 효과
const recommendBtn = document.querySelector("button[onclick*='recommend']");
if(recommendBtn) recommendBtn.click();
else {
// 버튼이 없으면 첫번째 버튼 강제 클릭
const firstBtn = document.querySelector('.actions button');
if(firstBtn) loadRank('volume', firstBtn);
}
});
}
// [추가] 상세 페이지 이동 함수
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;
}
const codes = Array.from(checkboxes).map(c => c.value).join(',');
location.href = `/stock/detail?codes=${codes}`;
};
window.addEventListener('DOMContentLoaded', () => {
// 기본값: 단기 추천 탭 먼저 보여주기
const recommendBtn = document.querySelector("button[onclick*='recommend']");
if(recommendBtn) recommendBtn.click();
else document.querySelector('.actions button').click();
});
</script>
</script>
</section>
</html>

View File

@ -21,6 +21,8 @@
<ul>
<li sec:authorize="isAuthenticated()"><a href="/stock/config">API 설정</a></li>
<li sec:authorize="isAuthenticated()"><a href="/stock/dashboard">내 잔고/거래</a></li>
<li sec:authorize="isAuthenticated()"><a href="/stock/auto-trade">자동 거래\</a></li>
<li sec:authorize="isAuthenticated()"><a href="/stock/history">내 거래 내역</a></li>
<li><a href="/stock/market">시장 지표</a></li>
</ul>
</li>