This commit is contained in:
2026-01-19 17:09:37 +09:00
parent bdc268e325
commit 91f9e4ee9a
20 changed files with 786 additions and 974 deletions
+57 -13
View File
@@ -1,5 +1,6 @@
package network
import AutoTradeItem
import io.ktor.client.*
import io.ktor.client.call.*
import io.ktor.client.engine.cio.CIO
@@ -323,21 +324,45 @@ class KisTradeService {
val rtCd = body["rt_cd"]?.jsonPrimitive?.content
val msg = body["msg1"]?.jsonPrimitive?.content ?: "메시지 없음"
if (rtCd == "0") Result.success("✅ 주문 성공: $msg")
else Result.failure(Exception("❌ 오류 ($rtCd): $msg"))
if (rtCd == "0") {
// 응답의 output 객체에서 주문 번호(ODNO) 추출
val orderNo = body["output"]?.jsonObject?.get("ODNO")?.jsonPrimitive?.content
?: body["output"]?.jsonObject?.get("odno")?.jsonPrimitive?.content // API마다 대소문자가 다를 수 있음
?: ""
Result.success(orderNo) // 성공 시 주문 번호 반환
} else {
val msg = body["msg1"]?.jsonPrimitive?.content ?: "메시지 없음"
Result.failure(Exception("❌ 오류 ($rtCd): $msg"))
}
} catch (e: Exception) { Result.failure(e) }
}
fun UnfilledOrder.toAutoTradeItem(isDomestic: Boolean): AutoTradeItem {
return AutoTradeItem(
orderNo = this.ord_no,
code = this.pdno,
name = this.prdt_name,
orderedPrice = this.ord_unpr.toDoubleOrNull() ?: 0.0,
quantity = 0, // 미체결 내역에서는 원 주문 수량을 알기 어려우므로 0 또는 별도 처리
remainedQuantity = this.rmnd_qty.toIntOrNull() ?: 0,
status = "PENDING_BUY", // 기본적으로 미체결은 매수/매도 대기 상태
isDomestic = isDomestic
)
}
/**
* [추가] 국내 미체결 내역 조회
*/
suspend fun fetchUnfilledOrders(): Result<List<UnfilledOrder>> {
val config = KisSession.config
if (config.isSimulation) Result.success(emptyList<UnfilledOrder>())
if (config.isSimulation) return Result.success(emptyList<UnfilledOrder>())
val baseUrl = if (config.isSimulation) vtsUrl else prodUrl
val trId = "TTTC0084R"
var pureAccount = config.accountNo.replace("-", "").trim()
if (pureAccount.length == 8) pureAccount += "01"
val cano = pureAccount.take(8)
val acntPrdtCd = pureAccount.takeLast(2)
return try {
val response = client.get("$baseUrl/uapi/domestic-stock/v1/trading/inquire-psbl-rvsecncl") {
header("authorization", "Bearer ${config.tradeToken}")
@@ -346,8 +371,8 @@ class KisTradeService {
header("tr_id", trId)
header("custtype", "P")
parameter("CANO", config.accountNo.take(8))
parameter("ACNT_PRDT_CD", config.accountNo.takeLast(2))
parameter("CANO", cano)
parameter("ACNT_PRDT_CD", acntPrdtCd)
parameter("CTX_AREA_FK100", "")
parameter("CTX_AREA_NK100", "")
parameter("T_GUBUN", "0")
@@ -356,8 +381,16 @@ class KisTradeService {
parameter("INQR_DVSN_1", "0")
parameter("INQR_DVSN_2", "0")
}
println("result >> ${response.status}")
val body = response.body<UnfilledResponse>()
if (body.rt_cd == "0") Result.success(body.output)
println("result >> ${body.msg1}")
println("result >> ${body.rt_cd}")
if (body.rt_cd == "0") {
var result = body
println("result >> ${result.output.size}")
Result.success(result.output)
}
else Result.failure(Exception(body.msg1))
} catch (e: Exception) { Result.failure(e) }
}
@@ -369,8 +402,13 @@ class KisTradeService {
val config = KisSession.config
val baseUrl = if (config.isSimulation) vtsUrl else prodUrl
val trId = if (config.isSimulation) "VTTC0803U" else "TTTC0803U"
var pureAccount = config.accountNo.replace("-", "").trim()
if (pureAccount.length == 8) pureAccount += "01"
val cano = pureAccount.take(8)
val acntPrdtCd = pureAccount.takeLast(2)
return try {
println("orgNo")
val response = client.post("$baseUrl/uapi/domestic-stock/v1/trading/order-rvsecncl") {
header("authorization", "Bearer ${config.tradeToken}")
header("appkey", if (config.isSimulation) config.vtsAppKey else config.realAppKey)
@@ -379,14 +417,15 @@ class KisTradeService {
header("Content-Type", "application/json")
setBody(mapOf(
"CANO" to config.accountNo.take(8),
"ACNT_PRDT_CD" to config.accountNo.takeLast(2),
"CANO" to cano,
"ACNT_PRDT_CD" to acntPrdtCd,
"KRX_FWDG_ORD_ORGNO" to "", // 공란 혹은 지점번호
"ORGN_ORD_NO" to orgNo, // 취소할 원주문번호
"RVSE_CNCL_DVSN" to "02", // 01: 정정, 02: 취소
"ORGN_ODNO" to orgNo, // 취소할 원주문번호
"RVSE_CNCL_DVSN_CD" to "02", // 01: 정정, 02: 취소
"ORD_DVSN" to "00", // 지정가
"ORD_QTY" to "0", // 0이면 전량 취소
"ORD_UNPR" to "0"
"ORD_UNPR" to "0",
"QTY_ALL_ORD_YN" to "Y",
))
}
val body = response.body<JsonObject>()
@@ -472,14 +511,19 @@ class KisTradeService {
val config = KisSession.config
val baseUrl = if (config.isSimulation) vtsUrl else prodUrl
val trId = if (config.isSimulation) "VTTC8434R" else "TTTC8434R"
var pureAccount = config.accountNo.replace("-", "").trim()
if (pureAccount.length == 8) pureAccount += "01"
val cano = pureAccount.take(8)
val acntPrdtCd = pureAccount.takeLast(2)
return try {
val response = client.get("$baseUrl/uapi/domestic-stock/v1/trading/inquire-balance") {
header("authorization", "Bearer ${config.tradeToken}")
header("appkey", if (config.isSimulation) config.vtsAppKey else config.realAppKey)
header("appsecret", if (config.isSimulation) config.vtsSecretKey else config.realSecretKey)
header("tr_id", trId)
parameter("CANO", config.accountNo.take(8))
parameter("ACNT_PRDT_CD", config.accountNo.takeLast(2))
parameter("CANO", cano)
parameter("ACNT_PRDT_CD", acntPrdtCd)
parameter("AFHR_FLPR_YN", "N")
parameter("OFL_YN", "N")
parameter("INQR_DVSN", "02")
+88 -159
View File
@@ -1,198 +1,127 @@
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.KisSession
import model.RealTimeTrade
import model.TradeType
import java.util.concurrent.atomic.AtomicBoolean
class KisWebSocketManager {
private val client = HttpClient(CIO) {
install(WebSockets) { pingInterval = 20_000 }
install(HttpTimeout) {
requestTimeoutMillis = 15_000
connectTimeoutMillis = 15_000
}
}
private val client = HttpClient { install(WebSockets) }
private var session: DefaultClientWebSocketSession? = null
private val scope = CoroutineScope(Dispatchers.Default + Job())
private val isConnected = AtomicBoolean(false)
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
// UI 상태값
val currentPrice = mutableStateOf("0")
val tradeLogs = mutableStateListOf<RealTimeTrade>()
// 콜백: 체결 발생 시 (주문번호, 종목코드, 가격, 수량, 매수/매도여부)
var onExecutionReceived: ((orderNo: String, code: String, price: String, qty: String, isBuy: Boolean) -> Unit)? = null
// 콜백: 감시 조건 도달 시 (종목코드, 현재가, 타입)
var onTargetReached: ((code: String, price: Double, isProfit: Boolean) -> Unit)? = null
// 콜백 리스너
var onPriceUpdate: ((String, Double) -> Unit)? = null
var onExecutionReceived: ((String, String, String, String, Boolean) -> Unit)? = null
suspend fun connect() {
val config = KisSession.config
if (config.websocketToken.isEmpty()) return
val hostUrl = "ops.koreainvestment.com"
val port = 21000 // 실전: 21000, 모의: 21000 (동일하나 TR_ID 등에 따라 다름)
if (isConnected.get()) return
val url = if (KisSession.config.isSimulation) "ops.koreainvestment.com:21000" else "ops.koreainvestment.com:31000"
scope.launch {
try {
client.webSocket(method = HttpMethod.Get, host = hostUrl, port = port, path = "/tryitout/H0STCNT0") {
client.webSocket(method = HttpMethod.Get, host = url.split(":")[0], port = url.split(":")[1].toInt(), path = "/last_price") {
session = this
println("✅ 웹소켓 서버 연결 성공")
isConnected.set(true)
println("✅ 웹소켓 연결 성공")
incoming.consumeAsFlow().collect { frame ->
if (frame is Frame.Text) {
parseTradeData(frame.readText())
}
// 연결 직후 HTS ID 기반 체결 통보 자동 구독
val htsId = KisSession.config.htsId
if (htsId.isNotEmpty()) sendRequest("1", "H0STT084R", htsId)
for (frame in incoming) {
if (frame is Frame.Text) handleMessage(frame.readText())
}
}
} catch (e: Exception) {
println("❌ 웹소켓 연결 오류: ${e.localizedMessage}")
println("❌ 웹소켓 에러: ${e.message}")
} finally {
isConnected.set(false)
}
}
}
private fun parseTradeData(data: String) {
// KIS 데이터 포맷: 수신구분|TRID|데이터건수|체결데이터
val parts = data.split("|")
if (parts.size < 4) return
private val _currentPrice = mutableStateOf("0")
val currentPrice = _currentPrice
val tradeLogs = androidx.compose.runtime.mutableStateListOf<model.RealTimeTrade>()
suspend fun unsubscribeStock(code: String) {
subscribeStock(code, isSubscribe = false)
}
private fun handleMessage(message: String) {
if (!message.startsWith("0") && !message.startsWith("1")) return
val parts = message.split("|")
if (parts.size < 4) return
val trId = parts[1]
val body = parts[3]
val dataRows = parts[3].split("^")
when (trId) {
"H0STCNT0" -> handlePriceData(body) // [1] 실시간 시세 처리
"H0STCNI0" -> handleExecutionData(body) // [2] 실시간 체결 통보 처리
}
}
"H0STCNT0" -> {
val price = dataRows[2]
_currentPrice.value = price // 상태 업데이트
onPriceUpdate?.invoke(dataRows[0], price.toDoubleOrNull() ?: 0.0)
/**
* [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())
}
}
/**
* [2] 실시간 개인 체결 통보 처리
*/
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)
}
}
/**
* 자동매매 목표가 도달 여부 판단
*/
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) }
// 로그 추가 (예시)
tradeLogs.add(0, model.RealTimeTrade(
time = dataRows[1],
price = price,
change = dataRows[4],
volume = dataRows[2],
type = model.TradeType.NEUTRAL
))
if (tradeLogs.size > 50) tradeLogs.removeLast()
}
"H0STT084R" -> onExecutionReceived?.invoke(dataRows[5], dataRows[9], dataRows[12], dataRows[13], dataRows[15] == "02")
}
}
/**
* 개인 체결 통보 구독 (HTS ID 필요)
*/
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) {
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": "$trId",
"tr_key": "$key"
}
}
}
""".trimIndent()
try {
currentSession.send(Frame.Text(requestJson))
} catch (e: Exception) {
println("❌ 웹소켓 요청 실패 ($trId): ${e.localizedMessage}")
}
}
fun clearData() {
tradeLogs.clear()
currentPrice.value = "0"
_currentPrice.value = "0"
}
suspend fun subscribeStock(code: String, isSubscribe: Boolean = true) {
val trType = if (isSubscribe) "1" else "2"
sendRequest(trType, "H0STCNT0", code)
if (isSubscribe) println("📡 구독 등록: $code") else println("📴 구독 해제: $code")
}
private suspend fun sendRequest(trType: String, trId: String, trKey: String) {
val approvalKey = KisSession.getWebSocketKey() ?: return
val json = """
{
"header": {
"approval_key": "$approvalKey",
"custtype": "P",
"tr_type": "$trType",
"content-type": "utf-8"
},
"body": {
"input": { "tr_id": "$trId", "tr_key": "$trKey" }
}
}
""".trimIndent()
session?.send(json)
}
private val activeSubscriptions = mutableSetOf<String>() // 현재 구독 중인 종목 코드 관리
suspend fun updateSubscriptions(requiredCodes: Set<String>) {
// 해지할 종목 (현재 구독 중이나 요구 리스트에 없는 것)
val toUnsubscribe = activeSubscriptions - requiredCodes
toUnsubscribe.forEach { subscribeStock(it, isSubscribe = false) }
// 신규 구독 (요구 리스트에는 있으나 현재 구독 중이 아닌 것)
val toSubscribe = requiredCodes - activeSubscriptions
toSubscribe.forEach { subscribeStock(it, isSubscribe = true) }
activeSubscriptions.clear()
activeSubscriptions.addAll(requiredCodes)
}
}