package service import AutoTradeItem import Defines.AUTOSELL import Defines.BLACKLISTEDSTOCKCODES import Defines.EMBEDDING_PORT import Defines.LLM_PORT 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.TradingDecision 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 report.SnapshotType 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.concurrent.atomic.AtomicLong import kotlin.collections.List import kotlin.collections.filter // 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(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.isAvailBuyTime(now) && 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)) } } 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) { 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)) { println("🚫 [안전 장치 작동] 현재 포지션이 가득 찼습니다. (최대 ${myOredsAndBalanceCodes.size}/${maxStocks}종목). 신규 매수를 일시 중단하고 매도에 집중합니다.") TradingLogStore.addNotice("SYSTEM", "LIMIT", "최대 보유 종목 도달로 신규 매수 일시 중단") AutoTradingManager.addToReanalysis(RankingStock(mksc_shrn_iscd = stockCode,hts_kor_isnm = stockName)) TradingLogStore.addLog(decision,"WATCH","매수 실패 : 최대 보유 종목 도달로 신규 매수 일시 중단 => 재분석 대기열에 추가") } else { println("basePrice : $basePrice, oneTickLowerPrice : $oneTickLowerPrice, finalPrice : $finalPrice") KisTradeService.postOrder(stockCode, orderQty, 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 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 )) 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.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 // 💡 [수정] 매수 주문(orderNo)에 대해 '진짜 산 가격'을 기록해야 합니다. // 기존에는 여기에 finalTargetPrice를 넣으셨는데, 그러면 매수 단가가 오염됩니다. TradingReportManager.updateExecution(orderNo, actualBuyPrice, dbItem.quantity) val absoluteMinRate = KisSession.config.getValues(ConfigIndex.TAX_INDEX) + 0.05 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()}") myOredsAndBalanceCodes.remove(dbItem.code) TradingReportManager.closePositionCycle(dbItem.code) // 사이클 종료 알림 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.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 targetProfitLimit = if (holding.isTodayEntry) { // 당일 매수 종목: 짧은 익절 (예: 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")) "07" 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 { 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() tradeService.postOrder( stockCode = holding.code, qty = holding.availOrderCount, price = "0", isBuy = false, ).onSuccess { newOrderNo -> println("✅ [보유 주식 손절 처리] 수익률($profit%) -> ${holding.valuationProfitAmount} 손해 중이며 현제 손절 가이드에 적합함 시장가 매도.") }.onFailure { } 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 ((!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//myOredsAndBalanceCodes.size < 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()}") if (now.isAfter(KisSession.endTime())) { executeClosingLiquidation(KisTradeService) } else { executeMarketLoop() } } } 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(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(10)) && now.isBefore(KisSession.startTime()) && !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 } var currentBalance : UnifiedBalance? = null var myOredsAndBalanceCodes : MutableSet = mutableSetOf() suspend fun checkBalance(isMorning: Boolean = true) { if (isMorning) { currentBalance = KisTradeService.fetchIntegratedBalance().getOrNull() currentBalance?.let { currentBalance -> if (LocalTime.now().isBefore(LocalTime.of(18,1))) { TradingReportManager.recordAssetSnapshot( if (LocalTime.now().isAfter(LocalTime.of(18, 0)) ) SnapshotType.END else SnapshotType.MIDDLE, currentBalance, "" ) } } if (KisSession.config.take_profit) currentBalance?.let { resumePendingSellOrders(KisTradeService, it) } if (KisSession.tradeConfig.auto_cancel_pending_buy) { checkAndCancelPendingBuyOrders() } } else { } } 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 // 조건 A: 설정된 대기 시간 경과 여부 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 * 100)}%)로 취소 시도") 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() { myOredsAndBalanceCodes.clear() checkBalance() val myCash = currentBalance?.deposit?.replace(",", "")?.toLongOrNull() ?: 0L val myHoldings = currentBalance?.getHoldings()?.map { myOredsAndBalanceCodes.add(it.code) it.code }?.toSet() ?: emptySet() val pendingStocks = DatabaseFactory.findAllMonitoringTrades().map { myOredsAndBalanceCodes.add(it.code) 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 (KisSession.isAvailBuyTime(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(100) } } sellSchedule() } println("⏱️ [Cycle End] ${LocalTime.now()}") } private var lastForceCheckMinute = -1 // 마지막으로 강제 체크를 수행한 '분'을 저장 suspend fun sellSchedule() { if (KisSession.config.take_profit == false) { } else { val now = LocalTime.now() val currentMinute = now.minute if (now.hour == 9 && currentMinute % 2 == 1 ) { if (lastForceCheckMinute != currentMinute) { TradingLogStore.addAnalyzer( " - ", " - ", "⏰ [강제 스케줄 실행] 오전 9시 ${currentMinute}분 - 보유주식 매도 체크를 시작합니다.", true ) println("⏰ [강제 스케줄 실행] 오전 9시 ${currentMinute}분 - 보유주식 매도 체크를 시작합니다.") checkBalance() lastForceCheckMinute = currentMinute // 실행 완료 기록 } } else if (((now.hour == 8 && KisSession.tradeConfig.before_nxt) || (now.hour >= 16 && now.hour < 20 && KisSession.tradeConfig.after_nxt)) && (currentMinute % 2 == 1)) { if (lastForceCheckMinute != currentMinute) { TradingLogStore.addAnalyzer( " - ", " - ", "⏰ [강제 스케줄 실행] 오후 ${now.hour}시 ${currentMinute}분 - 보유주식 시간외 단일가 또는 대체마켓 체크를 시작합니다.", true ) var list = mutableListOf("X") if (now.hour != 8 && now.hour < 18) { list.add("Y") } list.forEach { code -> KisTradeService.fetchIntegratedBalance(code).getOrNull()?.let { sellingAfterMarketOnePrice(KisTradeService, it, code) } } lastForceCheckMinute = currentMinute // 실행 완료 기록 } } } } 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.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(tradeService: KisTradeService) { val activeTrades = DatabaseFactory.findAllMonitoringTrades() val balanceResult = tradeService.fetchIntegratedBalance().getOrNull() val realHoldings = balanceResult?.getHoldings()?.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() } } } 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, ) }