From 747455813686da4cbec4f91422200a603d00aeba Mon Sep 17 00:00:00 2001 From: lunaticbum Date: Fri, 26 Jun 2026 10:17:03 +0900 Subject: [PATCH] ... --- src/main/kotlin/analyzer/TechnicalAnalyzer.kt | 62 ++++++++++++++++++- src/main/kotlin/service/AutoTradingManager.kt | 37 +++++++---- 2 files changed, 87 insertions(+), 12 deletions(-) diff --git a/src/main/kotlin/analyzer/TechnicalAnalyzer.kt b/src/main/kotlin/analyzer/TechnicalAnalyzer.kt index e0a9064..a5984dd 100644 --- a/src/main/kotlin/analyzer/TechnicalAnalyzer.kt +++ b/src/main/kotlin/analyzer/TechnicalAnalyzer.kt @@ -480,7 +480,7 @@ $standardizedScores } val reboundRate = if (bottomPrice > 0) ((currentClose - bottomPrice) / bottomPrice * 100) else 0.0 - if (reboundRate >= 3.0) { // 3% 이상 반등 시 사이클 종료 및 기록 + if (reboundRate >= minDropToDetect) { // 3% 이상 반등 시 사이클 종료 및 기록 reboundTerms.add(i - bottomIndex) dropRates.add(abs((bottomPrice - peakPrice) / peakPrice * 100)) reboundAmplitudes.add(reboundRate) // 🌟 상승폭 기록 @@ -614,7 +614,67 @@ $standardizedScores return VolatilityForecast(realisticHigh, realisticLow, extremeHigh, extremeLow) } + + /** + * [신규] 종목의 현재 하락 진행 상태와 통계적 예상 바닥(Bottom)을 계산합니다. + */ + fun predictDropBottom( + candles: List, + reboundStats: ReboundStats, + volatility: VolatilityForecast + ): DropPrediction? { + // 데이터가 부족하거나, 유의미한 과거 반등 패턴이 없으면 예측 불가 + if (candles.size < 20 || !reboundStats.isValid) return null + + // 1. 최근 20일 내 단기 고점 파악 (현재 진행 중인 하락 파동의 시작점) + val recentCandles = candles.takeLast(20) + var recentPeakPrice = 0.0 + + for (i in recentCandles.indices.reversed()) { + val highPrice = recentCandles[i].stck_hgpr.toDouble() + if (highPrice > recentPeakPrice) { + recentPeakPrice = highPrice + } + } + + if (recentPeakPrice == 0.0) return null + + val currentPrice = candles.last().stck_prpr.toDouble() + + // 2. 현재까지의 하락률 계산 + val currentDropRate = (recentPeakPrice - currentPrice) / recentPeakPrice * 100.0 // 양수로 표현 (예: 4.5% 하락) + + // 3. 1차 예상 바닥가 (과거 평균 하락폭 적용) + // 예: 고점이 10,000원이고 과거 평균 10% 빠졌다면, 예상 바닥은 9,000원 + val expectedBottomPrice = recentPeakPrice * (1.0 - (reboundStats.avgDropRate / 100.0)) + + // 4. 추가 하락 여력 계산 (얼마나 더 빠질 수 있는가?) + val remainingDropRate = reboundStats.avgDropRate - currentDropRate + + // 5. 바닥권 진입 판별 (예상 바닥가의 +2% 이내로 들어왔거나, 통계적 마지노선(extremeLow) 근처일 때) + val isBottomZone = currentPrice <= (expectedBottomPrice * 1.02) || currentPrice <= (volatility.extremeLow * 1.02) + + return DropPrediction( + recentPeakPrice = recentPeakPrice, + expectedBottomPrice = expectedBottomPrice, + extremeSupportPrice = volatility.extremeLow, + currentDropRate = -currentDropRate, // 음수로 표기 (예: -4.5%) + remainingDropRate = -remainingDropRate, // 음수면 더 빠질 공간이 남았다는 뜻 + isBottomZone = isBottomZone + ) + } + } + +data class DropPrediction( + val recentPeakPrice: Double, // 최근 단기 고점 + val expectedBottomPrice: Double, // 과거 평균 하락률(avgDropRate)을 적용한 1차 예상 바닥가 + val extremeSupportPrice: Double, // 2표준편차(extremeLow) 기반의 통계적 2차 마지노선 + val currentDropRate: Double, // 단기 고점 대비 현재까지 하락한 비율 (%) + val remainingDropRate: Double, // 1차 예상 바닥까지 남은 추가 하락 여력 (%) - 양수면 더 빠질 공간이 있다는 뜻 + val isBottomZone: Boolean // 현재 가격이 바닥권(예상 바닥가의 상하 2% 이내)에 진입했는지 여부 +) + data class VolatilityForecast( val realisticHigh: Double, // 1표준편차 상단 (현실적 목표가, 68% 확률 내) val realisticLow: Double, // 1표준편차 하단 (현실적 지지선) diff --git a/src/main/kotlin/service/AutoTradingManager.kt b/src/main/kotlin/service/AutoTradingManager.kt index 7bd27d1..2198789 100644 --- a/src/main/kotlin/service/AutoTradingManager.kt +++ b/src/main/kotlin/service/AutoTradingManager.kt @@ -462,7 +462,7 @@ object AutoTradingManager { } else { val now = LocalTime.now() - val targetProfitLimit = if (holding.isTodayEntry && now.isBefore(LocalTime.of(18, 0))) { + 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 { @@ -973,10 +973,10 @@ object AutoTradingManager { if (corpInfo?.cName.isNullOrEmpty()) { false } else if (it.code !in myHoldings && - it.code !in pendingStocks && - it.code !in executionCache.values.map { it.code } && - it.code !in failList && - it.code !in isSafetyBeltStockCodes){ + it.code !in pendingStocks && + it.code !in executionCache.values.map { it.code } && + it.code !in failList && + it.code !in isSafetyBeltStockCodes){ isOk } else { false @@ -1060,7 +1060,7 @@ object AutoTradingManager { if (now.isBefore(LocalTime.of(8,50)) && now.isAfter(LocalTime.of(8,45))) { cancelAllPendingSellOrders() isExecuted = true - } else if ( (now.isBefore(LocalTime.of(16,0)) && 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 -> @@ -1082,8 +1082,11 @@ object AutoTradingManager { println("⏰ [강제 스케줄 실행] 오전 9시 ${currentMinute}분 - 보유주식 매도 체크를 시작합니다.") checkBalance() isExecuted = true - } else if (((now.hour == 8 && KisSession.tradeConfig.before_nxt && currentMinute < 45) || - (now.hour >= 16 && now.hour < 20 && KisSession.tradeConfig.after_nxt)) && (currentMinute % 2 == 0)) { + } 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)) { TradingLogStore.addAnalyzer( " - ", " - ", @@ -1158,17 +1161,18 @@ object AutoTradingManager { val tempAnalyzer = TechnicalAnalyzer().apply { this.daily = dailyData } // 1. 변동성 기반 수익률 검증 (2% 이상 열려있는가?) - val volatility = tempAnalyzer.calculateVolatilityForecast(dailyData, 20) + val volatility = tempAnalyzer.calculateVolatilityForecast(dailyData, 40) val expectedProfitRate = ((volatility.realisticHigh - currentPrice) / currentPrice) * 100.0 // 2. 일봉 기준 반등 주기 통계 추출 (일주일 내 승부 가능한가?) - val dailyStats = tempAnalyzer.calculateDynamicReboundStats(dailyData) + val dailyStats = tempAnalyzer.calculateDynamicReboundStats(dailyData, 5.0) val isApproaching = tempAnalyzer.checkReboundApproaching( candles = dailyData, avgReboundTerm = dailyStats.avgReboundPeriod, - dropThreshold = dailyStats.avgDropRate * 0.8, + dropThreshold = dailyStats.avgDropRate, timeTolerance = dailyStats.timeTolerance ) + print("-> [${stock.name}] 필터링 ${dailyStats.avgReboundPeriod} ${dailyStats.avgDropRate} ${dailyStats.timeTolerance}") val isSteadyUptrend = tempAnalyzer.checkSteadyUptrend(dailyData) @@ -1183,7 +1187,18 @@ object AutoTradingManager { print("-> [${stock.name}] 조건 미달 필터링 (예측수익: ${"%.1f".format(expectedProfitRate)}%, 주기: ${"%.1f".format(dailyStats.avgReboundPeriod)}일, 진입권: $isValidEntryTiming) | ") return@withTimeout // 조건에 맞지 않으면 주봉/월봉 API 호출 및 LLM 분석 없이 즉시 다음 종목으로 넘어감 } + val dropPrediction = tempAnalyzer.predictDropBottom(dailyData, dailyStats, volatility) + if (dropPrediction != null) { + // 💡 [방어 로직] 아직 바닥까지 한참 남았는데 섣불리 들어가는 것을 방지! + // 과거 평균 10% 빠지는 종목인데, 지금 겨우 -3% 빠진 상태라면 (남은 하락폭 -7%) + if (dropPrediction.remainingDropRate < -2.0 && !dropPrediction.isBottomZone) { + print("-> [${stock.name}] 지하실 주의 (현재 ${"%.1f".format(dropPrediction.currentDropRate)}% 하락, 바닥까지 ${"%.1f".format(dropPrediction.remainingDropRate)}% 추가 하락 위험) | ") + return@withTimeout // 매수 후보에서 과감히 제외! + } + + // 반대로 완벽한 바닥권(isBottomZone = true)에 들어왔다면 매수 타점으로 인정하여 다음 단계로 넘김 + } println("🔍 [분석 진입] ${stock.name} (${LocalTime.now()}) (예측수익: ${"%.1f".format(expectedProfitRate)}%, 주기: ${"%.1f".format(dailyStats.avgReboundPeriod)}일, 진입권: $isValidEntryTiming)") if (!isSafetyBeltStockCodes.contains(stock.code)) { val analyzer = coroutineScope {