This commit is contained in:
2026-01-13 16:04:25 +09:00
parent d4770af62f
commit 2bb94e2856
23 changed files with 1406 additions and 1103 deletions
+52 -34
View File
@@ -4,65 +4,83 @@ 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 io.ktor.client.plugins.contentnegotiation.*
import io.ktor.client.plugins.logging.*
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import model.KisSession
import model.TokenRequest
import model.TokenResponse
import java.time.LocalDateTime
class KisAuthService {
private val client = HttpClient(CIO) {
install(ContentNegotiation) {
json(Json {
ignoreUnknownKeys = true
encodeDefaults = true // 기본값(grant_type)이 누락되지 않도록 설정
encodeDefaults = true // 기본값이 포함된 요청 바디를 정확히 전송하기 위해 필요
})
}
// 디버깅을 위해 로그 추가 (인텔 맥 콘솔에서 전송 데이터 확인 가능)
// [수정] 모든 로그(Headers + Body)를 찍도록 설정
install(Logging) {
level = LogLevel.BODY
logger = Logger.DEFAULT
level = LogLevel.ALL // 상세한 디버깅을 위해 ALL로 변경
}
}
private fun getBaseUrl(isSimulation: Boolean): String {
return if (isSimulation) {
"https://openapivts.koreainvestment.com:29443" // 'openapi' 추가됨
private fun getBaseUrl(isSimulation: Boolean) =
if (isSimulation) "https://openapivts.koreainvestment.com:29443"
else "https://openapi.koreainvestment.com:9443"
/**
* 실전(시세용)과 매매(모의/실전 선택) 토큰을 모두 갱신합니다.
*/
suspend fun refreshAllTokens(): Boolean = coroutineScope {
val config = KisSession.config
// 1. 실전 시세용 토큰 발급 (Market Token)
val marketTokenJob = async { fetchAccessToken(config.realAppKey, config.realSecretKey, false) }
// 2. 매매용 토큰 발급 (Trade Token - 설정에 따라 VTS 또는 Real 사용)
val tradeTokenJob = async {
if (config.isSimulation) fetchAccessToken(config.vtsAppKey, config.vtsSecretKey, true)
else marketTokenJob.await() // 실전 매매면 시세용 토큰과 동일함
}
val mResult = marketTokenJob.await()
val tResult = tradeTokenJob.await()
if (mResult.isSuccess && tResult.isSuccess) {
val mData = mResult.getOrThrow()
val tData = tResult.getOrThrow()
// KisSession 업데이트
KisSession.config = KisSession.config.copy(
marketToken = mData.access_token,
marketTokenExpiredAt = LocalDateTime.now().plusSeconds(mData.expires_in),
tradeToken = tData.access_token,
tradeTokenExpiredAt = LocalDateTime.now().plusSeconds(tData.expires_in),
)
true
} else {
"https://openapi.koreainvestment.com:9443"
false
}
}
suspend fun fetchAccessToken(
appKey: String,
secretKey: String,
isSimulation: Boolean
): Result<TokenResponse> {
private suspend fun fetchAccessToken(appKey: String, secretKey: String, isSim: Boolean): Result<TokenResponse> {
return try {
val url = "${getBaseUrl(isSimulation)}/oauth2/tokenP"
val response = client.post(url) {
// 헤더 설정 (매우 중요)
val response = client.post("${getBaseUrl(isSim)}/oauth2/tokenP") {
contentType(ContentType.Application.Json)
// 요청 바디 (TokenRequest 객체 전달)
setBody(TokenRequest(
"client_credentials",
appKey,
secretKey
))
}
if (response.status == HttpStatusCode.OK) {
Result.success(response.body())
} else {
val errorBody = response.bodyAsText()
println("HTTP ${response.status}: $errorBody")
Result.failure(Exception("HTTP ${response.status}: $errorBody"))
setBody(TokenRequest("client_credentials", appKey, secretKey))
}
if (response.status == HttpStatusCode.OK) Result.success(response.body())
else Result.failure(Exception("인증 실패: ${response.status}"))
} catch (e: Exception) {
Result.failure(e)
}
+292 -257
View File
@@ -9,167 +9,333 @@ 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) {
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.*
class KisTradeService {
private val client = HttpClient(CIO) {
install(ContentNegotiation) {
json(Json { ignoreUnknownKeys = true })
json(Json {
ignoreUnknownKeys = true
encodeDefaults = true // 기본값이 포함된 요청 바디를 정확히 전송하기 위해 필요
})
}
// [수정] 모든 로그(Headers + Body)를 찍도록 설정
install(Logging) {
logger = Logger.DEFAULT
level = LogLevel.INFO // 상세 로그 원하면 LogLevel.BODY
level = LogLevel.ALL // 상세한 디버깅을 위해 ALL로 변경
}
}
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)
}
}
private val prodUrl = "https://openapi.koreainvestment.com:9443"
private val vtsUrl = "https://openapivts.koreainvestment.com:29443"
/**
* [2] 국내 실시간 마켓 랭킹 (장중용)
* TR ID: FHPST01700000
* [1] 통합 잔고 조회 (국내 + 해외 합산)
*/
suspend fun fetchMarketRanking(
token: String,
config: AppConfig,
type: RankingType,
isDomestic: Boolean
): Result<List<RankingStock>> {
if (!isDomestic) return Result.failure(Exception("Domestic only"))
suspend fun fetchIntegratedBalance(): Result<UnifiedBalance> = coroutineScope {
val config = KisSession.config
return try {
// [수정] URL 경로 확인: /uapi/domestic-stock/v1/quotations/volume-rank
val url = "$baseUrl/uapi/domestic-stock/v1/quotations/volume-rank"
println("📡 [REQ] 국내 실시간 랭킹 조회: $url")
// 국내와 해외 잔고를 비동기로 동시 호출
val domesticJob = async { fetchDomesticRawBalance() }
val overseasJob = async { fetchOverseasRawBalance() }
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")
try {
val domRes = domesticJob.await().getOrNull()
val ovsRes = overseasJob.await().getOrNull()
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")
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
))
}
if (response.status != HttpStatusCode.OK) {
val errorBody = response.bodyAsText()
println("⚠️ [WARN] 서버 응답 에러 (${response.status}): $errorBody")
return Result.failure(Exception("HTTP ${response.status}"))
// 해외 종목 매핑 (해외 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 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))
val totalAmt = (domRes?.output2?.firstOrNull()?.tot_evlu_amt?.toLongOrNull() ?: 0L) +
(ovsRes?.output2?.firstOrNull()?.tot_evlu_amt?.toLongOrNull() ?: 0L)
Result.success(UnifiedBalance(
totalAsset = String.format("%,d", totalAmt),
totalProfitRate = domRes?.output2?.firstOrNull()?.evlu_pfls_rt ?: "0.0",
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 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> {
/**
* [국내 주식 순위] 명세서 기반 파라미터 최적화
*/
private suspend fun fetchDomesticRanking(type: RankingType): Result<List<RankingStock>> {
val config = KisSession.config
return try {
val cleanAccount = accountNo.filter { it.isDigit() }
if (cleanAccount.length != 10) {
return Result.failure(Exception("계좌번호 10자리를 입력해주세요."))
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 cano = cleanAccount.take(8)
val acntCd = cleanAccount.takeLast(2)
val body = response.body<RankingResponse>()
if (body.rt_cd == "0") Result.success(body.list) else Result.failure(Exception(body.msg1))
} catch (e: Exception) { Result.failure(e) }
}
// 웹 소스(KisApiService.kt) 54행 로직 적용
// 실전: TTTC8434R / 모의: VTTC8434R (VTRP 아님)
val trId = if (isSimulation) "VTTC8434R" else "TTTC8434R"
/**
* [해외 주식 순위] 모델 매핑 오류 수정
*/
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"
val response = client.get("$baseUrl/uapi/domestic-stock/v1/trading/inquire-balance") {
header("authorization", "Bearer $token")
header("appkey", appKey)
header("appsecret", appSecret)
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")
// 웹 소스 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("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, // "0" 이면 시장가
isBuy: Boolean
): Result<String> {
val config = KisSession.config
val isDomestic = stockCode.length == 6 && stockCode.all { it.isDigit() }
val baseUrl = if (config.isSimulation) vtsUrl else prodUrl
val trId = when {
isDomestic && config.isSimulation -> if (isBuy) "VTRP0001U" else "VTRP0002U"
isDomestic && !config.isSimulation -> if (isBuy) "TTTC0802U" else "TTTC0801U"
!isDomestic && config.isSimulation -> if (isBuy) "VTTT3001U" else "VTTT3002U"
else -> if (isBuy) "TTTS3001U" else "TTTS3002U"
}
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("Content-Type", "application/json")
setBody(mapOf(
"CANO" to config.accountNo.take(8),
"ACNT_PRDT_CD" to config.accountNo.takeLast(2),
"PDNO" to stockCode,
"ORD_DVSN" to if (price == "0") "01" else "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) }
}
/**
* [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"
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", "153000") // 장 마감 시간까지
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_clpr = 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",
acml_vol = obj["cntg_vol"]?.jsonPrimitive?.content ?: "0" // 필수 필드 누락 방지
)
}?.reversed() ?: emptyList()
Result.success(candles)
} catch (e: Exception) { Result.failure(e) }
}
// --- 내부 Raw 호출용 (통합 잔고에서 사용) ---
private suspend fun fetchDomesticRawBalance(): Result<StockBalanceResponse> {
val config = KisSession.config
val baseUrl = if (config.isSimulation) vtsUrl else prodUrl
val trId = if (config.isSimulation) "VTTC8434R" else "TTTC8434R"
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", config.accountNo.take(8))
parameter("ACNT_PRDT_CD", config.accountNo.takeLast(2))
parameter("AFHR_FLPR_YN", "N")
parameter("OFL_YN", "N")
parameter("INQR_DVSN", "02")
parameter("UNPR_DVSN", "01")
parameter("FUND_STTL_ICLD_YN", "N")
@@ -178,143 +344,12 @@ class KisTradeService(private val isSimulation: Boolean) {
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)
}
} 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)
}
private suspend fun fetchOverseasRawBalance(): Result<StockBalanceResponse> {
// 해외 잔고 조회 API 명세에 맞춰 구현 (국내와 유사하나 TR ID 및 파라미터 다름)
return Result.failure(Exception("Not Implemented"))
}
}
+58 -25
View File
@@ -6,45 +6,62 @@ import androidx.compose.ui.graphics.Color
import io.ktor.client.*
import io.ktor.client.engine.cio.*
import io.ktor.client.plugins.HttpTimeout
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.plugins.websocket.*
import io.ktor.http.*
import io.ktor.websocket.*
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.consumeAsFlow
import model.AppConfig
import model.KisSession
import model.RealTimeTrade
import model.TradeType
class KisWebSocketManager(private val isSimulation: Boolean) {
val client = HttpClient(CIO) {
class KisWebSocketManager {
private val client = HttpClient(CIO) {
install(WebSockets) {
// 타임아웃 설정 (필요 시)
pingInterval = 20_000
}
install(HttpTimeout) {
requestTimeoutMillis = 15_000
connectTimeoutMillis = 15_000 // 연결 시도 시간을 15초로 늘림
connectTimeoutMillis = 15_000
socketTimeoutMillis = 15_000
}
install(Logging) {
logger = Logger.DEFAULT
level = LogLevel.ALL // 상세한 디버깅을 위해 ALL로 변경
}
}
private var session: DefaultClientWebSocketSession? = null
// Coroutine 관리용 스코프 정의
private var session: DefaultClientWebSocketSession? = null
private val scope = CoroutineScope(Dispatchers.Default + Job())
// UI에서 관찰 상태값
// UI 관찰 상태값
val currentPrice = mutableStateOf("0")
val priceChangeColor = mutableStateOf(Color.Transparent)
val tradeLogs = mutableStateListOf<RealTimeTrade>() // 실시간 체결 내역 리스트
val tradeLogs = mutableStateListOf<RealTimeTrade>()
suspend fun connect() {
val config = KisSession.config
val approvalKey = config.websocketToken
suspend fun connect(approvalKey: String) {
val hostUrl = if (isSimulation) "ops.koreainvestment.com" else "ops.koreainvestment.com"
val port = if (isSimulation) 21001 else 21000
if (approvalKey.isEmpty()) {
println("⚠️ 웹소켓 승인키가 없습니다. 먼저 발급받아야 합니다.")
return
}
// 시세 데이터는 항상 실전 서버(21000)를 권장합니다.
val hostUrl = "ops.koreainvestment.com"
val port = 21000
scope.launch {
try {
client.webSocket(method = HttpMethod.Get, host = hostUrl, port = port, path = "/tryitout/H0STCNT0") {
session = this
// 서버로부터 오는 메시지 수신 루프
println("✅ 웹소켓 연결 성공")
incoming.consumeAsFlow().collect { frame ->
if (frame is Frame.Text) {
parseTradeData(frame.readText())
@@ -52,8 +69,7 @@ class KisWebSocketManager(private val isSimulation: Boolean) {
}
}
} catch (e: Exception) {
println("⚠️ 웹소켓 연결 실패 (장외 시간 또는 서버 점검): ${e.localizedMessage}")
e.printStackTrace()
println(" 웹소켓 연결 오류: ${e.localizedMessage}")
}
}
}
@@ -96,19 +112,38 @@ class KisWebSocketManager(private val isSimulation: Boolean) {
}
}
/**
* [2] 실시간 시세 구독 (Registration)
* tr_type = "1" (등록)
*/
suspend fun subscribeStock(stockCode: String) {
val session = session ?: return
sendRequest(stockCode, trType = "1")
println("📡 실시간 시세 구독 시작: $stockCode")
}
// 이전 구독이 있다면 해지 로직이 필요할 수 있으나,
// 기본적으로 새로운 종목 구독 메시지를 전송합니다.
val approvalKey = "" // 연결 시 저장해둔 키 사용 (필요시 클래스 변수로 저장)
/**
* [3] 실시간 시세 구독 취소 (Unsubscription)
* tr_type = "2" (해제)
*/
suspend fun unsubscribeStock(stockCode: String) {
if (stockCode.isEmpty()) return
sendRequest(stockCode, trType = "2")
println("🚫 실시간 시세 구독 해제: $stockCode")
}
/**
* 공통 요청 전송 함수
*/
private suspend fun sendRequest(stockCode: String, trType: String) {
val currentSession = session ?: return
val config = KisSession.config
val requestJson = """
{
"header": {
"approval_key": "$approvalKey",
"approval_key": "${config.websocketToken}",
"custtype": "P",
"tr_type": "1",
"tr_type": "$trType",
"content-type": "utf-8"
},
"body": {
@@ -118,14 +153,12 @@ class KisWebSocketManager(private val isSimulation: Boolean) {
}
}
}
""".trimIndent()
""".trimIndent()
try {
session.send(Frame.Text(requestJson))
// 기존 체결 로그 초기화
tradeLogs.clear()
currentSession.send(Frame.Text(requestJson))
} catch (e: Exception) {
e.printStackTrace()
println("❌ 웹소켓 요청 실패 ($trType): ${e.localizedMessage}")
}
}
}
@@ -10,7 +10,7 @@ object LlamaServerManager {
private val scope = CoroutineScope(Dispatchers.IO + Job())
fun startServer(binPath: String, modelPath: String) {
if (process != null) return // 이미 실행 중이면 무시
if (process != null || modelPath.isNullOrBlank()) return // 이미 실행 중이면 무시
val command = listOf(
binPath,