...
This commit is contained in:
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user