diff --git a/src/main/kotlin/service/AutoTradingManager.kt b/src/main/kotlin/service/AutoTradingManager.kt index a5d8108..5d8c247 100644 --- a/src/main/kotlin/service/AutoTradingManager.kt +++ b/src/main/kotlin/service/AutoTradingManager.kt @@ -279,7 +279,7 @@ object AutoTradingManager { // 시스템 가이드에 따른 1차 기준 단가 계산 val oneTickLowerPrice = initialBasePrice - (tickSize * KisSession.config.getValues(investmentGrade.buyGuide).toInt()) - val finalPrice = MarketUtil.roundToTickSize(oneTickLowerPrice) + val finalPrice = MarketUtil.roundToTickSize(oneTickLowerPrice.toDouble()) var stockCode = decision.stockCode var stockName = decision.stockName @@ -298,24 +298,45 @@ object AutoTradingManager { val totalQty = orderQty.replace(",", "").toIntOrNull() ?: 0 if (totalQty <= 0) return@launch - // 🌟 [핵심] 매수 2분할 로직 (1주는 1차에 전량, 그 이상은 절반씩) + // 🌟 1. 매도 기준(목표가/손절가)을 루프 밖에서 '최초 1회'만 고정으로 계산 + val sRate = -1.5 + val tax = KisSession.config.getValues(ConfigIndex.TAX_INDEX) + val effectiveProfitRate = (profitRate1 ?: KisSession.config.getValues(ConfigIndex.PROFIT_INDEX)) + tax + + var targetBasePrice = initialBasePrice // AI가 분석한 최초 기준가 + + // 물타기 시 평단가 보정 로직 + if (KisSession.tradeConfig.lowerAveragePrice && hasCode) { + try { + val oldTarget = currentBalance?.getHoldings()?.firstOrNull { it.availOrderCount.toInt() > 0 && it.code == decision.stockCode } + if (oldTarget != null) { + targetBasePrice = oldTarget.avgPrice.toDouble() * 1.5 // (기존 보정 로직 유지) + } + } catch (e: Exception) { + e.printStackTrace() + } + } + + // 🌟 통일된 최종 매도/손절 목표가 (1차든 2차든 이 가격으로 매도됨) + val unifiedTargetPrice = MarketUtil.roundToTickSize(targetBasePrice * (1 + effectiveProfitRate / 100.0)) + val unifiedStopPrice = MarketUtil.roundToTickSize(targetBasePrice * (1 + sRate / 100.0)) + + // 🌟 2. 분할 매수 수량 및 가격 설정 val qty1 = (totalQty + 1) / 2 val qty2 = totalQty - qty1 val price1 = finalPrice - // 2차 매수는 1차 매수가보다 3호가 아래에 대기 (더 싼 가격에 줍기) val price2 = MarketUtil.roundToTickSize(price1 - (tickSize * KisSession.tradeConfig.buyTickOffset)) - println("🔄 [분할 매수 진입] $stockName 1차(${qty1}주 / $price1) | 2차(${qty2}주 / $price2)") + println("🔄 [분할 매수 진입] $stockName 1차(${qty1}주 / $price1) | 2차(${qty2}주 / $price2) -> 🎯통일된 목표가: $unifiedTargetPrice") - // 분할 주문 리스트 생성 val orderList = mutableListOf>() if (qty1 > 0) orderList.add(qty1 to price1) if (qty2 > 0) orderList.add(qty2 to price2) - // 순차적으로 주문 전송 + // 🌟 3. 순차적 주문 전송 (DB 저장 시 unifiedTargetPrice 공통 사용) orderList.forEachIndexed { index, (qty, price) -> - val orderStep = index + 1 // 1차, 2차 표시용 + val orderStep = index + 1 KisTradeService.postOrder( stockCode, @@ -324,30 +345,6 @@ object AutoTradingManager { isBuy = true ).onSuccess { realOrderNo -> println("[${investmentGrade.displayName}] ${orderStep}차 주문 성공: $realOrderNo $stockCode ${qty}주 $price") - TradingLogStore.addLog( - decision, - "BUY", - "[${investmentGrade.displayName}] ${orderStep}차 분할 주문 성공: $realOrderNo" - ) - - val sRate = -1.5 - val tax = KisSession.config.getValues(ConfigIndex.TAX_INDEX) - val effectiveProfitRate = (profitRate1 ?: KisSession.config.getValues(ConfigIndex.PROFIT_INDEX)) + tax - - var targetBasePrice = initialBasePrice - try { - val oldTarget = currentBalance?.getHoldings()?.firstOrNull { it.availOrderCount.toInt() > 0 && it.code == decision.stockCode } - if (KisSession.tradeConfig.lowerAveragePrice && hasCode && oldTarget != null) { - 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)) if (!hasCode) { DatabaseFactory.saveAutoTrade( @@ -358,8 +355,8 @@ object AutoTradingManager { quantity = qty, profitRate = effectiveProfitRate, stopLossRate = sRate, - targetPrice = calculatedTarget, - stopLossPrice = calculatedStop, + targetPrice = unifiedTargetPrice, // 👈 통일된 목표가 주입 + stopLossPrice = unifiedStopPrice, // 👈 통일된 손절가 주입 status = "PENDING_BUY", isDomestic = true ) @@ -379,12 +376,10 @@ object AutoTradingManager { TradingLogStore.addLog( decision, "BUY", - "[${investmentGrade.displayName}] ${orderStep}차 감시 설정 완료 (예상 목표가: $calculatedTarget): $realOrderNo" + "[${investmentGrade.displayName}] ${orderStep}차 감시 설정 완료 (예상 목표가: $unifiedTargetPrice): $realOrderNo" ) }.onFailure { println("매수 실패: ${it.message} $stockCode $qty $price") - - // 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 ?: "매수 실패"} => 재분석 대기열에 추가") @@ -393,7 +388,6 @@ object AutoTradingManager { } } - // 🌟 [안전장치] KIS API 초당 호출 제한 방어 delay(250) } } else if (!hasCode && KisSession.isAvailBuyTime(LocalTime.now()) == false && @@ -438,22 +432,17 @@ object AutoTradingManager { if (dbItem.status == TradeStatus.PENDING_BUY) { // ✅ 1. 진짜 사온 가격 (실제 매수 체결가) var actualBuyPrice = execData.price.toDoubleOrNull() ?: dbItem.targetPrice - - // 💡 [수정] 매수 주문(orderNo)에 대해 '진짜 산 가격'을 기록해야 합니다. - // 기존에는 여기에 finalTargetPrice를 넣으셨는데, 그러면 매수 단가가 오염됩니다. TradingReportManager.updateExecution(orderNo, actualBuyPrice, dbItem.quantity) - var hasCodes = - KisSession.tradeConfig.lowerAveragePrice && currentBalance?.getHoldings() - ?.any { it.code.equals(dbItem.code) && it.quantity.toInt() > 2 && dbItem.quantity == KisSession.tradeConfig.lowerAverageStockCount } ?: false - if (hasCodes) { - actualBuyPrice = actualBuyPrice * 1.1 - } - val absoluteMinRate = KisSession.config.getValues(ConfigIndex.TAX_INDEX) - val finalProfitRate = maxOf(dbItem.profitRate, absoluteMinRate) - val finalTargetPrice = - MarketUtil.roundToTickSize(actualBuyPrice * (1 + finalProfitRate / 100.0)) - println("🎯 [매수 확정] ${dbItem.name} | 매수가: ${actualBuyPrice.toInt()} -> 목표가 설정: ${finalTargetPrice.toInt()}") + // 물타기 체크 로직 유지 + var hasCodes = KisSession.tradeConfig.lowerAveragePrice && currentBalance?.getHoldings() + ?.any { it.code == dbItem.code && it.quantity.toInt() > 2 && dbItem.quantity == KisSession.tradeConfig.lowerAverageStockCount } == true + if (hasCodes) { actualBuyPrice = actualBuyPrice * 1.1 } + + // 🌟 [수정] actualBuyPrice로 재계산하지 않고, excuteTrade에서 최초로 결정해 DB에 넣은 목표가를 그대로 가져옵니다. + val finalTargetPrice = dbItem.targetPrice + + println("🎯 [매수 확정] ${dbItem.name} | 매수가: ${actualBuyPrice.toInt()} -> 통일된 목표가 세팅: ${finalTargetPrice.toInt()}") KisTradeService.postOrder( stockCode = dbItem.code, @@ -468,12 +457,7 @@ object AutoTradingManager { stockName = dbItem.name, isBuy = false, orderQty = dbItem.quantity, - reason = "🎯 목표 수익률 ${ - String.format( - "%.2f", - finalProfitRate - ) - }% 도달을 위한 익절 주문", + reason = "🎯 목표 매도가 ${finalTargetPrice} 도달을 위한 익절 주문", holdingAvgPrice = actualBuyPrice, // 👈 여기서 매수단가를 넘겨줘야 매도 리포트가 정확해집니다! decision = null ) @@ -1207,7 +1191,7 @@ object AutoTradingManager { .getOrNull()?.stck_prpr?.toDouble() ?: 0.0 val orderedPrice = order.ord_unpr.toDoubleOrNull() ?: 0.0 // 조건 B: 현재가와 주문가의 괴리율 체크 (현재가가 너무 올라갔거나 내려갔을 때) - val priceGap = Math.abs(currentPrice - orderedPrice) / orderedPrice + val priceGap = (Math.abs(currentPrice - orderedPrice) / orderedPrice) * 100 println("checkAndCancelPendingBuyOrders order 주문가 :$order | 현제가 :$currentPrice ${elapsedMillis / 1000L}초 ${priceGap}% 차이 || 괴리 기준 ${KisSession.tradeConfig.auto_cancel_pending_rate}%") if (priceGap >= KisSession.tradeConfig.auto_cancel_pending_rate) {