분석후 분할 매수, 보유주식 분할 매도

This commit is contained in:
2026-09-16 17:35:06 +09:00
parent 3827698b6f
commit bcdb3546fa
2 changed files with 292 additions and 179 deletions
+1 -1
View File
@@ -247,7 +247,7 @@ class TradeConfig {
var ONE_STOCK_ALYSIS_TIME = 180000L
var isLowPerformanceMonitoring: Boolean = false
var useGradeShare : List<String> = listOf("LEVEL_4","LEVEL_5")
var useTagsShare : List<String> = listOf("NOTICE", "WATCH")
var useTagsShare : List<String> = listOf("NOTICE", "WATCH","매도 완료")
var useLogKeywordsShare : List<String> = listOf("재분석")
var useAutoRepost : Boolean = false
var minusFilter : Double = 15.0
+291 -178
View File
@@ -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<Pair<Int, Double>>()
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
}
}