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
|
2026-01-13 16:04:25 +09:00
|
|
|
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
|
2026-01-10 18:16:50 +09:00
|
|
|
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.AppConfig
|
|
|
|
|
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-10 18:16:50 +09:00
|
|
|
install(WebSockets) {
|
|
|
|
|
pingInterval = 20_000
|
|
|
|
|
}
|
|
|
|
|
install(HttpTimeout) {
|
|
|
|
|
requestTimeoutMillis = 15_000
|
2026-01-13 16:04:25 +09:00
|
|
|
connectTimeoutMillis = 15_000
|
2026-01-10 18:16:50 +09:00
|
|
|
socketTimeoutMillis = 15_000
|
|
|
|
|
}
|
2026-01-13 16:04:25 +09:00
|
|
|
install(Logging) {
|
|
|
|
|
logger = Logger.DEFAULT
|
|
|
|
|
level = LogLevel.ALL // 상세한 디버깅을 위해 ALL로 변경
|
|
|
|
|
}
|
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-13 16:04:25 +09:00
|
|
|
// UI 관찰 상태값
|
2026-01-10 18:16:50 +09:00
|
|
|
val currentPrice = mutableStateOf("0")
|
|
|
|
|
val priceChangeColor = mutableStateOf(Color.Transparent)
|
2026-01-13 16:04:25 +09:00
|
|
|
val tradeLogs = mutableStateListOf<RealTimeTrade>()
|
|
|
|
|
suspend fun connect() {
|
|
|
|
|
val config = KisSession.config
|
|
|
|
|
val approvalKey = config.websocketToken
|
|
|
|
|
|
|
|
|
|
if (approvalKey.isEmpty()) {
|
|
|
|
|
println("⚠️ 웹소켓 승인키가 없습니다. 먼저 발급받아야 합니다.")
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-01-10 18:16:50 +09:00
|
|
|
|
2026-01-13 16:04:25 +09:00
|
|
|
// 시세 데이터는 항상 실전 서버(21000)를 권장합니다.
|
|
|
|
|
val hostUrl = "ops.koreainvestment.com"
|
|
|
|
|
val port = 21000
|
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-13 16:04:25 +09:00
|
|
|
println("✅ 웹소켓 연결 성공")
|
|
|
|
|
|
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) {
|
|
|
|
|
// 한국투자증권 데이터 포맷: 수신구분|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
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-13 16:04:25 +09:00
|
|
|
/**
|
|
|
|
|
* [2] 실시간 시세 구독 (Registration)
|
|
|
|
|
* tr_type = "1" (등록)
|
|
|
|
|
*/
|
2026-01-10 18:16:50 +09:00
|
|
|
suspend fun subscribeStock(stockCode: String) {
|
2026-01-13 16:04:25 +09:00
|
|
|
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")
|
|
|
|
|
}
|
2026-01-10 18:16:50 +09:00
|
|
|
|
2026-01-13 16:04:25 +09:00
|
|
|
/**
|
|
|
|
|
* 공통 요청 전송 함수
|
|
|
|
|
*/
|
|
|
|
|
private suspend fun sendRequest(stockCode: String, trType: String) {
|
|
|
|
|
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": {
|
|
|
|
|
"tr_id": "H0STCNT0",
|
|
|
|
|
"tr_key": "$stockCode"
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
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-13 16:04:25 +09:00
|
|
|
println("❌ 웹소켓 요청 실패 ($trType): ${e.localizedMessage}")
|
2026-01-10 18:16:50 +09:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|