...
This commit is contained in:
parent
3d62a51153
commit
df50c9a2da
@ -48,6 +48,7 @@ import org.springframework.security.web.context.RequestAttributeSecurityContextR
|
|||||||
import org.springframework.security.web.context.SecurityContextRepository
|
import org.springframework.security.web.context.SecurityContextRepository
|
||||||
import org.springframework.security.web.util.matcher.AntPathRequestMatcher
|
import org.springframework.security.web.util.matcher.AntPathRequestMatcher
|
||||||
import org.springframework.security.web.util.matcher.NegatedRequestMatcher
|
import org.springframework.security.web.util.matcher.NegatedRequestMatcher
|
||||||
|
import org.springframework.security.web.util.matcher.RequestMatcher
|
||||||
import org.springframework.stereotype.Component
|
import org.springframework.stereotype.Component
|
||||||
import org.springframework.web.filter.OncePerRequestFilter
|
import org.springframework.web.filter.OncePerRequestFilter
|
||||||
import java.security.SignatureException
|
import java.security.SignatureException
|
||||||
@ -106,7 +107,10 @@ class SecurityConfig(
|
|||||||
http.securityContext { context ->
|
http.securityContext { context ->
|
||||||
context.securityContextRepository(securityContextRepository())
|
context.securityContextRepository(securityContextRepository())
|
||||||
}
|
}
|
||||||
.securityMatcher("/api/**") // 이 설정은 /api/ 경로에만 적용됨
|
.securityMatcher { request ->
|
||||||
|
val path = request.servletPath
|
||||||
|
path.startsWith("/api/") && !path.startsWith("/api/stock/")
|
||||||
|
}
|
||||||
.csrf { it.disable() }
|
.csrf { it.disable() }
|
||||||
.cors { it.configurationSource(corsConfigurationSource()) }
|
.cors { it.configurationSource(corsConfigurationSource()) }
|
||||||
.sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) } // API는 세션을 사용하지 않음
|
.sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) } // API는 세션을 사용하지 않음
|
||||||
@ -171,8 +175,11 @@ class SecurityConfig(
|
|||||||
@Bean
|
@Bean
|
||||||
@Order(2) // 웹 페이지 보안 설정
|
@Order(2) // 웹 페이지 보안 설정
|
||||||
fun webFilterChain(http: HttpSecurity): SecurityFilterChain {
|
fun webFilterChain(http: HttpSecurity): SecurityFilterChain {
|
||||||
http.securityMatcher(NegatedRequestMatcher(AntPathRequestMatcher("/api/**")))
|
// http.securityMatcher(NegatedRequestMatcher(AntPathRequestMatcher("/api/**")))
|
||||||
|
http.securityMatcher(NegatedRequestMatcher { request ->
|
||||||
|
val path = request.servletPath
|
||||||
|
path.startsWith("/api/") && !path.startsWith("/api/stock/")
|
||||||
|
})
|
||||||
http.cors { }
|
http.cors { }
|
||||||
.csrf { csrf ->
|
.csrf { csrf ->
|
||||||
csrf.ignoringRequestMatchers(
|
csrf.ignoringRequestMatchers(
|
||||||
@ -397,8 +404,11 @@ class CustomAccessDeniedHandler : AccessDeniedHandler {
|
|||||||
class ApiAndWebSecurityContextRepository : SecurityContextRepository {
|
class ApiAndWebSecurityContextRepository : SecurityContextRepository {
|
||||||
|
|
||||||
// API 요청은 /api/** 패턴에 매칭됩니다.
|
// API 요청은 /api/** 패턴에 매칭됩니다.
|
||||||
private val apiRequestMatcher = AntPathRequestMatcher("/api/**")
|
// private val apiRequestMatcher = AntPathRequestMatcher("/api/**")
|
||||||
|
private val apiRequestMatcher = RequestMatcher { request ->
|
||||||
|
val path = request.servletPath
|
||||||
|
path.startsWith("/api/") && !path.startsWith("/api/stock/")
|
||||||
|
}
|
||||||
// API 요청에 대해서는 세션을 전혀 사용하지 않고, 오직 요청 기간 동안만 SecurityContext를 저장합니다. (완벽한 STATELESS)
|
// API 요청에 대해서는 세션을 전혀 사용하지 않고, 오직 요청 기간 동안만 SecurityContext를 저장합니다. (완벽한 STATELESS)
|
||||||
private val apiContextRepository = RequestAttributeSecurityContextRepository()
|
private val apiContextRepository = RequestAttributeSecurityContextRepository()
|
||||||
|
|
||||||
|
|||||||
@ -1,22 +1,36 @@
|
|||||||
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.Dispatchers
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.reactor.awaitSingle
|
import kotlinx.coroutines.reactor.awaitSingle
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import kr.lunaticbum.back.lun.configs.core.GlobalEnvironment
|
||||||
import kr.lunaticbum.back.lun.model.KisAuthSession
|
import kr.lunaticbum.back.lun.model.KisAuthSession
|
||||||
import kr.lunaticbum.back.lun.model.KisConfigRequest
|
import kr.lunaticbum.back.lun.model.KisConfigRequest
|
||||||
import kr.lunaticbum.back.lun.model.ResponceResult
|
import kr.lunaticbum.back.lun.model.ResponceResult
|
||||||
import kr.lunaticbum.back.lun.model.ResultMV
|
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.repository.TradeHistoryRepository
|
||||||
|
import kr.lunaticbum.back.lun.service.DirectLoginService
|
||||||
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.service.StockMonitorService
|
||||||
|
import kr.lunaticbum.back.lun.services.TelegramBotService
|
||||||
import kr.lunaticbum.back.lun.utils.LogService
|
import kr.lunaticbum.back.lun.utils.LogService
|
||||||
|
import kr.lunaticbum.back.lun.utils.MarketTimeManager
|
||||||
import org.springframework.http.ResponseEntity
|
import org.springframework.http.ResponseEntity
|
||||||
|
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
|
||||||
|
import org.springframework.security.core.context.SecurityContextHolder
|
||||||
|
import org.springframework.security.core.userdetails.UserDetailsService
|
||||||
import org.springframework.stereotype.Controller
|
import org.springframework.stereotype.Controller
|
||||||
import org.springframework.web.bind.annotation.*
|
import org.springframework.web.bind.annotation.*
|
||||||
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.Flux // [추가]
|
||||||
import reactor.core.publisher.Mono
|
import reactor.core.publisher.Mono
|
||||||
|
import reactor.kotlin.core.util.function.component1
|
||||||
|
import reactor.kotlin.core.util.function.component2
|
||||||
import java.time.Duration // [추가]
|
import java.time.Duration // [추가]
|
||||||
|
|
||||||
data class StockOrderRequest(
|
data class StockOrderRequest(
|
||||||
@ -24,14 +38,26 @@ data class StockOrderRequest(
|
|||||||
val type: String,
|
val type: String,
|
||||||
val qty: Int,
|
val qty: Int,
|
||||||
val price: Int,
|
val price: Int,
|
||||||
// [추가] 자동매매 옵션
|
|
||||||
val isAutoTrade: Boolean = false,
|
val isAutoTrade: Boolean = false,
|
||||||
val targetProfitRate: Double = 0.0
|
val targetProfitRate: Double = 0.0,
|
||||||
|
val stopLossRate: Double = 0.0,
|
||||||
|
val isSellAll: Boolean = false
|
||||||
)
|
)
|
||||||
|
|
||||||
@Controller
|
@Controller
|
||||||
@RequestMapping("/stock")
|
@RequestMapping("/stock")
|
||||||
class StockViewController {
|
class StockViewController(
|
||||||
|
private val kisApiService: KisApiService,
|
||||||
|
private val kisMarketService: KisMarketService,
|
||||||
|
private val directLoginService: DirectLoginService,
|
||||||
|
private val userDetailsService: UserDetailsService
|
||||||
|
) {
|
||||||
|
|
||||||
|
@GetMapping("/auto-trade") // [추가] 자동매매 리스트 화면
|
||||||
|
fun autoTradePage(): ResultMV = ResultMV("content/stock/auto_trade").apply { setTitle("자동매매 관리") }
|
||||||
|
|
||||||
|
@GetMapping("/history") // [추가] 거래내역 화면
|
||||||
|
fun historyPage(): ResultMV = ResultMV("content/stock/history").apply { setTitle("거래 내역") }
|
||||||
|
|
||||||
@GetMapping("/detail")
|
@GetMapping("/detail")
|
||||||
fun detailPage(): ResultMV = ResultMV("content/stock/detail").apply { setTitle("종목 상세 분석") }
|
fun detailPage(): ResultMV = ResultMV("content/stock/detail").apply { setTitle("종목 상세 분석") }
|
||||||
|
|
||||||
@ -43,6 +69,60 @@ class StockViewController {
|
|||||||
|
|
||||||
@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 설정") }
|
||||||
|
|
||||||
|
@GetMapping("/direct-login")
|
||||||
|
suspend fun directLogin(
|
||||||
|
@RequestParam token: String,
|
||||||
|
session: HttpSession,
|
||||||
|
request: HttpServletRequest
|
||||||
|
): String {
|
||||||
|
return try {
|
||||||
|
val ip = request.getHeader("X-Forwarded-For") ?: request.remoteAddr
|
||||||
|
val ua = request.getHeader("User-Agent") ?: ""
|
||||||
|
val deviceId = request.cookies?.find { it.name == "LUN_DEVICE_ID" }?.value
|
||||||
|
|
||||||
|
// 1. 토큰 검증 (비동기)
|
||||||
|
val info = directLoginService.validateAndGet(token, ip, ua, deviceId)
|
||||||
|
|
||||||
|
// 2. 앱 로그인 처리 (블로킹 구간 격리)
|
||||||
|
// UserDetailsService는 블로킹 DB 호출을 포함할 수 있으므로 IO 스레드에서 실행
|
||||||
|
if (request.userPrincipal == null || request.userPrincipal.name != info.username) {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
val userDetails = userDetailsService.loadUserByUsername(info.username)
|
||||||
|
val auth = UsernamePasswordAuthenticationToken(userDetails, null, userDetails.authorities)
|
||||||
|
SecurityContextHolder.getContext().authentication = auth
|
||||||
|
session.setAttribute("SPRING_SECURITY_CONTEXT", SecurityContextHolder.getContext())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 주식 API 연결 (비동기)
|
||||||
|
val config = KisConfigRequest(info.appKey, info.appSecret, info.accountNo)
|
||||||
|
val accessToken = kisApiService.verifyAndGetToken(config).awaitSingle()
|
||||||
|
session.setAttribute("KIS_AUTH", KisAuthSession(info.appKey, info.appSecret, info.accountNo, accessToken))
|
||||||
|
|
||||||
|
"redirect:/stock/dashboard"
|
||||||
|
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
// 에러 메시지 한글 깨짐 방지 처리
|
||||||
|
val errorMsg = if (e.message?.contains("Timeout") == true) {
|
||||||
|
"로그인 시간 초과 (DB 응답 지연)"
|
||||||
|
} else {
|
||||||
|
e.message ?: "접속 실패"
|
||||||
|
}
|
||||||
|
val encodedMsg = java.net.URLEncoder.encode(errorMsg, "UTF-8")
|
||||||
|
"redirect:/stock/config?error=$encodedMsg"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// IP 추출 헬퍼 함수
|
||||||
|
private fun getClientIp(request: HttpServletRequest): String {
|
||||||
|
var ip = request.getHeader("X-Forwarded-For")
|
||||||
|
if (ip.isNullOrEmpty() || "unknown".equals(ip, ignoreCase = true)) {
|
||||||
|
ip = request.remoteAddr
|
||||||
|
}
|
||||||
|
return ip ?: ""
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@ -53,9 +133,56 @@ class StockApiController(
|
|||||||
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 // [추가] 注入
|
private val stockMonitorService: StockMonitorService,
|
||||||
|
private val tradeHistoryRepository: TradeHistoryRepository,
|
||||||
|
private val telegramBotService: TelegramBotService, // [추가]
|
||||||
|
private val directLoginService: DirectLoginService,
|
||||||
|
private val globalEvv: GlobalEnvironment // [추가]
|
||||||
) {
|
) {
|
||||||
|
|
||||||
|
@GetMapping("/status")
|
||||||
|
fun getMarketStatus(): ResponseEntity<Map<String, Any>> {
|
||||||
|
val status = MarketTimeManager.getCurrentStatus()
|
||||||
|
|
||||||
|
return ResponseEntity.ok(mapOf(
|
||||||
|
"resultCode" to 0,
|
||||||
|
"status_code" to status.code,
|
||||||
|
"status_name" to status.label,
|
||||||
|
"is_tradeable" to MarketTimeManager.isTradeable() // 정규장 거래 가능 여부
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/auth")
|
||||||
|
suspend fun authenticate(
|
||||||
|
@RequestBody body: Map<String, String>,
|
||||||
|
session: HttpSession
|
||||||
|
): ResponseEntity<Map<String, Any>> {
|
||||||
|
val appKey = body["appKey"] ?: ""
|
||||||
|
val appSecret = body["appSecret"] ?: ""
|
||||||
|
val accountNo = body["accountNo"] ?: ""
|
||||||
|
|
||||||
|
if (appKey.isBlank() || appSecret.isBlank() || accountNo.isBlank()) {
|
||||||
|
return ResponseEntity.ok(mapOf("resultCode" to 400, "resultMsg" to "모든 정보를 입력해주세요."))
|
||||||
|
}
|
||||||
|
|
||||||
|
return try {
|
||||||
|
// 1. 토큰 발급 시도 (유효성 검증)
|
||||||
|
val config = KisConfigRequest(appKey, appSecret, accountNo)
|
||||||
|
val token = kisApiService.verifyAndGetToken(config).awaitSingle()
|
||||||
|
|
||||||
|
// 2. 세션에 저장 (이게 있어야 대시보드 접근 가능)
|
||||||
|
val authInfo = KisAuthSession(appKey, appSecret, accountNo, token)
|
||||||
|
session.setAttribute("KIS_AUTH", authInfo)
|
||||||
|
|
||||||
|
ResponseEntity.ok(mapOf("resultCode" to 0, "resultMsg" to "인증되었습니다."))
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
ResponseEntity.ok(mapOf("resultCode" to 500, "resultMsg" to "인증 실패: ${e.message}"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@PostMapping("/order")
|
@PostMapping("/order")
|
||||||
suspend fun placeOrder(
|
suspend fun placeOrder(
|
||||||
@RequestBody req: StockOrderRequest,
|
@RequestBody req: StockOrderRequest,
|
||||||
@ -64,51 +191,115 @@ class StockApiController(
|
|||||||
val auth = session.getAttribute("KIS_AUTH") as? KisAuthSession
|
val auth = session.getAttribute("KIS_AUTH") as? KisAuthSession
|
||||||
?: return ResponseEntity.ok(mapOf("resultCode" to 401, "resultMsg" to "인증 정보가 없습니다."))
|
?: return ResponseEntity.ok(mapOf("resultCode" to 401, "resultMsg" to "인증 정보가 없습니다."))
|
||||||
|
|
||||||
if (req.qty <= 0) {
|
// 종목명 조회 (기존 로직 유지)
|
||||||
return ResponseEntity.ok(mapOf("resultCode" to 400, "resultMsg" to "수량을 확인해주세요."))
|
var stockName = req.code
|
||||||
}
|
try {
|
||||||
|
val priceRes = kisMarketService.getCurrentPrice(req.code, auth).awaitSingle()
|
||||||
|
val output = priceRes["output"] as? Map<String, String>
|
||||||
|
stockName = output?.get("rprs_mrkt_kor_name") ?: req.code
|
||||||
|
} catch (e: Exception) {}
|
||||||
|
|
||||||
return try {
|
return try {
|
||||||
// 1. 주문 실행
|
// [핵심 변경] 전량 매도(isSellAll)일 경우 서버에서 잔고 조회 후 수량 결정
|
||||||
val response = kisApiService.orderStock(
|
var finalQty = req.qty
|
||||||
auth,
|
var finalPrice = req.price.toString()
|
||||||
req.type,
|
|
||||||
req.code,
|
|
||||||
req.qty.toString(),
|
|
||||||
req.price.toString()
|
|
||||||
).awaitSingle()
|
|
||||||
|
|
||||||
|
if (req.isSellAll) {
|
||||||
|
println(">>> [SellAll Debug] 잔고 조회 시작 (Code: ${req.code})")
|
||||||
|
|
||||||
|
// 1. 내 잔고 조회
|
||||||
|
val balanceRes = kisApiService.getAccountBalance(auth).awaitSingle()
|
||||||
|
val stocks = balanceRes["output1"] as? List<Map<String, Any>> ?: emptyList()
|
||||||
|
|
||||||
|
// 2. 해당 종목 보유 수량 찾기
|
||||||
|
// API마다 필드명이 다를 수 있으므로 확인 필요 (pdno: 상품번호)
|
||||||
|
val targetStock = stocks.find { it["pdno"] == req.code }
|
||||||
|
finalQty = targetStock?.get("hldg_qty")?.toString()?.toIntOrNull() ?: 0
|
||||||
|
|
||||||
|
println(">>> [SellAll Debug] 조회된 보유수량: $finalQty (Raw: $targetStock)")
|
||||||
|
|
||||||
|
// 3. 가격은 시장가("0")로 강제 설정
|
||||||
|
finalPrice = "0"
|
||||||
|
|
||||||
|
if (finalQty <= 0) {
|
||||||
|
println(">>> [SellAll Debug] 보유 수량 0이라 중단")
|
||||||
|
return ResponseEntity.ok(mapOf("resultCode" to 400, "resultMsg" to "보유 수량이 없습니다."))
|
||||||
|
}
|
||||||
|
delay(800)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 결정된 수량과 가격으로 주문 실행
|
||||||
|
val response = kisApiService.orderStock(auth, req.type, req.code, finalQty.toString(), finalPrice).awaitSingle()
|
||||||
val rtCd = response["rt_cd"] as? String ?: ""
|
val rtCd = response["rt_cd"] as? String ?: ""
|
||||||
|
|
||||||
if (rtCd == "0") {
|
if (rtCd == "0") {
|
||||||
val output = response["output"] as? Map<String, Any> ?: emptyMap()
|
val output = response["output"] as? Map<String, Any> ?: emptyMap()
|
||||||
val orderNo = output["ODNO"] as? String ?: "번호없음"
|
val orderNo = output["ODNO"] as? String ?: "번호없음"
|
||||||
var msg = "주문 전송 완료 (주문번호: $orderNo)"
|
|
||||||
|
|
||||||
// 2. [추가] 자동매매 등록 (매수 주문이고, 자동매매 체크 시)
|
// 히스토리 저장 등 후처리 (기존 코드 유지)
|
||||||
if (req.type == "BUY" && req.isAutoTrade && req.targetProfitRate > 0) {
|
val msgType = if(req.isSellAll) "🔥시장가 전량매도" else (if(req.type == "BUY") "매수" else "매도")
|
||||||
// suspend 함수 호출
|
stockMonitorService.saveHistory(req.code, stockName, req.type, 0.0, finalQty, orderNo, false, "주문완료")
|
||||||
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))
|
telegramBotService.sendTelegramMessage(
|
||||||
|
globalEvv.telegramMyId,
|
||||||
|
"[$msgType] 주문 성공\n종목: $stockName\n수량: ${finalQty}주"
|
||||||
|
)
|
||||||
|
|
||||||
|
ResponseEntity.ok(mapOf("resultCode" to 0, "resultMsg" to "주문 전송 완료 (주문번호: $orderNo)"))
|
||||||
} else {
|
} else {
|
||||||
val msg = response["msg1"] as? String ?: "주문 실패"
|
val msg = response["msg1"] as? String ?: "주문 실패"
|
||||||
ResponseEntity.ok(mapOf("resultCode" to 500, "resultMsg" to msg))
|
ResponseEntity.ok(mapOf("resultCode" to 500, "resultMsg" to msg))
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
e.printStackTrace()
|
e.printStackTrace()
|
||||||
ResponseEntity.ok(mapOf("resultCode" to 500, "resultMsg" to "주문 중 오류 발생: ${e.message}"))
|
ResponseEntity.ok(mapOf("resultCode" to 500, "resultMsg" to "오류: ${e.message}"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@PostMapping("/auto-trade/update-stoploss")
|
||||||
|
suspend fun updateStopLoss(@RequestBody body: Map<String, Any>): ResponseEntity<Map<String, Any>> {
|
||||||
|
val id = body["id"] as? String ?: return ResponseEntity.ok(mapOf("resultCode" to 400))
|
||||||
|
val rate = body["stopLossRate"].toString().toDoubleOrNull() ?: return ResponseEntity.ok(mapOf("resultCode" to 400))
|
||||||
|
|
||||||
|
val success = stockMonitorService.updateStopLossRate(id, rate)
|
||||||
|
return if(success) ResponseEntity.ok(mapOf("resultCode" to 0, "resultMsg" to "수정되었습니다."))
|
||||||
|
else ResponseEntity.ok(mapOf("resultCode" to 500, "resultMsg" to "실패"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// [신규] 자동매매 리스트 조회 API
|
||||||
|
@GetMapping("/auto-trade/list")
|
||||||
|
fun getAutoTradeList(): ResponseEntity<Map<String, Any>> {
|
||||||
|
val list = stockMonitorService.getAllTasks()
|
||||||
|
return ResponseEntity.ok(mapOf("resultCode" to 0, "data" to list))
|
||||||
|
}
|
||||||
|
|
||||||
|
// [신규] 자동매매 취소 API
|
||||||
|
@PostMapping("/auto-trade/cancel")
|
||||||
|
suspend fun cancelAutoTrade(@RequestBody body: Map<String, String>): ResponseEntity<Map<String, Any>> {
|
||||||
|
val id = body["id"] ?: return ResponseEntity.ok(mapOf("resultCode" to 400, "resultMsg" to "ID 없음"))
|
||||||
|
val success = stockMonitorService.cancelMonitoring(id)
|
||||||
|
return if(success) ResponseEntity.ok(mapOf("resultCode" to 0, "resultMsg" to "취소되었습니다."))
|
||||||
|
else ResponseEntity.ok(mapOf("resultCode" to 500, "resultMsg" to "찾을 수 없습니다."))
|
||||||
|
}
|
||||||
|
|
||||||
|
// [신규] 목표 수익률 수정 API
|
||||||
|
@PostMapping("/auto-trade/update")
|
||||||
|
suspend fun updateAutoTrade(@RequestBody body: Map<String, Any>): ResponseEntity<Map<String, Any>> {
|
||||||
|
val id = body["id"] as? String ?: return ResponseEntity.ok(mapOf("resultCode" to 400))
|
||||||
|
val rate = body["targetRate"].toString().toDoubleOrNull() ?: return ResponseEntity.ok(mapOf("resultCode" to 400))
|
||||||
|
|
||||||
|
val success = stockMonitorService.updateTargetRate(id, rate)
|
||||||
|
return if(success) ResponseEntity.ok(mapOf("resultCode" to 0, "resultMsg" to "수정되었습니다."))
|
||||||
|
else ResponseEntity.ok(mapOf("resultCode" to 500, "resultMsg" to "실패"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// [신규] 거래 내역 조회 API
|
||||||
|
@GetMapping("/history/list")
|
||||||
|
suspend fun getHistoryList(): ResponseEntity<Map<String, Any>> {
|
||||||
|
val list = tradeHistoryRepository.findAllByOrderByTimeDesc().collectList().awaitSingle()
|
||||||
|
return ResponseEntity.ok(mapOf("resultCode" to 0, "data" to list))
|
||||||
|
}
|
||||||
|
|
||||||
@PostMapping("/config")
|
@PostMapping("/config")
|
||||||
suspend fun saveConfig(
|
suspend fun saveConfig(
|
||||||
@RequestBody config: KisConfigRequest,
|
@RequestBody config: KisConfigRequest,
|
||||||
@ -206,7 +397,7 @@ class StockApiController(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val delayTime = 600L
|
||||||
|
|
||||||
@GetMapping("/details")
|
@GetMapping("/details")
|
||||||
suspend fun getStockDetails(
|
suspend fun getStockDetails(
|
||||||
@ -217,12 +408,15 @@ class StockApiController(
|
|||||||
?: return ResponseEntity.ok(mapOf("resultCode" to 401, "resultMsg" to "인증 정보가 없습니다."))
|
?: return ResponseEntity.ok(mapOf("resultCode" to 401, "resultMsg" to "인증 정보가 없습니다."))
|
||||||
|
|
||||||
val codeList = codes.split(",").map { it.trim() }.filter { it.isNotEmpty() }.take(3)
|
val codeList = codes.split(",").map { it.trim() }.filter { it.isNotEmpty() }.take(3)
|
||||||
if (codeList.isEmpty()) {
|
if (codeList.isEmpty()) return ResponseEntity.ok(mapOf("resultCode" to 400))
|
||||||
return ResponseEntity.ok(mapOf("resultCode" to 400, "resultMsg" to "유효한 종목 코드가 없습니다."))
|
|
||||||
}
|
|
||||||
val delayTime = 500L
|
|
||||||
return try {
|
return try {
|
||||||
val results = Flux.fromIterable(codeList)
|
// 1. [병렬 호출] 내 잔고 조회 & 자동매매 목록 조회
|
||||||
|
val balanceMono = kisApiService.getAccountBalance(auth)
|
||||||
|
val tasks = stockMonitorService.getAllTasks() // 현재 진행중인 자동매매 목록
|
||||||
|
|
||||||
|
// 2. 종목별 시세/차트 조회 (기존 로직)
|
||||||
|
val detailsFlux = Flux.fromIterable(codeList)
|
||||||
.concatMap { code ->
|
.concatMap { code ->
|
||||||
kisMarketService.getCurrentPrice(code, auth)
|
kisMarketService.getCurrentPrice(code, auth)
|
||||||
.delayElement(Duration.ofMillis(delayTime))
|
.delayElement(Duration.ofMillis(delayTime))
|
||||||
@ -233,15 +427,19 @@ class StockApiController(
|
|||||||
.delayElement(Duration.ofMillis(delayTime))
|
.delayElement(Duration.ofMillis(delayTime))
|
||||||
}
|
}
|
||||||
.collectList()
|
.collectList()
|
||||||
.awaitSingle()
|
|
||||||
|
|
||||||
val dataList = results.map { item ->
|
// 3. 데이터 합치기 (잔고 + 상세정보)
|
||||||
val (code, priceRes, chartRes) = item as Triple<String, Map<*, *>, Map<*, *>>
|
val (balanceRes, details) = Mono.zip(balanceMono, detailsFlux).awaitSingle()
|
||||||
|
|
||||||
|
// 잔고 데이터 파싱 (보유 종목 찾기용)
|
||||||
|
val myStocks = (balanceRes["output1"] as? List<Map<String, Any>>) ?: emptyList()
|
||||||
|
|
||||||
|
val dataList = details.map { item ->
|
||||||
|
val (code, priceRes, chartRes) = item
|
||||||
val output = priceRes["output"] as? Map<String, String> ?: emptyMap()
|
val output = priceRes["output"] as? Map<String, String> ?: emptyMap()
|
||||||
val chartOutput = chartRes["output2"] as? List<Map<String, String>> ?: emptyList()
|
val chartOutput = chartRes["output2"] as? List<Map<String, String>> ?: emptyList()
|
||||||
|
|
||||||
// 차트 데이터 (시간순 정렬)
|
// 차트 데이터 가공
|
||||||
val chartList = chartOutput.take(60).reversed().map { tick ->
|
val chartList = chartOutput.take(60).reversed().map { tick ->
|
||||||
mapOf(
|
mapOf(
|
||||||
"time" to (tick["stck_cntg_hour"]?.substring(0, 4) ?: ""),
|
"time" to (tick["stck_cntg_hour"]?.substring(0, 4) ?: ""),
|
||||||
@ -250,45 +448,32 @@ class StockApiController(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// [추가] 구간별 평균 거래량/거래대금 계산 함수
|
// [추가] 내 보유 정보 찾기
|
||||||
// chartOutput은 최신순(내림차순)으로 들어옵니다. (0번 인덱스가 가장 최근 1분)
|
// API마다 종목코드 필드명이 다를 수 있으므로 pdno(잔고)와 stck_shrn_iscd(현재가) 비교
|
||||||
fun calcAvg(minutes: Int): Map<String, Long> {
|
val myStock = myStocks.find { it["pdno"] == code }
|
||||||
val subset = chartOutput.take(minutes)
|
val myQty = myStock?.get("hldg_qty")?.toString()?.toIntOrNull() ?: 0
|
||||||
if (subset.isEmpty()) return mapOf("vol" to 0L, "amt" to 0L)
|
val myAvgPrice = myStock?.get("pchs_avg_pric")?.toString()?.toDoubleOrNull() ?: 0.0
|
||||||
|
val myProfitRate = myStock?.get("evlu_pfls_rt")?.toString()?.toDoubleOrNull() ?: 0.0
|
||||||
|
|
||||||
val sumVol = subset.sumOf { (it["cntg_vol"]?.toLongOrNull() ?: 0L) }
|
// [추가] 자동매매 진행 여부 확인
|
||||||
// 분당 거래대금 추정 = 체결가 * 거래량
|
val autoTradeTask = tasks.find { it.stockCode == code }
|
||||||
val sumAmt = subset.sumOf {
|
val isAutoActive = autoTradeTask != null
|
||||||
val p = it["stck_prpr"]?.toLongOrNull() ?: 0L
|
val targetRate = autoTradeTask?.targetProfitRate ?: 0.0
|
||||||
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(
|
mapOf(
|
||||||
"code" to code,
|
"code" to code,
|
||||||
"name" to (output["rprs_mrkt_kor_name"] ?: ""),
|
"name" to (output["rprs_mrkt_kor_name"] ?: ""),
|
||||||
"price" to (output["stck_prpr"] ?: "0"),
|
"price" to (output["stck_prpr"] ?: "0"),
|
||||||
"change" to (output["prdy_vrss"] ?: "0"),
|
|
||||||
"change_rate" to (output["prdy_ctrt"] ?: "0.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"),
|
"volume" to (output["acml_vol"] ?: "0"),
|
||||||
"chart" to chartList,
|
"chart" to chartList,
|
||||||
"averages" to averages // [추가] 평균 데이터 전달
|
// 내 보유 정보
|
||||||
|
"my_qty" to myQty,
|
||||||
|
"my_price" to myAvgPrice,
|
||||||
|
"my_profit_rate" to myProfitRate,
|
||||||
|
// 자동매매 정보
|
||||||
|
"is_auto_active" to isAutoActive,
|
||||||
|
"auto_target_rate" to targetRate
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -296,7 +481,7 @@ class StockApiController(
|
|||||||
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
e.printStackTrace()
|
e.printStackTrace()
|
||||||
ResponseEntity.ok(mapOf("resultCode" to 500, "resultMsg" to "상세 조회 실패: ${e.message}"))
|
ResponseEntity.ok(mapOf("resultCode" to 500, "resultMsg" to "조회 실패: ${e.message}"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -327,6 +512,7 @@ class StockApiController(
|
|||||||
val evalAmount = stock["evlu_amt"]?.toString()?.toLongOrNull() ?: (currentPrice * qty).toLong()
|
val evalAmount = stock["evlu_amt"]?.toString()?.toLongOrNull() ?: (currentPrice * qty).toLong()
|
||||||
|
|
||||||
mapOf(
|
mapOf(
|
||||||
|
"code" to (stock["pdno"]?.toString() ?: ""),
|
||||||
"name" to (stock["prdt_name"]?.toString() ?: ""),
|
"name" to (stock["prdt_name"]?.toString() ?: ""),
|
||||||
"qty" to qty,
|
"qty" to qty,
|
||||||
"buy_price" to buyPrice,
|
"buy_price" to buyPrice,
|
||||||
|
|||||||
@ -0,0 +1,68 @@
|
|||||||
|
package kr.lunaticbum.back.lun.controllers
|
||||||
|
|
||||||
|
import jakarta.servlet.http.Cookie
|
||||||
|
import jakarta.servlet.http.HttpServletRequest
|
||||||
|
import jakarta.servlet.http.HttpServletResponse
|
||||||
|
import kr.lunaticbum.back.lun.service.DirectLoginService
|
||||||
|
import org.springframework.http.ResponseEntity
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping
|
||||||
|
import org.springframework.web.bind.annotation.RestController
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/stock")
|
||||||
|
class StockLinkController(
|
||||||
|
private val directLoginService: DirectLoginService
|
||||||
|
) {
|
||||||
|
@PostMapping("/generate-link")
|
||||||
|
suspend fun generateLink(
|
||||||
|
@RequestBody body: Map<String, String>,
|
||||||
|
request: HttpServletRequest,
|
||||||
|
response: HttpServletResponse, // [추가] 쿠키 설정을 위해 필요
|
||||||
|
principal: java.security.Principal?
|
||||||
|
): ResponseEntity<Map<String, String>> {
|
||||||
|
return try {
|
||||||
|
if (principal == null) return ResponseEntity.ok(mapOf("resultCode" to "401", "resultMsg" to "로그인 필요"))
|
||||||
|
|
||||||
|
val ip = request.getHeader("X-Forwarded-For") ?: request.remoteAddr
|
||||||
|
val ua = request.getHeader("User-Agent") ?: ""
|
||||||
|
|
||||||
|
// 1. 기존 쿠키 확인 또는 새 Device ID 생성
|
||||||
|
var deviceId = request.cookies?.find { it.name == "LUN_DEVICE_ID" }?.value
|
||||||
|
if (deviceId.isNullOrBlank()) {
|
||||||
|
deviceId = UUID.randomUUID().toString()
|
||||||
|
|
||||||
|
// 2. 쿠키 설정 (30일 유지, HttpOnly 아님-JS접근가능해야 편함 or HttpOnly 권장)
|
||||||
|
val cookie = Cookie("LUN_DEVICE_ID", deviceId)
|
||||||
|
cookie.path = "/"
|
||||||
|
cookie.maxAge = 60 * 60 * 24 * 30 // 30일
|
||||||
|
cookie.isHttpOnly = true // 보안 강화 (JS 탈취 방지)
|
||||||
|
response.addCookie(cookie)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. DB에 토큰 + DeviceID 저장
|
||||||
|
val token = directLoginService.createToken(
|
||||||
|
key = body["key"] ?: "",
|
||||||
|
secret = body["secret"] ?: "",
|
||||||
|
acc = body["acc"] ?: "",
|
||||||
|
username = principal.name,
|
||||||
|
ip = ip,
|
||||||
|
ua = ua,
|
||||||
|
deviceId = deviceId
|
||||||
|
)
|
||||||
|
|
||||||
|
// ... URL 생성 로직 ...
|
||||||
|
val scheme = request.scheme
|
||||||
|
val serverName = request.serverName
|
||||||
|
val serverPort = request.serverPort
|
||||||
|
val portPart = if ((scheme == "http" && serverPort == 80) || (scheme == "https" && serverPort == 443)) "" else ":$serverPort"
|
||||||
|
val fullUrl = "$scheme://$serverName$portPart/stock/direct-login?token=$token"
|
||||||
|
|
||||||
|
ResponseEntity.ok(mapOf("resultCode" to "0", "url" to fullUrl))
|
||||||
|
} catch (e: Exception) {
|
||||||
|
ResponseEntity.ok(mapOf("resultCode" to "500", "resultMsg" to e.message.toString()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,26 +1,23 @@
|
|||||||
// src/main/kotlin/kr/lunaticbum/back/lun/model/AutoTradeEntity.kt
|
|
||||||
|
|
||||||
package kr.lunaticbum.back.lun.model
|
package kr.lunaticbum.back.lun.model
|
||||||
|
|
||||||
import org.springframework.data.annotation.Id
|
import org.springframework.data.annotation.Id
|
||||||
import org.springframework.data.mongodb.core.mapping.Document
|
import org.springframework.data.mongodb.core.mapping.Document
|
||||||
|
|
||||||
// [수정] JPA @Entity 대신 MongoDB @Document 사용
|
|
||||||
@Document(collection = "auto_trade_tasks")
|
@Document(collection = "auto_trade_tasks")
|
||||||
data class AutoTradeEntity(
|
data class AutoTradeEntity(
|
||||||
@Id
|
@Id
|
||||||
val id: String? = null, // MongoDB는 ID가 보통 String입니다.
|
val id: String? = null,
|
||||||
|
|
||||||
val stockCode: String,
|
val stockCode: String,
|
||||||
|
val stockName: String,
|
||||||
val buyPrice: Double,
|
val buyPrice: Double,
|
||||||
val quantity: Int,
|
val quantity: Int,
|
||||||
val targetProfitRate: Double,
|
|
||||||
|
|
||||||
// 인증 정보
|
var targetProfitRate: Double, // 익절 수익률 (예: 5.0)
|
||||||
|
var stopLossRate: Double, // [추가] 손절 수익률 (예: -3.0)
|
||||||
|
|
||||||
val appKey: String,
|
val appKey: String,
|
||||||
val appSecret: String,
|
val appSecret: String,
|
||||||
val accountNo: String,
|
val accountNo: String,
|
||||||
|
|
||||||
// 가변 변수 (토큰 갱신용)
|
|
||||||
var accessToken: String
|
var accessToken: String
|
||||||
)
|
)
|
||||||
@ -0,0 +1,25 @@
|
|||||||
|
// src/main/kotlin/kr/lunaticbum/back/lun/model/DirectLoginToken.kt
|
||||||
|
package kr.lunaticbum.back.lun.model
|
||||||
|
|
||||||
|
import org.springframework.data.annotation.Id
|
||||||
|
import org.springframework.data.mongodb.core.mapping.Document
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
|
||||||
|
@Document(collection = "direct_login_tokens")
|
||||||
|
data class DirectLoginToken(
|
||||||
|
@Id
|
||||||
|
val token: String,
|
||||||
|
|
||||||
|
val appKey: String,
|
||||||
|
val appSecret: String,
|
||||||
|
val accountNo: String,
|
||||||
|
val username: String,
|
||||||
|
|
||||||
|
// [보안 강화]
|
||||||
|
val deviceId: String, // 브라우저에 심어둔 쿠키 값
|
||||||
|
val clientIp: String, // 생성 당시 IP
|
||||||
|
val userAgent: String, // 생성 당시 기기 정보
|
||||||
|
|
||||||
|
val createdAt: LocalDateTime = LocalDateTime.now(),
|
||||||
|
val expiresAt: LocalDateTime = LocalDateTime.now().plusDays(30) // 30일 유효
|
||||||
|
)
|
||||||
@ -0,0 +1,21 @@
|
|||||||
|
// src/main/kotlin/kr/lunaticbum/back/lun/model/TradeHistoryEntity.kt
|
||||||
|
package kr.lunaticbum.back.lun.model
|
||||||
|
|
||||||
|
import org.springframework.data.annotation.Id
|
||||||
|
import org.springframework.data.mongodb.core.mapping.Document
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
|
||||||
|
@Document(collection = "trade_history")
|
||||||
|
data class TradeHistoryEntity(
|
||||||
|
@Id
|
||||||
|
val id: String? = null,
|
||||||
|
val time: LocalDateTime = LocalDateTime.now(), // 거래 시간
|
||||||
|
val stockCode: String,
|
||||||
|
val stockName: String,
|
||||||
|
val orderType: String, // "BUY" or "SELL"
|
||||||
|
val price: Double, // 주문 가격 (또는 체결가)
|
||||||
|
val quantity: Int,
|
||||||
|
val orderNo: String, // 주문 번호
|
||||||
|
val isAutoTrade: Boolean, // 자동매매 여부
|
||||||
|
val resultMsg: String // 결과 메시지
|
||||||
|
)
|
||||||
@ -187,7 +187,7 @@ class UserManager(
|
|||||||
}
|
}
|
||||||
// 사용자를 찾지 못하면 예외를 던지도록 수정
|
// 사용자를 찾지 못하면 예외를 던지도록 수정
|
||||||
val user = findById(username)
|
val user = findById(username)
|
||||||
.blockOptional(Duration.ofMillis(5000L))
|
.blockOptional(Duration.ofMillis(15000L))
|
||||||
.orElseThrow { UsernameNotFoundException("User not found: $username") }
|
.orElseThrow { UsernameNotFoundException("User not found: $username") }
|
||||||
|
|
||||||
val userRole = user.getRole().name // "READ", "WRITE", 또는 "ADMIN"
|
val userRole = user.getRole().name // "READ", "WRITE", 또는 "ADMIN"
|
||||||
|
|||||||
@ -0,0 +1,9 @@
|
|||||||
|
// src/main/kotlin/kr/lunaticbum/back/lun/repository/DirectLoginRepository.kt
|
||||||
|
package kr.lunaticbum.back.lun.repository
|
||||||
|
|
||||||
|
import kr.lunaticbum.back.lun.model.DirectLoginToken
|
||||||
|
import org.springframework.data.mongodb.repository.ReactiveMongoRepository
|
||||||
|
import org.springframework.stereotype.Repository
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
interface DirectLoginRepository : ReactiveMongoRepository<DirectLoginToken, String>
|
||||||
@ -0,0 +1,13 @@
|
|||||||
|
// src/main/kotlin/kr/lunaticbum/back/lun/repository/TradeHistoryRepository.kt
|
||||||
|
package kr.lunaticbum.back.lun.repository
|
||||||
|
|
||||||
|
import kr.lunaticbum.back.lun.model.TradeHistoryEntity
|
||||||
|
import org.springframework.data.mongodb.repository.ReactiveMongoRepository
|
||||||
|
import org.springframework.stereotype.Repository
|
||||||
|
import reactor.core.publisher.Flux
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
interface TradeHistoryRepository : ReactiveMongoRepository<TradeHistoryEntity, String> {
|
||||||
|
// 최신순 조회
|
||||||
|
fun findAllByOrderByTimeDesc(): Flux<TradeHistoryEntity>
|
||||||
|
}
|
||||||
@ -0,0 +1,58 @@
|
|||||||
|
package kr.lunaticbum.back.lun.service
|
||||||
|
|
||||||
|
import kotlinx.coroutines.reactor.awaitSingle
|
||||||
|
import kotlinx.coroutines.reactor.awaitSingleOrNull
|
||||||
|
import kr.lunaticbum.back.lun.model.DirectLoginToken
|
||||||
|
import kr.lunaticbum.back.lun.repository.DirectLoginRepository
|
||||||
|
import org.springframework.stereotype.Service
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
@Service
|
||||||
|
class DirectLoginService(
|
||||||
|
private val directLoginRepository: DirectLoginRepository
|
||||||
|
) {
|
||||||
|
// 1. 토큰 생성 (deviceId 추가)
|
||||||
|
suspend fun createToken(
|
||||||
|
key: String, secret: String, acc: String, username: String,
|
||||||
|
ip: String, ua: String, deviceId: String // [추가]
|
||||||
|
): String {
|
||||||
|
val token = UUID.randomUUID().toString().replace("-", "")
|
||||||
|
|
||||||
|
val entity = DirectLoginToken(
|
||||||
|
token = token,
|
||||||
|
appKey = key,
|
||||||
|
appSecret = secret,
|
||||||
|
accountNo = acc,
|
||||||
|
username = username,
|
||||||
|
clientIp = ip,
|
||||||
|
userAgent = ua,
|
||||||
|
deviceId = deviceId // 저장
|
||||||
|
)
|
||||||
|
|
||||||
|
directLoginRepository.save(entity).awaitSingle()
|
||||||
|
return token
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 토큰 검증 (쿠키 값 비교 추가)
|
||||||
|
suspend fun validateAndGet(token: String, currentIp: String, currentUa: String, cookieDeviceId: String?): DirectLoginToken {
|
||||||
|
val info = directLoginRepository.findById(token).awaitSingleOrNull()
|
||||||
|
?: throw Exception("존재하지 않는 링크입니다.")
|
||||||
|
|
||||||
|
// 1. 만료일 체크
|
||||||
|
if (info.expiresAt.isBefore(LocalDateTime.now())) {
|
||||||
|
throw Exception("만료된 링크입니다. 다시 생성해주세요.")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 쿠키(Device ID) 검증 [핵심 보안]
|
||||||
|
// 링크가 유출되어도 해커 PC에는 이 쿠키가 없으므로 절대 접속 불가
|
||||||
|
if (info.deviceId != cookieDeviceId) {
|
||||||
|
throw Exception("등록된 기기가 아닙니다. (링크를 생성한 브라우저에서만 접속 가능합니다)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. IP 검증 (선택: 모바일이라 IP가 자주 바뀌면 이 부분은 주석 처리하세요)
|
||||||
|
// if (info.clientIp != currentIp) throw Exception("IP가 변경되었습니다.")
|
||||||
|
|
||||||
|
return info
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -6,6 +6,8 @@ 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 org.springframework.web.reactive.function.client.WebClientResponseException
|
||||||
import reactor.core.publisher.Mono
|
import reactor.core.publisher.Mono
|
||||||
|
import java.time.LocalTime
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
|
||||||
private val REAL_URL = "https://openapi.koreainvestment.com:9443"
|
private val REAL_URL = "https://openapi.koreainvestment.com:9443"
|
||||||
private val MOCK_URL = "https://openapivts.koreainvestment.com:29443"
|
private val MOCK_URL = "https://openapivts.koreainvestment.com:29443"
|
||||||
@ -78,33 +80,40 @@ class KisApiService() {
|
|||||||
|
|
||||||
fun orderStock(
|
fun orderStock(
|
||||||
auth: KisAuthSession,
|
auth: KisAuthSession,
|
||||||
orderType: String, // "BUY" or "SELL"
|
orderType: String,
|
||||||
stockCode: String,
|
stockCode: String,
|
||||||
qty: String,
|
qty: String,
|
||||||
price: String
|
price: String
|
||||||
): Mono<Map<*, *>> {
|
): Mono<Map<*, *>> {
|
||||||
// 계좌번호 분리
|
// 1. 계좌번호 포맷팅 (하이픈 제거)
|
||||||
val cano = if (auth.accountNo.length >= 8) auth.accountNo.substring(0, 8) else auth.accountNo
|
val cleanAccount = auth.accountNo.replace("-", "").trim()
|
||||||
val prdt = if (auth.accountNo.length >= 10) auth.accountNo.substring(8, 10) else "01"
|
val cano = if (cleanAccount.length >= 8) cleanAccount.substring(0, 8) else cleanAccount
|
||||||
|
val prdt = if (cleanAccount.length >= 10) cleanAccount.substring(8, 10) else "01"
|
||||||
|
|
||||||
// TR_ID 결정 (중요!)
|
// 2. TR_ID 결정
|
||||||
// 실전: 매수(TTTC0802U), 매도(TTTC0801U)
|
|
||||||
// 모의: 매수(VTTC0802U), 매도(VTTC0801U)
|
|
||||||
val trId = if (isRealTrading) {
|
val trId = if (isRealTrading) {
|
||||||
if (orderType == "BUY") "TTTC0802U" else "TTTC0801U"
|
if (orderType == "BUY") "TTTC0802U" else "TTTC0801U"
|
||||||
} else {
|
} else {
|
||||||
if (orderType == "BUY") "VTTC0802U" else "VTTC0801U"
|
if (orderType == "BUY") "VTTC0802U" else "VTTC0801U"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 3. 주문 구분 (시장가: 01, 지정가: 00)
|
||||||
|
val ordDvsn = if (price == "0") "01" else "00"
|
||||||
|
|
||||||
|
// 4. 요청 바디 구성
|
||||||
val requestBody = mapOf(
|
val requestBody = mapOf(
|
||||||
"CANO" to cano,
|
"CANO" to cano,
|
||||||
"ACNT_PRDT_CD" to prdt,
|
"ACNT_PRDT_CD" to prdt,
|
||||||
"PDNO" to stockCode,
|
"PDNO" to stockCode,
|
||||||
"ORD_DVSN" to "00", // 00: 지정가 (가격을 직접 입력)
|
"ORD_DVSN" to ordDvsn,
|
||||||
"ORD_QTY" to qty,
|
"ORD_QTY" to qty,
|
||||||
"ORD_UNPR" to price // 0원이면 시장가로 하려면 ORD_DVSN을 01로 바꿔야 함
|
"ORD_UNPR" to price
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// [디버깅 로그 1] 내가 보내는 데이터 확인
|
||||||
|
println(">>> [KIS Order Debug] TR_ID: $trId")
|
||||||
|
println(">>> [KIS Order Debug] Request Body: $requestBody")
|
||||||
|
|
||||||
return webClientBuilder.baseUrl(getBaseUrl()).build()
|
return webClientBuilder.baseUrl(getBaseUrl()).build()
|
||||||
.post()
|
.post()
|
||||||
.uri("/uapi/domestic-stock/v1/trading/order-cash")
|
.uri("/uapi/domestic-stock/v1/trading/order-cash")
|
||||||
@ -115,6 +124,15 @@ class KisApiService() {
|
|||||||
.bodyValue(requestBody)
|
.bodyValue(requestBody)
|
||||||
.retrieve()
|
.retrieve()
|
||||||
.bodyToMono(Map::class.java)
|
.bodyToMono(Map::class.java)
|
||||||
|
// [디버깅 로그 2] 에러 발생 시 상세 응답 확인
|
||||||
|
.onErrorResume(WebClientResponseException::class.java) { ex ->
|
||||||
|
val errorBody = ex.responseBodyAsString
|
||||||
|
println(">>> [KIS API Error] Status: ${ex.statusCode}")
|
||||||
|
println(">>> [KIS API Error] Body: $errorBody")
|
||||||
|
|
||||||
|
// 에러 내용을 포함해서 상위로 던짐
|
||||||
|
Mono.error(Exception("KIS Error: $errorBody"))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// [추가] 웹소켓 접속키 발급 (1회 발급 후 계속 사용 가능하지만, 여기선 호출 시마다 받도록 구현)
|
// [추가] 웹소켓 접속키 발급 (1회 발급 후 계속 사용 가능하지만, 여기선 호출 시마다 받도록 구현)
|
||||||
@ -140,22 +158,48 @@ class KisApiService() {
|
|||||||
@Service
|
@Service
|
||||||
class KisMarketService() {
|
class KisMarketService() {
|
||||||
|
|
||||||
|
fun checkHoliday(auth: KisAuthSession, date: String): Mono<Map<*, *>> {
|
||||||
|
return webClient.get()
|
||||||
|
.uri { it.path("/uapi/domestic-stock/v1/quotations/chk-holiday")
|
||||||
|
.queryParam("BASS_DT", date) // 기준일자 (YYYYMMDD)
|
||||||
|
.queryParam("CTX_AREA_NK", "")
|
||||||
|
.queryParam("CTX_AREA_FK", "")
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
.header("authorization", "Bearer ${auth.accessToken}")
|
||||||
|
.header("appkey", auth.appKey)
|
||||||
|
.header("appsecret", auth.appSecret)
|
||||||
|
.header("tr_id", "CTCA0903R") // 휴장일 조회 TR ID
|
||||||
|
.header("custtype", "P")
|
||||||
|
.retrieve()
|
||||||
|
.bodyToMono(Map::class.java)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
fun getMinuteChart(symbol: String, auth: KisAuthSession): Mono<Map<*, *>> {
|
fun getMinuteChart(symbol: String, auth: KisAuthSession): Mono<Map<*, *>> {
|
||||||
|
// [핵심] 현재 시간을 HHmmss 포맷으로 구해서 파라미터로 넘겨야 합니다.
|
||||||
|
val now = LocalTime.now().format(DateTimeFormatter.ofPattern("HHmmss"))
|
||||||
|
|
||||||
return webClient.get()
|
return webClient.get()
|
||||||
.uri { it.path("/uapi/domestic-stock/v1/quotations/inquire-time-itemchartprice")
|
.uri { it.path("/uapi/domestic-stock/v1/quotations/inquire-time-itemchartprice")
|
||||||
.queryParam("FID_COND_MRKT_DIV_CODE", "J")
|
.queryParam("FID_COND_MRKT_DIV_CODE", "J")
|
||||||
.queryParam("FID_INPUT_ISCD", symbol)
|
.queryParam("FID_INPUT_ISCD", symbol)
|
||||||
.queryParam("FID_ETC_CLS_CODE", "")
|
.queryParam("FID_ETC_CLS_CODE", "")
|
||||||
.queryParam("FID_INPUT_HOUR_1", "") // 비워두면 최신 데이터
|
.queryParam("FID_INPUT_HOUR_1", now) // [수정] 빈 값("") -> 현재시간(now)
|
||||||
.queryParam("FID_PW_DATA_INCU_YN", "N")
|
.queryParam("FID_PW_DATA_INCU_YN", "N")
|
||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
.header("authorization", "Bearer ${auth.accessToken}")
|
.header("authorization", "Bearer ${auth.accessToken}")
|
||||||
.header("appkey", auth.appKey)
|
.header("appkey", auth.appKey)
|
||||||
.header("appsecret", auth.appSecret)
|
.header("appsecret", auth.appSecret)
|
||||||
.header("tr_id", "FHKST03010200") // 주식 분봉 조회 TR (실전/모의 동일)
|
.header("tr_id", "FHKST03010200") // 주식 분봉 조회 TR
|
||||||
.retrieve()
|
.retrieve()
|
||||||
.bodyToMono(Map::class.java)
|
.bodyToMono(Map::class.java)
|
||||||
|
.onErrorResume(WebClientResponseException::class.java) { ex ->
|
||||||
|
// 에러 디버깅을 위해 로그 출력 추가
|
||||||
|
println(">>> 차트 조회 실패: ${ex.responseBodyAsString}")
|
||||||
|
Mono.error(ex)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. 주식 현재가 시세 조회 (에러 디버깅 추가)
|
// 3. 주식 현재가 시세 조회 (에러 디버깅 추가)
|
||||||
@ -235,11 +279,17 @@ class KisMarketService() {
|
|||||||
.queryParam("FID_COND_SCR_DIV_CODE", "20170")
|
.queryParam("FID_COND_SCR_DIV_CODE", "20170")
|
||||||
.queryParam("FID_INPUT_ISCD", "0000") // 0000: 전체
|
.queryParam("FID_INPUT_ISCD", "0000") // 0000: 전체
|
||||||
.queryParam("FID_RANK_SORT_CLS_CODE", type) // 0: 상승, 1: 하락
|
.queryParam("FID_RANK_SORT_CLS_CODE", type) // 0: 상승, 1: 하락
|
||||||
|
|
||||||
|
// [▼▼▼ 필수 파라미터 추가 ▼▼▼]
|
||||||
|
.queryParam("FID_ORG_ADJ_PRC", "0") // 수정주가 반영 여부 (0:반영안함, 1:반영)
|
||||||
|
.queryParam("FID_LS_DIV_CLS_CODE", "00") // 순위 관리 구분 코드 (00: 기본)
|
||||||
|
// [▲▲▲ 추가 완료 ▲▲▲]
|
||||||
|
|
||||||
.queryParam("FID_INPUT_CNT_1", "0") // 입력 수
|
.queryParam("FID_INPUT_CNT_1", "0") // 입력 수
|
||||||
.queryParam("FID_PRC_CLS_CODE", "1") // [수정] 0:관련없음 -> 1:보통 (장 종료후에는 1이 더 안정적일 수 있음)
|
.queryParam("FID_PRC_CLS_CODE", "1") // 1: 보통
|
||||||
.queryParam("FID_INPUT_PRICE_1", "")
|
.queryParam("FID_INPUT_PRICE_1", "")
|
||||||
.queryParam("FID_INPUT_PRICE_2", "")
|
.queryParam("FID_INPUT_PRICE_2", "")
|
||||||
.queryParam("FID_VOL_CNT", "") // 거래량 조건 없애기 (비워두면 전체)
|
.queryParam("FID_VOL_CNT", "") // 거래량 조건
|
||||||
.queryParam("FID_TRGT_CLS_CODE", "11111111")
|
.queryParam("FID_TRGT_CLS_CODE", "11111111")
|
||||||
.queryParam("FID_TRGT_EXLS_CLS_CODE", "000000")
|
.queryParam("FID_TRGT_EXLS_CLS_CODE", "000000")
|
||||||
.build()
|
.build()
|
||||||
|
|||||||
@ -5,10 +5,16 @@ package kr.lunaticbum.back.lun.service
|
|||||||
import jakarta.annotation.PostConstruct
|
import jakarta.annotation.PostConstruct
|
||||||
import kotlinx.coroutines.reactor.awaitSingle
|
import kotlinx.coroutines.reactor.awaitSingle
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import kr.lunaticbum.back.lun.configs.core.GlobalEnvironment
|
||||||
import kr.lunaticbum.back.lun.model.AutoTradeEntity
|
import kr.lunaticbum.back.lun.model.AutoTradeEntity
|
||||||
import kr.lunaticbum.back.lun.model.KisAuthSession
|
import kr.lunaticbum.back.lun.model.KisAuthSession
|
||||||
import kr.lunaticbum.back.lun.model.KisConfigRequest
|
import kr.lunaticbum.back.lun.model.KisConfigRequest
|
||||||
|
import kr.lunaticbum.back.lun.model.TradeHistoryEntity
|
||||||
import kr.lunaticbum.back.lun.repository.AutoTradeRepository
|
import kr.lunaticbum.back.lun.repository.AutoTradeRepository
|
||||||
|
import kr.lunaticbum.back.lun.repository.TradeHistoryRepository
|
||||||
|
import kr.lunaticbum.back.lun.services.TelegramBotService
|
||||||
|
import kr.lunaticbum.back.lun.utils.MarketTimeManager
|
||||||
|
import org.springframework.scheduling.annotation.Scheduled
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
import java.util.concurrent.CopyOnWriteArrayList
|
import java.util.concurrent.CopyOnWriteArrayList
|
||||||
|
|
||||||
@ -16,11 +22,45 @@ import java.util.concurrent.CopyOnWriteArrayList
|
|||||||
class StockMonitorService(
|
class StockMonitorService(
|
||||||
private val kisMarketService: KisMarketService,
|
private val kisMarketService: KisMarketService,
|
||||||
private val kisApiService: KisApiService,
|
private val kisApiService: KisApiService,
|
||||||
private val autoTradeRepository: AutoTradeRepository
|
private val autoTradeRepository: AutoTradeRepository,
|
||||||
|
private val tradeHistoryRepository: TradeHistoryRepository,
|
||||||
|
private val telegramBotService: TelegramBotService,
|
||||||
|
private val globalEvv: GlobalEnvironment
|
||||||
) {
|
) {
|
||||||
// 메모리 캐시 (실시간 조회를 위해 사용)
|
// 메모리 캐시 (실시간 조회를 위해 사용)
|
||||||
private val monitoringList = CopyOnWriteArrayList<AutoTradeEntity>()
|
private val monitoringList = CopyOnWriteArrayList<AutoTradeEntity>()
|
||||||
|
|
||||||
|
private var lastStatus: MarketTimeManager.MarketStatus? = null
|
||||||
|
|
||||||
|
// [신규] 1분마다 시장 상태 변경 체크 및 알림 발송
|
||||||
|
// (매분 0초에 실행)
|
||||||
|
@Scheduled(cron = "0 * * * * *")
|
||||||
|
fun checkMarketStatusAndNotify() {
|
||||||
|
// 1. 현재 상태 조회
|
||||||
|
val currentStatus = MarketTimeManager.getCurrentStatus()
|
||||||
|
|
||||||
|
// 2. 상태가 변했는지 확인 (초기 실행이 아니고, 상태가 달라졌을 때만)
|
||||||
|
if (lastStatus != null && lastStatus != currentStatus) {
|
||||||
|
val msg = when (currentStatus) {
|
||||||
|
MarketTimeManager.MarketStatus.PRE_OPEN -> "🌅 [장전] 동시호가가 시작되었습니다. (08:30)"
|
||||||
|
MarketTimeManager.MarketStatus.OPEN -> "🔔 [개장] 정규장이 시작되었습니다! 성투하세요. (09:00)"
|
||||||
|
MarketTimeManager.MarketStatus.CLOSE_5M -> "📢 [마감임박] 장마감 동시호가 시간입니다. (15:20)"
|
||||||
|
MarketTimeManager.MarketStatus.AFTER_HOURS -> "🌙 [마감] 정규장이 종료되었습니다. (15:30)"
|
||||||
|
MarketTimeManager.MarketStatus.SINGLE_PRICE -> "🕓 [시간외] 시간외 단일가 매매가 시작되었습니다. (16:00)"
|
||||||
|
MarketTimeManager.MarketStatus.CLOSED -> "💤 [종료] 금일 장이 완전히 마감되었습니다. (18:00)"
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
|
||||||
|
// 메시지가 정의된 상태라면 텔레그램 발송
|
||||||
|
if (msg != null) {
|
||||||
|
sendAlert(msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 현재 상태를 '이전 상태'로 저장
|
||||||
|
lastStatus = currentStatus
|
||||||
|
}
|
||||||
|
|
||||||
// 1. 서버 시작 시 MongoDB에서 데이터 복구
|
// 1. 서버 시작 시 MongoDB에서 데이터 복구
|
||||||
@PostConstruct
|
@PostConstruct
|
||||||
fun init() {
|
fun init() {
|
||||||
@ -34,30 +74,93 @@ class StockMonitorService(
|
|||||||
|
|
||||||
// 2. 감시 대상 등록 (MongoDB 저장 + 메모리 추가)
|
// 2. 감시 대상 등록 (MongoDB 저장 + 메모리 추가)
|
||||||
// Controller에서 호출하므로 suspend 함수로 변경
|
// Controller에서 호출하므로 suspend 함수로 변경
|
||||||
suspend fun addMonitoring(auth: KisAuthSession, code: String, buyPrice: Double, qty: Int, targetRate: Double) {
|
suspend fun addMonitoring(
|
||||||
|
auth: KisAuthSession, code: String, name: String,
|
||||||
auth.accessToken?.let{accessToken ->
|
buyPrice: Double, qty: Int, targetRate: Double, stopLossRate: Double
|
||||||
|
) {
|
||||||
|
auth.accessToken?.let { accessToken ->
|
||||||
val entity = AutoTradeEntity(
|
val entity = AutoTradeEntity(
|
||||||
stockCode = code,
|
stockCode = code,
|
||||||
|
stockName = name,
|
||||||
buyPrice = buyPrice,
|
buyPrice = buyPrice,
|
||||||
quantity = qty,
|
quantity = qty,
|
||||||
targetProfitRate = targetRate,
|
targetProfitRate = targetRate,
|
||||||
|
stopLossRate = stopLossRate, // [저장]
|
||||||
appKey = auth.appKey,
|
appKey = auth.appKey,
|
||||||
appSecret = auth.appSecret,
|
appSecret = auth.appSecret,
|
||||||
accountNo = auth.accountNo,
|
accountNo = auth.accountNo,
|
||||||
accessToken = accessToken
|
accessToken = accessToken
|
||||||
)
|
)
|
||||||
|
|
||||||
// MongoDB 저장 (awaitSingle 사용)
|
|
||||||
val saved = autoTradeRepository.save(entity).awaitSingle()
|
val saved = autoTradeRepository.save(entity).awaitSingle()
|
||||||
monitoringList.add(saved)
|
monitoringList.add(saved)
|
||||||
println(">>> [자동매매 등록] $code / 목표: ${targetRate}% / DB 저장 완료")
|
|
||||||
|
|
||||||
|
// [알림] 기존 봇 서비스를 사용하여 내 ID로 메시지 전송
|
||||||
|
sendAlert("📡 [자동매매 등록]\n$name ($code)\n수량: ${qty}주\n목표: +$targetRate% / 손절: $stopLossRate%")
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// [신규] 손절 수익률 수정 기능
|
||||||
|
suspend fun updateStopLossRate(id: String, newRate: Double): Boolean {
|
||||||
|
val task = monitoringList.find { it.id == id } ?: return false
|
||||||
|
task.stopLossRate = newRate
|
||||||
|
autoTradeRepository.save(task).awaitSingle()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// [신규] 거래 히스토리 저장
|
||||||
|
suspend fun saveHistory(code: String, name: String, type: String, price: Double, qty: Int, orderNo: String, isAuto: Boolean, msg: String) {
|
||||||
|
val history = TradeHistoryEntity(
|
||||||
|
stockCode = code,
|
||||||
|
stockName = name,
|
||||||
|
orderType = type,
|
||||||
|
price = price,
|
||||||
|
quantity = qty,
|
||||||
|
orderNo = orderNo,
|
||||||
|
isAutoTrade = isAuto,
|
||||||
|
resultMsg = msg
|
||||||
|
)
|
||||||
|
tradeHistoryRepository.save(history).awaitSingle()
|
||||||
|
}
|
||||||
|
|
||||||
|
// [신규] 감시 작업 취소 (삭제)
|
||||||
|
suspend fun cancelMonitoring(id: String): Boolean {
|
||||||
|
val task = monitoringList.find { it.id == id }
|
||||||
|
if (task != null) {
|
||||||
|
monitoringList.remove(task)
|
||||||
|
autoTradeRepository.deleteById(id).awaitSingle()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// [신규] 목표 수익률 수정
|
||||||
|
suspend fun updateTargetRate(id: String, newRate: Double): Boolean {
|
||||||
|
val task = monitoringList.find { it.id == id }
|
||||||
|
if (task != null) {
|
||||||
|
// 메모리 업데이트
|
||||||
|
task.targetProfitRate = newRate
|
||||||
|
// DB 업데이트
|
||||||
|
autoTradeRepository.save(task).awaitSingle()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// [신규] 전체 감시 목록 조회 (화면 표시용)
|
||||||
|
fun getAllTasks(): List<AutoTradeEntity> {
|
||||||
|
return monitoringList.toList()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// 3. 주기적 실행 (Scheduler에서 호출)
|
// 3. 주기적 실행 (Scheduler에서 호출)
|
||||||
fun checkAndExecuteAutoSell() {
|
fun checkAndExecuteAutoSell() {
|
||||||
|
if (!MarketTimeManager.isTradeable()) {
|
||||||
|
// (선택사항) 장 마감 중에는 로그를 남기지 않거나, 디버깅용으로만 남김
|
||||||
|
// println(">>> 장 마감 시간입니다. 자동매매 스킵")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (monitoringList.isEmpty()) return
|
if (monitoringList.isEmpty()) return
|
||||||
|
|
||||||
// 스케줄러는 동기식이므로 runBlocking 블록 내에서 비동기 작업을 수행합니다.
|
// 스케줄러는 동기식이므로 runBlocking 블록 내에서 비동기 작업을 수행합니다.
|
||||||
@ -84,7 +187,6 @@ class StockMonitorService(
|
|||||||
// 시세 확인 및 매도 로직
|
// 시세 확인 및 매도 로직
|
||||||
private suspend fun checkPriceAndTrade(task: AutoTradeEntity) {
|
private suspend fun checkPriceAndTrade(task: AutoTradeEntity) {
|
||||||
val tempAuth = KisAuthSession(task.appKey, task.appSecret, task.accountNo, task.accessToken)
|
val tempAuth = KisAuthSession(task.appKey, task.appSecret, task.accountNo, task.accessToken)
|
||||||
|
|
||||||
val response = kisMarketService.getCurrentPrice(task.stockCode, tempAuth).awaitSingle()
|
val response = kisMarketService.getCurrentPrice(task.stockCode, tempAuth).awaitSingle()
|
||||||
val output = response["output"] as? Map<String, String>
|
val output = response["output"] as? Map<String, String>
|
||||||
val currentPrice = output?.get("stck_prpr")?.toDoubleOrNull() ?: 0.0
|
val currentPrice = output?.get("stck_prpr")?.toDoubleOrNull() ?: 0.0
|
||||||
@ -92,18 +194,48 @@ class StockMonitorService(
|
|||||||
if (currentPrice > 0) {
|
if (currentPrice > 0) {
|
||||||
val currentRate = ((currentPrice - task.buyPrice) / task.buyPrice) * 100
|
val currentRate = ((currentPrice - task.buyPrice) / task.buyPrice) * 100
|
||||||
|
|
||||||
|
// 1. 익절 조건
|
||||||
if (currentRate >= task.targetProfitRate) {
|
if (currentRate >= task.targetProfitRate) {
|
||||||
println(">>> [조건 달성] ${task.stockCode} 수익률 ${String.format("%.2f", currentRate)}% -> 매도 실행")
|
executeSell(task, tempAuth, currentPrice, 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} 작업 삭제됨")
|
|
||||||
}
|
}
|
||||||
|
// 2. [추가] 손절 조건 (예: -5.0 <= -3.0 이면 매도)
|
||||||
|
else if (currentRate <= task.stopLossRate) {
|
||||||
|
executeSell(task, tempAuth, currentPrice, currentRate, "💧 자동손절")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun executeSell(task: AutoTradeEntity, auth: KisAuthSession, price: Double, rate: Double, typeMsg: String) {
|
||||||
|
// 매도 주문
|
||||||
|
val res = kisApiService.orderStock(auth, "SELL", task.stockCode, task.quantity.toString(), "0").awaitSingle()
|
||||||
|
val output = res["output"] as? Map<String, Any> ?: emptyMap()
|
||||||
|
val orderNo = output["ODNO"] as? String ?: "Unknown"
|
||||||
|
|
||||||
|
val msg = "$typeMsg (수익률 ${String.format("%.2f", rate)}%)"
|
||||||
|
|
||||||
|
// 히스토리 저장
|
||||||
|
saveHistory(task.stockCode, task.stockName, "SELL", price, task.quantity, orderNo, true, msg)
|
||||||
|
|
||||||
|
// [알림] 텔레그램 전송
|
||||||
|
sendAlert(
|
||||||
|
"$typeMsg 실행 완료!\n" +
|
||||||
|
"종목: ${task.stockName}\n" +
|
||||||
|
"수익률: ${String.format("%.2f", rate)}%\n" +
|
||||||
|
"체결가: ${price.toInt()}원"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 목록 제거
|
||||||
|
monitoringList.remove(task)
|
||||||
|
autoTradeRepository.delete(task).awaitSingle()
|
||||||
|
}
|
||||||
|
|
||||||
|
// [도구] 알림 전송 헬퍼
|
||||||
|
private fun sendAlert(msg: String) {
|
||||||
|
try {
|
||||||
|
// globalEvv.telegramMyId를 사용하여 나에게 메시지 전송
|
||||||
|
telegramBotService.sendTelegramMessage(globalEvv.telegramMyId, msg)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
println("텔레그램 전송 실패: ${e.message}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -0,0 +1,48 @@
|
|||||||
|
package kr.lunaticbum.back.lun.utils
|
||||||
|
|
||||||
|
import java.time.DayOfWeek
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
import java.time.LocalTime
|
||||||
|
import java.time.ZoneId
|
||||||
|
|
||||||
|
object MarketTimeManager {
|
||||||
|
// 한국 시간대
|
||||||
|
private val KST = ZoneId.of("Asia/Seoul")
|
||||||
|
|
||||||
|
enum class MarketStatus(val label: String, val code: String) {
|
||||||
|
PRE_OPEN("장전 동시호가", "PRE"), // 08:30 ~ 09:00
|
||||||
|
OPEN("정규장", "OPEN"), // 09:00 ~ 15:20
|
||||||
|
CLOSE_5M("장마감 동시호가", "CLOSE_READY"), // 15:20 ~ 15:30
|
||||||
|
AFTER_HOURS("시간외 종가", "AFTER"), // 15:30 ~ 16:00
|
||||||
|
SINGLE_PRICE("시간외 단일가", "SINGLE"), // 16:00 ~ 18:00
|
||||||
|
CLOSED("장 마감", "CLOSED"), // 그 외 시간
|
||||||
|
HOLIDAY("휴장일", "HOLIDAY") // 주말/공휴일
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getCurrentStatus(): MarketStatus {
|
||||||
|
val now = LocalDateTime.now(KST)
|
||||||
|
val time = now.toLocalTime()
|
||||||
|
val day = now.dayOfWeek
|
||||||
|
|
||||||
|
// 1. 주말 체크 (토, 일)
|
||||||
|
if (day == DayOfWeek.SATURDAY || day == DayOfWeek.SUNDAY) {
|
||||||
|
return MarketStatus.HOLIDAY
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 시간대별 상태 체크
|
||||||
|
return when {
|
||||||
|
time.isBefore(LocalTime.of(8, 30)) -> MarketStatus.CLOSED
|
||||||
|
time.isBefore(LocalTime.of(9, 0)) -> MarketStatus.PRE_OPEN
|
||||||
|
time.isBefore(LocalTime.of(15, 20)) -> MarketStatus.OPEN
|
||||||
|
time.isBefore(LocalTime.of(15, 30)) -> MarketStatus.CLOSE_5M
|
||||||
|
time.isBefore(LocalTime.of(16, 0)) -> MarketStatus.AFTER_HOURS
|
||||||
|
time.isBefore(LocalTime.of(18, 0)) -> MarketStatus.SINGLE_PRICE
|
||||||
|
else -> MarketStatus.CLOSED
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 현재 정규장 거래가 가능한지 여부
|
||||||
|
fun isTradeable(): Boolean {
|
||||||
|
return getCurrentStatus() == MarketStatus.OPEN
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -19,7 +19,7 @@ export let Api = {
|
|||||||
const targetUrl = url.startsWith('/') ? url : '/' + url;
|
const targetUrl = url.startsWith('/') ? url : '/' + url;
|
||||||
|
|
||||||
const defaultHeaders = { 'X-CSRF-TOKEN': this.getCsrfToken() };
|
const defaultHeaders = { 'X-CSRF-TOKEN': this.getCsrfToken() };
|
||||||
const config = { method, headers: { ...defaultHeaders, ...headers } };
|
const config = { method, headers: { ...defaultHeaders, ...headers },credentials: 'include' };
|
||||||
|
|
||||||
if (body) {
|
if (body) {
|
||||||
if (body instanceof FormData) config.body = body;
|
if (body instanceof FormData) config.body = body;
|
||||||
|
|||||||
284
src/main/resources/static/js/modules/stock.js
Normal file
284
src/main/resources/static/js/modules/stock.js
Normal file
@ -0,0 +1,284 @@
|
|||||||
|
import { Api } from '/js/modules/api.js';
|
||||||
|
|
||||||
|
export const StockUtils = {
|
||||||
|
// [설정] 웹소켓 URL (모의투자용)
|
||||||
|
WS_URL: "ws://ops.koreainvestment.com:31000",
|
||||||
|
ws: null,
|
||||||
|
|
||||||
|
// 1. 숫자/금액 포맷팅
|
||||||
|
format: {
|
||||||
|
number: (num) => parseInt(num || 0).toLocaleString(),
|
||||||
|
money: (num) => {
|
||||||
|
if (num >= 100000000) return (num / 100000000).toFixed(1) + '억';
|
||||||
|
if (num >= 10000) return (num / 10000).toFixed(0) + '만';
|
||||||
|
return parseInt(num).toLocaleString();
|
||||||
|
},
|
||||||
|
color: (val) => val > 0 ? '#e03e2d' : (val < 0 ? '#0e62cf' : '#333'),
|
||||||
|
icon: (val) => val > 0 ? '▲' : (val < 0 ? '▼' : '-')
|
||||||
|
},
|
||||||
|
|
||||||
|
// 2. 차트 그리기 (Chart.js 래퍼)
|
||||||
|
drawChart: (canvasId, chartData, isRising) => {
|
||||||
|
const canvas = document.getElementById(canvasId);
|
||||||
|
if (!canvas || !chartData || chartData.length === 0) return;
|
||||||
|
|
||||||
|
// 기존 차트 파괴 (메모리 누수 방지)
|
||||||
|
const existingChart = Chart.getChart(canvasId);
|
||||||
|
if (existingChart) existingChart.destroy();
|
||||||
|
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
const labels = chartData.map(d => d.time.substring(0, 2) + ":" + d.time.substring(2, 4));
|
||||||
|
const prices = chartData.map(d => parseInt(d.price));
|
||||||
|
const volumes = chartData.map(d => parseInt(d.volume));
|
||||||
|
|
||||||
|
const mainColor = isRising ? '#e03e2d' : '#0e62cf';
|
||||||
|
const gradient = ctx.createLinearGradient(0, 0, 0, 200);
|
||||||
|
gradient.addColorStop(0, isRising ? 'rgba(224, 62, 45, 0.2)' : 'rgba(14, 98, 207, 0.2)');
|
||||||
|
gradient.addColorStop(1, 'rgba(255, 255, 255, 0)');
|
||||||
|
|
||||||
|
new Chart(ctx, {
|
||||||
|
type: 'line',
|
||||||
|
data: {
|
||||||
|
labels: labels,
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: '주가', data: prices, borderColor: mainColor, backgroundColor: gradient,
|
||||||
|
borderWidth: 2, pointRadius: 0, pointHoverRadius: 4, fill: true, yAxisID: 'y', tension: 0.1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '거래량', data: volumes, type: 'bar', backgroundColor: 'rgba(200, 200, 200, 0.3)',
|
||||||
|
yAxisID: 'y1', barPercentage: 0.6
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true, maintainAspectRatio: false,
|
||||||
|
interaction: { mode: 'index', intersect: false },
|
||||||
|
plugins: { legend: { display: false } },
|
||||||
|
scales: {
|
||||||
|
x: { display: false },
|
||||||
|
y: { type: 'linear', display: true, position: 'right', grid: { color: '#f0f0f0' } },
|
||||||
|
y1: { type: 'linear', display: false, position: 'left', min: 0, max: Math.max(...volumes) * 5 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// 3. 웹소켓 관리
|
||||||
|
wsManager: {
|
||||||
|
socket: null,
|
||||||
|
async connect(code, onMessage) {
|
||||||
|
this.disconnect();
|
||||||
|
try {
|
||||||
|
// 접속키 발급
|
||||||
|
const res = await Api.request('/api/stock/ws-key');
|
||||||
|
if (res.resultCode !== 0) throw new Error(res.resultMsg);
|
||||||
|
|
||||||
|
this.socket = new WebSocket(StockUtils.WS_URL);
|
||||||
|
this.socket.onopen = () => {
|
||||||
|
console.log("[WS] Connected");
|
||||||
|
this.socket.send(JSON.stringify({
|
||||||
|
header: { approval_key: res.approval_key, custtype: "P", tr_type: "1", "content-type": "utf-8" },
|
||||||
|
body: { input: { tr_id: "H0STCNT0", tr_key: code } }
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
this.socket.onmessage = (evt) => {
|
||||||
|
if (evt.data[0] !== '{') { // 시스템 메시지 제외
|
||||||
|
const parts = evt.data.split('|');
|
||||||
|
if (parts.length >= 4) {
|
||||||
|
const fields = parts[3].split('^');
|
||||||
|
onMessage({ price: parseInt(fields[2]), rate: parseFloat(fields[4]) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
alert("실시간 연결 실패");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
disconnect() {
|
||||||
|
if (this.socket) {
|
||||||
|
this.socket.close();
|
||||||
|
this.socket = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 4. 주문 API 호출
|
||||||
|
async placeOrder(params) {
|
||||||
|
// params: { code, type, qty, price, isAuto, targetRate, stopLoss }
|
||||||
|
if (!params.price || !params.qty || parseInt(params.qty) <= 0) {
|
||||||
|
alert("가격과 수량을 확인해주세요.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const typeName = params.type === 'BUY' ? "매수" : "매도";
|
||||||
|
let msg = `${typeName} 주문을 전송하시겠습니까?\n\n종목: ${params.code}\n수량: ${params.qty}주\n가격: ${params.price == 0 ? '시장가' : StockUtils.format.number(params.price) + '원'}`;
|
||||||
|
|
||||||
|
if (params.type === 'BUY' && params.isAuto) {
|
||||||
|
msg += `\n\n[⚡자동매매]\n익절: +${params.targetRate}%\n손절: ${params.stopLoss}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!confirm(msg)) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await Api.request('/api/stock/order', 'POST', {
|
||||||
|
code: params.code,
|
||||||
|
type: params.type,
|
||||||
|
qty: parseInt(params.qty),
|
||||||
|
price: parseInt(params.price),
|
||||||
|
isAutoTrade: params.isAuto || false,
|
||||||
|
targetProfitRate: parseFloat(params.targetRate || 0),
|
||||||
|
stopLossRate: parseFloat(params.stopLoss || 0)
|
||||||
|
});
|
||||||
|
alert(res.resultCode === 0 ? res.resultMsg : "주문 실패: " + res.resultMsg);
|
||||||
|
if(res.resultCode === 0 && params.price == 0) location.reload(); // 시장가 주문 시 새로고침
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
alert("통신 오류");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 5. 수량 자동 계산기
|
||||||
|
calcQty(amountInputId, priceInputId, targetQtyId) {
|
||||||
|
const amount = parseInt(document.getElementById(amountInputId).value.replace(/,/g, '') || 0);
|
||||||
|
const price = parseInt(document.getElementById(priceInputId).value.replace(/,/g, '') || 0);
|
||||||
|
if (price > 0 && amount > 0) {
|
||||||
|
document.getElementById(targetQtyId).value = Math.floor(amount / price);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
security: {
|
||||||
|
// 보안 바로가기 링크 생성
|
||||||
|
async generateLink(inputs) {
|
||||||
|
// inputs: { key, secret, acc } -> id, pw 불필요
|
||||||
|
const { key, secret, acc } = inputs;
|
||||||
|
|
||||||
|
if (!key || !secret || !acc) {
|
||||||
|
alert("API 키와 계좌번호는 필수입니다.");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 서버에 요청하면 서버가 알아서 현재 로그인된 ID를 사용함
|
||||||
|
const res = await Api.request('/api/stock/generate-link', 'POST', {
|
||||||
|
key, secret, acc
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.resultCode === "0") {
|
||||||
|
return res.url;
|
||||||
|
} else if (res.resultCode === "401") {
|
||||||
|
alert("로그인이 필요합니다.");
|
||||||
|
return null;
|
||||||
|
} else {
|
||||||
|
alert("생성 실패: " + res.resultMsg);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
alert("통신 오류가 발생했습니다.");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 클립보드 복사
|
||||||
|
copyToClipboard(elementId) {
|
||||||
|
const el = document.getElementById(elementId);
|
||||||
|
if (!el || !el.value) {
|
||||||
|
alert("복사할 내용이 없습니다.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
el.select();
|
||||||
|
// 최신 브라우저 지원 시 clipboard API 사용, 아니면 execCommand
|
||||||
|
if (navigator.clipboard) {
|
||||||
|
navigator.clipboard.writeText(el.value)
|
||||||
|
.then(() => alert("복사되었습니다!"))
|
||||||
|
.catch(() => document.execCommand("copy"));
|
||||||
|
} else {
|
||||||
|
document.execCommand("copy");
|
||||||
|
alert("복사되었습니다!");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async connect(inputs) {
|
||||||
|
const { key, secret, acc } = inputs;
|
||||||
|
if (!key || !secret || !acc) {
|
||||||
|
alert("App Key, Secret Key, 계좌번호를 모두 입력해주세요.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. 인증 요청
|
||||||
|
const res = await Api.request('/api/stock/auth', 'POST', {
|
||||||
|
appKey: key,
|
||||||
|
appSecret: secret,
|
||||||
|
accountNo: acc
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.resultCode === 0) {
|
||||||
|
// 2. 인증 성공 시 사용자에게 선택권 부여
|
||||||
|
const wantLink = confirm(
|
||||||
|
"✅ 인증에 성공했습니다!\n\n" +
|
||||||
|
"매번 로그인할 필요 없는 '원터치 접속 링크'를 생성하고 복사하시겠습니까?\n\n" +
|
||||||
|
"[확인] 링크 생성 및 복사 후 대시보드로 이동\n" +
|
||||||
|
"[취소] 바로 대시보드로 이동"
|
||||||
|
);
|
||||||
|
|
||||||
|
if (wantLink) {
|
||||||
|
// 링크 생성 시도
|
||||||
|
const url = await StockUtils.security.generateLink(inputs);
|
||||||
|
|
||||||
|
if (url) {
|
||||||
|
try {
|
||||||
|
// 클립보드에 자동 복사
|
||||||
|
await navigator.clipboard.writeText(url);
|
||||||
|
alert(
|
||||||
|
"📋 링크가 클립보드에 복사되었습니다!\n\n" +
|
||||||
|
"브라우저 즐겨찾기나 메모장에 붙여넣기(Ctrl+V) 하세요.\n\n" +
|
||||||
|
"이제 대시보드로 이동합니다."
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
// 자동 복사 실패 시 (보안상 막힌 경우) 직접 복사 유도
|
||||||
|
prompt("자동 복사에 실패했습니다. 아래 링크를 직접 복사하세요.", url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 대시보드로 이동
|
||||||
|
location.href = "/stock/dashboard";
|
||||||
|
|
||||||
|
} else {
|
||||||
|
alert("인증 실패: " + res.resultMsg);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
alert("서버 통신 오류가 발생했습니다.");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async sellAll(code) {
|
||||||
|
if (!confirm(`🚨 해당 종목(${code})을 현재 시장가로 전량 매도하시겠습니까?\n\n서버 잔고 기준으로 모든 수량을 즉시 처분합니다.`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 가격/수량은 0으로 보내고 isSellAll 플래그만 true로 설정
|
||||||
|
const res = await Api.request('/api/stock/order', 'POST', {
|
||||||
|
code: code,
|
||||||
|
type: 'SELL',
|
||||||
|
isSellAll: true, // [핵심]
|
||||||
|
qty: 0,
|
||||||
|
price: 0
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.resultCode === 0) {
|
||||||
|
alert("✅ 전량 매도 주문이 접수되었습니다.\n" + res.resultMsg);
|
||||||
|
location.reload(); // 잔고 갱신을 위해 새로고침
|
||||||
|
} else {
|
||||||
|
alert("주문 실패: " + res.resultMsg);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
alert("통신 오류가 발생했습니다.");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
108
src/main/resources/templates/content/stock/auto_trade.html
Normal file
108
src/main/resources/templates/content/stock/auto_trade.html
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html xmlns:th="http://www.thymeleaf.org"
|
||||||
|
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
|
||||||
|
layout:decorate="~{layout/default_layout}">
|
||||||
|
|
||||||
|
<section layout:fragment="content">
|
||||||
|
<div class="container">
|
||||||
|
<header class="major">
|
||||||
|
<h2>자동매매 관리</h2>
|
||||||
|
<p>익절/손절 감시 목록 수정 및 취소</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="table-wrapper">
|
||||||
|
<table class="alt">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>종목</th>
|
||||||
|
<th class="text-right">매수가</th>
|
||||||
|
<th class="text-center">익절 %</th>
|
||||||
|
<th class="text-center">손절 %</th>
|
||||||
|
<th class="text-center">관리</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="task_list">
|
||||||
|
<tr><td colspan="5" class="text-center">로딩 중...</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="col-12"><a href="/stock/dashboard" class="button">대시보드</a></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script type="module">
|
||||||
|
import { Api } from '/js/modules/api.js';
|
||||||
|
|
||||||
|
window.addEventListener('DOMContentLoaded', loadTasks);
|
||||||
|
|
||||||
|
// 목록 조회
|
||||||
|
async function loadTasks() {
|
||||||
|
try {
|
||||||
|
const res = await Api.request('/api/stock/auto-trade/list');
|
||||||
|
const tbody = document.getElementById('task_list');
|
||||||
|
tbody.innerHTML = '';
|
||||||
|
|
||||||
|
if (!res.data || res.data.length === 0) {
|
||||||
|
tbody.innerHTML = '<tr><td colspan="5" class="text-center">진행 중인 작업이 없습니다.</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.data.forEach(task => {
|
||||||
|
const html = `
|
||||||
|
<tr>
|
||||||
|
<td><strong>${task.stockName}</strong><br><span style="font-size:0.8em; color:#888;">${task.stockCode}</span></td>
|
||||||
|
<td class="text-right">${parseInt(task.buyPrice).toLocaleString()}</td>
|
||||||
|
|
||||||
|
<td class="text-center">
|
||||||
|
<input type="number" value="${task.targetProfitRate}" step="0.1" style="width:60px; text-align:center; display:inline;" class="inp-rate" data-id="${task.id}" data-type="profit">
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td class="text-center">
|
||||||
|
<input type="number" value="${task.stopLossRate || -3.0}" step="0.1" style="width:60px; text-align:center; color:#0e62cf; display:inline;" class="inp-rate" data-id="${task.id}" data-type="loss">
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td class="text-center">
|
||||||
|
<button class="button small primary btn-cancel" data-id="${task.id}">중단</button>
|
||||||
|
</td>
|
||||||
|
</tr>`;
|
||||||
|
tbody.insertAdjacentHTML('beforeend', html);
|
||||||
|
});
|
||||||
|
|
||||||
|
bindEvents();
|
||||||
|
} catch (e) { console.error(e); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindEvents() {
|
||||||
|
// 수정 이벤트 (Enter키 혹은 포커스 아웃 시 저장)
|
||||||
|
document.querySelectorAll('.inp-rate').forEach(inp => {
|
||||||
|
inp.addEventListener('change', async (e) => {
|
||||||
|
const id = e.target.dataset.id;
|
||||||
|
const type = e.target.dataset.type;
|
||||||
|
const val = e.target.value;
|
||||||
|
|
||||||
|
const url = type === 'profit' ? '/api/stock/auto-trade/update' : '/api/stock/auto-trade/update-stoploss';
|
||||||
|
const payload = type === 'profit' ? { id: id, targetRate: val } : { id: id, stopLossRate: val };
|
||||||
|
|
||||||
|
if(confirm(`${type === 'profit' ? '익절' : '손절'} 목표를 ${val}%로 변경하시겠습니까?`)) {
|
||||||
|
const res = await Api.request(url, 'POST', payload);
|
||||||
|
alert(res.resultMsg);
|
||||||
|
} else {
|
||||||
|
// 취소 시 새로고침 (값 원복)
|
||||||
|
loadTasks();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 취소 버튼
|
||||||
|
document.querySelectorAll('.btn-cancel').forEach(btn => {
|
||||||
|
btn.addEventListener('click', async (e) => {
|
||||||
|
if(confirm("정말로 자동매매를 중단하시겠습니까?")) {
|
||||||
|
const res = await Api.request('/api/stock/auto-trade/cancel', 'POST', { id: e.target.dataset.id });
|
||||||
|
alert(res.resultMsg);
|
||||||
|
loadTasks();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</section>
|
||||||
|
</html>
|
||||||
@ -2,53 +2,106 @@
|
|||||||
<html xmlns:th="http://www.thymeleaf.org"
|
<html xmlns:th="http://www.thymeleaf.org"
|
||||||
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
|
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
|
||||||
layout:decorate="~{layout/default_layout}">
|
layout:decorate="~{layout/default_layout}">
|
||||||
|
|
||||||
<section layout:fragment="content">
|
<section layout:fragment="content">
|
||||||
<div class="container" style="max-width: 800px; margin: 50px auto;">
|
<div class="container">
|
||||||
<header class="major">
|
<header class="major">
|
||||||
<h2>한국투자증권 API 설정</h2>
|
<h2>API 설정</h2>
|
||||||
<p>보안을 위해 입력하신 키는 DB에 저장되지 않으며, 세션 종료 시 즉시 파기됩니다.</p>
|
<p>한국투자증권 API 접속 정보 및 바로가기 설정</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<form id="kisConfigForm" class="box">
|
<div class="box">
|
||||||
|
<h3>🔑 API 인증 정보 입력</h3>
|
||||||
|
<p style="font-size: 0.9em; color:#666;">발급받은 App Key와 Secret Key를 입력하세요.</p>
|
||||||
|
|
||||||
|
<form method="post" action="#" onsubmit="return false;">
|
||||||
|
<div class="row gtr-uniform">
|
||||||
|
<div class="col-12">
|
||||||
|
<label for="appKey">App Key</label>
|
||||||
|
<input type="text" name="appKey" id="appKey" placeholder="App Key 입력" />
|
||||||
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<label for="appSecret">App Secret</label>
|
||||||
|
<input type="password" name="appSecret" id="appSecret" placeholder="Secret Key 입력" />
|
||||||
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<label for="accountNo">계좌번호 (8자리+2자리)</label>
|
||||||
|
<input type="text" name="accountNo" id="accountNo" placeholder="12345678-01" />
|
||||||
|
</div>
|
||||||
|
<div class="col-12" style="margin-top: 1.5em;">
|
||||||
|
<button class="button primary fit" id="btn_connect">API 연결 및 저장 (세션 생성)</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="box" style="margin-top: 2em; border-top: 3px solid #e03e2d;">
|
||||||
|
<h3>🔗 원터치 보안 링크 생성</h3>
|
||||||
|
<p style="font-size: 0.8em; color: #666;">
|
||||||
|
현재 로그인된 계정(<strong><span th:text="${#authentication.name}">User</span></strong>)과 연동된 바로가기 링크를 생성합니다.<br>
|
||||||
|
이 링크는 현재 기기와 IP에서만 작동하며, <strong>비밀번호 입력 없이</strong> 즉시 접속됩니다.
|
||||||
|
</p>
|
||||||
|
|
||||||
<div class="row gtr-uniform">
|
<div class="row gtr-uniform">
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
<label for="kis_app_key">App Key (실전투자)</label>
|
<div style="display: flex; gap: 10px; align-items: center;">
|
||||||
<input type="text" id="kis_app_key" placeholder="App Key를 입력하세요" required />
|
<input type="text" id="generated_link" readonly placeholder="[링크 생성] 버튼을 눌러주세요."
|
||||||
</div>
|
style="background: #f1f1f1; cursor: pointer;" onclick="this.select()">
|
||||||
<div class="col-12">
|
<button class="button primary small" id="btn_generate" style="white-space: nowrap;">링크 생성</button>
|
||||||
<label for="kis_app_secret">App Secret</label>
|
<button class="button small" id="btn_copy" style="white-space: nowrap;">복사</button>
|
||||||
<input type="password" id="kis_app_secret" placeholder="App Secret을 입력하세요" required />
|
</div>
|
||||||
</div>
|
|
||||||
<div class="col-12">
|
|
||||||
<label for="kis_account_no">계좌번호</label>
|
|
||||||
<input type="text" id="kis_account_no" placeholder="'-' 제외 10자리 (예: 1234567801)" maxlength="10" required />
|
|
||||||
</div>
|
|
||||||
<div class="col-12">
|
|
||||||
<ul class="actions fit">
|
|
||||||
<li><button type="button" id="btn_save_kis" class="button primary fit">연결 및 시작하기</button></li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12" style="margin-top:1em;">
|
||||||
|
<a href="/stock/dashboard" class="button fit">대시보드로 이동</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script type="module">
|
<script type="module">
|
||||||
import { Api } from '/js/modules/api.js';
|
import { StockUtils } from '/js/modules/stock.js';
|
||||||
import { UI } from '/js/modules/ui.js';
|
|
||||||
document.getElementById('btn_save_kis').addEventListener('click', async () => {
|
// URL 파라미터에 에러가 있으면 알림 표시
|
||||||
const data = {
|
window.addEventListener('DOMContentLoaded', () => {
|
||||||
appKey: document.getElementById('kis_app_key').value,
|
const params = new URLSearchParams(location.search);
|
||||||
appSecret: document.getElementById('kis_app_secret').value,
|
if (params.has('error')) {
|
||||||
accountNo: document.getElementById('kis_account_no').value
|
alert("오류 발생: " + params.get('error'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 링크 생성 버튼 이벤트
|
||||||
|
document.getElementById('btn_generate').addEventListener('click', async () => {
|
||||||
|
// 입력된 AppKey 등만 가져옴 (ID/PW 불필요)
|
||||||
|
const inputs = {
|
||||||
|
key: document.getElementById('appKey').value.trim(),
|
||||||
|
secret: document.getElementById('appSecret').value.trim(),
|
||||||
|
acc: document.getElementById('accountNo').value.trim()
|
||||||
};
|
};
|
||||||
|
|
||||||
const res = await Api.request('/api/stock/config', 'POST', data);
|
const url = await StockUtils.security.generateLink(inputs);
|
||||||
if (res.resultCode === 0) {
|
if (url) {
|
||||||
location.href = "/stock/dashboard";
|
const inputEl = document.getElementById('generated_link');
|
||||||
} else {
|
inputEl.value = url;
|
||||||
UI.showAlert("연결 실패", res.resultMsg);
|
inputEl.style.backgroundColor = "#e6fffa";
|
||||||
|
inputEl.style.borderColor = "#38b2ac";
|
||||||
|
alert("✅ 나만의 바로가기 링크가 생성되었습니다!");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
document.getElementById('btn_connect').addEventListener('click', () => {
|
||||||
|
const inputs = {
|
||||||
|
key: document.getElementById('appKey').value.trim(),
|
||||||
|
secret: document.getElementById('appSecret').value.trim(),
|
||||||
|
acc: document.getElementById('accountNo').value.trim()
|
||||||
|
};
|
||||||
|
StockUtils.connect(inputs);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 복사 버튼 이벤트
|
||||||
|
document.getElementById('btn_copy').addEventListener('click', () => {
|
||||||
|
StockUtils.security.copyToClipboard('generated_link');
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
</section>
|
</section>
|
||||||
</html>
|
</html>
|
||||||
@ -82,13 +82,19 @@
|
|||||||
const curPrice = parseInt(stock.current_price).toLocaleString();
|
const curPrice = parseInt(stock.current_price).toLocaleString();
|
||||||
const bepPrice = parseInt(stock.break_even_price).toLocaleString(); // 손익분기
|
const bepPrice = parseInt(stock.break_even_price).toLocaleString(); // 손익분기
|
||||||
|
|
||||||
|
const detailLink = `/stock/detail?codes=${stock.code}`;
|
||||||
|
|
||||||
tr.innerHTML = `
|
tr.innerHTML = `
|
||||||
<td><strong>${stock.name}</strong></td>
|
<td>
|
||||||
|
<a href="${detailLink}" style="font-weight: bold; text-decoration: none; color: inherit;">
|
||||||
|
${stock.name}
|
||||||
|
</a>
|
||||||
|
<br/>
|
||||||
|
<span style="font-size: 0.8em; color: #888;">${stock.code}</span>
|
||||||
|
</td>
|
||||||
<td style="text-align: right;">${qty}</td>
|
<td style="text-align: right;">${qty}</td>
|
||||||
<td style="text-align: right;">${evalAmt}</td>
|
|
||||||
<td style="text-align: right; color: #666;">${buyPrice}</td>
|
<td style="text-align: right; color: #666;">${buyPrice}</td>
|
||||||
<td style="text-align: right;"><b>${curPrice}</b></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;">
|
<td style="text-align: right; color: ${profitColor}; font-weight: bold;">
|
||||||
${stock.profit_rate}%
|
${stock.profit_rate}%
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@ -6,488 +6,226 @@
|
|||||||
<head>
|
<head>
|
||||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||||
<style>
|
<style>
|
||||||
/* 주문 박스 스타일 */
|
/* UI 스타일 정의 */
|
||||||
.order-box {
|
.order-box { background: #f8f9fa; border: 1px solid #e9ecef; border-radius: 8px; padding: 1.25em; margin-top: 1em; }
|
||||||
background: #f8f9fa;
|
.my-stock-info { background-color: #fffbe6; border: 1px solid #ffe58f; padding: 0.8em; border-radius: 5px; margin-bottom: 1em; font-size: 0.9em; }
|
||||||
border: 1px solid #e9ecef;
|
.badge-auto { background-color: #52c41a; color: white; padding: 2px 6px; border-radius: 4px; font-size: 0.7em; vertical-align: middle; margin-left: 5px; }
|
||||||
border-radius: 8px;
|
.stats-table th { background: #f4f4f4; text-align: center; font-size: 0.8em; padding: 0.4em; }
|
||||||
padding: 1.25em;
|
.stats-table td { text-align: right; font-size: 0.8em; padding: 0.4em; }
|
||||||
margin-top: 1em;
|
.switch { position: relative; display: inline-block; width: 40px; height: 20px; vertical-align: middle; }
|
||||||
}
|
|
||||||
.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; }
|
.switch input { opacity: 0; width: 0; height: 0; }
|
||||||
.slider {
|
.slider { position: absolute; cursor: pointer; top: 0; left: 0; right: 0; bottom: 0; background-color: #ccc; transition: .4s; border-radius: 20px; }
|
||||||
position: absolute;
|
.slider:before { position: absolute; content: ""; height: 14px; width: 14px; left: 3px; bottom: 3px; background-color: white; transition: .4s; border-radius: 50%; }
|
||||||
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 { background-color: #e03e2d; }
|
||||||
input:checked + .slider:before { transform: translateX(20px); }
|
input:checked + .slider:before { transform: translateX(20px); }
|
||||||
|
|
||||||
/* 텍스트 색상 유틸 */
|
|
||||||
.text-blue { color: #0e62cf; }
|
|
||||||
.text-red { color: #e03e2d; }
|
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<section layout:fragment="content">
|
<section layout:fragment="content">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<header class="major">
|
<header class="major">
|
||||||
<h2>종목 상세 & 주문</h2>
|
<h2>종목 상세</h2>
|
||||||
<p>실시간 시세(웹소켓), 분봉 차트, 구간별 거래 추이 분석 및 스마트 주문</p>
|
<p>실시간 분석 및 스마트 주문</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="row" id="detail_container">
|
<div id="detail_container" class="row">
|
||||||
<div class="col-12 text-center">
|
<div class="col-12 text-center">로딩 중...</div>
|
||||||
<p>데이터를 불러오는 중입니다...</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-12">
|
<div class="col-12"><a href="/stock/market" class="button">목록으로</a></div>
|
||||||
<a href="/stock/market" class="button">목록으로 돌아가기</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script type="module">
|
<script type="module">
|
||||||
import { Api } from '/js/modules/api.js';
|
import { Api } from '/js/modules/api.js';
|
||||||
|
import { StockUtils } from '/js/modules/stock.js';
|
||||||
|
|
||||||
// 전역 변수 관리
|
// 페이지 로드 시 실행
|
||||||
let charts = []; // 차트 객체 배열 (재조회 시 기존 차트 파괴용)
|
|
||||||
let ws = null; // 웹소켓 객체
|
|
||||||
|
|
||||||
// 모의투자용 웹소켓 주소 (실전은 ops.koreainvestment.com:21000)
|
|
||||||
const WS_URL = "ws://ops.koreainvestment.com:31000";
|
|
||||||
|
|
||||||
// 1. 초기 로딩 (DOM 로드 시 실행)
|
|
||||||
window.addEventListener('DOMContentLoaded', async () => {
|
window.addEventListener('DOMContentLoaded', async () => {
|
||||||
const params = new URLSearchParams(location.search);
|
const codes = new URLSearchParams(location.search).get('codes');
|
||||||
const codes = params.get('codes');
|
if (!codes) return alert("종목이 없습니다.") || (location.href = '/stock/market');
|
||||||
|
|
||||||
if (!codes) {
|
|
||||||
alert("선택된 종목이 없습니다.");
|
|
||||||
location.href = '/stock/market';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 상세 데이터 API 호출
|
|
||||||
const res = await Api.request(`/api/stock/details?codes=${codes}`);
|
const res = await Api.request(`/api/stock/details?codes=${codes}`);
|
||||||
|
if (res.resultCode === 0) render(res.data);
|
||||||
if (res.resultCode === 0) {
|
else alert(res.resultMsg);
|
||||||
renderDetails(res.data);
|
|
||||||
} else if (res.resultCode === 401) {
|
|
||||||
if(confirm("인증 정보가 없습니다. 설정 페이지로 이동하시겠습니까?")) {
|
|
||||||
location.href = "/stock/config";
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
alert(res.resultMsg || "데이터 조회 실패");
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
document.getElementById('detail_container').innerHTML =
|
|
||||||
'<div class="col-12 text-center text-red">데이터를 가져오는 중 오류가 발생했습니다.</div>';
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 2. 화면 렌더링 함수
|
// 렌더링 로직 (HTML 생성)
|
||||||
function renderDetails(list) {
|
function render(list) {
|
||||||
const container = document.getElementById('detail_container');
|
const container = document.getElementById('detail_container');
|
||||||
container.innerHTML = '';
|
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');
|
const colClass = list.length === 1 ? 'col-12' : (list.length === 2 ? 'col-6 col-12-medium' : 'col-4 col-12-medium');
|
||||||
|
|
||||||
list.forEach((item, index) => {
|
list.forEach((item, idx) => {
|
||||||
|
// 데이터 준비
|
||||||
const rate = parseFloat(item.change_rate);
|
const rate = parseFloat(item.change_rate);
|
||||||
const colorStyle = rate > 0 ? 'color: #e03e2d;' : (rate < 0 ? 'color: #0e62cf;' : 'color: #333;');
|
const color = StockUtils.format.color(rate);
|
||||||
const icon = rate > 0 ? '▲' : (rate < 0 ? '▼' : '-');
|
const canvasId = `chart_${idx}`;
|
||||||
const canvasId = `chart_${index}`;
|
|
||||||
const avg = item.averages || {}; // 구간별 평균 데이터
|
|
||||||
|
|
||||||
|
// HTML 템플릿
|
||||||
const html = `
|
const html = `
|
||||||
<div class="${colClass}" style="margin-bottom: 2em;">
|
<div class="${colClass}" style="margin-bottom: 2em;">
|
||||||
<div class="box" style="padding: 1.5em; height: 100%;">
|
<div class="box" style="padding: 1.5em; height: 100%;">
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1em;">
|
||||||
<div style="margin-bottom: 1em; display: flex; justify-content: space-between; align-items: center;">
|
|
||||||
<div>
|
<div>
|
||||||
<h3 style="margin-bottom: 0.2em;">${item.name}</h3>
|
<h3 style="margin:0;">${item.name} ${item.is_auto_active ? '<span class="badge-auto">⚡AUTO</span>' : ''}</h3>
|
||||||
<span style="font-size: 0.8em; color: #888; letter-spacing: 1px;">${item.code}</span>
|
<span style="font-size: 0.8em; color: #888;">${item.code}</span>
|
||||||
</div>
|
</div>
|
||||||
<div style="text-align: right;">
|
<div style="text-align: right;">
|
||||||
<label class="switch">
|
<label class="switch">
|
||||||
<input type="checkbox" id="ws_toggle_${index}" onchange="toggleWebSocket('${item.code}', ${index}, this)">
|
<input type="checkbox" class="ws-toggle" data-code="${item.code}" data-idx="${idx}">
|
||||||
<span class="slider"></span>
|
<span class="slider"></span>
|
||||||
</label>
|
</label>
|
||||||
<div style="font-size: 0.7em; margin-top: 3px; color: #666;">실시간</div>
|
<div style="font-size: 0.7em; color: #666;">실시간</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<hr />
|
<hr />
|
||||||
|
|
||||||
|
${item.my_qty > 0 ? `
|
||||||
|
<div class="my-stock-info">
|
||||||
|
<div style="display: flex; justify-content: space-between;">
|
||||||
|
<span>📦 보유: <b>${item.my_qty}주</b></span>
|
||||||
|
<span>평단: <b>${StockUtils.format.number(item.my_price)}</b></span>
|
||||||
|
</div>
|
||||||
|
<div style="text-align: right; margin-top:5px; color:${StockUtils.format.color(item.my_profit_rate)}">
|
||||||
|
<b>${item.my_profit_rate}%</b>
|
||||||
|
</div>
|
||||||
|
</div>` : ''
|
||||||
|
}
|
||||||
|
|
||||||
<div style="display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 1em;">
|
<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}">
|
<h2 style="${color}; font-weight:bold; margin: 0;" id="price_display_${idx}">${StockUtils.format.number(item.price)}</h2>
|
||||||
${parseInt(item.price).toLocaleString()}
|
<span style="${color}; font-weight: bold;" id="rate_display_${idx}">${StockUtils.format.icon(rate)} ${rate}%</span>
|
||||||
</h2>
|
|
||||||
<span style="${colorStyle} font-weight: bold;" id="live_rate_${index}">
|
|
||||||
${icon} ${rate}%
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="height: 220px; width: 100%; position: relative; margin-bottom: 1.5em;">
|
<div style="height: 200px; width: 100%; position: relative; margin-bottom: 1em;"><canvas id="${canvasId}"></canvas></div>
|
||||||
<canvas id="${canvasId}"></canvas>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style="margin-bottom: 1.5em;">
|
${renderStatsTable(item.averages)}
|
||||||
<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">
|
<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="row gtr-50">
|
||||||
<div class="col-6">
|
<div class="col-6">
|
||||||
<label class="order-label">매수 가격</label>
|
<label style="font-size:0.8em;">가격 (0=시장가)</label>
|
||||||
<input type="number" id="price_${index}" value="${item.price}" style="text-align:right;" onchange="calcQty(${index})">
|
<input type="number" id="inp_price_${idx}" value="${item.price}" style="text-align:right;">
|
||||||
</div>
|
</div>
|
||||||
<div class="col-6">
|
<div class="col-6">
|
||||||
<label class="order-label">매수 수량</label>
|
<label style="font-size:0.8em;">수량</label>
|
||||||
<input type="number" id="qty_${index}" value="1" min="1" style="text-align:right; font-weight:bold; color:#e03e2d;">
|
<input type="number" id="inp_qty_${idx}" value="1" min="1" style="text-align:right; font-weight:bold; color:#e03e2d;">
|
||||||
</div>
|
</div>
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
<label class="order-label">총 투자금액 (원)</label>
|
<input type="number" id="inp_amt_${idx}" placeholder="투자금액 입력 (수량 자동계산)" style="text-align:right; font-size: 0.9em;">
|
||||||
<input type="number" id="total_amount_${index}" placeholder="예: 100000" style="text-align:right;" onkeyup="calcQty(${index})">
|
|
||||||
</div>
|
</div>
|
||||||
<div class="col-12">
|
<div class="col-12" style="margin-top: 0.5em; background:#fff; padding:5px; border-radius:5px;">
|
||||||
<div class="auto-trade-row">
|
<div style="display:flex; align-items:center; flex-wrap:wrap; gap:10px;">
|
||||||
<input type="checkbox" id="auto_trade_${index}" name="auto_trade">
|
<label><input type="checkbox" id="chk_auto_${idx}"> 자동매매</label>
|
||||||
<label for="auto_trade_${index}" style="margin-bottom: 0; font-size: 0.9em; cursor:pointer;">자동매도 실행</label>
|
<span style="font-size:0.8em; color:#e03e2d;">익절 <input type="number" id="inp_profit_${idx}" value="3.0" step="0.5" style="width:50px; text-align:center;">%</span>
|
||||||
|
<span style="font-size:0.8em; color:#0e62cf;">손절 <input type="number" id="inp_loss_${idx}" value="-3.0" max="-0.1" step="0.5" style="width:55px; text-align:center;">%</span>
|
||||||
<div 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>
|
</div>
|
||||||
<div class="col-6" style="margin-top: 1em;">
|
<div class="col-6" style="margin-top: 1em;">
|
||||||
<button class="button primary fit small" onclick="orderStock('${item.code}', 'BUY', ${index})">매수</button>
|
<button class="button primary fit small btn-order" data-type="BUY" data-idx="${idx}" data-code="${item.code}">매수</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-6" style="margin-top: 1em;">
|
<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>
|
<button class="button fit small btn-order" data-type="SELL" data-idx="${idx}" data-code="${item.code}">지정가 매도</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
${item.my_qty > 0 ? `
|
||||||
|
<div class="col-12" style="margin-top: 0.5em;">
|
||||||
|
<button class="button fit small btn-sell-all"
|
||||||
|
style="background-color:#d63031; color:white; border:none;"
|
||||||
|
data-code="${item.code}">
|
||||||
|
🔥 시장가 전량 매도 (보유분 전체)
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>`;
|
||||||
`;
|
|
||||||
container.innerHTML += html;
|
|
||||||
|
|
||||||
// 차트 그리기는 HTML 렌더링 후 비동기로 실행
|
container.insertAdjacentHTML('beforeend', html);
|
||||||
setTimeout(() => { drawChart(canvasId, item.chart, rate >= 0); }, 0);
|
|
||||||
|
// 차트 그리기
|
||||||
|
setTimeout(() => StockUtils.drawChart(canvasId, item.chart, rate >= 0), 0);
|
||||||
|
|
||||||
|
// 이벤트 바인딩 (계산기)
|
||||||
|
const amtInp = document.getElementById(`inp_amt_${idx}`);
|
||||||
|
const priceInp = document.getElementById(`inp_price_${idx}`);
|
||||||
|
amtInp.addEventListener('keyup', () => StockUtils.calcQty(`inp_amt_${idx}`, `inp_price_${idx}`, `inp_qty_${idx}`));
|
||||||
|
priceInp.addEventListener('change', () => StockUtils.calcQty(`inp_amt_${idx}`, `inp_price_${idx}`, `inp_qty_${idx}`));
|
||||||
|
});
|
||||||
|
|
||||||
|
bindGlobalEvents(); // 버튼 이벤트 연결
|
||||||
|
}
|
||||||
|
|
||||||
|
// 통계 테이블 HTML 생성
|
||||||
|
function renderStatsTable(avg) {
|
||||||
|
if(!avg) return '';
|
||||||
|
const row = (label, data) => `<tr><td>${label}</td><td>${StockUtils.format.number(data?.vol)}</td><td>${StockUtils.format.money(data?.amt)}</td></tr>`;
|
||||||
|
return `<div class="table-wrapper" style="margin-bottom:1.5em;"><table class="alt stats-table" style="margin:0;"><thead><tr><th>구분</th><th>거래량</th><th>거래대금</th></tr></thead><tbody>
|
||||||
|
${row('최근 1분', avg.min1)}${row('5분', avg.min5)}${row('60분', avg.min60)}
|
||||||
|
</tbody></table></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 이벤트 리스너 통합 관리
|
||||||
|
function bindGlobalEvents() {
|
||||||
|
// 주문 버튼
|
||||||
|
document.querySelectorAll('.btn-order').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
const idx = btn.dataset.idx;
|
||||||
|
StockUtils.placeOrder({
|
||||||
|
code: btn.dataset.code,
|
||||||
|
type: btn.dataset.type,
|
||||||
|
qty: document.getElementById(`inp_qty_${idx}`).value,
|
||||||
|
price: document.getElementById(`inp_price_${idx}`).value,
|
||||||
|
isAuto: document.getElementById(`chk_auto_${idx}`).checked,
|
||||||
|
targetRate: document.getElementById(`inp_profit_${idx}`).value,
|
||||||
|
stopLoss: document.getElementById(`inp_loss_${idx}`).value
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 시장가 매도 버튼
|
||||||
|
document.querySelectorAll('.btn-sell-all').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
StockUtils.sellAll(btn.dataset.code);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 웹소켓 토글
|
||||||
|
document.querySelectorAll('.ws-toggle').forEach(toggle => {
|
||||||
|
toggle.addEventListener('change', (e) => {
|
||||||
|
// 다른 토글 끄기 (하나만 허용)
|
||||||
|
if(e.target.checked) document.querySelectorAll('.ws-toggle').forEach(t => t !== e.target && (t.checked = false));
|
||||||
|
|
||||||
|
if (e.target.checked) {
|
||||||
|
const idx = e.target.dataset.idx;
|
||||||
|
StockUtils.wsManager.connect(e.target.dataset.code, (data) => {
|
||||||
|
// UI 업데이트 콜백
|
||||||
|
const pEl = document.getElementById(`price_display_${idx}`);
|
||||||
|
const rEl = document.getElementById(`rate_display_${idx}`);
|
||||||
|
if(pEl) {
|
||||||
|
pEl.innerText = StockUtils.format.number(data.price);
|
||||||
|
pEl.style.color = StockUtils.format.color(data.rate);
|
||||||
|
rEl.innerText = `${StockUtils.format.icon(data.rate)} ${data.rate}%`;
|
||||||
|
rEl.style.color = StockUtils.format.color(data.rate);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
StockUtils.wsManager.disconnect();
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- [기능 1] 웹소켓 실시간 처리 ---
|
// 페이지 이탈 시 소켓 종료
|
||||||
window.toggleWebSocket = async function(code, index, checkbox) {
|
window.addEventListener('beforeunload', () => StockUtils.wsManager.disconnect());
|
||||||
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>
|
</script>
|
||||||
</section>
|
</section>
|
||||||
</html>
|
</html>
|
||||||
82
src/main/resources/templates/content/stock/history.html
Normal file
82
src/main/resources/templates/content/stock/history.html
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html xmlns:th="http://www.thymeleaf.org"
|
||||||
|
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
|
||||||
|
layout:decorate="~{layout/default_layout}">
|
||||||
|
<section layout:fragment="content">
|
||||||
|
<div class="container">
|
||||||
|
<header class="major">
|
||||||
|
<h2>거래 내역</h2>
|
||||||
|
<p>나의 모든 매매 기록입니다.</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="table-wrapper">
|
||||||
|
<table class="alt">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>일시</th>
|
||||||
|
<th>종목</th>
|
||||||
|
<th class="text-center">구분</th>
|
||||||
|
<th class="text-right">단가</th>
|
||||||
|
<th class="text-right">수량</th>
|
||||||
|
<th class="text-right">총액</th>
|
||||||
|
<th>비고</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="history_list">
|
||||||
|
<tr><td colspan="7" class="text-center">불러오는 중...</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<a href="/stock/dashboard" class="button">대시보드로 이동</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script type="module">
|
||||||
|
import { Api } from '/js/modules/api.js';
|
||||||
|
|
||||||
|
window.addEventListener('DOMContentLoaded', async () => {
|
||||||
|
try {
|
||||||
|
const res = await Api.request('/api/stock/history/list');
|
||||||
|
const tbody = document.getElementById('history_list');
|
||||||
|
tbody.innerHTML = '';
|
||||||
|
|
||||||
|
if (!res.data || res.data.length === 0) {
|
||||||
|
tbody.innerHTML = '<tr><td colspan="7" class="text-center">거래 내역이 없습니다.</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.data.forEach(item => {
|
||||||
|
const tr = document.createElement('tr');
|
||||||
|
const isBuy = item.orderType === 'BUY';
|
||||||
|
const typeBadge = isBuy
|
||||||
|
? '<span style="color:#e03e2d; font-weight:bold;">매수</span>'
|
||||||
|
: '<span style="color:#0e62cf; font-weight:bold;">매도</span>';
|
||||||
|
|
||||||
|
const total = parseInt(item.price) * parseInt(item.quantity);
|
||||||
|
const autoBadge = item.isAutoTrade ? '<span style="font-size:0.8em; background:#eee; padding:2px 5px; border-radius:3px;">AUTO</span>' : '';
|
||||||
|
|
||||||
|
// 날짜 포맷팅 (YYYY-MM-DD HH:mm:ss)
|
||||||
|
const date = new Date(item.time).toLocaleString('ko-KR');
|
||||||
|
|
||||||
|
tr.innerHTML = `
|
||||||
|
<td style="font-size:0.9em;">${date}</td>
|
||||||
|
<td>
|
||||||
|
<strong>${item.stockName}</strong><br>
|
||||||
|
<span style="font-size:0.8em; color:#888;">${item.stockCode}</span>
|
||||||
|
</td>
|
||||||
|
<td class="text-center">${typeBadge}</td>
|
||||||
|
<td class="text-right">${parseInt(item.price).toLocaleString()}</td>
|
||||||
|
<td class="text-right">${item.quantity}</td>
|
||||||
|
<td class="text-right">${total.toLocaleString()}</td>
|
||||||
|
<td style="font-size:0.8em;">${autoBadge} ${item.resultMsg}</td>
|
||||||
|
`;
|
||||||
|
tbody.appendChild(tr);
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</section>
|
||||||
|
</html>
|
||||||
@ -1,112 +1,146 @@
|
|||||||
<div class="row">
|
<!DOCTYPE html>
|
||||||
<div class="col-12">
|
<html xmlns:th="http://www.thymeleaf.org"
|
||||||
<ul class="actions fit">
|
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
|
||||||
<li><button class="button fit primary" onclick="loadRank('volume', this)">거래량 상위</button></li>
|
layout:decorate="~{layout/default_layout}">
|
||||||
<li><button class="button fit" onclick="loadRank('amount', this)">거래대금 상위</button></li>
|
|
||||||
<li><button class="button fit" onclick="loadRank('recommend', this)">🔥 단기 추천</button></li>
|
|
||||||
|
|
||||||
<li><button class="button fit" onclick="loadRank('rising', this)">급등주</button></li>
|
<section layout:fragment="content">
|
||||||
<li><button class="button fit" onclick="loadRank('falling', this)">급락주</button></li>
|
<div class="container">
|
||||||
</ul>
|
<header class="major">
|
||||||
|
<h2>주식 시장 순위</h2>
|
||||||
|
<p>실시간 거래량, 등락률 상위 종목을 확인하세요.</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12">
|
||||||
|
<ul class="actions fit">
|
||||||
|
<li><button class="button fit primary" onclick="loadRank('volume', this)">거래량 상위</button></li>
|
||||||
|
<li><button class="button fit" onclick="loadRank('amount', this)">거래대금 상위</button></li>
|
||||||
|
<li><button class="button fit" onclick="loadRank('recommend', this)">🔥 단기 추천</button></li>
|
||||||
|
<li><button class="button fit" onclick="loadRank('rising', this)">급등주</button></li>
|
||||||
|
<li><button class="button fit" onclick="loadRank('falling', this)">급락주</button></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="table-wrapper">
|
||||||
|
<table class="alt">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th width="5%" class="text-center">선택</th>
|
||||||
|
<th width="10%">순위</th>
|
||||||
|
<th>종목명</th>
|
||||||
|
<th class="text-right">현재가</th>
|
||||||
|
<th class="text-right">등락률</th>
|
||||||
|
<th class="text-right">거래량</th>
|
||||||
|
<th class="text-right">거래대금</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="rank_list">
|
||||||
|
<tr><td colspan="7" class="text-center">데이터를 불러오는 중...</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12">
|
||||||
|
<button class="button primary fit" onclick="goToDetail()">선택한 종목 상세 보기 (최대 3개)</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="table-wrapper">
|
<script type="module">
|
||||||
<table class="alt">
|
import { Api } from '/js/modules/api.js';
|
||||||
<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) {
|
// 1. 랭킹 로드 함수
|
||||||
// 버튼 스타일 초기화
|
window.loadRank = async function(type, btn) {
|
||||||
document.querySelectorAll('.actions button').forEach(b => b.classList.remove('primary'));
|
// 버튼 스타일 초기화
|
||||||
if(btn) btn.classList.add('primary');
|
if(btn) {
|
||||||
|
document.querySelectorAll('.actions button').forEach(b => b.classList.remove('primary'));
|
||||||
const tbody = document.getElementById('rank_list');
|
btn.classList.add('primary');
|
||||||
tbody.innerHTML = '<tr><td colspan="6" class="text-center">로딩 중...</td></tr>';
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await Api.request(`/api/stock/rank/${type}`);
|
|
||||||
if (res.resultCode === 0) {
|
|
||||||
renderList(res.data);
|
|
||||||
} else if (res.resultCode === 401) {
|
|
||||||
if(confirm("API 설정이 필요합니다. 이동하시겠습니까?")) location.href = "/stock/config";
|
|
||||||
} else {
|
|
||||||
alert(res.resultMsg);
|
|
||||||
}
|
}
|
||||||
} 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');
|
||||||
const tbody = document.getElementById('rank_list');
|
tbody.innerHTML = '<tr><td colspan="7" class="text-center">로딩 중...</td></tr>';
|
||||||
tbody.innerHTML = '';
|
|
||||||
|
|
||||||
if (!list || list.length === 0) {
|
try {
|
||||||
tbody.innerHTML = '<tr><td colspan="6" class="text-center">조건에 맞는 종목이 없습니다.</td></tr>';
|
const res = await Api.request(`/api/stock/rank/${type}`);
|
||||||
return;
|
if (res.resultCode === 0) {
|
||||||
}
|
renderList(res.data);
|
||||||
|
} else if (res.resultCode === 401) {
|
||||||
|
if(confirm("API 설정이 필요합니다. 이동하시겠습니까?")) location.href = "/stock/config";
|
||||||
|
} else {
|
||||||
|
alert(res.resultMsg);
|
||||||
|
tbody.innerHTML = '<tr><td colspan="7" class="text-center">데이터 없음</td></tr>';
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
tbody.innerHTML = '<tr><td colspan="7" class="text-center">불러오기 실패</td></tr>';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
list.forEach(item => {
|
// 2. 리스트 렌더링 함수
|
||||||
const tr = document.createElement('tr');
|
function renderList(list) {
|
||||||
const rate = parseFloat(item.change_rate);
|
const tbody = document.getElementById('rank_list');
|
||||||
const colorClass = rate > 0 ? 'color: #e03e2d;' : (rate < 0 ? 'color: #0e62cf;' : '');
|
tbody.innerHTML = '';
|
||||||
|
|
||||||
// 거래대금 포맷팅 (예: 1,500 억)
|
if (!list || list.length === 0) {
|
||||||
const amountStr = item.amount ? parseInt(item.amount).toLocaleString() + ' 억' : '-';
|
tbody.innerHTML = '<tr><td colspan="7" class="text-center">조건에 맞는 종목이 없습니다.</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
tr.innerHTML = `
|
list.forEach(item => {
|
||||||
|
const tr = document.createElement('tr');
|
||||||
|
const rate = parseFloat(item.change_rate);
|
||||||
|
// 등락률 색상 (상승: 빨강, 하락: 파랑)
|
||||||
|
const colorStyle = rate > 0 ? 'color: #e03e2d;' : (rate < 0 ? 'color: #0e62cf;' : '');
|
||||||
|
|
||||||
|
// 거래대금 포맷팅
|
||||||
|
const amountStr = item.amount ? parseInt(item.amount).toLocaleString() + ' 억' : '-';
|
||||||
|
|
||||||
|
tr.innerHTML = `
|
||||||
<td class="text-center">
|
<td class="text-center">
|
||||||
<input type="checkbox" name="stock_chk" value="${item.code || item.name}" id="chk_${item.rank}">
|
<input type="checkbox" name="stock_chk" value="${item.code || item.name}" id="chk_${item.rank}">
|
||||||
<label for="chk_${item.rank}"></label>
|
<label for="chk_${item.rank}"></label>
|
||||||
</td>
|
</td>
|
||||||
<td>${item.rank}</td>
|
<td>${item.rank}</td>
|
||||||
<td><strong>${item.name}</strong><br><span style="font-size:0.8em; color:#888;">${item.code}</span></td>
|
<td>
|
||||||
|
<strong>${item.name}</strong><br>
|
||||||
|
<span style="font-size:0.8em; color:#888;">${item.code}</span>
|
||||||
|
</td>
|
||||||
<td style="text-align: right;">${parseInt(item.price).toLocaleString()}</td>
|
<td style="text-align: right;">${parseInt(item.price).toLocaleString()}</td>
|
||||||
`;
|
<td style="text-align: right; ${colorStyle} font-weight:bold;">${rate}%</td>
|
||||||
tbody.appendChild(tr);
|
<td style="text-align: right;">${parseInt(item.volume).toLocaleString()}</td>
|
||||||
|
<td style="text-align: right; color:#666;">${amountStr}</td>
|
||||||
|
`;
|
||||||
|
tbody.appendChild(tr);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 상세 페이지 이동 함수
|
||||||
|
window.goToDetail = function() {
|
||||||
|
const checkboxes = document.querySelectorAll('input[name="stock_chk"]:checked');
|
||||||
|
if (checkboxes.length === 0) {
|
||||||
|
alert("종목을 선택해주세요.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (checkboxes.length > 3) {
|
||||||
|
alert("최대 3개까지만 비교할 수 있습니다.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const codes = Array.from(checkboxes).map(c => c.value).join(',');
|
||||||
|
location.href = `/stock/detail?codes=${codes}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 초기 로딩
|
||||||
|
window.addEventListener('DOMContentLoaded', () => {
|
||||||
|
// 기본값: 단기 추천 탭 먼저 클릭 효과
|
||||||
|
const recommendBtn = document.querySelector("button[onclick*='recommend']");
|
||||||
|
if(recommendBtn) recommendBtn.click();
|
||||||
|
else {
|
||||||
|
// 버튼이 없으면 첫번째 버튼 강제 클릭
|
||||||
|
const firstBtn = document.querySelector('.actions button');
|
||||||
|
if(firstBtn) loadRank('volume', firstBtn);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
</script>
|
||||||
|
</section>
|
||||||
// [추가] 상세 페이지 이동 함수
|
</html>
|
||||||
window.goToDetail = function() {
|
|
||||||
const checkboxes = document.querySelectorAll('input[name="stock_chk"]:checked');
|
|
||||||
if (checkboxes.length === 0) {
|
|
||||||
alert("종목을 선택해주세요.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (checkboxes.length > 3) {
|
|
||||||
alert("최대 3개까지만 비교할 수 있습니다.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const codes = Array.from(checkboxes).map(c => c.value).join(',');
|
|
||||||
location.href = `/stock/detail?codes=${codes}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
window.addEventListener('DOMContentLoaded', () => {
|
|
||||||
// 기본값: 단기 추천 탭 먼저 보여주기
|
|
||||||
const recommendBtn = document.querySelector("button[onclick*='recommend']");
|
|
||||||
if(recommendBtn) recommendBtn.click();
|
|
||||||
else document.querySelector('.actions button').click();
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
@ -21,6 +21,8 @@
|
|||||||
<ul>
|
<ul>
|
||||||
<li sec:authorize="isAuthenticated()"><a href="/stock/config">API 설정</a></li>
|
<li sec:authorize="isAuthenticated()"><a href="/stock/config">API 설정</a></li>
|
||||||
<li sec:authorize="isAuthenticated()"><a href="/stock/dashboard">내 잔고/거래</a></li>
|
<li sec:authorize="isAuthenticated()"><a href="/stock/dashboard">내 잔고/거래</a></li>
|
||||||
|
<li sec:authorize="isAuthenticated()"><a href="/stock/auto-trade">자동 거래\</a></li>
|
||||||
|
<li sec:authorize="isAuthenticated()"><a href="/stock/history">내 거래 내역</a></li>
|
||||||
<li><a href="/stock/market">시장 지표</a></li>
|
<li><a href="/stock/market">시장 지표</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user