diff --git a/src/main/kotlin/service/AutoTradingManager.kt b/src/main/kotlin/service/AutoTradingManager.kt index 1526644..425abec 100644 --- a/src/main/kotlin/service/AutoTradingManager.kt +++ b/src/main/kotlin/service/AutoTradingManager.kt @@ -54,7 +54,8 @@ import kotlin.math.abs import kotlin.math.max // service/AutoTradingManager.kt -typealias TradingDecisionCallback = (TradingDecision?, Boolean)->Unit +typealias TradingDecisionCallback = (TradingDecision?, Boolean) -> Unit + object AutoTradingManager { @@ -71,15 +72,16 @@ object AutoTradingManager { 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 llmAnalyser by mutableStateOf(false) + var llmNews by mutableStateOf(false) + var tradeToken by mutableStateOf(false) + var webSocketConnect by mutableStateOf(false) var testFlag = false @@ -87,12 +89,18 @@ object AutoTradingManager { scope.launch { while (isActive) { val now = LocalTime.now(ZoneId.of("Asia/Seoul")) - var checkTime = 60_000 * 3L + 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)))) { + } else if ((now.isAfter(LocalTime.of(23, 50)) && now.isBefore( + LocalTime.of( + 8, + 0 + ) + )) + ) { // SystemSleepPreventer.sleepDisplay() } // if (!isTradingDay) { @@ -119,31 +127,54 @@ object AutoTradingManager { decision.analyzer?.let { a -> val volatility = a?.calculateVolatilityForecast(a.daily, 20) volatility?.let { - maxRealisticProfitRate = ((volatility.realisticHigh - decision.currentPrice) / decision.currentPrice) * 100.0 + 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) + 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 // 변동성이 충분히 크다면 원래 시스템 설정대로 진행 - } + 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 + 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 + 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,"물타기 시도 1주 매수") + TradingLogStore.addNotice( + decision.stockName, + decision.stockCode, + "물타기 시도 ${calculatedQty}주 매수" + ) } - val calculatedQty = if(hasCodes == true) KisSession.tradeConfig.lowerAverageStockCount else (maxBudget / decision.currentPrice).toInt().coerceAtLeast(1) excuteTrade( decision = decision, orderQty = calculatedQty.toString(), @@ -183,11 +214,13 @@ object AutoTradingManager { 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 @@ -202,6 +235,7 @@ object AutoTradingManager { 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 } @@ -210,8 +244,10 @@ object AutoTradingManager { 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 } } @@ -228,11 +264,19 @@ object AutoTradingManager { } } - fun excuteTrade(decision: TradingDecision, orderQty: String, profitRate1: Double?, investmentGrade: InvestmentGrade = InvestmentGrade.LEVEL_2_HIGH_RISK, hasCode: Boolean) { + 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()) + val oneTickLowerPrice = + basePrice - (tickSize * KisSession.config.getValues(investmentGrade.buyGuide) + .toInt()) var stockCode = decision.stockCode var stockName = decision.stockName val finalPrice = MarketUtil.roundToTickSize(oneTickLowerPrice.toDouble()) @@ -240,12 +284,27 @@ object AutoTradingManager { 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)}" ) + 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) + KisTradeService.postOrder( + stockCode, + realOrderQty, + finalPrice.toLong().toString(), + isBuy = true + ) .onSuccess { realOrderNo -> println("[${investmentGrade.displayName}] 주문 성공: $realOrderNo $stockCode $orderQty $finalPrice") TradingLogStore.addLog( @@ -257,21 +316,27 @@ object AutoTradingManager { val sRate = -1.5 var tax = KisSession.config.getValues(ConfigIndex.TAX_INDEX) val effectiveProfitRate = - (profitRate1 ?: KisSession.config.getValues(ConfigIndex.PROFIT_INDEX)) + tax + (profitRate1 + ?: KisSession.config.getValues(ConfigIndex.PROFIT_INDEX)) + tax try { - var oldTarget = currentBalance?.getHoldings()?.first { it.availOrderCount.toInt() > 0 && it.code.equals(decision.stockCode) } + 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())) + basePrice = + avgPrive * 1.5//((avgPrive * qty) + (decision.currentPrice * orderQty.toInt())).div(qty!!.toInt() + (orderQty.toInt())) println("물타기 ${avgPrive}, ${qty} ${basePrice}") } - } catch (e:Exception) {e.printStackTrace()} + } catch (e: Exception) { + e.printStackTrace() + } val calculatedTarget = MarketUtil.roundToTickSize(basePrice * (1 + effectiveProfitRate / 100.0)) - val calculatedStop = MarketUtil.roundToTickSize(basePrice * (1 + sRate / 100.0)) + val calculatedStop = + MarketUtil.roundToTickSize(basePrice * (1 + sRate / 100.0)) val inputQty = orderQty.replace(",", "").toIntOrNull() ?: 0 if (!hasCode) { DatabaseFactory.saveAutoTrade( @@ -330,11 +395,27 @@ object AutoTradingManager { 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 { val unfilledResult = KisTradeService.fetchUnfilledOrders() unfilledResult.onSuccess { response -> response.filter { it.sll_buy_dvsn_cd == "02" }.forEach { order -> - TradingLogStore.addNotice(order.prdt_name,order.pdno,"[주문 취소] 매수시간 종료 후 모든 매수 취소") + TradingLogStore.addNotice( + order.prdt_name, + order.pdno, + "[주문 취소] 매수시간 종료 후 모든 매수 취소" + ) KisTradeService.cancelOrder( order.ord_no, // 원주문번호 order.pdno @@ -342,17 +423,20 @@ object AutoTradingManager { } } } + } } - 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) + + 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 = mutableMapOf() + val executionCache = mutableMapOf() val processingIds = mutableSetOf() // 주문번호 기준 잠금 suspend fun syncAndExecute(orderNo: String) { if (processingIds.contains(orderNo)) return @@ -371,13 +455,16 @@ object AutoTradingManager { // 💡 [수정] 매수 주문(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 + 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) + 0.05 val finalProfitRate = maxOf(dbItem.profitRate, absoluteMinRate) - val finalTargetPrice = MarketUtil.roundToTickSize(actualBuyPrice * (1 + finalProfitRate / 100.0)) + val finalTargetPrice = + MarketUtil.roundToTickSize(actualBuyPrice * (1 + finalProfitRate / 100.0)) println("🎯 [매수 확정] ${dbItem.name} | 매수가: ${actualBuyPrice.toInt()} -> 목표가 설정: ${finalTargetPrice.toInt()}") @@ -394,11 +481,20 @@ object AutoTradingManager { stockName = dbItem.name, isBuy = false, orderQty = dbItem.quantity, - reason = "🎯 목표 수익률 ${String.format("%.2f", finalProfitRate)}% 도달을 위한 익절 주문", + reason = "🎯 목표 수익률 ${ + String.format( + "%.2f", + finalProfitRate + ) + }% 도달을 위한 익절 주문", holdingAvgPrice = actualBuyPrice, // 👈 여기서 매수단가를 넘겨줘야 매도 리포트가 정확해집니다! decision = null ) - DatabaseFactory.updateStatusAndOrderNo(dbItem.id!!, TradeStatus.SELLING, newSellOrderNo) + DatabaseFactory.updateStatusAndOrderNo( + dbItem.id!!, + TradeStatus.SELLING, + newSellOrderNo + ) executionCache.remove(orderNo) } } else if (dbItem.status == TradeStatus.SELLING) { @@ -409,7 +505,12 @@ object AutoTradingManager { TradingReportManager.updateExecution(orderNo, actualSellPrice, actualSellQty) println("🎊 [매칭 성공] 매도 완료: ${dbItem.name} | 매도가: ${actualSellPrice.toInt()}") - TradingLogStore.addSellLog(dbItem.name,actualSellPrice.toString(),"SELL","매도 완료") + TradingLogStore.addSellLog( + dbItem.name, + actualSellPrice.toString(), + "SELL", + "매도 완료" + ) TradingReportManager.closePositionCycle(dbItem.code) // 사이클 종료 알림 @@ -425,7 +526,7 @@ object AutoTradingManager { /** * 자동 발굴 루프 시작 및 Watchdog 실행 */ - fun startAutoDiscoveryLoop(doStart : Boolean = false) { + fun startAutoDiscoveryLoop(doStart: Boolean = false) { if (isRunning()) return // 1. 기존 Watchdog이 있다면 제거 후 새로 시작 @@ -433,7 +534,7 @@ object AutoTradingManager { watchdogJob = scope.launch { val activeTrades = DatabaseFactory.findAllMonitoringTrades() var now = LocalTime.now(ZoneId.of("Asia/Seoul")) - if (doStart && activeTrades.isNotEmpty() && !KisSession.isAvailBuyTime(now)) { + if (doStart && activeTrades.isNotEmpty() && !KisSession.isMarketOpenTime(now)) { executeClosingLiquidation(activeTrades) } while (isActive) { @@ -450,9 +551,13 @@ object AutoTradingManager { runDiscoveryLoop(globalCallback) } - suspend fun sellingAfterMarketOnePrice(tradeService: KisTradeService,balance : UnifiedBalance,marketCode : String = "Y") { + suspend fun sellingAfterMarketOnePrice( + tradeService: KisTradeService, + balance: UnifiedBalance, + marketCode: String = "Y" + ) { balance.getHoldings().forEach { holding -> - if (BLACKLISTEDSTOCKCODES.contains(holding.code)){ + if (BLACKLISTEDSTOCKCODES.contains(holding.code)) { println("❌ 차단 처리된 주식 : ${holding.name}") TradingLogStore.addAnalyzer( holding.name, @@ -462,13 +567,16 @@ object AutoTradingManager { } 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 - } + 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( @@ -485,32 +593,34 @@ object AutoTradingManager { 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") + println("✅ [${if (marketCode.equals("Y")) "시간외 단일가" else "대체거래소"} 주문 완료] ${holding.name}: $newOrderNo") TradingLogStore.addSellLog( "${holding.name}[${holding.code}]", targetPrice.toString(), "SELL", - "🎊 ${if(marketCode.equals("Y"))"시간외 단일가" else "대체거래소"} 주식 재고털이 주문 완료" + "🎊 ${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 + ) ) - 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}] " + "🎊 ${if (marketCode.equals("Y")) "시간외 단일가" else "대체거래소"} 주식 재고털이 주문 실패[${it.message}] " ) } } else { @@ -518,10 +628,27 @@ object AutoTradingManager { 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)}") + && 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}]", @@ -538,7 +665,7 @@ object AutoTradingManager { } - suspend fun resumePendingSellOrders(tradeService: KisTradeService,balance : UnifiedBalance) { + suspend fun resumePendingSellOrders(tradeService: KisTradeService, balance: UnifiedBalance) { val now = LocalTime.now() val currentMinute = now.minute if (now.isBefore(H15M30) && now.isAfter(H08M45)) { @@ -546,7 +673,7 @@ object AutoTradingManager { println("resumePendingSellOrders") balance.getHoldings().forEach { holding -> - if (BLACKLISTEDSTOCKCODES.contains(holding.code)){ + if (BLACKLISTEDSTOCKCODES.contains(holding.code)) { println("❌ 차단 처리된 주식 : ${holding.name}") TradingLogStore.addAnalyzer( holding.name, @@ -563,7 +690,9 @@ object AutoTradingManager { targetPrice = targetPrice isBefore930 = true } else { - targetPrice = MarketUtil.roundToTickSize(targetPrice + MarketUtil.getTickSize(targetPrice)) + targetPrice = MarketUtil.roundToTickSize( + targetPrice + MarketUtil.getTickSize(targetPrice) + ) } println("🔄 [보유 주식 주문] ${holding.name} (${holding.code}) 매도 목표 ${targetPrice} 미체결 매도 건 재주문 시도") tradeService.postOrder( @@ -579,18 +708,20 @@ object AutoTradingManager { "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 - )) + 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( @@ -608,9 +739,12 @@ object AutoTradingManager { && 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) + && 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, @@ -619,7 +753,7 @@ object AutoTradingManager { ).onSuccess { newOrderNo -> println("✅ [보유 주식 손절 처리] ${holding.name} 매수가 기준 (${holding.avgPrice.toDouble()} 3호가 위[${targetPrice}] 매도 주문") isSuccess = true - }.onFailure { err-> + }.onFailure { err -> println("✅ [보유 주식 손절 처리] ${holding.name} 실패 ${targetPrice} ${err.message}") errMsg = err.message.toString() } @@ -632,13 +766,32 @@ object AutoTradingManager { } 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)}") + && 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) + targetPrice = MarketUtil.roundToTickSize( + targetPrice + MarketUtil.getTickSize(targetPrice) * KisSession.tradeConfig.autoSellOrderAppend + ) tradeService.postOrder( @@ -648,7 +801,7 @@ object AutoTradingManager { isBuy = false, ).onSuccess { newOrderNo -> println("✅ [보유 주식 손절 처리] 수익률($profit%) -> ${holding.valuationProfitAmount} 손해 중이며 현제 손절 가이드에 적합함 시장가 매도.") - }.onFailure { err-> + }.onFailure { err -> println("✅ [보유 주식 손절 처리] 실패 ${err.message}") } @@ -658,7 +811,7 @@ object AutoTradingManager { "수익률($profit%) -> ${holding.valuationProfitAmount} 손해 중이며 현제 손절 가이드에 적합함 시장가 매도." ) } - analyzeDeepLossHoldingsAfterMarket(holding , true) + analyzeDeepLossHoldingsAfterMarket(holding, true) } delay(200) // API 호출 부하 방지 } @@ -666,7 +819,10 @@ object AutoTradingManager { } } - private suspend fun analyzeDeepLossHoldingsAfterMarket(holding: UnifiedStockHolding, isForce : Boolean = false) { // 💡 [신규 추가] 수익률이 크게 마이너스인 종목(-5.0% 이하) 심층 가이드 분석 + private suspend fun analyzeDeepLossHoldingsAfterMarket( + holding: UnifiedStockHolding, + isForce: Boolean = false + ) { // 💡 [신규 추가] 수익률이 크게 마이너스인 종목(-5.0% 이하) 심층 가이드 분석 val now = LocalTime.now() val currentMinute = now.minute if ((holding.availOrderCount.toInt() @@ -677,7 +833,8 @@ object AutoTradingManager { if (profit <= lossThreshold) { println("🔍 [손실 종목 분석] ${holding.name} (수익률: $profit%) - 가이드 산출 중...") - val dailyData = KisTradeService.fetchPeriodChartData(holding.code, "D", true).getOrNull() + val dailyData = + KisTradeService.fetchPeriodChartData(holding.code, "D", true).getOrNull() if (!dailyData.isNullOrEmpty()) { val analyzer = TechnicalAnalyzer().apply { this.daily = dailyData } @@ -694,7 +851,8 @@ object AutoTradingManager { // 🟢 [추매 타점] 볼린저 하단 터치(1.05배 이내) + RSI 과매도(35 이하) 구간 if (lowerBand > 0 && currentPrice <= lowerBand * 1.05 && rsiDaily < 35.0) { - advice = "📉 [추매 권장] 볼린저 밴드 하단 터치 및 RSI 과매도(${"%.1f".format(rsiDaily)}). 기술적 반등 확률이 매우 높은 통계적 바닥권입니다. (물타기 고려)" + advice = + "📉 [추매 권장] 볼린저 밴드 하단 터치 및 RSI 과매도(${"%.1f".format(rsiDaily)}). 기술적 반등 확률이 매우 높은 통계적 바닥권입니다. (물타기 고려)" TradingLogStore.addNotice( "보유주식[${holding.name}]", holding.code, @@ -703,7 +861,8 @@ object AutoTradingManager { } // 🔴 [손절 타점] 추세가 완전히 깨졌는데, 바닥(볼린저 하단)까지 한참 남았을 때 else if (isTrendBroken && currentPrice > lowerBand * 1.1) { - advice = "🚨 [손절 경고] 20일 추세가 완전히 무너졌으며, 아직 바닥(하단 밴드)도 확인되지 않았습니다. 추가 하락(지하실) 위험이 크므로 리스크 관리(손절)가 필요합니다." + advice = + "🚨 [손절 경고] 20일 추세가 완전히 무너졌으며, 아직 바닥(하단 밴드)도 확인되지 않았습니다. 추가 하락(지하실) 위험이 크므로 리스크 관리(손절)가 필요합니다." TradingLogStore.addNotice( "보유주식[${holding.name}]", holding.code, @@ -760,10 +919,14 @@ object AutoTradingManager { val config = KisSession.config // LLM 서버 시작 (설정된 모델 경로 사용) if (config.modelPath.isNotEmpty()) { - LlamaServerManager.startServer(binPath, config.modelPath,port = LLM_PORT) + LlamaServerManager.startServer(binPath, config.modelPath, port = LLM_PORT) } if (config.embedModelPath.isNotEmpty()) { - LlamaServerManager.startServer(binPath, config.embedModelPath, port = EMBEDDING_PORT) + LlamaServerManager.startServer( + binPath, + config.embedModelPath, + port = EMBEDDING_PORT + ) } KisWebSocketManager.connect() isSystemReadyToday = true @@ -772,7 +935,8 @@ object AutoTradingManager { println("❌ [System] 토큰 갱신 실패. 2분 후 재시도합니다.") } } - } catch (e: Exception) {} + } catch (e: Exception) { + } } var onMarketClosed: (() -> Unit)? = null @@ -795,9 +959,16 @@ object AutoTradingManager { 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 (now.isAfter(LocalTime.of(8, 0)) && now.isBefore( + LocalTime.of( + 15, + 30 + ) + ) + ) { if (!KisSession.isMarketTokenValid() || !KisSession.isTradeTokenValid()) { if (isSystemReadyToday) { println("⚠️ [System] 토큰 만료 감지. 재발급 프로세스를 가동합니다.") @@ -817,7 +988,8 @@ object AutoTradingManager { } } } - else ->{ + + else -> { waitTime = 3.0 } } @@ -832,7 +1004,7 @@ object AutoTradingManager { } } - suspend fun prepareMarketOpen(now : LocalTime) { + suspend fun prepareMarketOpen(now: LocalTime) { if (now.isAfter(KisSession.endTime()) || now.isBefore(KisSession.startTime())) { println("🌙 [System] 마감 시간 도달. 자원 정리 후 대기 모드(설정 화면)로 전환합니다.") onMarketClosed?.invoke() @@ -844,7 +1016,10 @@ object AutoTradingManager { isSystemReadyToday = false shouldShowFullWindow = false stopDiscovery() // 발굴 루프 완전 폭파 (내일 8시 30분에 다시 켜짐) - } else if (now.isAfter(KisSession.startTime().minusMinutes(20)) && now.isBefore(KisSession.startTime()) && !shouldShowFullWindow) { + } else if (now.isAfter( + KisSession.startTime().minusMinutes(20) + ) && now.isBefore(KisSession.startTime()) && !shouldShowFullWindow + ) { if (MarketUtil.canTradeToday()) { shouldShowFullWindow = true println("✅ [System] 오늘은 영업일입니다. 시스템을 가동합니다.") @@ -855,15 +1030,16 @@ object AutoTradingManager { } } } + var loadedTops = mutableListOf>() var defaultStockCount = 30 - var currentBalance : UnifiedBalance? = null + var currentBalance: UnifiedBalance? = null private var lastFetchTime: Long = 0L // 마지막 성공 시간 (Millisecond) - private val FETCH_INTERVAL = 2 * 60 * 1000 // 30분을 밀리초로 환산 (1800000 ms) + private val FETCH_INTERVAL = 1 * 60 * 1000 // 30분을 밀리초로 환산 (1800000 ms) - suspend fun checkBalance() { + suspend fun checkBalance() { val currentTime = System.currentTimeMillis() if (currentBalance == null || (currentTime - lastFetchTime) > FETCH_INTERVAL) { KisTradeService.fetchIntegratedBalance().getOrNull()?.let { @@ -878,8 +1054,15 @@ object AutoTradingManager { // 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() } + if (KisSession.config.take_profit) currentBalance?.let { + resumePendingSellOrders( + KisTradeService, + it + ) + } + if (KisSession.tradeConfig.auto_cancel_pending_buy) { + checkAndCancelPendingBuyOrders() + } } @@ -895,15 +1078,25 @@ object AutoTradingManager { val orderTimeMillis = parseOrderTime(order.ord_tmd) val elapsedMillis = currentTime - orderTimeMillis - if (elapsedMillis >= KisSession.tradeConfig.auto_cancel_pending_time ) { + if (elapsedMillis >= KisSession.tradeConfig.auto_cancel_pending_time) { // 2. 현재가 조회 (가격을 비교하기 위해) - val currentPrice = KisTradeService.fetchCurrentPrice(order.pdno).getOrNull()?.stck_prpr?.toDouble() ?: 0.0 + 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)}%)로 취소 시도") + TradingLogStore.addNotice( + order.prdt_name, + order.pdno, + "[주문 취소] ${order.prdt_name} (${order.pdno}) - 시간경과 및 가격괴리(${ + String.format( + "%.2f", + priceGap + ) + }%)로 취소 시도" + ) KisTradeService.cancelOrder( order.ord_no, // 원주문번호 order.pdno @@ -920,7 +1113,11 @@ object AutoTradingManager { val unfilledResult = KisTradeService.fetchUnfilledOrders() unfilledResult.onSuccess { response -> response.filter { it.sll_buy_dvsn_cd == "01" }.forEach { order -> - TradingLogStore.addNotice(order.prdt_name,order.pdno,"[주문 취소] 정규장 시작전 모든 매도 주문 취소") + TradingLogStore.addNotice( + order.prdt_name, + order.pdno, + "[주문 취소] 정규장 시작전 모든 매도 주문 취소" + ) KisTradeService.cancelOrder( order.ord_no, // 원주문번호 order.pdno @@ -946,9 +1143,12 @@ object AutoTradingManager { 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() + 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()) { @@ -958,9 +1158,14 @@ object AutoTradingManager { } loadedTops.shuffle() val count = minOf(loadedTops.size, defaultStockCount) - for (i in 0 ..< count) { + for (i in 0.. 0 && rate < KisSession.tradeConfig.plusFilter) || (rate < 0 && rate > (abs(KisSession.tradeConfig.minusFilter) * -1)) + val isOk = + (rate > 0 && rate < KisSession.tradeConfig.plusFilter) || (rate < 0 && rate > (abs( + KisSession.tradeConfig.minusFilter + ) * -1)) if (corpInfo?.cName.isNullOrEmpty()) { false @@ -976,7 +1184,8 @@ object AutoTradingManager { it.code !in pendingStocks && it.code !in executionCache.values.map { it.code } && it.code !in failList && - it.code !in isSafetyBeltStockCodes){ + it.code !in isSafetyBeltStockCodes + ) { isOk } else { false @@ -991,27 +1200,37 @@ object AutoTradingManager { reanalysisList.clear() if (KisSession.tradeConfig.lowerAveragePrice) { currentBalance?.getHoldings()?.map { - if( + 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)) - { - candidates.add(RankingStock(mksc_shrn_iscd = it.code, hts_kor_isnm = it.name)) + it.profitRate.toDouble() > (abs(KisSession.tradeConfig.lowerAverageMinRate) * -1) + ) { + candidates.add( + RankingStock( + mksc_shrn_iscd = it.code, + hts_kor_isnm = it.name + ) + ) println("물타기 대상 추가 ${it.name}[${it.code}]") var oldTarget = it - if ( oldTarget != null) { + if (oldTarget != null) { var avgPrive = oldTarget.avgPrice.toDouble() var qty = oldTarget.quantity.toDouble() - var basePrice = ((avgPrive * qty) + it.currentPrice.toDouble()).div(qty!!.toInt() + 1) + var basePrice = + ((avgPrive * qty) + it.currentPrice.toDouble()).div(qty!!.toInt() + 1) println("물타기 ${avgPrive}, ${qty} ${basePrice}") } } } } remainingCandidates.addAll(candidates.filter { - (if (KisSession.tradeConfig.lowerAveragePrice) { true } else {it.code !in myHoldings}) && + (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 && @@ -1047,6 +1266,7 @@ object AutoTradingManager { } println("⏱️ [Cycle End] ${LocalTime.now()}") } + // private var lastForceCheckMinute = -1 // 마지막으로 강제 체크를 수행한 '분'을 저장 private val executionCountMap = mutableMapOf() suspend fun sellSchedule() { @@ -1054,18 +1274,20 @@ object AutoTradingManager { 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 } + 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))) { + 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())) ) { + } 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,"[주문 취소] 정규장 후 모든 매수 취소") + TradingLogStore.addNotice(order.prdt_name, order.pdno, "[주문 취소] 정규장 후 모든 매수 취소") KisTradeService.cancelOrder( order.ord_no, // 원주문번호 order.pdno @@ -1086,8 +1308,14 @@ object AutoTradingManager { } 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)) { + (now.isAfter(LocalTime.of(15, 40)) && now.isBefore( + LocalTime.of( + 20, + 0 + ) + ) && KisSession.tradeConfig.after_nxt) + ) && (currentMinute % 2 == 0) + ) { TradingLogStore.addAnalyzer( " - ", " - ", @@ -1105,7 +1333,9 @@ object AutoTradingManager { } isExecuted = true } - if (isExecuted) { executionCountMap[timeKey] = currentCount + 1 } + if (isExecuted) { + executionCountMap[timeKey] = currentCount + 1 + } if (now.hour >= 20) { executionCountMap.clear() noticeFilter.clear() @@ -1121,8 +1351,14 @@ object AutoTradingManager { // println("📝 [Memory] ${stock.name} 관망 판정 -> 차기 루프 재분석 리스트 등록") } } + val failList = arrayListOf() - private suspend fun processSingleStock(stock: RankingStock, myCash: Long, tradeService: KisTradeService, callback: TradingDecisionCallback) { + 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) @@ -1134,7 +1370,9 @@ object AutoTradingManager { print("-> 기업명을 못찾아서 제외 | ") return@withTimeout } - if(currentBalance?.getHoldings()?.any { it.code.equals(stock.code) && it.quantity.toInt() > 2} == true) { + if (currentBalance?.getHoldings() + ?.any { it.code.equals(stock.code) && it.quantity.toInt() > 2 } == true + ) { println("물타기 대상 분석") } callback(TradingDecision().apply { @@ -1144,10 +1382,12 @@ object AutoTradingManager { }, false) val dailyData = - tradeService.fetchPeriodChartData(stock.code, "D", true).getOrNull() ?: return@withTimeout + tradeService.fetchPeriodChartData(stock.code, "D", true).getOrNull() + ?: return@withTimeout val today = dailyData.lastOrNull() ?: null var rate = today?.getFluctuationRate() ?: 0.0 - val isOk = ((rate < KisSession.tradeConfig.plusFilter) && (rate > (abs(KisSession.tradeConfig.minusFilter) * -1))) + val isOk = + ((rate < KisSession.tradeConfig.plusFilter) && (rate > (abs(KisSession.tradeConfig.minusFilter) * -1))) delay(50) // 1. var 대신 val을 사용해야 아래에서 스마트 캐스트가 작동하여 !!를 안 써도 됩니다. val currentStock = KisTradeService.fetchCurrentPrice(stock.code).getOrNull() @@ -1176,14 +1416,14 @@ object AutoTradingManager { } - // 🌟 [추가] 고도화된 사전 필터링 (검문소) val tempAnalyzer = TechnicalAnalyzer().apply { this.daily = dailyData } // 1. 변동성 기반 수익률 검증 (2% 이상 열려있는가?) println("(dailyData.size * 0.8).toInt() ${(dailyData.size * 0.3).toInt()}") val volatility = tempAnalyzer.calculateVolatilityForecast(dailyData, 20) - val expectedProfitRate = ((volatility.realisticHigh - currentPrice) / currentPrice) * 100.0 + val expectedProfitRate = + ((volatility.realisticHigh - currentPrice) / currentPrice) * 100.0 // 2. 일봉 기준 반등 주기 통계 추출 (일주일 내 승부 가능한가?) val dailyStats = tempAnalyzer.calculateDynamicReboundStats(dailyData, 5.0) @@ -1198,17 +1438,26 @@ object AutoTradingManager { // 🌟 [수정] 조건 통합 (OR 조건) - val isProfitable = expectedProfitRate >= KisSession.tradeConfig.minExpectedProfitRate || dailyStats.avgReboundAmplitude >= KisSession.tradeConfig.minExpectedProfitRate + val isProfitable = + expectedProfitRate >= KisSession.tradeConfig.minExpectedProfitRate || dailyStats.avgReboundAmplitude >= KisSession.tradeConfig.minExpectedProfitRate // 반등 주기에 도달했거나(Mean Reversion), 안정적으로 뻗어나가는 우상향 종목(Trend Following)이면 통과 - val isValidEntryTiming = (dailyStats.isValid && isApproaching && dailyStats.avgReboundPeriod <= KisSession.tradeConfig.maxExpectedReboundDays && dailyStats.avgReboundPeriod >= KisSession.tradeConfig.minExpectedReboundDays) || isSteadyUptrend + val isValidEntryTiming = + (dailyStats.isValid && isApproaching && dailyStats.avgReboundPeriod <= KisSession.tradeConfig.maxExpectedReboundDays && dailyStats.avgReboundPeriod >= KisSession.tradeConfig.minExpectedReboundDays) || isSteadyUptrend val currentAtr = tempAnalyzer.calculateATR(dailyData) if (!isProfitable || !isValidEntryTiming) { - print("-> [${stock.name}] 조건 미달 필터링 (예측수익: ${"%.1f".format(expectedProfitRate)}%, 주기: ${"%.1f".format(dailyStats.avgReboundPeriod)}일, 진입권: $isValidEntryTiming) | ") + print( + "-> [${stock.name}] 조건 미달 필터링 (예측수익: ${"%.1f".format(expectedProfitRate)}%, 주기: ${ + "%.1f".format( + dailyStats.avgReboundPeriod + ) + }일, 진입권: $isValidEntryTiming) | " + ) return@withTimeout // 조건에 맞지 않으면 주봉/월봉 API 호출 및 LLM 분석 없이 즉시 다음 종목으로 넘어감 } - val dropPrediction = tempAnalyzer.predictDropBottom(dailyData, dailyStats, volatility, currentAtr) + val dropPrediction = + tempAnalyzer.predictDropBottom(dailyData, dailyStats, volatility, currentAtr) if (dropPrediction != null) { // 💡 [방어 로직 1] 아직 바닥까지 한참 남았다면 지하실 방지 @@ -1229,7 +1478,7 @@ object AutoTradingManager { if (KisSession.tradeConfig.isUpcomingDividend) { var dividend = KisTradeService.fetchUpcomingDividend(stock.code).getOrNull() - if(dividend?.hasDividend == true){ + if (dividend?.hasDividend == true) { println("[${stock.name}] 배당락일 ${dividend.exDividendDate} : ${dividend.dividendAmount}") } else { println("[${stock.name}] 배당 정보 없어서 분석 종료") @@ -1239,16 +1488,30 @@ object AutoTradingManager { println("[${stock.name}] 배당 정보 무관 함.") } - println("🔍 [분석 진입] ${stock.name} (${LocalTime.now()}) (예측수익: ${"%.1f".format(expectedProfitRate)}%, 주기: ${"%.1f".format(dailyStats.avgReboundPeriod)}일, 진입권: $isValidEntryTiming)") + println( + "🔍 [분석 진입] ${stock.name} (${LocalTime.now()}) (예측수익: ${ + "%.1f".format( + expectedProfitRate + ) + }%, 주기: ${"%.1f".format(dailyStats.avgReboundPeriod)}일, 진입권: $isValidEntryTiming)" + ) if (!isSafetyBeltStockCodes.contains(stock.code)) { val analyzer = coroutineScope { - val min30 = async { tradeService.fetchChartData(stock.code, true).getOrDefault(emptyList()) } + val min30 = async { + tradeService.fetchChartData(stock.code, true).getOrDefault(emptyList()) + } delay(20) val weekly = - async { tradeService.fetchPeriodChartData(stock.code, "W", true).getOrDefault(emptyList()) } + async { + tradeService.fetchPeriodChartData(stock.code, "W", true) + .getOrDefault(emptyList()) + } delay(20) val monthly = - async { tradeService.fetchPeriodChartData(stock.code, "M", true).getOrDefault(emptyList()) } + async { + tradeService.fetchPeriodChartData(stock.code, "M", true) + .getOrDefault(emptyList()) + } delay(20) TechnicalAnalyzer().apply { this.daily = dailyData @@ -1263,8 +1526,16 @@ object AutoTradingManager { if (analyzer.isValid()) { println("✅ [분석 시작] ${stock.name} (${LocalTime.now()} 분석 데이터 정합성 -> ${analyzer.isValid()})") - RagService.processStock(currentPrice, analyzer, stock.name, stock.code) { decision, isSuccess -> - callback(decision?.apply { this.currentPrice = currentPrice }, isSuccess) + RagService.processStock( + currentPrice, + analyzer, + stock.name, + stock.code + ) { decision, isSuccess -> + callback( + decision?.apply { this.currentPrice = currentPrice }, + isSuccess + ) } } else { println("✅ [분석 실패] ${stock.name} (${LocalTime.now()} 분석 데이터 정합성 -> ${analyzer.isValid()})") @@ -1280,22 +1551,59 @@ object AutoTradingManager { } } - 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 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() @@ -1313,7 +1621,7 @@ object AutoTradingManager { while (System.currentTimeMillis() < endWait && isRunning()) { lastTickTime.set(System.currentTimeMillis()) // 대기 중에도 Watchdog에 생존 신고 println("💤 대기 모드 상태 확인...$minutes") - delay(if(minutes > 3.0 ) 10000 else 1000) + delay(if (minutes > 3.0) 10000 else 1000) } } @@ -1343,7 +1651,7 @@ object AutoTradingManager { while (System.currentTimeMillis() < endWait) { lastTickTime.set(System.currentTimeMillis()) // 대기 중에도 Watchdog에 생존 신고 println("💤 대기 모드 상태 확인...") - delay(if(5.0 > 3.0 ) 10000 else 1000) + delay(if (5.0 > 3.0) 10000 else 1000) } KisWebSocketManager.disconnect() BrowserManager.closeIfIdle(0)