package network import AutoTradeItem 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.http.* import io.ktor.serialization.kotlinx.json.* 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.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 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" /** * [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 )) } // 해외 종목 매핑 (해외 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 )) } 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 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") parameter("FID_COND_MRKT_DIV_CODE", "J") parameter("FID_COND_SCR_DIV_CODE", type.scrNo) parameter("FID_INPUT_ISCD", "0000") // 전체 시장 parameter("FID_DIV_CLS_CODE", "0") // 전체 parameter("FID_ETC_CLS_CODE", "0") parameter("FID_PRC_CLS_CODE", "0") when(type) { RankingType.VALUE -> { parameter("FID_BLNG_CLS_CODE", type.sortCode) } RankingType.VOLUME -> { parameter("FID_BLNG_CLS_CODE",type.sortCode) } RankingType.FALL -> { parameter("FID_RANK_SORT_CLS_CODE", type.sortCode) } RankingType.RISE -> { parameter("FID_RANK_SORT_CLS_CODE", type.sortCode) } // RankingType.AFTER -> { // parameter("FID_MKOP_CLS_CODE", type.sortCode) // } // RankingType.BEFORE -> { // parameter("FID_MKOP_CLS_CODE", type.sortCode) // } else -> { } } parameter("FID_PBMN", "") parameter("FID_APLY_RANG_PRC_1", "") parameter("FID_TRGT_CLS_CODE", "11111111") parameter("FID_TRGT_EXLS_CLS_CODE", "000000") parameter("FID_RSFL_RATE2", "") parameter("FID_RSFL_RATE1", "") parameter("FID_INPUT_CNT_1", "0") parameter("FID_INPUT_PRICE_1", "") parameter("FID_INPUT_PRICE_2", "") parameter("FID_VOL_CNT", "") parameter("FID_INPUT_DATE_1", "") // 상승/하락률 순위(HHPST01710000)일 경우 추가 파라미터 if (type.trId == "HHPST01710000") { parameter("fid_diff_div_code", "00") // 00: 전일 대비 } } val body = response.body() if (body.rt_cd == "0") Result.success(body.list) else Result.failure(Exception(body.msg1)) } 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 = 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", cano) parameter("ACNT_PRDT_CD", acntPrdtCd) parameter("AFHR_FLPR_YN", "N") parameter("OFL_YN", "N") parameter("INQR_DVSN", "02") 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", "") parameter("CTX_AREA_NK100", "") } Result.success(response.body()) } catch (e: Exception) { Result.failure(e) } } private suspend fun fetchOverseasRawBalance(): Result { // 해외 잔고 조회 API 명세에 맞춰 구현 (국내와 유사하나 TR ID 및 파라미터 다름) return Result.failure(Exception("Not Implemented")) } }