From 2bb94e2856f8496a49251ded09b964c047a2a857 Mon Sep 17 00:00:00 2001 From: lunaticbum Date: Tue, 13 Jan 2026 16:04:25 +0900 Subject: [PATCH] ... --- build.gradle.kts | 3 + src/main/kotlin/Main.kt | 65 +-- src/main/kotlin/database/DatabaseFactory.kt | 61 +- src/main/kotlin/model/AppConfig.kt | 53 +- src/main/kotlin/model/AuthModels.kt | 2 +- src/main/kotlin/model/ChartModels.kt | 2 +- src/main/kotlin/model/StockModels.kt | 69 ++- src/main/kotlin/network/KisAuthService.kt | 86 +-- src/main/kotlin/network/KisTradeService.kt | 549 ++++++++++-------- .../kotlin/network/KisWebSocketManager.kt | 83 ++- src/main/kotlin/network/LlamaServerManager.kt | 2 +- src/main/kotlin/ui/AiAnalysisView.kt | 37 +- src/main/kotlin/ui/BalanceSection.kt | 146 +++++ src/main/kotlin/ui/CandleChart.kt | 15 +- src/main/kotlin/ui/DashboardScreen.kt | 282 +-------- src/main/kotlin/ui/MarketSection.kt | 101 ++++ src/main/kotlin/ui/MarketStockItemRow.kt | 62 ++ src/main/kotlin/ui/OrderSection.kt | 111 ++-- src/main/kotlin/ui/RealTimeTradeList.kt | 43 ++ src/main/kotlin/ui/RecommendationTabs.kt | 304 +++++----- src/main/kotlin/ui/SettingsScreen.kt | 170 +++--- src/main/kotlin/ui/StockDetailArea.kt | 183 +++--- src/main/kotlin/ui/StockHeader.kt | 80 +++ 23 files changed, 1406 insertions(+), 1103 deletions(-) create mode 100644 src/main/kotlin/ui/BalanceSection.kt create mode 100644 src/main/kotlin/ui/MarketSection.kt create mode 100644 src/main/kotlin/ui/MarketStockItemRow.kt create mode 100644 src/main/kotlin/ui/RealTimeTradeList.kt create mode 100644 src/main/kotlin/ui/StockHeader.kt diff --git a/build.gradle.kts b/build.gradle.kts index 4e1ace4..77ce513 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -27,6 +27,9 @@ dependencies { implementation("io.ktor:ktor-client-content-negotiation:$ktorVersion") implementation("io.ktor:ktor-serialization-kotlinx-json:$ktorVersion") implementation("io.ktor:ktor-client-logging:${ktorVersion}") + + implementation("ch.qos.logback:logback-classic:1.4.11") + // Database (Exposed & SQLite) // H2 Database (네이티브 라이브러리 없는 순수 자바 DB) implementation("com.h2database:h2:2.2.224") diff --git a/src/main/kotlin/Main.kt b/src/main/kotlin/Main.kt index 18a8726..b72acc1 100644 --- a/src/main/kotlin/Main.kt +++ b/src/main/kotlin/Main.kt @@ -3,21 +3,14 @@ import androidx.compose.material.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Window import androidx.compose.ui.window.application -import kotlinx.coroutines.launch -import network.KisAuthService import org.jetbrains.exposed.sql.* import org.jetbrains.exposed.sql.transactions.transaction -import androidx.compose.runtime.* -import androidx.compose.ui.window.Window -import androidx.compose.ui.window.application import model.AppConfig +import model.KisSession import network.LlamaServerManager import org.jetbrains.exposed.sql.selectAll -import org.jetbrains.exposed.sql.transactions.transaction import ui.DashboardScreen import ui.SettingsScreen @@ -25,58 +18,58 @@ import ui.SettingsScreen enum class AppScreen { Settings, Dashboard } fun main() = application { - // 앱 경로 기준 리소스 위치 설정 + // 앱 실행 시 필요한 바이너리 경로 (실행 파일 위치) val binPath = "./src/main/resources/bin/llama-server" - val modelPath = "./src/main/resources/models/gemma-2-9b-it-Q4_K_M.gguf" - - LaunchedEffect(Unit) { - LlamaServerManager.startServer(binPath, modelPath) - } Window(onCloseRequest = ::exitApplication, title = "KIS AI 자동매매") { var currentScreen by remember { mutableStateOf(AppScreen.Settings) } - // 1. 초기 상태를 null로 두어 로딩 전임을 표시 - var savedConfig by remember { mutableStateOf(null) } - var token by remember { mutableStateOf("") } - var selectedStockCode by remember { mutableStateOf(null) } + var isLoaded by remember { mutableStateOf(false) } + val scope = rememberCoroutineScope() - // 앱 시작 시 DB에서 마지막 설정 로드 + // 1. 앱 시작 시 DB에서 마지막 설정 로드 (KisSession에 주입) LaunchedEffect(Unit) { DatabaseFactory.init() - val loaded = transaction { + transaction { ConfigTable.selectAll().lastOrNull()?.let { - AppConfig( - appKey = it[ConfigTable.appKey], - secretKey = it[ConfigTable.secretKey], - accountNo = it[ConfigTable.accountNo], + KisSession.config = AppConfig( + realAppKey = it[ConfigTable.realAppKey], + realSecretKey = it[ConfigTable.realSecretKey], + realAccountNo = it[ConfigTable.realAccountNo], + vtsAppKey = it[ConfigTable.vtsAppKey], + vtsSecretKey = it[ConfigTable.vtsSecretKey], + vtsAccountNo = it[ConfigTable.vtsAccountNo], isSimulation = it[ConfigTable.isSimulation], modelPath = it[ConfigTable.modelPath] ) } } - // 로드된 값이 있으면 업데이트, 없으면 기본 객체 생성 - savedConfig = loaded ?: AppConfig() + isLoaded = true } - // savedConfig가 로드될 때까지 기다림 (깜빡임 방지 및 데이터 보장) - if (savedConfig == null) { - Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - CircularProgressIndicator() - } + if (!isLoaded) { + // 로딩 중 표시 + CircularProgressIndicator() } else { when (currentScreen) { AppScreen.Settings -> { SettingsScreen( - initialConfig = savedConfig!!, // !! 사용하여 null 아님을 보장 - onAuthSuccess = { config, accessToken -> - savedConfig = config - token = accessToken + onAuthSuccess = { + // 2. 설정 및 인증 완료 시점의 처리 + val config = KisSession.config + + // LLM 서버 시작 (설정된 모델 경로 사용) + if (config.modelPath.isNotEmpty()) { + LlamaServerManager.startServer(binPath, config.modelPath) + } + + // 대시보드로 화면 전환 currentScreen = AppScreen.Dashboard } ) } AppScreen.Dashboard -> { - DashboardScreen(config = savedConfig!!, token = token) + // 이제 모든 서비스는 KisSession.config를 전역 참조함 + DashboardScreen() } } } diff --git a/src/main/kotlin/database/DatabaseFactory.kt b/src/main/kotlin/database/DatabaseFactory.kt index f35942a..7f91489 100644 --- a/src/main/kotlin/database/DatabaseFactory.kt +++ b/src/main/kotlin/database/DatabaseFactory.kt @@ -1,3 +1,4 @@ +import model.AppConfig import org.jetbrains.exposed.sql.* import org.jetbrains.exposed.sql.javatime.datetime import org.jetbrains.exposed.sql.transactions.transaction @@ -7,13 +8,17 @@ import java.time.LocalDateTime // 1. 앱 설정 테이블 object ConfigTable : Table("app_config") { val id = integer("id").autoIncrement() - val appKey = varchar("app_key", 255) - val secretKey = varchar("secret_key", 255) - val accountNo = varchar("account_no", 20) - val isSimulation = bool("is_simulation") - val modelPath = varchar("model_path", 512).default("") // 이 라인이 있어야 합니다. + val realAppKey = varchar("real_app_key", 255).default("") + val realSecretKey = varchar("real_secret_key", 255).default("") + val realAccountNo = varchar("real_account_no", 20).default("") + val vtsAppKey = varchar("vts_app_key", 255).default("") + val vtsSecretKey = varchar("vts_secret_key", 255).default("") + val vtsAccountNo = varchar("vts_account_no", 20).default("") + val isSimulation = bool("is_simulation").default(true) + val modelPath = varchar("model_path", 512).default("") override val primaryKey = PrimaryKey(id) } + // 2. 거래 내역 테이블 (대량 데이터용) object TradeLogTable : Table("trade_logs") { val id = long("id").autoIncrement() @@ -56,14 +61,40 @@ object DatabaseFactory { } } -// fun fetchRecentLogs(limit: Int = 50): List { -// return transaction { -// TradeLogTable.selectAll() -// .orderBy(TradeLogTable.timestamp to SortOrder.DESC) -// .limit(limit) -// .map { -// // ResultRow를 객체로 변환 -// } -// } -// } + fun findConfigByAccount(accountNo: String): AppConfig? { + return transaction { + ConfigTable.select { + (ConfigTable.realAccountNo eq accountNo) or (ConfigTable.vtsAccountNo eq accountNo) + }.lastOrNull()?.let { + AppConfig( + realAppKey = it[ConfigTable.realAppKey], + realSecretKey = it[ConfigTable.realSecretKey], + realAccountNo = it[ConfigTable.realAccountNo], + vtsAppKey = it[ConfigTable.vtsAppKey], + vtsSecretKey = it[ConfigTable.vtsSecretKey], + vtsAccountNo = it[ConfigTable.vtsAccountNo], + isSimulation = it[ConfigTable.isSimulation], + modelPath = it[ConfigTable.modelPath] + ) + } + } + } + + fun saveConfig(config: AppConfig) { + transaction { + // 기존 설정을 모두 지우고 최신 설정 하나만 유지 + ConfigTable.deleteAll() + ConfigTable.insert { + it[realAppKey] = config.realAppKey + it[realSecretKey] = config.realSecretKey + it[vtsAppKey] = config.vtsAppKey + it[vtsSecretKey] = config.vtsSecretKey + it[realAccountNo] = config.realAccountNo + it[vtsAccountNo] = config.vtsAccountNo + it[isSimulation] = config.isSimulation + it[modelPath] = config.modelPath + } + } + } + } \ No newline at end of file diff --git a/src/main/kotlin/model/AppConfig.kt b/src/main/kotlin/model/AppConfig.kt index cb8bb6e..b2edb25 100644 --- a/src/main/kotlin/model/AppConfig.kt +++ b/src/main/kotlin/model/AppConfig.kt @@ -1,11 +1,52 @@ package model -import kotlinx.serialization.Serializable +import java.time.LocalDateTime data class AppConfig( - val appKey: String = "", - val secretKey: String = "", - val accountNo: String = "", + // [DB 저장 데이터] + // 실전 3종 + val realAppKey: String = "", + val realSecretKey: String = "", + val realAccountNo: String = "", + + // 모의 3종 + val vtsAppKey: String = "", + val vtsSecretKey: String = "", + val vtsAccountNo: String = "", + + // [세션 데이터 - 메모리에서만 관리] + var marketToken: String = "", + var marketTokenExpiredAt: LocalDateTime? = null, // 만료 시간 추가 + + var tradeToken: String = "", + var tradeTokenExpiredAt: LocalDateTime? = null, + + var websocketToken: String = "", val isSimulation: Boolean = true, - val modelPath: String = "" // 추가된 필드 -) \ No newline at end of file + val modelPath: String = "") { + + val accountNo : String + get() { + return if (isSimulation) vtsAccountNo else realAccountNo + } +} + + + + +// [신규] 전역에서 참조할 단일 세션 객체 +object KisSession { + var config: AppConfig = AppConfig() + + // 시장 데이터 토큰 유효성 검사 (만료 5분 전부터는 유효하지 않은 것으로 간주) + fun isMarketTokenValid(): Boolean { + return config.marketToken.isNotEmpty() && + config.marketTokenExpiredAt?.isAfter(LocalDateTime.now().plusMinutes(5)) ?: false + } + + // 매매용 토큰 유효성 검사 + fun isTradeTokenValid(): Boolean { + return config.tradeToken.isNotEmpty() && + config.tradeTokenExpiredAt?.isAfter(LocalDateTime.now().plusMinutes(5)) ?: false + } +} \ No newline at end of file diff --git a/src/main/kotlin/model/AuthModels.kt b/src/main/kotlin/model/AuthModels.kt index f4619cf..23c9922 100644 --- a/src/main/kotlin/model/AuthModels.kt +++ b/src/main/kotlin/model/AuthModels.kt @@ -17,5 +17,5 @@ data class TokenResponse( val access_token: String, val access_token_token_expired: String? = null, val token_type: String? = null, - val expires_in: Int? = null + val expires_in: Long = 0L ) \ No newline at end of file diff --git a/src/main/kotlin/model/ChartModels.kt b/src/main/kotlin/model/ChartModels.kt index 2443495..5bc77c9 100644 --- a/src/main/kotlin/model/ChartModels.kt +++ b/src/main/kotlin/model/ChartModels.kt @@ -22,7 +22,7 @@ data class CandleData( val stck_hgpr: String, // 고가 val stck_lwpr: String, // 저가 val stck_clpr: String, // 종가 - val acml_vol: String // 누적 거래량 + val acml_vol: String ="" // 누적 거래량 ) @Serializable data class OverseasCandleData( diff --git a/src/main/kotlin/model/StockModels.kt b/src/main/kotlin/model/StockModels.kt index a537b8c..cd2abc5 100644 --- a/src/main/kotlin/model/StockModels.kt +++ b/src/main/kotlin/model/StockModels.kt @@ -1,5 +1,6 @@ package model +import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Serializable @@ -30,28 +31,53 @@ data class BalanceSummary( ) @Serializable data class RankingResponse( + var rt_cd : String, + var msg1 : String, + var msg_cd : String, + val output1: List = emptyList(), val output: List = emptyList() -) - -enum class RankingType(val code: String, val title: String) { - RISE("0", "상승"), - FALL("1", "하락"), - VOLUME("2", "거래량"), - AMOUNT("3", "금액"), - OVERTIME("4", "시간외"), // 웹 소스의 시간외 상승 TR 연동 - SHORT_HOT("5", "단기추천") // 단기 과열 및 추천 종목 +) { + val list = output + output1 + emptyList() } +enum class RankingType( + val title: String, + val trId: String, + val scrNo: String, + val path: String, + val sortCode: String // 추가: 각 TR ID에 맞는 정렬 코드 +) { + VOLUME("거래량순", "FHPST01710000", "20171", "/uapi/domestic-stock/v1/quotations/volume-rank", "0"), + VALUE("거래대금순", "FHPST01710000", "20171", "/uapi/domestic-stock/v1/quotations/volume-rank", "3"), + RISE("상승률순", "FHPST01700000", "20170", "/uapi/domestic-stock/v1/ranking/fluctuation", "0"), + FALL("하락률순", "FHPST01700000", "20170", "/uapi/domestic-stock/v1/ranking/fluctuation", "1"), + // MARKET_CAP("시가총액순", "FHPST01740000", "20174", "/uapi/domestic-stock/v1/quotations/market-cap", "0"), +// HTS_TOP20("HTS조회상위", "HHMCM000100C0", "20175", "/uapi/domestic-stock/v1/ranking/hts-top-view", "0"), + // 링크로 전달주신 추가 기능 보완 + VOLUME_POWER("체결강도순", "FHPST01680000", "20168", "/uapi/domestic-stock/v1/ranking/volume-power", "0"), +// BEFORE("장전예상", "FHPST01820000", "20182", "/uapi/domestic-stock/v1/ranking/exp-trans-updown", "0"), +// AFTER("장후예상", "FHPST01820000", "20182", "/uapi/domestic-stock/v1/ranking/exp-trans-updown", "1") +} @Serializable data class RankingStock( + val hts_kor_isnm: String = "", // 종목명 val hts_kor_alph_nm: String = "", // 종목명 val mkrtc_objt_iscd: String = "", // 종목코드 + val mksc_shrn_iscd: String = "", // 종목코드 val stck_prpr: String = "0", // 현재가 - val prdy_ctrt: String = "0.0" // 등락률 -) + val prdy_ctrt: String = "0.0", // 등락률 + val mrkt_div_cls_code : String = "J", +) { + val name : String + get() = hts_kor_isnm ?: hts_kor_alph_nm ?: mkrtc_objt_iscd ?: "" + val code : String + get() = mksc_shrn_iscd ?: mkrtc_objt_iscd ?: hts_kor_isnm ?: "" +} @Serializable data class OverseasRankingResponse( + val rt_cd: String = "", + val msg1: String = "", val output: List = emptyList() ) @@ -70,4 +96,23 @@ data class OverseasRankingStock( stck_prpr = last, prdy_ctrt = rate ) -} \ No newline at end of file +} + +@Serializable +data class UnifiedStockHolding( + val code: String, // 종목코드 + val name: String, // 종목명 + val quantity: String, // 보유수량 + val avgPrice: String, // 매입단가 + val currentPrice: String, // 현재가 + val profitRate: String, // 수익률 + val evalAmount: String, // 평가금액 + val isDomestic: Boolean // 국내/해외 구분 +) + +@Serializable +data class UnifiedBalance( + val totalAsset: String, // 총 평가자산 + val totalProfitRate: String, // 총 수익률 + val holdings: List // 통합 보유 종목 리스트 +) diff --git a/src/main/kotlin/network/KisAuthService.kt b/src/main/kotlin/network/KisAuthService.kt index fb73661..935f284 100644 --- a/src/main/kotlin/network/KisAuthService.kt +++ b/src/main/kotlin/network/KisAuthService.kt @@ -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 { + private suspend fun fetchAccessToken(appKey: String, secretKey: String, isSim: Boolean): Result { 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) } diff --git a/src/main/kotlin/network/KisTradeService.kt b/src/main/kotlin/network/KisTradeService.kt index a84a819..92cbbb2 100644 --- a/src/main/kotlin/network/KisTradeService.kt +++ b/src/main/kotlin/network/KisTradeService.kt @@ -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> { - 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() - 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> { - if (!isDomestic) return Result.failure(Exception("Domestic only")) + suspend fun fetchIntegratedBalance(): Result = 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() + + // 국내 종목 매핑 + 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() - 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> { - 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() - 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> { + 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 { + /** + * [국내 주식 순위] 명세서 기반 파라미터 최적화 + */ + private suspend fun fetchDomesticRanking(type: RankingType): Result> { + 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() + 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> { + 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() + 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 { + 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>() + 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>()["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> { + 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() + 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 { + 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() - 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 { - 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>() - json["approval_key"] - } catch (e: Exception) { - null - } - } - - suspend fun fetchOverseasChartData( - token: String, - appKey: String, - appSecret: String, - stockCode: String, - excd: String = "NAS" // 기본 나스닥 - ): Result> { - 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() - // 해외 데이터를 공통 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 { - 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>() - 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 { + // 해외 잔고 조회 API 명세에 맞춰 구현 (국내와 유사하나 TR ID 및 파라미터 다름) + return Result.failure(Exception("Not Implemented")) } } \ No newline at end of file diff --git a/src/main/kotlin/network/KisWebSocketManager.kt b/src/main/kotlin/network/KisWebSocketManager.kt index b89a49e..aaec7e7 100644 --- a/src/main/kotlin/network/KisWebSocketManager.kt +++ b/src/main/kotlin/network/KisWebSocketManager.kt @@ -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() // 실시간 체결 내역 리스트 + val tradeLogs = mutableStateListOf() + 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}") } } } \ No newline at end of file diff --git a/src/main/kotlin/network/LlamaServerManager.kt b/src/main/kotlin/network/LlamaServerManager.kt index bc1deb3..b6f1da3 100644 --- a/src/main/kotlin/network/LlamaServerManager.kt +++ b/src/main/kotlin/network/LlamaServerManager.kt @@ -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, diff --git a/src/main/kotlin/ui/AiAnalysisView.kt b/src/main/kotlin/ui/AiAnalysisView.kt index f119979..2eaa4b2 100644 --- a/src/main/kotlin/ui/AiAnalysisView.kt +++ b/src/main/kotlin/ui/AiAnalysisView.kt @@ -21,25 +21,26 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import kotlinx.coroutines.launch +import model.KisSession import model.RealTimeTrade import network.AiService @Composable -fun AiAnalysisView(stockName: String, currentPrice: String, trades: List) { +fun AiAnalysisView(stockName: String, currentPrice: String, trades: List) { var aiOpinion by remember { mutableStateOf("분석 대기 중...") } var isLoading by remember { mutableStateOf(false) } val scope = rememberCoroutineScope() - // 1. 모델 경로 유효성 체크 - val isModelConfigured = remember { - val path = util.AppConfigManager.modelPath + // KisSession의 전역 설정을 참조 + val isModelConfigured = remember(KisSession.config.modelPath) { + val path = KisSession.config.modelPath path.isNotEmpty() && java.io.File(path).exists() } Card( elevation = 2.dp, - modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), - backgroundColor = if (isModelConfigured) Color(0xFFF1F3F4) else Color(0xFFFFEBEE) + backgroundColor = if (isModelConfigured) Color(0xFFF1F3F4) else Color(0xFFFFEBEE), + modifier = Modifier.fillMaxWidth() ) { Column(modifier = Modifier.padding(12.dp)) { Row(verticalAlignment = Alignment.CenterVertically) { @@ -49,38 +50,22 @@ fun AiAnalysisView(stockName: String, currentPrice: String, trades: List Unit +) { + var balanceData by remember { mutableStateOf(null) } + var isLoading by remember { mutableStateOf(false) } + + // 화면 진입 시 및 갱신 시 데이터 로드 + LaunchedEffect(Unit) { + isLoading = true + tradeService.fetchIntegratedBalance().onSuccess { + balanceData = it + }.onFailure { + println("❌ 잔고 로드 실패: ${it.localizedMessage}") + } + isLoading = false + } + + Column(modifier = Modifier.fillMaxSize()) { + Text( + text = "나의 자산", + style = MaterialTheme.typography.h6, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(bottom = 8.dp) + ) + + // 1. 자산 요약 카드 + BalanceSummaryCard(balanceData) + + Spacer(modifier = Modifier.height(16.dp)) + + // 2. 통합 보유 종목 리스트 + Text( + text = "보유 종목", + style = MaterialTheme.typography.subtitle1, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(vertical = 8.dp) + ) + + if (isLoading) { + Box(Modifier.fillMaxSize(), contentAlignment = androidx.compose.ui.Alignment.Center) { + CircularProgressIndicator() + } + } else { + LazyColumn(modifier = Modifier.weight(1f, true)) { + items(balanceData?.holdings ?: emptyList()) { holding -> + UnifiedStockItemRow(holding) { + onStockSelect(holding.code, holding.name, holding.isDomestic) + } + } + } + } + } +} + +@Composable +fun BalanceSummaryCard(summary: UnifiedBalance?) { + Card( + elevation = 2.dp, + shape = androidx.compose.foundation.shape.RoundedCornerShape(8.dp), + modifier = Modifier.fillMaxWidth(), + backgroundColor = androidx.compose.ui.graphics.Color.White + ) { + Column(modifier = Modifier.padding(16.dp)) { + Text("총 평가 자산", style = MaterialTheme.typography.caption, color = androidx.compose.ui.graphics.Color.Gray) + Text( + text = "${summary?.totalAsset ?: "0"} 원", + style = MaterialTheme.typography.h5, + fontWeight = FontWeight.Bold + ) + + val rate = summary?.totalProfitRate?.toDoubleOrNull() ?: 0.0 + val color = if (rate > 0) androidx.compose.ui.graphics.Color.Red + else if (rate < 0) androidx.compose.ui.graphics.Color.Blue + else androidx.compose.ui.graphics.Color.DarkGray + + Text( + text = "수익률: ${if (rate > 0) "+" else ""}$rate%", + color = color, + style = MaterialTheme.typography.body2, + fontWeight = FontWeight.Medium + ) + } + } +} + +@Composable +fun UnifiedStockItemRow(holding: model.UnifiedStockHolding, onClick: () -> Unit) { + Card( + modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp).clickable { onClick() }, + elevation = 1.dp + ) { + Row(modifier = Modifier.padding(12.dp), verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) { + Column(modifier = Modifier.weight(1f)) { + Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) { + // 국내/해외 구분 배지 + Surface( + color = if (holding.isDomestic) androidx.compose.ui.graphics.Color(0xFFE3F2FD) + else androidx.compose.ui.graphics.Color(0xFFF3E5F5), + shape = androidx.compose.foundation.shape.RoundedCornerShape(4.dp) + ) { + Text( + text = if (holding.isDomestic) "국내" else "해외", + modifier = Modifier.padding(horizontal = 4.dp, vertical = 2.dp), + fontSize = 10.sp, + color = if (holding.isDomestic) androidx.compose.ui.graphics.Color.Blue + else androidx.compose.ui.graphics.Color(0xFF7B1FA2) + ) + } + Spacer(Modifier.width(4.dp)) + Text(holding.name, fontWeight = FontWeight.Bold, maxLines = 1) + } + Text(holding.code, style = MaterialTheme.typography.caption, color = androidx.compose.ui.graphics.Color.Gray) + } + + Column(horizontalAlignment = androidx.compose.ui.Alignment.End) { + Text("${holding.currentPrice} 원") + val rate = holding.profitRate.toDoubleOrNull() ?: 0.0 + Text( + text = "${if (rate > 0) "▲" else if (rate < 0) "▼" else ""}${holding.profitRate}%", + color = if (rate > 0) androidx.compose.ui.graphics.Color.Red + else if (rate < 0) androidx.compose.ui.graphics.Color.Blue + else androidx.compose.ui.graphics.Color.DarkGray, + fontSize = 12.sp + ) + } + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/ui/CandleChart.kt b/src/main/kotlin/ui/CandleChart.kt index e9b090b..2feb391 100644 --- a/src/main/kotlin/ui/CandleChart.kt +++ b/src/main/kotlin/ui/CandleChart.kt @@ -22,19 +22,22 @@ fun CandleChart(data: List, modifier: Modifier = Modifier) { val spacing = candleWidth * 0.2f // 캔들 사이 간격 // 1. 가격 범위 계산 (스케일링용) - val maxPrice = data.maxOf { it.stck_hgpr.toDouble() } - val minPrice = data.minOf { it.stck_lwpr.toDouble() } + val maxPrice = data.maxOf { it.stck_hgpr.toDoubleOrNull() ?: 0.0 } + val minPrice = data.minOf { it.stck_lwpr.toDoubleOrNull() ?: 0.0 } val priceRange = maxPrice - minPrice + // priceRange가 0일 경우(데이터가 모두 같을 때) 분모가 0이 되는 것 방지 fun getY(price: Double): Float { + if (priceRange == 0.0) return height / 2f return (height - ((price - minPrice) / priceRange * height)).toFloat() } +// 루프 내부에서도 동일하게 적용 data.forEachIndexed { index, candle -> - val open = candle.stck_oprc.toDouble() - val close = candle.stck_clpr.toDouble() - val high = candle.stck_hgpr.toDouble() - val low = candle.stck_lwpr.toDouble() + val open = candle.stck_oprc.toDoubleOrNull() ?: 0.0 + val close = candle.stck_clpr.toDoubleOrNull() ?: 0.0 + val high = candle.stck_hgpr.toDoubleOrNull() ?: 0.0 + val low = candle.stck_lwpr.toDoubleOrNull() ?: 0.0 val isRising = close >= open val color = if (isRising) Color(0xFFE03E2D) else Color(0xFF0E62CF) diff --git a/src/main/kotlin/ui/DashboardScreen.kt b/src/main/kotlin/ui/DashboardScreen.kt index 9301aa1..e9057fb 100644 --- a/src/main/kotlin/ui/DashboardScreen.kt +++ b/src/main/kotlin/ui/DashboardScreen.kt @@ -1,302 +1,78 @@ +// src/main/kotlin/ui/DashboardScreen.kt package ui import androidx.compose.foundation.background -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items // 반드시 수동 import 확인 -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.* import androidx.compose.runtime.* - -import io.ktor.client.engine.cio.CIO -// 아래 두 import가 'delegate' 에러를 해결합니다. -import androidx.compose.runtime.getValue -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import kotlinx.coroutines.launch -import model.AppConfig -import model.BalanceSummary -import model.RankingStock -import model.RankingType -import model.StockHolding +import model.KisSession import network.KisTradeService import network.KisWebSocketManager -import util.MarketUtil @Composable -fun DashboardScreen(config: AppConfig, token: String) { - val wsManager = remember { KisWebSocketManager(config.isSimulation) } - val tradeService = remember { KisTradeService(config.isSimulation) } +fun DashboardScreen() { + val tradeService = remember { KisTradeService() } + val wsManager = remember { KisWebSocketManager() } - // 전역 상태: 현재 선택된 종목 + // 전역 상태: 현재 선택된 종목 정보 var selectedStockCode by remember { mutableStateOf("") } var selectedStockName by remember { mutableStateOf("") } + var isDomestic by remember { mutableStateOf(true) } - // 잔고 데이터 상태 - var holdings by remember { mutableStateOf>(emptyList()) } - var summary by remember { mutableStateOf(null) } - - // 초기 데이터 로드 및 웹소켓 연결 + // 초기 웹소켓 연결 LaunchedEffect(Unit) { - val approvalKey = tradeService.fetchApprovalKey(config.appKey, config.secretKey) - approvalKey?.let { wsManager.connect(it) } - - tradeService.fetchBalance(token, config.appKey, config.secretKey, config.accountNo) - .onSuccess { - holdings = it.output1 - summary = it.output2.firstOrNull() - } + wsManager.connect() } - // 메인 3분할 레이아웃 Row(modifier = Modifier.fillMaxSize().background(Color(0xFFF2F2F2))) { - - // [좌측 25%] 나의 자산 및 잔고 + // [좌측 25%] 내 자산 및 통합 잔고 Column(modifier = Modifier.weight(0.25f).fillMaxHeight().padding(8.dp)) { - Text("나의 잔고", style = MaterialTheme.typography.h6, fontWeight = FontWeight.Bold) - Spacer(modifier = Modifier.height(8.dp)) - BalanceSummaryCard(summary) - Spacer(modifier = Modifier.height(8.dp)) - MyStockList(holdings) { code, name -> + BalanceSection(tradeService) { code, name, isDom -> selectedStockCode = code selectedStockName = name + isDomestic = isDom + println("selectedStockCode $selectedStockCode selectedStockName $selectedStockName isDomestic $isDomestic") } } VerticalDivider() - // [중앙 45%] 실시간 차트 및 주문 (가장 중요) - Column(modifier = Modifier.weight(0.45f).fillMaxHeight().background(Color.White).padding(12.dp)) { + // [중앙 45%] 실시간 정보 및 주문 + Column(modifier = Modifier.weight(0.45f).fillMaxHeight().background(Color.White)) { if (selectedStockCode.isNotEmpty()) { - StockDetailArea(config, token, selectedStockCode, selectedStockName, wsManager) + StockDetailSection( + stockCode = selectedStockCode, + stockName = selectedStockName, + isDomestic = isDomestic, + tradeService = tradeService, + wsManager = wsManager + ) } else { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Text("좌측 잔고나 우측 추천 종목을 클릭하세요", color = Color.Gray) + Text("분석할 종목을 선택하세요", color = Color.Gray) } } } VerticalDivider() - // [우측 30%] 시장 추천 리스트 (탭 방식) + // [우측 30%] 시장 추천 TOP 20 (실전 데이터) Column(modifier = Modifier.weight(0.3f).fillMaxHeight().padding(8.dp)) { - Text("시장 추천 TOP 20", style = MaterialTheme.typography.h6, fontWeight = FontWeight.Bold) - Spacer(modifier = Modifier.height(8.dp)) - RecommendationTabs(config, token) { code, name -> + MarketSection(tradeService) { code, name, isDom -> selectedStockCode = code selectedStockName = name + isDomestic = isDom + println("selectedStockCode $selectedStockCode selectedStockName $selectedStockName isDomestic $isDomestic") } } } } @Composable -fun StockItemRow(stock: StockHolding) { - Row( - modifier = Modifier.fillMaxWidth().padding(16.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Column(modifier = Modifier.weight(1f)) { - Text(stock.prdt_name, fontWeight = FontWeight.Bold, fontSize = 16.sp) - Text(stock.pdno, fontSize = 12.sp, color = Color.Gray) - } - - Column(horizontalAlignment = Alignment.End) { - Text("${stock.prpr} 원", fontWeight = FontWeight.Bold) - - // 수익률에 따른 색상 처리 (웹 소스 format.color 로직 이식) - val rate = stock.evlu_pfls_rt.toDoubleOrNull() ?: 0.0 - val color = when { - rate > 0 -> Color(0xFFE03E2D) // 웹 소스의 빨간색 - rate < 0 -> Color(0xFF0E62CF) // 웹 소스의 파란색 - else -> Color.DarkGray - } - - Text( - text = "${if(rate > 0) "▲" else if(rate < 0) "▼" else ""} ${stock.evlu_pfls_rt}%", - color = color, - fontSize = 13.sp, - fontWeight = FontWeight.Medium - ) - } - } -} -@Composable -fun RankingItemRow( - index: Int, // 순위 표시를 위해 index 추가 - rank: RankingStock, - isDomestic: Boolean, - type: RankingType, - onClick: () -> Unit -) { - val displayColor = when { - type == RankingType.FALL -> Color(0xFF0E62CF) // 하락 탭은 무조건 파랑 - rank.prdy_ctrt.toDoubleOrNull() ?: 0.0 > 0 -> Color(0xFFE03E2D) // 그 외 양수면 빨강 - rank.prdy_ctrt.toDoubleOrNull() ?: 0.0 < 0 -> Color(0xFF0E62CF) // 음수면 파랑 - else -> Color.DarkGray - } - - Card( - modifier = Modifier - .fillMaxWidth() - .clickable { onClick() }, - elevation = 0.dp, - backgroundColor = Color.White - ) { - Row( - modifier = Modifier.padding(vertical = 10.dp, horizontal = 8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - // [1] 순위 표시 (1~20) - Text( - text = "${index + 1}", - style = MaterialTheme.typography.caption, - fontWeight = FontWeight.Bold, - color = if (index < 3) displayColor else Color.Gray, // 1~3위 강조 - modifier = Modifier.width(24.dp) - ) - - // [2] 종목명 및 코드 - Column(modifier = Modifier.weight(1f)) { - Text( - text = rank.hts_kor_alph_nm, - fontSize = 13.sp, - fontWeight = FontWeight.SemiBold, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - Text( - text = rank.mkrtc_objt_iscd, - fontSize = 11.sp, - color = Color.Gray - ) - } - - // [3] 등락률 배지 - Surface( - color = displayColor.copy(alpha = 0.1f), - shape = RoundedCornerShape(4.dp) - ) { - Text( - text = "${if (rank.prdy_ctrt.toDouble() > 0) "+" else ""}${rank.prdy_ctrt}%", - color = displayColor, - fontSize = 12.sp, - fontWeight = FontWeight.Bold, - modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp) - ) - } - } - } -} - -@Composable -fun VerticalDivider(modifier: Modifier = Modifier) { - Box(modifier.fillMaxHeight().width(1.dp).background(Color.LightGray)) -} -@Composable -fun BalanceSummaryCard(summary: BalanceSummary?) { - Card( - elevation = 4.dp, - shape = RoundedCornerShape(8.dp), - modifier = Modifier.fillMaxWidth(), - backgroundColor = Color(0xFFF8F9FA) // 가벼운 배경색 - ) { - Column(modifier = Modifier.padding(20.dp)) { - Text("총 평가 자산", style = MaterialTheme.typography.caption, color = Color.Gray) - Text( - text = "${summary?.tot_evlu_amt ?: "0"} 원", - style = MaterialTheme.typography.h5, - fontWeight = FontWeight.Bold, - color = Color(0xFF333333) - ) - - Spacer(modifier = Modifier.height(8.dp)) - - val profitRate = summary?.evlu_pfls_rt?.toDoubleOrNull() ?: 0.0 - val profitColor = if (profitRate > 0) Color(0xFFE03E2D) else if (profitRate < 0) Color(0xFF0E62CF) else Color.DarkGray - - Row(verticalAlignment = Alignment.CenterVertically) { - Text("실현 수익률: ", style = MaterialTheme.typography.body2) - Text( - text = "${if (profitRate > 0) "+" else ""}$profitRate%", - style = MaterialTheme.typography.body1, - fontWeight = FontWeight.Bold, - color = profitColor - ) - } - } - } -} - -@Composable -fun StockItemRow(stock: StockHolding, onClick: () -> Unit) { - Card( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 4.dp) - .clickable { onClick() }, - elevation = 2.dp, - shape = RoundedCornerShape(4.dp) - ) { - Row( - modifier = Modifier.padding(12.dp), - verticalAlignment = Alignment.CenterVertically - ) { - // 1. 종목명 및 코드 (왼쪽) - Column(modifier = Modifier.weight(1.2f)) { - Text( - text = stock.prdt_name, - style = MaterialTheme.typography.body1, - fontWeight = FontWeight.Bold, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - Text( - text = stock.pdno, - style = MaterialTheme.typography.caption, - color = Color.Gray - ) - } - - // 2. 보유 수량 및 현재가 (중앙) - Column(modifier = Modifier.weight(1f), horizontalAlignment = Alignment.End) { - Text("${stock.hldg_qty} 주", style = MaterialTheme.typography.body2) - Text( - text = "${stock.prpr} 원", - style = MaterialTheme.typography.caption, - color = Color.DarkGray - ) - } - - // 3. 수익률 (오른쪽) - val rate = stock.evlu_pfls_rt.toDoubleOrNull() ?: 0.0 - val color = if (rate > 0) Color(0xFFE03E2D) else if (rate < 0) Color(0xFF0E62CF) else Color.DarkGray - - Box( - modifier = Modifier.weight(0.8f), - contentAlignment = Alignment.CenterEnd - ) { - Surface( - color = color.copy(alpha = 0.1f), - shape = RoundedCornerShape(4.dp) - ) { - Text( - text = "${if (rate > 0) "▲" else if (rate < 0) "▼" else ""}${stock.evlu_pfls_rt}%", - color = color, - style = MaterialTheme.typography.body2, - fontWeight = FontWeight.Bold, - modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp) - ) - } - } - } - } +fun VerticalDivider() { + Box(Modifier.fillMaxHeight().width(1.dp).background(Color.LightGray)) } \ No newline at end of file diff --git a/src/main/kotlin/ui/MarketSection.kt b/src/main/kotlin/ui/MarketSection.kt new file mode 100644 index 0000000..611dc19 --- /dev/null +++ b/src/main/kotlin/ui/MarketSection.kt @@ -0,0 +1,101 @@ +// src/main/kotlin/ui/MarketSection.kt +package ui + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.* +import androidx.compose.material.TabRowDefaults.tabIndicatorOffset +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import model.RankingStock +import model.RankingType +import network.KisTradeService + +@Composable +fun MarketSection( + tradeService: KisTradeService, + onStockSelect: (code: String, name: String, isDomestic: Boolean) -> Unit +) { + var selectedTab by remember { mutableStateOf(RankingType.VOLUME) } + var isDomestic by remember { mutableStateOf(true) } + var rankingList by remember { mutableStateOf>(emptyList()) } + var isLoading by remember { mutableStateOf(false) } + + // 탭 또는 국가 변경 시 데이터 로드 + LaunchedEffect(selectedTab, isDomestic) { + isLoading = true + tradeService.fetchMarketRanking(selectedTab, isDomestic).onSuccess { + rankingList = it + }.onFailure { + rankingList = emptyList() + } + isLoading = false + } + + Column(modifier = Modifier.fillMaxSize()) { + // [1] 상단 타이틀 및 국내/해외 토글 + Row( + modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text("시장 랭킹 (TOP 20)", style = MaterialTheme.typography.subtitle1, fontWeight = FontWeight.Bold) + + // 국내/해외 전환 스위치 방식 + Row(verticalAlignment = Alignment.CenterVertically) { + Text(if (isDomestic) "국내" else "해외", fontSize = 12.sp, fontWeight = FontWeight.Medium) + Switch( + checked = !isDomestic, + onCheckedChange = { isDomestic = !it }, + colors = SwitchDefaults.colors(checkedThumbColor = Color(0xFF0E62CF)) + ) + } + } + + // [2] 랭킹 타입 탭 (상승, 하락, 거래량 등) + ScrollableTabRow( + selectedTabIndex = selectedTab.ordinal, + backgroundColor = Color.Transparent, + contentColor = Color.Black, + edgePadding = 0.dp, + indicator = { tabPositions -> + TabRowDefaults.Indicator( + Modifier.tabIndicatorOffset(tabPositions[selectedTab.ordinal]), + color = Color(0xFFE03E2D) + ) + } + ) { + RankingType.values().forEach { type -> + Tab( + selected = selectedTab == type, + onClick = { selectedTab = type }, + text = { Text(type.title, fontSize = 12.sp) } + ) + } + } + + Spacer(modifier = Modifier.height(8.dp)) + + // [3] 랭킹 리스트 + if (isLoading) { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator(strokeWidth = 2.dp) + } + } else { + LazyColumn(modifier = Modifier.fillMaxSize()) { + items(rankingList.withIndex().toList()) { (index, stock) -> + MarketStockItemRow(index + 1, stock) { + onStockSelect(stock.code, stock.name, isDomestic) + } + Divider(color = Color(0xFFF5F5F5), thickness = 0.5.dp) + } + } + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/ui/MarketStockItemRow.kt b/src/main/kotlin/ui/MarketStockItemRow.kt new file mode 100644 index 0000000..f36fc25 --- /dev/null +++ b/src/main/kotlin/ui/MarketStockItemRow.kt @@ -0,0 +1,62 @@ +package ui + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import model.RankingStock +import model.RankingType +import network.KisTradeService + + +// src/main/kotlin/ui/MarketStockItemRow.kt +@Composable +fun MarketStockItemRow( + rank: Int, + stock: RankingStock, + onClick: () -> Unit +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { onClick() } + .padding(vertical = 10.dp, horizontal = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + // 순위 표시 + Text( + text = rank.toString(), + modifier = Modifier.width(24.dp), + fontSize = 14.sp, + fontWeight = FontWeight.Bold, + color = if (rank <= 3) Color(0xFFE03E2D) else Color.Gray + ) + + Column(modifier = Modifier.weight(1f)) { + Text(stock.name, fontSize = 13.sp, fontWeight = FontWeight.Medium, maxLines = 1) + Text(stock.code, fontSize = 10.sp, color = Color.Gray) + } + + Column(horizontalAlignment = Alignment.End) { + Text( + text = String.format("%,d", stock.stck_prpr.toLongOrNull() ?: 0L), + fontSize = 13.sp, + fontWeight = FontWeight.Bold + ) + val rate = stock.prdy_ctrt.toDoubleOrNull() ?: 0.0 + Text( + text = "${if (rate > 0) "+" else ""}${stock.prdy_ctrt}%", + fontSize = 11.sp, + color = if (rate > 0) Color(0xFFE03E2D) else if (rate < 0) Color(0xFF0E62CF) else Color.DarkGray + ) + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/ui/OrderSection.kt b/src/main/kotlin/ui/OrderSection.kt index 4c074eb..f810360 100644 --- a/src/main/kotlin/ui/OrderSection.kt +++ b/src/main/kotlin/ui/OrderSection.kt @@ -33,80 +33,61 @@ import kotlin.collections.isNotEmpty @Composable fun OrderSection( - config: AppConfig, - token: String, stockCode: String, currentPrice: String, - onOrderResult: (String, Boolean) -> Unit // 결과 메시지와 성공 여부 전달 + onOrderResult: (String, Boolean) -> Unit ) { - val scope = rememberCoroutineScope() // 에러 해결: scope 정의 - val tradeService = remember { KisTradeService(config.isSimulation) } // 에러 해결: 서비스 정의 + val scope = rememberCoroutineScope() + val tradeService = remember { KisTradeService() } // 전역 세션 참조 버전 var orderQty by remember { mutableStateOf("1") } - var orderPrice by remember { mutableStateOf("0") } // 0은 시장가 - var isSubmitting by remember { mutableStateOf(false) } + var orderPrice by remember { mutableStateOf("0") } // "0"은 시장가 - Column( - modifier = Modifier - .fillMaxWidth() - .background(Color(0xFFF8F9FA)) - .padding(12.dp) - ) { - Row(verticalAlignment = Alignment.CenterVertically) { - // 수량 입력 - OutlinedTextField( - value = orderQty, - onValueChange = { if(it.all { c -> c.isDigit() }) orderQty = it }, - label = { Text("수량", fontSize = 10.sp) }, - modifier = Modifier.width(100.dp).height(50.dp), - singleLine = true - ) - Spacer(modifier = Modifier.width(8.dp)) - // 가격 입력 (시장가 체크박스 기능 포함 가능) - OutlinedTextField( - value = if(orderPrice == "0") "시장가" else orderPrice, - onValueChange = { if(it.all { c -> c.isDigit() }) orderPrice = it }, - label = { Text("가격", fontSize = 10.sp) }, - modifier = Modifier.weight(1f).height(50.dp), - singleLine = true - ) - } + Column(modifier = Modifier.width(200.dp).background(Color(0xFFF8F9FA)).padding(8.dp)) { + Text("주문 설정", fontWeight = FontWeight.Bold, fontSize = 14.sp) + + OutlinedTextField( + value = orderQty, + onValueChange = { if(it.all { c -> c.isDigit() }) orderQty = it }, + label = { Text("수량") }, + modifier = Modifier.fillMaxWidth() + ) + + OutlinedTextField( + value = if(orderPrice == "0") "시장가" else orderPrice, + onValueChange = { if(it.all { c -> c.isDigit() }) orderPrice = it }, + label = { Text("가격") }, + modifier = Modifier.fillMaxWidth() + ) Spacer(modifier = Modifier.height(8.dp)) - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - // 매수 버튼 - Button( - onClick = { - isSubmitting = true - scope.launch { - val res = tradeService.postOrder(token, config, stockCode, orderQty, orderPrice, true) - res.onSuccess { onOrderResult(it, true) }.onFailure { onOrderResult(it.message ?: "에러", false) } - isSubmitting = false - } - }, - modifier = Modifier.weight(1f).height(45.dp), - colors = ButtonDefaults.buttonColors(backgroundColor = Color(0xFFE03E2D)), - enabled = !isSubmitting - ) { - Text("현금매수", color = Color.White, fontWeight = FontWeight.Bold) - } + Button( + onClick = { + scope.launch { + // KisSession을 사용하는 postOrder 호출 + tradeService.postOrder(stockCode, orderQty, orderPrice, isBuy = true) + .onSuccess { onOrderResult(it, true) } + .onFailure { onOrderResult(it.message ?: "에러", false) } + } + }, + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.buttonColors(backgroundColor = Color(0xFFE03E2D)) + ) { + Text("매수", color = Color.White) + } - // 매도 버튼 - Button( - onClick = { - isSubmitting = true - scope.launch { - val res = tradeService.postOrder(token, config, stockCode, orderQty, orderPrice, false) - res.onSuccess { onOrderResult(it, true) }.onFailure { onOrderResult(it.message ?: "에러", false) } - isSubmitting = false - } - }, - modifier = Modifier.weight(1f).height(45.dp), - colors = ButtonDefaults.buttonColors(backgroundColor = Color(0xFF0E62CF)), - enabled = !isSubmitting - ) { - Text("현금매도", color = Color.White, fontWeight = FontWeight.Bold) - } + Button( + onClick = { + scope.launch { + tradeService.postOrder(stockCode, orderQty, orderPrice, isBuy = false) + .onSuccess { onOrderResult(it, true) } + .onFailure { onOrderResult(it.message ?: "에러", false) } + } + }, + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.buttonColors(backgroundColor = Color(0xFF0E62CF)) + ) { + Text("매도", color = Color.White) } } } \ No newline at end of file diff --git a/src/main/kotlin/ui/RealTimeTradeList.kt b/src/main/kotlin/ui/RealTimeTradeList.kt new file mode 100644 index 0000000..b6de593 --- /dev/null +++ b/src/main/kotlin/ui/RealTimeTradeList.kt @@ -0,0 +1,43 @@ +// src/main/kotlin/ui/RealTimeTradeList.kt +package ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.Divider +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import model.RealTimeTrade + +@Composable +fun RealTimeTradeList(tradeLogs: List) { + Column(modifier = Modifier.fillMaxSize()) { + // [1] 리스트 헤더 영역 + Row( + modifier = Modifier + .fillMaxWidth() + .background(Color(0xFFEEEEEE)) // 연한 회색 배경 + .padding(vertical = 4.dp) + ) { + Text("시간", modifier = Modifier.weight(1f), textAlign = TextAlign.Center, fontSize = 11.sp, color = Color.Gray) + Text("체결가", modifier = Modifier.weight(1.5f), textAlign = TextAlign.Center, fontSize = 11.sp, color = Color.Gray) + Text("대비", modifier = Modifier.weight(1f), textAlign = TextAlign.Center, fontSize = 11.sp, color = Color.Gray) + Text("체결량", modifier = Modifier.weight(1f), textAlign = TextAlign.Center, fontSize = 11.sp, color = Color.Gray) + } + + // [2] 실제 데이터 리스트 + LazyColumn(modifier = Modifier.fillMaxSize()) { + // 최신 데이터가 위로 오도록 표시 (이미 tradeLogs에 add(0, new)로 들어옴) + items(tradeLogs) { trade -> + TradeLogRow(trade) // 기존에 만드신 행(Row) 컴포넌트 재사용 + Divider(color = Color(0xFFF5F5F5), thickness = 0.5.dp) + } + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/ui/RecommendationTabs.kt b/src/main/kotlin/ui/RecommendationTabs.kt index bb5576a..6ac66a2 100644 --- a/src/main/kotlin/ui/RecommendationTabs.kt +++ b/src/main/kotlin/ui/RecommendationTabs.kt @@ -1,152 +1,152 @@ -package ui - - -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items // 반드시 수동 import 확인 -import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.* -import androidx.compose.material.TabRowDefaults.tabIndicatorOffset -import androidx.compose.runtime.* -import io.ktor.client.engine.cio.CIO -// 아래 두 import가 'delegate' 에러를 해결합니다. -import androidx.compose.runtime.getValue -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import kotlinx.coroutines.launch -import model.AppConfig -import model.BalanceSummary -import model.RankingStock -import model.RankingType -import model.StockHolding -import network.KisTradeService -import util.MarketUtil - -@Composable -fun RecommendationTabs( - config: AppConfig, - token: String, - onSelect: (String, String) -> Unit -) { - var isDomestic by remember { mutableStateOf(true) } - var selectedType by remember { mutableStateOf(RankingType.RISE) } - var rankingList by remember { mutableStateOf>(emptyList()) } - - val tradeService = remember { KisTradeService(config.isSimulation) } - val isKoreaOpen = MarketUtil.isKoreanMarketOpen() - var errorMessage by remember { mutableStateOf(null) } // 에러 메시지 상태 추가 - - // 데이터 로드 로직 - LaunchedEffect(isDomestic, selectedType, isKoreaOpen) { - errorMessage = null // 로딩 시작 시 에러 초기화 - if (isDomestic) { - if (isKoreaOpen) { - tradeService.fetchMarketRanking(token, config, selectedType, true) - .onSuccess { rankingList = it.take(20) } - .onFailure { errorMessage = "실시간 데이터를 가져오지 못했습니다." } - } else { - tradeService.fetchDomesticPreviousDayRanking(token, config) - .onSuccess { rankingList = it } - .onFailure { errorMessage = "장외 데이터를 가져오지 못했습니다. (점검 중일 수 있음)" } - } - } else { - tradeService.fetchOverseasRanking(token, config) - .onSuccess { rankingList = it } - .onFailure { errorMessage = "해외 주식 데이터를 불러올 수 없습니다." } - } - } - - Column(modifier = Modifier.fillMaxSize()) { - // [1] 국내/해외 전환 버튼 (항상 노출) - Row(Modifier.fillMaxWidth().padding(8.dp)) { - MarketToggleButton("국내 주식", isDomestic, Color(0xFFE03E2D)) { isDomestic = true } - Spacer(Modifier.width(8.dp)) - MarketToggleButton("미국 주식", !isDomestic, Color(0xFF0E62CF)) { isDomestic = false } - } - - // [2] 랭킹 타입 탭 (상승/하락/거래량 등 - 항상 노출) - ScrollableTabRow( - selectedTabIndex = selectedType.ordinal, - edgePadding = 8.dp, - backgroundColor = Color.White, - indicator = { tabPositions -> - TabRowDefaults.Indicator( - modifier = Modifier.tabIndicatorOffset(tabPositions[selectedType.ordinal]), - color = if (isDomestic) Color(0xFFE03E2D) else Color(0xFF0E62CF) - ) - } - ) { - RankingType.values().forEach { type -> - Tab( - selected = selectedType == type, - onClick = { selectedType = type }, - text = { Text(type.title, fontSize = 12.sp) } - ) - } - } - - // [3] 장외 시간 안내 바 - if (isDomestic && !isKoreaOpen) { - Surface(color = Color(0xFFFFF9C4), modifier = Modifier.fillMaxWidth()) { - Text( - "현재 장외 시간입니다. 전일 종가 기준 TOP 20입니다.", - fontSize = 11.sp, modifier = Modifier.padding(8.dp) - ) - } - } - - // [4] 추천 리스트 영역 - Box(modifier = Modifier.weight(1f)) { - if (errorMessage != null) { - // 에러 발생 시 안내 - Column(Modifier.fillMaxSize(), Arrangement.Center, Alignment.CenterHorizontally) { - Text(errorMessage!!, color = Color.Gray) - Button(onClick = { /* 다시 시도 로직 */ }) { Text("다시 시도") } - } - } else if (rankingList.isEmpty()) { - // 로딩 중 - Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - CircularProgressIndicator() - } - } else { - // [성공] 리스트 노출 - LazyColumn { - itemsIndexed(rankingList) { index, stock -> - RankingItemRow(index, stock, isDomestic, selectedType) { - onSelect(stock.mkrtc_objt_iscd, stock.hts_kor_alph_nm) - } - } - } - } - } - } -} - -@Composable -fun MarketToggleButton(title: String, isSelected: Boolean, activeColor: Color, onClick: () -> Unit) { - OutlinedButton( - onClick = onClick, - colors = ButtonDefaults.outlinedButtonColors( - backgroundColor = if (isSelected) activeColor.copy(alpha = 0.1f) else Color.Transparent - ), - modifier = Modifier.height(36.dp), - border = BorderStroke(1.dp, if (isSelected) activeColor else Color.LightGray) - ) { - Text( - text = title, - color = if (isSelected) activeColor else Color.Gray, - fontSize = 12.sp, - fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal - ) - } -} +//package ui +// +// +//import androidx.compose.foundation.BorderStroke +//import androidx.compose.foundation.background +//import androidx.compose.foundation.clickable +//import androidx.compose.foundation.layout.* +//import androidx.compose.foundation.lazy.LazyColumn +//import androidx.compose.foundation.lazy.items // 반드시 수동 import 확인 +//import androidx.compose.foundation.lazy.itemsIndexed +//import androidx.compose.foundation.shape.RoundedCornerShape +//import androidx.compose.material.* +//import androidx.compose.material.TabRowDefaults.tabIndicatorOffset +//import androidx.compose.runtime.* +//import io.ktor.client.engine.cio.CIO +//// 아래 두 import가 'delegate' 에러를 해결합니다. +//import androidx.compose.runtime.getValue +//import androidx.compose.runtime.setValue +//import androidx.compose.ui.Alignment +//import androidx.compose.ui.Modifier +//import androidx.compose.ui.graphics.Color +//import androidx.compose.ui.text.font.FontWeight +//import androidx.compose.ui.text.style.TextOverflow +//import androidx.compose.ui.unit.dp +//import androidx.compose.ui.unit.sp +//import kotlinx.coroutines.launch +//import model.AppConfig +//import model.BalanceSummary +//import model.RankingStock +//import model.RankingType +//import model.StockHolding +//import network.KisTradeService +//import util.MarketUtil +// +//@Composable +//fun RecommendationTabs( +// config: AppConfig, +// token: String, +// onSelect: (String, String) -> Unit +//) { +// var isDomestic by remember { mutableStateOf(true) } +// var selectedType by remember { mutableStateOf(RankingType.RISE) } +// var rankingList by remember { mutableStateOf>(emptyList()) } +// +// val tradeService = remember { KisTradeService(config.isSimulation) } +// val isKoreaOpen = MarketUtil.isKoreanMarketOpen() +// var errorMessage by remember { mutableStateOf(null) } // 에러 메시지 상태 추가 +// +// // 데이터 로드 로직 +// LaunchedEffect(isDomestic, selectedType, isKoreaOpen) { +// errorMessage = null // 로딩 시작 시 에러 초기화 +// if (isDomestic) { +// if (isKoreaOpen) { +// tradeService.fetchMarketRanking(token, config, selectedType, true) +// .onSuccess { rankingList = it.take(20) } +// .onFailure { errorMessage = "실시간 데이터를 가져오지 못했습니다." } +// } else { +// tradeService.fetchDomesticPreviousDayRanking(token, config) +// .onSuccess { rankingList = it } +// .onFailure { errorMessage = "장외 데이터를 가져오지 못했습니다. (점검 중일 수 있음)" } +// } +// } else { +// tradeService.fetchOverseasRanking(token, config) +// .onSuccess { rankingList = it } +// .onFailure { errorMessage = "해외 주식 데이터를 불러올 수 없습니다." } +// } +// } +// +// Column(modifier = Modifier.fillMaxSize()) { +// // [1] 국내/해외 전환 버튼 (항상 노출) +// Row(Modifier.fillMaxWidth().padding(8.dp)) { +// MarketToggleButton("국내 주식", isDomestic, Color(0xFFE03E2D)) { isDomestic = true } +// Spacer(Modifier.width(8.dp)) +// MarketToggleButton("미국 주식", !isDomestic, Color(0xFF0E62CF)) { isDomestic = false } +// } +// +// // [2] 랭킹 타입 탭 (상승/하락/거래량 등 - 항상 노출) +// ScrollableTabRow( +// selectedTabIndex = selectedType.ordinal, +// edgePadding = 8.dp, +// backgroundColor = Color.White, +// indicator = { tabPositions -> +// TabRowDefaults.Indicator( +// modifier = Modifier.tabIndicatorOffset(tabPositions[selectedType.ordinal]), +// color = if (isDomestic) Color(0xFFE03E2D) else Color(0xFF0E62CF) +// ) +// } +// ) { +// RankingType.values().forEach { type -> +// Tab( +// selected = selectedType == type, +// onClick = { selectedType = type }, +// text = { Text(type.title, fontSize = 12.sp) } +// ) +// } +// } +// +// // [3] 장외 시간 안내 바 +// if (isDomestic && !isKoreaOpen) { +// Surface(color = Color(0xFFFFF9C4), modifier = Modifier.fillMaxWidth()) { +// Text( +// "현재 장외 시간입니다. 전일 종가 기준 TOP 20입니다.", +// fontSize = 11.sp, modifier = Modifier.padding(8.dp) +// ) +// } +// } +// +// // [4] 추천 리스트 영역 +// Box(modifier = Modifier.weight(1f)) { +// if (errorMessage != null) { +// // 에러 발생 시 안내 +// Column(Modifier.fillMaxSize(), Arrangement.Center, Alignment.CenterHorizontally) { +// Text(errorMessage!!, color = Color.Gray) +// Button(onClick = { /* 다시 시도 로직 */ }) { Text("다시 시도") } +// } +// } else if (rankingList.isEmpty()) { +// // 로딩 중 +// Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { +// CircularProgressIndicator() +// } +// } else { +// // [성공] 리스트 노출 +// LazyColumn { +// itemsIndexed(rankingList) { index, stock -> +// RankingItemRow(index, stock, isDomestic, selectedType) { +// onSelect(stock.mkrtc_objt_iscd, stock.hts_kor_alph_nm) +// } +// } +// } +// } +// } +// } +//} +// +//@Composable +//fun MarketToggleButton(title: String, isSelected: Boolean, activeColor: Color, onClick: () -> Unit) { +// OutlinedButton( +// onClick = onClick, +// colors = ButtonDefaults.outlinedButtonColors( +// backgroundColor = if (isSelected) activeColor.copy(alpha = 0.1f) else Color.Transparent +// ), +// modifier = Modifier.height(36.dp), +// border = BorderStroke(1.dp, if (isSelected) activeColor else Color.LightGray) +// ) { +// Text( +// text = title, +// color = if (isSelected) activeColor else Color.Gray, +// fontSize = 12.sp, +// fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal +// ) +// } +//} diff --git a/src/main/kotlin/ui/SettingsScreen.kt b/src/main/kotlin/ui/SettingsScreen.kt index c33a1eb..ee93388 100644 --- a/src/main/kotlin/ui/SettingsScreen.kt +++ b/src/main/kotlin/ui/SettingsScreen.kt @@ -5,8 +5,6 @@ import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.* -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.FolderOpen import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.DragData @@ -14,151 +12,113 @@ import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.onExternalDrag +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import kotlinx.coroutines.launch import model.AppConfig +import model.KisSession import network.KisAuthService +import network.KisTradeService import org.jetbrains.exposed.sql.deleteAll import org.jetbrains.exposed.sql.insert import org.jetbrains.exposed.sql.transactions.transaction -import javax.swing.JFileChooser -import javax.swing.filechooser.FileNameExtensionFilter -import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.Dialog // 파일 선택기용 +// src/main/kotlin/ui/SettingsScreen.kt @OptIn(ExperimentalComposeUiApi::class) @Composable -fun SettingsScreen( - initialConfig: AppConfig, // 모델 경로가 포함된 확장된 AppConfig 필요 - onAuthSuccess: (AppConfig, String) -> Unit -) { +fun SettingsScreen(onAuthSuccess: () -> Unit) { val scope = rememberCoroutineScope() - val authService = remember { KisAuthService() } + var config by remember { mutableStateOf(KisSession.config) } + var statusMessage by remember { mutableStateOf("정보를 입력하세요.") } - // 화면 입력 상태값 - var appKey by remember { mutableStateOf(initialConfig.appKey) } - var secretKey by remember { mutableStateOf(initialConfig.secretKey) } - var accountNo by remember { mutableStateOf(initialConfig.accountNo) } - var isSimulation by remember { mutableStateOf(initialConfig.isSimulation) } - var modelPath by remember { mutableStateOf(initialConfig.modelPath ?: "") } // AI 모델 경로 - - var statusMessage by remember { mutableStateOf("설정 정보를 입력하세요.") } - var isLoading by remember { mutableStateOf(false) } + // 계좌번호 입력 시 데이터 자동 로드 함수 + fun checkAndLoadConfig(accountNo: String, isReal: Boolean) { + val loaded = DatabaseFactory.findConfigByAccount(accountNo) + if (loaded != null) { + config = loaded + statusMessage = "✅ 기존 데이터를 불러왔습니다." + } + } LazyColumn(modifier = Modifier.fillMaxSize().padding(24.dp)) { item { - Text("API 및 계좌 설정", style = MaterialTheme.typography.h6) - OutlinedTextField(value = appKey, onValueChange = { appKey = it }, label = { Text("App Key") }, modifier = Modifier.fillMaxWidth()) - OutlinedTextField(value = secretKey, onValueChange = { secretKey = it }, label = { Text("Secret Key") }, modifier = Modifier.fillMaxWidth(), visualTransformation = PasswordVisualTransformation()) - OutlinedTextField(value = accountNo, onValueChange = { accountNo = it }, label = { Text("계좌번호") }, modifier = Modifier.fillMaxWidth()) + Text("거래 방식 선택", style = MaterialTheme.typography.h6) Row(verticalAlignment = Alignment.CenterVertically) { - Checkbox(checked = isSimulation, onCheckedChange = { isSimulation = it }) - Text("모의투자 서버 사용") + RadioButton(selected = !config.isSimulation, onClick = { config = config.copy(isSimulation = false) }) + Text("실전투자") + Spacer(Modifier.width(16.dp)) + RadioButton(selected = config.isSimulation, onClick = { config = config.copy(isSimulation = true) }) + Text("모의투자") } + Divider(Modifier.padding(vertical = 12.dp)) + + // 실전 3종 입력 + Text("실전투자 정보 (시세 조회 필수)", fontWeight = FontWeight.Bold) + OutlinedTextField(value = config.realAccountNo, onValueChange = { + config = config.copy(realAccountNo = it) + if(it.length >= 8) checkAndLoadConfig(it, true) + }, label = { Text("실전 계좌번호") }, modifier = Modifier.fillMaxWidth()) + OutlinedTextField(value = config.realAppKey, onValueChange = { config = config.copy(realAppKey = it) }, label = { Text("실전 App Key") }, modifier = Modifier.fillMaxWidth()) + OutlinedTextField(value = config.realSecretKey, onValueChange = { config = config.copy(realSecretKey = it) }, label = { Text("실전 Secret Key") }, modifier = Modifier.fillMaxWidth(), visualTransformation = PasswordVisualTransformation()) + + Spacer(Modifier.height(16.dp)) + + // 모의 3종 입력 + Text("모의투자 정보", fontWeight = FontWeight.Bold) + OutlinedTextField(value = config.vtsAccountNo, onValueChange = { + config = config.copy(vtsAccountNo = it) + if(it.length >= 8) checkAndLoadConfig(it, false) + }, label = { Text("모의 계좌번호") }, modifier = Modifier.fillMaxWidth()) + OutlinedTextField(value = config.vtsAppKey, onValueChange = { config = config.copy(vtsAppKey = it) }, label = { Text("모의 App Key") }, modifier = Modifier.fillMaxWidth()) + OutlinedTextField(value = config.vtsSecretKey, onValueChange = { config = config.copy(vtsSecretKey = it) }, label = { Text("모의 Secret Key") }, modifier = Modifier.fillMaxWidth(), visualTransformation = PasswordVisualTransformation()) Divider(Modifier.padding(vertical = 16.dp)) - // --- 추가된 AI 모델 설정 섹션 --- - Text("AI 모델 설정 (Gemma-2-9b)", style = MaterialTheme.typography.h6) - Spacer(modifier = Modifier.height(8.dp)) - - Row(verticalAlignment = Alignment.CenterVertically) { - OutlinedTextField( - value = modelPath, - onValueChange = { modelPath = it }, - label = { Text("GGUF 모델 경로") }, - modifier = Modifier.weight(1f), - placeholder = { Text("파일을 선택하거나 드래그하세요") } - ) - IconButton(onClick = { - val chooser = JFileChooser().apply { - fileFilter = FileNameExtensionFilter("GGUF 모델", "gguf") - } - if (chooser.showOpenDialog(null) == JFileChooser.APPROVE_OPTION) { - modelPath = chooser.selectedFile.absolutePath - } - }) { - Icon(Icons.Default.FolderOpen, contentDescription = "파일 선택") - } - } - - // 드래그 앤 드롭 영역 + // AI 모델 경로 및 드래그 앤 드롭 + Text("AI 모델 설정", fontWeight = FontWeight.Bold) Box( - modifier = Modifier - .fillMaxWidth() - .height(80.dp) - .padding(top = 8.dp) - .border(1.dp, Color.LightGray, RoundedCornerShape(8.dp)) + modifier = Modifier.fillMaxWidth().height(100.dp).border(1.dp, Color.Gray, RoundedCornerShape(8.dp)) .onExternalDrag(onDrop = { state -> val data = state.dragData if (data is DragData.FilesList) { val path = data.readFiles().firstOrNull()?.removePrefix("file:") - if (path?.endsWith(".gguf") == true) modelPath = path + if (path?.endsWith(".gguf") == true) config = config.copy(modelPath = path) } }), contentAlignment = Alignment.Center ) { - Text("여기에 .gguf 파일을 드래그하여 놓으세요", fontSize = 12.sp, color = Color.Gray) + Text(if(config.modelPath.isEmpty()) "GGUF 모델 파일을 여기로 드래그하세요" else config.modelPath, fontSize = 12.sp) } - Spacer(modifier = Modifier.height(24.dp)) + Spacer(Modifier.height(24.dp)) - // 저장 및 접속 버튼 Button( modifier = Modifier.fillMaxWidth().height(50.dp), - enabled = !isLoading, onClick = { - isLoading = true scope.launch { - // 1. 새로운 설정 객체 생성 (순서 주의: isSimulation 다음 modelPath) - val config = AppConfig( - appKey = appKey.trim(), - secretKey = secretKey.trim(), - accountNo = accountNo.trim(), - isSimulation = isSimulation, - modelPath = modelPath - ) +// isLoading = true + // 1. KisSession.config 업데이트 및 DB 저장 + KisSession.config = config + DatabaseFactory.saveConfig(config) + val authService = KisAuthService() + val tradeService = KisTradeService() + val authSuccess = authService.refreshAllTokens() + val wsKeySuccess = tradeService.refreshWebsocketKey() - transaction { - ConfigTable.deleteAll() - ConfigTable.insert { - it[ConfigTable.appKey] = config.appKey - it[ConfigTable.secretKey] = config.secretKey - it[ConfigTable.accountNo] = config.accountNo - it[ConfigTable.isSimulation] = config.isSimulation - it[ConfigTable.modelPath] = config.modelPath - } + if (authSuccess && wsKeySuccess) { + statusMessage = "✅ 인증 성공! LLM 시작 중..." + onAuthSuccess() + } else { + statusMessage = "❌ 인증 실패. 키 정보를 확인하세요." } - - statusMessage = "인증 토큰 발급 시도 중..." - authService.fetchAccessToken(appKey, secretKey, isSimulation) - .onSuccess { response -> - statusMessage = "✅ 인증 성공!" - onAuthSuccess(config, response.access_token) - }.onFailure { - statusMessage = "❌ 인증 실패(정보 저장됨): ${it.localizedMessage}" - } - isLoading = false +// isLoading = false } } - ) { - if (isLoading) { - // [수정된 프로그래스 바] size -> Modifier.size - CircularProgressIndicator( - modifier = Modifier.size(20.dp), - color = Color.White, - strokeWidth = 2.dp - ) - } else { - Text("설정 저장 및 접속 시작") - } - } - - Spacer(modifier = Modifier.height(16.dp)) - Text(statusMessage, color = if (statusMessage.contains("✅")) Color.Green else Color.Gray) + ) { Text("설정 저장 및 실행") } + Text(statusMessage, color = Color.Gray, modifier = Modifier.padding(top = 8.dp)) } } -} \ No newline at end of file +} diff --git a/src/main/kotlin/ui/StockDetailArea.kt b/src/main/kotlin/ui/StockDetailArea.kt index 94155f0..8ac97fc 100644 --- a/src/main/kotlin/ui/StockDetailArea.kt +++ b/src/main/kotlin/ui/StockDetailArea.kt @@ -34,149 +34,116 @@ import network.KisWebSocketManager import kotlin.collections.isNotEmpty @Composable -fun StockDetailArea( - config: AppConfig, - token: String, - code: String, - name: String, - wsManager: KisWebSocketManager // 매니저 수신 +fun StockDetailSection( + stockCode: String, + stockName: String, + isDomestic: Boolean, + tradeService: KisTradeService, + wsManager: KisWebSocketManager ) { - val currentPrice by wsManager.currentPrice - val priceColor by wsManager.priceChangeColor - val tradeLogs = wsManager.tradeLogs // Manager의 상태를 직접 참조 - val tradeService = remember { KisTradeService(config.isSimulation) } var chartData by remember { mutableStateOf>(emptyList()) } var isLoading by remember { mutableStateOf(false) } var resultMessage by remember { mutableStateOf("") } var isSuccess by remember { mutableStateOf(true) } - LaunchedEffect(code) { - if (code.isEmpty()) return@LaunchedEffect + // 이전 종목 코드를 기억하기 위한 상태 + var previousCode by remember { mutableStateOf("") } + + // 종목 변경 시 데이터 로드 및 웹소켓 구독 관리 + LaunchedEffect(stockCode) { + if (stockCode.isEmpty()) return@LaunchedEffect isLoading = true - if (code.isNotEmpty()) { - // 기존 종목 구독 해지 및 새 종목 구독 메시지 전송 - // (KisWebSocketManager에 해당 기능을 하는 함수를 만들어서 호출) - wsManager.subscribeStock(code) - } - // 종목 코드 판별 (숫자 6자리면 국내, 아니면 해외로 간주) - val isDomestic = code.all { it.isDigit() } && code.length == 6 - val result = if (isDomestic) { - tradeService.fetchChartData(token, config.appKey, config.secretKey, code) - .map { it.output2.reversed() } - } else { - // 해외 주식 처리 (우선 NAS 나스닥 기준으로 호출) - tradeService.fetchOverseasChartData(token, config.appKey, config.secretKey, code) + // 1. 웹소켓 구독 관리: 이전 종목 해제 -> 새 종목 구독 + if (previousCode.isNotEmpty()) { + wsManager.unsubscribeStock(previousCode) } + wsManager.subscribeStock(stockCode) + previousCode = stockCode - result.onSuccess { chartData = it } - .onFailure { println("차트 로드 실패: ${it.message}") } + // 2. 차트 데이터 로드 (KisSession 기반으로 파라미터 간소화) + tradeService.fetchChartData(stockCode, isDomestic) + .onSuccess { data -> + println("✅ 차트 데이터 로드 성공: ${data.size}개") // ${} 사용하여 정확히 출력 + chartData = data + } + .onFailure { error -> + println("❌ 차트 데이터 로드 실패: ${error.localizedMessage}") + chartData = emptyList() + } isLoading = false } - LaunchedEffect(resultMessage) { - if (resultMessage.isNotEmpty()) { - delay(3000) - resultMessage = "" + val latestPrice by wsManager.currentPrice // 웹소켓에서 업데이트되는 현재가 + + LaunchedEffect(latestPrice) { + println("latestPrice $latestPrice") + + if (chartData.isNotEmpty() && latestPrice != "0") { + + // 마지막 캔들 정보 업데이트 + val priceDouble = latestPrice.replace(",", "").toDoubleOrNull() ?: return@LaunchedEffect + val lastCandle = chartData.last() + + val updatedCandle = lastCandle.copy( + stck_clpr = latestPrice, + stck_hgpr = if (priceDouble > (lastCandle.stck_hgpr.toDoubleOrNull() ?: 0.0)) latestPrice else lastCandle.stck_hgpr, + stck_lwpr = if (priceDouble < (lastCandle.stck_lwpr.toDoubleOrNull() ?: Double.MAX_VALUE)) latestPrice else lastCandle.stck_lwpr + ) + + chartData = chartData.dropLast(1) + updatedCandle + println("chartData.size $chartData.size") } } - Column(modifier = Modifier.fillMaxSize()) { - // [상단 정보] 국내/해외 구분 배지 추가 - if (resultMessage.isNotEmpty()) { - Surface( - color = if (isSuccess) Color(0xFF4CAF50) else Color(0xFFF44336), - modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp) - ) { - Text( - text = resultMessage, - color = Color.White, - modifier = Modifier.padding(8.dp), - textAlign = TextAlign.Center, - fontSize = 12.sp - ) - } - } + Column(modifier = Modifier.fillMaxSize().padding(16.dp)) { + // [상단] 종목명 및 상태 메시지 + StockHeader(stockName, stockCode, isDomestic, resultMessage, isSuccess) - Row(verticalAlignment = Alignment.CenterVertically) { - val isDomestic = code.all { it.isDigit() } && code.length == 6 - Badge(backgroundColor = if (isDomestic) Color(0xFFE03E2D) else Color(0xFF0E62CF)) { - Text(if (isDomestic) "국내" else "해외", color = Color.White, fontSize = 10.sp) - } - Spacer(modifier = Modifier.width(8.dp)) - Text(name, style = MaterialTheme.typography.h5, fontWeight = FontWeight.Bold) - Text(" ($code)", color = Color.Gray) - } - - Spacer(modifier = Modifier.height(16.dp)) - - // [차트 영역] CandleChart 컴포저블 재사용 + // [중앙] 캔들 차트 (Card 내부) Card( - modifier = Modifier.fillMaxWidth().height(350.dp), + modifier = Modifier.fillMaxWidth().height(300.dp), backgroundColor = Color(0xFF121212) ) { if (isLoading) { Box(contentAlignment = Alignment.Center) { CircularProgressIndicator(color = Color.White) } - } else if (chartData.isNotEmpty()) { - CandleChart(data = chartData, modifier = Modifier.padding(16.dp)) } else { - Box(contentAlignment = Alignment.Center) { Text("데이터가 없습니다.", color = Color.Gray) } + CandleChart(data = chartData, modifier = Modifier.padding(16.dp)) } } - Spacer(modifier = Modifier.height(16.dp)) + + Spacer(modifier = Modifier.height(12.dp)) + + // [중앙 하단] AI 투자 전략 AiAnalysisView( - stockName = name, + stockName = stockName, currentPrice = wsManager.currentPrice.value, trades = wsManager.tradeLogs ) - Spacer(modifier = Modifier.height(16.dp)) - // 웹 소스 스타일의 주문 박스 -// Card(modifier = Modifier.fillMaxWidth(), backgroundColor = Color(0xFFF8F9FA)) { -// Column(modifier = Modifier.padding(16.dp)) { -// Text("주문 설정", fontWeight = FontWeight.Bold) -// // 수량 입력, 매수/매도 버튼 배치 (detail.html 참고) -// Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { -// Button(onClick = { /* 매수 */ }, modifier = Modifier.weight(1f), colors = ButtonDefaults.buttonColors(Color(0xFFE03E2D))) { -// Text("매수", color = Color.White) -// } -// Button(onClick = { /* 매도 */ }, modifier = Modifier.weight(1f), colors = ButtonDefaults.buttonColors(Color(0xFF0E62CF))) { -// Text("매도", color = Color.White) -// } -// } -// } -// } - Column(modifier = Modifier.weight(0.4f)) { - Text("실시간 체결", style = MaterialTheme.typography.subtitle2, fontWeight = FontWeight.Bold) - // 헤더 영역 - Row(modifier = Modifier.fillMaxWidth().background(Color(0xFFEEEEEE)).padding(vertical = 4.dp)) { - Text("시간", modifier = Modifier.weight(1f), textAlign = TextAlign.Center, fontSize = 11.sp) - Text("체결가", modifier = Modifier.weight(1.5f), textAlign = TextAlign.Center, fontSize = 11.sp) - Text("대비", modifier = Modifier.weight(1f), textAlign = TextAlign.Center, fontSize = 11.sp) - Text("체결량", modifier = Modifier.weight(1f), textAlign = TextAlign.Center, fontSize = 11.sp) + Spacer(modifier = Modifier.height(12.dp)) + + // [하단] 실시간 체결 내역 및 주문 섹션 + Row(modifier = Modifier.weight(1f)) { + // 실시간 체결 리스트 + Column(modifier = Modifier.weight(1f)) { + Text("실시간 체결", style = MaterialTheme.typography.subtitle2, fontWeight = FontWeight.Bold) + RealTimeTradeList(wsManager.tradeLogs) } - // 실시간 리스트 - LazyColumn(modifier = Modifier.fillMaxSize()) { - items(tradeLogs) { trade -> - TradeLogRow(trade) - Divider(color = Color(0xFFF5F5F5)) + Spacer(modifier = Modifier.width(12.dp)) + + // 주문 섹션 (인자 간소화) + OrderSection( + stockCode = stockCode, + currentPrice = wsManager.currentPrice.value, + onOrderResult = { msg, success -> + resultMessage = msg + isSuccess = success } - } + ) } - OrderSection( - config = config, - token = token, - stockCode = code, - currentPrice = currentPrice, - onOrderResult = { msg, success -> - resultMessage = msg - isSuccess = success - } - ) } - - } \ No newline at end of file diff --git a/src/main/kotlin/ui/StockHeader.kt b/src/main/kotlin/ui/StockHeader.kt new file mode 100644 index 0000000..36a2b62 --- /dev/null +++ b/src/main/kotlin/ui/StockHeader.kt @@ -0,0 +1,80 @@ +// src/main/kotlin/ui/StockHeader.kt +package ui + +import androidx.compose.foundation.layout.* +import androidx.compose.material.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +@Composable +fun StockHeader( + name: String, + code: String, + isDomestic: Boolean, + resultMessage: String, + isSuccess: Boolean +) { + Column(modifier = Modifier.fillMaxWidth()) { + // [1] 알림 메시지 영역 (주문 성공/실패 시 상단에 표시) + if (resultMessage.isNotEmpty()) { + Surface( + color = if (isSuccess) Color(0xFF4CAF50) else Color(0xFFF44336), // 성공 초록, 실패 빨강 + modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp), + shape = androidx.compose.foundation.shape.RoundedCornerShape(4.dp) + ) { + Text( + text = resultMessage, + color = Color.White, + modifier = Modifier.padding(8.dp), + textAlign = TextAlign.Center, + fontSize = 12.sp, + fontWeight = FontWeight.Bold + ) + } + } + + // [2] 종목명 및 국가 배지 영역 + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(vertical = 4.dp) + ) { + // 국가 구분 배지 + Surface( + color = if (isDomestic) Color(0xFFE03E2D) else Color(0xFF0E62CF), // 국내 빨강, 해외 파랑 + shape = androidx.compose.foundation.shape.RoundedCornerShape(4.dp) + ) { + Text( + text = if (isDomestic) "국내" else "해외", + color = Color.White, + fontSize = 10.sp, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp) + ) + } + + Spacer(modifier = Modifier.width(8.dp)) + + // 종목 이름 + Text( + text = name, + style = MaterialTheme.typography.h5, + fontWeight = FontWeight.Bold + ) + + Spacer(modifier = Modifier.width(6.dp)) + + // 종목 코드 + Text( + text = "($code)", + style = MaterialTheme.typography.body1, + color = Color.Gray + ) + } + } +} \ No newline at end of file