atrade/src/main/kotlin/network/KisWebSocketManager.kt

198 lines
6.6 KiB
Kotlin
Raw Normal View History

2026-01-10 18:16:50 +09:00
package network
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.graphics.Color
import io.ktor.client.*
import io.ktor.client.engine.cio.*
import io.ktor.client.plugins.HttpTimeout
import io.ktor.client.plugins.websocket.*
import io.ktor.http.*
import io.ktor.websocket.*
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.consumeAsFlow
2026-01-13 16:04:25 +09:00
import model.KisSession
2026-01-10 18:16:50 +09:00
import model.RealTimeTrade
import model.TradeType
2026-01-13 16:04:25 +09:00
class KisWebSocketManager {
private val client = HttpClient(CIO) {
2026-01-14 15:42:26 +09:00
install(WebSockets) { pingInterval = 20_000 }
2026-01-10 18:16:50 +09:00
install(HttpTimeout) {
requestTimeoutMillis = 15_000
2026-01-13 16:04:25 +09:00
connectTimeoutMillis = 15_000
}
2026-01-10 18:16:50 +09:00
}
2026-01-13 16:04:25 +09:00
private var session: DefaultClientWebSocketSession? = null
2026-01-10 18:16:50 +09:00
private val scope = CoroutineScope(Dispatchers.Default + Job())
2026-01-14 15:42:26 +09:00
// UI 상태값
2026-01-10 18:16:50 +09:00
val currentPrice = mutableStateOf("0")
2026-01-13 16:04:25 +09:00
val tradeLogs = mutableStateListOf<RealTimeTrade>()
2026-01-14 15:42:26 +09:00
// 콜백: 체결 발생 시 (주문번호, 종목코드, 가격, 수량, 매수/매도여부)
var onExecutionReceived: ((orderNo: String, code: String, price: String, qty: String, isBuy: Boolean) -> Unit)? = null
// 콜백: 감시 조건 도달 시 (종목코드, 현재가, 타입)
var onTargetReached: ((code: String, price: Double, isProfit: Boolean) -> Unit)? = null
2026-01-13 16:04:25 +09:00
suspend fun connect() {
val config = KisSession.config
2026-01-14 15:42:26 +09:00
if (config.websocketToken.isEmpty()) return
2026-01-13 16:04:25 +09:00
val hostUrl = "ops.koreainvestment.com"
2026-01-14 15:42:26 +09:00
val port = 21000 // 실전: 21000, 모의: 21000 (동일하나 TR_ID 등에 따라 다름)
2026-01-10 18:16:50 +09:00
scope.launch {
try {
client.webSocket(method = HttpMethod.Get, host = hostUrl, port = port, path = "/tryitout/H0STCNT0") {
session = this
2026-01-14 15:42:26 +09:00
println("✅ 웹소켓 서버 연결 성공")
2026-01-13 16:04:25 +09:00
2026-01-10 18:16:50 +09:00
incoming.consumeAsFlow().collect { frame ->
if (frame is Frame.Text) {
parseTradeData(frame.readText())
}
}
}
} catch (e: Exception) {
2026-01-13 16:04:25 +09:00
println("❌ 웹소켓 연결 오류: ${e.localizedMessage}")
2026-01-10 18:16:50 +09:00
}
}
}
private fun parseTradeData(data: String) {
2026-01-14 15:42:26 +09:00
// KIS 데이터 포맷: 수신구분|TRID|데이터건수|체결데이터
2026-01-10 18:16:50 +09:00
val parts = data.split("|")
2026-01-14 15:42:26 +09:00
if (parts.size < 4) return
val trId = parts[1]
val body = parts[3]
2026-01-10 18:16:50 +09:00
2026-01-14 15:42:26 +09:00
when (trId) {
"H0STCNT0" -> handlePriceData(body) // [1] 실시간 시세 처리
"H0STCNI0" -> handleExecutionData(body) // [2] 실시간 체결 통보 처리
}
2026-01-10 18:16:50 +09:00
}
2026-01-14 15:42:26 +09:00
/**
* [1] 실시간 가격 데이터 처리 감시 로직
*/
private fun handlePriceData(body: String) {
val rows = body.split("^")
if (rows.size < 16) return
val stockCode = rows[0]
val priceStr = rows[2]
val currentPriceInt = priceStr.toIntOrNull() ?: 0
val newTrade = RealTimeTrade(
time = rows[1].chunked(2).joinToString(":"),
price = priceStr,
change = rows[4],
volume = rows[12],
type = if (rows[15] == "1") TradeType.BUY else TradeType.SELL
)
scope.launch(Dispatchers.Main) {
tradeLogs.add(0, newTrade)
if (tradeLogs.size > 30) tradeLogs.removeLast()
currentPrice.value = String.format("%,d", currentPriceInt)
// 실시간 감시 엔진 작동
checkAutoTradeTargets(stockCode, currentPriceInt.toDouble())
2026-01-10 18:16:50 +09:00
}
}
2026-01-13 16:04:25 +09:00
/**
2026-01-14 15:42:26 +09:00
* [2] 실시간 개인 체결 통보 처리
2026-01-13 16:04:25 +09:00
*/
2026-01-14 15:42:26 +09:00
private fun handleExecutionData(body: String) {
val rows = body.split("^")
if (rows.size < 13) return
val orderNo = rows[1]
val stockCode = rows[7]
val side = rows[9] // 01: 매도, 02: 매수
val price = rows[11]
val qty = rows[12]
scope.launch(Dispatchers.Main) {
val isBuy = side == "02"
println("📣 체결 통보 수신: $stockCode | ${if(isBuy) "매수" else "매도"} | $price")
// 외부 콜백 실행 (DB 업데이트 및 UI 전환 트리거)
onExecutionReceived?.invoke(orderNo, stockCode, price, qty, isBuy)
// 매수 체결 시 즉시 해당 종목 실시간 시세 구독 시작
if (isBuy) subscribeStock(stockCode)
}
2026-01-13 16:04:25 +09:00
}
/**
2026-01-14 15:42:26 +09:00
* 자동매매 목표가 도달 여부 판단
2026-01-13 16:04:25 +09:00
*/
2026-01-14 15:42:26 +09:00
private fun checkAutoTradeTargets(code: String, currentPrice: Double) {
// DB에서 해당 종목의 감시 설정(익절/손절가)을 가져와 비교
// 효율성을 위해 Map 등에 캐싱하여 사용할 것을 권장
scope.launch(Dispatchers.IO) {
val config = DatabaseFactory.findConfigByCode(code) ?: return@launch
if (currentPrice >= config.targetPrice) {
withContext(Dispatchers.Main) { onTargetReached?.invoke(code, currentPrice, true) }
} else if (currentPrice <= config.stopLossPrice) {
withContext(Dispatchers.Main) { onTargetReached?.invoke(code, currentPrice, false) }
}
}
2026-01-13 16:04:25 +09:00
}
2026-01-10 18:16:50 +09:00
2026-01-13 16:04:25 +09:00
/**
2026-01-14 15:42:26 +09:00
* 개인 체결 통보 구독 (HTS ID 필요)
2026-01-13 16:04:25 +09:00
*/
2026-01-14 15:42:26 +09:00
suspend fun subscribeExecution(htsId: String) {
sendRequest(htsId, trType = "1", trId = "H0STCNI0")
println("📡 실시간 체결 통보 구독 시작: $htsId")
}
suspend fun subscribeStock(stockCode: String) {
sendRequest(stockCode, trType = "1", trId = "H0STCNT0")
}
suspend fun unsubscribeStock(stockCode: String) {
if (stockCode.isNotEmpty()) sendRequest(stockCode, trType = "2", trId = "H0STCNT0")
}
private suspend fun sendRequest(key: String, trType: String, trId: String) {
2026-01-13 16:04:25 +09:00
val currentSession = session ?: return
val config = KisSession.config
2026-01-10 18:16:50 +09:00
val requestJson = """
{
"header": {
2026-01-13 16:04:25 +09:00
"approval_key": "${config.websocketToken}",
2026-01-10 18:16:50 +09:00
"custtype": "P",
2026-01-13 16:04:25 +09:00
"tr_type": "$trType",
2026-01-10 18:16:50 +09:00
"content-type": "utf-8"
},
"body": {
"input": {
2026-01-14 15:42:26 +09:00
"tr_id": "$trId",
"tr_key": "$key"
2026-01-10 18:16:50 +09:00
}
}
}
2026-01-13 16:04:25 +09:00
""".trimIndent()
2026-01-10 18:16:50 +09:00
try {
2026-01-13 16:04:25 +09:00
currentSession.send(Frame.Text(requestJson))
2026-01-10 18:16:50 +09:00
} catch (e: Exception) {
2026-01-14 15:42:26 +09:00
println("❌ 웹소켓 요청 실패 ($trId): ${e.localizedMessage}")
2026-01-10 18:16:50 +09:00
}
}
2026-01-14 15:42:26 +09:00
fun clearData() {
tradeLogs.clear()
currentPrice.value = "0"
}
2026-01-10 18:16:50 +09:00
}