636 lines
28 KiB
Kotlin
636 lines
28 KiB
Kotlin
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"))
|
||
}
|
||
} |