package network import io.ktor.client.* import io.ktor.client.call.* import io.ktor.client.engine.cio.CIO import io.ktor.client.plugins.contentnegotiation.* 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.request.* import io.ktor.client.statement.bodyAsText import io.ktor.http.* import io.ktor.serialization.kotlinx.json.* import io.ktor.utils.io.CancellationException import kotlinx.serialization.json.Json import model.CandleData import model.RankingResponse import model.RankingStock import model.RankingType import model.StockBalanceResponse import kotlinx.coroutines.async import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.isActive import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.jsonArray import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive import model.* import java.time.LocalDate import java.time.LocalTime import java.time.format.DateTimeFormatter import kotlin.coroutines.coroutineContext object KisTradeService { private val client = HttpClient(CIO) { install(ContentNegotiation) { json(Json { ignoreUnknownKeys = true encodeDefaults = true // 기본값이 포함된 요청 바디를 정확히 전송하기 위해 필요 }) } // [수정] 모든 로그(Headers + Body)를 찍도록 설정 install(Logging) { logger = Logger.DEFAULT level = LogLevel.NONE } } private val prodUrl = "https://openapi.koreainvestment.com:9443" private val vtsUrl = "https://openapivts.koreainvestment.com:29443" suspend fun fetchIsHoliday(date: String): Result { val config = KisSession.config return try { val response = client.get("$prodUrl/uapi/domestic-stock/v1/quotations/chk-holiday") { header("authorization", "Bearer ${config.marketToken}") header("appkey", config.realAppKey) header("appsecret", config.realSecretKey) header("tr_id", "CTCA0903R") header("custtype", "P") parameter("BASS_DT", date) parameter("CTX_AREA_NK", "") parameter("CTX_AREA_FK", "") } val body = response.body() // output의 opnd_yn (영업일 여부)가 'Y'이면 영업일, 'N'이면 휴장일 val isOpeningDay = body["output"]?.jsonArray?.firstOrNull()?.jsonObject?.get("opnd_yn")?.jsonPrimitive?.content == "Y" Result.success(!isOpeningDay) } catch (e: Exception) { Result.failure(e) } } /** * [1] 통합 잔고 조회 (국내 + 해외 합산) */ suspend fun fetchIntegratedBalance(): Result = coroutineScope { val config = KisSession.config // 국내와 해외 잔고를 비동기로 동시 호출 val domesticJob = async { fetchDomesticRawBalance() } val overseasJob = async { fetchOverseasRawBalance() } try { val domRes = domesticJob.await().getOrNull() val ovsRes = overseasJob.await().getOrNull() val combinedHoldings = mutableListOf() // 국내 종목 매핑 domRes?.output1?.forEach { combinedHoldings.add(UnifiedStockHolding( code = it.pdno, name = it.prdt_name, quantity = it.hldg_qty, avgPrice = it.pchs_avg_pric, currentPrice = it.prpr, profitRate = it.evlu_pfls_rt, evalAmount = it.evlu_amt, isDomestic = true, availOrderCount = it.ord_psbl_qty ).apply { if (it.hldg_qty.toLong() > 0) { // println("보유 종목 : ${it.prdt_name} , 수량 : ${it.hldg_qty}") } }) } // 해외 종목 매핑 (해외 API 응답 모델 구조에 따라 필드 매핑) ovsRes?.output1?.forEach { combinedHoldings.add(UnifiedStockHolding( code = it.pdno, name = it.prdt_name, quantity = it.hldg_qty, avgPrice = it.pchs_avg_pric, currentPrice = it.prpr, profitRate = it.evlu_pfls_rt, evalAmount = it.evlu_amt, isDomestic = false, availOrderCount = it.ord_psbl_qty )) } val totalAmt = (domRes?.output2?.firstOrNull()?.tot_evlu_amt?.toLongOrNull() ?: 0L) + (ovsRes?.output2?.firstOrNull()?.tot_evlu_amt?.toLongOrNull() ?: 0L) val depositAmt = domRes?.output2?.firstOrNull()?.dnca_tot_amt?.toLongOrNull() ?: 0L Result.success(UnifiedBalance( totalAsset = String.format("%,d", totalAmt), totalProfitRate = domRes?.output2?.firstOrNull()?.evlu_pfls_rt ?: "0.0", deposit = String.format("%,d", depositAmt), holdings = combinedHoldings )) } catch (e: Exception) { Result.failure(e) } } /** * [통합 순위 조회] 국내/해외 분기 처리 */ suspend fun fetchMarketRanking(type: RankingType, isDomestic: Boolean): Result> { return if (isDomestic) { fetchDomesticRanking(type) } else { fetchOverseasRanking(type) } } /** * [국내 주식 순위] 명세서 기반 파라미터 최적화 */ private suspend fun fetchDomesticRanking(type: RankingType): Result> { val config = KisSession.config val jsonParser = Json { ignoreUnknownKeys = true coerceInputValues = true isLenient = true } return try { val response = client.get("${prodUrl}${type.path}") { header("authorization", "Bearer ${config.marketToken}") header("appkey", config.realAppKey) header("appsecret", config.realSecretKey) header("tr_id", type.trId) header("custtype", "P") header("Accept", "application/json") // [Step 1] 파라미터 기본값 재설정 (성공했던 원본 코드 기준) val params = mutableMapOf( "FID_COND_MRKT_DIV_CODE" to "J", "FID_COND_SCR_DIV_CODE" to type.scrNo, "FID_INPUT_ISCD" to "0000", "FID_DIV_CLS_CODE" to "0", "FID_BLNG_CLS_CODE" to "0", "FID_RANK_SORT_CLS_CODE" to "0", // "FID_MK_OP_CLS_CODE" 삭제! (이게 있으면 RISE/FALL이 깨짐) "FID_PRC_CLS_CODE" to "0", "FID_INPUT_CNT_1" to "0", // 1이 아니라 0이어야 함 "FID_INPUT_PRICE_1" to "", "FID_INPUT_PRICE_2" to "", "FID_VOL_CNT" to "", "FID_RSFL_RATE1" to "", "FID_RSFL_RATE2" to "", // 누락되었던 파라미터 추가 "FID_INPUT_DATE_1" to "", "FID_INPUT_DATE_2" to "", "FID_APLY_RANG_VOL" to "", "FID_APLY_RANG_PRC_1" to "", "FID_APLY_RANG_PRC_2" to "", "FID_PERIOD_DIV_CODE" to "", "FID_SELECT_DIV_CODE" to "", "FID_INPUT_OPTION_1" to "", "FID_INPUT_OPTION_2" to "", "FID_TRGT_CLS_CODE" to "11111111", "FID_TRGT_EXLS_CLS_CODE" to "000000" ) // [Step 2] Enum 특화 설정 덮어쓰기 params.putAll(type.extraParams) // [Step 3] 적용 params.forEach { (key, value) -> parameter(key, value) } } val rawJson = response.bodyAsText() val body = try { jsonParser.decodeFromString(rawJson) } catch (e: Exception) { println("[ERROR] ${type.title} 파싱 실패. Raw: $rawJson") return Result.failure(e) } if (body.rt_cd == "0") { Result.success(body.list ?: emptyList()) } else { // rt_cd가 비어있다면 에러 메시지도 없을 수 있으므로 Raw Body 일부를 포함하여 예외 처리 val errorMsg = if (body.msg1.isNotBlank()) body.msg1 else "응답 코드 없음 (Raw: $rawJson)" Result.failure(Exception(errorMsg)) } } catch (e: Exception) { Result.failure(e) } } /** * [추가] 기간별(일/주/월) 차트 데이터 조회 * @param periodCode "D"(일), "W"(주), "M"(월) */ suspend fun fetchPeriodChartData( stockCode: String, periodCode: String = "D", isDomestic: Boolean = true ): Result> { val config = KisSession.config val path = if (isDomestic) "/uapi/domestic-stock/v1/quotations/inquire-daily-itemchartprice" else "/uapi/overseas-stock/v1/quotations/inquire-daily-itemchartprice" val today = LocalDate.now() val formatter = DateTimeFormatter.ofPattern("yyyyMMdd") val endDate = today.format(formatter) // [수정] 100개를 가져오기 위해 시작일을 너무 멀지 않게 설정 (약 6개월 전) // 이렇게 하면 종료일(오늘)부터 소급하여 최대 100개의 최신 데이터를 안전하게 가져옵니다. val startDate = when (periodCode) { "D" -> today.minusMonths(6).format(formatter) // 일봉: 6개월치면 100개 충분 "W" -> today.minusYears(2).format(formatter) // 주봉: 2년치 "M" -> today.minusYears(8).format(formatter) // 월봉: 8년치 else -> today.minusYears(1).format(formatter) } return try { val response = client.get("$prodUrl$path") { header("authorization", "Bearer ${config.marketToken}") header("appkey", config.realAppKey) header("appsecret", config.realSecretKey) header("tr_id", if (isDomestic) "FHKST03010100" else "HHDFS76240000") header("custtype", "P") parameter("FID_INPUT_DATE_1", startDate) parameter("FID_INPUT_DATE_2", endDate) parameter("FID_COND_MRKT_DIV_CODE", "J") parameter("FID_INPUT_ISCD", stockCode) parameter("FID_PERIOD_DIV_CODE", periodCode) // D, W, M parameter("FID_ORG_ADJ_PRC", "0") } val body = response.body() val output2 = body["output2"]?.jsonArray // println("output2 ${output2}") val candles = output2?.map { element -> val obj = element.jsonObject CandleData( stck_bsop_date = obj["stck_bsop_date"]?.jsonPrimitive?.content ?: "", stck_prpr = obj["stck_clpr"]?.jsonPrimitive?.content ?: "0", // 분봉/시간 데이터는 stck_prpr이 종가 stck_oprc = obj["stck_oprc"]?.jsonPrimitive?.content ?: "0", stck_hgpr = obj["stck_hgpr"]?.jsonPrimitive?.content ?: "0", stck_lwpr = obj["stck_lwpr"]?.jsonPrimitive?.content ?: "0", cntg_vol = obj["cntg_vol"]?.jsonPrimitive?.content ?: "0", acml_tr_pbmn = obj["acml_tr_pbmn"]?.jsonPrimitive?.content ?: "0", stck_cntg_hour = obj["stck_cntg_hour"]?.jsonPrimitive?.content ?: "0", ) }?.reversed() ?: emptyList() Result.success(candles) } catch (e: Exception) { Result.failure(e) } } /** * [해외 주식 순위] 모델 매핑 오류 수정 */ private suspend fun fetchOverseasRanking(type: RankingType): Result> { val config = KisSession.config val path = "/uapi/overseas-stock/v1/quotations/rank-fluctuation" val trId = "HHDFS76240000" return try { val response = client.get("$prodUrl$path") { header("authorization", "Bearer ${config.marketToken}") header("appkey", config.realAppKey) header("appsecret", config.realSecretKey) header("tr_id", trId) header("custtype", "P") parameter("EXCD", "NAS") // 기본 나스닥 val gubn = when (type) { RankingType.RISE -> "0" RankingType.FALL -> "1" RankingType.VOLUME -> "2" RankingType.VALUE -> "3" else -> "0" } parameter("GUBN", gubn) } // [수정] OverseasRankingResponse로 정확히 파싱 후 변환 val body = response.body() if (body.rt_cd == "0") { Result.success(body.output.map { it.toRankingStock() }) } else { Result.failure(Exception("해외 랭킹 에러: ${body.msg1}")) } } catch (e: Exception) { Result.failure(e) } } /** * [3] 통합 주문 (지정가/시장가 매수/매도) */ suspend fun postOrder( stockCode: String, qty: String, price: String, isBuy: Boolean ): Result { val config = KisSession.config val isDomestic = stockCode.length == 6 && stockCode.all { it.isDigit() } val baseUrl = if (config.isSimulation) vtsUrl else prodUrl // 계좌번호 처리: 8자리면 01 자동 추가 var pureAccount = config.accountNo.replace("-", "").trim() if (pureAccount.length == 8) pureAccount += "01" val cano = pureAccount.take(8) val acntPrdtCd = pureAccount.takeLast(2) val trId = when { isDomestic && config.isSimulation -> if (isBuy) "VTTC0802U" else "VTTC0801U" isDomestic && !config.isSimulation -> if (isBuy) "TTTC0802U" else "TTTC0801U" else -> if (isBuy) "TTTS3002U" else "TTTS3001U" } return try { val response = client.post("$baseUrl/uapi/${if(isDomestic) "domestic" else "overseas"}-stock/v1/trading/order-cash") { 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) header("custtype", "P") // [해결] 필수 헤더 추가 header("Content-Type", "application/json") setBody(mapOf( "CANO" to cano, "ACNT_PRDT_CD" to acntPrdtCd, "PDNO" to stockCode, "ORD_DVSN" to if (price == "0" || price.isEmpty()) "01" else "00", "ORD_QTY" to qty, "ORD_UNPR" to if (price.isEmpty() || price == "0") "0" else price )) } val body = response.body() // [해결] Polymorphic 직렬화 에러 방지 val rtCd = body["rt_cd"]?.jsonPrimitive?.content val msg = body["msg1"]?.jsonPrimitive?.content ?: "메시지 없음" 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) } } /** * [추가] 국내 미체결 내역 조회 */ suspend fun fetchUnfilledOrders(): Result> { val config = KisSession.config if (config.isSimulation) return Result.success(emptyList()) 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}") header("appkey", if (config.isSimulation) config.vtsAppKey else config.realAppKey) header("appsecret", if (config.isSimulation) config.vtsSecretKey else config.realSecretKey) header("tr_id", trId) header("custtype", "P") parameter("CANO", cano) parameter("ACNT_PRDT_CD", acntPrdtCd) parameter("CTX_AREA_FK100", "") parameter("CTX_AREA_NK100", "") parameter("T_GUBUN", "0") parameter("LOAN_DT", "") parameter("P_S_GUBUN", "0") parameter("INQR_DVSN_1", "0") parameter("INQR_DVSN_2", "0") } val body = response.body() if (body.rt_cd == "0") { var result = body Result.success(result.output) } else Result.failure(Exception(body.msg1)) } catch (e: Exception) { Result.failure(e) } } /** * [추가] 주문 취소 (정정/취소 API) */ suspend fun cancelOrder(orgNo: String, stockCode: String): Result { 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) header("appsecret", if (config.isSimulation) config.vtsSecretKey else config.realSecretKey) header("tr_id", trId) header("Content-Type", "application/json") setBody(mapOf( "CANO" to cano, "ACNT_PRDT_CD" to acntPrdtCd, "KRX_FWDG_ORD_ORGNO" to "", // 공란 혹은 지점번호 "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", "QTY_ALL_ORD_YN" to "Y", )) } val body = response.body() if (body["rt_cd"]?.jsonPrimitive?.content == "0") Result.success("취소 완료") else Result.failure(Exception(body["msg1"]?.jsonPrimitive?.content)) } catch (e: Exception) { Result.failure(e) } } /** * [4] 웹소켓 승인키(Approval Key) 발급 */ suspend fun refreshWebsocketKey(): Boolean { val config = KisSession.config return try { val response = client.post("$prodUrl/oauth2/Approval") { header("Content-Type", "application/json") setBody(mapOf("grant_type" to "client_credentials", "appkey" to config.realAppKey, "secretkey" to config.realSecretKey)) } if (response.status == HttpStatusCode.OK) { val approvalKey = response.body>()["approval_key"] if (approvalKey != null) { KisSession.config = KisSession.config.copy(websocketToken = approvalKey,) true } else false } else false } catch (e: Exception) { false } } /** * [5] 차트 데이터 조회 (일봉 기준) */ suspend fun fetchChartData(stockCode: String, isDomestic: Boolean): Result> { val config = KisSession.config // 국내 주식 분봉 조회 TR ID: FHKST03010200 val trId = if (isDomestic) "FHKST03010200" else "HHDFS76240000" val path = if (isDomestic) "/uapi/domestic-stock/v1/quotations/inquire-time-itemchartprice" else "/uapi/overseas-stock/v1/quotations/inquire-time-itemchartprice" val now = LocalTime.now().minusMinutes(30) val searchTime = if (now.isAfter(LocalTime.of(15, 30))) { "150000" } else { now.format(DateTimeFormatter.ofPattern("HHmmss")) } return try { val response = client.get("$prodUrl$path") { header("authorization", "Bearer ${config.marketToken}") header("appkey", config.realAppKey) header("appsecret", config.realSecretKey) header("tr_id", trId) header("custtype", "P") header("content-type", "application/json; charset=utf-8") parameter("FID_ETC_CLS_CODE", "") parameter("FID_COND_MRKT_DIV_CODE", "J") parameter("FID_INPUT_ISCD", stockCode) parameter("FID_INPUT_HOUR_1", searchTime) // 장 마감 시간까지 parameter("FID_PW_DATA_INCU_YN", "Y") // 전일 데이터 포함 여부 } // API 응답에서 output2(캔들 리스트)를 CandleData로 변환 (역순으로 오므로 reverse 필요) val body = response.body() val output2 = body["output2"]?.jsonArray val candles = output2?.map { element -> val obj = element.jsonObject CandleData( stck_bsop_date = obj["stck_bsop_date"]?.jsonPrimitive?.content ?: "", stck_prpr = obj["stck_prpr"]?.jsonPrimitive?.content ?: "0", // 분봉/시간 데이터는 stck_prpr이 종가 stck_oprc = obj["stck_oprc"]?.jsonPrimitive?.content ?: "0", stck_hgpr = obj["stck_hgpr"]?.jsonPrimitive?.content ?: "0", stck_lwpr = obj["stck_lwpr"]?.jsonPrimitive?.content ?: "0", cntg_vol = obj["cntg_vol"]?.jsonPrimitive?.content ?: "0", acml_tr_pbmn = obj["acml_tr_pbmn"]?.jsonPrimitive?.content ?: "0", stck_cntg_hour = obj["stck_cntg_hour"]?.jsonPrimitive?.content ?: "0", ) }?.reversed() ?: emptyList() Result.success(candles) } catch (e: Exception) { Result.failure(e) } } // --- 내부 Raw 호출용 (통합 잔고에서 사용) --- private suspend fun fetchDomesticRawBalance(): Result { val config = KisSession.config val baseUrl = prodUrl val trId = "TTTC8434R" val allHoldings = mutableListOf() var totalBalance: StockBalanceResponse? = null var ctxAreaFk = "" var ctxAreaNk = "" var trCont = "" var pageCount = 1 var pureAccount = config.accountNo.replace("-", "").trim() if (pureAccount.length == 8) pureAccount += "01" val cano = pureAccount.take(8) val acntPrdtCd = pureAccount.takeLast(2) println("🚀 [잔고조회 시작] 계좌: ${config.realAccountNo}") try { do { if (!coroutineContext.isActive) throw _root_ide_package_.io.ktor.utils.io.CancellationException("UI에서 작업을 취소함") // [추가] println("📡 [Step $pageCount] 요청 전송 중... (tr_cont: $trCont)") 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) header("tr_cont", trCont) parameter("CANO", cano) parameter("ACNT_PRDT_CD", acntPrdtCd) parameter("AFHR_FLPR_YN", "N") parameter("OFL_YN", "N") parameter("INQR_DVSN", "0") parameter("UNPR_DVSN", "01") parameter("FUND_STTL_ICLD_YN", "N") parameter("FNCG_AMT_AUTO_RDPT_YN", "N") parameter("PRCS_DVSN", "00") parameter("CTX_AREA_FK100", ctxAreaFk) parameter("CTX_AREA_NK100", ctxAreaNk) } if (!response.status.isSuccess()) { println("❌ [Step $pageCount] HTTP 에러 발생: ${response.status}") return Result.failure(Exception("HTTP Error: ${response.status}")) } val body = response.body() println("✅ [Step $pageCount] 수신 완료 - 종목 수: ${body.output1.size}") allHoldings.addAll(body.output1) if (totalBalance == null) totalBalance = body // 다음 페이지를 위한 헤더 정보 추출 trCont = response.headers["tr_cont"] ?: "D" ctxAreaFk = body.ctx_area_fk100 ?: "" ctxAreaNk = body.ctx_area_nk100 ?: "" println("📝 [Header Check] tr_cont: $trCont, ctx_area_nk100: $ctxAreaNk") if ( trCont == "M") { pageCount++ trCont = "N" println("⏳ [연속 조회] 250ms 대기 후 다음 페이지 요청...") delay(250) // API 과부하 방지 } } while (trCont == "N") println("🎊 [잔고조회 종료] 총 수집 종목: ${allHoldings.size}") return if (totalBalance != null) { Result.success(totalBalance.copy(output1 = allHoldings)) } else { Result.failure(Exception("응답 바디가 비어있습니다.")) } } catch (e: Exception) { if (e is CancellationException) { println("ℹ️ [잔고조회] 사용자가 화면을 벗어나 조회를 중단합니다.") throw e } println("💥 [Fatal Error] 잔고 조회 중 예외 발생: ${e.message}") e.printStackTrace() return Result.failure(e) } } private suspend fun fetchOverseasRawBalance(): Result { // 해외 잔고 조회 API 명세에 맞춰 구현 (국내와 유사하나 TR ID 및 파라미터 다름) return Result.failure(Exception("Not Implemented")) } }