From b83eb11cb7ea26dfd198b77345dafec5a0fce931 Mon Sep 17 00:00:00 2001 From: lunaticbum Date: Mon, 10 Aug 2026 11:29:59 +0900 Subject: [PATCH] ../. --- src/main/kotlin/analyzer/TechnicalAnalyzer.kt | 104 ++++++++++++++---- src/main/kotlin/service/AutoTradingManager.kt | 22 ++-- 2 files changed, 96 insertions(+), 30 deletions(-) diff --git a/src/main/kotlin/analyzer/TechnicalAnalyzer.kt b/src/main/kotlin/analyzer/TechnicalAnalyzer.kt index a6c9a62..737609c 100644 --- a/src/main/kotlin/analyzer/TechnicalAnalyzer.kt +++ b/src/main/kotlin/analyzer/TechnicalAnalyzer.kt @@ -618,52 +618,112 @@ $standardizedScores /** * [신규] 종목의 현재 하락 진행 상태와 통계적 예상 바닥(Bottom)을 계산합니다. */ + /** + * [개선] 종목의 현재 하락 진행 상태와 통계적 예상 바닥(Bottom)을 계산합니다. + * ATR을 추가로 받아 변동성 기반의 바닥 밴드를 형성합니다. + */ + /** + * [개선] 노이즈(윗꼬리/아랫꼬리)를 제거한 현실적인 평균 고점/저점을 기준으로 하락률을 계산합니다. + */ fun predictDropBottom( candles: List, reboundStats: ReboundStats, - volatility: VolatilityForecast + volatility: VolatilityForecast, + currentAtr: Double ): DropPrediction? { - // 데이터가 부족하거나, 유의미한 과거 반등 패턴이 없으면 예측 불가 if (candles.size < 20 || !reboundStats.isValid) return null - // 1. 최근 20일 내 단기 고점 파악 (현재 진행 중인 하락 파동의 시작점) + // 전체 캔들의 80% 구간만 사용 (너무 오래된 데이터 제외) val recentCandles = candles.takeLast((candles.size.times(0.8).toInt())) - var recentPeakPrice = 0.0 - for (i in recentCandles.indices.reversed()) { - val highPrice = recentCandles[i].stck_hgpr.toDouble() - if (highPrice > recentPeakPrice) { - recentPeakPrice = highPrice - } + // 1. 고가 평균점 (Smoothed Peak) 만들기 + // 최고가들을 내림차순 정렬하여 상위 3개의 평균을 구함 (비정상적인 윗꼬리 1~2개 무시 효과) + val topHighs = recentCandles.map { it.stck_hgpr.toDouble() }.sortedDescending() + val smoothedPeak = if (topHighs.size >= 3) { + topHighs.take(3).average() + } else { + topHighs.firstOrNull() ?: 0.0 } - if (recentPeakPrice == 0.0) return null + if (smoothedPeak == 0.0) return null + + // 2. 저점 평균점 (Smoothed Bottom) 만들기 + // 최저가들을 오름차순 정렬하여 하위 3개의 평균을 구함 (순간적인 투매 아랫꼬리 방어) + val bottomLows = recentCandles.map { it.stck_lwpr.toDouble() }.sorted() + val smoothedBottom = if (bottomLows.size >= 3) { + bottomLows.take(3).average() + } else { + bottomLows.firstOrNull() ?: 0.0 + } val currentPrice = candles.last().stck_prpr.toDouble() - // 2. 현재까지의 하락률 계산 - val currentDropRate = (recentPeakPrice - currentPrice) / recentPeakPrice * 100.0 // 양수로 표현 (예: 4.5% 하락) + // 3. 말씀하신 '고가는 좀 낮게, 저가는 저점에 가깝게' 보정 + // 평균 고점에서 변동성(ATR)의 일정 비율만큼 한 번 더 깎아내서 더 보수적인 진짜 고점(True Peak)을 만듦 + val truePeak = smoothedPeak - (currentAtr * 0.3) - // 3. 1차 예상 바닥가 (과거 평균 하락폭 적용) - // 예: 고점이 10,000원이고 과거 평균 10% 빠졌다면, 예상 바닥은 9,000원 - val expectedBottomPrice = recentPeakPrice * (1.0 - (reboundStats.avgDropRate / 100.0)) + // 현재가 대비 하락률은 보정된 truePeak를 기준으로 계산 + val currentDropRate = (truePeak - currentPrice) / truePeak * 100.0 - // 4. 추가 하락 여력 계산 (얼마나 더 빠질 수 있는가?) + // 예상 바닥가도 truePeak 기준에서 과거 평균 하락폭을 빼서 산출 + val expectedBottomPrice = truePeak * (1.0 - (reboundStats.avgDropRate / 100.0)) val remainingDropRate = reboundStats.avgDropRate - currentDropRate - // 5. 바닥권 진입 판별 (예상 바닥가의 +2% 이내로 들어왔거나, 통계적 마지노선(extremeLow) 근처일 때) - val isBottomZone = currentPrice <= (expectedBottomPrice * 1.015) || currentPrice <= (volatility.extremeLow * 1.015) + // 바닥권 인정 마진 (ATR 기반) + val bottomMargin = currentAtr * 0.7 + + // 저점 평균(smoothedBottom)도 마지노선 계산에 추가 가중치로 섞어줌으로써 저점에 더 밀착시킴 + val adjustedExtremeLow = (volatility.extremeLow + smoothedBottom) / 2.0 + + val isBottomZone = currentPrice <= (expectedBottomPrice + bottomMargin) || currentPrice <= (adjustedExtremeLow + bottomMargin) return DropPrediction( - recentPeakPrice = recentPeakPrice, + recentPeakPrice = truePeak, // 외부에는 보정된 고점을 전달 expectedBottomPrice = expectedBottomPrice, - extremeSupportPrice = volatility.extremeLow, - currentDropRate = -currentDropRate, // 음수로 표기 (예: -4.5%) - remainingDropRate = -remainingDropRate, // 음수면 더 빠질 공간이 남았다는 뜻 + extremeSupportPrice = adjustedExtremeLow, // 보정된 지지선 전달 + currentDropRate = -currentDropRate, + remainingDropRate = -remainingDropRate, isBottomZone = isBottomZone ) } + /** + * 🌟 [신규] 바닥권 도달 시, 하락이 멈추고 지지선이 형성되었는지(Brake) 확인합니다. + */ + fun checkBrakeAndReversal(candles: List): Boolean { + if (candles.size < 3) return false + + val today = candles.last() + val yesterday = candles[candles.size - 2] + + val tClose = today.stck_prpr.toDouble() + val tOpen = today.stck_oprc.toDouble() + val tHigh = today.stck_hgpr.toDouble() + val tLow = today.stck_lwpr.toDouble() + val tVol = today.cntg_vol.toDouble() + + val yVol = yesterday.cntg_vol.toDouble() + + // 1. 밑꼬리 확인 (망치형 / 도지형) + // 몸통(Body) 대비 아래쪽 꼬리(Lower Shadow)가 얼마나 긴가? + val body = abs(tClose - tOpen) + val lowerShadow = minOf(tClose, tOpen) - tLow + val upperShadow = tHigh - maxOf(tClose, tOpen) + + // 꼬리가 몸통보다 1.5배 이상 길고, 윗꼬리보다 아랫꼬리가 더 길면 강력한 누군가의 '매수 개입(지지)'으로 봅니다. + val hasLongLowerShadow = (lowerShadow > body * 1.5) && (lowerShadow > upperShadow) + + // 2. 단기 양봉 전환 (하락을 멈추고 고개를 듦) + val isBullishBrake = tClose > tOpen && tClose >= yesterday.stck_prpr.toDouble() + + // 3. 투매 진정 (거래량 급감) + // 전일 대비 거래량이 눈에 띄게 줄었다는 것은 매도세(던지는 물량)가 말랐다는 뜻입니다. + val isVolumeDriedUp = tVol < yVol * 0.7 + + // 🌟 지지(밑꼬리)가 나왔거나, 양봉으로 돌렸거나, 던지는 물량이 마른 상태 중 하나라도 충족해야 브레이크가 걸린 것으로 봅니다. + return hasLongLowerShadow || isBullishBrake || isVolumeDriedUp + } + } data class DropPrediction( diff --git a/src/main/kotlin/service/AutoTradingManager.kt b/src/main/kotlin/service/AutoTradingManager.kt index 4572291..d4d1531 100644 --- a/src/main/kotlin/service/AutoTradingManager.kt +++ b/src/main/kotlin/service/AutoTradingManager.kt @@ -1186,22 +1186,28 @@ object AutoTradingManager { // 반등 주기에 도달했거나(Mean Reversion), 안정적으로 뻗어나가는 우상향 종목(Trend Following)이면 통과 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) | ") return@withTimeout // 조건에 맞지 않으면 주봉/월봉 API 호출 및 LLM 분석 없이 즉시 다음 종목으로 넘어감 } - val dropPrediction = tempAnalyzer.predictDropBottom(dailyData, dailyStats, volatility) + val dropPrediction = tempAnalyzer.predictDropBottom(dailyData, dailyStats, volatility, currentAtr) if (dropPrediction != null) { - // 💡 [방어 로직] 아직 바닥까지 한참 남았는데 섣불리 들어가는 것을 방지! - // 과거 평균 10% 빠지는 종목인데, 지금 겨우 -3% 빠진 상태라면 (남은 하락폭 -7%) - if (dropPrediction.remainingDropRate < -1.5 && !dropPrediction.isBottomZone) { - print("-> [${stock.name}] 지하실 주의 (현재 ${"%.1f".format(dropPrediction.currentDropRate)}% 하락, 바닥까지 ${"%.1f".format(dropPrediction.remainingDropRate)}% 추가 하락 위험) | ") - return@withTimeout // 매수 후보에서 과감히 제외! + // 💡 [방어 로직 1] 아직 바닥까지 한참 남았다면 지하실 방지 + if (dropPrediction.remainingDropRate < -2.0 && !dropPrediction.isBottomZone) { + print("-> [${stock.name}] 지하실 주의 (현재 ${"%.1f".format(dropPrediction.currentDropRate)}% 하락, 추가 하락 위험) | ") + return@withTimeout } - // 반대로 완벽한 바닥권(isBottomZone = true)에 들어왔다면 매수 타점으로 인정하여 다음 단계로 넘김 + // 💡 [방어 로직 2 - 신규] 가격은 바닥권에 왔지만, 캔들에 브레이크(지지)가 걸렸는가? + if (dropPrediction.isBottomZone) { + val hasBrake = tempAnalyzer.checkBrakeAndReversal(dailyData) + if (!hasBrake) { + print("-> [${stock.name}] 바닥 가격 도달했으나, 브레이크(지지/거래량 진정) 미확인. 떨어지는 칼날 회피 | ") + return@withTimeout // 브레이크가 없으면 매수 안 함! + } + } } if (KisSession.tradeConfig.isUpcomingDividend) {