....
This commit is contained in:
parent
526a7b598f
commit
3d62a51153
@ -16,3 +16,26 @@
|
|||||||
//import org.springframework.stereotype.Component
|
//import org.springframework.stereotype.Component
|
||||||
//import java.time.LocalDateTime
|
//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
|
package kr.lunaticbum.back.lun.controllers
|
||||||
|
|
||||||
import jakarta.servlet.http.HttpServletRequest
|
|
||||||
import jakarta.servlet.http.HttpSession
|
import jakarta.servlet.http.HttpSession
|
||||||
import kotlinx.coroutines.reactor.awaitSingle
|
import kotlinx.coroutines.reactor.awaitSingle
|
||||||
import kr.lunaticbum.back.lun.model.KisAuthSession
|
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.model.UserManager
|
||||||
import kr.lunaticbum.back.lun.service.KisApiService
|
import kr.lunaticbum.back.lun.service.KisApiService
|
||||||
import kr.lunaticbum.back.lun.service.KisMarketService
|
import kr.lunaticbum.back.lun.service.KisMarketService
|
||||||
|
import kr.lunaticbum.back.lun.service.StockMonitorService
|
||||||
import kr.lunaticbum.back.lun.utils.LogService
|
import kr.lunaticbum.back.lun.utils.LogService
|
||||||
import org.springframework.http.ResponseEntity
|
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.stereotype.Controller
|
||||||
import org.springframework.web.bind.annotation.GetMapping
|
import org.springframework.web.bind.annotation.*
|
||||||
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.reactive.function.client.WebClient
|
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
|
@Controller
|
||||||
@RequestMapping("/stock")
|
@RequestMapping("/stock")
|
||||||
class StockViewController {
|
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")
|
@GetMapping("/config")
|
||||||
fun configPage(): ResultMV = ResultMV("content/stock/config").apply { setTitle("Stock API 설정") }
|
fun configPage(): ResultMV = ResultMV("content/stock/config").apply { setTitle("Stock API 설정") }
|
||||||
|
|
||||||
|
|
||||||
@GetMapping("/market")
|
@GetMapping("/market")
|
||||||
fun margketPage(): ResultMV = ResultMV("content/stock/market").apply { setTitle("Stock API 설정") }
|
fun margketPage(): ResultMV = ResultMV("content/stock/market").apply { setTitle("Stock API 설정") }
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/stock")
|
@RequestMapping("/api/stock")
|
||||||
class StockApiController(
|
class StockApiController(
|
||||||
private val webClient: WebClient, // WebClientConfig.kt 활용
|
private val webClient: WebClient,
|
||||||
private val userManager: UserManager,
|
private val userManager: UserManager,
|
||||||
private val kisApiService: KisApiService,
|
private val kisApiService: KisApiService,
|
||||||
private val kisMarketService: KisMarketService,
|
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")
|
@PostMapping("/config")
|
||||||
suspend fun saveConfig(
|
suspend fun saveConfig(
|
||||||
@RequestBody config: KisConfigRequest,
|
@RequestBody config: KisConfigRequest,
|
||||||
session: HttpSession // 세션 주입
|
session: HttpSession
|
||||||
): ResponseEntity<ResponceResult> {
|
): ResponseEntity<ResponceResult> {
|
||||||
return try {
|
return try {
|
||||||
// 1. KIS 서버로 토큰 발급 테스트 (유효성 검사)
|
|
||||||
val token = kisApiService.verifyAndGetToken(config).awaitSingle()
|
val token = kisApiService.verifyAndGetToken(config).awaitSingle()
|
||||||
|
|
||||||
// 2. DB 저장 없이 세션에만 저장 (브라우저 종료 시 삭제)
|
|
||||||
val authInfo = KisAuthSession(
|
val authInfo = KisAuthSession(
|
||||||
appKey = config.appKey,
|
appKey = config.appKey,
|
||||||
appSecret = config.appSecret,
|
appSecret = config.appSecret,
|
||||||
@ -62,31 +123,235 @@ class StockApiController(
|
|||||||
accessToken = token
|
accessToken = token
|
||||||
)
|
)
|
||||||
session.setAttribute("KIS_AUTH", authInfo)
|
session.setAttribute("KIS_AUTH", authInfo)
|
||||||
|
|
||||||
ResponseEntity.ok(ResponceResult().apply { resultCode = 0; resultMsg = "연결 성공" })
|
ResponseEntity.ok(ResponceResult().apply { resultCode = 0; resultMsg = "연결 성공" })
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
ResponseEntity.ok(ResponceResult().apply { resultCode = 7001; resultMsg = "연결 실패: ${e.message}" })
|
ResponseEntity.ok(ResponceResult().apply { resultCode = 7001; resultMsg = "연결 실패: ${e.message}" })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/market")
|
@GetMapping("/ws-key")
|
||||||
suspend fun getMarketIndicators(): ResponseEntity<Map<String, Any>> {
|
suspend fun getWebSocketKey(session: HttpSession): ResponseEntity<Map<String, Any>> {
|
||||||
// 공용 혹은 세션에 저장된 키를 사용 (세션에 없다면 기본 시스템 키 사용 로직 필요)
|
val auth = session.getAttribute("KIS_AUTH") as? KisAuthSession
|
||||||
val token = "..." // 발급받은 토큰
|
?: return ResponseEntity.ok(mapOf("resultCode" to 401, "resultMsg" to "인증 정보가 없습니다."))
|
||||||
val appKey = "..."
|
|
||||||
val appSecret = "..."
|
|
||||||
|
|
||||||
val kospi = kisMarketService.getDomesticIndex("0001", token, appKey, appSecret).awaitSingle()
|
return try {
|
||||||
val kosdaq = kisMarketService.getDomesticIndex("1001", token, appKey, appSecret).awaitSingle()
|
// 접속키 발급 요청 (Config 정보 재구성 필요)
|
||||||
|
val config = KisConfigRequest(auth.appKey, auth.appSecret, auth.accountNo)
|
||||||
|
val approvalKey = kisApiService.getWebSocketApprovalKey(config).awaitSingle()
|
||||||
|
|
||||||
// 응답 데이터 정리
|
ResponseEntity.ok(mapOf(
|
||||||
val result = mapOf(
|
"resultCode" to 0,
|
||||||
"kospi" to (kospi["output"] as Map<*, *>)["bstp_nmix_prpr"], // 현재가
|
"approval_key" to approvalKey
|
||||||
"kospi_change" to (kospi["output"] as Map<*, *>)["bstp_nmix_prdy_ctrt"], // 등락률
|
))
|
||||||
"kosdaq" to (kosdaq["output"] as Map<*, *>)["bstp_nmix_prpr"],
|
} catch (e: Exception) {
|
||||||
"kosdaq_change" to (kosdaq["output"] as Map<*, *>)["bstp_nmix_prdy_ctrt"]
|
e.printStackTrace()
|
||||||
|
ResponseEntity.ok(mapOf("resultCode" to 500, "resultMsg" to "접속키 발급 실패: ${e.message}"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/rank/{type}")
|
||||||
|
suspend fun getRank(
|
||||||
|
@PathVariable type: String,
|
||||||
|
session: HttpSession
|
||||||
|
): ResponseEntity<Map<String, Any>> {
|
||||||
|
val auth = session.getAttribute("KIS_AUTH") as? KisAuthSession
|
||||||
|
?: return ResponseEntity.ok(mapOf("resultCode" to 401, "resultMsg" to "인증 정보가 없습니다."))
|
||||||
|
|
||||||
|
return try {
|
||||||
|
val responseMono = when (type) {
|
||||||
|
"volume", "recommend", "amount" -> kisMarketService.getVolumeRank(auth)
|
||||||
|
"rising" -> kisMarketService.getFluctuationRank(auth, "0")
|
||||||
|
"falling" -> kisMarketService.getFluctuationRank(auth, "1")
|
||||||
|
else -> throw IllegalArgumentException("잘못된 요청입니다.")
|
||||||
|
}
|
||||||
|
|
||||||
|
val response = responseMono.awaitSingle()
|
||||||
|
var output = response["output"] as? List<Map<String, String>> ?: emptyList()
|
||||||
|
|
||||||
|
if (type == "recommend") {
|
||||||
|
output = output.filter {
|
||||||
|
val rate = it["prdy_ctrt"]?.toDoubleOrNull() ?: 0.0
|
||||||
|
val price = it["stck_prpr"]?.toIntOrNull() ?: 0
|
||||||
|
rate > 0.0 && rate < 25.0 && price >= 1000
|
||||||
|
}
|
||||||
|
} else if (type == "amount") {
|
||||||
|
output = output.sortedByDescending {
|
||||||
|
val price = it["stck_prpr"]?.toLongOrNull() ?: 0L
|
||||||
|
val vol = it["acml_vol"]?.toLongOrNull() ?: 0L
|
||||||
|
price * vol
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val list = output.take(15).mapIndexed { index, item ->
|
||||||
|
val code = item["mksc_shrn_iscd"] ?: item["stck_shrn_iscd"] ?: item["iscd_stat_cls_code"] ?: ""
|
||||||
|
val price = item["stck_prpr"]?.toLongOrNull() ?: 0L
|
||||||
|
val vol = item["acml_vol"]?.toLongOrNull() ?: 0L
|
||||||
|
val amount = (price * vol) / 100000000
|
||||||
|
|
||||||
|
mapOf(
|
||||||
|
"rank" to (index + 1),
|
||||||
|
"code" to code,
|
||||||
|
"name" to (item["hts_kor_isnm"] ?: item["prdt_name"] ?: ""),
|
||||||
|
"price" to (item["stck_prpr"] ?: "0"),
|
||||||
|
"change_rate" to (item["prdy_ctrt"] ?: "0.0"),
|
||||||
|
"volume" to (item["acml_vol"] ?: "0"),
|
||||||
|
"amount" to amount
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
ResponseEntity.ok(mapOf("resultCode" to 0, "data" to list))
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
ResponseEntity.ok(mapOf("resultCode" to 500, "resultMsg" to "조회 실패: ${e.message}"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@GetMapping("/details")
|
||||||
|
suspend fun getStockDetails(
|
||||||
|
@RequestParam codes: String,
|
||||||
|
session: HttpSession
|
||||||
|
): ResponseEntity<Map<String, Any>> {
|
||||||
|
val auth = session.getAttribute("KIS_AUTH") as? KisAuthSession
|
||||||
|
?: return ResponseEntity.ok(mapOf("resultCode" to 401, "resultMsg" to "인증 정보가 없습니다."))
|
||||||
|
|
||||||
|
val codeList = codes.split(",").map { it.trim() }.filter { it.isNotEmpty() }.take(3)
|
||||||
|
if (codeList.isEmpty()) {
|
||||||
|
return ResponseEntity.ok(mapOf("resultCode" to 400, "resultMsg" to "유효한 종목 코드가 없습니다."))
|
||||||
|
}
|
||||||
|
val delayTime = 500L
|
||||||
|
return try {
|
||||||
|
val results = Flux.fromIterable(codeList)
|
||||||
|
.concatMap { code ->
|
||||||
|
kisMarketService.getCurrentPrice(code, auth)
|
||||||
|
.delayElement(Duration.ofMillis(delayTime))
|
||||||
|
.flatMap { priceData ->
|
||||||
|
kisMarketService.getMinuteChart(code, auth)
|
||||||
|
.map { chartData -> Triple(code, priceData, chartData) }
|
||||||
|
}
|
||||||
|
.delayElement(Duration.ofMillis(delayTime))
|
||||||
|
}
|
||||||
|
.collectList()
|
||||||
|
.awaitSingle()
|
||||||
|
|
||||||
|
val dataList = results.map { item ->
|
||||||
|
val (code, priceRes, chartRes) = item as Triple<String, Map<*, *>, Map<*, *>>
|
||||||
|
|
||||||
|
val output = priceRes["output"] as? Map<String, String> ?: emptyMap()
|
||||||
|
val chartOutput = chartRes["output2"] as? List<Map<String, String>> ?: emptyList()
|
||||||
|
|
||||||
|
// 차트 데이터 (시간순 정렬)
|
||||||
|
val chartList = chartOutput.take(60).reversed().map { tick ->
|
||||||
|
mapOf(
|
||||||
|
"time" to (tick["stck_cntg_hour"]?.substring(0, 4) ?: ""),
|
||||||
|
"price" to (tick["stck_prpr"] ?: "0"),
|
||||||
|
"volume" to (tick["cntg_vol"] ?: "0")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// [추가] 구간별 평균 거래량/거래대금 계산 함수
|
||||||
|
// chartOutput은 최신순(내림차순)으로 들어옵니다. (0번 인덱스가 가장 최근 1분)
|
||||||
|
fun calcAvg(minutes: Int): Map<String, Long> {
|
||||||
|
val subset = chartOutput.take(minutes)
|
||||||
|
if (subset.isEmpty()) return mapOf("vol" to 0L, "amt" to 0L)
|
||||||
|
|
||||||
|
val sumVol = subset.sumOf { (it["cntg_vol"]?.toLongOrNull() ?: 0L) }
|
||||||
|
// 분당 거래대금 추정 = 체결가 * 거래량
|
||||||
|
val sumAmt = subset.sumOf {
|
||||||
|
val p = it["stck_prpr"]?.toLongOrNull() ?: 0L
|
||||||
|
val v = it["cntg_vol"]?.toLongOrNull() ?: 0L
|
||||||
|
p * v
|
||||||
|
}
|
||||||
|
|
||||||
|
return mapOf(
|
||||||
|
"vol" to (sumVol / subset.size),
|
||||||
|
"amt" to (sumAmt / subset.size)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val averages = mapOf(
|
||||||
|
"min1" to calcAvg(1),
|
||||||
|
"min5" to calcAvg(5),
|
||||||
|
"min10" to calcAvg(10),
|
||||||
|
"min60" to calcAvg(60)
|
||||||
)
|
)
|
||||||
|
|
||||||
return ResponseEntity.ok(result) as ResponseEntity<Map<String, Any>>
|
mapOf(
|
||||||
|
"code" to code,
|
||||||
|
"name" to (output["rprs_mrkt_kor_name"] ?: ""),
|
||||||
|
"price" to (output["stck_prpr"] ?: "0"),
|
||||||
|
"change" to (output["prdy_vrss"] ?: "0"),
|
||||||
|
"change_rate" to (output["prdy_ctrt"] ?: "0.0"),
|
||||||
|
"high" to (output["stck_hgpr"] ?: "0"),
|
||||||
|
"low" to (output["stck_lwpr"] ?: "0"),
|
||||||
|
"open" to (output["stck_oprc"] ?: "0"),
|
||||||
|
"volume" to (output["acml_vol"] ?: "0"),
|
||||||
|
"chart" to chartList,
|
||||||
|
"averages" to averages // [추가] 평균 데이터 전달
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
ResponseEntity.ok(mapOf("resultCode" to 0, "data" to dataList))
|
||||||
|
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
ResponseEntity.ok(mapOf("resultCode" to 500, "resultMsg" to "상세 조회 실패: ${e.message}"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/balance")
|
||||||
|
suspend fun getBalance(session: HttpSession): ResponseEntity<Map<String, Any>> {
|
||||||
|
val auth = session.getAttribute("KIS_AUTH") as? KisAuthSession
|
||||||
|
?: return ResponseEntity.ok(mapOf("resultCode" to 401, "resultMsg" to "인증 정보가 없습니다."))
|
||||||
|
|
||||||
|
return try {
|
||||||
|
val response = kisApiService.getAccountBalance(auth).awaitSingle()
|
||||||
|
val rtCd = response["rt_cd"] as? String ?: ""
|
||||||
|
if (rtCd != "0") {
|
||||||
|
val msg = response["msg1"] as? String ?: "알 수 없는 오류"
|
||||||
|
return ResponseEntity.ok(mapOf("resultCode" to 500, "resultMsg" to "KIS 오류: $msg ($rtCd)"))
|
||||||
|
}
|
||||||
|
|
||||||
|
val output1 = response["output1"] as? List<Map<String, Any>> ?: emptyList()
|
||||||
|
val output2 = response["output2"] as? List<Map<String, Any>> ?: emptyList()
|
||||||
|
val summary = if (output2.isNotEmpty()) output2[0] else emptyMap()
|
||||||
|
|
||||||
|
val taxFeeRate = 1.0025
|
||||||
|
|
||||||
|
val stocks = output1.map { stock ->
|
||||||
|
val buyPrice = stock["pchs_avg_pric"]?.toString()?.toDoubleOrNull() ?: 0.0
|
||||||
|
val currentPrice = stock["prpr"]?.toString()?.toDoubleOrNull() ?: 0.0
|
||||||
|
val qty = stock["hldg_qty"]?.toString()?.toIntOrNull() ?: 0
|
||||||
|
val profitRate = stock["evlu_pfls_rt"]?.toString()?.toDoubleOrNull() ?: 0.0
|
||||||
|
val evalAmount = stock["evlu_amt"]?.toString()?.toLongOrNull() ?: (currentPrice * qty).toLong()
|
||||||
|
|
||||||
|
mapOf(
|
||||||
|
"name" to (stock["prdt_name"]?.toString() ?: ""),
|
||||||
|
"qty" to qty,
|
||||||
|
"buy_price" to buyPrice,
|
||||||
|
"current_price" to currentPrice,
|
||||||
|
"eval_amount" to evalAmount,
|
||||||
|
"profit_rate" to profitRate,
|
||||||
|
"break_even_price" to buyPrice * taxFeeRate
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val resultData = mapOf(
|
||||||
|
"total_asset" to (summary["tot_evlu_amt"]?.toString() ?: "0"),
|
||||||
|
"total_profit_rate" to (summary["evlu_pfls_rt"]?.toString() ?: "0.0"),
|
||||||
|
"stocks" to stocks
|
||||||
|
)
|
||||||
|
|
||||||
|
ResponseEntity.ok(mapOf("resultCode" to 0, "data" to resultData))
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
ResponseEntity.ok(mapOf("resultCode" to 500, "resultMsg" to "처리 실패: ${e.message}"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/market")
|
||||||
|
suspend fun getMarketIndicators(): ResponseEntity<Map<String, Any>> {
|
||||||
|
return ResponseEntity.ok(mapOf())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -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
|
package kr.lunaticbum.back.lun.service
|
||||||
|
|
||||||
|
import kr.lunaticbum.back.lun.model.KisAuthSession
|
||||||
import kr.lunaticbum.back.lun.model.KisConfigRequest
|
import kr.lunaticbum.back.lun.model.KisConfigRequest
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
import org.springframework.web.reactive.function.client.WebClient
|
import org.springframework.web.reactive.function.client.WebClient
|
||||||
|
import org.springframework.web.reactive.function.client.WebClientResponseException
|
||||||
import reactor.core.publisher.Mono
|
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
|
@Service
|
||||||
class KisApiService() {
|
class KisApiService() {
|
||||||
private val baseUrl = "https://openapi.koreainvestment.com:9443" // 실전투자용
|
// 두 환경의 URL을 상수로 정의
|
||||||
private val webClient: WebClient = WebClient.create(baseUrl)
|
|
||||||
|
// [설정] 기본적으로 실전투자를 사용하려면 true, 모의투자는 false로 설정하세요.
|
||||||
|
// 혹은 설정 화면에서 체크박스를 받아오는 방식으로 구조를 변경할 수도 있습니다.
|
||||||
|
|
||||||
|
|
||||||
|
// WebClient를 매번 생성하거나(단순함), baseUrl 변경이 필요하므로 요청 시 build
|
||||||
|
private val webClientBuilder: WebClient.Builder = WebClient.builder()
|
||||||
|
|
||||||
fun verifyAndGetToken(config: KisConfigRequest): Mono<String> {
|
fun verifyAndGetToken(config: KisConfigRequest): Mono<String> {
|
||||||
val body = mapOf(
|
val body = mapOf(
|
||||||
@ -17,22 +34,160 @@ class KisApiService() {
|
|||||||
"appsecret" to config.appSecret
|
"appsecret" to config.appSecret
|
||||||
)
|
)
|
||||||
|
|
||||||
return webClient.post()
|
return webClientBuilder.baseUrl(getBaseUrl()).build()
|
||||||
.uri("$baseUrl/oauth2/tokenP")
|
.post()
|
||||||
|
.uri("/oauth2/tokenP")
|
||||||
.bodyValue(body)
|
.bodyValue(body)
|
||||||
.retrieve()
|
.retrieve()
|
||||||
.bodyToMono(Map::class.java)
|
.bodyToMono(Map::class.java)
|
||||||
.map { it["access_token"]?.toString() ?: throw Exception("토큰 발급 실패") }
|
.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
|
@Service
|
||||||
class KisMarketService() {
|
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")
|
// 1. 국내 지수 조회 (KOSPI: "0001", KOSDAQ: "1001")
|
||||||
fun getDomesticIndex(indexCode: String, token: String, appKey: String, appSecret: String): Mono<Map<*, *>> {
|
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")
|
.uri { it.path("/uapi/domestic-stock/v1/quotations/inquire-index-price")
|
||||||
.queryParam("FID_COND_MRKT_DIV_CODE", "U") // 업종
|
.queryParam("FID_COND_MRKT_DIV_CODE", "U") // 업종
|
||||||
.queryParam("FID_INPUT_ISCD", indexCode)
|
.queryParam("FID_INPUT_ISCD", indexCode)
|
||||||
@ -46,6 +201,59 @@ class KisMarketService() {
|
|||||||
.bodyToMono(Map::class.java)
|
.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")
|
// 2. 환율 및 해외 지수 조회 (환율: "FX@KRW", 나스닥: "NAS@IXIC")
|
||||||
// ※ 해외 지수는 '해외주식 현재가 상세' API 등을 활용합니다.
|
// ※ 해외 지수는 '해외주식 현재가 상세' API 등을 활용합니다.
|
||||||
fun getMarketIndicator(symbol: String, token: String, appKey: String, appSecret: String): Mono<Map<*, *>> {
|
fun getMarketIndicator(symbol: String, token: String, appKey: String, appSecret: String): Mono<Map<*, *>> {
|
||||||
|
|||||||
@ -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>
|
</div>
|
||||||
|
|
||||||
<script type="module">
|
<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 () => {
|
document.getElementById('btn_save_kis').addEventListener('click', async () => {
|
||||||
const data = {
|
const data = {
|
||||||
appKey: document.getElementById('kis_app_key').value,
|
appKey: document.getElementById('kis_app_key').value,
|
||||||
@ -42,7 +44,7 @@
|
|||||||
|
|
||||||
const res = await Api.request('/api/stock/config', 'POST', data);
|
const res = await Api.request('/api/stock/config', 'POST', data);
|
||||||
if (res.resultCode === 0) {
|
if (res.resultCode === 0) {
|
||||||
location.href = "/stock/dashboard.bs";
|
location.href = "/stock/dashboard";
|
||||||
} else {
|
} else {
|
||||||
UI.showAlert("연결 실패", res.resultMsg);
|
UI.showAlert("연결 실패", res.resultMsg);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -18,42 +18,88 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="col-8 col-12-medium">
|
<div class="col-8 col-12-medium">
|
||||||
<div class="table-wrapper">
|
<div class="table-wrapper">
|
||||||
<table>
|
<table class="alt"> <thead>
|
||||||
<thead>
|
|
||||||
<tr>
|
<tr>
|
||||||
<th>종목명</th>
|
<th>종목명</th>
|
||||||
<th>보유수량</th>
|
<th class="text-right">보유수량</th>
|
||||||
<th>매입가</th>
|
<th class="text-right">평가금액</th> <th class="text-right">매입가</th>
|
||||||
<th>현재가</th>
|
<th class="text-right">현재가</th>
|
||||||
<th>수익률</th>
|
<th class="text-right">손익분기</th> <th class="text-right">수익률</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="stock_list">
|
<tbody id="stock_list">
|
||||||
<tr><td colspan="5" class="text-center">데이터를 불러오는 중...</td></tr>
|
<tr><td colspan="7" class="text-center">데이터를 불러오는 중...</td></tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script type="module">
|
<script type="module">
|
||||||
|
// [수정] UI는 ui.js에서, Api는 api.js에서 각각 가져와야 합니다.
|
||||||
|
import { Api } from '/js/modules/api.js';
|
||||||
|
import { UI } from '/js/modules/ui.js';
|
||||||
|
|
||||||
window.addEventListener('DOMContentLoaded', async () => {
|
window.addEventListener('DOMContentLoaded', async () => {
|
||||||
try {
|
try {
|
||||||
const res = await Api.request('/api/stock/balance');
|
const res = await Api.request('/api/stock/balance');
|
||||||
if (res.resultCode === 0) {
|
if (res.resultCode === 0) {
|
||||||
renderBalance(res.data);
|
renderBalance(res.data);
|
||||||
|
} else if (res.resultCode === 401) {
|
||||||
|
alert("API 설정이 필요합니다.");
|
||||||
|
location.href = "/stock/config";
|
||||||
} else {
|
} else {
|
||||||
location.href = "/stock/config.bs";
|
alert(res.resultMsg);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
UI.showAlert("오류", "데이터를 가져올 수 없습니다.");
|
console.error(e);
|
||||||
|
alert("데이터를 가져올 수 없습니다.");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
function renderBalance(data) {
|
function renderBalance(data) {
|
||||||
// 자산 및 종목 렌더링 로직
|
// 1. 총 자산 및 수익률 표시
|
||||||
|
document.getElementById('total_asset').innerText = parseInt(data.total_asset).toLocaleString() + ' 원';
|
||||||
|
|
||||||
|
const totalRate = parseFloat(data.total_profit_rate);
|
||||||
|
const rateEl = document.getElementById('total_profit_rate');
|
||||||
|
rateEl.innerText = totalRate + '%';
|
||||||
|
rateEl.style.color = totalRate > 0 ? '#e03e2d' : (totalRate < 0 ? '#0e62cf' : '#333');
|
||||||
|
|
||||||
|
// 2. 종목 리스트 렌더링
|
||||||
|
const tbody = document.getElementById('stock_list');
|
||||||
|
tbody.innerHTML = '';
|
||||||
|
|
||||||
|
if (data.stocks && data.stocks.length > 0) {
|
||||||
|
data.stocks.forEach(stock => {
|
||||||
|
const tr = document.createElement('tr');
|
||||||
|
|
||||||
|
// 수익률 색상 결정 (빨강: 이익, 파랑: 손실)
|
||||||
|
const profitColor = stock.profit_rate > 0 ? '#e03e2d' : (stock.profit_rate < 0 ? '#0e62cf' : '#333');
|
||||||
|
|
||||||
|
// 숫자 포맷팅
|
||||||
|
const qty = stock.qty.toLocaleString();
|
||||||
|
const evalAmt = stock.eval_amount.toLocaleString();
|
||||||
|
const buyPrice = parseInt(stock.buy_price).toLocaleString();
|
||||||
|
const curPrice = parseInt(stock.current_price).toLocaleString();
|
||||||
|
const bepPrice = parseInt(stock.break_even_price).toLocaleString(); // 손익분기
|
||||||
|
|
||||||
|
tr.innerHTML = `
|
||||||
|
<td><strong>${stock.name}</strong></td>
|
||||||
|
<td style="text-align: right;">${qty}</td>
|
||||||
|
<td style="text-align: right;">${evalAmt}</td>
|
||||||
|
<td style="text-align: right; color: #666;">${buyPrice}</td>
|
||||||
|
<td style="text-align: right;"><b>${curPrice}</b></td>
|
||||||
|
<td style="text-align: right; color: #888;">${bepPrice}</td>
|
||||||
|
<td style="text-align: right; color: ${profitColor}; font-weight: bold;">
|
||||||
|
${stock.profit_rate}%
|
||||||
|
</td>
|
||||||
|
`;
|
||||||
|
tbody.appendChild(tr);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
tbody.innerHTML = '<tr><td colspan="7" class="text-center">보유 중인 주식이 없습니다.</td></tr>';
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
</section>
|
</section>
|
||||||
</html>
|
</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>
|
<div class="row">
|
||||||
<html xmlns:th="http://www.thymeleaf.org"
|
<div class="col-12">
|
||||||
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
|
<ul class="actions fit">
|
||||||
layout:decorate="~{layout/default_layout}">
|
<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">
|
<li><button class="button fit" onclick="loadRank('rising', this)">급등주</button></li>
|
||||||
<div class="container">
|
<li><button class="button fit" onclick="loadRank('falling', this)">급락주</button></li>
|
||||||
<header class="major">
|
</ul>
|
||||||
<h2>글로벌 시장 지표</h2>
|
|
||||||
</header>
|
|
||||||
<div class="row">
|
|
||||||
</div>
|
|
||||||
</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';
|
||||||
|
|
||||||
|
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>';
|
||||||
|
|
||||||
<script th:inline="javascript">
|
|
||||||
async function loadMarketData() {
|
|
||||||
try {
|
try {
|
||||||
// Api 모듈이 window에 등록될 때까지 기다리거나 체크
|
const res = await Api.request(`/api/stock/rank/${type}`);
|
||||||
if (typeof Api === 'undefined') {
|
if (res.resultCode === 0) {
|
||||||
console.error("Api 모듈이 아직 로드되지 않았습니다.");
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("시장 지표 로드 시작");
|
list.forEach(item => {
|
||||||
const data = await Api.request('/api/stock/market', 'GET');
|
const tr = document.createElement('tr');
|
||||||
|
const rate = parseFloat(item.change_rate);
|
||||||
|
const colorClass = rate > 0 ? 'color: #e03e2d;' : (rate < 0 ? 'color: #0e62cf;' : '');
|
||||||
|
|
||||||
const kospiEl = document.getElementById('idx_kospi');
|
// 거래대금 포맷팅 (예: 1,500 억)
|
||||||
if(kospiEl && data.kospi) {
|
const amountStr = item.amount ? parseInt(item.amount).toLocaleString() + ' 억' : '-';
|
||||||
kospiEl.innerText = `${parseFloat(data.kospi).toLocaleString()}`;
|
|
||||||
renderChange(kospiEl, data.kospi_change);
|
tr.innerHTML = `
|
||||||
|
<td class="text-center">
|
||||||
|
<input type="checkbox" name="stock_chk" value="${item.code || item.name}" id="chk_${item.rank}">
|
||||||
|
<label for="chk_${item.rank}"></label>
|
||||||
|
</td>
|
||||||
|
<td>${item.rank}</td>
|
||||||
|
<td><strong>${item.name}</strong><br><span style="font-size:0.8em; color:#888;">${item.code}</span></td>
|
||||||
|
<td style="text-align: right;">${parseInt(item.price).toLocaleString()}</td>
|
||||||
|
`;
|
||||||
|
tbody.appendChild(tr);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// KOSDAQ 등 나머지 업데이트...
|
// [추가] 상세 페이지 이동 함수
|
||||||
} catch (e) {
|
window.goToDetail = function() {
|
||||||
console.error("시장 지표 로드 실패", e);
|
const checkboxes = document.querySelectorAll('input[name="stock_chk"]:checked');
|
||||||
|
if (checkboxes.length === 0) {
|
||||||
|
alert("종목을 선택해주세요.");
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
if (checkboxes.length > 3) {
|
||||||
|
alert("최대 3개까지만 비교할 수 있습니다.");
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderChange(el, change) {
|
const codes = Array.from(checkboxes).map(c => c.value).join(',');
|
||||||
if (!change) return;
|
location.href = `/stock/detail?codes=${codes}`;
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
// common.js가 로드된 후 실행되도록 보장
|
|
||||||
window.addEventListener('load', loadMarketData);
|
window.addEventListener('DOMContentLoaded', () => {
|
||||||
</script>
|
// 기본값: 단기 추천 탭 먼저 보여주기
|
||||||
</section>
|
const recommendBtn = document.querySelector("button[onclick*='recommend']");
|
||||||
</html>
|
if(recommendBtn) recommendBtn.click();
|
||||||
|
else document.querySelector('.actions button').click();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
Loading…
x
Reference in New Issue
Block a user