From fcd6b5e800c2932e0857f4488aaad0a9cf86f237 Mon Sep 17 00:00:00 2001 From: lunaticbum Date: Fri, 13 Mar 2026 16:37:53 +0900 Subject: [PATCH] .... --- src/main/kotlin/Main.kt | 33 +-- src/main/kotlin/database/DatabaseFactory.kt | 60 +++- src/main/kotlin/model/AppConfig.kt | 9 +- src/main/kotlin/model/StockModels.kt | 58 +++- src/main/kotlin/service/AutoTradingManager.kt | 280 ++++++++++++++++-- src/main/kotlin/ui/DashboardScreen.kt | 17 +- src/main/kotlin/ui/IntegratedOrderSection.kt | 59 +--- src/main/kotlin/ui/SettingsScreen.kt | 190 +++++++----- src/main/kotlin/ui/TradingDecisionLog.kt | 227 ++++++++++++++ 9 files changed, 727 insertions(+), 206 deletions(-) create mode 100644 src/main/kotlin/ui/TradingDecisionLog.kt diff --git a/src/main/kotlin/Main.kt b/src/main/kotlin/Main.kt index 907c5d9..67a26ce 100644 --- a/src/main/kotlin/Main.kt +++ b/src/main/kotlin/Main.kt @@ -46,9 +46,10 @@ import service.SystemSleepPreventer import service.TradingDecisionCallback import ui.DashboardScreen import ui.SettingsScreen +import ui.TradingDecisionLog // 화면 상태 정의 -enum class AppScreen { Settings, Dashboard } +enum class AppScreen { Settings, Dashboard, TradingDecision } fun getLlamaBinPath(): String { @@ -75,26 +76,10 @@ fun getLlamaBinPath(): String { fun main() = application { SystemSleepPreventer.start() - LaunchedEffect(Unit) { - // NewsService나 KisTradeService에서 사용하는 client를 전달 -// DartCodeManager.updateCorpCodes(HttpClient(CIO) { -// install(ContentNegotiation) { -// json(Json { -// ignoreUnknownKeys = true -// encodeDefaults = true // 기본값이 포함된 요청 바디를 정확히 전송하기 위해 필요 -// }) -// } -// // [수정] 모든 로그(Headers + Body)를 찍도록 설정 -// install(Logging) { -// logger = Logger.DEFAULT -// level = LogLevel.BODY -// } -// }) - } // 앱 실행 시 필요한 바이너리 경로 (실행 파일 위치) val binPath = getLlamaBinPath() val windowState = rememberWindowState( - placement = WindowPlacement.Maximized + placement = WindowPlacement.Fullscreen ) Window(onCloseRequest = ::exitApplication, title = "KIS AI 자동매매", state = windowState) { var currentScreen by remember { mutableStateOf(AppScreen.Settings) } @@ -127,6 +112,7 @@ fun main() = application { MAX_PRICE = it[ConfigTable.max_price], MIN_PRICE = it[ConfigTable.min_price], MIN_PURCHASE_SCORE = it[ConfigTable.min_purchase_score], + SELL_PROFIT = it[ConfigTable.sell_profit], GRADE_5_BUY = it[grade_5_buy], GRADE_5_PROFIT = it[grade_5_profit], GRADE_4_BUY = it[grade_4_buy], @@ -143,6 +129,7 @@ fun main() = application { } isLoaded = true + } if (!isLoaded) { @@ -158,7 +145,10 @@ fun main() = application { val config = KisSession.config AutoTradingManager.isSystemReadyToday = true AutoTradingManager.isSystemCleanedUpToday = false - // LLM 서버 시작 (설정된 모델 경로 사용) + CoroutineScope(Dispatchers.Default).launch { + AutoTradingManager.startAutoDiscoveryLoop() + } + if (config.modelPath.isNotEmpty()) { LlamaServerManager.startServer(binPath, config.modelPath,port = 8080) } @@ -167,13 +157,16 @@ fun main() = application { } // 대시보드로 화면 전환 - currentScreen = AppScreen.Dashboard + currentScreen = AppScreen.TradingDecision } ) } AppScreen.Dashboard -> { DashboardScreen() } + AppScreen.TradingDecision -> { + TradingDecisionLog() + } } } } diff --git a/src/main/kotlin/database/DatabaseFactory.kt b/src/main/kotlin/database/DatabaseFactory.kt index 7ebdd55..cf5c152 100644 --- a/src/main/kotlin/database/DatabaseFactory.kt +++ b/src/main/kotlin/database/DatabaseFactory.kt @@ -1,4 +1,5 @@ import AutoTradeTable.orderNo +import androidx.compose.runtime.mutableStateListOf import kotlinx.serialization.Serializable import model.AppConfig import org.jetbrains.exposed.sql.* @@ -7,6 +8,8 @@ import org.jetbrains.exposed.sql.javatime.datetime import org.jetbrains.exposed.sql.transactions.transaction import java.io.File import java.time.LocalDateTime +import java.time.LocalTime +import java.time.format.DateTimeFormatter object TradeStatus { @@ -42,6 +45,7 @@ object ConfigTable : Table("app_config") { val max_price = double("max_price").default( 40000.0) val min_price = double("min_price").default( 800.0) val min_purchase_score = double("min_purchase_score").default( 65.0) + val sell_profit = double("sell_profit").default( 1.3) val grade_5_buy = integer("grade_5_buy").default(0) val grade_5_profit = double("grade_5_profit").default(1.8) val grade_4_buy = integer("grade_4_buy").default(1) @@ -226,6 +230,7 @@ object DatabaseFactory { MAX_PRICE = it[ConfigTable.max_price], MIN_PRICE = it[ConfigTable.min_price], MIN_PURCHASE_SCORE = it[ConfigTable.min_purchase_score], + SELL_PROFIT = it[ConfigTable.sell_profit], GRADE_5_BUY = it[ConfigTable.grade_5_buy], GRADE_5_PROFIT = it[ConfigTable.grade_5_profit], GRADE_4_BUY = it[ConfigTable.grade_4_buy], @@ -251,9 +256,9 @@ object DatabaseFactory { it[vtsSecretKey] = config.vtsSecretKey it[realAccountNo] = config.realAccountNo it[vtsAccountNo] = config.vtsAccountNo - it[ConfigTable.nAppKey] = config.nAppKey - it[ConfigTable.nSecretKey] = config.nSecretKey - it[ConfigTable.dAppKey] = config.dAppKey + it[nAppKey] = config.nAppKey + it[nSecretKey] = config.nSecretKey + it[dAppKey] = config.dAppKey it[isSimulation] = config.isSimulation it[htsId] = config.htsId it[modelPath] = config.modelPath @@ -265,6 +270,7 @@ object DatabaseFactory { it[max_price] = config.MAX_PRICE it[min_price] = config.MIN_PRICE it[min_purchase_score] = config.MIN_PURCHASE_SCORE + it[sell_profit] = config.SELL_PROFIT it[grade_5_buy] = config.GRADE_5_BUY it[grade_5_profit] = config.GRADE_5_PROFIT it[grade_4_buy] = config.GRADE_4_BUY @@ -275,7 +281,7 @@ object DatabaseFactory { it[grade_2_profit] = config.GRADE_2_PROFIT it[grade_1_buy] = config.GRADE_1_BUY it[grade_1_profit] = config.GRADE_1_PROFIT - it[ConfigTable.max_count] =config.MAX_COUNT + it[max_count] = config.MAX_COUNT } } } @@ -365,4 +371,48 @@ data class AutoTradeItem( val isDomestic: Boolean = true, val profitRate: Double = 0.0, // 설정 시 사용한 목표 비율 val stopLossRate: Double = 0.0 -) \ No newline at end of file +) + + +object TradingLogStore { + // UI에서 관찰할 수 있는 경량 로그 리스트 + val decisionLogs = mutableStateListOf() + + data class LogEntry( + val time: String, + val stockName: String, + val decision: String, + val confidence: Double, + val reason: String + ) + + fun addLog(decision: TradingDecision) { + synchronized(this) { + if (decisionLogs.size > 100) decisionLogs.removeAt(0) + decisionLogs.add(LogEntry( + time = LocalTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss")), + stockName = "${decision.stockName}[${decision.currentPrice}]", + decision = decision.decision ?: "HOLD", + confidence = decision.confidence, + reason = decision.reason ?: "" + )) + } + } + + fun addLog(tradingDecision: TradingDecision , decision: String, log: String) { + synchronized(this) { + if (decisionLogs.size > 1000) decisionLogs.removeAt(0) + decisionLogs.add( + LogEntry( + time = LocalTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss")), + stockName = "${tradingDecision.stockName}[${tradingDecision.currentPrice}][]", + decision = decision, + confidence = tradingDecision.confidence, + reason = log + ) + ) + } + } + +} + diff --git a/src/main/kotlin/model/AppConfig.kt b/src/main/kotlin/model/AppConfig.kt index a414b8e..3e51299 100644 --- a/src/main/kotlin/model/AppConfig.kt +++ b/src/main/kotlin/model/AppConfig.kt @@ -12,9 +12,10 @@ enum class ConfigIndex(val index : Int,val label : String) { MAX_PRICE_INDEX(MAX_BUDGET_INDEX.index + 1 , "단일 종목 주당 최대 금액"), MIN_PRICE_INDEX(MAX_PRICE_INDEX.index + 1, "단일 종목 주당 최소 금액"), MIN_PURCHASE_SCORE_INDEX(MIN_PRICE_INDEX.index + 1, "주문 최소 기준 점수"), - MAX_COUNT_INDEX(MIN_PURCHASE_SCORE_INDEX.index + 1, "주문 최대 개수"), + MAX_COUNT_INDEX(MIN_PURCHASE_SCORE_INDEX.index + 1, "단일 종목 최대 주문 개수"), + SELL_PROFIT(MAX_COUNT_INDEX.index + 1, "보유 주식 매도 기준 수익율"), - GRADE_5_BUY(MAX_COUNT_INDEX.index + 1, "강력 추천 투자 매수 기준"), + GRADE_5_BUY(SELL_PROFIT.index + 1, "강력 추천 투자 매수 기준"), GRADE_4_BUY(GRADE_5_BUY.index + 1, "안정적 투자 매수 기준"), GRADE_3_BUY(GRADE_4_BUY.index + 1, "보수적 투자 매수 기준"), GRADE_2_BUY(GRADE_3_BUY.index + 10, "하이리스크,리턴 매수 기준"), @@ -69,6 +70,8 @@ data class AppConfig( var MAX_PRICE: Double = 40000.0, var MIN_PRICE: Double = 800.0, var MIN_PURCHASE_SCORE: Double = 65.0, + var SELL_PROFIT: Double = 1.3, + var GRADE_5_BUY : Int = 0, var GRADE_4_BUY : Int = 1, var GRADE_3_BUY : Int = 2, @@ -97,6 +100,7 @@ data class AppConfig( ConfigIndex.BUY_WEIGHT_INDEX -> {BUY_WEIGHT = value} ConfigIndex.MAX_BUDGET_INDEX -> {MAX_BUDGET = value} ConfigIndex.MIN_PURCHASE_SCORE_INDEX -> {MIN_PURCHASE_SCORE = value} + ConfigIndex.SELL_PROFIT -> {SELL_PROFIT = value} ConfigIndex.GRADE_5_PROFIT -> {GRADE_5_PROFIT = value} ConfigIndex.GRADE_4_PROFIT -> {GRADE_4_PROFIT = value} ConfigIndex.GRADE_3_PROFIT -> {GRADE_3_PROFIT = value} @@ -133,6 +137,7 @@ data class AppConfig( ConfigIndex.MIN_PURCHASE_SCORE_INDEX -> { MIN_PURCHASE_SCORE } + ConfigIndex.SELL_PROFIT -> {SELL_PROFIT } ConfigIndex.GRADE_5_BUY -> {GRADE_5_BUY.toDouble()} ConfigIndex.GRADE_4_BUY -> {GRADE_4_BUY.toDouble()} ConfigIndex.GRADE_3_BUY -> {GRADE_3_BUY.toDouble()} diff --git a/src/main/kotlin/model/StockModels.kt b/src/main/kotlin/model/StockModels.kt index fab462e..9701738 100644 --- a/src/main/kotlin/model/StockModels.kt +++ b/src/main/kotlin/model/StockModels.kt @@ -214,4 +214,60 @@ data class ExecutionData( val price: String, val qty: String, val isFilled: Boolean -) \ No newline at end of file +) + +enum class InvestmentGrade( + val displayName: String, + val description: String, + val shortWeight: Double = 0.0, + val midWeight: Double = 0.0, + val longWeight: Double = 0.0, + val profitGuide: ConfigIndex, + val buyGuide: ConfigIndex, +) { + LEVEL_5_STRONG_RECOMMEND( + displayName = "최상급 추천", + description = "단기·중기·장기 모두 우수하고, 신뢰도 매우 높은 범용 매수 추천", + shortWeight = 1.0, + midWeight = 1.0, + longWeight = 1.0, + profitGuide = ConfigIndex.GRADE_5_PROFIT, + buyGuide = ConfigIndex.GRADE_5_BUY, + ), + LEVEL_4_BALANCED_RECOMMEND( + displayName = "균형 추천", + description = "중기·장기 기본은 양호하고, 단기 성과도 준수한 안정형 추천", + shortWeight = 0.8, + midWeight = 1.0, + longWeight = 1.0, + profitGuide = ConfigIndex.GRADE_4_PROFIT, + buyGuide = ConfigIndex.GRADE_4_BUY, + ), + LEVEL_3_CAUTIOUS_RECOMMEND( + displayName = "보수적 추천", + description = "중기/장기 기본은 양호하지만, 단기 변동성이 높아 신중히 접근해야 함", + shortWeight = 0.6, + midWeight = 1.0, + longWeight = 1.0, + profitGuide = ConfigIndex.GRADE_3_PROFIT, + buyGuide = ConfigIndex.GRADE_3_BUY, + ), + LEVEL_2_HIGH_RISK( + displayName = "고위험 추천", + description = "단기/초단기 성과만 강하고, 중기·장기가 애매하여 리스크가 큰 투자", + shortWeight = 1.0, + midWeight = 0.4, + longWeight = 0.4, + profitGuide = ConfigIndex.GRADE_2_PROFIT, + buyGuide = ConfigIndex.GRADE_2_BUY, + ), + LEVEL_1_SPECULATIVE( + displayName = "순수 공격적 선택", + description = "단기/초단기 성과에만 의존하는 단기 급등형 공격적 투자", + shortWeight = 1.0, + midWeight = 0.2, + longWeight = 0.2, + profitGuide = ConfigIndex.GRADE_1_PROFIT, + buyGuide = ConfigIndex.GRADE_1_BUY, + ) +} diff --git a/src/main/kotlin/service/AutoTradingManager.kt b/src/main/kotlin/service/AutoTradingManager.kt index de6dc42..a80a11b 100644 --- a/src/main/kotlin/service/AutoTradingManager.kt +++ b/src/main/kotlin/service/AutoTradingManager.kt @@ -1,6 +1,8 @@ package service +import AutoTradeItem import TradingDecision +import androidx.compose.runtime.remember import getLlamaBinPath import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -17,6 +19,8 @@ import kotlinx.coroutines.withTimeout import kotlinx.serialization.Serializable import model.CandleData import model.ConfigIndex +import model.ExecutionData +import model.InvestmentGrade import model.KisSession import model.RankingStock import model.RankingType @@ -46,9 +50,6 @@ object AutoTradingManager { private val lastTickTime = AtomicLong(System.currentTimeMillis()) private var watchdogJob: Job? = null - // 설정 상수 - private const val MIN_RISE_RATE = 0.1 - private const val MAX_RISE_RATE = 21.0 private const val CYCLE_TIMEOUT = 30 * 60 * 1000L // 한 사이클 최대 10분 private const val WATCHDOG_CHECK_INTERVAL = 30 * 1000L // 30초마다 생존 확인 private const val STUCK_THRESHOLD = 3 * 60 * 1000L // 5분간 반응 없으면 'Stuck'으로 판단 @@ -58,10 +59,246 @@ object AutoTradingManager { // private val processedCodes = mutableSetOf() // 중복 처리 방지용 (선택 사항) private val reanalysisList = mutableListOf() private val retryCountMap = mutableMapOf() + + val globalCallback = { completeTradingDecision: TradingDecision?, isSuccess: Boolean -> + if (isSuccess && completeTradingDecision != null) { + // 1. 로그 저장소에 기록 (UI에서 이걸 읽음) + TradingLogStore.addLog(completeTradingDecision) + + println("🚀 [자동매수 실행] ${completeTradingDecision.stockName}") + if (completeTradingDecision != null && !completeTradingDecision.stockCode.isNullOrEmpty()) { + var basePrice = completeTradingDecision.currentPrice + var stockCode = completeTradingDecision.stockCode + println("basePrice $basePrice") + val minScore = KisSession.config.getValues(ConfigIndex.MIN_PURCHASE_SCORE_INDEX) + var maxBudget = KisSession.config.getValues(ConfigIndex.MAX_BUDGET_INDEX) + val buyWeight = KisSession.config.getValues(ConfigIndex.BUY_WEIGHT_INDEX) + val baseProfit = KisSession.config.getValues(ConfigIndex.PROFIT_INDEX) + + fun resultCheck(completeTradingDecision :TradingDecision) { + val weights = mapOf( + "short" to 0.2, // 초단기 점수가 낮아도 전체에 미치는 영향 감소 + "profit" to 0.4, + "safe" to 0.4 // 중장기 점수 비중 강화 + ) + + val totalScore = + ((completeTradingDecision.shortPossible() + append) * weights["short"]!!) + + ((completeTradingDecision.profitPossible() + append) * weights["profit"]!!) + + ((completeTradingDecision.safePossible() + append) * weights["safe"]!!) + + if (totalScore >= minScore && completeTradingDecision.confidence >= MIN_CONFIDENCE) { + var investmentGrade : InvestmentGrade = AutoTradingManager.getInvestmentGrade(completeTradingDecision,totalScore, completeTradingDecision.confidence) + + val finalMargin = baseProfit * KisSession.config.getValues(investmentGrade.profitGuide) + println(""" + 사명 : ${completeTradingDecision.corpName} + 신뢰도 : ${completeTradingDecision.confidence + append} + 단기성 : ${completeTradingDecision.shortPossible() + append} + 수익성 : ${completeTradingDecision.profitPossible()+ append} + 안전성 : ${completeTradingDecision.safePossible()+ append} + ${investmentGrade.displayName} : ${investmentGrade.description} + 총점 : ${totalScore} + """.trimIndent()) + println("🚀 [매수 진행] 토탈 스코어: ${String.format("%.1f", totalScore)} -> 종목: ${completeTradingDecision.stockCode}") + + // basePrice(현재가 혹은 지정가)를 기준으로 매수 가능 수량 산출 (최소 1주 보장) + val gradeRate = (1.0 - (investmentGrade.ordinal * 0.1)) + val maxQty = (KisSession.config.getValues(ConfigIndex.MAX_COUNT_INDEX) * gradeRate).roundToInt() + maxBudget = maxBudget * gradeRate + val calculatedQty = if (basePrice > 0) { + (maxBudget / basePrice).toInt().coerceAtLeast(1) + } else { + 1 + } + // 5. 매수 실행 (계산된 finalMargin 전달) + AutoTradingManager.excuteTrade( + decision = completeTradingDecision, + orderQty = min(calculatedQty, maxQty).toString(), + profitRate1 = finalMargin, + investmentGrade = investmentGrade, + ) + + } else if(totalScore >= (minScore * 0.85) && completeTradingDecision.confidence + append >= (MIN_CONFIDENCE * 0.85)) { + AutoTradingManager.addToReanalysis(RankingStock(mksc_shrn_iscd = completeTradingDecision.stockCode,hts_kor_isnm = completeTradingDecision.stockName)) + TradingLogStore.addLog(completeTradingDecision,"HOLD","✋ [관망] 토탈 스코어 또는 신뢰도 미달 이나 약간의 오차로 재분석 대기열에 추가") + } else { + TradingLogStore.addLog(completeTradingDecision,"HOLD","✋ [관망] 토탈 스코어(${String.format("%.1f[${minScore}]", totalScore)}) 또는 신뢰도 (${String.format("%.1f[${MIN_CONFIDENCE}]", completeTradingDecision.confidence)}) 미달") + } + } + when (completeTradingDecision?.decision) { + "BUY" -> { + append = buyWeight + TradingLogStore.addLog(completeTradingDecision,"BUY","[$stockCode] 매수 추천 resultCheck: ${completeTradingDecision?.reason}") + resultCheck(completeTradingDecision) + } + "SELL" -> println("[$stockCode] 매도: ${completeTradingDecision?.reason}") + "HOLD" -> { + append = 0.0 + TradingLogStore.addLog(completeTradingDecision,"HOLD","[$stockCode] 관망 유지 resultCheck: ${completeTradingDecision?.reason}") + resultCheck(completeTradingDecision) + } + else -> { + append = 0.0 + println("[$stockCode] ${completeTradingDecision?.decision} resultCheck: ${completeTradingDecision?.reason}") + } + } + } + } + } + + val MIN_CONFIDENCE = 70.0 // 최소 신뢰도 + var append = 0.0 + + fun getInvestmentGrade( + ts: TradingDecision, + totalScore: Double, + confidence: Double + ): InvestmentGrade { + // 1. 기본 조건 충족 여부 + if (totalScore < 68.0 || confidence < 70.0) { + return InvestmentGrade.LEVEL_1_SPECULATIVE // 매도/관망 (추천 등급 없음) + } + + // 2. 단기/중기/장기 패턴 기준 + val ultraShort = ts.ultraShortScore + val short = ts.shortTermScore + val mid = ts.midTermScore + val long = ts.longTermScore + + val shortAvg = listOf(ultraShort, short).average() // 초단기+단기 + val midLongAvg = listOf(mid, long).average() // 중기+장기 + + return when { + // LEVEL_5: 단기·중기·장기 모두 매우 높고, 신뢰도까지 높음 + shortAvg >= 85.0 && midLongAvg >= 80.0 -> + if (ts.analyzer?.isOverheatedStock() ?: true) InvestmentGrade.LEVEL_4_BALANCED_RECOMMEND else InvestmentGrade.LEVEL_5_STRONG_RECOMMEND + + // LEVEL_4: 중기·장기 기본 준수, 단기까지 양호 + midLongAvg >= 75.0 && shortAvg >= 70.0 -> + if (ts.analyzer?.isOverheatedStock() ?: true) InvestmentGrade.LEVEL_3_CAUTIOUS_RECOMMEND else InvestmentGrade.LEVEL_4_BALANCED_RECOMMEND + + // LEVEL_3: 중기·장기 기본 이상, 단기만 단기 변동성 높은 보수형 + midLongAvg >= 70.0 && shortAvg in 60.0..70.0 -> + if (ts.analyzer?.isOverheatedStock() ?: true) InvestmentGrade.LEVEL_2_HIGH_RISK else InvestmentGrade.LEVEL_3_CAUTIOUS_RECOMMEND + + // LEVEL_2: 단기/초단기만 강하고, 중기·장기 애매 + shortAvg >= 75.0 && midLongAvg < 65.0 -> + if (ts.analyzer?.isOverheatedStock() ?: true) InvestmentGrade.LEVEL_1_SPECULATIVE else InvestmentGrade.LEVEL_2_HIGH_RISK + + // LEVEL_1: 단기/초단기만 의미 있고, 중기·장기 심각히 약함 + shortAvg >= 70.0 && midLongAvg < 55.0 -> + InvestmentGrade.LEVEL_1_SPECULATIVE + + // 기본 조건은 충족했지만, 패턴에 잘 맞지 않을 때 (예: 중립) + else -> + if (ts.analyzer?.isOverheatedStock() ?: true) InvestmentGrade.LEVEL_1_SPECULATIVE else InvestmentGrade.LEVEL_2_HIGH_RISK + } + } + + fun excuteTrade(decision: TradingDecision,orderQty: String, profitRate1: Double?,investmentGrade: InvestmentGrade = InvestmentGrade.LEVEL_2_HIGH_RISK) { + scope.launch { + var basePrice = decision.currentPrice + val tickSize = MarketUtil.getTickSize(basePrice) + val oneTickLowerPrice = basePrice - (tickSize * KisSession.config.getValues(investmentGrade.buyGuide).toInt()) + var stockCode = decision.stockCode + var stockName = decision.stockName + val finalPrice = MarketUtil.roundToTickSize(oneTickLowerPrice.toDouble()) + + println("basePrice : $basePrice, oneTickLowerPrice : $oneTickLowerPrice, finalPrice : $finalPrice") + KisTradeService.postOrder(stockCode, orderQty, finalPrice.toLong().toString(), isBuy = true) + .onSuccess { realOrderNo -> // KIS 서버에서 생성된 실제 주문번호 + println("주문 성공: $realOrderNo ${stockCode} $orderQty $finalPrice") + TradingLogStore.addLog(decision,"BUY","주문 성공: $realOrderNo") + val pRate = 0.4 + val sRate = -1.5 + + var tax = KisSession.config.getValues(ConfigIndex.TAX_INDEX) + val effectiveProfitRate = maxOf(((profitRate1 ?: pRate) + tax), (KisSession.config.getValues( + ConfigIndex.PROFIT_INDEX) + tax)) + + val calculatedTarget = MarketUtil.roundToTickSize(basePrice * (1 + effectiveProfitRate / 100.0)) + val calculatedStop = MarketUtil.roundToTickSize(basePrice * (1 + sRate / 100.0)) + val inputQty = orderQty.replace(",", "").toIntOrNull() ?: 0 + + DatabaseFactory.saveAutoTrade(AutoTradeItem( + orderNo = realOrderNo, + code = stockCode, + name = stockName, + quantity = inputQty, + profitRate = effectiveProfitRate, // 보정된 수익률 저장 + stopLossRate = sRate, + targetPrice = calculatedTarget, + stopLossPrice = calculatedStop, + status = "PENDING_BUY", + isDomestic = true + )) + syncAndExecute(realOrderNo) + TradingLogStore.addLog(decision,"BUY","매수 및 감시 설정 완료 (목표 수익률: ${String.format("%.4f", effectiveProfitRate)}%): $realOrderNo") + } + .onFailure { + println("매수 실패: ${it.message} ${stockCode} $orderQty $finalPrice") + TradingLogStore.addLog(decision,"BUY",it.message ?: "매수 실패") + } + } + } + + val executionCache = mutableMapOf() + val processingIds = mutableSetOf() // 주문번호 기준 잠금 + suspend fun syncAndExecute(orderNo: String) { + if (processingIds.contains(orderNo)) return + processingIds.add(orderNo) + + try { + val dbItem = DatabaseFactory.findByOrderNo(orderNo) + val execData = executionCache[orderNo] + + if (dbItem != null && execData != null && execData.isFilled) { + if (dbItem.status == TradeStatus.PENDING_BUY) { + // 1. 실제 매수 체결가 가져오기 (문자열인 경우 숫자로 변환) + val actualBuyPrice = execData.price.toDoubleOrNull() ?: dbItem.targetPrice + + // 2. 최소 마진 설정 (수수료/세금 0.3% + 순수익 1.5% = 1.8%) + + val minEffectiveRate = KisSession.config.getValues(ConfigIndex.PROFIT_INDEX) + KisSession.config.getValues(ConfigIndex.TAX_INDEX) + + // 3. DB에 설정된 목표 수익률과 최소 보장 수익률 중 큰 값 선택 + val finalProfitRate = maxOf(dbItem.profitRate, minEffectiveRate) + + // 4. 실제 체결가 기준 익절 가격 재계산 및 틱 사이즈 보정 + val finalTargetPrice = MarketUtil.roundToTickSize(actualBuyPrice * (1 + finalProfitRate / 100.0)) + + println("🎯 [매칭 성공] 익절 주문 실행: ${dbItem.name} | 매수가: ${actualBuyPrice.toInt()} -> 목표가: ${finalTargetPrice.toInt()} (${String.format("%.2f", finalProfitRate)}% 적용)") + + KisTradeService.postOrder( + stockCode = dbItem.code, + qty = dbItem.quantity.toString(), + price = finalTargetPrice.toLong().toString(), + isBuy = false + ).onSuccess { newSellOrderNo -> + // 익절가 업데이트 및 상태 변경 + DatabaseFactory.updateStatusAndOrderNo(dbItem.id!!, TradeStatus.SELLING, newSellOrderNo) + // (선택 사항) 실제 계산된 익절가를 DB에 기록하고 싶다면 별도 update 로직 추가 가능 + + executionCache.remove(orderNo) + }.onFailure { + println("❌ 익절 주문 실패: ${it.message}") + } + } else if (dbItem.status == TradeStatus.SELLING) { + println("🎊 [매칭 성공] 매도 완료 처리: ${dbItem.name}") + DatabaseFactory.updateStatusAndOrderNo(dbItem.id!!, TradeStatus.COMPLETED) + executionCache.remove(orderNo) + } + } + } finally { + processingIds.remove(orderNo) + } + } + /** * 자동 발굴 루프 시작 및 Watchdog 실행 */ - fun startAutoDiscoveryLoop(tradeService: KisTradeService, callback: TradingDecisionCallback) { + fun startAutoDiscoveryLoop() { if (isRunning()) return // 1. 기존 Watchdog이 있다면 제거 후 새로 시작 @@ -72,27 +309,22 @@ object AutoTradingManager { val now = System.currentTimeMillis() if (isRunning() && (now - lastTickTime.get() > STUCK_THRESHOLD)) { println("🚨 [Watchdog] 루프 멈춤 감지 (5분간 응답 없음). 강제 재시작합니다.") - restartLoop(tradeService, callback) + restartLoop() } } } // 2. 메인 루프 실행 - runDiscoveryLoop(tradeService, callback) + runDiscoveryLoop(KisTradeService, globalCallback) } suspend fun resumePendingSellOrders(tradeService: KisTradeService,balance : UnifiedBalance) { // 1. DB에서 매도 중(SELLING)이거나 만료(EXPIRED)된 매도 건을 가져옵니다. println("resumePendingSellOrders") -// val pendingSells = DatabaseFactory.getAutoTradesByStatus(listOf(TradeStatus.SELLING, TradeStatus.EXPIRED)) -// println("pendingSells >>> ${pendingSells.size}") balance.holdings.forEach { holding -> - // 2. 실제로 잔고에 해당 종목이 있는지 확인 (안전장치) -// val balance = tradeService.fetchIntegratedBalance().getOrNull() -// val holding = balance?.holdings?.find { it.code == item.code } - if (holding != null && holding.quantity.toInt() > 0 && holding.availOrderCount.toInt() > 0 && holding.profitRate.toDouble() > 0.8) { + if (holding != null && holding.quantity.toInt() > 0 && holding.availOrderCount.toInt() > 0 && holding.profitRate.toDouble() > KisSession.config.SELL_PROFIT) { println("${holding.name} - 매수 : ${holding.avgPrice} - 현재 : ${holding.currentPrice} ") // 3. 기존 목표가(targetPrice)로 다시 매도 주문 전송 @@ -113,8 +345,7 @@ object AutoTradingManager { println("❌ [재주문 실패] ${holding.name}: ${it.message}") } } else { - // 잔고에 없다면 이미 매도된 것으로 간주하고 완료 처리 -// DatabaseFactory.updateStatusAndOrderNo(item.id!!, TradeStatus.COMPLETED) + } delay(200) // API 호출 부하 방지 } @@ -167,17 +398,11 @@ object AutoTradingManager { now = LocalTime.now(ZoneId.of("Asia/Seoul")) currentTimeMillis = System.currentTimeMillis() lastTickTime.set(System.currentTimeMillis()) // 생존 신고 -// if (now.minute % 5 == 0) { -// SystemSleepPreventer.sleepDisplay() -// } else { -// SystemSleepPreventer.wakeDisplay() -// } when { //장중 now.isBefore(LocalTime.of(16, 0)) && now.isAfter(LocalTime.of(8, 50)) -> { waitTime = 0.2 if (now.isAfter(LocalTime.of(8, 0)) && now.isBefore(LocalTime.of(15, 30))) { - // 토큰 중 하나라도 만료 5분 전이거나 비어있다면 다시 준비 상태로 전환 if (!KisSession.isMarketTokenValid() || !KisSession.isTradeTokenValid()) { if (isSystemReadyToday) { println("⚠️ [System] 토큰 만료 감지. 재발급 프로세스를 가동합니다.") @@ -231,18 +456,11 @@ object AutoTradingManager { println("미확인 데이터 ${remainingCandidates.size}") } - - // [프로세스 3] 종목별 순회 분석 var totalCount = remainingCandidates.size println("후보군 조건 충족 총 개수 : ${totalCount}") val iterator = remainingCandidates.iterator() while (iterator.hasNext()) { - if (now.minute % 2 == 0) { -// SystemSleepPreventer.sleepDisplay() - } else { -// SystemSleepPreventer.wakeDisplay() - } totalCount-- val stock = iterator.next() try { @@ -411,9 +629,9 @@ object AutoTradingManager { ).awaitAll().flatten() } - private fun restartLoop(tradeService: KisTradeService, callback: TradingDecisionCallback) { + private fun restartLoop() { discoveryJob?.cancel() - startAutoDiscoveryLoop(tradeService, callback) + startAutoDiscoveryLoop() } private suspend fun waitForNextCycle(minutes: Double) { @@ -460,10 +678,10 @@ object AutoTradingManager { } } - fun checkAndRestart(tradeService: KisTradeService, callback: TradingDecisionCallback) { + fun checkAndRestart() { if (!isRunning()) { println("⚠️ [Watchdog] 자동 발굴 루프가 중단된 것을 감지했습니다. 재시작을 시도합니다...") - startAutoDiscoveryLoop(tradeService, callback) + startAutoDiscoveryLoop() } else { } diff --git a/src/main/kotlin/ui/DashboardScreen.kt b/src/main/kotlin/ui/DashboardScreen.kt index f016784..7c63aa6 100644 --- a/src/main/kotlin/ui/DashboardScreen.kt +++ b/src/main/kotlin/ui/DashboardScreen.kt @@ -58,14 +58,6 @@ fun DashboardScreen() { var monthSummary by remember { mutableStateOf>(mutableListOf()) } var yearSummary by remember { mutableStateOf>(mutableListOf()) } - fun setupAutoTradingWatchdog(tradeService: KisTradeService, callback: TradingDecisionCallback) { - CoroutineScope(Dispatchers.Default).launch { -// while (true) { -// delay(60000) // 1분마다 체크 - AutoTradingManager.checkAndRestart(tradeService, callback) -// } - } - } var callback = object : TradingDecisionCallback { @@ -91,11 +83,6 @@ fun DashboardScreen() { } } - LaunchedEffect(Unit) { - // 화면이 완전히 그려지고 안정화될 때까지 1초 대기 - delay(1000) - AutoTradingManager.startAutoDiscoveryLoop(tradeService, callback) - } // 리소스 정리는 여전히 DisposableEffect에서 수행 DisposableEffect(Unit) { @@ -108,13 +95,13 @@ fun DashboardScreen() { var refreshTrigger by remember { mutableStateOf(0) } // [핵심] 아직 DB에 등록되기 전에 도착한 체결 데이터를 임시 보관하는 버퍼 val executionCache = remember { mutableMapOf() } - val processingIds = remember { mutableSetOf() } // 주문번호 기준 잠금 + // [중앙 관리 함수] 체결 정보와 DB 정보를 매칭하여 실행 LaunchedEffect(refreshTrigger) { // setupAutoTradingWatchdog(tradeService,callback) } - + val processingIds = remember { mutableSetOf() } // 주문번호 기준 잠금 suspend fun syncAndExecute(orderNo: String) { if (processingIds.contains(orderNo)) return processingIds.add(orderNo) diff --git a/src/main/kotlin/ui/IntegratedOrderSection.kt b/src/main/kotlin/ui/IntegratedOrderSection.kt index d0239c6..c4c1aad 100644 --- a/src/main/kotlin/ui/IntegratedOrderSection.kt +++ b/src/main/kotlin/ui/IntegratedOrderSection.kt @@ -22,6 +22,7 @@ import androidx.compose.ui.unit.min import androidx.compose.ui.unit.sp import kotlinx.coroutines.launch import model.ConfigIndex +import model.InvestmentGrade import model.KisSession import model.RankingStock import network.KisTradeService @@ -30,61 +31,7 @@ import util.MarketUtil import kotlin.math.min import kotlin.math.roundToInt -enum class InvestmentGrade( - val displayName: String, - val description: String, - val shortWeight: Double = 0.0, - val midWeight: Double = 0.0, - val longWeight: Double = 0.0, - val profitGuide: ConfigIndex, - val buyGuide: ConfigIndex, -) { - LEVEL_5_STRONG_RECOMMEND( - displayName = "최상급 추천", - description = "단기·중기·장기 모두 우수하고, 신뢰도 매우 높은 범용 매수 추천", - shortWeight = 1.0, - midWeight = 1.0, - longWeight = 1.0, - profitGuide = ConfigIndex.GRADE_5_PROFIT, - buyGuide = ConfigIndex.GRADE_5_BUY, - ), - LEVEL_4_BALANCED_RECOMMEND( - displayName = "균형 추천", - description = "중기·장기 기본은 양호하고, 단기 성과도 준수한 안정형 추천", - shortWeight = 0.8, - midWeight = 1.0, - longWeight = 1.0, - profitGuide = ConfigIndex.GRADE_4_PROFIT, - buyGuide = ConfigIndex.GRADE_4_BUY, - ), - LEVEL_3_CAUTIOUS_RECOMMEND( - displayName = "보수적 추천", - description = "중기/장기 기본은 양호하지만, 단기 변동성이 높아 신중히 접근해야 함", - shortWeight = 0.6, - midWeight = 1.0, - longWeight = 1.0, - profitGuide = ConfigIndex.GRADE_3_PROFIT, - buyGuide = ConfigIndex.GRADE_3_BUY, - ), - LEVEL_2_HIGH_RISK( - displayName = "고위험 추천", - description = "단기/초단기 성과만 강하고, 중기·장기가 애매하여 리스크가 큰 투자", - shortWeight = 1.0, - midWeight = 0.4, - longWeight = 0.4, - profitGuide = ConfigIndex.GRADE_2_PROFIT, - buyGuide = ConfigIndex.GRADE_2_BUY, - ), - LEVEL_1_SPECULATIVE( - displayName = "순수 공격적 선택", - description = "단기/초단기 성과에만 의존하는 단기 급등형 공격적 투자", - shortWeight = 1.0, - midWeight = 0.2, - longWeight = 0.2, - profitGuide = ConfigIndex.GRADE_1_PROFIT, - buyGuide = ConfigIndex.GRADE_1_BUY, - ) -} + /** @@ -316,7 +263,7 @@ fun IntegratedOrderSection( AutoTradingManager.addToReanalysis(RankingStock(mksc_shrn_iscd = completeTradingDecision.stockCode,hts_kor_isnm = completeTradingDecision.stockName)) println("✋ [관망] 토탈 스코어 또는 신뢰도 미달 이나 약간의 오차로 재분석 대기열에 추가") } else { - println("✋ [관망] 토탈 스코어(${String.format("%.1f", totalScore)}) 또는 신뢰도 ${completeTradingDecision.confidence} 미달") + println("✋ [관망] 토탈 스코어(${String.format("%.1f[${minScore}]", totalScore)}) 또는 신뢰도 (${String.format("%.1f[${MIN_CONFIDENCE}]", completeTradingDecision.confidence)}) 미달") } } when (completeTradingDecision?.decision) { diff --git a/src/main/kotlin/ui/SettingsScreen.kt b/src/main/kotlin/ui/SettingsScreen.kt index bf3b6ae..e3f7799 100644 --- a/src/main/kotlin/ui/SettingsScreen.kt +++ b/src/main/kotlin/ui/SettingsScreen.kt @@ -24,6 +24,7 @@ import io.ktor.client.plugins.logging.LogLevel import io.ktor.client.plugins.logging.Logger import io.ktor.client.plugins.logging.Logging import io.ktor.serialization.kotlinx.json.json +import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.serialization.json.Json import model.AppConfig @@ -53,17 +54,22 @@ fun SettingsScreen(onAuthSuccess: () -> Unit) { } } - LazyColumn(modifier = Modifier.fillMaxSize().padding(24.dp)) { + LazyColumn(modifier = Modifier.fillMaxSize().padding(20.dp)) { item { - Text("거래 방식 선택", style = MaterialTheme.typography.h6) - Row(verticalAlignment = Alignment.CenterVertically) { + Row( + modifier = Modifier.fillMaxWidth(), // 필요에 따라 너비 설정 + verticalAlignment = Alignment.CenterVertically // 상하 중앙 정렬 추가 + ){ + Text("투자 방식", style = MaterialTheme.typography.subtitle1) + Spacer(Modifier.width(10.dp)) RadioButton(selected = !config.isSimulation, onClick = { config = config.copy(isSimulation = false,) }) - Text("실전투자") - Spacer(Modifier.width(16.dp)) + Text("실전") + Spacer(Modifier.width(10.dp)) RadioButton(selected = config.isSimulation, onClick = { config = config.copy() }) - Text("모의투자") + Text("모의") } - Divider(Modifier.padding(vertical = 12.dp)) + + Divider(Modifier.padding(vertical = 10.dp)) OutlinedTextField( value = config.htsId, onValueChange = { config = config.copy(htsId = it,) }, @@ -73,94 +79,126 @@ fun SettingsScreen(onAuthSuccess: () -> Unit) { ) // 실전 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()) + Row( + modifier = Modifier.fillMaxWidth(), // 필요에 따라 너비 설정 + verticalAlignment = Alignment.CenterVertically // 상하 중앙 정렬 추가 + ) { + OutlinedTextField( + value = config.realAccountNo, onValueChange = { + config = config.copy(realAccountNo = it,) + if (it.length >= 8) checkAndLoadConfig(it, true) + }, label = { Text("실전 계좌번호") }, modifier = Modifier.weight(0.5f)) + OutlinedTextField( + value = config.realAppKey, + onValueChange = { config = config.copy(realAppKey = it,) }, + label = { Text("실전 App Key") }, + modifier = Modifier.weight(0.5f) + ) + } OutlinedTextField(value = config.realSecretKey, onValueChange = { config = config.copy(realSecretKey = it,) }, label = { Text("실전 Secret Key") }, modifier = Modifier.fillMaxWidth(), visualTransformation = PasswordVisualTransformation()) - - Spacer(Modifier.height(16.dp)) + Spacer(Modifier.height(10.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()) + Row( + modifier = Modifier.fillMaxWidth(), // 필요에 따라 너비 설정 + verticalAlignment = Alignment.CenterVertically // 상하 중앙 정렬 추가 + ) { + OutlinedTextField(value = config.vtsAccountNo, onValueChange = { + config = config.copy(vtsAccountNo = it,) + if (it.length >= 8) checkAndLoadConfig(it, false) + }, label = { Text("모의 계좌번호") }, modifier = Modifier.weight(0.5f)) + OutlinedTextField( + value = config.vtsAppKey, + onValueChange = { config = config.copy(vtsAppKey = it,) }, + label = { Text("모의 App Key") }, + modifier = Modifier.weight(0.5f) + ) + } 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)) - + Spacer(Modifier.height(10.dp)) + Text("정보 조회 Api Keyz", fontWeight = FontWeight.Bold) OutlinedTextField(value = config.nAppKey, onValueChange = { config = config.copy(nAppKey = it,) }, label = { Text("NAVER Client ID") }, modifier = Modifier.fillMaxWidth()) OutlinedTextField(value = config.nSecretKey, onValueChange = { config = config.copy(nSecretKey = it,) }, label = { Text("NAVER Client Secret") }, modifier = Modifier.fillMaxWidth(), visualTransformation = PasswordVisualTransformation()) OutlinedTextField(value = config.dAppKey, onValueChange = { config = config.copy(dAppKey = it,) }, label = { Text("Dart ApiKey") }, modifier = Modifier.fillMaxWidth()) - // AI 모델 경로 및 드래그 앤 드롭 + Spacer(Modifier.height(10.dp)) Text("AI 모델 설정", fontWeight = FontWeight.Bold) - Box( - 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) config = config.copy(modelPath = path,) - } - }), - contentAlignment = Alignment.Center - ) { - Text(if(config.modelPath.isEmpty()) "GGUF 모델 파일을 여기로 드래그하세요" else config.modelPath, fontSize = 12.sp) + Row( + modifier = Modifier.fillMaxWidth(), // 필요에 따라 너비 설정 + verticalAlignment = Alignment.CenterVertically // 상하 중앙 정렬 추가 + ){ + Box( + modifier = Modifier.weight(0.5f).height(60.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) config = config.copy(modelPath = path,) + } + }), + contentAlignment = Alignment.Center + ) { + Text(if(config.modelPath.isEmpty()) "GGUF 모델 파일을 여기로 드래그하세요" else config.modelPath, fontSize = 12.sp) + } + Box( + modifier = Modifier.weight(0.5f).height(60.dp).border(1.dp, Color.Gray, RoundedCornerShape(8.dp)) + .onExternalDrag(onDrop = { state -> + val data = state.dragData + if (data is DragData.FilesList) { + val embedModelPath = data.readFiles().firstOrNull()?.removePrefix("file:") + if (embedModelPath?.endsWith(".gguf") == true) config = config.copy(embedModelPath = embedModelPath,) + } + }), + contentAlignment = Alignment.Center + ) { + Text(if(config.embedModelPath.isEmpty()) "임베드용 GGUF 모델 파일을 여기로 드래그하세요" else config.embedModelPath, fontSize = 12.sp) + } } - Box( - 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 embedModelPath = data.readFiles().firstOrNull()?.removePrefix("file:") - if (embedModelPath?.endsWith(".gguf") == true) config = config.copy(embedModelPath = embedModelPath,) - } - }), - contentAlignment = Alignment.Center - ) { - Text(if(config.embedModelPath.isEmpty()) "임베드용 GGUF 모델 파일을 여기로 드래그하세요" else config.embedModelPath, fontSize = 12.sp) - } - - Spacer(Modifier.height(24.dp)) + Spacer(Modifier.height(10.dp)) Button( modifier = Modifier.fillMaxWidth().height(50.dp), onClick = { scope.launch { -// isLoading = true - // 1. KisSession.config 업데이트 및 DB 저장 - KisSession.config = config - DatabaseFactory.saveConfig(config) - DartCodeManager.updateCorpCodes(HttpClient(CIO) { - install(ContentNegotiation) { - json(Json { - ignoreUnknownKeys = true - encodeDefaults = true // 기본값이 포함된 요청 바디를 정확히 전송하기 위해 필요 - }) - } - // [수정] 모든 로그(Headers + Body)를 찍도록 설정 - install(Logging) { - logger = Logger.DEFAULT - level = LogLevel.BODY - } - }) - val authService = KisAuthService - val tradeService = KisTradeService - val authSuccess = authService.refreshAllTokens() - val wsKeySuccess = tradeService.refreshWebsocketKey() + var retryCount = 0 + val maxRetries = 3 + val retryDelay = 90_000L // 1분 30초 + var isAuthCompleted = false - if (authSuccess && wsKeySuccess) { - statusMessage = "✅ 인증 성공! LLM 시작 중..." - onAuthSuccess() - } else { - statusMessage = "❌ 인증 실패. 키 정보를 확인하세요." + while (retryCount <= maxRetries && !isAuthCompleted) { + if (retryCount > 0) { + statusMessage = "⏳ 인증 재시도 중... (${retryCount}/${maxRetries}) - 1분 30초 후 재시작" + delay(retryDelay) + } + + // 1. 설정값 저장 + KisSession.config = config + DatabaseFactory.saveConfig(config) + + // 2. 법인코드 업데이트 + DartCodeManager.updateCorpCodes(HttpClient(CIO) { + install(ContentNegotiation) { json(Json { ignoreUnknownKeys = true }) } + }) + + // 3. 토큰 및 웹소켓 키 갱신 + val authSuccess = KisAuthService.refreshAllTokens() + val wsKeySuccess = KisTradeService.refreshWebsocketKey() + + if (authSuccess && wsKeySuccess) { + statusMessage = "✅ 인증 성공! LLM 시작 중..." + isAuthCompleted = true + onAuthSuccess() + } else { + retryCount++ + if (retryCount > maxRetries) { + statusMessage = "❌ 인증 실패. 3회 재시도 후 중단되었습니다. 키 정보를 확인하세요." + } else { + statusMessage = "⚠️ 인증 실패. 잠시 후 자동으로 다시 시도합니다. (시도 $retryCount)" + } + } } -// isLoading = false } } ) { Text("설정 저장 및 실행") } diff --git a/src/main/kotlin/ui/TradingDecisionLog.kt b/src/main/kotlin/ui/TradingDecisionLog.kt new file mode 100644 index 0000000..8f442b5 --- /dev/null +++ b/src/main/kotlin/ui/TradingDecisionLog.kt @@ -0,0 +1,227 @@ +package ui + + +import AutoTradeItem +import TradingDecision +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.GridItemSpan +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import model.ConfigIndex +import model.KisSession +import service.TechnicalAnalyzer + +@Composable +fun TradingDecisionLog() { + Row(modifier = Modifier.fillMaxSize().background(Color(0xFFF2F2F2))) { + Column(modifier = Modifier.weight(0.5f).padding(8.dp).fillMaxHeight().background(Color.White)) { + Text("AI 자동매매 실시간 로그", style = MaterialTheme.typography.h6) + Divider(Modifier.padding(vertical = 8.dp)) + + LazyColumn(reverseLayout = true) { // 최신 로그가 위로 오게 함 + items(TradingLogStore.decisionLogs) { log -> + Card( + modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), + elevation = 2.dp + ) { + Column(modifier = Modifier.padding(12.dp)) { + Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) { + Text("${log.time} - ${log.stockName}", fontWeight = FontWeight.Bold) + Text( + text = log.decision, + color = if (log.decision == "BUY") Color.Red else Color.Gray, + fontWeight = FontWeight.ExtraBold + ) + } + Text("신뢰도: ${log.confidence}%", fontSize = 11.sp) + Text("이유: ${log.reason}", fontSize = 12.sp, color = Color.DarkGray) + } + } + } + } + } + Column(modifier = Modifier.weight(0.5f).padding(6.dp).fillMaxHeight().background(Color.White)) { + LazyVerticalGrid( + columns = GridCells.Fixed(2), // 2열 병렬 배치 + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + modifier = Modifier.fillMaxWidth().fillMaxHeight().background(Color.White) + ) { + item(span = { GridItemSpan(maxLineSpan) }) { // 2열을 모두 차지함 + Text( + "💰 거래 기본 설정", + style = MaterialTheme.typography.subtitle2, + modifier = Modifier.padding(top = 10.dp, bottom = 8.dp) + ) + } + var defaults = arrayOf( + ConfigIndex.TAX_INDEX, + ConfigIndex.PROFIT_INDEX, + ConfigIndex.BUY_WEIGHT_INDEX, + ConfigIndex.MAX_BUDGET_INDEX, + ConfigIndex.MAX_PRICE_INDEX, + ConfigIndex.MIN_PRICE_INDEX, + ConfigIndex.MIN_PURCHASE_SCORE_INDEX, + ConfigIndex.SELL_PROFIT, + ConfigIndex.MAX_COUNT_INDEX, + ) + items(defaults.size) { index -> + val configKey = defaults.get(index) + + // 1. 키보드 입력을 실시간으로 보여줄 로컬 상태 (String) + var localText by remember(configKey) { + mutableStateOf(KisSession.config.getValues(configKey)?.toString() ?: "") + } + + // 저장 로직을 공통 함수로 분리 + val saveAction = { + var newValue = localText.toDoubleOrNull() ?: 0.0 + if (configKey.label.contains("PROFIT")) { + newValue = newValue / KisSession.config.getValues(ConfigIndex.PROFIT_INDEX) + } + KisSession.config.setValues(configKey, newValue) + DatabaseFactory.saveConfig(KisSession.config) + println("💾 저장됨: ${configKey.label} = $newValue") + } + + var text = if (configKey.label.contains("PROFIT")) { + "${(localText.toDoubleOrNull() ?: 1.0) * KisSession.config.getValues(ConfigIndex.PROFIT_INDEX)}" + } else { + localText + } + + OutlinedTextField( + value = text, + onValueChange = { localText = it }, // 화면에는 즉시 반영 + label = { Text(configKey.label) }, + modifier = Modifier + .fillMaxWidth() + .onFocusChanged { focusState -> + // 2. 포커스를 잃었을 때 저장 + if (!focusState.isFocused) { + saveAction() + } + }, + keyboardOptions = KeyboardOptions( + imeAction = ImeAction.Done, + keyboardType = KeyboardType.Decimal + ), + keyboardActions = KeyboardActions( + // 3. 엔터(Done) 키를 눌렀을 때 저장 + onDone = { + saveAction() + } + ), + singleLine = true + ) + } + item(span = { GridItemSpan(maxLineSpan) }) { // 2열을 모두 차지함 + Text( + "💰매수 정책 및 기대 수익률", + style = MaterialTheme.typography.subtitle2, + modifier = Modifier.padding(top = 10.dp, bottom = 8.dp) + ) + } + var defaults2 = arrayOf( + arrayOf(ConfigIndex.GRADE_5_BUY, + ConfigIndex.GRADE_5_PROFIT,), + arrayOf(ConfigIndex.GRADE_4_BUY, + ConfigIndex.GRADE_4_PROFIT,), + arrayOf(ConfigIndex.GRADE_3_BUY, + ConfigIndex.GRADE_3_PROFIT,), + arrayOf(ConfigIndex.GRADE_2_BUY, + ConfigIndex.GRADE_2_PROFIT,), + arrayOf(ConfigIndex.GRADE_1_BUY, + ConfigIndex.GRADE_1_PROFIT,), + ) + for (items in defaults2) { + val common = findLongestCommonSubstring(items.first().label,items.last().label) + item(span = { GridItemSpan(maxLineSpan) }) { // 2열을 모두 차지함 + Text( + common, + style = MaterialTheme.typography.body1, + modifier = Modifier.padding(top = 10.dp, bottom = 8.dp) + ) + } + + items(items.size) { index -> + val configKey = items.get(index) + + // 1. 키보드 입력을 실시간으로 보여줄 로컬 상태 (String) + var localText by remember(configKey) { + mutableStateOf(KisSession.config.getValues(configKey)?.toString() ?: "") + } + + var labelText by remember(configKey) { + mutableStateOf(KisSession.config.getValues(configKey)?.toString() ?: "") + } + + val saveAction = { + var newValue = localText.toDoubleOrNull() ?: 0.0 +// + KisSession.config.setValues(configKey, newValue) + DatabaseFactory.saveConfig(KisSession.config) + println("💾 저장됨: ${configKey.label} = $newValue") + labelText = if (configKey.name.contains("PROFIT")) { + getRemaining(configKey.label,common) + ": 기준율(${KisSession.config.getValues(ConfigIndex.PROFIT_INDEX)}) * 성향별 비율(${KisSession.config.getValues(configKey)}) + 세금제비용(${KisSession.config.getValues( + ConfigIndex.TAX_INDEX)}) = ${(localText.toDouble() * KisSession.config.getValues(ConfigIndex.PROFIT_INDEX)) + KisSession.config.getValues( + ConfigIndex.TAX_INDEX)}" + } else { + getRemaining(configKey.label,common) + ": -${localText} 호가 매수}" + } + } + + labelText = if (configKey.name.contains("PROFIT")) { + getRemaining(configKey.label,common) + ": 기준율(${KisSession.config.getValues(ConfigIndex.PROFIT_INDEX)}) * 성향별 비율(${KisSession.config.getValues(configKey)}) + 세금제비용(${KisSession.config.getValues( + ConfigIndex.TAX_INDEX)}) = ${(localText.toDouble() * KisSession.config.getValues(ConfigIndex.PROFIT_INDEX)) + KisSession.config.getValues( + ConfigIndex.TAX_INDEX)} " + } else { + getRemaining(configKey.label,common) + ": -${localText} 호가 매수}" + } + + OutlinedTextField( + value = localText, + onValueChange = { localText = it }, // 화면에는 즉시 반영 + label = { Text(labelText) }, + modifier = Modifier + .fillMaxWidth() + .onFocusChanged { focusState -> + // 2. 포커스를 잃었을 때 저장 + if (!focusState.isFocused) { + saveAction() + } + }, + keyboardOptions = KeyboardOptions( + imeAction = ImeAction.Done, + keyboardType = KeyboardType.Decimal + ), + keyboardActions = KeyboardActions( + // 3. 엔터(Done) 키를 눌렀을 때 저장 + onDone = { + saveAction() + } + ), + singleLine = true + ) + } + } + } + } + } +} \ No newline at end of file