...
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user