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.logging.DEFAULT import io.ktor.client.plugins.logging.LogLevel import io.ktor.client.plugins.logging.Logger import io.ktor.client.plugins.logging.Logging import io.ktor.client.plugins.websocket.* import io.ktor.http.* import io.ktor.websocket.* import kotlinx.coroutines.* import kotlinx.coroutines.flow.consumeAsFlow import model.AppConfig import model.KisSession import model.RealTimeTrade import model.TradeType class KisWebSocketManager { private val client = HttpClient(CIO) { install(WebSockets) { pingInterval = 20_000 } install(HttpTimeout) { requestTimeoutMillis = 15_000 connectTimeoutMillis = 15_000 socketTimeoutMillis = 15_000 } install(Logging) { logger = Logger.DEFAULT level = LogLevel.ALL // 상세한 디버깅을 위해 ALL로 변경 } } private var session: DefaultClientWebSocketSession? = null private val scope = CoroutineScope(Dispatchers.Default + Job()) // UI 관찰 상태값 val currentPrice = mutableStateOf("0") val priceChangeColor = mutableStateOf(Color.Transparent) val tradeLogs = mutableStateListOf() suspend fun connect() { val config = KisSession.config val approvalKey = config.websocketToken if (approvalKey.isEmpty()) { println("⚠️ 웹소켓 승인키가 없습니다. 먼저 발급받아야 합니다.") return } // 시세 데이터는 항상 실전 서버(21000)를 권장합니다. val hostUrl = "ops.koreainvestment.com" val port = 21000 scope.launch { try { client.webSocket(method = HttpMethod.Get, host = hostUrl, port = port, path = "/tryitout/H0STCNT0") { session = this println("✅ 웹소켓 연결 성공") incoming.consumeAsFlow().collect { frame -> if (frame is Frame.Text) { parseTradeData(frame.readText()) } } } } catch (e: Exception) { println("❌ 웹소켓 연결 오류: ${e.localizedMessage}") } } } private fun parseTradeData(data: String) { // 한국투자증권 데이터 포맷: 수신구분|TRID|데이터건수|체결데이터 val parts = data.split("|") if (parts.size > 3) { val rows = parts[3].split("^") if (rows.size > 15) { val newTrade = RealTimeTrade( time = rows[1].chunked(2).joinToString(":"), // HHMMSS -> HH:MM:SS price = rows[2], change = rows[4], volume = rows[12], type = if (rows[15] == "1") TradeType.BUY else TradeType.SELL ) // 메인 스레드에서 UI 상태 업데이트 CoroutineScope(Dispatchers.Main).launch { tradeLogs.add(0, newTrade) // 최신 데이터를 맨 위로 if (tradeLogs.size > 30) tradeLogs.removeLast() // 현재가 및 색상 업데이트 로직 포함 가능 currentPrice.value = newTrade.price } } } } private fun updatePriceWithEffect(newPrice: String) { val oldPrice = currentPrice.value.replace(",", "").toIntOrNull() ?: 0 val current = newPrice.toIntOrNull() ?: 0 currentPrice.value = String.format("%, d", current) priceChangeColor.value = when { current > oldPrice -> Color.Red.copy(alpha = 0.2f) current < oldPrice -> Color.Blue.copy(alpha = 0.2f) else -> Color.Transparent } } /** * [2] 실시간 시세 구독 (Registration) * tr_type = "1" (등록) */ suspend fun subscribeStock(stockCode: String) { sendRequest(stockCode, trType = "1") println("📡 실시간 시세 구독 시작: $stockCode") } /** * [3] 실시간 시세 구독 취소 (Unsubscription) * tr_type = "2" (해제) */ suspend fun unsubscribeStock(stockCode: String) { if (stockCode.isEmpty()) return sendRequest(stockCode, trType = "2") println("🚫 실시간 시세 구독 해제: $stockCode") } /** * 공통 요청 전송 함수 */ private suspend fun sendRequest(stockCode: String, trType: String) { val currentSession = session ?: return val config = KisSession.config val requestJson = """ { "header": { "approval_key": "${config.websocketToken}", "custtype": "P", "tr_type": "$trType", "content-type": "utf-8" }, "body": { "input": { "tr_id": "H0STCNT0", "tr_key": "$stockCode" } } } """.trimIndent() try { currentSession.send(Frame.Text(requestJson)) } catch (e: Exception) { println("❌ 웹소켓 요청 실패 ($trType): ${e.localizedMessage}") } } }