package service import AutoTradeItem import Defines.BLACKLISTEDSTOCKCODES import Defines.EMBEDDING_PORT import Defines.LLM_PORT import TradingLogStore import TradingLogStore.noticeFilter import analyzer.AdvancedTradeAssistant import analyzer.RiskManager 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.TradingDecision import model.UnifiedBalance import model.UnifiedStockHolding import network.DartCodeManager import network.KisAuthService import network.KisTradeService import network.KisWebSocketManager import network.RagService import network.RagService.isSafetyBeltStockCodes import network.StockUniverseLoader import okhttp3.internal.wait import report.TradingReportManager import util.MarketUtil import java.time.LocalDate import java.time.LocalDateTime import java.time.LocalTime import java.time.ZoneId import java.util.Collections import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.atomic.AtomicLong import kotlin.collections.List import kotlin.collections.filter import kotlin.math.abs import kotlin.math.max // 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 var CYCLE_TIMEOUT = KisSession.tradeConfig.CYCLE_TIMEOUT var WATCHDOG_CHECK_INTERVAL = KisSession.tradeConfig.WATCHDOG_CHECK_INTERVAL var STUCK_THRESHOLD = KisSession.tradeConfig.STUCK_THRESHOLD var ONE_STOCK_ALYSIS_TIME = KisSession.tradeConfig.ONE_STOCK_ALYSIS_TIME 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 now = LocalTime.now(ZoneId.of("Asia/Seoul")) var checkTime = 60_000 * 3L val isTradingDay = MarketUtil.canTradeToday() if (isTradingDay && now.isAfter(KisSession.startTime()) && now.isBefore(KisSession.endTime()) && !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 -> val seoulZone = ZoneId.of("Asia/Seoul") val now = LocalTime.now(ZoneId.of("Asia/Seoul")) if (KisSession.isMarketAnalyzerTime(now) && isSuccess && completeTradingDecision != null) { val decision = completeTradingDecision println("${decision.stockName} ${decision.decision}") // 1. 이미 AI가 결정한 decision과 confidence를 신뢰함 if (decision.decision == "BUY") { var maxRealisticProfitRate = 0.0 // AI가 이미 검증한 등급을 사용 (재계산 불필요) val grade = decision.investmentGrade ?: InvestmentGrade.LEVEL_1_SPECULATIVE decision.analyzer?.let { a -> val volatility = a?.calculateVolatilityForecast(a.daily, 20) volatility?.let { maxRealisticProfitRate = ((volatility.realisticHigh - decision.currentPrice) / decision.currentPrice) * 100.0 } } // 1. 통계적으로 도달 가능한 현실적인 최대 수익률 계산 (1표준편차 상단 기준) // 2. 시스템 기본 설정 수익률과 비교 val baseProfitRate = KisSession.config.getValues(ConfigIndex.PROFIT_INDEX) + KisSession.config.getValues( grade.profitGuide ) // 3. 스마트 익절률 결정: 시스템 설정값이 통계적 한계를 넘어서면, 통계적 한계치로 눈높이를 낮춤 val finalProfitRate = if (maxRealisticProfitRate > 0.0 && baseProfitRate > maxRealisticProfitRate) { max(maxRealisticProfitRate, 0.05) } else { baseProfitRate // 변동성이 충분히 크다면 원래 시스템 설정대로 진행 } // 2. 최종 매수 실행 val gradeRate = KisSession.config.getValues(grade.allocationRate) val maxBudget = KisSession.config.getValues(ConfigIndex.MAX_BUDGET_INDEX) * gradeRate decision.maxRealisticProfitRate = maxRealisticProfitRate TradingLogStore.addLog( decision, "BUY", decision.summary( KisSession.config.getValues(ConfigIndex.PROFIT_INDEX) * KisSession.config.getValues( grade.profitGuide ) ) ) var hasCodes = KisSession.tradeConfig.lowerAveragePrice && currentBalance?.getHoldings() ?.any { it.code.equals(decision.stockCode) && it.quantity.toInt() > 2 && it.availOrderCount.toInt() > 0 } ?: false val calculatedQty = if (hasCodes == true) KisSession.tradeConfig.lowerAverageStockCount else (maxBudget / decision.currentPrice).toInt() .coerceAtLeast(1) if (hasCodes == true) { TradingLogStore.addNotice( decision.stockName, decision.stockCode, "물타기 시도 ${calculatedQty}주 매수" ) } excuteTrade( decision = decision, orderQty = calculatedQty.toString(), profitRate1 = finalProfitRate, investmentGrade = grade, hasCode = hasCodes == true ) } else if (decision.decision.equals("RETRY") || decision.confidence >= 60.0) { // 아까운 종목만 재분석 addToReanalysis(RankingStock(decision.stockCode, decision.stockName)) } } else { } } 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, hasCode: Boolean ) { 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()) val maxStocks = KisSession.config.getValues(ConfigIndex.MAX_HOLDING_COUNT).toInt() if (!canAddNewPosition(maxStocks)) { TradingLogStore.addNotice("SYSTEM", "LIMIT", "최대 보유 종목 도달로 신규 매수 일시 중단") addToReanalysis(RankingStock(mksc_shrn_iscd = stockCode, hts_kor_isnm = stockName)) TradingLogStore.addWatchLog( decision, "WATCH", "매수 실패 : 최대 보유 종목 도달로 신규 매수 일시 중단 => 재분석 대기열에 추가" ) } else if (KisSession.isAvailBuyTime(LocalTime.now()) || hasCode) { println( "basePrice : $basePrice, oneTickLowerPrice : $oneTickLowerPrice, finalPrice : $finalPrice hasStocks : ${ stockCode.contains( stockCode ) }" ) var realOrderQty = orderQty KisTradeService.postOrder( stockCode, realOrderQty, finalPrice.toLong().toString(), isBuy = true ) .onSuccess { realOrderNo -> println("[${investmentGrade.displayName}] 주문 성공: $realOrderNo $stockCode $orderQty $finalPrice") TradingLogStore.addLog( decision, "BUY", "[${investmentGrade.displayName}] 주문 성공: $realOrderNo" ) val sRate = -1.5 var tax = KisSession.config.getValues(ConfigIndex.TAX_INDEX) val effectiveProfitRate = (profitRate1 ?: KisSession.config.getValues(ConfigIndex.PROFIT_INDEX)) + tax try { var oldTarget = currentBalance?.getHoldings() ?.first { it.availOrderCount.toInt() > 0 && it.code.equals(decision.stockCode) } if (KisSession.tradeConfig.lowerAveragePrice && hasCode && oldTarget != null) { var avgPrive = oldTarget.avgPrice.toDouble() var qty = oldTarget.quantity.toDouble() basePrice = avgPrive * 1.5//((avgPrive * qty) + (decision.currentPrice * orderQty.toInt())).div(qty!!.toInt() + (orderQty.toInt())) println("물타기 ${avgPrive}, ${qty} ${basePrice}") } } catch (e: Exception) { e.printStackTrace() } val calculatedTarget = MarketUtil.roundToTickSize(basePrice * (1 + effectiveProfitRate / 100.0)) val calculatedStop = MarketUtil.roundToTickSize(basePrice * (1 + sRate / 100.0)) val inputQty = orderQty.replace(",", "").toIntOrNull() ?: 0 if (!hasCode) { DatabaseFactory.saveAutoTrade( AutoTradeItem( orderNo = realOrderNo, code = stockCode, name = stockName, quantity = inputQty, profitRate = effectiveProfitRate, stopLossRate = sRate, targetPrice = calculatedTarget, stopLossPrice = calculatedStop, status = "PENDING_BUY", isDomestic = true ) ) TradingReportManager.recordTradeDecision( orderNo = realOrderNo, stockCode = stockCode, stockName = stockName, isBuy = true, orderQty = inputQty, reason = decision.reason ?: "", // AI 이유 decision = decision // AI 객체 통째로 전달 ) 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.addWatchLog( decision, "WATCH", "${it.message ?: " 매수 실패"} => 재분석 대기열에 추가" ) } else { TradingLogStore.addLog(decision, "BUY", it.message ?: "매수 실패") } } } else if (!hasCode && KisSession.isAvailBuyTime(LocalTime.now())) { AutoTradingManager.addToReanalysis( RankingStock( mksc_shrn_iscd = stockCode, hts_kor_isnm = stockName ) ) TradingLogStore.addWatchLog( decision, "WATCH", "매수 시간 외 분석 => 재분석 대기열에 추가" ) } else if (KisSession.isMarketOpenTime(LocalTime.now()) == false){ val unfilledResult = KisTradeService.fetchUnfilledOrders() unfilledResult.onSuccess { response -> response.filter { it.sll_buy_dvsn_cd == "02" }.forEach { order -> TradingLogStore.addNotice( order.prdt_name, order.pdno, "[주문 취소] 매수시간 종료 후 모든 매수 취소" ) KisTradeService.cancelOrder( order.ord_no, // 원주문번호 order.pdno ) delay(200) } } } } } var onExecutionReceived: ((String, String, String, String, Boolean) -> Unit)? = { code, qty, price, orderNo, isBuy -> scope.launch { val exec = ExecutionData(orderNo, code, price, qty, isBuy) println("exec >> ${exec}") executionCache[orderNo] = exec syncAndExecute(orderNo) } } val executionCache = ConcurrentHashMap() val processingIds = Collections.newSetFromMap(ConcurrentHashMap()) 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. 진짜 사온 가격 (실제 매수 체결가) var actualBuyPrice = execData.price.toDoubleOrNull() ?: dbItem.targetPrice // 💡 [수정] 매수 주문(orderNo)에 대해 '진짜 산 가격'을 기록해야 합니다. // 기존에는 여기에 finalTargetPrice를 넣으셨는데, 그러면 매수 단가가 오염됩니다. TradingReportManager.updateExecution(orderNo, actualBuyPrice, dbItem.quantity) var hasCodes = KisSession.tradeConfig.lowerAveragePrice && currentBalance?.getHoldings() ?.any { it.code.equals(dbItem.code) && it.quantity.toInt() > 2 && dbItem.quantity == KisSession.tradeConfig.lowerAverageStockCount } ?: false if (hasCodes) { actualBuyPrice = actualBuyPrice * 1.1 } val absoluteMinRate = KisSession.config.getValues(ConfigIndex.TAX_INDEX) val finalProfitRate = maxOf(dbItem.profitRate, absoluteMinRate) val finalTargetPrice = MarketUtil.roundToTickSize(actualBuyPrice * (1 + finalProfitRate / 100.0)) println("🎯 [매수 확정] ${dbItem.name} | 매수가: ${actualBuyPrice.toInt()} -> 목표가 설정: ${finalTargetPrice.toInt()}") KisTradeService.postOrder( stockCode = dbItem.code, qty = dbItem.quantity.toString(), price = finalTargetPrice.toLong().toString(), isBuy = false ).onSuccess { newSellOrderNo -> // 💡 [매도 주문 기록] 이제 팔기 시작했다는 의사결정을 리포트에 남깁니다. TradingReportManager.recordTradeDecision( orderNo = newSellOrderNo, stockCode = dbItem.code, stockName = dbItem.name, isBuy = false, orderQty = dbItem.quantity, reason = "🎯 목표 수익률 ${ String.format( "%.2f", finalProfitRate ) }% 도달을 위한 익절 주문", holdingAvgPrice = actualBuyPrice, // 👈 여기서 매수단가를 넘겨줘야 매도 리포트가 정확해집니다! decision = null ) DatabaseFactory.updateStatusAndOrderNo( dbItem.id!!, TradeStatus.SELLING, newSellOrderNo ) executionCache.remove(orderNo) } } else if (dbItem.status == TradeStatus.SELLING) { // ✅ 2. 매도 완료 시점 (실제 매도 체결가) val actualSellPrice = execData.price.toDoubleOrNull() ?: 0.0 val actualSellQty = execData.qty.toIntOrNull() ?: dbItem.quantity // 💡 매도 주문번호에 대해 '진짜 판 가격'을 기록 TradingReportManager.updateExecution(orderNo, actualSellPrice, actualSellQty) println("🎊 [매칭 성공] 매도 완료: ${dbItem.name} | 매도가: ${actualSellPrice.toInt()}") TradingLogStore.addSellLog( dbItem.name, actualSellPrice.toString(), "SELL", "매도 완료" ) TradingReportManager.closePositionCycle(dbItem.code) // 사이클 종료 알림 DatabaseFactory.updateStatusAndOrderNo(dbItem.id!!, TradeStatus.COMPLETED) executionCache.remove(orderNo) } } } finally { processingIds.remove(orderNo) } } /** * 자동 발굴 루프 시작 및 Watchdog 실행 */ fun startAutoDiscoveryLoop(doStart: Boolean = false) { if (isRunning()) return // 1. 기존 Watchdog이 있다면 제거 후 새로 시작 watchdogJob?.cancel() watchdogJob = scope.launch { val activeTrades = DatabaseFactory.findAllMonitoringTrades() var now = LocalTime.now(ZoneId.of("Asia/Seoul")) if (doStart && activeTrades.isNotEmpty() && !KisSession.isMarketOpenTime(now)) { executeClosingLiquidation(activeTrades) } 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.getHoldings().forEach { holding -> if (BLACKLISTEDSTOCKCODES.contains(holding.code)) { println("❌ 차단 처리된 주식 : ${holding.name}") TradingLogStore.addAnalyzer( holding.name, holding.code, "거랙 차단 대상 : ${holding.currentPrice}[${holding.quantity}주] 보유, 수익률(${holding.profitRate.toDouble()})" ) } else { val now = LocalTime.now() val targetProfitLimit = if (holding.isTodayEntry && now.isBefore(LocalTime.of(16, 0))) { // 당일 매수 종목: 짧은 익절 (예: 1.0% 이상이면 즉시 매도) KisSession.config.getValues(ConfigIndex.PROFIT_INDEX) + KisSession.config.getValues( ConfigIndex.TAX_INDEX ) } else { // 오래 보유한 종목: 기존 설정값 준수 (예: 3.0% 등) KisSession.config.SELL_PROFIT } if (holding != null && holding.quantity.toInt() > 0 && holding.availOrderCount.toInt() > 0 && holding.profitRate.toDouble() > targetProfitLimit) { var targetPrice = holding.currentPrice.toDouble() TradingLogStore.addAfterMarketLog( holding.name, holding.code, "${if ("Y".equals(marketCode)) "시간외 단일가" else "대체거래소"} 시세로 ${holding.profitRate} 수익 예상" ) tradeService.postOrder( stockCode = holding.code, qty = holding.availOrderCount, price = targetPrice.toInt().toString(), isBuy = false, orderDivision = if (marketCode.equals("Y")) "41" else "", marketCode = if (marketCode.equals("Y")) "KRX" else "NXT" ).onSuccess { newOrderNo -> println("✅ [${if (marketCode.equals("Y")) "시간외 단일가" else "대체거래소"} 주문 완료] ${holding.name}: $newOrderNo") TradingLogStore.addSellLog( "${holding.name}[${holding.code}]", targetPrice.toString(), "SELL", "🎊 ${if (marketCode.equals("Y")) "시간외 단일가" else "대체거래소"} 주식 재고털이 주문 완료" ) DatabaseFactory.saveAutoTrade( AutoTradeItem( orderNo = newOrderNo, code = holding.code, name = holding.name, quantity = holding.quantity.toInt(), profitRate = 0.0, stopLossRate = 0.0, targetPrice = targetPrice.toDouble(), stopLossPrice = 0.0, status = "SELLING", isDomestic = true ) ) syncAndExecute(newOrderNo) }.onFailure { TradingLogStore.addSellLog( "${holding.name}[${holding.code}]", targetPrice.toString(), "SELL", "🎊 ${if (marketCode.equals("Y")) "시간외 단일가" else "대체거래소"} 주식 재고털이 주문 실패[${it.message}] " ) } } else { if ("Y".equals(marketCode)) { if (KisSession.config.getValues(ConfigIndex.STOP_LOSS) > 0.0 && holding != null && holding.quantity.toInt() > 0 && holding.availOrderCount.toInt() > 0 && holding.profitRate.toDouble() <= KisSession.config.getValues( ConfigIndex.LOSS_MINRATE ) && holding.profitRate.toDouble() >= KisSession.config.getValues( ConfigIndex.LOSS_MAXRATE ) && holding.valuationProfitAmount.toDouble() >= KisSession.config.getValues( ConfigIndex.LOSS_MAX_MONEY ) ) { println( "${holding.name} ${holding.profitRate.toDouble()} ${holding.valuationProfitAmount.toDouble()} ${ KisSession.config.getValues( ConfigIndex.LOSS_MAX_MONEY ) } , ${KisSession.config.getValues(ConfigIndex.LOSS_MINRATE)} , ${ KisSession.config.getValues( ConfigIndex.STOP_LOSS ) }" ) val profit = holding.profitRate.toDouble() TradingLogStore.addNotice( "보유주식[${holding.name}]", holding.code, "수익률($profit%) -> ${holding.valuationProfitAmount} 손해 중이며 현제 손절 가이드에 적합함." ) } analyzeDeepLossHoldingsAfterMarket(holding) } } delay(300) // API 호출 부하 방지 } } } suspend fun resumePendingSellOrders(tradeService: KisTradeService, balance: UnifiedBalance) { val now = LocalTime.now() val currentMinute = now.minute if (now.isBefore(H15M30) && now.isAfter(H08M45)) { println("resumePendingSellOrders") balance.getHoldings().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] 주문"} 완료" ) DatabaseFactory.saveAutoTrade( AutoTradeItem( orderNo = newOrderNo, code = holding.code, name = holding.name, quantity = holding.quantity.toInt(), profitRate = 0.0, stopLossRate = 0.0, targetPrice = targetPrice.toDouble(), stopLossPrice = 0.0, status = "SELLING", isDomestic = true ) ) syncAndExecute(newOrderNo) }.onFailure { TradingLogStore.addSellLog( holding.code, targetPrice.toString(), "SELL", "🎊 보유 주식 매도 주문 실패[${it.message}] " ) } } else { var errMsg = "" var isSuccess = false if (KisSession.tradeConfig.autoSellOrder && holding != null && holding.quantity.toInt() > 0 && holding.availOrderCount.toInt() > 0 && holding.profitRate.toDouble() <= KisSession.tradeConfig.autoSellOrderMin && holding.profitRate.toDouble() >= KisSession.tradeConfig.autoSellOrderMax && holding.avgPrice.toDouble() > holding.currentPrice.toDouble() ) { var targetPrice = holding.avgPrice.toDouble() targetPrice = MarketUtil.roundToTickSize( targetPrice + (MarketUtil.getTickSize(targetPrice) * KisSession.tradeConfig.autoSellOrderAppend) ) tradeService.postOrder( stockCode = holding.code, qty = holding.availOrderCount, price = targetPrice.toInt().toString(), isBuy = false, ).onSuccess { newOrderNo -> println("✅ [보유 주식 손절 처리] ${holding.name} 매수가 기준 (${holding.avgPrice.toDouble()} 3호가 위[${targetPrice}] 매도 주문") isSuccess = true }.onFailure { err -> println("✅ [보유 주식 손절 처리] ${holding.name} 실패 ${targetPrice} ${err.message}") errMsg = err.message.toString() } TradingLogStore.addNotice( "보유주식[${holding.name}]", holding.code, "매수가 기준 (${holding.avgPrice.toDouble()} ${KisSession.tradeConfig.autoSellOrderAppend}호가 위[${targetPrice}] 매도 주문 ${if (isSuccess) "성공" else "실패[${errMsg}]"}" ) } else if (KisSession.config.stop_Loss && holding != null && holding.quantity.toInt() > 0 && holding.availOrderCount.toInt() > 0 && holding.profitRate.toDouble() <= KisSession.config.getValues( ConfigIndex.LOSS_MINRATE ) && holding.profitRate.toDouble() >= KisSession.config.getValues( ConfigIndex.LOSS_MAXRATE ) && holding.valuationProfitAmount.toDouble() >= KisSession.config.getValues( ConfigIndex.LOSS_MAX_MONEY ) ) { println( "${holding.name} ${holding.profitRate.toDouble()} ${holding.valuationProfitAmount.toDouble()} ${ KisSession.config.getValues( ConfigIndex.LOSS_MAX_MONEY ) } , ${KisSession.config.getValues(ConfigIndex.LOSS_MINRATE)} , ${ KisSession.config.getValues( ConfigIndex.STOP_LOSS ) }" ) val profit = holding.profitRate.toDouble() var targetPrice = holding.currentPrice.toDouble() targetPrice = MarketUtil.roundToTickSize( targetPrice + MarketUtil.getTickSize(targetPrice) * KisSession.tradeConfig.autoSellOrderAppend ) tradeService.postOrder( stockCode = holding.code, qty = holding.availOrderCount, price = targetPrice.toInt().toString(), isBuy = false, ).onSuccess { newOrderNo -> println("✅ [보유 주식 손절 처리] 수익률($profit%) -> ${holding.valuationProfitAmount} 손해 중이며 현제 손절 가이드에 적합함 시장가 매도.") }.onFailure { err -> println("✅ [보유 주식 손절 처리] 실패 ${err.message}") } TradingLogStore.addNotice( "보유주식[${holding.name}]", holding.code, "수익률($profit%) -> ${holding.valuationProfitAmount} 손해 중이며 현제 손절 가이드에 적합함 시장가 매도." ) } analyzeDeepLossHoldingsAfterMarket(holding, true) } delay(200) // API 호출 부하 방지 } } } } private suspend fun analyzeDeepLossHoldingsAfterMarket( holding: UnifiedStockHolding, isForce: Boolean = false ) { // 💡 [신규 추가] 수익률이 크게 마이너스인 종목(-5.0% 이하) 심층 가이드 분석 val now = LocalTime.now() val currentMinute = now.minute if ((holding.availOrderCount.toInt() ?: 0) > 0 && ((!isForce && (now.hour == 8 || now.hour == 16 || now.hour == 17)) || (isForce && (currentMinute % 5 == 0))) ) { 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)}). 기술적 반등 확률이 매우 높은 통계적 바닥권입니다. (물타기 고려)" TradingLogStore.addNotice( "보유주식[${holding.name}]", holding.code, "수익률($profit%) -> $advice" ) } // 🔴 [손절 타점] 추세가 완전히 깨졌는데, 바닥(볼린저 하단)까지 한참 남았을 때 else if (isTrendBroken && currentPrice > lowerBand * 1.1) { advice = "🚨 [손절 경고] 20일 추세가 완전히 무너졌으며, 아직 바닥(하단 밴드)도 확인되지 않았습니다. 추가 하락(지하실) 위험이 크므로 리스크 관리(손절)가 필요합니다." TradingLogStore.addNotice( "보유주식[${holding.name}]", holding.code, "수익률 심각($profit%) -> $advice", holding.quantity.toInt() ) } // 🟡 [관망] 어정쩡하게 물려있는 상태 else { advice = "⏳ [관망 유지] 뚜렷한 반등 시그널(바닥)이나 치명적 투매 시그널이 없습니다. 조금 더 지켜봅니다." TradingLogStore.addAnalyzer( "보유주식[${holding.name}]", holding.code, "수익률($profit%) -> $advice", false ) } } } else { // -5% 이내의 자잘한 손실은 별도 분석 없이 조용히 넘기거나 약식 로그만 남김 // TradingLogStore.addAnalyzer("보유주식[${holding.name}]", holding.code, "수익률 미달 대기중 (${profit}%)") } } } var isSystemReadyToday = false var isSystemCleanedUpToday = false private var lastRetryTime = 0L val binPath = getLlamaBinPath() fun canAddNewPosition( // 대표님의 시스템에 맞는 미체결 주문 객체 리스트 maxAllowedStocks: Int ): Boolean { // 현재 노출 수가 최대 허용치보다 작을 때만 true(매수 가능) 반환 return true //(currentBalance?.getHoldings()?.count { it.availOrderCount.toInt() > 0 } ?: 0) > maxAllowedStocks } 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 H15M30 = LocalTime.of(15, 30) val H08M45 = LocalTime.of(8, 45) 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(KisSession.endTime()) || now.isBefore(KisSession.startTime()) -> { prepareMarketOpen(now) } now.isBefore(KisSession.endTime()) && now.isAfter(KisSession.startTime()) -> { 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()}") val activeTrades = DatabaseFactory.findAllMonitoringTrades() if (now.isAfter(KisSession.endBuyTime()) && activeTrades.isNotEmpty()) { executeClosingLiquidation(activeTrades) } else { executeMarketLoop() } } } else -> { waitTime = 3.0 } } } catch (e: TimeoutCancellationException) { println("⏳ [Cycle Timeout] 사이클이 너무 길어져 초기화 후 재시작합니다.") } catch (e: Exception) { println("⚠️ [Loop Error] ${e.message}") delay(1000) } waitForNextCycle(waitTime) } } } suspend fun prepareMarketOpen(now: LocalTime) { if (now.isAfter(KisSession.endTime()) || now.isBefore(KisSession.startTime())) { 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( KisSession.startTime().minusMinutes(20) ) && now.isBefore(KisSession.startTime()) && !shouldShowFullWindow ) { if (MarketUtil.canTradeToday()) { shouldShowFullWindow = true println("✅ [System] 오늘은 영업일입니다. 시스템을 가동합니다.") tryRefreshToken() // 토큰 갱신 및 화면 표시 신호(shouldShowFullWindow = true) } else { println("💤 [System] 오늘은 휴장일(또는 주말)입니다. 대기 모드를 유지합니다.") delay(3600_000) // 휴장일이면 1시간 뒤에 다시 체크하도록 긴 지연시간 부여 } } } var loadedTops = mutableListOf>() var defaultStockCount = 30 var currentBalance: UnifiedBalance? = null private var lastFetchTime: Long = 0L // 마지막 성공 시간 (Millisecond) private val FETCH_INTERVAL = 1 * 60 * 1000 // 30분을 밀리초로 환산 (1800000 ms) suspend fun checkBalance() { val currentTime = System.currentTimeMillis() if (currentBalance == null || (currentTime - lastFetchTime) > FETCH_INTERVAL) { KisTradeService.fetchIntegratedBalance().getOrNull()?.let { currentBalance = it lastFetchTime = currentTime // 호출 성공 시 현재 시간으로 갱신 println("잔고 동기화 완료") } ?: run { println("잔고 조회 실패 (네트워크 오류 등)") } } else { // 30분이 지나지 않았다면 기존에 저장된 currentBalance를 그대로 사용 println("${(FETCH_INTERVAL / (1000 * 60))}분이 지나지 않아 기존 잔고 데이터 유지 (남은 시간: ${(FETCH_INTERVAL - (currentTime - lastFetchTime)) / 1000}초)") } if (KisSession.config.take_profit) currentBalance?.let { resumePendingSellOrders( KisTradeService, it ) } if (KisSession.tradeConfig.auto_cancel_pending_buy) { checkAndCancelPendingBuyOrders() } } suspend fun checkAndCancelPendingBuyOrders( ) { // 1. 미체결 내역 조회 val unfilledResult = KisTradeService.fetchUnfilledOrders() unfilledResult.onSuccess { response -> val currentTime = System.currentTimeMillis() // 매수 주문('02')만 필터링 response.filter { it.sll_buy_dvsn_cd == "02" }.forEach { order -> val orderTimeMillis = parseOrderTime(order.ord_tmd) val elapsedMillis = currentTime - orderTimeMillis if (elapsedMillis >= KisSession.tradeConfig.auto_cancel_pending_time) { // 2. 현재가 조회 (가격을 비교하기 위해) val currentPrice = KisTradeService.fetchCurrentPrice(order.pdno) .getOrNull()?.stck_prpr?.toDouble() ?: 0.0 val orderedPrice = order.ord_unpr.toDoubleOrNull() ?: 0.0 // 조건 B: 현재가와 주문가의 괴리율 체크 (현재가가 너무 올라갔거나 내려갔을 때) val priceGap = Math.abs(currentPrice - orderedPrice) / orderedPrice println("checkAndCancelPendingBuyOrders order $order ${elapsedMillis / 1000L}초 ${priceGap}% 차이") if (priceGap >= KisSession.tradeConfig.auto_cancel_pending_rate) { TradingLogStore.addNotice( order.prdt_name, order.pdno, "[주문 취소] ${order.prdt_name} (${order.pdno}) - 시간경과 및 가격괴리(${ String.format( "%.2f", priceGap ) }%)로 취소 시도" ) KisTradeService.cancelOrder( order.ord_no, // 원주문번호 order.pdno ) } } } } } suspend fun cancelAllPendingSellOrders( ) { // 1. 미체결 내역 조회 val unfilledResult = KisTradeService.fetchUnfilledOrders() unfilledResult.onSuccess { response -> response.filter { it.sll_buy_dvsn_cd == "01" }.forEach { order -> TradingLogStore.addNotice( order.prdt_name, order.pdno, "[주문 취소] 정규장 시작전 모든 매도 주문 취소" ) KisTradeService.cancelOrder( order.ord_no, // 원주문번호 order.pdno ) } } } // 주문 시간 문자열을 Millis로 변환하는 유틸리티 (당일 주문 기준) fun parseOrderTime(ordTmd: String): Long { return try { val now = java.time.LocalDateTime.now() val hour = ordTmd.substring(0, 2).toInt() val min = ordTmd.substring(2, 4).toInt() val sec = ordTmd.substring(4, 6).toInt() val orderDateTime = now.withHour(hour).withMinute(min).withSecond(sec) orderDateTime.atZone(java.time.ZoneId.systemDefault()).toInstant().toEpochMilli() } catch (e: Exception) { System.currentTimeMillis() } } suspend fun executeMarketLoop() { checkBalance() var myCash = currentBalance?.deposit?.replace(",", "")?.toLongOrNull() ?: KisSession.config.getValues(ConfigIndex.MAX_PRICE_INDEX).toLong() myCash = max(myCash, KisSession.config.getValues(ConfigIndex.MAX_PRICE_INDEX).toLong()) val myHoldings = currentBalance?.getHoldings()?.filter { !it.isTodayEntry }?.map { it.code }?.toSet() ?: emptySet() val pendingStocks = DatabaseFactory.findAllMonitoringTrades().map { it.code } var now = LocalTime.now(ZoneId.of("Asia/Seoul")) if (remainingCandidates.isEmpty()) { if (loadedTops.size < defaultStockCount) { loadedTops.addAll(StockUniverseLoader.loadUniverse()) println("✅ 총 ${loadedTops.size}개의 종목이 로드되있음.") } loadedTops.shuffle() val count = minOf(loadedTops.size, defaultStockCount) for (i in 0.. = fetchCandidates(KisTradeService).apply { }.filter { val rate = it.prdy_ctrt.toDouble() val corpInfo = DartCodeManager.getCorpCode(it.code) val isOk = (rate > 0 && rate < KisSession.tradeConfig.plusFilter) || (rate < 0 && rate > (abs( KisSession.tradeConfig.minusFilter ) * -1)) if (corpInfo?.cName.isNullOrEmpty()) { false } else if (it.code !in myHoldings && it.code !in pendingStocks && it.code !in executionCache.values.map { it.code } && it.code !in failList && it.code !in isSafetyBeltStockCodes ) { isOk } else { false } } .filter { !it.name.contains("호스팩", true) } .toMutableList() if (reanalysisList.isNotEmpty()) { candidates.addAll(reanalysisList) } reanalysisList.clear() val wateringCodes = mutableSetOf() if (KisSession.tradeConfig.lowerAveragePrice) { // map 대신 forEach를 사용하여 메모리 낭비 방지 currentBalance?.getHoldings()?.forEach { if ( it.quantity.toInt() > KisSession.tradeConfig.lowerAverageTargetCount && it.profitRate.toDouble() < 0.0 && it.profitRate.toDouble() < (abs(KisSession.tradeConfig.lowerAverageMaxRate) * -1) && it.profitRate.toDouble() > (abs(KisSession.tradeConfig.lowerAverageMinRate) * -1) ) { // 물타기 전용 Set에 코드 등록 wateringCodes.add(it.code) candidates.add( RankingStock( mksc_shrn_iscd = it.code, hts_kor_isnm = it.name ) ) println("💧 [물타기 우선순위 할당] ${it.name}[${it.code}]") val avgPrive = it.avgPrice.toDouble() val qty = it.quantity.toDouble() val basePrice = ((avgPrive * qty) + it.currentPrice.toDouble()).div(qty.toInt() + 1) println(" -> 단가 현황: 평단가 $avgPrive, 보유 ${qty}주, 예상단가 $basePrice") } } } // 1. 기존 필터링 및 중복 제거 적용 val filteredCandidates = candidates.filter { (if (KisSession.tradeConfig.lowerAveragePrice) { true } else { it.code !in myHoldings }) && it.code !in pendingStocks && it.code !in executionCache.values.map { it.code } && it.code !in failList && it.code !in isSafetyBeltStockCodes }.distinctBy { it.code } // 🌟 2. [핵심] 리스트를 두 그룹으로 파티셔닝 (물타기 대상 vs 신규 발굴) val (wateringList, newList) = filteredCandidates.partition { it.code in wateringCodes } remainingCandidates.clear() // 🌟 3. 분석 대기열 재조립 (물타기 대상 1순위 배치) remainingCandidates.addAll(wateringList) // 물타기 대상을 무조건 리스트 맨 앞으로 (셔플 안 함) remainingCandidates.addAll(newList.shuffled()) } else { println("미확인 데이터 ${remainingCandidates.size}") } var totalCount = remainingCandidates.size println("후보군 조건 충족 총 개수 : ${totalCount}") val iterator = remainingCandidates.iterator() while (iterator.hasNext()) { totalCount-- val stock = iterator.next() if (KisSession.isMarketAnalyzerTime(now)) { 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(500) } } sellSchedule() } println("⏱️ [Cycle End] ${LocalTime.now()}") } // private var lastForceCheckMinute = -1 // 마지막으로 강제 체크를 수행한 '분'을 저장 private val executionCountMap = mutableMapOf() suspend fun sellSchedule() { if (KisSession.config.take_profit == false) return val now = LocalTime.now() val timeKey = String.format("%02d:%02d", now.hour, now.minute) // 예: "09:05" val currentCount = executionCountMap.getOrDefault(timeKey, 0) if (currentCount >= KisSession.tradeConfig.excuteCountOnMin) { return } var isExecuted = false val currentMinute = now.minute if (now.isBefore(LocalTime.of(8, 50)) && now.isAfter(LocalTime.of(8, 45))) { cancelAllPendingSellOrders() isExecuted = true } else if ((now.isBefore(LocalTime.of(15, 40)) && now.isAfter(KisSession.endBuyTime()))) { val unfilledResult = KisTradeService.fetchUnfilledOrders() unfilledResult.onSuccess { response -> response.filter { it.sll_buy_dvsn_cd == "02" }.forEach { order -> TradingLogStore.addNotice(order.prdt_name, order.pdno, "[주문 취소] 정규장 후 모든 매수 취소") KisTradeService.cancelOrder( order.ord_no, // 원주문번호 order.pdno ) } } isExecuted = true } else if (now.hour == 9 && now.minute % KisSession.tradeConfig.excuteMinCheck == 0) { TradingLogStore.addAnalyzer( " - ", " - ", "⏰ [강제 스케줄 실행] 오전 9시 ${currentMinute}분 - 보유주식 매도 체크를 시작합니다.", true ) println("⏰ [강제 스케줄 실행] 오전 9시 ${currentMinute}분 - 보유주식 매도 체크를 시작합니다.") checkBalance() isExecuted = true } else if ( ( (now.hour == 8 && KisSession.tradeConfig.before_nxt && currentMinute < 45) || (now.isAfter(LocalTime.of(15, 40)) && now.isBefore( LocalTime.of( 20, 0 ) ) && KisSession.tradeConfig.after_nxt) ) && (currentMinute % 2 == 0) ) { TradingLogStore.addAnalyzer( " - ", " - ", "⏰ [강제 스케줄 실행] 오후 ${now.hour}시 ${currentMinute}분 - 보유주식 시간외 단일가 또는 대체마켓 체크를 시작합니다.", true ) var list = mutableListOf("X") if (now.hour != 8 && now.hour < 20) { list.add("Y") } list.forEach { code -> KisTradeService.fetchIntegratedBalance(code).getOrNull()?.let { sellingAfterMarketOnePrice(KisTradeService, it, code) } } isExecuted = true } if (isExecuted) { executionCountMap[timeKey] = currentCount + 1 } if (now.hour >= 20) { executionCountMap.clear() noticeFilter.clear() } } fun addToReanalysis(stock: RankingStock) { val count = retryCountMap.getOrDefault(stock.code, 0) if (count < 30) { // 최대 2회까지만 재시도하여 무한 루프 방지 retryCountMap[stock.code] = count + 1 reanalysisList.add(stock) } } 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) withTimeout(ONE_STOCK_ALYSIS_TIME) { // 1. 기업 코드 검증 val corpInfo = DartCodeManager.getCorpCode(stock.code) if (corpInfo?.cName.isNullOrEmpty()) return@withTimeout // 🌟 [핵심] 물타기 대상 여부 플래그 식별 val targetHolding = currentBalance?.getHoldings()?.firstOrNull { it.code == stock.code && it.quantity.toInt() > KisSession.tradeConfig.lowerAverageTargetCount } val isWatering = targetHolding != null && KisSession.tradeConfig.lowerAveragePrice if (isWatering) { println("💧 [물타기 분석 모드 가동] ${stock.name} (현재 수익률: ${targetHolding?.profitRate}%)") } // 2. 재무 안정성 체크 (물타기라도 상폐/자본잠식 위험주는 추가 매수 금지) if (isSafetyBeltStockCodes.contains(stock.code)) { print("-> [${stock.name}] 재무 건전성 미달 제외 | ") return@withTimeout } callback(TradingDecision().apply { this.stockCode = stock.code this.confidence = -1.0 this.stockName = stock.name }, false) // 3. 배당 필터 (물타기 종목은 배당 여부와 무관하게 탈출이 우선이므로 패스) if (!isWatering && KisSession.tradeConfig.isUpcomingDividend) { val dividend = KisTradeService.fetchUpcomingDividend(stock.code).getOrNull() if (dividend?.hasDividend != true) return@withTimeout } val dailyData = tradeService.fetchPeriodChartData(stock.code, "D", true).getOrNull() ?: return@withTimeout if (dailyData.size < 30) return@withTimeout val today = dailyData.lastOrNull() ?: return@withTimeout val rate = today.getFluctuationRate() // 🌟 [완화 1] 당일 등락률 필터 // 신규: 좁은 박스권만 허용 | 물타기: 급락(-8%)이 아니면 대부분 허용 val isOk = if (isWatering) { rate > -8.0 && rate < 15.0 } else { (rate < KisSession.tradeConfig.plusFilter) && (rate > (abs(KisSession.tradeConfig.minusFilter) * -1)) } delay(50) val currentStock = KisTradeService.fetchCurrentPrice(stock.code).getOrNull() ?: return@withTimeout val currentPrice = currentStock.stck_prpr.toDouble() val riskResult = RiskManager.evaluateRisk(currentStock) if (!riskResult.isSafe) return@withTimeout // 🌟 [완화 2] 예산 필터 (물타기는 기존 설정 수량 매수 예산만 있으면 통과) val budgetCheck = if (isWatering) { val waterBudget = currentPrice * KisSession.tradeConfig.lowerAverageStockCount myCash >= waterBudget } else { currentPrice <= maxBudget && (myCash <= 10L || currentPrice <= myCash) } if (!isOk || !budgetCheck || currentPrice > maxPrice || currentPrice < minPrice) { print("-> [${stock.name}] 조건/가격 제외 (물타기여부:$isWatering) | ") return@withTimeout } val tempAnalyzer = TechnicalAnalyzer().apply { this.daily = dailyData } val volatility = tempAnalyzer.calculateVolatilityForecast(dailyData, 20) val expectedProfitRate = ((volatility.realisticHigh - currentPrice) / currentPrice) * 100.0 val dailyStats = tempAnalyzer.calculateDynamicReboundStats(dailyData, 3.0) // 🌟 [완화 3] 기대수익률 및 진입 타이밍 이원화 val isProfitable: Boolean val isValidEntryTiming: Boolean if (isWatering) { // 물타기: 평단 낮추기 목적이므로 기대수익률 1.0% 이상이면 충분 isProfitable = expectedProfitRate >= 1.0 || dailyStats.avgReboundAmplitude >= 1.5 // 물타기 진입 타이밍: 반등 주기 도달 OR (일봉 RSI 35 이하 과매도) OR (볼린저 하단 터치) val rsi = tempAnalyzer.calculateRSI(dailyData) val lowerBand = AdvancedTradeAssistant.calculateBollingerLowerBand(dailyData) val isOversoldZone = rsi <= 38.0 || (lowerBand > 0 && currentPrice <= lowerBand * 1.03) isValidEntryTiming = isOversoldZone || (dailyStats.isValid && tempAnalyzer.checkReboundApproaching( dailyData, dailyStats.avgReboundPeriod, dailyStats.avgDropRate, dailyStats.timeTolerance )) } else { // 신규 진입: 기존 엄격 기준 유지 isProfitable = expectedProfitRate >= KisSession.tradeConfig.minExpectedProfitRate || dailyStats.avgReboundAmplitude >= KisSession.tradeConfig.minExpectedProfitRate val isApproaching = tempAnalyzer.checkReboundApproaching( dailyData, dailyStats.avgReboundPeriod, dailyStats.avgDropRate, dailyStats.timeTolerance ) isValidEntryTiming = (dailyStats.isValid && isApproaching && dailyStats.avgReboundPeriod <= KisSession.tradeConfig.maxExpectedReboundDays && dailyStats.avgReboundPeriod >= KisSession.tradeConfig.minExpectedReboundDays) || tempAnalyzer.checkSteadyUptrend(dailyData) } val currentAtr = tempAnalyzer.calculateATR(dailyData) if (!isProfitable || !isValidEntryTiming) { print("-> [${stock.name}] 목표 수익 조건 미달 (물타기:$isWatering, 수익:$isProfitable, 타이밍:$isValidEntryTiming) | 기대 수익율 : ${expectedProfitRate}") return@withTimeout } // 🌟 [완화 4] 지하실 방어 및 브레이크 확인 val dropPrediction = tempAnalyzer.predictDropBottom(dailyData, dailyStats, volatility, currentAtr) if (dropPrediction != null) { val distanceToBottomPct = ((currentPrice - dropPrediction.expectedBottomPrice) / currentPrice) * 100.0 val allowedMargin = if (isWatering) 4.0 else 2.0 // 물타기는 하락 여력 마진을 4%까지 관대하게 인정 if (distanceToBottomPct > allowedMargin && !dropPrediction.isBottomZone) { print("-> [${stock.name}] 지하실 주의 (추가하락여력: ${"%.1f".format(distanceToBottomPct)}%) | ") return@withTimeout } // 멈춤 브레이크는 물타기에서도 필수 (떨어지는 칼날에 물타면 비중만 커져 위험) if (dropPrediction.isBottomZone || isWatering) { val hasBrake = tempAnalyzer.checkBrakeAndReversal(dailyData) if (!hasBrake) { print("-> [${stock.name}] 지지/브레이크 미확인 (칼날 회피) | ") return@withTimeout } } } println("\n💧 [검문소 통과 -> ${if (isWatering) "물타기 탈출 분석" else "신규 매수 분석"}] ${stock.name} (물타기:$isWatering, 수익:$isProfitable, 타이밍:$isValidEntryTiming) | 기대 수익율 : ${expectedProfitRate}") // 멀티 타임프레임 및 AI 분석 진입 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, isWatering) { decision, isSuccess -> callback(decision?.apply { this.currentPrice = currentPrice }, isSuccess) } } } } catch (e: Exception) { e.printStackTrace() } } private suspend fun fetchCandidates(tradeService: KisTradeService): List = coroutineScope { listOf( 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.VALUE, true) .getOrDefault(emptyList()) }, async { tradeService.fetchMarketRanking(RankingType.VOLUME_POWER, 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(activeTrades: List) { activeTrades.forEach { trade -> try { DatabaseFactory.updateStatusAndOrderNo(trade.id!!, TradeStatus.EXPIRED) println("📢 [마감 정리 체크] ${trade.name}") } catch (e: Exception) { println("⚠️ [마감 에러] ${trade.name}: ${e.message}") } delay(5) } } 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() } } } 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, ) }