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

131 lines
4.7 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
import model.RealTimeTrade
import model.TradeType
class KisWebSocketManager(private val isSimulation: Boolean) {
val client = HttpClient(CIO) {
install(WebSockets) {
// 타임아웃 설정 (필요 시)
pingInterval = 20_000
}
install(HttpTimeout) {
requestTimeoutMillis = 15_000
connectTimeoutMillis = 15_000 // 연결 시도 시간을 15초로 늘림
socketTimeoutMillis = 15_000
}
}
private var session: DefaultClientWebSocketSession? = null
// Coroutine 관리용 스코프 정의
private val scope = CoroutineScope(Dispatchers.Default + Job())
// UI에서 관찰할 상태값들
val currentPrice = mutableStateOf("0")
val priceChangeColor = mutableStateOf(Color.Transparent)
val tradeLogs = mutableStateListOf<RealTimeTrade>() // 실시간 체결 내역 리스트
suspend fun connect(approvalKey: String) {
val hostUrl = if (isSimulation) "ops.koreainvestment.com" else "ops.koreainvestment.com"
val port = if (isSimulation) 21001 else 21000
scope.launch {
try {
client.webSocket(method = HttpMethod.Get, host = hostUrl, port = port, path = "/tryitout/H0STCNT0") {
session = this
// 서버로부터 오는 메시지 수신 루프
incoming.consumeAsFlow().collect { frame ->
if (frame is Frame.Text) {
parseTradeData(frame.readText())
}
}
}
} catch (e: Exception) {
println("⚠️ 웹소켓 연결 실패 (장외 시간 또는 서버 점검): ${e.localizedMessage}")
e.printStackTrace()
}
}
}
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
}
}
suspend fun subscribeStock(stockCode: String) {
val session = session ?: return
// 이전 구독이 있다면 해지 로직이 필요할 수 있으나,
// 기본적으로 새로운 종목 구독 메시지를 전송합니다.
val approvalKey = "" // 연결 시 저장해둔 키 사용 (필요시 클래스 변수로 저장)
val requestJson = """
{
"header": {
"approval_key": "$approvalKey",
"custtype": "P",
"tr_type": "1",
"content-type": "utf-8"
},
"body": {
"input": {
"tr_id": "H0STCNT0",
"tr_key": "$stockCode"
}
}
}
""".trimIndent()
try {
session.send(Frame.Text(requestJson))
// 기존 체결 로그 초기화
tradeLogs.clear()
} catch (e: Exception) {
e.printStackTrace()
}
}
}