From bcdb3546fa3b8b7cfc651fd1bc660a511a0173d1 Mon Sep 17 00:00:00 2001 From: lunaticbum Date: Wed, 16 Sep 2026 17:35:06 +0900 Subject: [PATCH] =?UTF-8?q?=EB=B6=84=EC=84=9D=ED=9B=84=20=EB=B6=84?= =?UTF-8?q?=ED=95=A0=20=EB=A7=A4=EC=88=98,=20=EB=B3=B4=EC=9C=A0=EC=A3=BC?= =?UTF-8?q?=EC=8B=9D=20=EB=B6=84=ED=95=A0=20=EB=A7=A4=EB=8F=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/kotlin/model/AppConfig.kt | 2 +- src/main/kotlin/service/AutoTradingManager.kt | 469 +++++++++++------- 2 files changed, 292 insertions(+), 179 deletions(-) diff --git a/src/main/kotlin/model/AppConfig.kt b/src/main/kotlin/model/AppConfig.kt index 5ba608d..b5b49e6 100644 --- a/src/main/kotlin/model/AppConfig.kt +++ b/src/main/kotlin/model/AppConfig.kt @@ -247,7 +247,7 @@ class TradeConfig { var ONE_STOCK_ALYSIS_TIME = 180000L var isLowPerformanceMonitoring: Boolean = false var useGradeShare : List = listOf("LEVEL_4","LEVEL_5") - var useTagsShare : List = listOf("NOTICE", "WATCH") + var useTagsShare : List = listOf("NOTICE", "WATCH","매도 완료") var useLogKeywordsShare : List = listOf("재분석") var useAutoRepost : Boolean = false var minusFilter : Double = 15.0 diff --git a/src/main/kotlin/service/AutoTradingManager.kt b/src/main/kotlin/service/AutoTradingManager.kt index d45541f..37628da 100644 --- a/src/main/kotlin/service/AutoTradingManager.kt +++ b/src/main/kotlin/service/AutoTradingManager.kt @@ -274,14 +274,15 @@ object AutoTradingManager { hasCode: Boolean ) { scope.launch { - var basePrice = decision.currentPrice - val tickSize = MarketUtil.getTickSize(basePrice) - val oneTickLowerPrice = - basePrice - (tickSize * KisSession.config.getValues(investmentGrade.buyGuide) - .toInt()) + val initialBasePrice = decision.currentPrice + val tickSize = MarketUtil.getTickSize(initialBasePrice) + + // 시스템 가이드에 따른 1차 기준 단가 계산 + val oneTickLowerPrice = initialBasePrice - (tickSize * KisSession.config.getValues(investmentGrade.buyGuide).toInt()) + val finalPrice = MarketUtil.roundToTickSize(oneTickLowerPrice) + 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)) { @@ -293,60 +294,68 @@ object AutoTradingManager { "매수 실패 : 최대 보유 종목 도달로 신규 매수 일시 중단 => 재분석 대기열에 추가" ) } 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") + + val totalQty = orderQty.replace(",", "").toIntOrNull() ?: 0 + if (totalQty <= 0) return@launch + + // 🌟 [핵심] 매수 2분할 로직 (1주는 1차에 전량, 그 이상은 절반씩) + val qty1 = (totalQty + 1) / 2 + val qty2 = totalQty - qty1 + + val price1 = finalPrice + // 2차 매수는 1차 매수가보다 3호가 아래에 대기 (더 싼 가격에 줍기) + val price2 = MarketUtil.roundToTickSize(price1 - (tickSize * 3)) + + println("🔄 [분할 매수 진입] $stockName 1차(${qty1}주 / $price1) | 2차(${qty2}주 / $price2)") + + // 분할 주문 리스트 생성 + val orderList = mutableListOf>() + if (qty1 > 0) orderList.add(qty1 to price1) + if (qty2 > 0) orderList.add(qty2 to price2) + + // 순차적으로 주문 전송 + orderList.forEachIndexed { index, (qty, price) -> + val orderStep = index + 1 // 1차, 2차 표시용 + + KisTradeService.postOrder( + stockCode, + qty.toString(), + price.toLong().toString(), + isBuy = true + ).onSuccess { realOrderNo -> + println("[${investmentGrade.displayName}] ${orderStep}차 주문 성공: $realOrderNo $stockCode ${qty}주 $price") TradingLogStore.addLog( decision, "BUY", - "[${investmentGrade.displayName}] 주문 성공: $realOrderNo" + "[${investmentGrade.displayName}] ${orderStep}차 분할 주문 성공: $realOrderNo" ) val sRate = -1.5 - var tax = KisSession.config.getValues(ConfigIndex.TAX_INDEX) - val effectiveProfitRate = - (profitRate1 - ?: KisSession.config.getValues(ConfigIndex.PROFIT_INDEX)) + tax + val tax = KisSession.config.getValues(ConfigIndex.TAX_INDEX) + val effectiveProfitRate = (profitRate1 ?: KisSession.config.getValues(ConfigIndex.PROFIT_INDEX)) + tax + + var targetBasePrice = initialBasePrice try { - var oldTarget = currentBalance?.getHoldings() - ?.first { it.availOrderCount.toInt() > 0 && it.code.equals(decision.stockCode) } + val oldTarget = currentBalance?.getHoldings()?.firstOrNull { it.availOrderCount.toInt() > 0 && it.code == 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}") + val avgPrive = oldTarget.avgPrice.toDouble() + // 대표님이 작성하신 기존 물타기 basePrice 보정 로직 유지 + targetBasePrice = avgPrive * 1.5 } } catch (e: Exception) { e.printStackTrace() } + val calculatedTarget = MarketUtil.roundToTickSize(targetBasePrice * (1 + effectiveProfitRate / 100.0)) + val calculatedStop = MarketUtil.roundToTickSize(targetBasePrice * (1 + sRate / 100.0)) - 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, + quantity = qty, profitRate = effectiveProfitRate, stopLossRate = sRate, targetPrice = calculatedTarget, @@ -360,75 +369,48 @@ object AutoTradingManager { stockCode = stockCode, stockName = stockName, isBuy = true, - orderQty = inputQty, - reason = decision.reason ?: "", // AI 이유 - decision = decision // AI 객체 통째로 전달 + orderQty = qty, + reason = "${decision.reason ?: ""} (${orderStep}차 분할 진입)", + decision = decision ) syncAndExecute(realOrderNo) } - // 💡 [개선 3] 감시 설정 로그에도 등급 정보 노출 + TradingLogStore.addLog( decision, "BUY", - "[${investmentGrade.displayName}] 매수 및 감시 설정 완료 (목표 수익률: ${ - String.format( - "%.4f", - effectiveProfitRate - ) - }%): $realOrderNo" + "[${investmentGrade.displayName}] ${orderStep}차 감시 설정 완료 (예상 목표가: $calculatedTarget): $realOrderNo" ) - } - .onFailure { - println("매수 실패: ${it.message} ${stockCode} $orderQty $finalPrice") + }.onFailure { + println("매수 실패: ${it.message} $stockCode $qty $price") - if (it.message?.contains("주문가능금액을 초과") == true) { - AutoTradingManager.addToReanalysis( - RankingStock( - mksc_shrn_iscd = stockCode, - hts_kor_isnm = stockName - ) - ) - TradingLogStore.addWatchLog( - decision, - "WATCH", - "${it.message ?: " 매수 실패"} => 재분석 대기열에 추가" - ) + // 1차 주문에서 이미 예수금이 부족하다면 2차는 무의미하므로 재분석 대기열 처리 + if (it.message?.contains("주문가능금액을 초과") == true && index == 0) { + AutoTradingManager.addToReanalysis(RankingStock(mksc_shrn_iscd = stockCode, hts_kor_isnm = stockName)) + TradingLogStore.addWatchLog(decision, "WATCH", "${it.message ?: "매수 실패"} => 재분석 대기열에 추가") } else { - TradingLogStore.addLog(decision, "BUY", it.message ?: "매수 실패") + TradingLogStore.addLog(decision, "BUY", "${orderStep}차 매수 실패: ${it.message}") } } + + // 🌟 [안전장치] KIS API 초당 호출 제한 방어 + delay(250) + } } else if (!hasCode && KisSession.isAvailBuyTime(LocalTime.now()) == false && (decision.investmentGrade?.displayName?.contains("4") == true || decision.investmentGrade?.displayName?.contains("5") == true) - ) { - AutoTradingManager.addToReanalysis( - RankingStock( - mksc_shrn_iscd = stockCode, - hts_kor_isnm = stockName - ) - ) - TradingLogStore.addWatchLog( - decision, - "WATCH", - "매수 시간 외 분석 => 재분석 대기열에 추가" - ) + ) { + 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 - ) + TradingLogStore.addNotice(order.prdt_name, order.pdno, "[주문 취소] 매수시간 종료 후 모든 매수 취소") + KisTradeService.cancelOrder(order.ord_no, order.pdno) delay(200) } } } - } } @@ -583,50 +565,105 @@ object AutoTradingManager { KisSession.config.SELL_PROFIT } if (holding != null && holding.quantity.toInt() > 0 && holding.availOrderCount.toInt() > 0 && holding.profitRate.toDouble() > targetProfitLimit) { - var targetPrice = holding.currentPrice.toDouble() + + val totalQty = holding.availOrderCount.toInt() + val currentPrice = holding.currentPrice.toDouble() + + // 🌟 [수정] 1주 남았을 때는 현재가(안전)로, 그 이상은 절반씩 분할 + val qty1 = (totalQty + 1) / 2 // 절반 (홀수면 현재가 비중을 높임) + val qty2 = totalQty - qty1 // 나머지 (4호가 위) + + val price1 = currentPrice + val price2 = MarketUtil.roundToTickSize(currentPrice + (MarketUtil.getTickSize(currentPrice) * 4)) + TradingLogStore.addAfterMarketLog( holding.name, holding.code, - "${if ("Y".equals(marketCode)) "시간외 단일가" else "대체거래소"} 시세로 ${holding.profitRate} 수익 예상" + "${if ("Y" == 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 + // 1차 매도: 현재가 + if (qty1 > 0) { + tradeService.postOrder( + stockCode = holding.code, + qty = qty1.toString(), + price = price1.toInt().toString(), + isBuy = false, + orderDivision = if (marketCode == "Y") "41" else "", + marketCode = if (marketCode == "Y") "KRX" else "NXT" + ).onSuccess { newOrderNo -> + println("✅ [1차 분할주문 완료] ${holding.name}: $newOrderNo ($price1)") + TradingLogStore.addSellLog( + "${holding.name}[${holding.code}]", + price1.toString(), + "SELL", + "🎊 ${if (marketCode == "Y") "시간외 단일가" else "대체거래소"} 1차 분할 매도(현재가) ${qty1}주 매도 완료" ) - ) - syncAndExecute(newOrderNo) - }.onFailure { - TradingLogStore.addSellLog( - "${holding.name}[${holding.code}]", - targetPrice.toString(), - "SELL", - "🎊 ${if (marketCode.equals("Y")) "시간외 단일가" else "대체거래소"} 주식 재고털이 주문 실패[${it.message}] " - ) + DatabaseFactory.saveAutoTrade( + AutoTradeItem( + orderNo = newOrderNo, + code = holding.code, + name = holding.name, + quantity = qty1, + profitRate = 0.0, + stopLossRate = 0.0, + targetPrice = price1, + stopLossPrice = 0.0, + status = "SELLING", + isDomestic = true + ) + ) + syncAndExecute(newOrderNo) + }.onFailure { + TradingLogStore.addSellLog( + "${holding.name}[${holding.code}]", + price1.toString(), + "SELL", + "❌ 1차 분할매도 주문 실패[${it.message}]" + ) + } + } + + // 2차 매도: 4호가 위 + if (qty2 > 0) { + tradeService.postOrder( + stockCode = holding.code, + qty = qty2.toString(), + price = price2.toInt().toString(), + isBuy = false, + orderDivision = if (marketCode == "Y") "41" else "", + marketCode = if (marketCode == "Y") "KRX" else "NXT" + ).onSuccess { newOrderNo -> + println("✅ [2차 분할주문 완료] ${holding.name}: $newOrderNo ($price2)") + TradingLogStore.addSellLog( + "${holding.name}[${holding.code}]", + price2.toString(), + "SELL", + "🎊 ${if (marketCode == "Y") "시간외 단일가" else "대체거래소"} 2차 분할매도(4호가 위) ${qty2}주 매도 완료" + ) + DatabaseFactory.saveAutoTrade( + AutoTradeItem( + orderNo = newOrderNo, + code = holding.code, + name = holding.name, + quantity = qty2, + profitRate = 0.0, + stopLossRate = 0.0, + targetPrice = price2, + stopLossPrice = 0.0, + status = "SELLING", + isDomestic = true + ) + ) + syncAndExecute(newOrderNo) + }.onFailure { + TradingLogStore.addSellLog( + "${holding.name}[${holding.code}]", + price2.toString(), + "SELL", + "❌ 2차 분할매도 주문 실패[${it.message}]" + ) + } } } else { if ("Y".equals(marketCode)) { @@ -660,6 +697,39 @@ object AutoTradingManager { holding.code, "수익률($profit%) -> ${holding.valuationProfitAmount} 손해 중이며 현제 손절 가이드에 적합함." ) + }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}]"}" + ) + } } analyzeDeepLossHoldingsAfterMarket(holding) } @@ -687,54 +757,97 @@ object AutoTradingManager { ) } 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 + + val totalQty = holding.availOrderCount.toInt() + val currentPrice = holding.currentPrice.toDouble() + + // 🌟 [수정] 1주 남았을 때는 현재가(안전)로, 그 이상은 절반씩 분할 + val qty1 = (totalQty + 1) / 2 + val qty2 = totalQty - qty1 + + val price1 = currentPrice + val price2 = MarketUtil.roundToTickSize(currentPrice + (MarketUtil.getTickSize(currentPrice) * 4)) + + println("🔄 [보유 주식 주문] ${holding.name} (${holding.code}) 분할 매도: 현재가($price1) ${qty1}주, 4호가 위($price2) ${qty2}주") + + // 1차 매도: 현재가 + if (qty1 > 0) { + tradeService.postOrder( + stockCode = holding.code, + qty = qty1.toString(), + price = price1.toInt().toString(), + isBuy = false, + ).onSuccess { newOrderNo -> + println("✅ [1차 분할주문 완료] ${holding.name}: $newOrderNo") + TradingLogStore.addSellLog( + holding.code, + price1.toString(), + "SELL", + "🎊 보유 주식[수익률: ${holding.profitRate}] 1차 분할매도(현재가) ${qty1}주 완료" ) - ) - syncAndExecute(newOrderNo) - }.onFailure { - TradingLogStore.addSellLog( - holding.code, - targetPrice.toString(), - "SELL", - "🎊 보유 주식 매도 주문 실패[${it.message}] " - ) + DatabaseFactory.saveAutoTrade( + AutoTradeItem( + orderNo = newOrderNo, + code = holding.code, + name = holding.name, + quantity = qty1, + profitRate = 0.0, + stopLossRate = 0.0, + targetPrice = price1, + stopLossPrice = 0.0, + status = "SELLING", + isDomestic = true + ) + ) + syncAndExecute(newOrderNo) + }.onFailure { + TradingLogStore.addSellLog( + holding.code, + price1.toString(), + "SELL", + "❌ 보유 주식 1차 매도 주문 실패[${it.message}]" + ) + } + } + + // 2차 매도: 4호가 위 + if (qty2 > 0) { + tradeService.postOrder( + stockCode = holding.code, + qty = qty2.toString(), + price = price2.toInt().toString(), + isBuy = false, + ).onSuccess { newOrderNo -> + println("✅ [2차 분할주문 완료] ${holding.name}: $newOrderNo") + TradingLogStore.addSellLog( + holding.code, + price2.toString(), + "SELL", + "🎊 보유 주식[수익률: ${holding.profitRate}] 2차 분할매도(4호가 위) ${qty2}주 완료" + ) + DatabaseFactory.saveAutoTrade( + AutoTradeItem( + orderNo = newOrderNo, + code = holding.code, + name = holding.name, + quantity = qty2, + profitRate = 0.0, + stopLossRate = 0.0, + targetPrice = price2, + stopLossPrice = 0.0, + status = "SELLING", + isDomestic = true + ) + ) + syncAndExecute(newOrderNo) + }.onFailure { + TradingLogStore.addSellLog( + holding.code, + price2.toString(), + "SELL", + "❌ 보유 주식 2차 매도 주문 실패[${it.message}]" + ) + } } } else { var errMsg = "" @@ -1397,7 +1510,7 @@ object AutoTradingManager { // 2. 재무 안정성 체크 (물타기라도 상폐/자본잠식 위험주는 추가 매수 금지) if (isSafetyBeltStockCodes.contains(stock.code)) { - print("-> [${stock.name}] 재무 건전성 미달 제외 | ") + println("-> [${stock.name}] 재무 건전성 미달 제외 | ") return@withTimeout } @@ -1445,7 +1558,7 @@ object AutoTradingManager { } if (!isOk || !budgetCheck || currentPrice > maxPrice || currentPrice < minPrice) { - print("-> [${stock.name}] 조건/가격 제외 (물타기여부:$isWatering) | ") + println("-> [${stock.name}] 조건/가격 제외 (물타기여부:$isWatering) | ") return@withTimeout } @@ -1486,7 +1599,7 @@ object AutoTradingManager { val currentAtr = tempAnalyzer.calculateATR(dailyData) if (!isProfitable || !isValidEntryTiming) { - print("-> [${stock.name}] 목표 수익 조건 미달 (물타기:$isWatering, 수익:$isProfitable, 타이밍:$isValidEntryTiming) | 기대 수익율 : ${expectedProfitRate} || ${dailyStats.avgReboundAmplitude}") + println("-> [${stock.name}] 목표 수익 조건 미달 (물타기:$isWatering, 수익:$isProfitable, 타이밍:$isValidEntryTiming) | 기대 수익율 : ${expectedProfitRate} || ${dailyStats.avgReboundAmplitude}") return@withTimeout } @@ -1497,7 +1610,7 @@ object AutoTradingManager { val allowedMargin = if (isWatering) 4.0 else 2.0 // 물타기는 하락 여력 마진을 4%까지 관대하게 인정 if (distanceToBottomPct > allowedMargin && !dropPrediction.isBottomZone) { - print("-> [${stock.name}] 지하실 주의 (추가하락여력: ${"%.1f".format(distanceToBottomPct)}%) | ") + println("-> [${stock.name}] 지하실 주의 (추가하락여력: ${"%.1f".format(distanceToBottomPct)}%) | ") return@withTimeout } @@ -1505,7 +1618,7 @@ object AutoTradingManager { if (dropPrediction.isBottomZone || isWatering) { val hasBrake = tempAnalyzer.checkBrakeAndReversal(dailyData) if (!hasBrake) { - print("-> [${stock.name}] 지지/브레이크 미확인 (칼날 회피) | ") + println("-> [${stock.name}] 지지/브레이크 미확인 (칼날 회피) | ") return@withTimeout } }