atrade/src/main/kotlin/network/KisTradeService.kt
2026-04-06 09:44:39 +09:00

636 lines
28 KiB
Kotlin
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<Boolean> {
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<JsonObject>()
// 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(marketCode : String = "N"): Result<UnifiedBalance> = coroutineScope {
val config = KisSession.config
// 국내와 해외 잔고를 비동기로 동시 호출
val domesticJob = async { fetchDomesticRawBalance(marketCode) }
val overseasJob = async { fetchOverseasRawBalance() }
try {
val domRes = domesticJob.await().getOrNull()
val ovsRes = overseasJob.await().getOrNull()
val combinedHoldings = mutableListOf<UnifiedStockHolding>()
// 국내 종목 매핑
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<List<RankingStock>> {
return if (isDomestic) {
fetchDomesticRanking(type)
} else {
fetchOverseasRanking(type)
}
}
/**
* [국내 주식 순위] 명세서 기반 파라미터 최적화
*/
private suspend fun fetchDomesticRanking(type: RankingType): Result<List<RankingStock>> {
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<RankingResponse>(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<List<CandleData>> {
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<JsonObject>()
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<List<RankingStock>> {
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<OverseasRankingResponse>()
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,
orderDivision: String = "",
marketCode : String = ""
): Result<String> {
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"
}
val finalOrderDivision = when {
marketCode.equals("SOR") || price == "0" || price.isEmpty() -> "01" // 시장가
else -> "00" // 지정가
}
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(
// "EXCG_ID_DVSN_CD" to marketCode,
"CANO" to cano,
"ACNT_PRDT_CD" to acntPrdtCd,
"PDNO" to stockCode,
"ORD_DVSN" to finalOrderDivision,
"ORD_QTY" to qty,
"ORD_UNPR" to if (price.isEmpty() || price == "0") "0" else price
))
}
val body = response.body<JsonObject>() // [해결] 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<List<UnfilledOrder>> {
val config = KisSession.config
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}")
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<UnfilledResponse>()
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<String> {
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<JsonObject>()
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<Map<String, String>>()["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<List<CandleData>> {
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<JsonObject>()
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(markgetCode : String = "N"): Result<StockBalanceResponse> {
val config = KisSession.config
val baseUrl = prodUrl
val trId = "TTTC8434R"
val allHoldings = mutableListOf<StockHolding>()
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", markgetCode)
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<StockBalanceResponse>()
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<StockBalanceResponse> {
// 해외 잔고 조회 API 명세에 맞춰 구현 (국내와 유사하나 TR ID 및 파라미터 다름)
return Result.failure(Exception("Not Implemented"))
}
}