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 kotlinx.serialization.json.Json import model.AppConfig import model.CandleData import model.ChartResponse import model.OverseasChartResponse import model.OverseasRankingResponse import model.RankingResponse import model.RankingStock import model.RankingType import model.StockBalanceResponse class KisTradeService(private val isSimulation: Boolean) { private val client = HttpClient(CIO) { install(ContentNegotiation) { json(Json { ignoreUnknownKeys = true }) } install(Logging) { logger = Logger.DEFAULT level = LogLevel.INFO // 상세 로그 원하면 LogLevel.BODY } } suspend fun fetchDomesticPreviousDayRanking(token: String, config: AppConfig): Result> { return try { // [수정] URL 경로 확인: /uapi/domestic-stock/v1/quotations/pdy-rank val url = "$baseUrl/uapi/domestic-stock/v1/quotations/pdy-rank" println("📡 [REQ] 국내 전일 등락 조회: $url") val response = client.get(url) { header("authorization", "Bearer $token") header("appkey", config.appKey) header("appsecret", config.secretKey) header("tr_id", "HHPST01710000") header("custtype", "P") header("Content-Type", "application/json; charset=utf-8") // 헤더 명시 parameter("fid_cond_mrkt_div_code", "J") parameter("fid_cond_scr_div_code", "20171") parameter("fid_input_iscd", "0000") parameter("fid_rank_sort_cls_code", "0") parameter("fid_input_cntstr_value", "") parameter("fid_prc_cls_code", "1") } if (response.status != HttpStatusCode.OK) { val errorBody = response.bodyAsText() println("⚠️ [WARN] 서버 응답 에러 (${response.status}): $errorBody") return Result.failure(Exception("HTTP ${response.status}: $errorBody")) } val body = response.body() Result.success(body.output.take(20)) } catch (e: Exception) { println("❌ [ERR] 국내 전일 등락 실패: ${e.message}") Result.failure(e) } } /** * [2] 국내 실시간 마켓 랭킹 (장중용) * TR ID: FHPST01700000 */ suspend fun fetchMarketRanking( token: String, config: AppConfig, type: RankingType, isDomestic: Boolean ): Result> { if (!isDomestic) return Result.failure(Exception("Domestic only")) return try { // [수정] URL 경로 확인: /uapi/domestic-stock/v1/quotations/volume-rank val url = "$baseUrl/uapi/domestic-stock/v1/quotations/volume-rank" println("📡 [REQ] 국내 실시간 랭킹 조회: $url") val response = client.get(url) { header("authorization", "Bearer $token") header("appkey", config.appKey) header("appsecret", config.secretKey) header("tr_id", "FHPST01700000") header("custtype", "P") header("Content-Type", "application/json; charset=utf-8") parameter("fid_cond_mrkt_div_code", "J") parameter("fid_cond_scr_div_code", "20170") parameter("fid_input_iscd", "0000") parameter("fid_div_cls_code", "0") parameter("fid_rank_sort_cls_code", type.code) parameter("fid_etc_cls_code", "0") } if (response.status != HttpStatusCode.OK) { val errorBody = response.bodyAsText() println("⚠️ [WARN] 서버 응답 에러 (${response.status}): $errorBody") return Result.failure(Exception("HTTP ${response.status}")) } val body = response.body() Result.success(body.output.take(20)) } catch (e: Exception) { println("❌ [ERR] 실시간 랭킹 실패: ${e.message}") Result.failure(e) } } private val prodBaseUrl = "https://openapi.koreainvestment.com:9443" // 해외 실시간/전일 등락 상위 suspend fun fetchOverseasRanking(token: String, config: AppConfig): Result> { return try { val response = client.get("$baseUrl/uapi/overseas-stock/v1/quotations/rank-fluctuation") { header("authorization", "Bearer $token") header("appkey", config.appKey) header("appsecret", config.secretKey) header("tr_id", "HHDFS76240000") parameter("EXCD", "NAS") // 나스닥 기준 parameter("GUBN", "0") // 상승률순 } val body = response.body() Result.success(body.output.map { it.toRankingStock() }.take(20)) } catch (e: Exception) { Result.failure(e) } } private val baseUrl = if (isSimulation) "https://openapivts.koreainvestment.com:29443" else "https://openapi.koreainvestment.com:9443" suspend fun fetchBalance( token: String, appKey: String, appSecret: String, accountNo: String ): Result { return try { val cleanAccount = accountNo.filter { it.isDigit() } if (cleanAccount.length != 10) { return Result.failure(Exception("계좌번호 10자리를 입력해주세요.")) } val cano = cleanAccount.take(8) val acntCd = cleanAccount.takeLast(2) // 웹 소스(KisApiService.kt) 54행 로직 적용 // 실전: TTTC8434R / 모의: VTTC8434R (VTRP 아님) val trId = if (isSimulation) "VTTC8434R" else "TTTC8434R" val response = client.get("$baseUrl/uapi/domestic-stock/v1/trading/inquire-balance") { header("authorization", "Bearer $token") header("appkey", appKey) header("appsecret", appSecret) header("tr_id", trId) header("custtype", "P") // 웹 소스 61~72행 파라미터 명칭과 동일하게 세팅 parameter("CANO", cano) parameter("ACNT_PRDT_CD", acntCd) parameter("AFHR_FLPR_YN", "N") // 명칭 수정: AFHR_FLG -> AFHR_FLPR_YN parameter("OFL_YN", "N") // 명칭 수정: OFL_FLG -> OFL_YN 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", "") } if (response.status == HttpStatusCode.OK) { val body = response.body() if (body.rt_cd == "0") { Result.success(body) } else { Result.failure(Exception("API 에러: ${body.msg1} (코드:${body.rt_cd})")) } } else { Result.failure(Exception("HTTP 오류: ${response.status}")) } } catch (e: Exception) { Result.failure(e) } } suspend fun fetchChartData( token: String, appKey: String, appSecret: String, stockCode: String ): Result { return try { val response = client.get("$baseUrl/uapi/domestic-stock/v1/quotations/inquire-daily-itemchartprice") { header("authorization", "Bearer $token") header("appkey", appKey) header("appsecret", appSecret) header("tr_id", "FHKST03010100") // 국내주식 기간별 시세 TR ID header("custtype", "P") parameter("FID_COND_SCR_DIV_CODE", "16.4") parameter("FID_INPUT_ISCD", stockCode) parameter("FID_INPUT_DATE_1", "20240101") // 시작일 (예시) parameter("FID_INPUT_DATE_2", "20260110") // 종료일 parameter("FID_PERIOD_DIV_CODE", "D") // 일봉 parameter("FID_ORG_ADJ_PRC", "0") // 수정주가 반영 } Result.success(response.body()) } catch (e: Exception) { Result.failure(e) } } suspend fun fetchApprovalKey(appKey: String, appSecret: String): String? { return try { val response = client.post("$baseUrl/oauth2/Approval") { header("Content-Type", "application/json") setBody(mapOf("grant_type" to "client_credentials", "appkey" to appKey, "secretkey" to appSecret)) } // 응답에서 approval_key만 추출 (실제 모델 정의 필요) val json = response.body>() json["approval_key"] } catch (e: Exception) { null } } suspend fun fetchOverseasChartData( token: String, appKey: String, appSecret: String, stockCode: String, excd: String = "NAS" // 기본 나스닥 ): Result> { return try { val response = client.get("$baseUrl/uapi/overseas-stock/v1/quotations/inquire-daily-chartprice") { header("authorization", "Bearer $token") header("appkey", appKey) header("appsecret", appSecret) header("tr_id", "HHDFS76240000") // 해외 주식 기간별 시세 TR ID header("custtype", "P") parameter("EXCD", excd) parameter("SYMB", stockCode) parameter("GUBN", "0") // 0: 일봉, 1: 주봉, 2: 월봉 parameter("BYMD", "") // 공백 시 현재일 기준 parameter("MODP", "Y") // 수정주가 반영 } val body = response.body() // 해외 데이터를 공통 CandleData 형식으로 변환하여 차트 컴포저블 재사용 val converted = body.output2.map { CandleData( stck_bsop_date = it.xy_date, stck_oprc = it.open, stck_hgpr = it.high, stck_lwpr = it.low, stck_clpr = it.last, acml_vol = it.t_vol ) }.reversed() Result.success(converted) } catch (e: Exception) { Result.failure(e) } } suspend fun postOrder( token: String, config: AppConfig, stockCode: String, qty: String, price: String, // "0"이면 시장가 isBuy: Boolean ): Result { return try { val cleanAccount = config.accountNo.filter { it.isDigit() } val trId = if (config.isSimulation) { if (isBuy) "VTRP0001U" else "VTRP0002U" // 모의: 매수/매도 } else { if (isBuy) "TTTC0802U" else "TTTC0801U" // 실전: 매수/매도 } val response = client.post("$baseUrl/uapi/domestic-stock/v1/trading/order-cash") { header("authorization", "Bearer $token") header("appkey", config.appKey) header("appsecret", config.secretKey) header("tr_id", trId) header("Content-Type", "application/json") setBody(mapOf( "CANO" to cleanAccount.take(8), "ACNT_PRDT_CD" to cleanAccount.takeLast(2), "PDNO" to stockCode, "ORD_DVSN" to if (price == "0") "01" else "00", // 01:시장가, 00:지정가 "ORD_QTY" to qty, "ORD_UNPR" to price )) } val body = response.body>() if (body["rt_cd"] == "0") { Result.success("주문 성공: ${body["msg1"]}") } else { Result.failure(Exception("${body["msg1"]}")) } } catch (e: Exception) { Result.failure(e) } } }