package service import AutoTradeItem import Defines.AUTOSELL import Defines.BLACKLISTEDSTOCKCODES import Defines.EMBEDDING_PORT import Defines.LLM_PORT import network.TradingDecision import TradingLogStore import analyzer.AdvancedTradeAssistant import analyzer.TechnicalAnalyzer import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import getLlamaBinPath import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.withTimeout import model.ConfigIndex import model.ExecutionData import model.KisSession import model.RankingStock import model.RankingType import model.UnifiedBalance import model.UnifiedStockHolding import network.DartCodeManager import network.KisAuthService import network.KisTradeService import network.KisWebSocketManager import network.RagService import network.StockUniverseLoader import util.MarketUtil import java.time.LocalDate import java.time.LocalDateTime import java.time.LocalTime import java.time.ZoneId import java.util.concurrent.atomic.AtomicLong import kotlin.collections.List // service/AutoTradingManager.kt typealias TradingDecisionCallback = (TradingDecision?, Boolean)->Unit object AutoTradingManager { private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) private var discoveryJob: Job? = null // 모니터링을 위한 상태 변수 private val lastTickTime = AtomicLong(System.currentTimeMillis()) private var watchdogJob: Job? = null private const val CYCLE_TIMEOUT = 15 * 60 * 1000L // 한 사이클 최대 10분 private const val WATCHDOG_CHECK_INTERVAL = 30 * 1000L // 30초마다 생존 확인 private const val STUCK_THRESHOLD = 7 * 60 * 1000L // 5분간 반응 없으면 'Stuck'으로 판단 private const val ONE_STOCK_ALYSIS_TIME = 180000L fun isRunning(): Boolean = discoveryJob?.isActive == true private var remainingCandidates = mutableListOf() // private val processedCodes = mutableSetOf() // 중복 처리 방지용 (선택 사항) private val reanalysisList = mutableListOf() private val retryCountMap = mutableMapOf() var shouldShowFullWindow by mutableStateOf(false) var llmAnalyser by mutableStateOf(false) var llmNews by mutableStateOf(false) var tradeToken by mutableStateOf(false) var webSocketConnect by mutableStateOf(false) var testFlag = false fun startBackgroundScheduler() { scope.launch { while (isActive) { val seoulZone = ZoneId.of("Asia/Seoul") val now = LocalTime.now(ZoneId.of("Asia/Seoul")) val nowDate = LocalDate.now(seoulZone) var checkTime = 60_000 * 3L val isTradingDay = nowDate.dayOfWeek.value in 1..5 if (isTradingDay && now.isAfter(H07M50) && now.isBefore(H18) && !shouldShowFullWindow) { shouldShowFullWindow = true SystemSleepPreventer.wakeDisplay() } else if (now.isAfter(LocalTime.of(23, 50)) && now.isBefore(LocalTime.of(8, 0))) { SystemSleepPreventer.sleepDisplay() } if (!isTradingDay) { checkTime = 60_000 * 30L } delay(checkTime) // 1분마다 체크 } } } // val globalCallback = { completeTradingDecision: TradingDecision?, isSuccess: Boolean -> // if (isSuccess && completeTradingDecision != null) { // // 1. 로그 저장소에 기록 (UI에서 이걸 읽음) // TradingLogStore.addLog(completeTradingDecision) // // println("🚀 [자동매수 실행] ${completeTradingDecision.stockName}") // if (completeTradingDecision.confidence < 10) { // addToReanalysis(RankingStock(mksc_shrn_iscd = completeTradingDecision.stockCode,hts_kor_isnm = completeTradingDecision.stockName)) // TradingLogStore.addLog(completeTradingDecision,"RETRY","분석 신뢰도 오류 인지로 재분석 대기열에 추가") // }else 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 = completeTradingDecision.investmentGrade ?: InvestmentGrade.LEVEL_1_SPECULATIVE // // val finalMargin = baseProfit * KisSession.config.getValues(investmentGrade.profitGuide) // println("🚀 [매수 진행] 토탈 스코어: ${String.format("%.1f", totalScore)} -> 종목: ${completeTradingDecision.stockCode}") // // // basePrice(현재가 혹은 지정가)를 기준으로 매수 가능 수량 산출 (최소 1주 보장) // val gradeRate = KisSession.config.getValues(investmentGrade.allocationRate) // 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 전달) // excuteTrade( // decision = completeTradingDecision, // orderQty = min(calculatedQty, maxQty).toString(), // profitRate1 = finalMargin, // investmentGrade = investmentGrade, // ) // // } else if(totalScore >= (minScore * 0.9) && completeTradingDecision.confidence + append >= (MIN_CONFIDENCE * 0.9)) { // addToReanalysis(RankingStock(mksc_shrn_iscd = completeTradingDecision.stockCode,hts_kor_isnm = completeTradingDecision.stockName)) // TradingLogStore.addLog(completeTradingDecision,"RETRY","✋ [관망] 토탈 스코어[$totalScore] 또는 신뢰도[${completeTradingDecision.confidence}] 미달 이나 약간의 오차로 재분석 대기열에 추가") // } else { // TradingLogStore.addLog(completeTradingDecision,"HOLD","✋ [관망] 토탈 스코어(${String.format("%.1f[${minScore}]", totalScore)}) 또는 신뢰도 (${String.format("%.1f[${MIN_CONFIDENCE}]", completeTradingDecision.confidence)}) 미달") // } // } // if (completeTradingDecision?.decision?.contains("매수") == true) { // completeTradingDecision.decision = "BUY" // } // when (completeTradingDecision?.decision) { // "BUY","매수" -> { // append = buyWeight // TradingLogStore.addLog(completeTradingDecision,"BUY","[$stockCode] 매수 추천 : ${completeTradingDecision?.reason}") // resultCheck(completeTradingDecision) // } // "SELL" -> { // TradingLogStore.addLog(completeTradingDecision,"SELL","[$stockCode] 매도 추천 : ${completeTradingDecision?.reason}") // println("[$stockCode] 매도: ${completeTradingDecision?.reason}") // } // "HOLD" -> { // append = 0.0 // TradingLogStore.addLog(completeTradingDecision,"HOLD","[$stockCode] 관망 유지 : ${completeTradingDecision?.reason}") // resultCheck(completeTradingDecision) // } // else -> { // append = 0.0 // println("[$stockCode] ${completeTradingDecision?.decision} : ${completeTradingDecision?.reason}") // } // } // } // } // } val globalCallback = { completeTradingDecision: TradingDecision?, isSuccess: Boolean -> if (isSuccess && completeTradingDecision != null) { val decision = completeTradingDecision // 1. 이미 AI가 결정한 decision과 confidence를 신뢰함 if (decision.decision == "BUY") { val minScore = KisSession.config.getValues(ConfigIndex.MIN_PURCHASE_SCORE_INDEX) // AI가 이미 검증한 등급을 사용 (재계산 불필요) val grade = decision.investmentGrade ?: InvestmentGrade.LEVEL_1_SPECULATIVE // 2. 최종 매수 실행 val gradeRate = KisSession.config.getValues(grade.allocationRate) val maxBudget = KisSession.config.getValues(ConfigIndex.MAX_BUDGET_INDEX) * gradeRate val calculatedQty = (maxBudget / decision.currentPrice).toInt().coerceAtLeast(1) TradingLogStore.addLog(decision,"BUY",decision.summary()) excuteTrade( decision = decision, orderQty = calculatedQty.toString(), profitRate1 = KisSession.config.getValues(ConfigIndex.PROFIT_INDEX) * KisSession.config.getValues(grade.profitGuide), investmentGrade = grade ) } else if (decision.decision.equals("RETRY") || decision.confidence >= 60.0) { // 아까운 종목만 재분석 addToReanalysis(RankingStock(decision.stockCode, decision.stockName)) } } } fun getInvestmentGrade( ts: TradingDecision, totalScore: Double, confidence: Double, finScore100: Double // 💡 [수정1] 컴파일 에러 방지용 파라미터 추가 ): InvestmentGrade { val minScore = KisSession.config.getValues(ConfigIndex.MIN_PURCHASE_SCORE_INDEX) val minConfidence = minScore if (totalScore < (minScore * 0.8) || confidence < minConfidence) { return InvestmentGrade.LEVEL_0_SPECULATIVE } val shortAvg = (ts.ultraShortScore + ts.shortTermScore) / 2.0 val midLongAvg = (ts.midTermScore + ts.longTermScore) / 2.0 val isOverheated = ts.analyzer?.isOverheatedStock() ?: true // 1. 기본 등급 산정 var rawGrade = when { midLongAvg >= 70.0 -> { if (shortAvg >= 75.0) InvestmentGrade.LEVEL_5_STRONG_RECOMMEND else if (shortAvg >= 65.0) InvestmentGrade.LEVEL_4_BALANCED_RECOMMEND else InvestmentGrade.LEVEL_3_CAUTIOUS_RECOMMEND } midLongAvg >= 60.0 -> { if (shortAvg >= 70.0) InvestmentGrade.LEVEL_4_BALANCED_RECOMMEND else if (shortAvg >= 60.0) InvestmentGrade.LEVEL_3_CAUTIOUS_RECOMMEND else InvestmentGrade.LEVEL_2_HIGH_RISK } else -> { if (shortAvg >= 70.0) InvestmentGrade.LEVEL_2_HIGH_RISK else InvestmentGrade.LEVEL_1_SPECULATIVE } } // 💡 [수정2] 누락되었던 우량주 눌림목 프리미엄 & 잡주 투매 회피 로직 추가 val isHealthy = finScore100 >= 70.0 val isPullback = midLongAvg >= 75.0 && shortAvg <= 45.0 if (isHealthy && isPullback) { rawGrade = when (rawGrade) { InvestmentGrade.LEVEL_1_SPECULATIVE, InvestmentGrade.LEVEL_2_HIGH_RISK -> InvestmentGrade.LEVEL_3_CAUTIOUS_RECOMMEND InvestmentGrade.LEVEL_3_CAUTIOUS_RECOMMEND -> InvestmentGrade.LEVEL_4_BALANCED_RECOMMEND else -> rawGrade } } else if (!isHealthy && isPullback) { rawGrade = when (rawGrade) { InvestmentGrade.LEVEL_5_STRONG_RECOMMEND, InvestmentGrade.LEVEL_4_BALANCED_RECOMMEND, InvestmentGrade.LEVEL_3_CAUTIOUS_RECOMMEND -> InvestmentGrade.LEVEL_1_SPECULATIVE InvestmentGrade.LEVEL_2_HIGH_RISK, InvestmentGrade.LEVEL_1_SPECULATIVE -> InvestmentGrade.LEVEL_0_SPECULATIVE else -> InvestmentGrade.LEVEL_0_SPECULATIVE } } return if (isOverheated) { when (rawGrade) { InvestmentGrade.LEVEL_5_STRONG_RECOMMEND -> InvestmentGrade.LEVEL_4_BALANCED_RECOMMEND InvestmentGrade.LEVEL_4_BALANCED_RECOMMEND -> InvestmentGrade.LEVEL_3_CAUTIOUS_RECOMMEND InvestmentGrade.LEVEL_3_CAUTIOUS_RECOMMEND -> InvestmentGrade.LEVEL_2_HIGH_RISK else -> InvestmentGrade.LEVEL_1_SPECULATIVE } } else { rawGrade } } 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 -> // 💡 [개선 1] 첫 번째 성공 로그에 등급 이름 추가 println("[${investmentGrade.displayName}] 주문 성공: $realOrderNo $stockCode $orderQty $finalPrice") TradingLogStore.addLog(decision, "BUY", "[${investmentGrade.displayName}] 주문 성공: $realOrderNo") // 손절 라인 하드코딩 (필요시 Config로 빼는 것 권장) val sRate = -1.5 var tax = KisSession.config.getValues(ConfigIndex.TAX_INDEX) // 최소 보장 수익률(전역 설정)과 요청 수익률 중 큰 값 선택 후 세금 더하기 val effectiveProfitRate = (profitRate1 ?: 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) // 💡 [개선 3] 감시 설정 로그에도 등급 정보 노출 TradingLogStore.addLog(decision, "BUY", "[${investmentGrade.displayName}] 매수 및 감시 설정 완료 (목표 수익률: ${String.format("%.4f", effectiveProfitRate)}%): $realOrderNo") } .onFailure { println("매수 실패: ${it.message} ${stockCode} $orderQty $finalPrice") if (it.message?.contains("주문가능금액을 초과") == true) { AutoTradingManager.addToReanalysis(RankingStock(mksc_shrn_iscd = stockCode,hts_kor_isnm = stockName)) TradingLogStore.addLog(decision,"WATCH","${it.message ?: " 매수 실패"} => 재분석 대기열에 추가") } else { TradingLogStore.addLog(decision,"BUY",it.message ?: "매수 실패") } } } } var onExecutionReceived : ((String, String, String, String, Boolean) -> Unit)? = {code, qty, price,orderNo, isBuy -> scope.launch { val exec = ExecutionData(orderNo, code, price, qty, isBuy) executionCache[orderNo] = exec syncAndExecute(orderNo) } } 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 val absoluteMinRate = KisSession.config.getValues(ConfigIndex.TAX_INDEX) + 0.05 val finalProfitRate = maxOf(dbItem.profitRate, absoluteMinRate) // 3. 실제 체결가 기준 익절 가격 재계산 및 틱 사이즈 보정 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) TradingLogStore.addSellLog(dbItem.name,finalTargetPrice.toString(),"SELL","🎯 [매칭 성공] 익절 주문 실행: ${dbItem.name} | 매수가: ${actualBuyPrice.toInt()} -> 목표가: ${finalTargetPrice.toInt()} (${String.format("%.2f", finalProfitRate)}% 적용)") executionCache.remove(orderNo) }.onFailure { println("❌ 익절 주문 실패: ${it.message}") TradingLogStore.addSellLog(dbItem.name,finalTargetPrice.toString(),"SELL","❌ 익절 주문 실패: ${it.message}") } } else if (dbItem.status == TradeStatus.SELLING) { println("🎊 [매칭 성공] 매도 완료 처리: ${dbItem.name}") TradingLogStore.addSellLog(dbItem.name,execData.price,"SELL","🎊 [매칭 성공] 매도 완료 처리") DatabaseFactory.updateStatusAndOrderNo(dbItem.id!!, TradeStatus.COMPLETED) executionCache.remove(orderNo) } } } finally { processingIds.remove(orderNo) } } /** * 자동 발굴 루프 시작 및 Watchdog 실행 */ fun startAutoDiscoveryLoop() { if (isRunning()) return // 1. 기존 Watchdog이 있다면 제거 후 새로 시작 watchdogJob?.cancel() watchdogJob = scope.launch { while (isActive) { delay(WATCHDOG_CHECK_INTERVAL) val now = System.currentTimeMillis() if (isRunning() && (now - lastTickTime.get() > STUCK_THRESHOLD)) { println("🚨 [Watchdog] 루프 멈춤 감지 (5분간 응답 없음). 강제 재시작합니다.") restartLoop() } } } // 2. 메인 루프 실행 runDiscoveryLoop(globalCallback) } suspend fun sellingAfterMarketOnePrice(tradeService: KisTradeService,balance : UnifiedBalance,marketCode : String = "Y") { balance.holdings.forEach { holding -> if (BLACKLISTEDSTOCKCODES.contains(holding.code)){ println("❌ 차단 처리된 주식 : ${holding.name}") TradingLogStore.addAnalyzer( holding.name, holding.code, "거랙 차단 대상 : ${holding.currentPrice}[${holding.quantity}주] 보유, 수익률(${holding.profitRate.toDouble()})" ) } else { println("sellingAfterMarketOnePrice") if (holding != null && holding.quantity.toInt() > 0 && holding.availOrderCount.toInt() > 0 && holding.profitRate.toDouble() > KisSession.config.SELL_PROFIT) { var targetPrice = holding.currentPrice.toDouble() TradingLogStore.addAfterMarketLog( holding.name, holding.code, "${if ("Y".equals(marketCode)) "시간외 단일가" else "대체거래소"} 시세로 ${holding.profitRate} 수익 예상" ) targetPrice = MarketUtil.roundToTickSize(targetPrice + MarketUtil.getTickSize(targetPrice)) tradeService.postOrder( stockCode = holding.code, qty = holding.availOrderCount, price = targetPrice.toInt().toString(), isBuy = false, orderDivision = if (marketCode.equals("Y")) "07" else "", marketCode = if (marketCode.equals("Y")) "KRX" else "NXT" ).onSuccess { newOrderNo -> println("✅ [재주문 완료] ${holding.name}: $newOrderNo") TradingLogStore.addSellLog( holding.code, targetPrice.toString(), "SELL", "🎊 시간외 단일가 주식 재고털이 주문 완료" ) }.onFailure { TradingLogStore.addSellLog( holding.code, targetPrice.toString(), "SELL", "🎊 시간외 단일가 주식 재고털이 주문 실패[${it.message}] " ) } } else { analyzeDeepLossHoldingsAfterMarket(holding) } delay(300) // API 호출 부하 방지 } } } suspend fun resumePendingSellOrders(tradeService: KisTradeService,balance : UnifiedBalance) { val now = LocalTime.now() val currentMinute = now.minute // if (now.isBefore(H16) && now.isAfter(H08M35)) { println("resumePendingSellOrders") balance.holdings.forEach { holding -> if (BLACKLISTEDSTOCKCODES.contains(holding.code)){ println("❌ 차단 처리된 주식 : ${holding.name}") TradingLogStore.addAnalyzer( holding.name, holding.code, "거랙 차단 대상 : ${holding.currentPrice}[${holding.quantity}주] 보유, 수익률(${holding.profitRate.toDouble()})" ) } else { if (holding != null && holding.quantity.toInt() > 0 && holding.availOrderCount.toInt() > 0 && holding.profitRate.toDouble() > KisSession.config.SELL_PROFIT) { var targetPrice = holding.currentPrice.toDouble() val now = LocalTime.now() val currentMinute = now.minute var isBefore930 = false if (now.hour == 9 && currentMinute < 30) { targetPrice = targetPrice isBefore930 = true } else { targetPrice = MarketUtil.roundToTickSize(targetPrice + MarketUtil.getTickSize(targetPrice)) } println("🔄 [보유 주식 주문] ${holding.name} (${holding.code}) 매도 목표 ${targetPrice} 미체결 매도 건 재주문 시도") tradeService.postOrder( stockCode = holding.code, qty = holding.availOrderCount, price = targetPrice.toInt().toString(), isBuy = false, ).onSuccess { newOrderNo -> println("✅ [보유 주식 주문 완료] ${holding.name}: $newOrderNo") TradingLogStore.addSellLog( holding.code, targetPrice.toString(), "SELL", "🎊 보유 주식[예상수익 : ${holding.profitRate}] ${if (isBefore930) "09:30 이전 현시세{${holding.currentPrice}}로 매도[$targetPrice] 주문" else "09:30 이후 시세{${holding.currentPrice}} 기준 호가 위 매도[$targetPrice] 주문"} 완료" ) }.onFailure { TradingLogStore.addSellLog( holding.code, targetPrice.toString(), "SELL", "🎊 보유 주식 매도 주문 실패[${it.message}] " ) } } else { analyzeDeepLossHoldingsAfterMarket(holding) } delay(200) // API 호출 부하 방지 } } // } } private suspend fun analyzeDeepLossHoldingsAfterMarket(holding: UnifiedStockHolding) { // 💡 [신규 추가] 수익률이 크게 마이너스인 종목(-5.0% 이하) 심층 가이드 분석 val now = LocalTime.now() val currentMinute = now.minute if ((now.hour == 8 || now.hour == 16 || now.hour == 17)) { val profit = holding.profitRate.toDouble() val lossThreshold = -5.0 // 가이드를 작동시킬 손실 기준선 (필요시 ConfigIndex 로 빼셔도 좋습니다) if (profit <= lossThreshold) { println("🔍 [손실 종목 분석] ${holding.name} (수익률: $profit%) - 가이드 산출 중...") // 차트 데이터 빠르게 가져오기 (일봉 위주로 큰 추세만 확인) val dailyData = KisTradeService.fetchPeriodChartData(holding.code, "D", true).getOrNull() if (!dailyData.isNullOrEmpty()) { val analyzer = TechnicalAnalyzer().apply { this.daily = dailyData } val currentPrice = holding.currentPrice.toDouble() // 1. 볼린저 밴드 하단선 (통계적 바닥) 확인 val lowerBand = AdvancedTradeAssistant.calculateBollingerLowerBand(dailyData) // 2. RSI 확인 (과매도 투매 상태인지) val rsiDaily = analyzer.calculateRSI(dailyData) // 3. 중기 추세 확인 (최근 20일 기준 10% 이상 하락했는지) val isTrendBroken = analyzer.calculateChange(dailyData.takeLast(20)) < -10.0 var advice = "" // 🟢 [추매 타점] 볼린저 하단 터치(1.05배 이내) + RSI 과매도(35 이하) 구간 if (lowerBand > 0 && currentPrice <= lowerBand * 1.05 && rsiDaily < 35.0) { advice = "📉 [추매 권장] 볼린저 밴드 하단 터치 및 RSI 과매도(${"%.1f".format(rsiDaily)}). 기술적 반등 확률이 매우 높은 통계적 바닥권입니다. (물타기 고려)" } // 🔴 [손절 타점] 추세가 완전히 깨졌는데, 바닥(볼린저 하단)까지 한참 남았을 때 else if (isTrendBroken && currentPrice > lowerBand * 1.1) { advice = "🚨 [손절 경고] 20일 추세가 완전히 무너졌으며, 아직 바닥(하단 밴드)도 확인되지 않았습니다. 추가 하락(지하실) 위험이 크므로 리스크 관리(손절)가 필요합니다." } // 🟡 [관망] 어정쩡하게 물려있는 상태 else { advice = "⏳ [관망 유지] 뚜렷한 반등 시그널(바닥)이나 치명적 투매 시그널이 없습니다. 조금 더 지켜봅니다." } // 분석 결과를 UI 로그에 띄워 대표님이 확인할 수 있게 함 TradingLogStore.addNotice( "보유주식[${holding.name}]", holding.code, "수익률 심각($profit%) -> $advice", ) } } else { // -5% 이내의 자잘한 손실은 별도 분석 없이 조용히 넘기거나 약식 로그만 남김 // TradingLogStore.addAnalyzer("보유주식[${holding.name}]", holding.code, "수익률 미달 대기중 (${profit}%)") } } } var isSystemReadyToday = false var isSystemCleanedUpToday = false private var lastRetryTime = 0L val binPath = getLlamaBinPath() suspend fun tryRefreshToken() { try { // 2분 간격 재시도 로직 (처음 실행 시에는 lastRetryTime이 0이므로 즉시 실행) if (currentTimeMillis - lastRetryTime >= 2 * 60 * 1000L) { lastRetryTime = currentTimeMillis println("🌅 [System] 오전 8시 업무 시작 준비 시도...") SystemSleepPreventer.wakeDisplay() // 모니터 깨우기 val authSuccess = KisAuthService.refreshAllTokens() val wsSuccess = KisTradeService.refreshWebsocketKey() if (authSuccess && wsSuccess) { println("✅ [System] 토큰 갱신 성공. AI 서버를 기동합니다.") // 서버 시작 로직 실행 (Main.kt에 있던 로직 활용) val config = KisSession.config // LLM 서버 시작 (설정된 모델 경로 사용) if (config.modelPath.isNotEmpty()) { LlamaServerManager.startServer(binPath, config.modelPath,port = LLM_PORT) } if (config.embedModelPath.isNotEmpty()) { LlamaServerManager.startServer(binPath, config.embedModelPath, port = EMBEDDING_PORT) } KisWebSocketManager.connect() isSystemReadyToday = true shouldShowFullWindow = true } else { println("❌ [System] 토큰 갱신 실패. 2분 후 재시도합니다.") } } } catch (e: Exception) {} } var onMarketClosed: (() -> Unit)? = null var now = LocalTime.now(ZoneId.of("Asia/Seoul")) var currentTimeMillis = System.currentTimeMillis() var waitTime = 0.2 val H16 = LocalTime.of(16, 0) val H18 = LocalTime.of(18, 0) val H08M00 = LocalTime.of(8, 0) val H08M45 = LocalTime.of(8, 45) val H07M50 = LocalTime.of(7, 50) private fun runDiscoveryLoop(callback: TradingDecisionCallback) { discoveryJob = scope.launch { println("🚀 [AutoTrading] 발굴 루프 시작: ${LocalDateTime.now()}") while (isActive) { try { now = LocalTime.now(ZoneId.of("Asia/Seoul")) currentTimeMillis = System.currentTimeMillis() lastTickTime.set(System.currentTimeMillis()) // 생존 신고 when { now.isAfter(H18) || now.isBefore(H08M00) -> { prepareMarketOpen(now) } now.isBefore(H18) && now.isAfter(H08M00) -> { waitTime = 0.2 if (now.isAfter(LocalTime.of(8, 0)) && now.isBefore(LocalTime.of(15, 30))) { if (!KisSession.isMarketTokenValid() || !KisSession.isTradeTokenValid()) { if (isSystemReadyToday) { println("⚠️ [System] 토큰 만료 감지. 재발급 프로세스를 가동합니다.") isSystemReadyToday = false KisWebSocketManager.disconnect() tryRefreshToken() } } } withTimeout(CYCLE_TIMEOUT) { println("⏱️ [Cycle Start] ${LocalTime.now()}") if (now.isAfter(H18)) { executeClosingLiquidation(KisTradeService) } else { executeMarketLoop() } } } // // //장외 // now.isAfter(H16) || now.isBefore(H08M35) -> { // finalizeMarketClose(now) // } else ->{ waitTime = 3.0 } } } catch (e: TimeoutCancellationException) { println("⏳ [Cycle Timeout] 사이클이 너무 길어져 초기화 후 재시작합니다.") } catch (e: Exception) { println("⚠️ [Loop Error] ${e.message}") delay(1500) } waitForNextCycle(waitTime) } } } suspend fun prepareMarketOpen(now : LocalTime) { if (now.isAfter(H18) || now.isBefore(H07M50)) { println("🌙 [System] 마감 시간 도달. 자원 정리 후 대기 모드(설정 화면)로 전환합니다.") onMarketClosed?.invoke() RagService.clearDailyCache() KisWebSocketManager.disconnect() BrowserManager.closeIfIdle(0) LlamaServerManager.stopAll() // AI 서버 완전 종료 TradingLogStore.clear() isSystemReadyToday = false shouldShowFullWindow = false stopDiscovery() // 발굴 루프 완전 폭파 (내일 8시 30분에 다시 켜짐) } else if (now.isAfter(H07M50) && now.isBefore(H08M00) && !isSystemReadyToday) { if (MarketUtil.canTradeToday()) { SystemSleepPreventer.wakeDisplay() shouldShowFullWindow = true println("✅ [System] 오늘은 영업일입니다. 시스템을 가동합니다.") tryRefreshToken() // 토큰 갱신 및 화면 표시 신호(shouldShowFullWindow = true) } else { println("💤 [System] 오늘은 휴장일(또는 주말)입니다. 대기 모드를 유지합니다.") delay(3600_000) // 휴장일이면 1시간 뒤에 다시 체크하도록 긴 지연시간 부여 } } } var loadedTops = mutableListOf>() fun poll100Stocks(): List> { val count = minOf(loadedTops.size, 150) if (count == 0) return emptyList() // 앞의 100개를 복사 val batch = loadedTops.subList(0, count).toList() // 원본에서 삭제 (이 작업이 큐의 pop/remove 역할을 합니다) loadedTops.subList(0, count).clear() return batch } suspend fun checkBalance(isMorning: Boolean = true) : UnifiedBalance? { var balance : UnifiedBalance? = null if (isMorning) { balance = KisTradeService.fetchIntegratedBalance().getOrNull() if (AUTOSELL) balance?.let { resumePendingSellOrders(KisTradeService, it) } return balance } else { } return null } suspend fun executeMarketLoop() { val balance = checkBalance() // if (AUTOSELL) balance?.let { resumePendingSellOrders(KisTradeService, it) } val myCash = balance?.deposit?.replace(",", "")?.toLongOrNull() ?: 0L val myHoldings = balance?.holdings?.filter { it.quantity.toInt() > 0 }?.map { it.code }?.toSet() ?: emptySet() val pendingStocks = DatabaseFactory.findAllMonitoringTrades().map { it.code } if (remainingCandidates.isEmpty()) { if (loadedTops.size < 100) { loadedTops.addAll(StockUniverseLoader.loadUniverse()) loadedTops.shuffle() println("✅ 총 ${loadedTops.size}개의 종목이 로드되있음.") } poll100Stocks().forEach { (code, name) -> addToReanalysis(RankingStock(mksc_shrn_iscd = code, hts_kor_isnm = name)) } val candidates: MutableList = fetchCandidates(KisTradeService).apply { }.filter { val rate = it.prdy_ctrt.toDouble() val corpInfo = DartCodeManager.getCorpCode(it.code) val isOk = (rate > 0 && rate < 15) || (rate < 0 && rate > -15) if (corpInfo?.cName.isNullOrEmpty()) { false } else { isOk } } .filter { !it.name.contains("호스팩", true) } .sortedBy { (it.prdy_ctrt.toDoubleOrNull() ?: 0.0) } .toMutableList() if (reanalysisList.isNotEmpty()) { candidates.addAll(reanalysisList) } reanalysisList.clear() remainingCandidates.addAll(candidates.filter { it.code !in myHoldings && it.code !in pendingStocks && it.code !in executionCache.values.map { it.code } && it.code !in failList} .distinctBy { it.code }) } else { println("미확인 데이터 ${remainingCandidates.size}") } var totalCount = remainingCandidates.size println("후보군 조건 충족 총 개수 : ${totalCount}") val iterator = remainingCandidates.iterator() while (iterator.hasNext()) { totalCount-- val stock = iterator.next() if (now.isBefore(H16) && now.isAfter(H08M45)) { if (BLACKLISTEDSTOCKCODES.contains(stock.code)) { println("❌ 차단 처리된 주식 : ${stock.name}") } else { try { processSingleStock(stock, myCash, KisTradeService, globalCallback) } catch (e: Exception) { println("❌ 처리 중 오류 발생 (건너뜀): ${stock.name}") } finally { iterator.remove() } println("남은 후보군 개수 : ${totalCount}") delay(100) } } sellSchedule() } println("⏱️ [Cycle End] ${LocalTime.now()}") } private var lastForceCheckMinute = -1 // 마지막으로 강제 체크를 수행한 '분'을 저장 suspend fun sellSchedule() { val now = LocalTime.now() val currentMinute = now.minute println("매도 스케줄 체크") if (now.hour == 9 && (currentMinute % 10 == 1 || currentMinute % 10 == 7)) { if (lastForceCheckMinute != currentMinute) { TradingLogStore.addAnalyzer(" - ", " - ", "⏰ [강제 스케줄 실행] 오전 9시 ${currentMinute}분 - 보유주식 매도 체크를 시작합니다.", true) println("⏰ [강제 스케줄 실행] 오전 9시 ${currentMinute}분 - 보유주식 매도 체크를 시작합니다.") checkBalance() lastForceCheckMinute = currentMinute // 실행 완료 기록 } } else if((now.hour == 8 || now.hour == 16 || now.hour == 17) && (currentMinute % 10 == 3)) { if (lastForceCheckMinute != currentMinute) { TradingLogStore.addAnalyzer( " - ", " - ", "⏰ [강제 스케줄 실행] 오후 ${now.hour}시 ${currentMinute}분 - 보유주식 시간외 단일가 또는 대체마켓 체크를 시작합니다.", true ) listOf("Y","X").forEach { code -> KisTradeService.fetchIntegratedBalance(code).getOrNull()?.let { sellingAfterMarketOnePrice(KisTradeService, it, code) } } lastForceCheckMinute = currentMinute // 실행 완료 기록 } } } suspend fun finalizeMarketClose(now: LocalTime) { when { (AutoTradingManager.now.hour == 0 && AutoTradingManager.now.minute == 0 && (isSystemReadyToday || isSystemCleanedUpToday)) -> { waitTime = 10.0 isSystemReadyToday = false isSystemCleanedUpToday = false } (AutoTradingManager.now.isAfter(LocalTime.of(8, 0)) && !isSystemReadyToday) -> { waitTime = 3.0 if (!KisSession.isMarketTokenValid() || !KisSession.isTradeTokenValid()) { KisWebSocketManager.disconnect() tryRefreshToken() } } (AutoTradingManager.now.isAfter(LocalTime.of(18, 20))) -> { try { waitTime = 5.0 println("current SystemCleanedUpToday is $isSystemCleanedUpToday") if (!isSystemCleanedUpToday) { println("🌙 [System] 업무 종료 및 자원 정리 시작...") SystemSleepPreventer.sleepDisplay() // 모니터 끄기 KisWebSocketManager.disconnect() BrowserManager.closeIfIdle(0) // 즉시 닫기 if (LlamaServerManager.stopAll()) { isSystemCleanedUpToday = true } } println("✅ [System] 오늘의 모든 정리가 완료되었습니다.") } catch (e: Exception) { } } (AutoTradingManager.now.isAfter(LocalTime.of(18, 15)) && AutoTradingManager.now.minute % 15 == 0) -> { try { waitTime = 5.0 SystemSleepPreventer.sleepDisplay() // 모니터 끄기 } catch (e: Exception) { } } else -> { waitTime = 5.0 } } } fun addToReanalysis(stock: RankingStock) { val count = retryCountMap.getOrDefault(stock.code, 0) if (count < 10) { // 최대 2회까지만 재시도하여 무한 루프 방지 retryCountMap[stock.code] = count + 1 reanalysisList.add(stock) // println("📝 [Memory] ${stock.name} 관망 판정 -> 차기 루프 재분석 리스트 등록") } } val failList = arrayListOf() private suspend fun processSingleStock(stock: RankingStock, myCash: Long, tradeService: KisTradeService, callback: TradingDecisionCallback) { try { val maxBudget = KisSession.config.getValues(ConfigIndex.MAX_BUDGET_INDEX) val maxPrice = KisSession.config.getValues(ConfigIndex.MAX_PRICE_INDEX) val minPrice = KisSession.config.getValues(ConfigIndex.MIN_PRICE_INDEX) // 개별 종목 분석은 최대 2분으로 제한 withTimeout(ONE_STOCK_ALYSIS_TIME) { val corpInfo = DartCodeManager.getCorpCode(stock.code) if (corpInfo?.cName.isNullOrEmpty()) { print("-> 기업명을 못찾아서 제외 | ") return@withTimeout } callback(TradingDecision().apply { this.stockCode = stock.code this.confidence = -1.0 this.stockName = stock.name }, false) val dailyData = tradeService.fetchPeriodChartData(stock.code, "D", true).getOrNull() ?: return@withTimeout val today = dailyData.lastOrNull() ?: null if (today == null) { failList.add(stock.code) print("-> 금일 금액 조회 실패 | ") return@withTimeout } val currentPrice = today.stck_prpr.toDouble() if (currentPrice > myCash || currentPrice > maxBudget || currentPrice > maxPrice || currentPrice < minPrice) { print("-> 가격 정책으로 제외 [1주:${currentPrice}, 자산:${myCash}, 최소 기준:${minPrice}, 최대 기준:${maxPrice}] | ") return@withTimeout } println("🔍 [분석 진입] ${stock.name} (${LocalTime.now()})") val analyzer = coroutineScope { val min30 = async { tradeService.fetchChartData(stock.code, true).getOrDefault(emptyList()) } val weekly = async { tradeService.fetchPeriodChartData(stock.code, "W", true).getOrDefault(emptyList()) } val monthly = async { tradeService.fetchPeriodChartData(stock.code, "M", true).getOrDefault(emptyList()) } TechnicalAnalyzer().apply { this.daily = dailyData this.min30 = min30.await() this.weekly = weekly.await() this.monthly = monthly.await() } } if (analyzer.isValid()) { RagService.processStock(currentPrice, analyzer, stock.name, stock.code) { decision, isSuccess -> callback(decision?.apply { this.currentPrice = currentPrice }, isSuccess) } } println("✅ [분석 종료] ${stock.name} (${LocalTime.now()})") } } catch (e: Exception) { println("❌ [Stock Error] ${stock.name}: ${e.message}") } } private suspend fun fetchCandidates(tradeService: KisTradeService): List = coroutineScope { listOf( // async { tradeService.fetchMarketRanking(RankingType.VOLUME1, true).getOrDefault(emptyList()) }, // async { tradeService.fetchMarketRanking(RankingType.VOLUME0, true).getOrDefault(emptyList()) }, async { tradeService.fetchMarketRanking(RankingType.VOLUME, true).getOrDefault(emptyList()) }, async { tradeService.fetchMarketRanking(RankingType.VOLUME0, true).getOrDefault(emptyList()) }, async { tradeService.fetchMarketRanking(RankingType.VOLUME1, true).getOrDefault(emptyList()) }, async { tradeService.fetchMarketRanking(RankingType.VOLUME4, true).getOrDefault(emptyList()) }, async { tradeService.fetchMarketRanking(RankingType.RISE, true).getOrDefault(emptyList()) }, async { tradeService.fetchMarketRanking(RankingType.FALL, true).getOrDefault(emptyList()) }, // async { tradeService.fetchMarketRanking(RankingType.RISE2, true).getOrDefault(emptyList()) }, // async { tradeService.fetchMarketRanking(RankingType.FALL2, true).getOrDefault(emptyList()) }, async { tradeService.fetchMarketRanking(RankingType.VALUE, true).getOrDefault(emptyList()) }, async { tradeService.fetchMarketRanking(RankingType.VOLUME_POWER, true).getOrDefault(emptyList()) }, // async { tradeService.fetchMarketRanking(RankingType.NEW_HIGH, true).getOrDefault(emptyList()) }, async { tradeService.fetchMarketRanking(RankingType.COMPANY_TRADE, true).getOrDefault(emptyList()) }, async { tradeService.fetchMarketRanking(RankingType.FINANCE, true).getOrDefault(emptyList()) }, async { tradeService.fetchMarketRanking(RankingType.MARKET_VALUE, true).getOrDefault(emptyList()) }, async { tradeService.fetchMarketRanking(RankingType.SHORT_SALE, true).getOrDefault(emptyList()) }, ).awaitAll().flatten() } private fun restartLoop() { discoveryJob?.cancel() startAutoDiscoveryLoop() } private suspend fun waitForNextCycle(minutes: Double) { println("💤 대기 모드 진입... $minutes") val endWait = System.currentTimeMillis() + (minutes * 60 * 1000L) try { BrowserManager.closeIfIdle(0) // 즉시 닫기 } catch (e: Exception) { } while (System.currentTimeMillis() < endWait && isRunning()) { lastTickTime.set(System.currentTimeMillis()) // 대기 중에도 Watchdog에 생존 신고 println("💤 대기 모드 상태 확인...$minutes") delay(if(minutes > 3.0 ) 10000 else 1000) } } private suspend fun executeClosingLiquidation(tradeService: KisTradeService) { val activeTrades = DatabaseFactory.findAllMonitoringTrades() val balanceResult = tradeService.fetchIntegratedBalance().getOrNull() val realHoldings = balanceResult?.holdings?.associateBy { it.code } ?: emptyMap() activeTrades.forEach { trade -> try { if (!realHoldings.containsKey(trade.code)) { DatabaseFactory.updateStatusAndOrderNo(trade.id!!, TradeStatus.EXPIRED) return@forEach } // 마감 정리 로직 (필요 시 주석 해제하여 사용) println("📢 [마감 정리 체크] ${trade.name}") } catch (e: Exception) { println("⚠️ [마감 에러] ${trade.name}: ${e.message}") } delay(200) } } fun stopDiscovery() { discoveryJob?.cancel() discoveryJob = null println("🛑 [AutoTrading] 자동 발굴 중단됨") scope.launch { onMarketClosed?.invoke() println("💤 대기 모드 진입... $5.0") val endWait = System.currentTimeMillis() + (5.0 * 60 * 1000L) BrowserManager.closeIfIdle(0) // 즉시 닫기 while (System.currentTimeMillis() < endWait) { lastTickTime.set(System.currentTimeMillis()) // 대기 중에도 Watchdog에 생존 신고 println("💤 대기 모드 상태 확인...") delay(if(5.0 > 3.0 ) 10000 else 1000) } KisWebSocketManager.disconnect() BrowserManager.closeIfIdle(0) LlamaServerManager.stopAll() // AI 서버 완전 종료 TradingLogStore.clear() onMarketClosed?.invoke() } } fun checkAndRestart() { if (!isRunning()) { println("⚠️ [Watchdog] 자동 발굴 루프가 중단된 것을 감지했습니다. 재시작을 시도합니다...") startAutoDiscoveryLoop() } else { } } } data class Candle( val timestamp: Long, val open: Double, val high: Double, val low: Double, val close: Double, val volume: Double ) 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, val allocationRate: 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, allocationRate = ConfigIndex.GRADE_5_ALLOCATIONRATE, ), 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, allocationRate = ConfigIndex.GRADE_4_ALLOCATIONRATE, ), 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, allocationRate = ConfigIndex.GRADE_3_ALLOCATIONRATE, ), 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, allocationRate = ConfigIndex.GRADE_2_ALLOCATIONRATE, ), LEVEL_1_SPECULATIVE( displayName = "순수 투기/초단타", description = "재무 및 중장기 지표 무관, 오직 초단기 분봉과 에너지만 살아있는 극도의 투기적 진입", shortWeight = 1.0, midWeight = 0.2, longWeight = 0.2, profitGuide = ConfigIndex.GRADE_1_PROFIT, buyGuide = ConfigIndex.GRADE_1_BUY, allocationRate = ConfigIndex.GRADE_1_ALLOCATIONRATE, ), LEVEL_0_SPECULATIVE( displayName = "매수 금지 (관망)", description = "최소 신뢰도(Confidence) 미달로 시스템 통과 실패", shortWeight = 0.1, midWeight = 0.1, longWeight = 0.1, profitGuide = ConfigIndex.GRADE_1_PROFIT, // 더미 데이터 buyGuide = ConfigIndex.GRADE_1_BUY, allocationRate = ConfigIndex.GRADE_1_ALLOCATIONRATE, ) }