atrade/src/main/kotlin/network/KisTradeService.kt

320 lines
13 KiB
Kotlin
Raw Normal View History

2026-01-10 18:16:50 +09:00
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<List<RankingStock>> {
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<RankingResponse>()
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<List<RankingStock>> {
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<RankingResponse>()
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<List<RankingStock>> {
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<OverseasRankingResponse>()
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<StockBalanceResponse> {
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<StockBalanceResponse>()
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<ChartResponse> {
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<Map<String, String>>()
json["approval_key"]
} catch (e: Exception) {
null
}
}
suspend fun fetchOverseasChartData(
token: String,
appKey: String,
appSecret: String,
stockCode: String,
excd: String = "NAS" // 기본 나스닥
): Result<List<CandleData>> {
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<OverseasChartResponse>()
// 해외 데이터를 공통 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<String> {
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<Map<String, Any>>()
if (body["rt_cd"] == "0") {
Result.success("주문 성공: ${body["msg1"]}")
} else {
Result.failure(Exception("${body["msg1"]}"))
}
} catch (e: Exception) {
Result.failure(e)
}
}
}