....
This commit is contained in:
parent
526a7b598f
commit
3d62a51153
@ -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()
|
||||
}
|
||||
}
|
||||
@ -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("/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 "인증 정보가 없습니다."))
|
||||
|
||||
return try {
|
||||
// 접속키 발급 요청 (Config 정보 재구성 필요)
|
||||
val config = KisConfigRequest(auth.appKey, auth.appSecret, auth.accountNo)
|
||||
val approvalKey = kisApiService.getWebSocketApprovalKey(config).awaitSingle()
|
||||
|
||||
ResponseEntity.ok(mapOf(
|
||||
"resultCode" to 0,
|
||||
"approval_key" to approvalKey
|
||||
))
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
ResponseEntity.ok(mapOf("resultCode" to 500, "resultMsg" to "접속키 발급 실패: ${e.message}"))
|
||||
}
|
||||
}
|
||||
|
||||
@GetMapping("/rank/{type}")
|
||||
suspend fun getRank(
|
||||
@PathVariable type: String,
|
||||
session: HttpSession
|
||||
): ResponseEntity<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)
|
||||
)
|
||||
|
||||
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>> {
|
||||
// 공용 혹은 세션에 저장된 키를 사용 (세션에 없다면 기본 시스템 키 사용 로직 필요)
|
||||
val token = "..." // 발급받은 토큰
|
||||
val appKey = "..."
|
||||
val appSecret = "..."
|
||||
|
||||
val kospi = kisMarketService.getDomesticIndex("0001", token, appKey, appSecret).awaitSingle()
|
||||
val kosdaq = kisMarketService.getDomesticIndex("1001", token, appKey, appSecret).awaitSingle()
|
||||
|
||||
// 응답 데이터 정리
|
||||
val result = mapOf(
|
||||
"kospi" to (kospi["output"] as Map<*, *>)["bstp_nmix_prpr"], // 현재가
|
||||
"kospi_change" to (kospi["output"] as Map<*, *>)["bstp_nmix_prdy_ctrt"], // 등락률
|
||||
"kosdaq" to (kosdaq["output"] as Map<*, *>)["bstp_nmix_prpr"],
|
||||
"kosdaq_change" to (kosdaq["output"] as Map<*, *>)["bstp_nmix_prdy_ctrt"]
|
||||
)
|
||||
|
||||
return ResponseEntity.ok(result) as ResponseEntity<Map<String, Any>>
|
||||
return ResponseEntity.ok(mapOf())
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
)
|
||||
@ -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>
|
||||
@ -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<*, *>> {
|
||||
@ -63,4 +271,4 @@ class KisMarketService() {
|
||||
.retrieve()
|
||||
.bodyToMono(Map::class.java)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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")
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -18,42 +18,88 @@
|
||||
</div>
|
||||
<div class="col-8 col-12-medium">
|
||||
<div class="table-wrapper">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>종목명</th>
|
||||
<th>보유수량</th>
|
||||
<th>매입가</th>
|
||||
<th>현재가</th>
|
||||
<th>수익률</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<table class="alt"> <thead>
|
||||
<tr>
|
||||
<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';
|
||||
|
||||
<script type="module">
|
||||
window.addEventListener('DOMContentLoaded', async () => {
|
||||
try {
|
||||
const res = await Api.request('/api/stock/balance');
|
||||
if (res.resultCode === 0) {
|
||||
renderBalance(res.data);
|
||||
} else {
|
||||
location.href = "/stock/config.bs";
|
||||
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 {
|
||||
alert(res.resultMsg);
|
||||
}
|
||||
} catch (e) {
|
||||
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>';
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
UI.showAlert("오류", "데이터를 가져올 수 없습니다.");
|
||||
}
|
||||
});
|
||||
|
||||
function renderBalance(data) {
|
||||
// 자산 및 종목 렌더링 로직
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
</section>
|
||||
</html>
|
||||
493
src/main/resources/templates/content/stock/detail.html
Normal file
493
src/main/resources/templates/content/stock/detail.html
Normal 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>
|
||||
@ -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}">
|
||||
<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>
|
||||
|
||||
<section layout:fragment="content">
|
||||
<div class="container">
|
||||
<header class="major">
|
||||
<h2>글로벌 시장 지표</h2>
|
||||
</header>
|
||||
<div class="row">
|
||||
</div>
|
||||
<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() {
|
||||
try {
|
||||
// Api 모듈이 window에 등록될 때까지 기다리거나 체크
|
||||
if (typeof Api === 'undefined') {
|
||||
console.error("Api 모듈이 아직 로드되지 않았습니다.");
|
||||
return;
|
||||
}
|
||||
<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';
|
||||
|
||||
console.log("시장 지표 로드 시작");
|
||||
const data = await Api.request('/api/stock/market', 'GET');
|
||||
window.loadRank = async function(type, btn) {
|
||||
// 버튼 스타일 초기화
|
||||
document.querySelectorAll('.actions button').forEach(b => b.classList.remove('primary'));
|
||||
if(btn) btn.classList.add('primary');
|
||||
|
||||
const kospiEl = document.getElementById('idx_kospi');
|
||||
if(kospiEl && data.kospi) {
|
||||
kospiEl.innerText = `${parseFloat(data.kospi).toLocaleString()}`;
|
||||
renderChange(kospiEl, data.kospi_change);
|
||||
}
|
||||
const tbody = document.getElementById('rank_list');
|
||||
tbody.innerHTML = '<tr><td colspan="6" class="text-center">로딩 중...</td></tr>';
|
||||
|
||||
// KOSDAQ 등 나머지 업데이트...
|
||||
} catch (e) {
|
||||
console.error("시장 지표 로드 실패", e);
|
||||
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);
|
||||
}
|
||||
} 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;
|
||||
}
|
||||
|
||||
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);
|
||||
list.forEach(item => {
|
||||
const tr = document.createElement('tr');
|
||||
const rate = parseFloat(item.change_rate);
|
||||
const colorClass = rate > 0 ? 'color: #e03e2d;' : (rate < 0 ? 'color: #0e62cf;' : '');
|
||||
|
||||
// 거래대금 포맷팅 (예: 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);
|
||||
});
|
||||
}
|
||||
|
||||
// [추가] 상세 페이지 이동 함수
|
||||
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;
|
||||
}
|
||||
|
||||
// common.js가 로드된 후 실행되도록 보장
|
||||
window.addEventListener('load', loadMarketData);
|
||||
</script>
|
||||
</section>
|
||||
</html>
|
||||
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>
|
||||
Loading…
x
Reference in New Issue
Block a user